diff --git a/e2e/app.ts b/e2e/app.ts index 70db7ff..e048516 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -28,9 +28,16 @@ export async function currentTime(page: Page): Promise { * than the DOM — freezing playback first makes those comparisons deterministic. */ export async function pausePreview(page: Page): Promise { - // move focus off any control so the window-level Space shortcut pauses playback - await page.getByRole('main').click({ position: { x: 4, y: 4 } }) - await page.keyboard.press('Space') + // Toggle playback off via the preview's own control (aria-pressed mirrors the + // clock). Idempotent: only clicks when it's actually playing, so callers land + // on a paused, stable frame no matter the current state. Note: clicking the + // preview surface itself also toggles playback, so we can't rely on a stray + // click elsewhere to "just move focus" any more. + const toggle = page.getByRole('button', { name: 'Preview playback' }) + if ((await toggle.getAttribute('aria-pressed')) === 'true') { + await toggle.click() + } + await expect(toggle).toHaveAttribute('aria-pressed', 'false') await page.waitForTimeout(300) } diff --git a/e2e/playback.spec.ts b/e2e/playback.spec.ts index a9d8135..02c099d 100644 --- a/e2e/playback.spec.ts +++ b/e2e/playback.spec.ts @@ -11,9 +11,6 @@ test.describe('Playback', () => { }) test('space pauses and resumes, R restarts', async ({ page }) => { - // move focus off any control so the window-level shortcuts fire - await page.getByRole('main').click({ position: { x: 4, y: 4 } }) - // the take auto-plays on load await expect.poll(() => currentTime(page)).toBeGreaterThan(0) @@ -30,12 +27,67 @@ test.describe('Playback', () => { // R restarts from the top. Let it play clearly past the start first so the // reset is unambiguous (a tiny `before` leaves too narrow a window to observe - // the drop, especially when a CPU-bound render shares the runner). - await expect.poll(() => currentTime(page)).toBeGreaterThan(1.5) + // the drop, especially when a CPU-bound render shares the runner). Give the + // poll extra room: under full-suite parallelism the rAF clock is starved and + // its dt clamp makes the playhead advance slower than wall-clock, so reaching + // 1.5s of progress can take noticeably longer than the default 5s timeout. + await expect.poll(() => currentTime(page), { timeout: 15000 }).toBeGreaterThan(1.5) await page.keyboard.press('r') await expect.poll(() => currentTime(page)).toBeLessThan(1) }) + test('preview and playback bar share the same playback state', async ({ page }) => { + const preview = page.getByRole('group', { name: 'Code preview' }) + const previewPlayback = preview.getByRole('button', { name: 'Preview playback' }) + const playbackBarToggle = page.getByTitle('Play / pause (Space)') + + await expect(previewPlayback).toHaveAttribute('aria-pressed', 'true') + + await preview.click() + await expect(previewPlayback).toHaveAttribute('aria-pressed', 'false') + + await playbackBarToggle.click() + await expect(previewPlayback).toHaveAttribute('aria-pressed', 'true') + + // The nested preview button owns its click; the surface must not toggle a second time. + await previewPlayback.click() + await expect(previewPlayback).toHaveAttribute('aria-pressed', 'false') + }) + + test('preview playback control supports keyboard activation', async ({ page }) => { + const previewPlayback = page.getByRole('button', { name: 'Preview playback' }) + + await previewPlayback.focus() + await expect(previewPlayback).toHaveAttribute('aria-pressed', 'true') + + await page.keyboard.press('Space') + await expect(previewPlayback).toHaveAttribute('aria-pressed', 'false') + + await page.keyboard.press('Enter') + await expect(previewPlayback).toHaveAttribute('aria-pressed', 'true') + }) + + test('clicking the preview surface freezes and resumes the real clock', async ({ page }) => { + // aria-pressed alone could be cosmetic — assert the toggle drives the actual + // playback clock. This guards the wiring: the surface must wrap the live + // renderer, not a detached/placeholder one. + const preview = page.getByRole('group', { name: 'Code preview' }) + + // the take auto-plays on load + await expect.poll(() => currentTime(page)).toBeGreaterThan(0) + + // click the surface (center hits the canvas, not the corner button) — pauses + await preview.click() + await page.waitForTimeout(250) + const paused = await currentTime(page) + await page.waitForTimeout(400) + expect(await currentTime(page)).toBe(paused) + + // click again — the clock advances past where it froze + await preview.click() + await expect.poll(() => currentTime(page)).toBeGreaterThan(paused) + }) + test('duration selector drives the total timeline length', async ({ page }) => { // isolate the duration from the end-hold extension (which also adds to the total) const endHold = page.locator('select', { diff --git a/src/App.tsx b/src/App.tsx index 962fc92..55e1ceb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { buildTimeline, currentStep, stepAnchor } from './lib/timeline' import { TopBar } from './components/TopBar' import { CodePanel, makeDefaultSteps } from './components/CodePanel' import { StylePanel } from './components/StylePanel' +import { PreviewPlaybackSurface } from './components/PreviewPlaybackSurface' import { BrandOverlay } from './components/BrandOverlay' // WebGL is the sole renderer; lazy-load it so the three.js/R3F bundle isn't part of // the initial paint (a lightweight fallback shows while it streams in). @@ -185,7 +186,7 @@ export default function App() { setActiveStep={setActiveStep} />
-
+ @@ -201,7 +202,7 @@ export default function App() { /> -
+ void + children: ReactNode +}) { + const handleSurfaceClick = (event: MouseEvent) => { + if (event.defaultPrevented || isInteractiveTarget(event.target)) return + onTogglePlayback() + } + + const handleSurfaceKeyDown = (event: KeyboardEvent) => { + if (isInteractiveTarget(event.target)) event.stopPropagation() + } + + return ( +
+ {children} + +
+ ) +}