diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index cc4190b81..4858f5d08 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//`. 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. 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/docs/architecture.md b/docs/architecture.md index 1abc4b228..27f4e4bb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. +- 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. - 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 ae1bfae5e..9217469d6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -129,6 +129,8 @@ 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. + `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. `e2e/first-run.spec.ts` drives the same first run against stubbed Supabase routes in English and Hungarian, so it proves the client flow only. diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts index ba53e0811..5d7d739a3 100644 --- a/e2e/production/canary.spec.ts +++ b/e2e/production/canary.spec.ts @@ -30,6 +30,7 @@ async function signInAsCanary(page: Page, { aiRequest }: { aiRequest: boolean }) await page.addInitScript((session) => { localStorage.setItem("debate-auth-production", JSON.stringify(session)); localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0 })); + localStorage.setItem("debateowl_intro_seen_v1", "canary"); }, data.session); } diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index 261198ea9..d540f663a 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -150,6 +150,8 @@ async function main() { state: { hasCompletedOnboarding: true, isTourRunning: false }, version: 0, })); + // A production build opens the one-time DebateOwl welcome; this test is not about it. + localStorage.setItem('debateowl_intro_seen_v1', 'test-sw'); }, stubSession()); return nextContext; }; diff --git a/src/App.tsx b/src/App.tsx index 0b152cd8e..1c3e0dd2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { AccountQueryCache } from "@/components/auth/AccountQueryCache"; 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 { OfflineIndicator } from "@/components/OfflineIndicator"; import { StagingBanner } from "@/components/StagingBanner"; import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability"; @@ -88,6 +89,8 @@ const AppLayout = lazy(() => { }); 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 }))); // TanStack Query refetches after an `offline` then `online` pair, but a page // opened offline never sees `offline`. Retry whatever failed once the @@ -281,6 +284,37 @@ 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. + */ +function AppWelcome() { + const [introOpen, setIntroOpen] = useState(() => shouldShowIntro({ + search: window.location.search, + storage: browserStorage(), + env: import.meta.env, + })); + const [layoutReady, setLayoutReady] = useState(false); + + useEffect(() => { + if (!introOpen) return; + let live = true; + appLayoutLoader().then(() => { if (live) setLayoutReady(true); }, (): void => undefined); + return () => { live = false; }; + }, [introOpen]); + + if (!introOpen) return ; + if (!layoutReady) return null; + return ( + + { + markIntroSeen(browserStorage()); + setIntroOpen(false); + }} /> + + ); +} + function OptionalOnboardingTour() { const location = useLocation(); const userId = useAuth().user?.id; @@ -370,7 +404,7 @@ function AppRoutes() { element={ }> - + }> diff --git a/src/components/features/onboarding/DebateOwlIntro.tsx b/src/components/features/onboarding/DebateOwlIntro.tsx new file mode 100644 index 000000000..d96467540 --- /dev/null +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -0,0 +1,148 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { motion, useReducedMotion } from 'framer-motion'; +import { Check, Paintbrush } from 'lucide-react'; +import { Owl, type OwlPose } from '@/components/brand/Owl'; +import { Button, ThinkingDots } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; +import { OWL_NAME } from '@/lib/debateowl-intro'; +import { cn } from '@/lib/utils'; + +// The owl's lines. Beat n shows lines 1..n; each beat starts at `at` ms and sets the owl's pose. +// Beat 0 is the owl landing, surprised to see you. The last beat lands at 5.6 s and the owl +// settles by 6.6 s. Reduced motion, and Skip animation, show the last beat straight away. +const BEATS: { at: number; pose: OwlPose }[] = [ + { at: 0, pose: 'surprised' }, + { at: 700, pose: 'celebrating' }, + { at: 2300, pose: 'idea' }, + { at: 3900, pose: 'thinking' }, + { at: 5600, pose: 'coach' }, +]; +const LAST_BEAT = BEATS.length - 1; +const SETTLED_AT = 6600; + +const EASE_OUT = [0.22, 1, 0.36, 1] as const; + +/** + * The one-time DebateOwl welcome: the owl introduces itself line by line beside the rename + * notice, in one dialog with one way out. Got it, Esc and the backdrop all call onClose. + */ +export function DebateOwlIntro({ onClose, owlName = OWL_NAME }: { onClose: () => void; owlName?: string }) { + const { t } = useTranslation(); + const reduceMotion = useReducedMotion() === true; + const [beat, setBeat] = useState(() => (reduceMotion ? LAST_BEAT : 0)); + const [settled, setSettled] = useState(reduceMotion); + const doneButton = useRef(null); + + useEffect(() => { + if (reduceMotion || settled) { + setBeat(LAST_BEAT); + setSettled(true); + return; + } + const timers = [ + ...BEATS.slice(1).map(({ at }, index) => window.setTimeout(() => setBeat(index + 1), at)), + window.setTimeout(() => setSettled(true), SETTLED_AT), + ]; + return () => timers.forEach((timer) => window.clearTimeout(timer)); + }, [reduceMotion, settled]); + + // The owl's personal name is still being chosen; until then it introduces itself as DebateOwl. + const name = owlName.trim(); + const greeting = name ? t('debateOwlIntro.greetingNamed', { name }) : t('debateOwlIntro.greeting'); + const pose = BEATS[beat].pose; + + return ( + { if (!open) onClose(); }}> + { + event.preventDefault(); + doneButton.current?.focus(); + }} + > +
+ + + + + + +
+
    + = 1} className="font-display text-xl font-[600] leading-7 text-heading text-balance"> + {greeting} + + = 2} icon={}> + {t('debateOwlIntro.newLook')} + + = 3} icon={}> + {t('debateOwlIntro.company')} + + = 4} icon={}> + {t('debateOwlIntro.sameTools')} + +
+
+
+ +
+ {t('debateOwlIntro.noticeTitle')} + + {t('debateOwlIntro.noticeBody')} + +
+ +
+ {settled ? : ( + + )} + +
+
+
+ ); +} + +/** One of the owl's lines. It holds its space before it appears, so the bubble never jumps. */ +function Line({ shown, icon, className, children }: { + shown: boolean; + icon?: ReactNode; + className?: string; + children: string; +}) { + return ( + + {icon && } + {children} + + ); +} diff --git a/src/lib/debateowl-intro.ts b/src/lib/debateowl-intro.ts new file mode 100644 index 000000000..0f406aac0 --- /dev/null +++ b/src/lib/debateowl-intro.ts @@ -0,0 +1,59 @@ +/** + * The one-time DebateOwl welcome: the owl says hello, says what changed (the look only) and that + * DebateLab is slowly becoming DebateOwl. It opens once per browser, inside the signed-in app. + */ +export const INTRO_SEEN_KEY = 'debateowl_intro_seen_v1'; + +/** + * The owl's personal name, the same in every language, used where the owl introduces itself + * (debateOwlIntro.greetingNamed). Change it here only. Empty, the owl speaks as DebateOwl. + */ +export const OWL_NAME = 'Ollie'; + +/** `?intro=show` opens the welcome even when seen or under test; `?intro=skip` keeps it closed. */ +export const INTRO_QUERY_PARAM = 'intro'; + +type IntroStorage = Pick; + +interface IntroEnvironment { + VITE_E2E?: string; + VITE_LOCAL_PREVIEW?: string; +} + +/** + * Whether this page load opens the welcome. Playwright (VITE_E2E) and the local verify stack + * (VITE_LOCAL_PREVIEW) never see it unless the URL asks. Any stored value counts as seen, and + * storage that can't be read keeps it closed, since a flag that can't be saved would reopen it + * on every load. + */ +export function shouldShowIntro({ search, storage, env }: { + search: string; + storage: IntroStorage | undefined; + env: IntroEnvironment; +}): boolean { + const requested = new URLSearchParams(search).get(INTRO_QUERY_PARAM); + if (requested === 'show') return true; + if (requested === 'skip') return false; + if (env.VITE_E2E === '1' || env.VITE_LOCAL_PREVIEW === 'true') return false; + try { + return storage !== undefined && storage.getItem(INTRO_SEEN_KEY) === null; + } catch { + return false; + } +} + +export function markIntroSeen(storage: IntroStorage | undefined, now = new Date()): void { + try { + storage?.setItem(INTRO_SEEN_KEY, now.toISOString()); + } catch { + // Private mode or a full quota: the welcome closes for this load either way. + } +} + +export function browserStorage(): IntroStorage | undefined { + try { + return typeof window === 'undefined' ? undefined : window.localStorage; + } catch { + return undefined; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index cef68dcf5..ebb083b2d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1528,6 +1528,17 @@ "debateFirstDesc": "Everything in DebateLab belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here.", "debateCurrentDesc": "Everything in DebateLab belongs to your Current Debate: its motion, its sources, and your results. Open or change it here." }, + "debateOwlIntro": { + "greeting": "Hi, I'm DebateOwl!", + "greetingNamed": "Hi, I'm {{name}}, the DebateOwl owl!", + "newLook": "DebateLab has a new look.", + "company": "I'll keep you company while things load.", + "sameTools": "Same tools as before, in the same spots.", + "noticeTitle": "DebateLab is slowly becoming DebateOwl", + "noticeBody": "Same app, same account, same saved work, with a new look and name. We're moving to help more people find us on Google.", + "skipAnimation": "Skip animation", + "done": "Got it" + }, "timer": { "title": "Debate Timer", "displayLabel": "Timer display", diff --git a/src/locales/es.json b/src/locales/es.json index 019fd35e2..a961cfac1 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1528,6 +1528,17 @@ "debateFirstDesc": "Todo en DebateLab pertenece a un debate: la moción que preparas, sus fuentes y tus resultados. Inicia aquí tu primer debate.", "debateCurrentDesc": "Todo en DebateLab pertenece a tu debate actual: su moción, sus fuentes y tus resultados. Ábrelo o cámbialo aquí." }, + "debateOwlIntro": { + "greeting": "¡Hola, soy DebateOwl!", + "greetingNamed": "¡Hola, soy {{name}}, el búho de DebateOwl!", + "newLook": "DebateLab tiene un aspecto nuevo.", + "company": "Te haré compañía mientras algo carga.", + "sameTools": "Las mismas herramientas de siempre, en su sitio.", + "noticeTitle": "DebateLab se está convirtiendo poco a poco en DebateOwl", + "noticeBody": "La misma app, la misma cuenta y el mismo trabajo guardado, con un aspecto y un nombre nuevos. Cambiamos para que más gente nos encuentre en Google.", + "skipAnimation": "Saltar animación", + "done": "Entendido" + }, "timer": { "title": "Cronómetro de debate", "displayLabel": "Pantalla del cronómetro", diff --git a/src/locales/hu.json b/src/locales/hu.json index 2b1ee3e58..fb6c31df8 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1528,6 +1528,17 @@ "debateFirstDesc": "A DebateLabban minden egy vitához tartozik: a tétel, amelyre készülsz, a forrásai és az eredményeid. Itt indíthatod el az első vitádat.", "debateCurrentDesc": "A DebateLabban minden az aktuális vitádhoz tartozik: a tételéhez, a forrásaihoz és az eredményeidhez. Itt nyithatod meg vagy cserélheted le." }, + "debateOwlIntro": { + "greeting": "Szia, én vagyok a DebateOwl!", + "greetingNamed": "Szia, {{name}} vagyok, a DebateOwl baglya!", + "newLook": "A DebateLab új külsőt kapott.", + "company": "Veled leszek, amíg valami betöltődik.", + "sameTools": "Ugyanazok az eszközök, ugyanott, ahol eddig.", + "noticeTitle": "A DebateLabből lassan DebateOwl lesz", + "noticeBody": "Ugyanaz az alkalmazás, ugyanaz a fiók, ugyanazok a mentett munkák, csak új külsővel és új névvel. Azért váltunk, hogy többen megtaláljanak minket a Google-ben.", + "skipAnimation": "Animáció kihagyása", + "done": "Értem" + }, "timer": { "title": "Vitaidőmérő", "displayLabel": "Időmérő-kijelző", diff --git a/src/test/onboarding/debateowl-intro.test.tsx b/src/test/onboarding/debateowl-intro.test.tsx new file mode 100644 index 000000000..29b92b20a --- /dev/null +++ b/src/test/onboarding/debateowl-intro.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { DebateOwlIntro } from '@/components/features/onboarding/DebateOwlIntro'; +import { INTRO_SEEN_KEY, markIntroSeen, shouldShowIntro } from '@/lib/debateowl-intro'; + +function memoryStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; +} + +const appEnv = {}; + +describe('shouldShowIntro', () => { + it('shows once: never seen opens it, a stored flag of any value keeps it closed', () => { + const storage = memoryStorage(); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(true); + markIntroSeen(storage); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(false); + expect(shouldShowIntro({ search: '', storage: memoryStorage({ [INTRO_SEEN_KEY]: '1' }), env: appEnv })).toBe(false); + }); + + it('stays closed under Playwright and the local verify stack unless the URL asks for it', () => { + const storage = memoryStorage(); + expect(shouldShowIntro({ search: '', storage, env: { VITE_E2E: '1' } })).toBe(false); + expect(shouldShowIntro({ search: '', storage, env: { VITE_LOCAL_PREVIEW: 'true' } })).toBe(false); + expect(shouldShowIntro({ search: '?intro=show', storage, env: { VITE_E2E: '1' } })).toBe(true); + }); + + it('follows ?intro=show and ?intro=skip over the stored flag', () => { + expect(shouldShowIntro({ search: '?intro=skip', storage: memoryStorage(), env: appEnv })).toBe(false); + expect(shouldShowIntro({ search: '?intro=show', storage: memoryStorage({ [INTRO_SEEN_KEY]: 'x' }), env: appEnv })).toBe(true); + }); + + it('stays closed when storage cannot be read, since the flag could not be saved either', () => { + const blocked = { getItem: () => { throw new Error('denied'); }, setItem: () => { throw new Error('denied'); } }; + expect(shouldShowIntro({ search: '', storage: blocked, env: appEnv })).toBe(false); + expect(shouldShowIntro({ search: '', storage: undefined, env: appEnv })).toBe(false); + expect(() => markIntroSeen(blocked)).not.toThrow(); + }); +}); + +describe('DebateOwlIntro', () => { + function renderIntro(owlName?: string) { + const storage = memoryStorage(); + render( markIntroSeen(storage)} owlName={owlName} />); + return storage; + } + + it('Got it marks it seen; Skip animation only skips ahead', () => { + const storage = renderIntro(); + expect(screen.getByRole('dialog', { name: 'DebateLab is slowly becoming DebateOwl' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Skip animation' })); + expect(screen.queryByRole('button', { name: 'Skip animation' })).toBeNull(); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Got it' })); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(false); + }); + + it('Esc marks it seen', () => { + const storage = renderIntro(); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(false); + }); + + it('introduces the owl as DebateOwl until it has a name', () => { + renderIntro(''); + expect(screen.getByText("Hi, I'm DebateOwl!")).toBeTruthy(); + }); + + it('Ollie introduces itself by name', () => { + renderIntro(); + expect(screen.getByText("Hi, I'm Ollie, the DebateOwl owl!")).toBeTruthy(); + }); +});