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
10 changes: 5 additions & 5 deletions .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,20 @@ Each feature file starts with an H1 title and one paragraph describing the user-

## Features

- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), a signed-out deep link through a wrong code, the resend countdown and back, and a second account on the same browser (`recipes/auth.spec.ts`).
- [Demo-code sign-in](./auth-demo-login.md): sign-in, reload, rejected code, sign-out (`e2e/full-stack/auth.spec.ts`); email one-time-code sign-in through Mailpit, the emailed sign-in link through `/auth/callback` (including after a failed resend, and a used link), a signed-out deep link through a wrong code, the resend countdown and back, a second account on the same browser, and the same account after Sign out on Settings landing on `/app` (`recipes/auth.spec.ts`).
- [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, building a flowchart from the case, provider failure and retry, a reply cut off at its length cap, quota block (`recipes/case-generation.spec.ts`).
- [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`).
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, and with web search (`recipes/ai-tools.spec.ts`).
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cut off at its length cap, cited, with web search, and the student's bubble contrast with the Hungarian layout at 375 px (`recipes/ai-tools.spec.ts`).
- [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`).
- [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`).
- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to their successor (`recipes/model-defaults.spec.ts`).
- [Practice](./practice.md): a full Lincoln-Douglas sparring round with verdict, and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`).
- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, retired model IDs move to their successor, and an Auto choice fits the picker at 375 px (`recipes/model-defaults.spec.ts`).
- [Practice](./practice.md): the Sparring setup's whole-round cost and Start's pending state, a full Lincoln-Douglas sparring round with verdict (and its Hungarian chart labels), and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`).
- [Voice rounds](./voice-rounds.md): spoken Sparring and Crossfire on the browser voice, with no ElevenLabs key (`recipes/voice-rounds.spec.ts`).
- [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it.
- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`).
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` home pages (`recipes/localization.spec.ts`).
- Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`).

Expand Down
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/features/ai-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The single-shot coaching tools: Attack finds weaknesses in a pasted argument, Br
- `brainstorm-run` returns ranked ideas for a motion. Provider feature: `brainstorm`.
- `factoids-run` finishes research and shows search suggestions. It drops unverified sources because there is no web key locally. Provider feature: `factoids`.
- `ask-stream` streams a coaching answer through `ai-stream`. Provider feature: `ask-stream`, recorded as an `ask` usage row.
- `ask-bubble` measures the student's own message (body, bold, and its time line, test id `message-meta`) against the blue bubble in light and dark: each is at least 4.5:1 (about 5.1 light, 6.7 dark). At 375 px in Hungarian the placeholder `Mit kérdeznél?` fits the field and the chat header wraps instead of overflowing (`recipes/ai-tools.spec.ts`).
- `ask-cited` uses the structured endpoint when citation mode is on. Provider feature: `ask`.

## How to get to it (user POV)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A visitor signs in with a demo access code on the Login page. The `demo-code-log
- `auth-session-expired` signs out a session the server revoked: the stored tokens are refused, so the app goes to `/login` and shows `Your session expired. Sign in again.` once (`recipes/auth.spec.ts`).
- `auth-email-link` opens the sign-in link from the email after a resend failed with 429. It lands on `/app` through `/auth/callback` without showing `/login` on the way, and the same link opened again shows `This sign-in link has expired or was already used. Send a new code.` on `/login` (`recipes/auth.spec.ts`).
- `auth-deep-link-email-code` opens `/app/generate` signed out, lands on `/login` (one `main`, an `h1`), sends a code, and sees `Resend in 0:NN` disabled. A wrong code shows `That code is wrong or has expired. Check it or send a new one.`. After the countdown it resends (`New code sent`, the old error gone), and the new code returns to `/app/generate` (`recipes/auth.spec.ts`).
- `auth-same-account-after-sign-out` signs out from Settings and signs the same account back in by email code: it lands on `/app`, not Settings. A session that ends by itself still returns the account to the page it left (`recipes/auth.spec.ts`).
- `auth-switch-account` signs out from Settings (the logout request has `scope=local`) and signs a second account in on the same `/login` page. It lands on `/app`, not on Settings, and gets its own onboarding tour. On phones the menu still opens and navigates while the tour shows (`recipes/auth.spec.ts`).

## How to get to it (user POV)
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/features/model-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GP

- `model-pro-default`: a new Pro account's `user_settings.feature_configs` hold `anthropic/claude-sonnet-5.5` for every tool, the selector shows `Claude Sonnet 5.5` with `Claude Opus 5.5` still enabled, and a Generate run records `model: anthropic/claude-sonnet-5.5` in `ai_requests`.
- `model-basic-default`: a trial account with no saved settings gets `anthropic/claude-haiku-5.5` everywhere. `Claude Opus 5.5` and `Claude Sonnet 5.5` are disabled options, and a Generate run records Haiku 5.5.
- `model-picker-narrow`: with `Auto Fast` picked, the field shows only the name and test id `model-auto-description` shows its description below, in English and Hungarian at 375 px, with neither cut off.
- `model-retired`: saved `anthropic/claude-sonnet-5` configs become `anthropic/claude-sonnet-5.5` on screen and in `user_settings`.

## How to get to it (user POV)
Expand Down
4 changes: 3 additions & 1 deletion .claude/skills/verify-debatelab/features/practice.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a
## Sub-features

- `spar-round` starts a round with a chosen format, submits the user's speeches, and receives opponent speeches.
- `spar-start` the setup prices the whole round (test id `sparring-round-cost`, `N AI uses`, and `For the whole round: 4 replies + the judge` for the school format), in English and Hungarian at 375 px and 1280 px. While the pre-round quota check runs, Start reads `Opening room...`, is disabled with `aria-busy`, and test id `sparring-starting` says `Checking your AI uses for today…`; a second press sends no second check, and the round opens without a reload once the check answers.
- `spar-verdict` judges the finished round. A `sparring_sessions` row reaches `status: complete`, with `side: pro` and the debate for its motion.
- `cross-turn` answers a user question with an AI response and attaches a Jev `Direct answer` signal.
- `cross-debrief` ends the session and shows the debrief. A `crossfires` row is filed in the debate for its resolution.
Expand All @@ -27,7 +28,8 @@ Preconditions:

- **Start a sparring round.** On `/app/sparring`, fill the field labelled `Motion`. Open the combobox named `Round format` and choose the option matching `Lincoln-Douglas · 5 speeches`. Click `Start round`. A new account has no Current Debate, so `Start a debate with this motion?` opens: `chooseForMotion(page, "create")`.
- **Speeches.** For each user turn, fill the textbox named `Draft for <turn>` (`Affirmative constructive`, `First affirmative rebuttal`, `Second affirmative rebuttal`) and click `Submit speech`. Each turn opens with a stopped clock (test id `sparring-ready`, button `Start speaking`); filling the draft starts it. After each of the first two, the last `FAKE-PROVIDER opponent speech` is visible.
- **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`.
- **Verdict.** The heading `Here is what happened in the room.` and the text `FAKE-PROVIDER verdict: the user had the edge.` appear. `providerCall("sparring").user` contains `SPARRING ACTION:`. `sparring_sessions` statuses for the user equal `["complete"]`, and `select("side,debates(motion)")` gives `{side: "pro", debates: {motion}}`. In Hungarian the radar chart axis (`.recharts-polar-angle-axis-tick text`) reads `Logika`, `Bizonyíték`, `Előadás`, `Stratégia`.
- **Hold the quota check.** `page.route("**/functions/v1/ai")` and wait before `route.fallback()` when the body has `quotaCheck: true`; register it after the page loads, since setup asks once on load.
- **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`.
- **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired.
- **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: <resolution>, debates: {motion: <resolution>}}]`.
Expand Down
70 changes: 70 additions & 0 deletions .claude/skills/verify-debatelab/recipes/ai-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,76 @@ test("ask-stream: Ask streams a coaching answer and records usage", async ({ sig
}, { timeout: AI_TIMEOUT }).toContainEqual(expect.objectContaining({ feature: "ask", status: "success" }));
});

/** WCAG contrast of an element's text against the first painted background behind it. */
async function textContrast(page: import("@playwright/test").Page, text: string) {
return page.evaluate((needle) => {
// "meta" is the first message's time line, the student's own bubble.
const target = (needle === "meta"
? document.querySelector("[data-testid=message-meta]")
: [...document.querySelectorAll("p, li, strong")].find((element) => element.textContent?.trim() === needle)) as HTMLElement | null | undefined;
if (!target) throw new Error(`no element with text ${needle}`);
const rgb = (value: string) => {
const ctx = document.createElement("canvas").getContext("2d")!;
ctx.fillStyle = value;
ctx.fillRect(0, 0, 1, 1);
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 4);
};
let node: HTMLElement | null = target;
let background = "rgba(0, 0, 0, 0)";
while (node) {
const color = getComputedStyle(node).backgroundColor;
if (rgb(color)[3] > 0) { background = color; break; }
node = node.parentElement;
}
const luminance = ([r, g, b]: number[]) => [r, g, b]
.map((channel) => { const c = channel / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; })
.reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
const [fg, bg] = [luminance(rgb(getComputedStyle(target).color)), luminance(rgb(background))];
return { color: getComputedStyle(target).color, background, ratio: (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05) };
}, text);
}

// QA round 3: the student's own message was gray on blue (about 2:1), and the
// Hungarian placeholder was cut off at 375 px.
test("ask-bubble: the student's message meets WCAG AA in light and dark, and the placeholder fits at 375 px", async ({ signedIn: page, evidence }, testInfo) => {
if (testInfo.project.name === "mobile-chromium") await page.setViewportSize({ width: 375, height: 812 });
const question = "How should I weigh **access** against cost?";
await page.goto("/app/ask");
await page.getByPlaceholder("Ask your debate coach...").fill(question);
const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT });
await page.getByRole("button", { name: "Send message" }).click();
await stream;
const ratios: Record<string, unknown> = {};
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expect(page.locator("html")).toHaveClass(colorScheme === "dark" ? /\bdark\b/ : /^(?!.*\bdark\b)/);
for (const [part, text] of [["body", "How should I weigh access against cost?"], ["bold", "access"], ["time", "meta"]] as const) {
const measured = await textContrast(page, text);
ratios[`${colorScheme}-${part}`] = measured;
expect(measured.ratio, `${colorScheme} ${part} contrast`).toBeGreaterThanOrEqual(4.5);
}
await evidence.shot(`01-user-bubble-${colorScheme}`);
}
await evidence.json("02-contrast", ratios);
await page.emulateMedia({ colorScheme: "light" });

await page.getByRole("button", { name: "Language", exact: true }).click();
await page.getByRole("radio", { name: "Magyar" }).first().click();
const box = page.getByPlaceholder("Mit kérdeznél?");
await expect(box).toBeVisible();
// The whole placeholder fits the field's text area.
const fits = await box.evaluate((input: HTMLTextAreaElement | HTMLInputElement) => {
const style = getComputedStyle(input);
const ctx = document.createElement("canvas").getContext("2d")!;
ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const room = input.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
return { text: ctx.measureText(input.placeholder).width, room };
});
await evidence.json("03-hu-placeholder", fits);
expect(fits.text).toBeLessThanOrEqual(fits.room);
await evidence.shot("03-hu-placeholder");
});

test("ask-cutoff: a streamed answer cut off at its length cap says so under the answer", async ({ signedIn: page, evidence }) => {
test.skip(LIVE, "only the fake provider can force a cut-off reply");
await fake.truncate("ask-stream");
Expand Down
23 changes: 23 additions & 0 deletions .claude/skills/verify-debatelab/recipes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,26 @@ test("auth-switch-account: the next account on this browser starts on /app, gets
await deleteDemoUser(second);
}
});

// QA round 3: Sign out lives on Settings, and the same account signing back in by
// email code landed on /app/settings ("Opening Settings") instead of /app.
test("auth-same-account-after-sign-out: signing back in by email code after Sign out on Settings opens /app", async ({ signedIn: page, user, request, evidence }) => {
test.skip(!mailer, "WORKSPACE_TEST_MAILER_URL is missing; run through verify.sh drive");
await page.goto("/app/settings");
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/login$/);
await evidence.step("Signed out from /app/settings");

const sentAfter = Date.now();
await page.getByRole("tab", { name: "Email" }).click();
await page.getByLabel("Email address").fill(user.email);
await page.getByRole("button", { name: "Send code" }).click();
await expect(page.getByText("Code sent").first()).toBeVisible();
const otp = await readOtp(request, user.email, sentAfter);
await page.getByRole("textbox", { name: "Verification code" }).fill(otp);
await page.getByRole("button", { name: "Verify code" }).click();
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
await expect(page.getByText("Opening Settings")).toHaveCount(0);
await evidence.shot("01-landed-on-app");
await evidence.step("The same account signed back in by email code and landed on /app");
});
Loading
Loading