Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Fake mode also runs `e2e/full-stack/`. Live mode runs the recipes, `e2e/live/`,

To prove a feature without a recipe, copy `recipes/case-generation.spec.ts` to `recipes/<feature>.spec.ts` and use the selectors from the matching file in `features/`. Recipes are maintained files. Keep the ones worth keeping.

**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts/<run-id>/`. The one-time DebateOwl welcome never opens on this stack (`VITE_LOCAL_PREVIEW`) or under Playwright (`VITE_E2E`); open `/app?intro=show` to see it, and close it with `Got it` or Esc. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you.
**Interactive.** Run `$H user` to mint a code. It prints `DEMO_CODE=...`, and `down` deletes that user. Then point a browser tool (Playwright MCP `browser_navigate` etc.) at `http://127.0.0.1:18081/login`, choose the `Demo code` tab, fill the `Demo code` textbox, and click `Sign in with demo code`. Save your own screenshots into `.verify/artifacts/<run-id>/`. The one-time DebateOwl welcome never opens on this stack (`VITE_LOCAL_PREVIEW`) or under Playwright (`VITE_E2E`); open `/app?intro=show` to see it, and close it with `Got it` or Esc. Closing it continues straight into the tour's Start debate step when that account has not finished the tour. A new account shows the onboarding tour on `/app`. Its first step offers `Start debate`; to skip the tour, click `Skip tour` (`[data-test-id="button-skip"]`). Then start a debate from the header (`Start debate`, test id `current-debate-desktop` or `current-debate-mobile`) or answer the motion dialog the first tool opens: `Start debate` files the work in a new debate, `Not now, use it once` keeps it one-off. Interactive runs have no remote-Supabase guard. Doctor's local-mode check is what protects you.

Selectors: use accessible names and roles as the feature files list them (`getByRole("button", { name: "Generate debate case" })`). The exceptions use `data-testid`: Crossfire's controls, the Current Debate control, strip and dashboard, and the voice controls (`voice-mode`, `voice-mic`). Use `exact: true` for short names such as `Start` or `Save`, because the header's `Start debate` also matches `Start`. Never use coordinates.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Preconditions:

## Gotchas

- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateOwl`, `Start your first debate here.`) offers `Start debate`. The fixtures pre-seed `debate-trainer-onboarding` to skip it. The tour is tracked per account (store version 1, `completedUserIds`). A version 0 seed with `hasCompletedOnboarding: true` covers only the next account that signs in, so a second account in the same browser sees the tour. Interactive runs click `Skip tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md).
- A first sign-in on a new account starts the onboarding tour on `/app`. Its first step (`Welcome to DebateOwl`, `Start your first debate here.`) offers `Start debate`. When the one-time DebateOwl welcome is due, closing it (`Got it` or Esc) continues straight into that step; the verify stack keeps the welcome closed unless the URL is `?intro=show`. The fixtures pre-seed `debate-trainer-onboarding` to skip the tour. The tour is tracked per account (store version 1, `completedUserIds`). A version 0 seed with `hasCompletedOnboarding: true` covers only the next account that signs in, so a second account in the same browser sees the tour. Interactive runs click `Skip tour` (`[data-test-id="button-skip"]`). The tour itself is covered in [current-debate](./current-debate.md).
- Sessions are stored under the `debate-auth-local` localStorage key in local-preview mode. Don't reuse stub-session helpers (`e2e/helpers/auth.ts`). They fake auth and prove rendering only.
- Google sign-in is not wired to the local stack. Demo codes, email codes and the email link work here.
- The revoked-session check forces one token refresh before it signs out. An Auth outage (network error, 5xx, 429) keeps the session, and that path is covered by `src/test/auth/session-expiry.test.ts`, not by a recipe.
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/current-debate.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Each student works in one Current Debate at a time: a motion with its own Librar
- Home (`/app`): the card with test id `home-current-debate`, which says `No current debate` for a new account.
- Every tool page shows the strip with test id `current-debate-strip` (`Working in: <motion>`), with a `Use once` button for one-off mode.
- The debate dashboard is `/app/debate`.
- A new account's onboarding tour starts on `/app` with a `Start debate` button in its first step.
- A new account's onboarding tour starts on `/app` with a `Start debate` button in its first step. The one-time welcome, when it shows, closes straight into that step. This stack keeps the welcome closed unless the URL is `?intro=show`.

## Driving it with verify.sh + Playwright

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The repository is an npm workspace. The web app is the root package and still li

[`src/lib/stores/session-store.ts`](../src/lib/stores/session-store.ts) owns active AI results, conversations, saved work, sources, folders, flowcharts, autosave state, and persisted form state. It persists selected local state and hydrates authenticated rows from Supabase.

[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion. The first-run tour starts with the Current Debate card, then points at Library and Generate. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate.
[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion. The first-run tour starts with the Current Debate card (the Start debate step), then points at Library and Generate. When the one-time welcome is due, that step opens as the welcome closes. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate.

[`src/lib/stores/settings-store.ts`](../src/lib/stores/settings-store.ts) owns language, theme, feature provider settings, prompts, and research preferences. It persists locally and synchronizes authenticated settings.

Expand Down Expand Up @@ -141,7 +141,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path.
- Google and any emailed sign-in link return to `/auth/callback` (with `?next=/app/...` for a deep link; only `/app` paths are accepted). [`AuthProvider`](../src/lib/supabase/AuthProvider.tsx) exchanges the code there and keeps `loading` true until the exchange settles. The client sets `experimental.appendPkceFlowIdToRedirects`, so each sign-in keeps its own PKCE verifier and its redirect carries `sb_flow_id`; another tab's sign-in or a failed resend no longer breaks a pending link. An `error`/`error_code` from Auth, or a failed exchange, opens `/login` with a localized message. Links that still point at `/?code=` or `/app?code=` are moved onto the callback route before the first render ([`src/lib/auth-callback.ts`](../src/lib/auth-callback.ts)). Each project's Auth redirect allowlist needs `<site origin>/auth/callback**`; a redirect to the Site URL's own host passes without it.
- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser. The one-time DebateOwl welcome ([`src/lib/debateowl-intro.ts`](../src/lib/debateowl-intro.ts)) opens once per browser inside the signed-in app, is remembered in localStorage (`debateowl_intro_seen_v1`), and runs before the tour.
- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser. The one-time DebateOwl welcome ([`src/lib/debateowl-intro.ts`](../src/lib/debateowl-intro.ts)) opens once per browser inside the signed-in app, is remembered in localStorage (`debateowl_intro_seen_v1`), and closing it continues straight into the tour's Start debate step. If that chunk fails twice, the app logs once, remembers the welcome, and continues without the tour. `?intro=show` opens the welcome again; a finished tour stays finished.
- RLS owns row visibility and mutation rights.
- Shared backend helpers validate bearer tokens and admin roles.
- Admin endpoints verify server-side roles.
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ The server is never reused. CI uses retries and two workers. On failure, traces,

Playwright starts that server with `VITE_E2E=1`. The client then keeps the environment switcher, but the production fallback is `http://e2e.invalid` instead of the production Supabase project, and a request that still names that project is refused in the browser. `playwright.config.ts` also refuses to start when `VITE_SUPABASE_ENV` or `SUPABASE_URL` already points at production. `E2E_ALLOW_PROD=1` is the explicit override. The production canary uses `playwright.production.config.ts` and does not set `VITE_E2E`. Plain `npm run dev` is unchanged and still defaults to production.

The one-time DebateOwl welcome stays closed whenever `VITE_E2E` or `VITE_LOCAL_PREVIEW` is set, so Playwright, the verify stack and agentic tests never meet it; `?intro=show` opens it anyway and `?intro=skip` keeps it closed in any build. The production canary and the service worker offline test (`npm run test:sw`), which drive production builds, seed `debateowl_intro_seen_v1` instead.
The one-time DebateOwl welcome stays closed whenever `VITE_E2E` or `VITE_LOCAL_PREVIEW` is set, so Playwright, the verify stack and agentic tests never meet it; `?intro=show` opens it anyway and `?intro=skip` keeps it closed in any build. Closing it continues into the tour's Start debate step when that account has not finished the tour. If that chunk fails twice, the app logs once, remembers the welcome, and continues without the tour. The production canary and the service worker offline test (`npm run test:sw`), which drive production builds, seed `debateowl_intro_seen_v1` instead.

`e2e/hungarian-parity.spec.ts` is the focused Hungarian browser contract. On desktop and mobile Chromium it checks persisted `<html lang="hu">`, public and authenticated page chrome, route titles, onboarding, saved-chat empty state, Library controls, Timer semantics, localized close labels, and runtime errors. It deliberately does not reject arbitrary English text because source titles, user content, model names, and citations may legitimately remain in their source language.

Expand Down
104 changes: 81 additions & 23 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ThemeProvider } from "@/components/ThemeProvider";
import { useSessionStorageSync } from "@/hooks/use-session-storage-sync";
import { hasCompletedOnboarding, useOnboardingStore } from "@/hooks/use-onboarding";
import { browserStorage, markIntroSeen, shouldShowIntro } from "@/lib/debateowl-intro";
import { focusCurrentDebate, noteTourChunkFailure, tourChunkGaveUp } from "@/lib/onboarding-tour-load";
import { OfflineIndicator } from "@/components/OfflineIndicator";
import { StagingBanner } from "@/components/StagingBanner";
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
Expand Down Expand Up @@ -88,10 +89,32 @@ const AppLayout = lazy(() => {
void preloadStartupRoute(window.location.pathname);
return appLayoutLoader();
});
const OnboardingTour = lazy(() => import("@/components/features/onboarding/OnboardingTour")
.then(({ OnboardingTour: Tour }) => ({ default: Tour })));
const DebateOwlIntro = lazy(() => import("@/components/features/onboarding/DebateOwlIntro")
.then(({ DebateOwlIntro: Intro }) => ({ default: Intro })));
type OnboardingTourComponent = typeof import("@/components/features/onboarding/OnboardingTour").OnboardingTour;

// Loaded while the welcome is open, so closing it can show the Start debate step
// without waiting on a second chunk.
let onboardingTourComponent: OnboardingTourComponent | null = null;
let onboardingTourPromise: Promise<OnboardingTourComponent> | null = null;

function loadOnboardingTour(): Promise<OnboardingTourComponent> {
if (onboardingTourComponent) return Promise.resolve(onboardingTourComponent);
// A failed fetch has to be retryable. Keeping the rejected promise would
// leave the Start debate step closed for the rest of the page. The second
// failure is the last: `noteTourChunkFailure` logs once and the tour owner
// continues without it.
onboardingTourPromise ??= import("@/components/features/onboarding/OnboardingTour").then((mod) => {
onboardingTourComponent = mod.OnboardingTour;
return mod.OnboardingTour;
}).catch((error: unknown) => {
onboardingTourPromise = null;
noteTourChunkFailure(error);
throw error;
});
return onboardingTourPromise;
}

const FirstRun = lazy(() => import("@/components/features/onboarding/FirstRun")
.then(({ FirstRun: Run }) => ({ default: Run })));

// TanStack Query refetches after an `offline` then `online` pair, but a page
// opened offline never sees `offline`. Retry whatever failed once the
Expand Down Expand Up @@ -286,37 +309,44 @@ export function RouteContent({ children }: { children: ReactNode }) {
}

/**
* The one-time DebateOwl welcome opens over the signed-in app once its layout has loaded.
* The onboarding tour waits for it to close; without the welcome the tour mounts as before.
* One first-run moment. The owl welcome, when it is due, opens over the signed-in
* app once its layout has loaded. Closing it continues straight into the tour's
* Start debate step. With no welcome, the tour mounts as before.
* Rendered only under RequireAuth, so an account is already signed in.
*/
function AppWelcome() {
const [introOpen, setIntroOpen] = useState(() => shouldShowIntro({
export function AppWelcome() {
const [showIntro] = useState(() => shouldShowIntro({
search: window.location.search,
storage: browserStorage(),
env: import.meta.env,
}));
const [layoutReady, setLayoutReady] = useState(false);
const userId = useAuth().user?.id;
const tourCompleted = useOnboardingStore((state) => hasCompletedOnboarding(state, userId));

useEffect(() => {
if (!introOpen) return;
if (!showIntro) return;
let live = true;
void loadOnboardingTour().catch((): void => undefined);
appLayoutLoader().then(() => { if (live) setLayoutReady(true); }, (): void => undefined);
return () => { live = false; };
}, [introOpen]);
}, [showIntro]);

if (!introOpen) return <OptionalOnboardingTour />;
if (!showIntro) return <OptionalOnboardingTour continueFromIntro={false} />;
if (!layoutReady) return null;
return (
<Suspense fallback={null}>
<DebateOwlIntro onClose={() => {
markIntroSeen(browserStorage());
setIntroOpen(false);
}} />
<FirstRun
showIntro
tourCompleted={tourCompleted}
onIntroSeen={() => markIntroSeen(browserStorage())}
tour={(continueFromIntro) => <OptionalOnboardingTour continueFromIntro={continueFromIntro} />}
/>
</Suspense>
);
}

function OptionalOnboardingTour() {
function OptionalOnboardingTour({ continueFromIntro }: { continueFromIntro: boolean }) {
const location = useLocation();
const userId = useAuth().user?.id;
const completed = useOnboardingStore((state) => hasCompletedOnboarding(state, userId));
Expand All @@ -332,17 +362,45 @@ function OptionalOnboardingTour() {
if (!userId || !shouldLoadTour) return null;

// Keyed by account, so one account's half-finished tour never carries into the next.
return <TimedOnboardingTour key={userId} userId={userId} />;
return <TimedOnboardingTour key={userId} userId={userId} continueFromIntro={continueFromIntro} />;
}

function TimedOnboardingTour({ userId }: { userId: string }) {
function TimedOnboardingTour({ userId, continueFromIntro }: { userId: string; continueFromIntro: boolean }) {
const [eligibleAt] = useState(() => typeof performance === "undefined" ? 0 : performance.now());
const [Tour, setTour] = useState<OnboardingTourComponent | null>(() => onboardingTourComponent);
const [gaveUp, setGaveUp] = useState(false);

return (
<Suspense fallback={null}>
<OnboardingTour eligibleAt={eligibleAt} userId={userId} />
</Suspense>
);
useEffect(() => {
if (Tour || gaveUp) return;
let live = true;
const giveUp = () => {
setGaveUp(true);
focusCurrentDebate();
};
// The welcome's preload counts as the first try when it ran. One more
// failure after that, and the page continues without the tour.
if (tourChunkGaveUp()) {
giveUp();
return;
}
const load = () => {
loadOnboardingTour().then((Loaded) => {
if (live) setTour(() => Loaded);
}, () => {
if (!live) return;
if (tourChunkGaveUp()) {
giveUp();
return;
}
load();
});
};
load();
return () => { live = false; };
}, [Tour, gaveUp]);

if (gaveUp || !Tour) return null;
return <Tour eligibleAt={eligibleAt} userId={userId} continueFromIntro={continueFromIntro} />;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/components/features/onboarding/DebateOwlIntro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ export function DebateOwlIntro({ onClose, owlName = OWL_NAME }: { onClose: () =>
event.preventDefault();
doneButton.current?.focus();
}}
// The tour's Next button takes focus as this dialog goes. Restoring
// focus here would put it back on the page body.
onCloseAutoFocus={(event) => {
event.preventDefault();
}}
>
<div className="flex flex-col items-center gap-5 px-6 pt-7 sm:flex-row sm:items-center sm:gap-6 sm:px-8 sm:pt-8">
<motion.div
Expand Down
Loading
Loading