From 2d9ba5bff5d5e11a9eebac80f68086fd331bec98 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:28:27 +0100 Subject: [PATCH 01/10] feat: make editor responsive on mobile --- e2e/responsive.spec.ts | 87 ++++++++++++++++++++++++++++++++++ src/App.tsx | 71 +++++++++++++++++++++++---- src/components/PlaybackBar.tsx | 16 +++---- src/components/TopBar.tsx | 20 +++++--- 4 files changed, 170 insertions(+), 24 deletions(-) create mode 100644 e2e/responsive.spec.ts diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts new file mode 100644 index 0000000..dcd8904 --- /dev/null +++ b/e2e/responsive.spec.ts @@ -0,0 +1,87 @@ +import { expect, test, type Page } from '@playwright/test' +import { codePanel, expectNoPageErrors, openApp } from './app.js' + +async function expectNoHorizontalPageOverflow(page: Page): Promise { + 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) + }) + + 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' }) + + 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(page.getByTitle('Play / pause (Space)')).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() + 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) + 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) + }) +}) diff --git a/src/App.tsx b/src/App.tsx index cdff19a..d0ebbae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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. @@ -35,6 +43,7 @@ export default function App() { // (null = normal wall-clock playback) const [exportProgress, setExportProgress] = useState(null) const [activeStep, setActiveStep] = useState(0) + const [mobileWorkspace, setMobileWorkspace] = useState('preview') const update = useCallback((patch: Partial) => { setSettings((prev) => ({ ...prev, ...patch })) @@ -198,14 +207,50 @@ export default function App() { onSave={saveCurrent} onOpenProjects={() => setProjectsOpen(true)} /> -
- -
+ + + +
+
aside]:w-full lg:flex lg:w-[320px] lg:flex-none`} + > + +
+ +
- + +
aside]:w-full lg:flex lg:w-[300px] lg:flex-none`} + > + +
+ {exporting && ( +
{/* timeline */} -
+
{fmt(current)} -
+
-
+
+
{isSteps && (
-
+
-
+
{isSteps ? 'Intro' : 'Motion'} @@ -236,7 +236,7 @@ export function PlaybackBar({ />
-
+
{isSteps && ( Step {stepNow + 1} / {settings.steps.length} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 2b2447f..36b3425 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -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" > - GitHub + GitHub {starCount !== null && ( - + @@ -83,7 +83,7 @@ export function TopBar({ onOpenProjects: () => void }) { return ( -
+
@@ -104,11 +104,13 @@ export function TopBar({
-
+
From 36f4ebdb654011c7b098a4807bb733c65cda3ed9 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:38:18 +0100 Subject: [PATCH 02/10] fix: wrap playback controls on mobile --- src/components/PlaybackBar.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/components/PlaybackBar.tsx b/src/components/PlaybackBar.tsx index a37a06d..0106176 100644 --- a/src/components/PlaybackBar.tsx +++ b/src/components/PlaybackBar.tsx @@ -103,7 +103,7 @@ export function PlaybackBar({ const stepNow = isSteps ? currentStep(timeline, playback.progress) : 0 return ( -
+
{/* timeline */}
@@ -155,7 +155,11 @@ export function PlaybackBar({ )} {/* controls */} -
+
{isSteps && (
-
+
-
- +
+ {isSteps ? 'Intro' : 'Motion'} @@ -228,7 +232,8 @@ export function PlaybackBar({ title: m.wTitle || m.title, label: ( - {m.wLabel || m.label} + + {m.wLabel || m.label} ), } @@ -236,7 +241,7 @@ export function PlaybackBar({ />
-
+
{isSteps && ( Step {stepNow + 1} / {settings.steps.length} From e1b314ed634188fc1ab4967a839213e1fe72d536 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:38:37 +0100 Subject: [PATCH 03/10] test: prevent mobile playback overflow --- e2e/responsive.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index dcd8904..baeef79 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -26,15 +26,26 @@ for (const viewport of [ 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(page.getByTitle('Loop')).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) }) From 91439089d996cdb2e5dda35f9c68574ee22d6cde Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:18:28 +0100 Subject: [PATCH 04/10] test(e2e): give responsive WebGL checks more headroom --- e2e/responsive.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index baeef79..68fb66d 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -1,6 +1,8 @@ import { expect, test, type Page } from '@playwright/test' import { codePanel, expectNoPageErrors, openApp } from './app.js' +test.describe.configure({ timeout: 90_000 }) + async function expectNoHorizontalPageOverflow(page: Page): Promise { const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, From acda932b0f429cc7524e2dcc9f70e97d9e04ff05 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:29:09 +0100 Subject: [PATCH 05/10] test: run responsive WebGL checks serially --- e2e/responsive.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index 68fb66d..036f724 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -1,7 +1,10 @@ import { expect, test, type Page } from '@playwright/test' import { codePanel, expectNoPageErrors, openApp } from './app.js' -test.describe.configure({ timeout: 90_000 }) +// Each responsive test mounts CodeReel's continuously-rendering 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 { const overflow = await page.evaluate( From d530732811535716e554c0083dbb5c488504dc41 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:43:22 +0100 Subject: [PATCH 06/10] test: stabilise responsive checks around WebGL playback --- e2e/responsive.spec.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index 036f724..8e72a49 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -1,11 +1,25 @@ import { expect, test, type Page } from '@playwright/test' import { codePanel, expectNoPageErrors, openApp } from './app.js' -// Each responsive test mounts CodeReel's continuously-rendering WebGL preview. -// Keep this file serial so local UI mode does not run several canvases at once -// and starve Playwright's actionability checks. +// 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 pausePlayback(page: Page): Promise { + const toggle = page.locator('button[aria-label="Preview playback"]') + await expect(toggle).toHaveAttribute('aria-pressed', /true|false/, { timeout: 15_000 }) + + if ((await toggle.getAttribute('aria-pressed')) === 'true') { + // This is test setup, not the interaction under test. Trigger the native click + // directly so freezing the renderer does not itself depend on Playwright's + // actionability checks while WebGL is busy drawing the opening frames. + await toggle.evaluate((button: HTMLButtonElement) => button.click()) + } + + await expect(toggle).toHaveAttribute('aria-pressed', 'false') +} + async function expectNoHorizontalPageOverflow(page: Page): Promise { const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, @@ -22,6 +36,7 @@ for (const viewport of [ test.beforeEach(async ({ page }) => { await openApp(page) + await pausePlayback(page) }) test.afterEach(async ({ page }) => { @@ -62,6 +77,9 @@ for (const viewport of [ 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 pausePlayback(page) await expectNoHorizontalPageOverflow(page) await workspaceNav.getByRole('button', { name: 'Style' }).click() From 99275895150e229626947941b3111631cb984fdd Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:54:34 +0100 Subject: [PATCH 07/10] test: target loop control unambiguously --- e2e/responsive.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index 8e72a49..c0ea8b9 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -55,7 +55,9 @@ for (const viewport of [ await expect(playbackControls).toBeVisible() await expect(page.getByTitle('Restart (R)')).toBeVisible() await expect(page.getByTitle('Play / pause (Space)')).toBeVisible() - await expect(page.getByTitle('Loop')).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() @@ -63,9 +65,10 @@ for (const viewport of [ const playbackOverflow = await playbackControls.evaluate( (element) => element.scrollWidth - element.clientWidth, ) - expect(playbackOverflow, 'playback controls should not scroll horizontally').toBeLessThanOrEqual( - 1, - ) + expect( + playbackOverflow, + 'playback controls should not scroll horizontally', + ).toBeLessThanOrEqual(1) await expectNoHorizontalPageOverflow(page) }) From 249a3d37c07cac1448021e97bd64bb85ceb40dce Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:53:18 +0100 Subject: [PATCH 08/10] test: stabilise projects flow under CI rendering load --- e2e/app.ts | 11 +++++------ e2e/projects.spec.ts | 8 +++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/e2e/app.ts b/e2e/app.ts index e048516..5c102de 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -28,14 +28,13 @@ export async function currentTime(page: Page): Promise { * than the DOM — freezing playback first makes those comparisons deterministic. */ export async function pausePreview(page: Page): Promise { - // 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. + // This is test setup, not the interaction under test. Read the preview control's + // state first, then 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' }) + 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) diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index d28c02c..f02d63f 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -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 @@ -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 }) => { @@ -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') From 626d035526981111e11f8d94b41bbdc266b4972c Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:32:35 +0100 Subject: [PATCH 09/10] test: reuse shared preview pause helper --- e2e/responsive.spec.ts | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts index c0ea8b9..4b37395 100644 --- a/e2e/responsive.spec.ts +++ b/e2e/responsive.spec.ts @@ -1,25 +1,11 @@ import { expect, test, type Page } from '@playwright/test' -import { codePanel, expectNoPageErrors, openApp } from './app.js' +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 pausePlayback(page: Page): Promise { - const toggle = page.locator('button[aria-label="Preview playback"]') - await expect(toggle).toHaveAttribute('aria-pressed', /true|false/, { timeout: 15_000 }) - - if ((await toggle.getAttribute('aria-pressed')) === 'true') { - // This is test setup, not the interaction under test. Trigger the native click - // directly so freezing the renderer does not itself depend on Playwright's - // actionability checks while WebGL is busy drawing the opening frames. - await toggle.evaluate((button: HTMLButtonElement) => button.click()) - } - - await expect(toggle).toHaveAttribute('aria-pressed', 'false') -} - async function expectNoHorizontalPageOverflow(page: Page): Promise { const overflow = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, @@ -36,7 +22,7 @@ for (const viewport of [ test.beforeEach(async ({ page }) => { await openApp(page) - await pausePlayback(page) + await pausePreview(page) }) test.afterEach(async ({ page }) => { @@ -82,7 +68,7 @@ for (const viewport of [ await editor.fill(marker) // Editing code intentionally restarts playback. Freeze it again before the // remaining responsive interactions so WebGL cannot starve their clicks. - await pausePlayback(page) + await pausePreview(page) await expectNoHorizontalPageOverflow(page) await workspaceNav.getByRole('button', { name: 'Style' }).click() From d982607b708b43bb1facc7f8d9a33a0add07e3e0 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sat, 22 Aug 2026 07:42:56 +0100 Subject: [PATCH 10/10] test: allow preview pause from hidden mobile workspaces --- e2e/app.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/e2e/app.ts b/e2e/app.ts index 5c102de..8140105 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -28,10 +28,12 @@ export async function currentTime(page: Page): Promise { * than the DOM — freezing playback first makes those comparisons deterministic. */ export async function pausePreview(page: Page): Promise { - // This is test setup, not the interaction under test. Read the preview control's - // state first, then 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' }) + // 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.evaluate((button: HTMLButtonElement) => button.click())