diff --git a/e2e/app.ts b/e2e/app.ts index e048516..8140105 100644 --- a/e2e/app.ts +++ b/e2e/app.ts @@ -28,14 +28,15 @@ 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. - 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) 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') diff --git a/e2e/responsive.spec.ts b/e2e/responsive.spec.ts new file mode 100644 index 0000000..4b37395 --- /dev/null +++ b/e2e/responsive.spec.ts @@ -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 { + 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) + }) +}) 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'} @@ -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} 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({
-
+