From 676bc099150b5dd825a378ec7ca37bc899a7e6a1 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:39:37 +0100 Subject: [PATCH 1/2] feat: toggle playback from preview canvas --- e2e/playback.spec.ts | 34 +++++++++++- src/App.tsx | 13 +++-- src/components/PreviewPlaybackSurface.tsx | 68 +++++++++++++++++++++++ 3 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 src/components/PreviewPlaybackSurface.tsx diff --git a/e2e/playback.spec.ts b/e2e/playback.spec.ts index a772017..827cc29 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) @@ -34,6 +31,37 @@ test.describe('Playback', () => { await expect.poll(() => currentTime(page)).toBeLessThan(before) }) + 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('duration selector drives the total timeline length', async ({ page }) => { const durationSelect = page.locator('select', { has: page.locator('option', { hasText: '15s' }), diff --git a/src/App.tsx b/src/App.tsx index 3835ae1..6edffb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { TopBar } from './components/TopBar' import { CodePanel, makeDefaultSteps } from './components/CodePanel' import { StylePanel } from './components/StylePanel' import { PreviewCanvas } from './components/PreviewCanvas' +import { PreviewPlaybackSurface } from './components/PreviewPlaybackSurface' import { PlaybackBar } from './components/PlaybackBar' import { ExportModal } from './components/ExportModal' @@ -125,11 +126,13 @@ export default function App() { setActiveStep={setActiveStep} />
- + + + 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} + +
+ ) +} From 831ff4b99bc3a5de8fde239b5e501b860b7f1eda Mon Sep 17 00:00:00 2001 From: Eddie Jaoude Date: Sat, 15 Aug 2026 09:21:57 +0100 Subject: [PATCH 2/2] fix: wire preview playback toggle to the WebGL renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #26 was authored against the DOM PreviewCanvas renderer and merged after the WebGL-only consolidation deleted it, leaving App.tsx importing a missing module (build broke on TS2307) and wrapping the toggle surface around the retired renderer instead of WebGLScene. - App: drop the dead PreviewCanvas import; wrap PreviewPlaybackSurface around the live WebGLScene + BrandOverlay. - e2e/app: pausePreview now pauses idempotently via the "Preview playback" button's aria-pressed — clicking the surface now toggles playback, so the old focus-nudge click would have un-paused instead. - e2e/playback: add a test asserting a surface click freezes/resumes the real clock (not just aria-pressed); extend the R-restart poll timeout so the starved-clock case under full-suite parallelism isn't flaky. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/app.ts | 13 ++++++++++--- e2e/playback.spec.ts | 28 ++++++++++++++++++++++++++-- src/App.tsx | 10 +--------- 3 files changed, 37 insertions(+), 14 deletions(-) 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 7cdbde0..02c099d 100644 --- a/e2e/playback.spec.ts +++ b/e2e/playback.spec.ts @@ -27,8 +27,11 @@ 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) }) @@ -64,6 +67,27 @@ test.describe('Playback', () => { 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 b98b99c..55e1ceb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,6 @@ import { buildTimeline, currentStep, stepAnchor } from './lib/timeline' import { TopBar } from './components/TopBar' import { CodePanel, makeDefaultSteps } from './components/CodePanel' import { StylePanel } from './components/StylePanel' -import { PreviewCanvas } from './components/PreviewCanvas' 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 @@ -188,13 +187,6 @@ export default function App() { />
- - -
@@ -210,7 +202,7 @@ export default function App() { /> -
+