Skip to content

Commit c56cf8f

Browse files
committed
Merge remote-tracking branch 'origin/main' into s176
2 parents 0d9e26c + da7473e commit c56cf8f

51 files changed

Lines changed: 6428 additions & 99 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,4 @@ Each feature file starts with an H1 title and one paragraph describing the user-
5454
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.spec.ts`).
5555
- 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`).
5656

57-
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`).
57+
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`).

‎docs/operations.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,16 @@ Vercel builds and serves the frontend from the Git repository. Merging to `main`
4646
- 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`;
4747
- 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;
4848
- no-cache headers for `/`, `index.html`, `app-shell.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`;
49-
- `noindex` headers for `/app`, `/login`, and `/share` routes;
50-
- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only.
49+
- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`;
50+
- 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.
5151

5252
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.
5353

54-
[`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>`.
54+
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.
55+
56+
`/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.
57+
58+
[`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>`.
5559

5660
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.
5761

‎docs/testing.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ The server is never reused. CI uses retries and two workers. On failure, traces,
131131

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

134+
`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.
135+
134136
`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.
135137

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

‎e2e/free-tools.spec.ts‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
const ORIGIN = 'https://debate.pi-stack.org';
4+
const TOOL_PAGES = [
5+
{ path: '/tools', h1: 'Free debate tools', type: 'CollectionPage' },
6+
{ path: '/tools/debate-timer', h1: 'Free debate timer for every format', type: 'WebApplication' },
7+
{ path: '/tools/debate-motion-generator', h1: 'Debate motion generator', type: 'WebApplication' },
8+
{ path: '/tools/debate-flow-sheet', h1: 'Printable debate flow sheet', type: 'WebApplication' },
9+
{ path: '/tools/speech-time-calculator', h1: 'How long is my speech? Speech time calculator', type: 'WebApplication' },
10+
] as const;
11+
12+
/** Every request the page makes to a Supabase host or API path. The free tools need none. */
13+
function watchSupabase(page: Page): string[] {
14+
const calls: string[] = [];
15+
page.on('request', (request) => {
16+
const url = new URL(request.url());
17+
if (url.hostname.endsWith('supabase.co') || /^\/(auth|rest|functions|storage)\/v1\//.test(url.pathname)) calls.push(request.url());
18+
});
19+
return calls;
20+
}
21+
22+
const T0 = new Date('2026-10-22T10:00:00Z').getTime();
23+
24+
/** Opens a page on a fake clock that only moves when the test runs it forward. */
25+
async function openPaused(page: Page, path: string) {
26+
await page.clock.install({ time: T0 });
27+
await page.goto(path);
28+
await page.clock.pauseAt(T0 + 5_000);
29+
}
30+
31+
function watchErrors(page: Page): string[] {
32+
const errors: string[] = [];
33+
page.on('pageerror', (error) => errors.push(error.message));
34+
page.on('console', (message) => {
35+
if (message.type() === 'error' && !/Failed to load resource|net::ERR_/.test(message.text())) errors.push(message.text());
36+
});
37+
return errors;
38+
}
39+
40+
test.describe('free debate tools', { tag: '@smoke' }, () => {
41+
for (const tool of TOOL_PAGES) {
42+
test(`${tool.path} renders signed out with its head and structured data`, async ({ page }) => {
43+
const supabase = watchSupabase(page);
44+
const errors = watchErrors(page);
45+
const response = await page.goto(tool.path);
46+
expect(response?.ok()).toBeTruthy();
47+
await expect(page.getByRole('heading', { level: 1, name: tool.h1 })).toBeVisible();
48+
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}${tool.path}`);
49+
await expect(page).toHaveTitle(/DebateLab/);
50+
const graphs = await page.locator('script[type="application/ld+json"]').allTextContents();
51+
const types = graphs.flatMap((json) => (JSON.parse(json)['@graph'] as { '@type': string }[]).map((node) => node['@type']));
52+
expect(types).toContain(tool.type);
53+
expect(types).toContain('BreadcrumbList');
54+
expect(JSON.stringify(graphs)).not.toMatch(/aggregateRating|"review"/);
55+
expect(supabase, supabase.join('\n')).toEqual([]);
56+
expect(errors, errors.join('\n')).toEqual([]);
57+
});
58+
}
59+
60+
test('the timer starts, pauses and opens points of information after the first minute', async ({ page }) => {
61+
await openPaused(page, '/tools/debate-timer?format=bp');
62+
const clock = page.getByRole('timer', { name: 'Speech time remaining' });
63+
await expect(clock).toHaveText('7:00');
64+
await expect(page.getByText('Protected time', { exact: true })).toBeVisible();
65+
66+
await page.getByRole('button', { name: 'Start' }).click();
67+
await page.clock.runFor(61_000);
68+
await expect(clock).toHaveText('5:59');
69+
await expect(page.getByText('POIs open', { exact: true })).toBeVisible();
70+
71+
await page.getByRole('button', { name: 'Pause' }).click();
72+
await page.clock.runFor(5_000);
73+
await expect(clock).toHaveText('5:59');
74+
75+
// The round survives a reload.
76+
await page.reload();
77+
await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:59');
78+
79+
// Keys act once the timer has focus.
80+
await page.getByRole('timer', { name: 'Speech time remaining' }).click();
81+
await page.keyboard.press('ArrowRight');
82+
await expect(page.getByRole('heading', { level: 2, name: 'Leader of the Opposition (Opening Opposition)' })).toBeVisible();
83+
});
84+
85+
test('a custom format applies, lands in the share link, and survives a reload', async ({ page }) => {
86+
await page.goto('/tools/debate-timer?format=wsdc');
87+
await page.getByRole('button', { name: 'Build a custom format' }).click();
88+
const editor = page.getByRole('form', { name: 'Custom format' });
89+
await editor.getByLabel('Format name').fill('Club round');
90+
// Whole and fractional minutes both submit; the browser's step check once blocked every value.
91+
await editor.getByLabel('Minutes', { exact: true }).first().fill('5');
92+
await editor.getByLabel('Minutes', { exact: true }).nth(1).fill('2.5');
93+
await editor.getByRole('button', { name: 'Use this format' }).click();
94+
await expect(editor).toHaveCount(0);
95+
await expect(page).toHaveURL(/format=custom&custom=[A-Za-z0-9_-]+/);
96+
await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('5:00');
97+
await page.reload();
98+
await expect(page.getByRole('region', { name: /round timer/ }).getByLabel('Format')).toHaveValue('custom');
99+
await expect(page.getByRole('navigation', { name: 'Speeches' })).toContainText('2:30');
100+
});
101+
102+
test('prep banks run down one side at a time', async ({ page }) => {
103+
await openPaused(page, '/tools/debate-timer?format=pf');
104+
const teamA = page.getByRole('button', { name: /Team A prep/ });
105+
const teamB = page.getByRole('button', { name: /Team B prep/ });
106+
await teamA.click();
107+
await page.clock.runFor(30_000);
108+
await teamB.click();
109+
await page.clock.runFor(10_000);
110+
await expect(teamA).toContainText('2:30');
111+
await expect(teamB).toContainText('2:50');
112+
await expect(teamB).toHaveAttribute('aria-pressed', 'true');
113+
await expect(teamA).toHaveAttribute('aria-pressed', 'false');
114+
});
115+
116+
test('the embed renders the format alone, makes no third-party request, and stays out of the index', async ({ page, baseURL }) => {
117+
const external: string[] = [];
118+
page.on('request', (request) => {
119+
if (new URL(request.url()).origin !== new URL(baseURL!).origin) external.push(request.url());
120+
});
121+
await page.goto('/embed/debate-timer?format=wsdc');
122+
await expect(page.getByRole('heading', { level: 2, name: 'First Proposition' })).toBeVisible();
123+
await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex');
124+
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`);
125+
const attribution = page.getByRole('link', { name: 'Timer by DebateLab' });
126+
await expect(attribution).toHaveAttribute('href', `${ORIGIN}/tools/debate-timer`);
127+
await expect(attribution).not.toHaveAttribute('rel', /nofollow/);
128+
await expect(page.getByLabel('Format')).toHaveCount(0);
129+
await page.getByRole('button', { name: 'Start' }).click();
130+
await expect(page.getByRole('button', { name: 'Pause' })).toBeVisible();
131+
// Running for a few seconds gives any stray request time to show up.
132+
await expect(page.getByRole('timer', { name: 'Speech time remaining' })).toHaveText('7:57', { timeout: 10_000 });
133+
expect(external, external.join('\n')).toEqual([]);
134+
});
135+
136+
test('a drawn set of motions keeps its permalink', async ({ page }) => {
137+
await page.goto('/tools/debate-motion-generator');
138+
await page.getByRole('button', { name: 'Education', exact: true }).click();
139+
await page.getByRole('button', { name: 'Draw three' }).click();
140+
const options = page.getByRole('region', { name: 'Motion generator' }).locator('ol > li');
141+
await expect(options).toHaveCount(3);
142+
const drawn = await options.locator('p').first().textContent();
143+
await expect(page).toHaveURL(/[?&]m=edu-\d{3}%2Cedu-\d{3}%2Cedu-\d{3}/);
144+
await page.reload();
145+
await expect(page.getByRole('region', { name: 'Motion generator' }).locator('ol > li').first().locator('p').first()).toHaveText(drawn!);
146+
// The whole bank is on the page for readers and crawlers.
147+
await expect(page.locator('#edu-001')).toBeAttached();
148+
});
149+
150+
test('the calculator times a pasted speech and fits it to speech slots', async ({ page }) => {
151+
await page.goto('/tools/speech-time-calculator');
152+
await page.getByLabel('Your speech').fill(Array.from({ length: 260 }, (_, i) => `word${i}`).join(' '));
153+
await expect(page.getByTestId('word-count')).toHaveText('260');
154+
// 260 words at 130 wpm is two minutes: it fits a 2:00 final focus exactly.
155+
await expect(page.getByTestId('slot-fits').locator('li', { hasText: 'PF 2:00' })).toContainText('0:00 spare');
156+
});
157+
158+
test('the flow sheet has a column per speech and prints', async ({ page }) => {
159+
await page.goto('/tools/debate-flow-sheet');
160+
await page.getByLabel('Format').selectOption('bp');
161+
const sheet = page.getByTestId('flow-sheet');
162+
for (const speech of ['PM', 'LO', 'DPM', 'DLO', 'MG', 'MO', 'GW', 'OW']) await expect(sheet.getByText(speech, { exact: true })).toBeVisible();
163+
await page.emulateMedia({ media: 'print' });
164+
await expect(page.getByRole('button', { name: /Print/ })).toBeHidden();
165+
await expect(sheet).toBeVisible();
166+
});
167+
168+
test('the landing footer links to the tools hub', async ({ page }) => {
169+
await page.goto('/');
170+
await page.getByRole('contentinfo').getByRole('link', { name: 'Free debate tools' }).click();
171+
await expect(page).toHaveURL(/\/tools$/);
172+
await expect(page.getByRole('heading', { level: 1, name: 'Free debate tools' })).toBeVisible();
173+
});
174+
});

‎embed/debate-timer/index.html‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<!-- The embeddable timer: framed on other sites, so it loads only its own
7+
bundle (no app shell, analytics, recorder or service worker) and stays
8+
out of search results in favour of the tool page. -->
9+
<title>Debate timer | DebateLab</title>
10+
<meta name="robots" content="noindex" />
11+
<link rel="canonical" href="https://debate.pi-stack.org/tools/debate-timer" />
12+
<link rel="icon" type="image/png" href="/favicon.png" />
13+
<script src="/src/embed/debate-timer.tsx" type="module"></script>
14+
</head>
15+
<body>
16+
<div id="root"></div>
17+
<noscript>
18+
<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>
19+
</noscript>
20+
</body>
21+
</html>

‎public/sw.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ self.addEventListener('fetch', (event) => {
113113
const url = new URL(request.url);
114114
if (url.origin !== self.location.origin) return;
115115
if (API_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
116+
// Embeds are framed on other sites: never store one as the app shell, and
117+
// never answer one with the cached shell, which refuses to be framed.
118+
if (url.pathname.startsWith('/embed/')) return;
116119

117120
if (request.mode === 'navigate') {
118121
event.respondWith(navigationFallback(request));

0 commit comments

Comments
 (0)