Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions desktop/.claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
name: verify
description: Drive the desktop app end-to-end with Playwright and capture screenshot evidence that a change works
---

# Verifying desktop changes

The desktop app only renders with the E2E mock bridge — it cannot run in a
plain browser, and there is no headless Tauri. Drive the built frontend with
Playwright and read the screenshots.

## Recipe

1. Build, killing any stale preview server first (`reuseExistingServer: true`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this file in the project?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops sorry

would keep serving the previous build's code):

```bash
cd desktop
lsof -ti :4173 | xargs kill -9 2>/dev/null; pnpm build
```

2. Write a throwaway driver spec — screenshots, no assertions — as
`desktop/tests/e2e/<name>.driver.ts`. Every drive starts with
`installMockBridge(page, mockOptions, seedOptions)` from
`tests/helpers/bridge.ts`. Useful options: `skipCommunitySeed` boots into
the first-run welcome screen, `skipOnboardingSeed` keeps onboarding
incomplete, `profileReadDelayMs` holds loading gates on screen long enough
to capture them.

3. CI specs must be listed in `playwright.config.ts` `testMatch`, but a
driver file should not be — run it via a throwaway config that copies the
main config's `webServer` block and sets
`testMatch: ["**/<name>.driver.ts"]`:

```bash
pnpm exec playwright test -c verify.driver.config.ts
```

4. Read the PNGs to confirm what the user actually sees. Playwright clears
`test-results/` at the start of every run, so capture all frames in one
run.

5. Delete the driver spec and throwaway config before committing.

Mock identities live in `TEST_IDENTITIES` (`tests/helpers/bridge.ts`): alice
has a relay profile so onboarding auto-completes after importing her key;
tyler with `username: ""` stays in onboarding.
171 changes: 117 additions & 54 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { isTauri } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { Hexagon } from "lucide-react";
import {
type ReactNode,
useCallback,
Expand All @@ -26,7 +25,10 @@ import type { Community } from "@/features/communities/types";
import { useCommunityInit } from "@/features/communities/useCommunityInit";
import { useNestNotifications } from "@/features/communities/useNestNotifications";
import { useCommunities } from "@/features/communities/useCommunities";
import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import {
WelcomeSetup,
type WelcomeSetupPage,
} from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
Expand Down Expand Up @@ -178,8 +180,32 @@ function CommunitySwitchGate() {
);
}

function OnboardingLoadingGate() {
// Headings for the connecting variant, matching the WelcomeSetup sub-page the
// user pressed the button on so the gate reads as that page's loading state.
const CONNECTING_GATE_HEADINGS: Record<
Exclude<WelcomeSetupPage, "welcome">,
string
> = {
"create-community": "Join a community",
invite: "Redeem an invite",
"nostr-key": "Use your existing key",
};

// Shown while a first-run community handoff settles (config apply + relay
// round trips). For the default-community path the user pressed a button on
// the welcome page itself, so the gate keeps rendering that same page with
// the buttons disabled — seamless continuity. Every other WelcomeSetup
// sub-page (key import, invite, join community) instead gets a forward-motion
// connecting view under its own heading; showing the welcome replica there
// reads as being kicked back to step 1.
function OnboardingLoadingGate({
source,
}: {
source: WelcomeSetupPage | null;
}) {
const systemColorScheme = useSystemColorScheme();
const connectingHeading =
source && source !== "welcome" ? CONNECTING_GATE_HEADINGS[source] : null;

return (
<div
Expand All @@ -196,55 +222,85 @@ function OnboardingLoadingGate() {
inactiveSegmentClassName="bg-muted-foreground/25"
/>

<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction="forward"
effect="none"
transitionKey="community-connecting"
>
<div className="flex h-14 w-14 items-center justify-center rounded-lg border border-border bg-background text-foreground shadow-xs">
<Hexagon className="h-7 w-7" aria-hidden="true" />
</div>

<h1 className="mt-6 text-3xl font-semibold tracking-tight">
Welcome to Buzz
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground">
Choose your first community to get started.
</p>

<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3">
<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
>
Continue with default community
</Button>

<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
variant="secondary"
>
Join a community
</Button>

<Button
aria-disabled="true"
className="h-10 w-full"
data-testid="welcome-continue-nostr"
tabIndex={-1}
type="button"
variant="ghost"
{connectingHeading !== null ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction="forward"
effect="none"
transitionKey="community-connecting"
>
<div
className="flex w-full max-w-[440px] flex-col items-center"
data-testid="onboarding-connecting-gate"
role="status"
>
I already have a key
</Button>
</div>
</OnboardingSlideTransition>
<h1 className="text-3xl font-semibold tracking-tight">
{connectingHeading}
</h1>
<BeeLoader
ariaLabel="Connecting to your community…"
className="mt-12 h-auto w-20"
tintClassName="text-muted-foreground"
/>
<p className="mt-6 text-sm leading-6 text-muted-foreground">
Connecting to your community…
</p>
</div>
</OnboardingSlideTransition>
) : (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction="forward"
effect="none"
transitionKey="community-connecting"
>
<img
alt="Buzz"
className="h-14 w-14 rounded-xl shadow-xs"
src="/app-icon@2x.png"
srcSet="/app-icon@2x.png 1x, /app-icon@3x.png 2x"
/>

<h1 className="mt-6 text-3xl font-semibold tracking-tight">
Welcome to Buzz
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground">
Choose your first community to get started.
</p>

<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3">
<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
>
Continue with default community
</Button>

<Button
aria-disabled="true"
className="h-10 w-full"
tabIndex={-1}
type="button"
variant="secondary"
>
Join a community
</Button>

<Button
aria-disabled="true"
className="h-10 w-full"
data-testid="welcome-continue-nostr"
tabIndex={-1}
type="button"
variant="ghost"
>
I already have a key
</Button>
</div>
</OnboardingSlideTransition>
)}
</div>
</div>
);
Expand Down Expand Up @@ -276,11 +332,13 @@ function CommunityQueryProvider({ children }: { children: ReactNode }) {
}

function AppReady({
firstRunHandoffSource,
isCompletingFirstRunCommunity,
isSharedIdentity,
isCommunitySwitch,
onFirstRunCommunitySettled,
}: {
firstRunHandoffSource: WelcomeSetupPage | null;
isCompletingFirstRunCommunity: boolean;
isSharedIdentity: boolean;
isCommunitySwitch: boolean;
Expand Down Expand Up @@ -323,7 +381,7 @@ function AppReady({

if (onboarding.stage === "blocking") {
if (isCompletingFirstRunCommunity) {
return <OnboardingLoadingGate />;
return <OnboardingLoadingGate source={firstRunHandoffSource} />;
}

return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
Expand Down Expand Up @@ -361,6 +419,8 @@ export function App() {
} = useCommunities();
const [isCompletingFirstRunCommunity, setIsCompletingFirstRunCommunity] =
useState(false);
const [firstRunHandoffSource, setFirstRunHandoffSource] =
useState<WelcomeSetupPage | null>(null);
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);

useEffect(() => {
Expand Down Expand Up @@ -399,8 +459,9 @@ export function App() {
);

const handleSetupComplete = useCallback(
(community: Community) => {
(community: Community, source: WelcomeSetupPage) => {
setIsCompletingFirstRunCommunity(true);
setFirstRunHandoffSource(source);
const communityId = addCommunity(community);
switchCommunity(communityId);
},
Expand All @@ -409,6 +470,7 @@ export function App() {

const handleFirstRunCommunitySettled = useCallback(() => {
setIsCompletingFirstRunCommunity(false);
setFirstRunHandoffSource(null);
}, []);

const bootSplashPhase = useBootSplashHold();
Expand Down Expand Up @@ -454,7 +516,7 @@ export function App() {
// backend is still configured for the previous one.
if (!community.isReady || community.appliedKey !== communityKey) {
if (isCompletingFirstRunCommunity) {
return <OnboardingLoadingGate />;
return <OnboardingLoadingGate source={firstRunHandoffSource} />;
}

return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
Expand All @@ -471,6 +533,7 @@ export function App() {
return (
<CommunityQueryProvider key={communityKey}>
<AppReady
firstRunHandoffSource={firstRunHandoffSource}
isCompletingFirstRunCommunity={isCompletingFirstRunCommunity}
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
Expand Down
15 changes: 10 additions & 5 deletions desktop/src/features/communities/ui/WelcomeSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@ import type { Community } from "../types";
import { initFirstCommunity } from "../communityStorage";
import { CommunityEditForm } from "./CommunityEditForm";

type WelcomeSetupPage = "welcome" | "create-community" | "invite" | "nostr-key";
export type WelcomeSetupPage =
| "welcome"
| "create-community"
| "invite"
| "nostr-key";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;

type WelcomeSetupProps = {
defaultRelayUrl: string;
initialTransitionMode?: WelcomeTransitionMode;
onComplete: (community: Community) => void;
onComplete: (community: Community, source: WelcomeSetupPage) => void;
};

const DEFAULT_COMMUNITY_HANDOFF_MIN_MS = 200;
Expand Down Expand Up @@ -138,16 +142,17 @@ export function WelcomeSetup({
}

// The parent moves this community into React state so first-run setup
// can continue without a full page reload.
onComplete(community);
// can continue without a full page reload. The source page lets the
// parent's loading gate keep matching the page the user came from.
onComplete(community, page);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to connect. Try again.",
);
setIsConnecting(false);
}
},
[onComplete],
[onComplete, page],
);

const handleNostrImport = React.useCallback(
Expand Down
Loading