From 6437804e6a0fb7cf4c5a759ecc284ef1c28ad5b6 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:58:00 +0000 Subject: [PATCH 1/5] Add free no-login debate tools: timer, motions, flow sheet, speech calculator Four public tools under /tools, prerendered with their own heads and JSON-LD, plus an embeddable timer at /embed/debate-timer. vercel.json allows framing on /embed/* only; every other path still refuses it. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 2 +- docs/operations.md | 10 +- e2e/free-tools.spec.ts | 155 ++ embed/debate-timer/index.html | 21 + public/sw.js | 3 + src/App.tsx | 10 + .../free-tools/CustomFormatEditor.tsx | 135 + src/components/free-tools/DebateTimer.tsx | 369 +++ src/components/free-tools/FlowSheet.tsx | 142 + src/components/free-tools/FormatReference.tsx | 56 + src/components/free-tools/MotionGenerator.tsx | 170 ++ .../free-tools/SpeechTimeCalculator.tsx | 106 + src/components/free-tools/ToolPageLayout.tsx | 143 + src/components/free-tools/timer-session.ts | 76 + src/components/landing/Footer.tsx | 3 + src/embed/debate-timer.tsx | 56 + src/hooks/use-screen-wake-lock.ts | 25 + src/i18n.ts | 12 +- src/lib/free-tools/custom-format.ts | 135 + src/lib/free-tools/formats.ts | 245 ++ src/lib/free-tools/motion-bank.ts | 96 + src/lib/free-tools/motion-types.ts | 36 + src/lib/free-tools/practice-motions.ts | 2436 +++++++++++++++++ src/lib/free-tools/round-timer.ts | 162 ++ src/lib/free-tools/speech-time.ts | 60 + src/lib/observability.ts | 5 +- src/lib/route-modules.ts | 5 + src/lib/seo/pages.ts | 2 + src/lib/seo/tool-pages.ts | 142 + src/lib/timer-signals.ts | 62 + src/locales/tools/en.json | 177 ++ src/locales/tools/es.json | 177 ++ src/locales/tools/hu.json | 177 ++ src/pages/TimerPage.tsx | 74 +- src/pages/tools/DebateTimerToolPage.tsx | 157 ++ src/pages/tools/FlowSheetToolPage.tsx | 82 + src/pages/tools/MotionGeneratorToolPage.tsx | 118 + .../tools/SpeechTimeCalculatorToolPage.tsx | 112 + src/pages/tools/ToolsHubPage.tsx | 43 + src/prerender/entry.tsx | 9 + src/prerender/tool-pages.tsx | 16 + src/test/free-tools/motion-bank.test.ts | 47 + src/test/free-tools/round-timer.test.ts | 163 ++ src/test/free-tools/speech-time.test.ts | 35 + src/test/i18n-keys.test.ts | 18 +- src/test/i18n.test.ts | 9 + src/test/vercel-deployment-config.test.ts | 64 +- vercel.json | 34 +- vite.config.ts | 28 +- 49 files changed, 6321 insertions(+), 99 deletions(-) create mode 100644 e2e/free-tools.spec.ts create mode 100644 embed/debate-timer/index.html create mode 100644 src/components/free-tools/CustomFormatEditor.tsx create mode 100644 src/components/free-tools/DebateTimer.tsx create mode 100644 src/components/free-tools/FlowSheet.tsx create mode 100644 src/components/free-tools/FormatReference.tsx create mode 100644 src/components/free-tools/MotionGenerator.tsx create mode 100644 src/components/free-tools/SpeechTimeCalculator.tsx create mode 100644 src/components/free-tools/ToolPageLayout.tsx create mode 100644 src/components/free-tools/timer-session.ts create mode 100644 src/embed/debate-timer.tsx create mode 100644 src/hooks/use-screen-wake-lock.ts create mode 100644 src/lib/free-tools/custom-format.ts create mode 100644 src/lib/free-tools/formats.ts create mode 100644 src/lib/free-tools/motion-bank.ts create mode 100644 src/lib/free-tools/motion-types.ts create mode 100644 src/lib/free-tools/practice-motions.ts create mode 100644 src/lib/free-tools/round-timer.ts create mode 100644 src/lib/free-tools/speech-time.ts create mode 100644 src/lib/seo/tool-pages.ts create mode 100644 src/lib/timer-signals.ts create mode 100644 src/locales/tools/en.json create mode 100644 src/locales/tools/es.json create mode 100644 src/locales/tools/hu.json create mode 100644 src/pages/tools/DebateTimerToolPage.tsx create mode 100644 src/pages/tools/FlowSheetToolPage.tsx create mode 100644 src/pages/tools/MotionGeneratorToolPage.tsx create mode 100644 src/pages/tools/SpeechTimeCalculatorToolPage.tsx create mode 100644 src/pages/tools/ToolsHubPage.tsx create mode 100644 src/prerender/tool-pages.tsx create mode 100644 src/test/free-tools/motion-bank.test.ts create mode 100644 src/test/free-tools/round-timer.test.ts create mode 100644 src/test/free-tools/speech-time.test.ts diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 565c095c3..f1a75bad3 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -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`). diff --git a/docs/operations.md b/docs/operations.md index 58abd5e2e..62af3f79d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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` 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. Every path except `/embed/*` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/*` 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/).*)` and `/embed/(.*)`) so no path gets 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 `` 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 ``. +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 `` 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 ``. The guide pages in `public//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. diff --git a/e2e/free-tools.spec.ts b/e2e/free-tools.spec.ts new file mode 100644 index 000000000..2885937f7 --- /dev/null +++ b/e2e/free-tools.spec.ts @@ -0,0 +1,155 @@ +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'); + + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('heading', { level: 2, name: 'Leader of the Opposition (Opening Opposition)' })).toBeVisible(); + }); + + 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(); + }); +}); diff --git a/embed/debate-timer/index.html b/embed/debate-timer/index.html new file mode 100644 index 000000000..f83b79110 --- /dev/null +++ b/embed/debate-timer/index.html @@ -0,0 +1,21 @@ + + + + + + + Debate timer | DebateLab + + + + + + +
+ + + diff --git a/public/sw.js b/public/sw.js index bf4cb219b..4c1d239d7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -104,6 +104,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)); diff --git a/src/App.tsx b/src/App.tsx index f2ac09a73..6887dc5a8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,11 @@ const FlowchartPage = lazy(routeLoaders["/app/flowchart"]); const LoginPage = lazy(routeLoaders["/login"]); const SourcesPage = lazy(routeLoaders["/expocit/sources"]); const NotFound = prerenderedPage("*"); +const ToolsHubPage = prerenderedPage("/tools"); +const DebateTimerToolPage = prerenderedPage("/tools/debate-timer"); +const MotionGeneratorToolPage = prerenderedPage("/tools/debate-motion-generator"); +const FlowSheetToolPage = prerenderedPage("/tools/debate-flow-sheet"); +const SpeechTimeCalculatorToolPage = prerenderedPage("/tools/speech-time-calculator"); const OnboardingTour = lazy(() => import("@/components/features/onboarding/OnboardingTour") .then(({ OnboardingTour: Tour }) => ({ default: Tour }))); @@ -274,6 +279,11 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> {/* Protected App Routes */} void }) { + const { t } = useTranslation('tools'); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(() => (choice.id === 'custom' ? choice.custom : roundFormatToCustom(formatFor(choice)))); + + const begin = () => { + setDraft(choice.id === 'custom' ? choice.custom : roundFormatToCustom(formatFor(choice))); + setOpen(true); + }; + const setSegment = (index: number, change: Partial) => + setDraft((current) => ({ ...current, segments: current.segments.map((segment, i) => (i === index ? { ...segment, ...change } : segment)) })); + const move = (index: number, delta: number) => setDraft((current) => { + const segments = [...current.segments]; + const [moved] = segments.splice(index, 1); + segments.splice(index + delta, 0, moved); + return { ...current, segments }; + }); + const remove = (index: number) => setDraft((current) => ({ ...current, segments: current.segments.filter((_, i) => i !== index) })); + const add = () => setDraft((current) => ({ + ...current, + segments: [...current.segments, { label: t('custom.newSpeech', { n: current.segments.length + 1 }), seconds: 240, kind: 'speech', side: 'a', poi: false }], + })); + + const valid = draft.segments.length > 0 && draft.segments.every((segment) => segment.label.trim()); + + if (!open) { + return ( +
+ +
+ ); + } + + return ( +
{ + event.preventDefault(); + if (!valid) return; + onApply({ id: 'custom', custom: { ...draft, name: draft.name.trim() || t('custom.defaultName'), segments: draft.segments.map((segment) => ({ ...segment, label: segment.label.trim() })) } }); + setOpen(false); + }} + > +

{t('custom.title')}

+
+ + +
+ +
    + {draft.segments.map((segment, index) => ( +
  1. + + + + + +
    + + + +
    +
  2. + ))} +
+

{t('custom.poiHint')}

+
+ + + +
+
+ ); +} diff --git a/src/components/free-tools/DebateTimer.tsx b/src/components/free-tools/DebateTimer.tsx new file mode 100644 index 000000000..d80276f51 --- /dev/null +++ b/src/components/free-tools/DebateTimer.tsx @@ -0,0 +1,369 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronLeft, ChevronRight, Maximize, Minimize, Pause, Play, RotateCcw, Volume2, VolumeX } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ClockAnnouncer, CLOCK_TONE_TEXT, clockTone } from '@/components/features/practice/RoundClock'; +import { useScreenWakeLock } from '@/hooks/use-screen-wake-lock'; +import { playSignal } from '@/lib/timer-signals'; +import { BUILT_IN_FORMATS, formatDuration, isBuiltInFormatId, type RoundSegment } from '@/lib/free-tools/formats'; +import { + clockFace, + cueBetween, + elapsedMs, + goToSegment, + initialRoundState, + isPrepRunning, + isSpeechRunning, + prepRemainingMs, + resetPrep, + resetSpeech, + segmentStatus, + settlePrep, + togglePrep, + toggleSpeech, + type PrepSide, +} from '@/lib/free-tools/round-timer'; +import { cn } from '@/lib/utils'; +import { formatFor, initialSession, searchForChoice, storeSession, type FormatChoice, type TimerSession } from './timer-session'; + +const SIDE_TONE: Record = { + a: 'bg-sky-600 dark:bg-sky-400', + b: 'bg-rose-600 dark:bg-rose-400', + both: 'bg-slate-500 dark:bg-slate-400', +}; + +function isTyping(target: EventTarget | null): boolean { + return target instanceof HTMLElement && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)); +} + +function isActivatable(target: EventTarget | null): boolean { + return target instanceof HTMLElement && ['BUTTON', 'A', 'SUMMARY'].includes(target.tagName); +} + +/** The protected and open stretches of a speech, with a marker for where the speaker is. */ +function PoiStrip({ segment, elapsed }: { segment: RoundSegment; elapsed: number }) { + const { t } = useTranslation('tools'); + if (!segment.poi) return null; + const total = segment.seconds; + const opens = (segment.poi.opensAt / total) * 100; + const closes = (segment.poi.closesAt / total) * 100; + const position = Math.min(100, (elapsed / 1000 / total) * 100); + return ( +