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/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.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`).

Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`).
Not yet mapped in recipes: theme and UI language, the public landing and SEO pages, the free no-login tools under `/tools` and `/embed/debate-timer` (no backend; `e2e/free-tools.spec.ts` covers them), and share-link creation (the app has no client caller for the `share` function). The agentic suite covers UI language switching (`agentic/workspace.e2e.ts`) and renders the public pages in every language (`agentic/pages.e2e.ts`).
10 changes: 7 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,16 @@ Vercel builds and serves the frontend from the Git repository. Merging to `main`
- 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`;
- 301 redirects from retired guide slugs to the guide that replaced them, such as `/debate-weighing-guide` to `/how-to-weigh-in-debate`. The `trailingSlash` 308 runs first, so `/debate-weighing-guide/` reaches the same page in two permanent hops and needs no rule of its own;
- no-cache headers for `/`, `index.html`, `app-shell.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`;
- `noindex` headers for `/app`, `/login`, and `/share` routes;
- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only.
- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`;
- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header.

After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization and SoftwareApplication JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404.

[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `<lastmod>` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, and the landing page's last source commit. A page without a date is listed without `<lastmod>`.
The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are prerendered the same way, listed in [`src/prerender/tool-pages.tsx`](../src/prerender/tool-pages.tsx) with heads in [`src/lib/seo/tool-pages.ts`](../src/lib/seo/tool-pages.ts). They render their own JSON-LD in the page body, so the dev server and the prerendered HTML carry the same structured data, and their `dateModified` is `TOOL_PAGES_MODIFIED` in that file: bump it with a substantive content change. Their speech times come from [`src/lib/free-tools/formats.ts`](../src/lib/free-tools/formats.ts), which cites a league source per format.

`/embed/debate-timer` is a second Vite HTML entry ([`embed/debate-timer/index.html`](../embed/debate-timer/index.html)), not an app route: it loads only the timer, never the app shell, Jam, analytics or the service worker, so a site that frames it sees no third-party request. It is `noindex` with its canonical URL on `/tools/debate-timer`, and the service worker leaves `/embed/` alone.

[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `<lastmod>` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, the tool pages' `TOOL_PAGES_MODIFIED`, and the landing page's last source commit. A page without a date is listed without `<lastmod>`.

The guide pages in `public/<slug>/index.html` are generated, not hand-edited: run `python3 scripts/generate_seo_content.py` and commit its output. Short pages live in its `PAGES` list; pages with long-form copy, their own highlight-card descriptions, and their own FAQs (which also feed the page's FAQPage JSON-LD) live in [`scripts/seo_guides.py`](../scripts/seo_guides.py). Keep `PAGES` in order, because each page's related-guide links are picked by position. A guide entry can set its own `modified` date, which overrides `CONTENT_DATE` for that page alone.

Expand Down
2 changes: 2 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ The server is never reused. CI uses retries and two workers. On failure, traces,

`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.

`e2e/free-tools.spec.ts` (tag `@smoke`) opens every `/tools` page signed out and checks its heading, canonical URL and JSON-LD, and that it makes no Supabase request. On a paused Playwright clock it runs the timer past the BP protected minute, through a reload, and runs the two Public Forum prep banks; it also draws a motion set and reloads its permalink, times a pasted speech, lays out a BP flow sheet under print media, and checks that `/embed/debate-timer` is `noindex` and requests nothing from another origin. The format data, timer transitions, custom-format links, motion filters and speech-time arithmetic have unit tests in `src/test/free-tools/`. The dev server cannot prove the response headers: `src/test/vercel-deployment-config.test.ts` checks which paths may be framed.

`src/test/i18n-keys.test.ts` fails when a literal `t()` key in `src/` is missing from `en.json`, or when a call that passes `count` lacks its `_one` and `_other` forms. Dynamic keys such as ``t(`nav.${key}`)`` are out of its reach. `src/test/i18n.test.ts` requires the same keys in all three catalogs. Together they prove every literal key has a Spanish and Hungarian entry, not that the translation is natural.

The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support.
Expand Down
174 changes: 174 additions & 0 deletions e2e/free-tools.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { test, expect, type Page } from '@playwright/test';

const ORIGIN = 'https://debate.pi-stack.org';
const TOOL_PAGES = [
{ path: '/tools', h1: 'Free debate tools', type: 'CollectionPage' },
{ path: '/tools/debate-timer', h1: 'Free debate timer for every format', type: 'WebApplication' },
{ path: '/tools/debate-motion-generator', h1: 'Debate motion generator', type: 'WebApplication' },
{ path: '/tools/debate-flow-sheet', h1: 'Printable debate flow sheet', type: 'WebApplication' },
{ path: '/tools/speech-time-calculator', h1: 'How long is my speech? Speech time calculator', type: 'WebApplication' },
] as const;

/** Every request the page makes to a Supabase host or API path. The free tools need none. */
function watchSupabase(page: Page): string[] {
const calls: string[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (url.hostname.endsWith('supabase.co') || /^\/(auth|rest|functions|storage)\/v1\//.test(url.pathname)) calls.push(request.url());
});
return calls;
}

const T0 = new Date('2026-10-22T10:00:00Z').getTime();

/** Opens a page on a fake clock that only moves when the test runs it forward. */
async function openPaused(page: Page, path: string) {
await page.clock.install({ time: T0 });
await page.goto(path);
await page.clock.pauseAt(T0 + 5_000);
}

function watchErrors(page: Page): string[] {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && !/Failed to load resource|net::ERR_/.test(message.text())) errors.push(message.text());
});
return errors;
}

test.describe('free debate tools', { tag: '@smoke' }, () => {
for (const tool of TOOL_PAGES) {
test(`${tool.path} renders signed out with its head and structured data`, async ({ page }) => {
const supabase = watchSupabase(page);
const errors = watchErrors(page);
const response = await page.goto(tool.path);
expect(response?.ok()).toBeTruthy();
await expect(page.getByRole('heading', { level: 1, name: tool.h1 })).toBeVisible();
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}${tool.path}`);
await expect(page).toHaveTitle(/DebateLab/);
const graphs = await page.locator('script[type="application/ld+json"]').allTextContents();
const types = graphs.flatMap((json) => (JSON.parse(json)['@graph'] as { '@type': string }[]).map((node) => node['@type']));
expect(types).toContain(tool.type);
expect(types).toContain('BreadcrumbList');
expect(JSON.stringify(graphs)).not.toMatch(/aggregateRating|"review"/);
expect(supabase, supabase.join('\n')).toEqual([]);
expect(errors, errors.join('\n')).toEqual([]);
});
}

test('the timer starts, pauses and opens points of information after the first minute', async ({ page }) => {
await openPaused(page, '/tools/debate-timer?format=bp');
const clock = page.getByRole('timer', { name: 'Speech time remaining' });
await expect(clock).toHaveText('7:00');
await expect(page.getByText('Protected time', { exact: true })).toBeVisible();

await page.getByRole('button', { name: 'Start' }).click();
await page.clock.runFor(61_000);
await expect(clock).toHaveText('5:59');
await expect(page.getByText('POIs open', { exact: true })).toBeVisible();

await page.getByRole('button', { name: 'Pause' }).click();
await page.clock.runFor(5_000);
await expect(clock).toHaveText('5:59');

// The round survives a reload.
await page.reload();
await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:59');

// Keys act once the timer has focus.
await page.getByRole('timer', { name: 'Speech time remaining' }).click();
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('heading', { level: 2, name: 'Leader of the Opposition (Opening Opposition)' })).toBeVisible();
});

test('a custom format applies, lands in the share link, and survives a reload', async ({ page }) => {
await page.goto('/tools/debate-timer?format=wsdc');
await page.getByRole('button', { name: 'Build a custom format' }).click();
const editor = page.getByRole('form', { name: 'Custom format' });
await editor.getByLabel('Format name').fill('Club round');
// Whole and fractional minutes both submit; the browser's step check once blocked every value.
await editor.getByLabel('Minutes', { exact: true }).first().fill('5');
await editor.getByLabel('Minutes', { exact: true }).nth(1).fill('2.5');
await editor.getByRole('button', { name: 'Use this format' }).click();
await expect(editor).toHaveCount(0);
await expect(page).toHaveURL(/format=custom&custom=[A-Za-z0-9_-]+/);
await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:00');
await page.reload();
await expect(page.getByRole('region', { name: /round timer/ }).getByLabel('Format')).toHaveValue('custom');
await expect(page.getByRole('navigation', { name: 'Speeches' })).toContainText('2:30');
});

test('prep banks run down one side at a time', async ({ page }) => {
await openPaused(page, '/tools/debate-timer?format=pf');
const teamA = page.getByRole('button', { name: /Team A prep/ });
const teamB = page.getByRole('button', { name: /Team B prep/ });
await teamA.click();
await page.clock.runFor(30_000);
await teamB.click();
await page.clock.runFor(10_000);
await expect(teamA).toContainText('2:30');
await expect(teamB).toContainText('2:50');
await expect(teamB).toHaveAttribute('aria-pressed', 'true');
await expect(teamA).toHaveAttribute('aria-pressed', 'false');
});

test('the embed renders the format alone, makes no third-party request, and stays out of the index', async ({ page, baseURL }) => {
const external: string[] = [];
page.on('request', (request) => {
if (new URL(request.url()).origin !== new URL(baseURL!).origin) external.push(request.url());
});
await page.goto('/embed/debate-timer?format=wsdc');
await expect(page.getByRole('heading', { level: 2, name: 'First Proposition' })).toBeVisible();
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex');
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`);
const attribution = page.getByRole('link', { name: 'Timer by DebateLab' });
await expect(attribution).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`);
await expect(attribution).not.toHaveAttribute('rel', /nofollow/);
await expect(page.getByLabel('Format')).toHaveCount(0);
await page.getByRole('button', { name: 'Start' }).click();
await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible();
// Running for a few seconds gives any stray request time to show up.
await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('7:57', { timeout: 10_000 });
expect(external, external.join('\n')).toEqual([]);
});

test('a drawn set of motions keeps its permalink', async ({ page }) => {
await page.goto('/tools/debate-motion-generator');
await page.getByRole('button', { name: 'Education', exact: true }).click();
await page.getByRole('button', { name: 'Draw three' }).click();
const options = page.getByRole('region', { name: 'Motion generator' }).locator('ol > li');
await expect(options).toHaveCount(3);
const drawn = await options.locator('p').first().textContent();
await expect(page).toHaveURL(/[?&]m=edu-\d{3}%2Cedu-\d{3}%2Cedu-\d{3}/);
await page.reload();
await expect(page.getByRole('region', { name: 'Motion generator' }).locator('ol > li').first().locator('p').first()).toHaveText(drawn!);
// The whole bank is on the page for readers and crawlers.
await expect(page.locator('#edu-001')).toBeAttached();
});

test('the calculator times a pasted speech and fits it to speech slots', async ({ page }) => {
await page.goto('/tools/speech-time-calculator');
await page.getByLabel('Your speech').fill(Array.from({ length: 260 }, (_, i) => `word${i}`).join(' '));
await expect(page.getByTestId('word-count')).toHaveText('260');
// 260 words at 130 wpm is two minutes: it fits a 2:00 final focus exactly.
await expect(page.getByTestId('slot-fits').locator('li', { hasText: 'PF 2:00' })).toContainText('0:00 spare');
});

test('the flow sheet has a column per speech and prints', async ({ page }) => {
await page.goto('/tools/debate-flow-sheet');
await page.getByLabel('Format').selectOption('bp');
const sheet = page.getByTestId('flow-sheet');
for (const speech of ['PM', 'LO', 'DPM', 'DLO', 'MG', 'MO', 'GW', 'OW']) await expect(sheet.getByText(speech, { exact: true })).toBeVisible();
await page.emulateMedia({ media: 'print' });
await expect(page.getByRole('button', { name: /Print/ })).toBeHidden();
await expect(sheet).toBeVisible();
});

test('the landing footer links to the tools hub', async ({ page }) => {
await page.goto('/');
await page.getByRole('contentinfo').getByRole('link', { name: 'Free debate tools' }).click();
await expect(page).toHaveURL(/\/tools$/);
await expect(page.getByRole('heading', { level: 1, name: 'Free debate tools' })).toBeVisible();
});
});
21 changes: 21 additions & 0 deletions embed/debate-timer/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- The embeddable timer: framed on other sites, so it loads only its own
bundle (no app shell, analytics, recorder or service worker) and stays
out of search results in favour of the tool page. -->
<title>Debate timer | DebateLab</title>
<meta name="robots" content="noindex" />
<link rel="canonical" href="https://debate.pi-stack.org/tools/debate-timer" />
<link rel="icon" type="image/png" href="/favicon.png" />
<script src="/src/embed/debate-timer.tsx" type="module"></script>
</head>
<body>
<div id="root"></div>
<noscript>
<p style="font-family: system-ui, sans-serif; padding: 16px;">This timer needs JavaScript. <a href="https://debate.pi-stack.org/tools/debate-timer" target="_blank" rel="noopener">Open the debate timer by DebateLab</a>.</p>
</noscript>
</body>
</html>
3 changes: 3 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ self.addEventListener('fetch', (event) => {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (API_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
// Embeds are framed on other sites: never store one as the app shell, and
// never answer one with the cached shell, which refuses to be framed.
if (url.pathname.startsWith('/embed/')) return;

if (request.mode === 'navigate') {
event.respondWith(navigationFallback(request));
Expand Down
Loading
Loading