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
13 changes: 10 additions & 3 deletions e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,16 @@ export async function currentTime(page: Page): Promise<number> {
* than the DOM — freezing playback first makes those comparisons deterministic.
*/
export async function pausePreview(page: Page): Promise<void> {
// 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)
}

Expand Down
62 changes: 57 additions & 5 deletions e2e/playback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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', {
Expand Down
5 changes: 3 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -185,7 +186,7 @@ export default function App() {
setActiveStep={setActiveStep}
/>
<main className="flex min-w-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1">
<PreviewPlaybackSurface playing={playback.playing} onTogglePlayback={toggle}>
<Suspense
fallback={
<div className="stage-grid flex min-h-0 flex-1 items-center justify-center text-sm text-zinc-500">
Expand All @@ -201,7 +202,7 @@ export default function App() {
/>
</Suspense>
<BrandOverlay settings={settings} progress={sceneProgress} />
</div>
</PreviewPlaybackSurface>
<PlaybackBar
settings={settings}
update={update}
Expand Down
68 changes: 68 additions & 0 deletions src/components/PreviewPlaybackSurface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Pause, Play } from 'lucide-react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'

const INTERACTIVE_SELECTOR = [
'a[href]',
'area[href]',
'button',
'input',
'select',
'textarea',
'summary',
'label',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable="false"])',
'[role="button"]',
'[role="link"]',
'[tabindex]:not([tabindex="-1"])',
].join(',')

function isInteractiveTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest(INTERACTIVE_SELECTOR) !== null
}

export function PreviewPlaybackSurface({
playing,
onTogglePlayback,
children,
}: {
playing: boolean
onTogglePlayback: () => void
children: ReactNode
}) {
const handleSurfaceClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.defaultPrevented || isInteractiveTarget(event.target)) return
onTogglePlayback()
}

const handleSurfaceKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (isInteractiveTarget(event.target)) event.stopPropagation()
}

return (
<div
role="group"
aria-label="Code preview"
onClick={handleSurfaceClick}
onKeyDown={handleSurfaceKeyDown}
className="group relative flex min-h-0 flex-1 cursor-pointer"
>
{children}
<button
type="button"
aria-label="Preview playback"
aria-pressed={playing}
title={playing ? 'Pause preview' : 'Play preview'}
onClick={onTogglePlayback}
className="absolute top-4 right-4 z-10 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 shadow-lg backdrop-blur-sm transition-all duration-150 hover:bg-black/65 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-400 active:scale-95"
>
{playing ? (
<Pause aria-hidden="true" className="h-4 w-4" />
) : (
<Play aria-hidden="true" className="ml-0.5 h-4 w-4" />
)}
</button>
</div>
)
}
Loading