Skip to content
Closed
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
63 changes: 63 additions & 0 deletions .github/workflows/pre-pr-mobile-responsive.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Pre-PR mobile responsive validation

on:
push:
branches: [validation/mobile-responsive-editor]
pull_request:
branches: [main]

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Verify lockfile stays unchanged
run: git diff --exit-code -- package-lock.json

- name: Format check
run: npm run format:check

- name: Lint
run: npm run lint

- name: Unit tests
run: npm run test:unit

- name: Typecheck and build
run: npm run build

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Responsive E2E
run: npx playwright test e2e/responsive.spec.ts --workers=1

- name: Responsive E2E serial stress
run: npx playwright test e2e/responsive.spec.ts --repeat-each=5 --workers=1

- name: Responsive E2E parallel stress
run: npx playwright test e2e/responsive.spec.ts --repeat-each=5 --workers=4

- name: Playback regression E2E
run: npx playwright test e2e/playback.spec.ts --workers=1

- name: Projects regression E2E
run: npx playwright test e2e/projects.spec.ts --workers=1

- name: Diff whitespace check
run: git diff --check 9f5a2746c9bebc70c2fa18d69cb09a4efbcfb39d...HEAD

- name: Clean worktree
run: test -z "$(git status --porcelain)"
87 changes: 87 additions & 0 deletions e2e/responsive.spec.ts
Original file line number Diff line number Diff line change
@@ -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<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)
})

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)
})
})
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
16 changes: 8 additions & 8 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 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,8 @@ 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 className="mt-2.5 flex items-center gap-x-3 gap-y-2.5 overflow-x-auto pb-1 lg:flex-wrap lg:overflow-visible lg:pb-0">
<div className="flex shrink-0 items-center gap-1.5">
{isSteps && (
<button
type="button"
Expand Down Expand Up @@ -209,9 +209,9 @@ export function PlaybackBar({
</button>
</div>

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

<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] font-medium tracking-wide text-zinc-500 uppercase">
{isSteps ? 'Intro' : 'Motion'}
</span>
Expand All @@ -236,7 +236,7 @@ export function PlaybackBar({
/>
</div>

<div className="ml-auto flex items-center gap-4">
<div className="flex shrink-0 items-center gap-4 lg:ml-auto">
{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