From 748f3c9e12465f0e5e5acb4ebc4a0d45a64c3b11 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 20:27:44 +0000 Subject: [PATCH 1/6] One-time DebateOwl welcome on the first app open The owl says hello and introduces DebateOwl as the new look of DebateLab. Two more steps follow: what changed (the look, the owl guide, everything still where it was) and the slow move to the DebateOwl name. The hello takes about 3 seconds. Under reduced motion it shows its last frame and starts no timers. It opens once per browser inside the signed-in app, after the layout loads, and runs before the onboarding tour. Closing it any way (Skip, Got it, Esc, the X, the backdrop) writes debateowl_intro_seen_v1 to localStorage. There is no profile column for UI flags, so localStorage is the only store, as with the tour. Playwright (VITE_E2E) and the verify stack (VITE_LOCAL_PREVIEW) never open it. ?intro=show forces it and ?intro=skip suppresses it. The production canary seeds the flag. The owl's personal name is the empty owlName key; while it stays empty the owl says "Hi there!". Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/SKILL.md | 2 +- docs/architecture.md | 2 +- docs/testing.md | 2 + e2e/production/canary.spec.ts | 1 + src/App.tsx | 36 +++- .../features/onboarding/DebateOwlIntro.tsx | 198 ++++++++++++++++++ src/lib/debateowl-intro.ts | 53 +++++ src/locales/en.json | 20 ++ src/locales/es.json | 20 ++ src/locales/hu.json | 20 ++ src/test/onboarding/debateowl-intro.test.tsx | 74 +++++++ 11 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 src/components/features/onboarding/DebateOwlIntro.tsx create mode 100644 src/lib/debateowl-intro.ts create mode 100644 src/test/onboarding/debateowl-intro.test.tsx diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index cc4190b81..520d5ac65 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 `Skip`, `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 661885c91..f2cbfd58e 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 seeds `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/src/App.tsx b/src/App.tsx index 4547c885c..cea12cd1f 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"; @@ -81,6 +82,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 @@ -274,6 +277,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; @@ -362,7 +396,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..ac66d37d4 --- /dev/null +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -0,0 +1,198 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { Check, Paintbrush } from 'lucide-react'; +import { Owl, OwlAvatar, type OwlPose } from '@/components/brand/Owl'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; + +const STEPS = 3; + +// The hello: the owl lands surprised, waves as it greets you, then points on to the next step. +// Each beat starts at its offset in ms; the last lands at 3.2 s. Reduced motion shows the last beat. +const HELLO_BEATS: { at: number; pose: OwlPose }[] = [ + { at: 0, pose: 'surprised' }, + { at: 900, pose: 'celebrating' }, + { at: 2000, pose: 'celebrating' }, + { at: 3200, pose: 'point' }, +]; +const LAST_BEAT = HELLO_BEATS.length - 1; + +const STEP_TRANSITION = { duration: 0.22, ease: [0.22, 1, 0.36, 1] } as const; + +/** The one-time DebateOwl welcome. Every way out (Skip, Got it, Esc, the X, the backdrop) calls onClose. */ +export function DebateOwlIntro({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + const reduceMotion = useReducedMotion() === true; + const [step, setStep] = useState(0); + const [beat, setBeat] = useState(() => (reduceMotion ? LAST_BEAT : 0)); + const nextButton = useRef(null); + + useEffect(() => { + if (reduceMotion) { + setBeat(LAST_BEAT); + return; + } + const timers = HELLO_BEATS.slice(1).map(({ at }, index) => window.setTimeout(() => setBeat(index + 1), at)); + return () => timers.forEach((timer) => window.clearTimeout(timer)); + }, [reduceMotion]); + + // The owl's personal name is still being chosen; with no name it just says hello. + const owlName = t('debateOwlIntro.owlName').trim(); + const greeting = owlName ? t('debateOwlIntro.greetingNamed', { name: owlName }) : t('debateOwlIntro.greeting'); + const last = step === STEPS - 1; + + return ( + { if (!open) onClose(); }}> + { + event.preventDefault(); + nextButton.current?.focus(); + }} + > +
+ + + {step === 0 && ( +
+ + + + + +
+ + = 1}>{greeting} + + +
+ = 2}>{t('debateOwlIntro.hello')} + = 3} className="text-muted-foreground">{t('debateOwlIntro.whatChanged')} +
+
+
+
+ )} + + {step === 1 && ( +
+ {t('debateOwlIntro.changesTitle')} + {t('debateOwlIntro.changesTitle')} +
    + +
+
+ )} + + {step === 2 && ( +
+ + {t('debateOwlIntro.nameTitle')} + + {t('debateOwlIntro.nameBody')} + +
+ )} +
+
+
+ +
+ +
+ {!last && ( + + )} + +
+
+
+
+ ); +} + +/** A line of the owl's speech. It holds its space before it appears, so the bubble never jumps. */ +function Line({ shown, className, children }: { shown: boolean; className?: string; children: string }) { + return ( + + {children} + + ); +} + +function Change({ icon, title, description }: { icon: ReactNode; title: string; description: string }) { + return ( +
  • + {icon} +
    +

    {title}

    +

    {description}

    +
    +
  • + ); +} + +function Progress({ step, label }: { step: number; label: string }) { + return ( +
    + {label} + {Array.from({ length: STEPS }, (_, index) => ( +
    + ); +} diff --git a/src/lib/debateowl-intro.ts b/src/lib/debateowl-intro.ts new file mode 100644 index 000000000..71ebe0c86 --- /dev/null +++ b/src/lib/debateowl-intro.ts @@ -0,0 +1,53 @@ +/** + * 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'; + +/** `?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 bd263ca05..989c82798 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1488,6 +1488,26 @@ "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": { + "owlName": "", + "greeting": "Hi there!", + "greetingNamed": "Hi, I'm {{name}}!", + "hello": "This is DebateOwl, the new look of DebateLab.", + "whatChanged": "Here's what changed.", + "changesTitle": "Same app, new look", + "freshLookTitle": "A fresh look", + "freshLookDesc": "New colors, rounder letters and buttons that press down when you click them.", + "owlGuideTitle": "A friendlier owl guide", + "owlGuideDesc": "The owl keeps you company around the app, from the home screen to your history.", + "sameTitle": "Everything you use is still where it was", + "sameDesc": "Same tools, same menus, same steps.", + "nameTitle": "A new name, slowly", + "nameBody": "To help people find us on Google, we're slowly moving DebateLab to the name DebateOwl. Your account, cases and history stay exactly the same.", + "skip": "Skip", + "next": "Next", + "done": "Got it", + "progress": "Step {{step}} of {{steps}}" + }, "timer": { "title": "Debate Timer", "displayLabel": "Timer display", diff --git a/src/locales/es.json b/src/locales/es.json index 879bfb95e..643f71e39 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1488,6 +1488,26 @@ "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": { + "owlName": "", + "greeting": "¡Hola!", + "greetingNamed": "¡Hola, soy {{name}}!", + "hello": "Esto es DebateOwl, el nuevo aspecto de DebateLab.", + "whatChanged": "Te cuento qué ha cambiado.", + "changesTitle": "La misma app, con otro aspecto", + "freshLookTitle": "Un aspecto renovado", + "freshLookDesc": "Colores nuevos, letras más redondas y botones que se hunden al pulsarlos.", + "owlGuideTitle": "Un búho guía más cercano", + "owlGuideDesc": "El búho te acompaña por la app, desde el inicio hasta tu historial.", + "sameTitle": "Todo lo que usas sigue en su sitio", + "sameDesc": "Las mismas herramientas, los mismos menús, los mismos pasos.", + "nameTitle": "Un nombre nuevo, poco a poco", + "nameBody": "Para que la gente nos encuentre en Google, estamos cambiando poco a poco el nombre de DebateLab a DebateOwl. Tu cuenta, tus casos y tu historial siguen exactamente igual.", + "skip": "Saltar", + "next": "Siguiente", + "done": "Entendido", + "progress": "Paso {{step}} de {{steps}}" + }, "timer": { "title": "Cronómetro de debate", "displayLabel": "Pantalla del cronómetro", diff --git a/src/locales/hu.json b/src/locales/hu.json index 08953a64a..2ac83ca2e 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1488,6 +1488,26 @@ "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": { + "owlName": "", + "greeting": "Szia!", + "greetingNamed": "Szia, {{name}} vagyok!", + "hello": "Ez a DebateOwl, a DebateLab új külseje.", + "whatChanged": "Megmutatom, mi változott.", + "changesTitle": "Ugyanaz az alkalmazás, új külsővel", + "freshLookTitle": "Friss külső", + "freshLookDesc": "Új színek, kerekebb betűk és gombok, amelyek kattintásra lenyomódnak.", + "owlGuideTitle": "Barátságosabb bagolykísérő", + "owlGuideDesc": "A bagoly végigkísér az alkalmazásban, a kezdőlaptól az előzményekig.", + "sameTitle": "Minden ott van, ahol eddig", + "sameDesc": "Ugyanazok az eszközök, menük és lépések.", + "nameTitle": "Új név, fokozatosan", + "nameBody": "Hogy könnyebben megtaláljanak minket a Google-ben, a DebateLab nevét fokozatosan DebateOwlra cseréljük. A fiókod, az eseteid és az előzményeid pontosan ugyanazok maradnak.", + "skip": "Kihagyás", + "next": "Következő", + "done": "Értem", + "progress": "{{step}}/{{steps}}. lépés" + }, "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..a3b70946c --- /dev/null +++ b/src/test/onboarding/debateowl-intro.test.tsx @@ -0,0 +1,74 @@ +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() { + const storage = memoryStorage(); + render( markIntroSeen(storage)} />); + return storage; + } + + it('Skip marks it seen', () => { + const storage = renderIntro(); + // owlName is empty until the owl has a name: the greeting must not show a key or a blank. + expect(screen.getByRole('dialog', { name: 'Hi there!' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + 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('the last step closes with Got it and marks it seen', () => { + const storage = renderIntro(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Got it' })); + expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(false); + }); +}); From 5e5bc444e49ccc69594c6407097f061796d4e1f6 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 20:38:31 +0000 Subject: [PATCH 2/6] Welcome and rename notice in one dialog with one dismiss The owl's introduction and the rename notice now share one dialog, so nobody sees two popups. Over about 6.6 seconds the owl says its lines into a speech bubble, one beat per pose: it introduces itself as DebateOwl, says DebateLab has a new look, bobs over thinking dots as it promises company while things load, and gives a thumbs-up beside the mint check for the same tools as before. The notice ("DebateLab is slowly becoming DebateOwl") sits under the bubble from the start. Got it is the one way out; Esc and the backdrop still close it and mark it seen. Skip animation jumps to the last frame without closing. Reduced motion shows that frame at once. The owl's name moves to OWL_NAME in src/lib/debateowl-intro.ts, a constant rather than a locale key, since it is the same in every language. While it is empty the owl calls itself DebateOwl. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/SKILL.md | 2 +- .../features/onboarding/DebateOwlIntro.tsx | 252 +++++++----------- src/lib/debateowl-intro.ts | 6 + src/locales/en.json | 27 +- src/locales/es.json | 27 +- src/locales/hu.json | 27 +- src/test/onboarding/debateowl-intro.test.tsx | 29 +- 7 files changed, 151 insertions(+), 219 deletions(-) diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index 520d5ac65..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//`. 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 `Skip`, `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. 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/src/components/features/onboarding/DebateOwlIntro.tsx b/src/components/features/onboarding/DebateOwlIntro.tsx index ac66d37d4..8aac675c3 100644 --- a/src/components/features/onboarding/DebateOwlIntro.tsx +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -1,198 +1,148 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { motion, useReducedMotion } from 'framer-motion'; import { Check, Paintbrush } from 'lucide-react'; -import { Owl, OwlAvatar, type OwlPose } from '@/components/brand/Owl'; -import { Button } from '@/components/ui/button'; +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'; -const STEPS = 3; - -// The hello: the owl lands surprised, waves as it greets you, then points on to the next step. -// Each beat starts at its offset in ms; the last lands at 3.2 s. Reduced motion shows the last beat. -const HELLO_BEATS: { at: number; pose: OwlPose }[] = [ +// 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: 900, pose: 'celebrating' }, - { at: 2000, pose: 'celebrating' }, - { at: 3200, pose: 'point' }, + { at: 700, pose: 'celebrating' }, + { at: 2300, pose: 'idea' }, + { at: 3900, pose: 'thinking' }, + { at: 5600, pose: 'coach' }, ]; -const LAST_BEAT = HELLO_BEATS.length - 1; +const LAST_BEAT = BEATS.length - 1; +const SETTLED_AT = 6600; -const STEP_TRANSITION = { duration: 0.22, ease: [0.22, 1, 0.36, 1] } as const; +const EASE_OUT = [0.22, 1, 0.36, 1] as const; -/** The one-time DebateOwl welcome. Every way out (Skip, Got it, Esc, the X, the backdrop) calls onClose. */ -export function DebateOwlIntro({ onClose }: { onClose: () => void }) { +/** + * 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 [step, setStep] = useState(0); const [beat, setBeat] = useState(() => (reduceMotion ? LAST_BEAT : 0)); - const nextButton = useRef(null); + const [settled, setSettled] = useState(reduceMotion); + const doneButton = useRef(null); useEffect(() => { - if (reduceMotion) { + if (reduceMotion || settled) { setBeat(LAST_BEAT); + setSettled(true); return; } - const timers = HELLO_BEATS.slice(1).map(({ at }, index) => window.setTimeout(() => setBeat(index + 1), at)); + 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]); + }, [reduceMotion, settled]); - // The owl's personal name is still being chosen; with no name it just says hello. - const owlName = t('debateOwlIntro.owlName').trim(); - const greeting = owlName ? t('debateOwlIntro.greetingNamed', { name: owlName }) : t('debateOwlIntro.greeting'); - const last = step === STEPS - 1; + // 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(); - nextButton.current?.focus(); + doneButton.current?.focus(); }} > -
    - - + + - {step === 0 && ( -
    - - - - - -
    - - = 1}>{greeting} - - -
    - = 2}>{t('debateOwlIntro.hello')} - = 3} className="text-muted-foreground">{t('debateOwlIntro.whatChanged')} -
    -
    -
    -
    - )} + +
    +
    - {step === 1 && ( -
    - {t('debateOwlIntro.changesTitle')} - {t('debateOwlIntro.changesTitle')} -
      - -
    -
    - )} +
    +
      + = 1} className="font-display text-xl font-[600] leading-7 text-heading"> + {greeting} + + = 2} icon={}> + {t('debateOwlIntro.newLook')} + + = 3} icon={}> + {t('debateOwlIntro.company')} + + = 4} icon={}> + {t('debateOwlIntro.sameTools')} + +
    +
    +
    - {step === 2 && ( -
    - - {t('debateOwlIntro.nameTitle')} - - {t('debateOwlIntro.nameBody')} - -
    - )} - - +
    + {t('debateOwlIntro.noticeTitle')} + + {t('debateOwlIntro.noticeBody')} +
    -
    - -
    - {!last && ( - - )} - -
    + )} +
    ); } -/** A line of the owl's speech. It holds its space before it appears, so the bubble never jumps. */ -function Line({ shown, className, children }: { shown: boolean; className?: string; children: string }) { +/** 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 ( - - {children} - - ); -} - -function Change({ icon, title, description }: { icon: ReactNode; title: string; description: string }) { - return ( -
  • - {icon} -
    -

    {title}

    -

    {description}

    -
    -
  • - ); -} - -function Progress({ step, label }: { step: number; label: string }) { - return ( -
    - {label} - {Array.from({ length: STEPS }, (_, index) => ( -
    + {icon && } + {children} + ); } diff --git a/src/lib/debateowl-intro.ts b/src/lib/debateowl-intro.ts index 71ebe0c86..97554b4f4 100644 --- a/src/lib/debateowl-intro.ts +++ b/src/lib/debateowl-intro.ts @@ -4,6 +4,12 @@ */ export const INTRO_SEEN_KEY = 'debateowl_intro_seen_v1'; +/** + * The owl's personal name, the same in every language. Empty until one is picked from the + * shortlist; while empty the owl introduces itself as DebateOwl. + */ +export const OWL_NAME = ''; + /** `?intro=show` opens the welcome even when seen or under test; `?intro=skip` keeps it closed. */ export const INTRO_QUERY_PARAM = 'intro'; diff --git a/src/locales/en.json b/src/locales/en.json index 989c82798..be2846b9e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1489,24 +1489,15 @@ "debateCurrentDesc": "Everything in DebateLab belongs to your Current Debate: its motion, its sources, and your results. Open or change it here." }, "debateOwlIntro": { - "owlName": "", - "greeting": "Hi there!", - "greetingNamed": "Hi, I'm {{name}}!", - "hello": "This is DebateOwl, the new look of DebateLab.", - "whatChanged": "Here's what changed.", - "changesTitle": "Same app, new look", - "freshLookTitle": "A fresh look", - "freshLookDesc": "New colors, rounder letters and buttons that press down when you click them.", - "owlGuideTitle": "A friendlier owl guide", - "owlGuideDesc": "The owl keeps you company around the app, from the home screen to your history.", - "sameTitle": "Everything you use is still where it was", - "sameDesc": "Same tools, same menus, same steps.", - "nameTitle": "A new name, slowly", - "nameBody": "To help people find us on Google, we're slowly moving DebateLab to the name DebateOwl. Your account, cases and history stay exactly the same.", - "skip": "Skip", - "next": "Next", - "done": "Got it", - "progress": "Step {{step}} of {{steps}}" + "greeting": "Hi, I'm DebateOwl!", + "greetingNamed": "Hi, I'm {{name}} from DebateOwl!", + "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", diff --git a/src/locales/es.json b/src/locales/es.json index 643f71e39..38e05f435 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1489,24 +1489,15 @@ "debateCurrentDesc": "Todo en DebateLab pertenece a tu debate actual: su moción, sus fuentes y tus resultados. Ábrelo o cámbialo aquí." }, "debateOwlIntro": { - "owlName": "", - "greeting": "¡Hola!", - "greetingNamed": "¡Hola, soy {{name}}!", - "hello": "Esto es DebateOwl, el nuevo aspecto de DebateLab.", - "whatChanged": "Te cuento qué ha cambiado.", - "changesTitle": "La misma app, con otro aspecto", - "freshLookTitle": "Un aspecto renovado", - "freshLookDesc": "Colores nuevos, letras más redondas y botones que se hunden al pulsarlos.", - "owlGuideTitle": "Un búho guía más cercano", - "owlGuideDesc": "El búho te acompaña por la app, desde el inicio hasta tu historial.", - "sameTitle": "Todo lo que usas sigue en su sitio", - "sameDesc": "Las mismas herramientas, los mismos menús, los mismos pasos.", - "nameTitle": "Un nombre nuevo, poco a poco", - "nameBody": "Para que la gente nos encuentre en Google, estamos cambiando poco a poco el nombre de DebateLab a DebateOwl. Tu cuenta, tus casos y tu historial siguen exactamente igual.", - "skip": "Saltar", - "next": "Siguiente", - "done": "Entendido", - "progress": "Paso {{step}} de {{steps}}" + "greeting": "¡Hola, soy DebateOwl!", + "greetingNamed": "¡Hola, soy {{name}}, 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", diff --git a/src/locales/hu.json b/src/locales/hu.json index 2ac83ca2e..b86e61238 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1489,24 +1489,15 @@ "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": { - "owlName": "", - "greeting": "Szia!", - "greetingNamed": "Szia, {{name}} vagyok!", - "hello": "Ez a DebateOwl, a DebateLab új külseje.", - "whatChanged": "Megmutatom, mi változott.", - "changesTitle": "Ugyanaz az alkalmazás, új külsővel", - "freshLookTitle": "Friss külső", - "freshLookDesc": "Új színek, kerekebb betűk és gombok, amelyek kattintásra lenyomódnak.", - "owlGuideTitle": "Barátságosabb bagolykísérő", - "owlGuideDesc": "A bagoly végigkísér az alkalmazásban, a kezdőlaptól az előzményekig.", - "sameTitle": "Minden ott van, ahol eddig", - "sameDesc": "Ugyanazok az eszközök, menük és lépések.", - "nameTitle": "Új név, fokozatosan", - "nameBody": "Hogy könnyebben megtaláljanak minket a Google-ben, a DebateLab nevét fokozatosan DebateOwlra cseréljük. A fiókod, az eseteid és az előzményeid pontosan ugyanazok maradnak.", - "skip": "Kihagyás", - "next": "Következő", - "done": "Értem", - "progress": "{{step}}/{{steps}}. lépés" + "greeting": "Szia, én vagyok a DebateOwl!", + "greetingNamed": "Szia, {{name}} vagyok a DebateOwltól!", + "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ő", diff --git a/src/test/onboarding/debateowl-intro.test.tsx b/src/test/onboarding/debateowl-intro.test.tsx index a3b70946c..f55904ad1 100644 --- a/src/test/onboarding/debateowl-intro.test.tsx +++ b/src/test/onboarding/debateowl-intro.test.tsx @@ -43,17 +43,19 @@ describe('shouldShowIntro', () => { }); describe('DebateOwlIntro', () => { - function renderIntro() { + function renderIntro(owlName?: string) { const storage = memoryStorage(); - render( markIntroSeen(storage)} />); + render( markIntroSeen(storage)} owlName={owlName} />); return storage; } - it('Skip marks it seen', () => { + it('Got it marks it seen; Skip animation only skips ahead', () => { const storage = renderIntro(); - // owlName is empty until the owl has a name: the greeting must not show a key or a blank. - expect(screen.getByRole('dialog', { name: 'Hi there!' })).toBeTruthy(); - fireEvent.click(screen.getByRole('button', { name: 'Skip' })); + 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); }); @@ -63,12 +65,13 @@ describe('DebateOwlIntro', () => { expect(shouldShowIntro({ search: '', storage, env: appEnv })).toBe(false); }); - it('the last step closes with Got it and marks it seen', () => { - const storage = renderIntro(); - fireEvent.click(screen.getByRole('button', { name: 'Next' })); - fireEvent.click(screen.getByRole('button', { name: 'Next' })); - 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('introduces the owl as DebateOwl until it has a name', () => { + renderIntro(''); + expect(screen.getByText("Hi, I'm DebateOwl!")).toBeTruthy(); + }); + + it('uses the owl name once one is set', () => { + renderIntro('Hootie'); + expect(screen.getByText("Hi, I'm Hootie from DebateOwl!")).toBeTruthy(); }); }); From 8917c296b3b907f3fb658f7d0b3f3a411a35af35 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 20:39:59 +0000 Subject: [PATCH 3/6] Keep the owl nameless until a name is picked No name has been chosen, so the owl introduces itself as DebateOwl in every language and OWL_NAME stays empty. The test for a set name uses an obvious placeholder instead of a made-up name. Co-Authored-By: Claude Opus 5.5 --- src/lib/debateowl-intro.ts | 5 +++-- src/test/onboarding/debateowl-intro.test.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/debateowl-intro.ts b/src/lib/debateowl-intro.ts index 97554b4f4..cce9bb7bc 100644 --- a/src/lib/debateowl-intro.ts +++ b/src/lib/debateowl-intro.ts @@ -5,8 +5,9 @@ export const INTRO_SEEN_KEY = 'debateowl_intro_seen_v1'; /** - * The owl's personal name, the same in every language. Empty until one is picked from the - * shortlist; while empty the owl introduces itself as DebateOwl. + * The owl's personal name, the same in every language. No name has been picked, so it stays + * empty and the owl speaks as DebateOwl; the copy reads complete without it. Drop the chosen + * name in here and the greeting becomes debateOwlIntro.greetingNamed. */ export const OWL_NAME = ''; diff --git a/src/test/onboarding/debateowl-intro.test.tsx b/src/test/onboarding/debateowl-intro.test.tsx index f55904ad1..40b754538 100644 --- a/src/test/onboarding/debateowl-intro.test.tsx +++ b/src/test/onboarding/debateowl-intro.test.tsx @@ -70,8 +70,8 @@ describe('DebateOwlIntro', () => { expect(screen.getByText("Hi, I'm DebateOwl!")).toBeTruthy(); }); - it('uses the owl name once one is set', () => { - renderIntro('Hootie'); - expect(screen.getByText("Hi, I'm Hootie from DebateOwl!")).toBeTruthy(); + it('fits the owl name in once one is picked', () => { + renderIntro('OWL_NAME_PLACEHOLDER'); + expect(screen.getByText("Hi, I'm OWL_NAME_PLACEHOLDER from DebateOwl!")).toBeTruthy(); }); }); From e21d8ee27516d22b452d14261bb517bbe01e603b Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 20:41:19 +0000 Subject: [PATCH 4/6] The owl is called Ollie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWL_NAME is now "Ollie", still the one place the name lives. The owl introduces itself as "Hi, I'm Ollie, the DebateOwl owl!", in Spanish "¡Hola, soy Ollie, el búho de DebateOwl!" and in Hungarian "Szia, Ollie vagyok, a DebateOwl baglya!". The product name stays DebateOwl. Co-Authored-By: Claude Opus 5.5 --- src/lib/debateowl-intro.ts | 7 +++---- src/locales/en.json | 2 +- src/locales/es.json | 2 +- src/locales/hu.json | 2 +- src/test/onboarding/debateowl-intro.test.tsx | 6 +++--- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/lib/debateowl-intro.ts b/src/lib/debateowl-intro.ts index cce9bb7bc..0f406aac0 100644 --- a/src/lib/debateowl-intro.ts +++ b/src/lib/debateowl-intro.ts @@ -5,11 +5,10 @@ export const INTRO_SEEN_KEY = 'debateowl_intro_seen_v1'; /** - * The owl's personal name, the same in every language. No name has been picked, so it stays - * empty and the owl speaks as DebateOwl; the copy reads complete without it. Drop the chosen - * name in here and the greeting becomes debateOwlIntro.greetingNamed. + * 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 = ''; +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'; diff --git a/src/locales/en.json b/src/locales/en.json index 3bb0a7a22..ebb083b2d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1530,7 +1530,7 @@ }, "debateOwlIntro": { "greeting": "Hi, I'm DebateOwl!", - "greetingNamed": "Hi, I'm {{name}} from 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.", diff --git a/src/locales/es.json b/src/locales/es.json index 26644aea1..a961cfac1 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1530,7 +1530,7 @@ }, "debateOwlIntro": { "greeting": "¡Hola, soy DebateOwl!", - "greetingNamed": "¡Hola, soy {{name}}, de 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.", diff --git a/src/locales/hu.json b/src/locales/hu.json index a6bb02033..fb6c31df8 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1530,7 +1530,7 @@ }, "debateOwlIntro": { "greeting": "Szia, én vagyok a DebateOwl!", - "greetingNamed": "Szia, {{name}} vagyok a DebateOwltól!", + "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.", diff --git a/src/test/onboarding/debateowl-intro.test.tsx b/src/test/onboarding/debateowl-intro.test.tsx index 40b754538..29b92b20a 100644 --- a/src/test/onboarding/debateowl-intro.test.tsx +++ b/src/test/onboarding/debateowl-intro.test.tsx @@ -70,8 +70,8 @@ describe('DebateOwlIntro', () => { expect(screen.getByText("Hi, I'm DebateOwl!")).toBeTruthy(); }); - it('fits the owl name in once one is picked', () => { - renderIntro('OWL_NAME_PLACEHOLDER'); - expect(screen.getByText("Hi, I'm OWL_NAME_PLACEHOLDER from DebateOwl!")).toBeTruthy(); + it('Ollie introduces itself by name', () => { + renderIntro(); + expect(screen.getByText("Hi, I'm Ollie, the DebateOwl owl!")).toBeTruthy(); }); }); From 4778c6694b9a0dbbb4ac73d0de97324d2c64a6cf Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 20:46:14 +0000 Subject: [PATCH 5/6] Service worker offline test marks the welcome seen test:sw drives a production build, where the one-time DebateOwl welcome opens and covered the Tools page the test clicks. It now seeds debateowl_intro_seen_v1 next to the tour flag, as the canary does. Co-Authored-By: Claude Opus 5.5 --- docs/testing.md | 2 +- scripts/test-service-worker-offline.mjs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/testing.md b/docs/testing.md index 76594b848..9217469d6 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 seeds `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. 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/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; }; From c1fe62fe813f87897c9e365bae7f523242670f3b Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 26 Oct 2026 20:54:27 +0000 Subject: [PATCH 6/6] Ollie's greeting wraps evenly on phones At 390 px the greeting left "owl!" alone on its second line. It now balances across both lines. Co-Authored-By: Claude Opus 5.5 --- src/components/features/onboarding/DebateOwlIntro.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/features/onboarding/DebateOwlIntro.tsx b/src/components/features/onboarding/DebateOwlIntro.tsx index 8aac675c3..d96467540 100644 --- a/src/components/features/onboarding/DebateOwlIntro.tsx +++ b/src/components/features/onboarding/DebateOwlIntro.tsx @@ -91,7 +91,7 @@ export function DebateOwlIntro({ onClose, owlName = OWL_NAME }: { onClose: () => )} >
      - = 1} className="font-display text-xl font-[600] leading-7 text-heading"> + = 1} className="font-display text-xl font-[600] leading-7 text-heading text-balance"> {greeting} = 2} icon={}>