Skip to content
15 changes: 8 additions & 7 deletions e2e/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ 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> {
// 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' })
// This is test setup, not the interaction under test. The preview workspace may
// be visually hidden on mobile while Code or Style is active, so include hidden
// controls when locating the same mounted playback button. Trigger its native
// click directly so a busy software-rendered WebGL frame cannot starve
// Playwright's actionability checks while we pause it.
const toggle = page.getByRole('button', { name: 'Preview playback', includeHidden: true })
await expect(toggle).toHaveAttribute('aria-pressed', /true|false/, { timeout: 15_000 })
if ((await toggle.getAttribute('aria-pressed')) === 'true') {
await toggle.click()
await toggle.evaluate((button: HTMLButtonElement) => button.click())
}
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
await page.waitForTimeout(300)
Expand Down
8 changes: 7 additions & 1 deletion e2e/projects.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test, type Page } from '@playwright/test'
import { codePanel, expectNoPageErrors, openApp } from './app.js'
import { codePanel, expectNoPageErrors, openApp, pausePreview } from './app.js'

// Each Playwright test runs in a fresh browser context, so localStorage starts
// empty — no cross-test cleanup needed. The one intra-test persistence check
Expand Down Expand Up @@ -30,6 +30,9 @@ function row(page: Page, name: string) {
test.describe('Projects (local persistence)', () => {
test.beforeEach(async ({ page }) => {
await openApp(page)
// Project persistence is the behaviour under test here. Freeze the animated
// WebGL preview so software rendering cannot starve unrelated modal actions.
await pausePreview(page)
})

test.afterEach(async ({ page }) => {
Expand Down Expand Up @@ -78,6 +81,9 @@ test.describe('Projects (local persistence)', () => {
// switch to steps mode, then save as project 2
await codePanel(page).getByRole('button', { name: 'Steps' }).click()
await expect(codePanel(page).getByText('Step 1 / 3', { exact: true })).toBeVisible()
// Changing editor mode restarts playback. Pause it again before the second
// modal interaction for the same reason as the beforeEach setup above.
await pausePreview(page)
await openProjects(page)
await saveCurrentAs(page, 'Steps Project')

Expand Down
110 changes: 110 additions & 0 deletions e2e/responsive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { expect, test, type Page } from '@playwright/test'
import { codePanel, expectNoPageErrors, openApp, pausePreview } from './app.js'

// Each responsive test mounts CodeReel's WebGL preview. Keep this file serial so
// local UI mode does not run several canvases at once and starve Playwright's
// actionability checks.
test.describe.configure({ mode: 'serial', timeout: 90_000 })

async function expectNoHorizontalPageOverflow(page: Page): Promise<void> {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
)
expect(overflow, 'the page should not overflow horizontally').toBeLessThanOrEqual(1)
}

for (const viewport of [
{ width: 320, height: 800 },
{ width: 375, height: 812 },
]) {
test.describe(`mobile workspace at ${viewport.width}px`, () => {
test.use({ viewport })

test.beforeEach(async ({ page }) => {
await openApp(page)
await pausePreview(page)
})

test.afterEach(async ({ page }) => {
await expectNoPageErrors(page)
})

test('keeps preview and primary controls usable without page overflow', async ({ page }) => {
const workspaceNav = page.getByRole('navigation', { name: 'Editor workspace' })
const previewTab = workspaceNav.getByRole('button', { name: 'Preview' })
const playbackControls = page.getByRole('group', { name: 'Playback controls' })

await expect(workspaceNav).toBeVisible()
await expect(previewTab).toHaveAttribute('aria-pressed', 'true')
await expect(page.locator('main')).toBeVisible()
await expect(page.locator('main canvas')).toBeVisible({ timeout: 15_000 })
await expect(playbackControls).toBeVisible()
await expect(page.getByTitle('Restart (R)')).toBeVisible()
await expect(page.getByTitle('Play / pause (Space)')).toBeVisible()
await expect(
playbackControls.getByRole('button', { name: 'Loop', exact: true }),
).toBeVisible()
await expect(page.getByRole('button', { name: 'Open projects' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Save project' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Export GIF' })).toBeVisible()

const playbackOverflow = await playbackControls.evaluate(
(element) => element.scrollWidth - element.clientWidth,
)
expect(
playbackOverflow,
'playback controls should not scroll horizontally',
).toBeLessThanOrEqual(1)
await expectNoHorizontalPageOverflow(page)
})

test('switches Code, Preview and Style without losing editor state', async ({ page }) => {
const workspaceNav = page.getByRole('navigation', { name: 'Editor workspace' })
const marker = `const mobileWidth = ${viewport.width}`

await workspaceNav.getByRole('button', { name: 'Code' }).click()
const editor = page.locator('textarea').first()
await expect(editor).toBeVisible()
await editor.fill(marker)
// Editing code intentionally restarts playback. Freeze it again before the
// remaining responsive interactions so WebGL cannot starve their clicks.
await pausePreview(page)
await expectNoHorizontalPageOverflow(page)

await workspaceNav.getByRole('button', { name: 'Style' }).click()
const stylePanel = page.locator('aside').last()
await expect(stylePanel).toBeVisible()
const styleToggle = stylePanel.getByRole('switch').first()
await expect(styleToggle).toBeVisible()
const wasChecked = await styleToggle.getAttribute('aria-checked')
await styleToggle.click()
await expect(styleToggle).toHaveAttribute(
'aria-checked',
wasChecked === 'true' ? 'false' : 'true',
)
await expectNoHorizontalPageOverflow(page)

await workspaceNav.getByRole('button', { name: 'Code' }).click()
await expect(editor).toHaveValue(marker)

await workspaceNav.getByRole('button', { name: 'Preview' }).click()
await expect(page.locator('main canvas')).toBeVisible({ timeout: 15_000 })
await expectNoHorizontalPageOverflow(page)
})
})
}

test.describe('desktop workspace', () => {
test.use({ viewport: { width: 1600, height: 900 } })

test('preserves the existing three-column editor', async ({ page }) => {
await openApp(page)

await expect(page.getByRole('navigation', { name: 'Editor workspace' })).toBeHidden()
await expect(codePanel(page)).toBeVisible()
await expect(page.locator('main')).toBeVisible()
await expect(page.locator('aside').last()).toBeVisible()
await expectNoHorizontalPageOverflow(page)
await expectNoPageErrors(page)
})
})
71 changes: 62 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ import { PlaybackBar } from './components/PlaybackBar'
import { ExportModal } from './components/ExportModal'
import { ProjectsModal } from './components/ProjectsModal'

type MobileWorkspace = 'code' | 'preview' | 'style'

const MOBILE_WORKSPACES: { id: MobileWorkspace; label: string }[] = [
{ id: 'code', label: 'Code' },
{ id: 'preview', label: 'Preview' },
{ id: 'style', label: 'Style' },
]

export default function App() {
// Restore the last working draft (autosaved locally) so a reload continues where
// the user left off; fall back to a fresh project on first visit.
Expand All @@ -35,6 +43,7 @@ export default function App() {
// (null = normal wall-clock playback)
const [exportProgress, setExportProgress] = useState<number | null>(null)
const [activeStep, setActiveStep] = useState(0)
const [mobileWorkspace, setMobileWorkspace] = useState<MobileWorkspace>('preview')

const update = useCallback((patch: Partial<Settings>) => {
setSettings((prev) => ({ ...prev, ...patch }))
Expand Down Expand Up @@ -198,14 +207,50 @@ export default function App() {
onSave={saveCurrent}
onOpenProjects={() => setProjectsOpen(true)}
/>
<div className="flex min-h-0 flex-1">
<CodePanel
settings={settings}
update={update}
activeStep={activeStep}
setActiveStep={setActiveStep}
/>
<main className="flex min-w-0 flex-1 flex-col">

<nav
aria-label="Editor workspace"
className="grid shrink-0 grid-cols-3 gap-1 border-b border-white/5 bg-ink-900 p-1.5 lg:hidden"
>
{MOBILE_WORKSPACES.map((workspace) => {
const active = mobileWorkspace === workspace.id
return (
<button
key={workspace.id}
type="button"
aria-pressed={active}
onClick={() => setMobileWorkspace(workspace.id)}
className={`cursor-pointer rounded-lg px-3 py-2 text-[12px] font-medium transition-colors ${
active
? 'bg-accent-500/15 text-accent-300 ring-1 ring-accent-500/30'
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-300'
}`}
>
{workspace.label}
</button>
)
})}
</nav>

<div className="flex min-h-0 flex-1 overflow-hidden">
<div
className={`${
mobileWorkspace === 'code' ? 'flex' : 'hidden'
} min-h-0 flex-1 justify-center max-lg:[&>aside]:w-full lg:flex lg:w-[320px] lg:flex-none`}
>
<CodePanel
settings={settings}
update={update}
activeStep={activeStep}
setActiveStep={setActiveStep}
/>
</div>

<main
className={`${
mobileWorkspace === 'preview' ? 'flex' : 'hidden'
} min-w-0 flex-1 flex-col lg:flex`}
>
<PreviewPlaybackSurface playing={playback.playing} onTogglePlayback={toggle}>
<Suspense
fallback={
Expand All @@ -232,8 +277,16 @@ export default function App() {
onStep={goToStep}
/>
</main>
<StylePanel settings={settings} update={update} />

<div
className={`${
mobileWorkspace === 'style' ? 'flex' : 'hidden'
} min-h-0 flex-1 justify-center max-lg:[&>aside]:w-full lg:flex lg:w-[300px] lg:flex-none`}
>
<StylePanel settings={settings} update={update} />
</div>
</div>

{exporting && (
<ExportModal
settings={settings}
Expand Down
25 changes: 15 additions & 10 deletions src/components/PlaybackBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,13 @@ export function PlaybackBar({
const stepNow = isSteps ? currentStep(timeline, playback.progress) : 0

return (
<div className="border-t border-white/5 bg-ink-900 px-5 pt-3 pb-4">
<div className="min-w-0 shrink-0 border-t border-white/5 bg-ink-900 px-3 pt-3 pb-3 sm:px-5 sm:pb-4">
{/* timeline */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3">
<span className="w-10 text-right font-mono text-[11px] text-zinc-500 tabular-nums">
{fmt(current)}
</span>
<div className="relative flex-1">
<div className="relative min-w-0 flex-1">
<input
type="range"
min={0}
Expand Down Expand Up @@ -155,8 +155,12 @@ export function PlaybackBar({
)}

{/* controls */}
<div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2.5">
<div className="flex items-center gap-1.5">
<div
role="group"
aria-label="Playback controls"
className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2.5"
>
<div className="flex shrink-0 items-center gap-1.5">
{isSteps && (
<button
type="button"
Expand Down Expand Up @@ -209,10 +213,10 @@ export function PlaybackBar({
</button>
</div>

<div className="h-6 w-px bg-white/8" />
<div className="hidden h-6 w-px shrink-0 bg-white/8 sm:block" />

<div className="flex items-center gap-2">
<span className="text-[11px] font-medium tracking-wide text-zinc-500 uppercase">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-medium tracking-wide text-zinc-500 uppercase">
{isSteps ? 'Intro' : 'Motion'}
</span>
<Segmented<AnimationStyle>
Expand All @@ -228,15 +232,16 @@ export function PlaybackBar({
title: m.wTitle || m.title,
label: (
<span className="flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5" /> {m.wLabel || m.label}
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{m.wLabel || m.label}</span>
</span>
),
}
})}
/>
</div>

<div className="ml-auto flex items-center gap-4">
<div className="flex w-full min-w-0 flex-wrap items-center gap-x-4 gap-y-2 lg:ml-auto lg:w-auto lg:shrink-0 lg:flex-nowrap">
{isSteps && (
<span className="text-[11px] font-medium tracking-wide text-zinc-500 tabular-nums">
Step {stepNow + 1} / {settings.steps.length}
Expand Down
20 changes: 13 additions & 7 deletions src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ function GitHubLink() {
rel="noreferrer"
aria-label="View CodeReel on GitHub"
title="View CodeReel on GitHub"
className="flex h-[34px] items-center gap-1.5 rounded-lg bg-white/5 px-2.5 text-[12px] font-medium text-zinc-400 ring-1 ring-white/5 ring-inset transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-accent-400 focus-visible:outline-none"
className="hidden h-[34px] items-center gap-1.5 rounded-lg bg-white/5 px-2.5 text-[12px] font-medium text-zinc-400 ring-1 ring-white/5 ring-inset transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-accent-400 focus-visible:outline-none sm:flex"
>
<GitHubMark className="h-4 w-4" />
<span className="hidden sm:inline">GitHub</span>
<span className="hidden md:inline">GitHub</span>
{starCount !== null && (
<span className="hidden items-center gap-1 border-l border-white/10 pl-1.5 text-zinc-300 sm:flex">
<span className="hidden items-center gap-1 border-l border-white/10 pl-1.5 text-zinc-300 md:flex">
<Star className="h-3.5 w-3.5" aria-hidden="true" />
{STAR_COUNT_FORMATTER.format(starCount)}
</span>
Expand All @@ -83,7 +83,7 @@ export function TopBar({
onOpenProjects: () => void
}) {
return (
<header className="flex h-14 shrink-0 items-center gap-4 border-b border-white/5 bg-ink-900 px-4">
<header className="flex min-h-14 shrink-0 flex-wrap items-center gap-2 border-b border-white/5 bg-ink-900 px-3 py-2 lg:h-14 lg:flex-nowrap lg:gap-4 lg:px-4 lg:py-0">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-500 to-fuchsia-500 shadow-lg shadow-accent-500/30">
<Film className="h-4.5 w-4.5 text-white" />
Expand All @@ -104,11 +104,13 @@ export function TopBar({
</span>
</div>

<div className="ml-auto flex items-center gap-3">
<div className="flex w-full min-w-0 items-center justify-between gap-2 lg:ml-auto lg:w-auto lg:justify-end lg:gap-3">
<GitHubLink />
<button
type="button"
onClick={onOpenProjects}
aria-label="Open projects"
title="Open projects"
className="flex h-[34px] cursor-pointer items-center gap-1.5 rounded-lg bg-white/5 px-2.5 text-[12px] font-medium text-zinc-300 ring-1 ring-white/5 ring-inset transition-colors hover:bg-white/10 hover:text-white"
>
<FolderOpen className="h-4 w-4" />
Expand All @@ -117,6 +119,8 @@ export function TopBar({
<button
type="button"
onClick={onSave}
aria-label="Save project"
title="Save project"
className="flex h-[34px] cursor-pointer items-center gap-1.5 rounded-lg bg-white/5 px-2.5 text-[12px] font-medium text-zinc-300 ring-1 ring-white/5 ring-inset transition-colors hover:bg-white/10 hover:text-white"
>
<Save className="h-4 w-4" />
Expand All @@ -134,10 +138,12 @@ export function TopBar({
<button
type="button"
onClick={onExport}
className="flex cursor-pointer items-center gap-2 rounded-xl bg-gradient-to-br from-accent-500 to-fuchsia-500 px-4 py-2 text-[13px] font-semibold text-white shadow-lg shadow-accent-500/25 transition-all duration-150 hover:brightness-110 active:scale-[0.97]"
aria-label="Export GIF"
title="Export GIF"
className="flex h-[34px] cursor-pointer items-center gap-2 rounded-xl bg-gradient-to-br from-accent-500 to-fuchsia-500 px-2.5 text-[13px] font-semibold text-white shadow-lg shadow-accent-500/25 transition-all duration-150 hover:brightness-110 active:scale-[0.97] sm:h-auto sm:px-4 sm:py-2"
>
<Clapperboard className="h-4 w-4" />
Export GIF
<span className="hidden sm:inline">Export GIF</span>
</button>
</div>
</header>
Expand Down
Loading