Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Fake mode also runs `e2e/full-stack/`. Live mode runs the recipes, `e2e/live/`,

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

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

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

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

- Google OAuth, six-digit email OTP, and demo-code token-hash handoffs all finish as ordinary Supabase sessions and use the same store hydration path.
- Google and any emailed sign-in link return to `/auth/callback` (with `?next=/app/...` for a deep link; only `/app` paths are accepted). [`AuthProvider`](../src/lib/supabase/AuthProvider.tsx) exchanges the code there and keeps `loading` true until the exchange settles. The client sets `experimental.appendPkceFlowIdToRedirects`, so each sign-in keeps its own PKCE verifier and its redirect carries `sb_flow_id`; another tab's sign-in or a failed resend no longer breaks a pending link. An `error`/`error_code` from Auth, or a failed exchange, opens `/login` with a localized message. Links that still point at `/?code=` or `/app?code=` are moved onto the callback route before the first render ([`src/lib/auth-callback.ts`](../src/lib/auth-callback.ts)). Each project's Auth redirect allowlist needs `<site origin>/auth/callback**`; a redirect to the Site URL's own host passes without it.
- Sign-out ends only this device's session (`scope: 'local'`). After a sign-out, `/login` returns only the same account to the page it left; another account starts on `/app`. The onboarding tour is tracked per account on each browser.
- 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html lang="hu">`, public and authenticated page chrome, route titles, onboarding, saved-chat empty state, Library controls, Timer semantics, localized close labels, and runtime errors. It deliberately does not reject arbitrary English text because source titles, user content, model names, and citations may legitimately remain in their source language.

`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.
Expand Down
1 change: 1 addition & 0 deletions e2e/production/canary.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 2 additions & 0 deletions scripts/test-service-worker-offline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
36 changes: 35 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <OptionalOnboardingTour />;
if (!layoutReady) return null;
return (
<Suspense fallback={null}>
<DebateOwlIntro onClose={() => {
markIntroSeen(browserStorage());
setIntroOpen(false);
}} />
</Suspense>
);
}

function OptionalOnboardingTour() {
const location = useLocation();
const userId = useAuth().user?.id;
Expand Down Expand Up @@ -370,7 +404,7 @@ function AppRoutes() {
element={
<RequireAuth fallback={<PendingAppRoute />}>
<ToolRouteAnalytics />
<OptionalOnboardingTour />
<AppWelcome />
<Suspense fallback={<PendingAppRoute />}>
<AppLayout />
</Suspense>
Expand Down
148 changes: 148 additions & 0 deletions src/components/features/onboarding/DebateOwlIntro.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(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 (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent
data-testid="debateowl-intro"
// One way out: Got it. Esc and the backdrop still close it.
closeClassName="hidden"
className="max-h-[calc(100dvh-1.5rem)] max-w-[34rem] gap-0 overflow-y-auto p-0 lg:max-w-[38rem]"
onOpenAutoFocus={(event) => {
event.preventDefault();
doneButton.current?.focus();
}}
>
<div className="flex flex-col items-center gap-5 px-6 pt-7 sm:flex-row sm:items-center sm:gap-6 sm:px-8 sm:pt-8">
<motion.div
className="grid h-36 w-36 shrink-0 place-items-center rounded-full bg-plume-100 dark:bg-plume-900"
initial={reduceMotion ? false : { scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 18 }}
>
<motion.span
className={cn('-mb-2', pose === 'thinking' && 'owl-bob')}
initial={reduceMotion ? false : { y: 36, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 16, delay: 0.15 }}
>
<Owl pose={pose} size={136} priority />
</motion.span>
</motion.div>

<div
className={cn(
'relative w-full rounded-2xl border-2 border-border bg-accent px-5 py-4',
// The bubble's tail points up at the owl when stacked, left beside it.
'before:absolute before:h-4 before:w-4 before:rotate-45 before:border-border before:bg-accent',
'before:-top-[9px] before:left-1/2 before:-translate-x-1/2 before:border-l-2 before:border-t-2',
'sm:before:left-[-9px] sm:before:top-1/2 sm:before:-translate-y-1/2 sm:before:translate-x-0 sm:before:border-t-0 sm:before:border-b-2',
)}
>
<ul className="relative space-y-2">
<Line shown={beat >= 1} className="font-display text-xl font-[600] leading-7 text-heading text-balance">
{greeting}
</Line>
<Line shown={beat >= 2} icon={<Paintbrush className="h-4 w-4 text-primary" strokeWidth={2.5} />}>
{t('debateOwlIntro.newLook')}
</Line>
<Line shown={beat >= 3} icon={<ThinkingDots className="text-[0.7rem] text-primary" />}>
{t('debateOwlIntro.company')}
</Line>
<Line shown={beat >= 4} icon={<Check className="h-4 w-4 text-success" strokeWidth={3} />}>
{t('debateOwlIntro.sameTools')}
</Line>
</ul>
</div>
</div>

<div className="px-6 pb-6 pt-6 sm:px-8">
<DialogTitle className="text-[1.375rem] leading-7">{t('debateOwlIntro.noticeTitle')}</DialogTitle>
<DialogDescription className="mt-2 text-base leading-relaxed text-foreground">
{t('debateOwlIntro.noticeBody')}
</DialogDescription>
</div>

<div className="flex min-h-[4.75rem] items-center justify-between gap-3 border-t-2 border-border px-6 py-4 sm:px-8">
{settled ? <span /> : (
<Button variant="ghost" size="sm" className="-ml-3.5 text-muted-foreground" onClick={() => setSettled(true)}>
{t('debateOwlIntro.skipAnimation')}
</Button>
)}
<Button ref={doneButton} onClick={onClose}>{t('debateOwlIntro.done')}</Button>
</div>
</DialogContent>
</Dialog>
);
}

/** 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 (
<motion.li
className={cn('flex items-start gap-2.5 text-base leading-6 text-foreground', className)}
initial={false}
animate={shown ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }}
transition={{ duration: 0.32, ease: EASE_OUT }}
>
{icon && <span className="mt-1 flex h-4 w-5 shrink-0 items-center justify-center" aria-hidden="true">{icon}</span>}
<span>{children}</span>
</motion.li>
);
}
59 changes: 59 additions & 0 deletions src/lib/debateowl-intro.ts
Original file line number Diff line number Diff line change
@@ -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<Storage, 'getItem' | 'setItem'>;

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;
}
}
11 changes: 11 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading