diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index 3bf777bd..b231a9b3 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -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/.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//`. 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//`. 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. diff --git a/.claude/skills/verify-debatelab/features/auth-demo-login.md b/.claude/skills/verify-debatelab/features/auth-demo-login.md index 0f39f9e7..0cda7d9e 100644 --- a/.claude/skills/verify-debatelab/features/auth-demo-login.md +++ b/.claude/skills/verify-debatelab/features/auth-demo-login.md @@ -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. diff --git a/.claude/skills/verify-debatelab/features/current-debate.md b/.claude/skills/verify-debatelab/features/current-debate.md index f7de8ba8..fa7e63d7 100644 --- a/.claude/skills/verify-debatelab/features/current-debate.md +++ b/.claude/skills/verify-debatelab/features/current-debate.md @@ -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: `), 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 diff --git a/docs/architecture.md b/docs/architecture.md index 27f4e4bb..ac69bb1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. @@ -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 `/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. diff --git a/docs/testing.md b/docs/testing.md index 9217469d..463fe8e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 ``, 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. diff --git a/src/App.tsx b/src/App.tsx index 3f78c25d..f9b245db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -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 | null = null; + +function loadOnboardingTour(): Promise { + 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 @@ -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 ; + if (!showIntro) return ; if (!layoutReady) return null; return ( - { - markIntroSeen(browserStorage()); - setIntroOpen(false); - }} /> + markIntroSeen(browserStorage())} + tour={(continueFromIntro) => } + /> ); } -function OptionalOnboardingTour() { +function OptionalOnboardingTour({ continueFromIntro }: { continueFromIntro: boolean }) { const location = useLocation(); const userId = useAuth().user?.id; const completed = useOnboardingStore((state) => hasCompletedOnboarding(state, userId)); @@ -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 ; + return ; } -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); + const [gaveUp, setGaveUp] = useState(false); - return ( - - - - ); + 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 ; } /** diff --git a/src/components/features/onboarding/DebateOwlIntro.tsx b/src/components/features/onboarding/DebateOwlIntro.tsx index 6e46d76c..230228d0 100644 --- a/src/components/features/onboarding/DebateOwlIntro.tsx +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -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(); + }} >
void; + tour: (continueFromIntro: boolean) => ReactNode; +}) { + const [introDismissed, setIntroDismissed] = useState(false); + const step = firstRunStep({ showIntro, introDismissed, tourCompleted }); + if (step === 'intro') { + return ( + { + onIntroSeen(); + setIntroDismissed(true); + }} + /> + ); + } + // `idle` still mounts the tour owner. It draws nothing once the tour is finished. + return <>{tour(step === 'start-debate')}; +} diff --git a/src/components/features/onboarding/OnboardingTour.tsx b/src/components/features/onboarding/OnboardingTour.tsx index 999705f4..4fba6f6a 100644 --- a/src/components/features/onboarding/OnboardingTour.tsx +++ b/src/components/features/onboarding/OnboardingTour.tsx @@ -1,11 +1,12 @@ // src/components/features/onboarding/OnboardingTour.tsx -import { useEffect, useCallback, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Joyride, { ACTIONS, CallBackProps, EVENTS, STATUS, Step } from 'react-joyride'; import { useLocation } from 'react-router-dom'; import { Owl } from '@/components/brand/Owl'; import { Button } from '@/components/ui/button'; import { hasCompletedOnboarding as completedFor, useOnboardingStore } from '@/hooks/use-onboarding'; +import { focusCurrentDebate, focusTourPrimary } from '@/lib/onboarding-tour-load'; import { useDebateStore } from '@/lib/stores/debate-store'; // Below `lg` the header's menu button is the navigation. There the tour draws no @@ -27,7 +28,16 @@ function useCompactLayout() { return compact; } -export function OnboardingTour({ eligibleAt, userId }: { eligibleAt?: number; userId: string }) { +export function OnboardingTour({ + eligibleAt, + userId, + continueFromIntro = false, +}: { + eligibleAt?: number; + userId: string; + /** The owl welcome just closed, so the Start debate step opens before the next paint. */ + continueFromIntro?: boolean; +}) { const { t } = useTranslation(); const hasCompletedOnboarding = useOnboardingStore((state) => completedFor(state, userId)); const { isTourRunning, setOnboardingComplete, startTour, stopTour } = useOnboardingStore(); @@ -122,6 +132,29 @@ export function OnboardingTour({ eligibleAt, userId }: { eligibleAt?: number; us setScrollOffset((document.querySelector('header')?.getBoundingClientRect().height ?? 0) + 24); }, [resolveTourSteps]); + // The parent leaves `continueFromIntro` true for the whole Start debate step. + // Consume it once the spotlight has opened. `startTour` does not reset + // `stepIndex`, and the create-dialog pause still depends on `debateDialog`, + // so a later language or debate change must not run this handoff again. + // If the card is not in the document yet, leave it pending for the next pass. + const handoffPending = useRef(continueFromIntro); + useLayoutEffect(() => { + if (!handoffPending.current || hasCompletedOnboarding || location.pathname !== '/app') return; + if (!document.querySelector('[data-tour="current-debate"]')) return; + handoffPending.current = false; + prepareTour(); + startTour(); + }, [hasCompletedOnboarding, location.pathname, prepareTour, startTour]); + + // Joyride also focuses a hidden beacon. That runs in its mount timeout, so + // the visible Next button has to take focus after it. The welcome dialog + // has already given up restoring focus to the page body. + useEffect(() => { + if (!continueFromIntro || hasCompletedOnboarding || !isTourRunning) return; + const timer = window.setTimeout(() => { focusTourPrimary(); }, 0); + return () => window.clearTimeout(timer); + }, [continueFromIntro, hasCompletedOnboarding, isTourRunning]); + // Start tour automatically for new users useEffect(() => { if (hasCompletedOnboarding || location.pathname !== '/app') return; @@ -178,6 +211,9 @@ export function OnboardingTour({ eligibleAt, userId }: { eligibleAt?: number; us if (finishedStatuses.includes(status)) { setOnboardingComplete(userId); setStepIndex(0); + // The tooltip is about to unmount. The card it was pointing at is the + // control the student can use next. + requestAnimationFrame(() => { focusCurrentDebate(); }); } else if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) { setStepIndex(index + (action === ACTIONS.PREV ? -1 : 1)); } diff --git a/src/lib/debateowl-intro.ts b/src/lib/debateowl-intro.ts index e1eb4fb3..d152fd19 100644 --- a/src/lib/debateowl-intro.ts +++ b/src/lib/debateowl-intro.ts @@ -1,6 +1,7 @@ /** * The one-time DebateOwl welcome: the owl says hello, says what changed (the look only) and that * DebateLab is becoming DebateOwl. It opens once per browser, inside the signed-in app. + * Closing it continues straight into the product tour's Start debate step. */ export const INTRO_SEEN_KEY = 'debateowl_intro_seen_v1'; @@ -57,3 +58,27 @@ export function browserStorage(): IntroStorage | undefined { return undefined; } } + +/** The visible part of the first visit. `idle` means nothing is on screen. */ +export type FirstRunStep = 'intro' | 'start-debate' | 'tour' | 'idle'; + +/** + * The welcome and the product tour are one first-run moment. + * The welcome, when it is due, opens first. Closing it continues into the tour's + * Start debate step when that account has not finished the tour. With no welcome, + * an unfinished tour still opens on its own, at that same first step. A finished + * tour stays closed, including when `?intro=show` replays the welcome. + */ +export function firstRunStep({ + showIntro, + introDismissed, + tourCompleted, +}: { + showIntro: boolean; + introDismissed: boolean; + tourCompleted: boolean; +}): FirstRunStep { + if (showIntro && !introDismissed) return 'intro'; + if (tourCompleted) return 'idle'; + return showIntro ? 'start-debate' : 'tour'; +} diff --git a/src/lib/onboarding-tour-load.ts b/src/lib/onboarding-tour-load.ts new file mode 100644 index 00000000..daf80894 --- /dev/null +++ b/src/lib/onboarding-tour-load.ts @@ -0,0 +1,47 @@ +import { browserStorage, markIntroSeen } from '@/lib/debateowl-intro'; + +/** The welcome preloads the tour once. The tour owner tries once more, then stops. */ +const TOUR_CHUNK_ATTEMPTS = 2; + +let attempts = 0; +let logged = false; + +export function resetTourChunkLoadForTests(): void { + attempts = 0; + logged = false; +} + +export function tourChunkGaveUp(): boolean { + return attempts >= TOUR_CHUNK_ATTEMPTS; +} + +/** + * Count one failed fetch of the tour chunk. The second failure logs once, + * remembers the welcome, and lets the page continue without the tour. + */ +export function noteTourChunkFailure(error: unknown): 'retry' | 'give-up' { + attempts += 1; + if (attempts < TOUR_CHUNK_ATTEMPTS) return 'retry'; + if (!logged) { + logged = true; + const detail = error instanceof Error ? error.message : 'unknown error'; + console.warn('[Onboarding] Could not load the tour. Continuing without it.', detail); + markIntroSeen(browserStorage()); + } + return 'give-up'; +} + +/** The Current Debate card is what the first step was pointing at. */ +export function focusCurrentDebate(): void { + if (typeof document === 'undefined') return; + const card = document.querySelector('[data-tour="current-debate"]'); + if (!card) return; + const control = card.querySelector('button:not([disabled]), a[href]'); + (control ?? card).focus(); +} + +/** Joyride's Next button, the action the welcome just handed the student. */ +export function focusTourPrimary(): void { + if (typeof document === 'undefined') return; + document.querySelector('[data-action="primary"]')?.focus(); +} diff --git a/src/test/onboarding/debateowl-intro.test.tsx b/src/test/onboarding/debateowl-intro.test.tsx index 1345ed3d..be24aa46 100644 --- a/src/test/onboarding/debateowl-intro.test.tsx +++ b/src/test/onboarding/debateowl-intro.test.tsx @@ -1,7 +1,16 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from '@supabase/supabase-js'; +import { MemoryRouter } from 'react-router-dom'; +import { AppWelcome } from '@/App'; import { DebateOwlIntro } from '@/components/features/onboarding/DebateOwlIntro'; -import { INTRO_SEEN_KEY, markIntroSeen, shouldShowIntro } from '@/lib/debateowl-intro'; +import { FirstRun } from '@/components/features/onboarding/FirstRun'; +import { OnboardingTour } from '@/components/features/onboarding/OnboardingTour'; +import { useOnboardingStore } from '@/hooks/use-onboarding'; +import { INTRO_SEEN_KEY, firstRunStep, markIntroSeen, shouldShowIntro } from '@/lib/debateowl-intro'; +import { noteTourChunkFailure, resetTourChunkLoadForTests } from '@/lib/onboarding-tour-load'; +import { AuthContext, type AuthContextValue } from '@/lib/supabase/auth-context'; +import { useDebateStore } from '@/lib/stores/debate-store'; function memoryStorage(initial: Record = {}) { const values = new Map(Object.entries(initial)); @@ -42,6 +51,194 @@ describe('shouldShowIntro', () => { }); }); +describe('firstRunStep', () => { + it('opens the welcome first, then continues into the Start debate step', () => { + expect(firstRunStep({ showIntro: true, introDismissed: false, tourCompleted: false })).toBe('intro'); + expect(firstRunStep({ showIntro: true, introDismissed: true, tourCompleted: false })).toBe('start-debate'); + }); + + it('lets an unfinished tour open on its own when the welcome is not due', () => { + expect(firstRunStep({ showIntro: false, introDismissed: false, tourCompleted: false })).toBe('tour'); + expect(firstRunStep({ showIntro: false, introDismissed: true, tourCompleted: false })).toBe('tour'); + }); + + it('keeps a finished tour closed, including when the welcome is replayed', () => { + expect(firstRunStep({ showIntro: true, introDismissed: false, tourCompleted: true })).toBe('intro'); + expect(firstRunStep({ showIntro: true, introDismissed: true, tourCompleted: true })).toBe('idle'); + expect(firstRunStep({ showIntro: false, introDismissed: false, tourCompleted: true })).toBe('idle'); + }); +}); + +describe('FirstRun', () => { + it('Got it marks the welcome seen and continues into the Start debate step', () => { + const storage = memoryStorage(); + const tour = vi.fn((continueFromIntro: boolean) =>

{continueFromIntro ? 'start-debate' : 'tour'}

); + render( + markIntroSeen(storage)} + tour={tour} + />, + ); + + expect(screen.getByRole('dialog', { name: 'DebateLab is becoming DebateOwl' })).toBeTruthy(); + expect(screen.queryByText('start-debate')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Skip animation' })); + expect(storage.getItem(INTRO_SEEN_KEY)).toBeNull(); + expect(screen.queryByText('start-debate')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Got it' })); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(false); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByText('start-debate')).toBeTruthy(); + }); + + it('Esc closes the welcome the same way and still continues', () => { + const tour = vi.fn((continueFromIntro: boolean) =>

{continueFromIntro ? 'start-debate' : 'tour'}

); + render( undefined} tour={tour} />); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(screen.getByText('start-debate')).toBeTruthy(); + }); + + it('?intro=show replays the welcome and does not restart a finished tour', () => { + const storage = memoryStorage({ [INTRO_SEEN_KEY]: 'seen' }); + const showIntro = shouldShowIntro({ search: '?intro=show', storage, env: appEnv }); + const tour = vi.fn((continueFromIntro: boolean) =>

{continueFromIntro ? 'restarted' : 'owner'}

); + render( markIntroSeen(storage)} tour={tour} />); + + expect(screen.getByRole('dialog', { name: 'DebateLab is becoming DebateOwl' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Got it' })); + expect(screen.queryByText('restarted')).toBeNull(); + expect(screen.getByText('owner')).toBeTruthy(); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(false); + }); + + it('with no welcome, the tour opens on its own and not as a continuation', () => { + const tour = vi.fn((continueFromIntro: boolean) =>

{continueFromIntro ? 'start-debate' : 'tour'}

); + render( undefined} tour={tour} />); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByText('tour')).toBeTruthy(); + }); +}); + +describe('OnboardingTour continuing from the welcome', () => { + beforeEach(() => { + localStorage.clear(); + useOnboardingStore.setState({ completedUserIds: [], legacyCompleted: false, isTourRunning: false }); + useDebateStore.setState({ currentDebateId: null, dialog: null }); + }); + + function renderTour(continueFromIntro: boolean) { + return render( + +
+ +
, + ); + } + + it('opens the Start debate step and not a second welcome dialog', async () => { + renderTour(true); + expect(screen.getByText(/Start your first debate here/)).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Start debate' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: 'DebateLab is becoming DebateOwl' })).toBeNull(); + await waitFor(() => { + expect(document.activeElement).toBe(document.querySelector('[data-action="primary"]')); + }); + fireEvent.click(screen.getByRole('button', { name: 'Skip tour' })); + await waitFor(() => { + expect(document.activeElement).toBe(document.querySelector('[data-tour="current-debate"] button')); + }); + }); + + it('stays closed when that account has already finished the tour', () => { + useOnboardingStore.getState().setOnboardingComplete('student-1'); + renderTour(true); + expect(screen.queryByText(/Start your first debate here/)).toBeNull(); + expect(screen.queryByRole('button', { name: 'Start debate' })).toBeNull(); + }); +}); + +const signedIn: AuthContextValue = { + session: null, + user: { id: 'student-1' } as User, + loading: false, + callbackError: null, + clearCallbackError: () => undefined, + signInWithGoogle: async () => undefined, + signInWithEmailOtp: async () => undefined, + verifyEmailOtp: async () => undefined, + signInWithDemoCode: async () => undefined, + signOut: async () => undefined, + leaveEnvironment: async () => undefined, + discardTabState: () => undefined, +}; + +describe('AppWelcome hands the welcome to the Start debate step', () => { + beforeEach(() => { + localStorage.clear(); + resetTourChunkLoadForTests(); + window.history.pushState({}, '', '/app?intro=show'); + useOnboardingStore.setState({ completedUserIds: [], legacyCompleted: false, isTourRunning: false }); + useDebateStore.setState({ currentDebateId: null, dialog: null }); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('Got it closes the welcome into the tour, with focus on the tour and then the card', async () => { + render( + + +
+ +
+
, + ); + + const gotIt = await screen.findByRole('button', { name: 'Got it' }, { timeout: 5000 }); + expect(gotIt).toHaveFocus(); + fireEvent.click(gotIt); + + expect(await screen.findByText(/Start your first debate here/)).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: 'DebateLab is becoming DebateOwl' })).toBeNull(); + expect(localStorage.getItem(INTRO_SEEN_KEY)).toBeTruthy(); + await waitFor(() => { + expect(document.activeElement).toBe(document.querySelector('[data-action="primary"]')); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Skip tour' })); + await waitFor(() => { + expect(document.activeElement).toBe(document.querySelector('[data-tour="current-debate"] button')); + }); + }); +}); + +describe('a tour chunk that fails to load', () => { + beforeEach(() => { + localStorage.clear(); + resetTourChunkLoadForTests(); + }); + + it('retries once, then logs once and remembers the welcome', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + expect(noteTourChunkFailure(new Error('offline'))).toBe('retry'); + expect(localStorage.getItem(INTRO_SEEN_KEY)).toBeNull(); + expect(warn).not.toHaveBeenCalled(); + + expect(noteTourChunkFailure(new Error('offline'))).toBe('give-up'); + expect(localStorage.getItem(INTRO_SEEN_KEY)).toBeTruthy(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0]?.[0])).toContain('[Onboarding] Could not load the tour'); + + expect(noteTourChunkFailure(new Error('offline'))).toBe('give-up'); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); +}); + describe('DebateOwlIntro', () => { function renderIntro(owlName?: string) { const storage = memoryStorage(); diff --git a/src/test/onboarding/tour-chunk-fallback.test.tsx b/src/test/onboarding/tour-chunk-fallback.test.tsx new file mode 100644 index 00000000..f3675173 --- /dev/null +++ b/src/test/onboarding/tour-chunk-fallback.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from '@supabase/supabase-js'; +import { MemoryRouter } from 'react-router-dom'; +import { AppWelcome } from '@/App'; +import { INTRO_SEEN_KEY } from '@/lib/debateowl-intro'; +import { resetTourChunkLoadForTests } from '@/lib/onboarding-tour-load'; +import { AuthContext, type AuthContextValue } from '@/lib/supabase/auth-context'; +import { useDebateStore } from '@/lib/stores/debate-store'; +import { useOnboardingStore } from '@/hooks/use-onboarding'; + +vi.mock('@/components/features/onboarding/OnboardingTour', () => ({ + get OnboardingTour() { + throw new Error('chunk failed'); + }, +})); + +const signedIn: AuthContextValue = { + session: null, + user: { id: 'student-1' } as User, + loading: false, + callbackError: null, + clearCallbackError: () => undefined, + signInWithGoogle: async () => undefined, + signInWithEmailOtp: async () => undefined, + verifyEmailOtp: async () => undefined, + signInWithDemoCode: async () => undefined, + signOut: async () => undefined, + leaveEnvironment: async () => undefined, + discardTabState: () => undefined, +}; + +describe('AppWelcome when the tour chunk never loads', () => { + beforeEach(() => { + localStorage.clear(); + resetTourChunkLoadForTests(); + window.history.pushState({}, '', '/app?intro=show'); + useOnboardingStore.setState({ completedUserIds: [], legacyCompleted: false, isTourRunning: false }); + useDebateStore.setState({ currentDebateId: null, dialog: null }); + }); + + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('gives up after the second failure, logs once, and leaves focus on the card', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + render( + + +
+ +
+
, + ); + + const gotIt = await screen.findByRole('button', { name: 'Got it' }, { timeout: 5000 }); + const onboardingWarnings = () => warn.mock.calls.filter((call) => String(call[0]).includes('[Onboarding]')); + expect(onboardingWarnings()).toHaveLength(0); + + fireEvent.click(gotIt); + + await waitFor(() => { + expect(onboardingWarnings()).toHaveLength(1); + }); + expect(screen.queryByRole('dialog', { name: 'DebateLab is becoming DebateOwl' })).toBeNull(); + expect(screen.queryByText(/Start your first debate here/)).toBeNull(); + expect(localStorage.getItem(INTRO_SEEN_KEY)).toBeTruthy(); + await waitFor(() => { + expect(document.activeElement).toBe(document.querySelector('[data-tour="current-debate"] button')); + }); + warn.mockRestore(); + }); +});