Skip to content

Instantly share code, notes, and snippets.

@quanhua92
Last active June 29, 2026 15:29
Show Gist options
  • Select an option

  • Save quanhua92/62df24af3b0b05afe931d46b24a21ac9 to your computer and use it in GitHub Desktop.

Select an option

Save quanhua92/62df24af3b0b05afe931d46b24a21ac9 to your computer and use it in GitHub Desktop.
OpenVideoKit web — 8-phase build plan (P0–P7): TanStack Router + React 19 + Tailwind v4 + shadcn/ui, CapCut-style responsive studio, AI-first-class editor

OpenVideoKit — Web Build Plan

8-phase plan to ship a scene-based, AI-first, mobile+desktop video editor SPA on top of HyperFrames.

Stack: TanStack Router · React 19 · Tailwind v4 · shadcn/ui · Vite 8 · Zustand · TanStack Query · zod · MSW · dnd-kit · CodeMirror 6 · Biome

Spec sources:


Phases at a glance

# Phase Deliverable
P0 Foundation Boots, routes navigate, providers wired, shadcn/ui init, MSW + zod schemas, AI/EditBus types exported. Empty Studio shell.
P1 Responsive Studio shell ONE <Studio> (CapCut bottom-sheet mobile / 4-zone desktop via shadcn ResizablePanelGroup + Sheet + Tabs). 6 PanelSlots reserved incl. AI. Playhead Zustand store.
P2 Read-only studio wired to mock HF renderer behind SlideRendererContext DI. Timeline + Properties + Captions render from mock fixture. Scrub → tl.time() seeks, slide swaps, audio syncs via timeupdate. Mock AI Dock with 3-scenario picker.
P3 Editing — props + timeline EditBus runtime (every mutation dispatches a typed op). Live-bind via ref writes (no re-render, GSAP survives). dnd-kit touch+mouse reorder. Undo/redo. SHA-256 image uploads.
P4 Voiceover + captions Mock TTS pipeline (deterministic durations). Word-level karaoke via timeWordsByCharRatio. GSAP direct-color tweens. 4 caption styles. Lint predicates block banned patterns in CI.
P5 Per-slide HTML editor + LintGate CodeMirror 6 lazy-loaded. R1–R4 lintHtml in shared/lib (pure, sync — imported by both editor and AI). Accept/revert atomic. Placeholder gutter markers.
P6 AI Dock (Tier-1 + Tier-2) AIProviderContext + EchoProvider (mock) + stubbed real providers. RFC 6902 JSON Patch for Tier-1; full HTML swap for Tier-2. Accept always dispatches via EditBus — same op shape as human edits. Tier-2 gated by lintHtml. Inline diff + expand. Settings → AI panel. Human edits surface as system pings.
P7 Asset library + export pipeline IndexedDB-backed SHA-256 content addressing. AssetDropzone + library grid + search. 6-step export pipeline UI (assemble → stamp → voiceover → captions → render → progress) streaming mock events. aHash render cache.

Every phase opens with a Problem / Solution section explaining the architectural pain points and how the phase resolves them — not just what ships, but why.


What you have after all 8 phases

A production-grade, AI-first, mobile+desktop video editor SPA — a CapCut-class scene editor in the browser, built on HyperFrames, fully mock-wired and ready for real backend swap-in.

Capabilities

Surface What works
Studio One responsive component: 4-zone desktop (rail / stage / props / timeline, all resizable) ↔ CapCut-style mobile (stage + transport + bottom-sheet tools, one panel active at a time)
Stage Live HF preview; scrub drives GSAP tl.time() via rAF (no per-frame React re-renders); slide swap on boundary cross; audio sync via external <audio> + timeupdate
Timeline Drag-reorder (dnd-kit, touch + mouse with activation distance); add/remove slide; playhead; audio lanes; per-slide durations measured from TTS (read-only field)
Properties Live-bind text fields (ref-write — no re-stamp, GSAP timeline survives); image upload → SHA-256 ref; voiceover textarea triggers batch TTS; per-slide transition override
HTML editor CodeMirror 6 (route-split, ~400KB lazy-loaded); R1–R4 lint gate refuses <html> wrapper, missing data-composition-id, Tailwind usage; accept/revert atomic; placeholder gutter markers warn on __FIELD__ deletion
Captions Word-level karaoke with per-word timing by char ratio; 4 styles (highlight / neon / editorial / eco-green); GSAP direct-color tweens (no transform / scale / font-size / text-shadow on .word--active); CI lint catches banned patterns
AI Dock Mock streaming via EchoProvider; OpenAI/Anthropic/Ollama stubbed behind AIProvider interface; Tier-1 RFC 6902 JSON patches + Tier-2 HTML swaps; every Accept dispatches via EditBus (same op as a keyboard edit); Tier-2 auto-rejected if lintHtml fails; inline unified diff + expand to side-by-side dialog; Settings → AI provider switch (localStorage); human edits surface as system pings
Asset library IndexedDB persistence; SHA-256 content addressing (dedup free); drag-to-field; search by ref prefix
Export 6-step pipeline UI streaming mock events (assemble → stamp → voiceover → captions → render → progress); aHash + Hamming ≤ 5 render cache skips unchanged slides; HTML5 <video> preview + download
Undo/redo ⌘Z / ⌘⇧Z work uniformly across all edit types (field, slide order, HTML, asset, voiceover) — driven by the EditBus event stream

Architectural guarantees baked in

  • AI is just another client of the editing API. No backdoor. Human keyboard edits and AI proposals hit the same editBus.dispatch(op) path, the same applyOp reducer, the same inverseOp for undo, the same EditEvent stream for audit log + chat system pings, and the same lintHtml() gate (Tier-2). Switching AI providers changes one Context value; nothing else.
  • HyperFrames is pluggable. Every editor file consumes useSlideRenderer(); only shared/renderer/hfRenderer.ts imports HF directly. Swap to the own-renderer (RFC §9.3) by implementing the same 3-method interface. Preview ↔ export fidelity drift becomes structurally impossible (RFC §9.4) — both go through one engine.
  • Mock-first. Every API call goes through a typed client.ts → MSW handler → zod parse. P7+ swaps MSW for a real FastAPI backend without touching any call site.
  • AGENTS.md contract honored. __FIELD__ stamping (function-form replaceAll), bare <template> (no <html> wrapper), GSAP direct-color tweens for captions, no banned patterns on .word--active, voice IDs end in Neural, position:absolute;inset:0 host divs, text-align:center + padding-top:XXvh for vertical centering.
  • shadcn/ui throughout. No hand-rolled Button/Dialog/Tooltip/etc. Components added via CLI, themed via shadcn CSS variables, formatted by Biome. Custom CSS reserved for the caption-active color token (--caption-active: oklch(0.92 0.18 95)#ffea00) and the 4 caption style files.
  • Performance invariants. Playhead writes via Zustand getState() inside rAF (zero React re-renders per frame). Live-bind writes via ref.textContent (no slide tree re-mount). CodeMirror chunk is route-split. Asset blob URLs revoked on unmount.

Deferred (post-P7)

Items intentionally out of scope for the 8 phases. Each has a clear insertion point when prioritized.

Backend & cloud (RFC §13 — P1 cloud phase)

  • Real FastAPI backend. Replace MSW handlers with real fetch calls to a Python server reusing the existing src/openvideokit/ modules (templating.py, voiceover.py, captions.py, rendering.py). Call sites unchanged.
  • Real edge-tts + ffprobe + ffmpeg pipeline. The mock /api/tts endpoint returns deterministic durations from a text hash; real impl runs the batch TTS pipeline from voiceover.py.
  • Real npx hyperframes render subprocess. Export mock streams canned events; real impl spawns the HF CLI and parses stdout (features/export/lib/parseProgress.ts already structured for this).
  • OAuth2/JWT auth, project metadata sync, S3 + presigned asset URLs, asset catalog + search, credit ledger. All RFC §13 cloud control plane concerns.
  • Cross-device project sync. Cloud stores project index.json + slide HTML as opaque content; no inference in cloud.

AI providers

  • Real OpenAI / Anthropic / Ollama HTTP wiring. P6 ships the AIProvider abstraction and stubs that throw "Not implemented in P6". Real impls consume the same stream(messages, ctx) → AsyncIterable<AIStreamEvent> contract — no editor code changes.
  • Tier-2 HTML generation quality. EchoProvider returns canned HTML; real providers generate actual GSAP/CSS. Quality of generated HTML is a prompt-engineering concern, not architectural.
  • API key encryption. P6 stores keys in localStorage under ovk:ai: prefix with a UI warning. Real encryption would use WebCrypto with a user-supplied passphrase (out of scope for local-first v1).
  • Move/copy/test JSON Patch ops. P6's applyPatch implements replace/add/remove only. If real providers emit move/copy/test, extend applyPatch (single file).

Renderer

  • Own renderer to replace HyperFrames (RFC §9.3). Headless-Chromium frame capture + FFmpeg encode/mux. Slides in behind the same SlideRenderer interface; editor and export unchanged.
  • Frame capture for aHash render cache. P7's cache mock returns placeholder hashes; real HIT detection needs the own-renderer's renderAt(t) → frame to return pixel data.

Editor features

  • transitions bundle (RFC §5 root between-slide transitions). The 17th bundle in the curriculum, marked [~] deferred. Would add a transitions editor surface + the <!-- SCENE_TRANSITIONS --> marker injection (currently captions-only).
  • Multi-slide selection / batch operations. Timeline is single-selection in P3; multi-select + batch-reorder/delete is a follow-up.
  • Collaborative editing (multi-cursor). Out of scope per RFC §20.
  • Native signed installer. v1 ships as a local web app; pywebview / Electron / Tauri-with-Python-sidecar is a deferred packaging step (RFC §14) — zero core rewrite either way.

Asset library

  • Asset purge UI. IndexedDB quota warnings ship in P7; user-driven purge is a follow-up.
  • Asset folders/tags. P7 is flat (SHA-ref + mime + date); hierarchical organization is a UX follow-up.
  • Browser image compression on upload. Optional; would cap asset size before storing. package.json reserves the dep slot.

Polish

  • Command palette (⌘K). app/layout/CommandPalette.tsx reserved in P0 layout; wiring is a follow-up using shadcn Command (cmdk).
  • Keyboard shortcuts panel. P3 ships ⌘Z / ⌘⇧Z; full shortcuts help is a follow-up.
  • buildCaptionHtml / buildCaptionTimelineJs consumed by export. P4 ships these (locked contract); P7 wires them into assembleWorkspace. If caption format changes, the contract is the single update point.

How to use this plan

Each .md is independently shippable. Suggested workflow:

  1. Read the phase's Problem / Solution first — that's the architectural commitment.
  2. Skim Files added / modified for scope.
  3. Work the Tasks checklist top-to-bottom (it's ordered by dependency).
  4. Run Verification before declaring the phase done — Vitest fixtures are pinned from the curriculum bundles where possible.
  5. Check Risks / open questions for known sharp edges.

Phases build linearly (P0 → P7) but the contracts between them are stable: P3's EditOp shapes are what P6 AI proposals produce; P5's lintHtml() is what P6 Tier-2 gates through; P4's caption helpers are what P7 export consumes. Changing a contract means updating two files (the producer + the consumer), not refactoring end-to-end.

Phase P0 — Foundation

App boots, routes navigate, mock data layer in place, AI/EditBus primitive types defined. Studio route renders an empty shell.

Problem

Before this phase, ovk-web/ is a bare Vite + React 19 + TanStack Router scaffold with several blockers that make every later phase harder:

  1. Duplicate router wiring. src/router.tsx and src/main.tsx both create a router with subtly different options (defaultPreloadStaleTime only in router.tsx). One is dead code; later phases won't know which to extend.
  2. No providers. No Query provider (server state), no Toast provider (feedback), no Renderer provider (DI seam from bundle §9). Every feature phase would have to retrofit its own.
  3. No API layer. No typed client, no zod schemas, no mock transport. Phase P2 onward would either invent fixtures inline or couple directly to a real backend that doesn't exist yet (RFC §13 says cloud is deferred past v1).
  4. No design tokens. Tailwind v4 is installed but @theme is empty. Without reserved tokens, #ffea00 (caption-active per AGENTS.md CRITICAL RULES) gets reused for unrelated UI and the contract drifts.
  5. AI is not first-class yet. There are no exported types for AIProvider, EditProposal, or EditBus. P3+ cannot reference a stable contract, so AI gets bolted on as an afterthought in P6 instead of being structural.
  6. No AppShell. Without a top-level layout + route skeleton, P1 has no surface to lay panels onto.

Solution

This phase establishes the contracts every later phase depends on, without building any feature yet:

  • Consolidate the router into a single app/router.ts and delete src/router.tsx. main.tsx imports from there.
  • Wire three providers in __root.tsx: QueryProvider (server state), RendererProvider (HF DI seam — empty impl), and <Toaster/> from shadcn/ui's sonner (feedback). No manual TooltipProvider — shadcn's tooltip component (added in P1) wraps its own. Later phases inject real values, never add new providers.
  • Initialize shadcn/ui via pnpm dlx shadcn@latest init. The CLI generates components.json, src/lib/utils.ts (the cn() helper), and rewrites src/styles.css with the full shadcn theme variable system (--background, --foreground, --primary, --muted, --border, etc. in OKLCH; :root + .dark blocks; @layer base reset). P0 only inits — no components added yet; those land in P1 as needed.
  • Mock-first API layer: typed fetch client that runs every response through zod. zod schemas for RootIndex and SlideIndex lifted verbatim from RFC §5.2/§5.3. MSW handlers serve a 3-slide fixture project so P2 can render against realistic data.
  • Reserve one custom token alongside shadcn's theme: --caption-active: oklch(0.92 0.18 95) (≈ #ffea00) in @theme, used exclusively for caption word highlighting. A CI grep check forbids this color literal outside features/captions/**. Everything else uses shadcn's --primary, --accent, --destructive, etc.
  • Export AI/EditBus types only (no runtime) from shared/ai/types.ts and shared/edit/EditBus.ts. They define the op shape (setField, reorderSlides, setSlideHtml, etc.) that P3 implements and P6 dispatches through. Defining the contract now means every later phase programs to an interface, not an implementation.
  • AppShell + routing skeleton: top bar with project switcher placeholder, <Outlet/>, and five route files registered (index, projects, projects.$projectId, projects.$projectId/editor, templates).

Deliverable

A user (or reviewer) can:

  1. Run pnpm dev.
  2. Navigate to / → see AppShell + home placeholder.
  3. Navigate to /projects/proj-1/editor → see empty Studio shell (just the routed page, no panels yet).
  4. Open React DevTools → confirm three providers are mounted (QueryClientProvider, RendererProvider, plus the Toaster DOM root).
  5. Open MSW DevTools → confirm fixture project handler responds.
  6. Run pnpm test → zod schemas parse the fixture and reject invalid variants (bad caption_style, missing slides, voice id without Neural suffix).

Files added / modified

ovk-web/
├── src/
│   ├── main.tsx                              # MODIFIED — imports app/router
│   ├── router.tsx                            # DELETED
│   ├── styles.css                            # MODIFIED — @theme tokens
│   ├── app/
│   │   ├── router.ts                         # NEW — single router source
│   │   ├── providers/
│   │   │   ├── QueryProvider.tsx
│   │   │   ├── RendererProvider.tsx          # empty SlideRenderer (impl in P2)
│   │   │   └── Toaster.tsx                   # NEW — re-export shadcn sonner Toaster
│   │   └── layout/
│   │       └── AppShell.tsx
│   ├── routes/
│   │   ├── __root.tsx                        # MODIFIED — mount providers
│   │   ├── index.tsx                         # MODIFIED — home placeholder
│   │   ├── projects.tsx                      # NEW
│   │   ├── projects.$projectId.tsx           # NEW — layout route
│   │   └── projects.$projectId/
│   │       └── editor.tsx                    # NEW — empty shell
│   ├── lib/
│   │   └── utils.ts                          # NEW — generated by shadcn init (cn helper)
│   └── shared/
│       ├── ai/
│       │   └── types.ts                      # NEW — AIProvider, EditProposal, AIStreamEvent
│       ├── edit/
│       │   ├── EditBus.ts                    # NEW — EditEvent types + op shapes (no runtime)
│       │   └── ops.ts                        # NEW — op creator signatures only
│       ├── api/
│       │   ├── client.ts                     # NEW — fetch + zod parse
│       │   ├── schemas/
│       │   │   ├── rootIndex.ts              # NEW — RFC §5.2 zod
│       │   │   └── slideIndex.ts             # NEW — RFC §5.3 zod
│       │   └── msw/
│       │       ├── handlers.ts               # NEW
│       │       └── fixtures.ts               # NEW — 3-slide project
│       └── lib/
│           └── placeholders.ts               # NEW — port from data_binding bundle
├── components.json                           # NEW — shadcn config (style, aliases)
└── package.json                              # MODIFIED — add deps

Key decisions

  • shadcn/ui is the design system. Components are copied into src/components/ui/ (not an npm dep), owned by us, themed via the shadcn CSS variable system. No hand-rolled Button/Dialog/Tooltip etc. — every later phase adds pnpm dlx shadcn@latest add <name> instead of writing primitives.
  • shadcn config tuned for this repo: style: "new-york", rsc: false (Vite SPA, not Next.js), tsx: true, baseColor: "neutral", iconLibrary: "lucide" (already in deps). Aliases: @/components, @/lib, @/shared. Biome as formatter (set in components.json so generated files match repo style).
  • Mock-first via MSW. Every API call goes through client.ts → MSW → zod. P7 swaps MSW handlers for a real backend without touching call sites.
  • One custom token alongside shadcn's theme: --caption-active: oklch(0.92 0.18 95) (≈ #ffea00). Reserved exclusively for caption word highlighting (per AGENTS.md CRITICAL RULES). A CI grep check enforces the literal appears only in features/captions/**. Everything else uses shadcn tokens.
  • AI types are exported, not implemented. P3 builds the runtime that satisfies these types; P6 builds the provider that produces them. Locking the contract now prevents interface churn.
  • RendererProvider ships empty in P0 and gets a real HF impl in P2. The provider is structural from day 1 so no feature file imports HF directly.
  • One router file. app/router.ts is the only place createRouter is called. main.tsx imports and mounts it.

Tasks

Repo cleanup

  • Delete src/router.tsx
  • Create src/app/router.ts with consolidated options (defaultPreload: 'intent', scrollRestoration: true, defaultPreloadStaleTime: 0)
  • Update src/main.tsx to import from app/router.ts
  • Verify Register module augmentation lives once

Dependencies

  • Add: @tanstack/react-query, @tanstack/react-query-devtools, zustand, zod, msw
  • Add dev deps: @tanstack/router-devtools (core), @testing-library/react (already present)
  • Run pnpm install and verify lockfile updates

shadcn/ui init

  • Run pnpm dlx shadcn@latest init — answer: style new-york, base color neutral, CSS variables yes, RSC no, aliases @/components, @/lib/utils, @/shared, @/hooks
  • Verify generated: components.json, src/lib/utils.ts (the cn() helper using clsx + tailwind-merge)
  • Verify src/styles.css rewritten with shadcn theme variables (:root + .dark blocks, @layer base reset, @custom-variant dark)
  • Add clsx, tailwind-merge, class-variance-authority, lucide-react (shadcn peers — CLI installs these)
  • Add the custom caption token: append --caption-active: oklch(0.92 0.18 95); to the :root block in styles.css (NOT in .dark — caption color is constant across themes per AGENTS.md)
  • Configure components.json formatter to Biome (so shadcn add matches repo lint)
  • Add shadcn's sonner for toasts: pnpm dlx shadcn@latest add sonner

Schemas + MSW

  • Port RootIndexSchema from RFC §5.2 with all invariants (canvas fps enum, music volume 0–1, slides uniqueness)
  • Port SlideIndexSchema from RFC §5.3 with voice id Neural suffix check
  • Write fixture: 3 slides, 1 music asset (sha256:...), 1 voiceover asset, caption_style: 'highlight'
  • MSW handlers: GET /api/projects, GET /api/projects/:id, GET /api/projects/:id/slides/:slideId
  • Enable MSW in dev only (browser worker)

Types (no runtime)

  • shared/ai/types.ts: AIProvider, AIMessage, AIStreamEvent, EditProposal, AIContext
  • shared/edit/EditBus.ts: EditEvent discriminated union, EditOp types (signatures only)
  • shared/edit/ops.ts: setField, reorderSlides, addSlide, removeSlide, setVoiceover, setTransition, setAsset, setSlideHtml — op creator function signatures

App skeleton

  • app/layout/AppShell.tsx: top bar with logo + project switcher (static) + Export button placeholder — compose from shadcn Button and DropdownMenu (add via pnpm dlx shadcn@latest add button dropdown-menu)
  • app/providers/Toaster.tsx: re-export shadcn's <Toaster/> from sonner with our theme (richColors, position bottom-right desktop / top-center mobile)
  • __root.tsx: mount AppShell + QueryProvider + RendererProvider + <Toaster/> + <Outlet/>
  • Route files: index.tsx (home placeholder), projects.tsx, projects.$projectId.tsx (layout, redirects to editor), projects.$projectId/editor.tsx (empty)
  • Verify navigation: //projects/projects/proj-1/editor

Helpers

  • shared/lib/placeholders.ts: port placeholderFor(id) + stampSafe(html, id, value) from data_binding bundle; include fixture-based test asserting naive form corrupts on $& and safe form doesn't
  • Skip writing cn() manually — use the shadcn-generated src/lib/utils.ts

Verification

Manual:

  • pnpm dev boots, no console errors
  • Navigate /projects/proj-1/editor → empty Studio page renders inside AppShell
  • React DevTools shows: QueryClientProviderRendererProviderRouterProvider; Toaster mounted as DOM root
  • MSW DevTools shows handlers registered

Automated:

  • pnpm test — zod schemas: parse fixture ✓; reject bad caption_style ✓; reject voice without Neural ✓; reject duplicate slides
  • pnpm teststampSafe round-trip: replace __TITLE__ with value containing $& preserves value
  • pnpm check (biome) passes — including shadcn-generated files
  • pnpm build succeeds; main chunk < 250KB (shadcn button + dropdown-menu + sonner only)
  • CI grep: oklch(0.92 0.18 95) literal appears only in features/captions/** and src/styles.css definition

Dependencies

  • Requires: — (entry point)
  • Unblocks: P1 (needs providers + AppShell + tokens), P2 (needs schemas + MSW), P3 (needs EditBus types), P6 (needs AI types)

Risks / open questions

  • TanStack Router + autoCodeSplitting + Vite 8: already configured in scaffold, but untested with multiple nested routes. If file-route generation fails, fall back to explicit createFileRoute() calls.
  • MSW browser worker setup with Vite needs the public/mockServiceWorker.js asset. Run msw init public/.
  • React 19 + Tailwind v4 + Vite 8 is a recent stack — confirm @tailwindcss/vite plugin handles @theme correctly.
  • Deferred: real backend wiring (P7), real AI provider impls (post-P6).

Phase P1 — Responsive Studio shell

One <Studio> component renders 4-zone desktop OR CapCut-style mobile bottom-sheet; 6 PanelSlots reserved (including AI); playhead store live.

Problem

The studio must work as a professional desktop editor (4-zone layout like Premiere/Figma) and a CapCut-style mobile editor (one panel at a time, bottom-sheet) — without losing any features on mobile. The naive solutions all fail:

  1. Two route trees (MobileStudio vs DesktopStudio). Drifts immediately — features land on desktop but not mobile (or vice versa), the AI slot ends up desktop-only, and every component is written twice.
  2. One layout with responsive hidden lg:block. Side panels on mobile eat the stage; CapCut's insight is that mobile needs temporal panels (one at a time), not shrunken spatial panels. Tailwind responsive utilities alone can't express that.
  3. Panels that know where they're rendered. If <PropertiesPanel> hardcodes "I'm a right sidebar," it can't be reused in a bottom sheet. Every panel component gets coupled to a layout slot.
  4. Playhead in useState. A 60Hz rAF loop calling setT triggers a React re-render every frame → stage, timeline, captions all re-render → jank. The playhead is the most frequently updated piece of state in the app.
  5. AI Dock added late. If the Studio layout doesn't reserve space for AI from day 1, the AI Dock (P6) becomes a bolt-on dialog instead of a first-class panel.

Solution

This phase establishes the slot abstraction and the single shared playhead store that every later phase depends on:

  • ONE <Studio> component with an internal breakpoint switch via useMediaQuery('(min-width: 1024px)'). Desktop renders StudioDesktop (CSS grid 4-zone); mobile renders StudioMobile (stage + transport + bottom toolbar + BottomSheet). No two route trees.
  • Slot-agnostic panels. <PanelSlot id="props"> is a wrapper that, on desktop, renders its children into a CSS grid region; on mobile, renders into a BottomSheet when its id matches activePanel. The panel components themselves (Properties, Timeline, etc.) don't know which — they just render their content. P2+ fills these slots with real components.
  • CapCut bottom-sheet model on mobile. A bottom toolbar shows 6 tool buttons (Props / Timeline / HTML / Assets / Captions / AI). Tapping one sets activePanel and opens the sheet. ONE panel active at a time. Stage stays visible above; transport stays visible at top of sheet.
  • Playhead in Zustand (shared/store/playhead.ts) with selector subscriptions. The rAF loop in P2 writes t 60×/sec, but components select only what they need (e.g. timeline reads t for the playhead line, but a Properties panel doesn't subscribe at all). Zustand's selector model means zero React re-renders per frame unless a component opts in.
  • Reserve all 6 PanelSlots from P1: props, timeline, html, assets, captions, ai. Empty placeholder content ("Props panel — wired in P2"). AI slot's empty state says "AI ready — demos in P2, real AI in P6".
  • CSS variable-driven resizers via shadcn resizable (wraps react-resizable-panels) so panels are user-adjustable on desktop without bespoke pointer-event math.
  • No hand-rolled UI primitives. Everything (Button, Tooltip, Slider, Dialog, Dropdown, Skeleton, Sheet, Tabs, Resizable) is added via pnpm dlx shadcn@latest add <name> and lands in src/components/ui/. Later phases compose from these.

Deliverable

A reviewer can:

  1. Open the app at /projects/proj-1/editor on desktop (≥1024px) → see 4-zone grid: left rail (placeholder nav), center stage (empty), right panel (Props placeholder), bottom timeline (placeholder). All 6 slots visible.
  2. Drag the resizers between zones (shadcn ResizablePanel handles) → panels resize; state persists in panel group.
  3. Scrub the TransportBar slider → playhead.t value updates in DevTools.
  4. Resize browser below 1024px → layout swaps: stage on top, transport bar, bottom toolbar with 6 tool buttons.
  5. Tap a tool button on mobile → corresponding BottomSheet opens with the slot's placeholder content.
  6. Tap AI tool → sheet opens with "AI ready — demos in P2" empty state.
  7. Run pnpm test → PanelSlot routing tests pass (renders into correct region per breakpoint).

Files added / modified

src/
├── components/
│   └── ui/                                   # shadcn-generated components
│       ├── button.tsx                        # NEW (shadcn add)
│       ├── tooltip.tsx                       # NEW
│       ├── slider.tsx                        # NEW
│       ├── dialog.tsx                        # NEW (Radix UI primitive)
│       ├── dropdown-menu.tsx                 # NEW
│       ├── sheet.tsx                         # NEW — mobile bottom-sheet uses side="bottom"
│       ├── tabs.tsx                          # NEW — right-panel tabs
│       ├── skeleton.tsx                      # NEW
│       ├── resizable.tsx                     # NEW — react-resizable-panels wrapper
│       └── sonner.tsx                        # NEW (added in P0; re-exported here if needed)
├── features/
│   └── studio/
│       ├── Studio.tsx                        # NEW — responsive switch
│       ├── StudioDesktop.tsx                 # NEW — ResizablePanelGroup 4-zone
│       ├── StudioMobile.tsx                  # NEW — stage + transport + toolbar + Sheet
│       ├── PanelSlot.tsx                     # NEW — slot-agnostic renderer
│       ├── MobileToolbar.tsx                 # NEW — 6 tool buttons (shadcn Button)
│       ├── TransportBar.tsx                  # NEW — play/pause/scrub/time
│       └── EmptySlot.tsx                     # NEW — placeholder content
├── shared/
│   ├── store/
│   │   └── playhead.ts                       # NEW — Zustand store
│   └── lib/
│       └── useMediaQuery.ts                  # NEW
└── routes/
    └── projects.$projectId/
        └── editor.tsx                        # MODIFIED — render <Studio/>

Key decisions

  • ONE <Studio>, internal breakpoint switch. No two route trees, no lg:hidden sprinkled everywhere. The component conditionally renders StudioDesktop or StudioMobile. Both share <TransportBar> and (later) the same slot content.
  • PanelSlot is the slot abstraction. On desktop: <PanelSlot id="props">{children}</PanelSlot> renders into the right ResizablePanel. On mobile: renders into a shadcn <Sheet side="bottom"> if id === activePanel, otherwise renders nothing. This is what makes panels reusable across breakpoints.
  • shadcn sheet = the CapCut bottom sheet. side="bottom" slides up from the bottom, supports backdrop tap to close, drag handle, and snap. No custom BottomSheet component — just the shadcn primitive with side="bottom" on mobile and side="right" (or omitted) for desktop dialogs.
  • shadcn resizable for desktop zones. <ResizablePanelGroup direction="horizontal"> with three panels (rail/stage/props), nested with a vertical group for stage/timeline. State persists automatically; no CSS variable math needed.
  • shadcn tabs for right-panel multi-slots. On desktop, the right panel hosts tabs: Props / HTML / Captions / AI (4 slots in one resizable panel). Assets gets a dialog. Timeline gets the bottom panel. This keeps the 4-zone grid clean.
  • One-active-panel on mobile (CapCut model). Mobile can't show 6 panels simultaneously; it shows one at a time via Sheet. The toolbar is the picker. Stage + transport are always visible.
  • Zustand + selectors, not useState. The rAF loop in P2 will write playhead.t 60×/sec. Components select only the slice they need. Timeline's playhead line uses usePlayhead(s => s.t) but a static Properties panel doesn't subscribe → no re-render.
  • Reserve AI slot from day 1. The empty state makes the architectural commitment visible: AI is a first-class panel, not a future feature.

Tasks

Playhead store

  • shared/store/playhead.ts: create<PlayheadState> with t, playing, duration, seek(t), togglePlay(), setDuration(d)
  • Selector hook usePlayhead(selector)
  • Vitest: seek updates t; selector returns stable refs

Media query + Studio switch

  • shared/lib/useMediaQuery.ts: SSR-safe useMediaQuery('(min-width: 1024px)')
  • Studio.tsx: const isDesktop = useMediaQuery(...); return <StudioDesktop/> or <StudioMobile/>
  • Both layouts render <TransportBar/> and <PanelSlot/> wrappers

StudioDesktop

  • <ResizablePanelGroup direction="horizontal"> with three panels: rail (min 48, default 64, max 128), stage (no min, contains nested vertical group), props (min 240, default 320, max 480)
  • Nested inside stage: <ResizablePanelGroup direction="vertical"> with stage-canvas and timeline (min 80, default 200, max 50%)
  • Right panel uses shadcn <Tabs> for 4 slots: Props / HTML / Captions / AI (Assets opens in a dialog separately)
  • Use shadcn ResizableHandle withHandle between all panels

StudioMobile

  • Layout: stage (40vh) → transport bar → bottom toolbar (fixed)
  • MobileToolbar: 6 tool buttons (Props / Timeline / HTML / Assets / Captions / AI) using shadcn Button variants (ghost inactive, secondary active) with lucide-react icons
  • BottomSheet = shadcn <Sheet side="bottom"> controlled by activePanel state; <SheetContent> with header (title + close X via SheetClose) + scrollable body
  • activePanel state local to StudioMobile (useState<PanelId | null>)
  • Each tool tap → setActivePanel(id) → Sheet opens with that slot's content

PanelSlot

  • Props: id: PanelId, title: string, children
  • Desktop: render into the appropriate ResizablePanel (or Tabs trigger) per id (lookup table)
  • Mobile: render into <Sheet side="bottom" open={activePanel===id}> when id === activePanel, else render null
  • Vitest: render with desktop fixture → children in correct panel; mobile fixture with activePanel='props' → children in Sheet

TransportBar

  • Play/pause shadcn Button (icon-only, ghost variant) toggles playhead.togglePlay
  • Scrub uses shadcn Slider bound to playhead.t / playhead.duration; onValueChange calls playhead.seek (uncontrolled internal state to avoid per-drag re-renders)
  • Time display via usePlayhead(s => s.t) selector formatted as mm:ss
  • Optional: shadcn Tooltip on each control for labels

shadcn component adds

  • pnpm dlx shadcn@latest add button tooltip slider sheet tabs dropdown-menu skeleton resizable dialog
  • Verify each lands in src/components/ui/ and passes pnpm check (Biome)
  • Confirm tree-shake: build shows only the actually-imported components in the main chunk

Empty slot content

  • EmptySlot.tsx: composes shadcn Skeleton (3 lines) + label using Button ghost variant
  • Each of 6 slots renders placeholder: "Props panel — wired in P2/P3/..." etc.
  • AI slot specifically: "AI ready — demos in P2, real AI in P6"

Verification

Manual:

  • Desktop (≥1024px): ResizablePanelGroup 4-zone visible; drag each ResizableHandle → panel resizes; refresh → sizes persist via autoSaveId
  • Mobile (<1024px): stage + transport + bottom toolbar visible; tap each tool → shadcn Sheet slides up with placeholder
  • Scrub TransportBar slider → DevTools shows playhead.t updating (use usePlayhead.subscribe debug log)
  • No React Profiler entries while scrubbing (proves selector pattern works)

Automated:

  • pnpm test — PanelSlot renders children into correct desktop ResizablePanel
  • pnpm test — PanelSlot renders children into shadcn Sheet on mobile when active
  • pnpm test — PanelSlot renders null on mobile when inactive
  • pnpm test — useMediaQuery returns boolean (mock matchMedia)
  • pnpm check (biome) passes on shadcn-generated files
  • pnpm build — verify shadcn components bundled cleanly; no manual UI primitive files exist

Responsive audit:

  • Run Chrome DevTools device emulation (iPhone 14 Pro, iPad, desktop) → layout swaps cleanly at 1024px

Dependencies

  • Requires: P0 (providers, AppShell, theme tokens)
  • Unblocks: P2 (slots exist for stage/timeline/properties/captions/AI to fill)

Risks / open questions

  • CSS grid + position:absolute; inset:0 host divs (required by HF per AGENTS.md). The stage ResizablePanel must be position: relative so the HF host div fills it. Test with a static placeholder div first; may need a wrapper inside the panel.
  • shadcn Sheet mobile height. Default is content-driven; for editor panels we want a taller sheet (e.g. h-[80vh] or snap points). May require overriding sm:max-w-* classes — shadcn allows direct class pass-through.
  • react-resizable-panels + nested groups need explicit autoSaveId for persistence and direction-flip handling. Confirm the nested vertical group inside the horizontal one works without ID clashes.
  • Deferred: real panel content (P2+), real playhead duration (P2), dnd-kit sensors (P3).

Phase P2 — Read-only studio wired to mock

Stage renders slide HTML via HF; Timeline + Properties + Captions render from the mocked project. Scrub → HF seeks via tl.time(), slide swaps on boundary cross, audio syncs via timeupdate. AI Dock shows a 3-scenario mock chat.

Problem

P1 ships an empty Studio shell with reserved slots. Filling those slots with editing immediately invites a category of bugs that are hard to untangle later:

  1. Editing-while-wiring. If P2 builds editable fields and the data wiring simultaneously, every bug is ambiguous: is it the binding, the mutation, the render, or the store? Read-only-first is the standard fix — see the data flow once, then enable writes in P3.
  2. HF renderer coupled to editor code. Calling npx hyperframes or <hyperframes-player> directly from <StageCanvas> violates the RFC §9 contract. There's no swap path to our own renderer (§9.3) and "preview looked different from export" (§9.4) becomes possible.
  3. Per-frame React re-renders. A naive rAF loop calling setPlayhead(t) triggers a React render 60×/sec. Every component subscribed to playhead re-renders. The stage re-mounts the HF player. Captions re-render. Jank.
  4. Audio drift. requestAnimationFrame for audio sync seems natural but drifts on throttled tabs and variable-refresh displays. The bundle §8 spec is explicit: external <audio> elements sync via timeupdate (coarse, spec-defined ~4–66Hz).
  5. AI Dock invisible until P6. If P2 leaves the AI slot empty, the "AI is first-class" commitment from P0/P1 rings hollow. The 3-scenario mock chat we agreed on has to actually play.

Solution

This phase builds the read-only data flow end-to-end, with the playhead loop and HF DI seam done correctly the first time:

  • Read-only by construction. All field components render with disabled (or readOnly). P3 flips them on; the data layer is identical. Bugs in P2 are unambiguously data-flow bugs.
  • SlideRendererContext is the only HF entry point. No feature file imports hyperframes — only shared/renderer/hfRenderer.ts does. The context ships an HfRenderer impl in P2 and gets swapped (without editor changes) when the own-renderer lands. Preview ↔ export drift becomes structurally impossible (RFC §9.4).
  • rAF + Zustand selector pattern. The rAF loop in usePreviewEngine writes playhead.t 60×/sec to the Zustand store. Components subscribe via usePlayhead(s => s.t) — but the rAF loop itself triggers zero React renders. The renderer reads t directly from the store inside the rAF callback via usePlayhead.getState(), never via React state.
  • Audio sync via timeupdate. <ExternalAudio> mounts one <audio> element per track (music + voiceover), subscribes to the HyperFrames player's timeupdate event, and sets el.currentTime = syncPos(t, track) per tick. Music uses modulo (loop); voiceover uses clamp (no loop). This is the bundle §8 contract verbatim.
  • Pure helpers ported first, with Vitest fixtures. cumulativeStarts (timeline), scale (letterbox), stamp/placeholderFor (stamping) — each ported with its pinned _output.txt from the curriculum bundle as the assertion. Catches translation bugs early.
  • Mock AI Dock with 3-scenario picker. Pre-loaded welcome message. + button opens shadcn Popover with 3 scenarios: Change title / Rewrite HTML / Add slide. Each plays a hardcoded user→assistant→proposal flow. Accept/Reject only fire a shadcn sonner toast ("Mock: real edits ship in P6") — no EditBus dispatch.

Deliverable

A reviewer can:

  1. Navigate to /projects/proj-1/editor on desktop → see the 3-slide fixture project rendered: stage shows slide-0 (HF player), timeline shows 3 clips with measured durations, right-panel Tabs show Props (read-only fields) / Captions (mock timings) / AI (welcome message). HTML tab is disabled (lands in P5).
  2. Press Play (TransportBar) → playhead advances; HF tl.time() seeks the slide's GSAP timeline per rAF; when playhead crosses the slide-0 duration boundary, stage swaps to slide-1; audio plays in sync (music loops, voiceover clamps at end).
  3. Open the AI tab → see welcome message. Tap + → shadcn Popover with 3 scenarios. Pick "Change title" → watch the typewriter-streamed assistant turn + inline diff proposal. Tap Accept → toast "Mock: real edits ship in P6".
  4. Resize to mobile → layout swaps; tap a tool in the bottom toolbar → shadcn Sheet opens with the same content.
  5. React DevTools Profiler: while playing, only the timeline playhead line and the transport time display re-render — the stage does NOT re-render each frame.
  6. pnpm test → all ported helpers match pinned fixtures; renderer invariants pass.

Files added / modified

src/
├── components/ui/                           # (no new shadcn adds in P2 — reuses P1)
├── features/
│   ├── stage/
│   │   ├── components/
│   │   │   ├── StageCanvas.tsx              # NEW — viewport + scaled canvas + host div
│   │   │   ├── Viewport.tsx                 # NEW — letterbox container
│   │   │   ├── ScaledCanvas.tsx             # NEW — 1920x1080 scaled to fit
│   │   │   └── HostDiv.tsx                  # NEW — position:absolute;inset:0 HF mount
│   │   ├── hooks/
│   │   │   └── usePreviewEngine.ts          # NEW — rAF → tl.time(localTime) via getState
│   │   └── lib/
│   │       └── scale.ts                     # NEW — min(vw/cw, vh/ch)
│   ├── timeline/
│   │   ├── components/
│   │   │   ├── TimelinePanel.tsx            # NEW — composes SlideLane + AudioLane
│   │   │   ├── SlideLane.tsx                # NEW — Gantt clips
│   │   │   ├── AudioLane.tsx                # NEW — music + voiceover bars
│   │   │   ├── Clip.tsx                     # NEW — single slide clip
│   │   │   ├── Playhead.tsx                 # NEW — vertical line (subscribes via selector)
│   │   │   └── MobileSlideReel.tsx          # NEW — horizontal swipe variant
│   │   └── lib/
│   │       └── cumulativeStarts.ts          # NEW — port from timeline_panel bundle
│   ├── properties/
│   │   ├── components/
│   │   │   ├── PropertiesPanel.tsx          # NEW — field list (read-only)
│   │   │   ├── FieldGroup.tsx               # NEW — section header + fields
│   │   │   ├── TextareaField.tsx            # NEW — disabled (P3 enables)
│   │   │   ├── FileInput.tsx                # NEW — disabled
│   │   │   ├── VoiceoverField.tsx           # NEW — disabled amber textarea
│   │   │   └── TransitionControl.tsx        # NEW — disabled select
│   │   └── hooks/
│   │       └── useActiveSlide.ts            # NEW — current slide from route + store
│   ├── captions/
│   │   ├── components/
│   │   │   └── CaptionLayer.tsx             # NEW — render word spans (mock timings)
│   │   └── lib/
│   │       └── mockTimings.ts               # NEW — fixture word timings
│   └── ai/
│       ├── components/
│       │   ├── AIDock.tsx                   # NEW — PanelSlot content
│       │   ├── ChatThread.tsx               # NEW — message list
│       │   ├── Message.tsx                  # NEW — user/assistant bubble
│       │   ├── EditProposal.tsx             # NEW — inline diff + Accept/Reject
│       │   ├── Composer.tsx                 # NEW — + Popover + input + send
│       │   └── ScenarioPicker.tsx           # NEW — shadcn Popover with 3 scenarios
│       └── lib/
│           └── scenarios.ts                 # NEW — hardcoded flows for 3 scenarios
├── shared/
│   ├── renderer/
│   │   ├── SlideRendererContext.tsx         # NEW — Context + Provider + useSlideRenderer
│   │   ├── hfRenderer.ts                    # NEW — HyperFrames impl of SlideRenderer
│   │   └── types.ts                         # NEW — SlideRenderer, Frame, Fields, Assets
│   ├── audio/
│   │   ├── components/
│   │   │   └── ExternalAudio.tsx            # NEW — <audio> + timeupdate listener
│   │   └── lib/
│   │       └── syncPos.ts                   # NEW — musicPos/voicePos/syncPos
│   ├── api/queries/
│   │   ├── useProject.ts                    # NEW — TanStack Query hook
│   │   └── useSlide.ts                      # NEW
│   └── lib/
│       └── stamp.ts                         # NEW — port from data_binding bundle
└── routes/projects.$projectId/
    └── editor.tsx                           # MODIFIED — wire Stage/Timeline/Props/AI into PanelSlots

Key decisions

  • Read-only fields are the same components as editable. TextareaField takes disabled prop; P2 passes true, P3 passes false. No re-write, no separate "display" component.
  • usePreviewEngine writes via usePlayhead.getState(), not React state. The rAF callback reads playhead.t directly from Zustand's external store and calls tl.time(localTime) on the HF player — no React reconciliation per frame. This is the critical perf invariant.
  • Slide swap on boundary cross is editor-driven, not HF-driven. When playhead.t enters [slideStart, slideStart+dur), StageCanvas unmounts the old slide's HostDiv and mounts the new one. HF does not orchestrate slides in preview — only at export (P7). This matches bundle §8 "real-time, not frame-accurate."
  • Audio sync via timeupdate, not rAF. Per bundle §8 / RFC §8. The player emits timeupdate ~4–66Hz; ExternalAudio sets el.currentTime from there. Coarse by design.
  • Mock AI scenarios are pure state, no EditBus. scenarios.ts exports 3 hardcoded conversation arrays. Accept shows a toast via shadcn sonner. P6 swaps the data source for AIProviderContext and wires Accept to editBus.dispatch().
  • Helpers ported with frozen fixtures. cumulativeStarts([4,5,3], 0) MUST return {starts:[0,4,9], total:12} — exact bytes from the bundle's _output.txt. Catches transcription bugs.

Tasks

Pure helpers (port + test)

  • shared/lib/stamp.ts: placeholderFor(id) and stampSafe(html, id, value) (function-form replaceAll). Fixture: stamp $& into __TITLE__ preserves the literal.
  • features/timeline/lib/cumulativeStarts.ts: prefix-sum with gap. Fixture: [4,5,3] gap 0 → starts [0,4,9] total 12.
  • features/stage/lib/scale.ts: min(vw/cw, vh/ch). Fixture: 1920×1080 in 800×450 → 0.416667.
  • shared/audio/lib/syncPos.ts: musicPos (modulo), voicePos (clamp). Fixture: t=75, music dur 30 → 15; voice dur 70 → 70 (clamped).

SlideRenderer DI

  • shared/renderer/types.ts: Frame, Fields, Assets, SlideRenderer interface (load, renderAt, duration)
  • shared/renderer/hfRenderer.ts: HfRenderer class implementing SlideRenderer via <hyperframes-player>. load() mounts the player; renderAt(t) calls player.currentTime = t; duration() reads from player.
  • shared/renderer/SlideRendererContext.tsx: Context + Provider + useSlideRenderer() hook.
  • Mount <SlideRendererProvider value={hfRenderer}> in __root.tsx or project layout route.

Data layer

  • shared/api/queries/useProject.ts: useQuery({ queryKey: ['project', id], queryFn: () => client.getProject(id) })
  • shared/api/queries/useSlide.ts: similar for single slide
  • MSW handlers extended if needed (P0 should already cover)

Stage

  • Viewport.tsx: flex container, position: relative, letterbox via object-fit: contain math
  • ScaledCanvas.tsx: 1920×1080 div with transform: scale(...); transform-origin top-left
  • HostDiv.tsx: <div data-composition-src={...} class="clip" style="position:absolute;inset:0;z-index:100-{idx}">
  • StageCanvas.tsx: orchestrates the three; reads active slide from playhead boundary; mounts/unmounts HostDiv
  • usePreviewEngine.ts: useEffect rAF loop; reads usePlayhead.getState().t; computes localTime = clamp(global - slideStart, 0, slide.duration); calls renderer.renderAt(localTime); cancel on unmount

Timeline

  • TimelinePanel.tsx: reads project.slides + per-slide durations; renders SlideLane + AudioLane + Playhead
  • Clip.tsx: width = dur * pxPerSec, left = start * pxPerSec; read-only
  • Playhead.tsx: usePlayhead(s => s.t) selector; left = t * pxPerSec
  • AudioLane.tsx: music (loop pattern hatching) + voiceover (single bar to total duration)
  • MobileSlideReel.tsx: horizontal scroll-snap list of slide thumbnails; current slide auto-centers on playhead

Properties (read-only)

  • All field components accept disabled and render with shadcn Textarea / Input / Select (from P3 adds — if not added yet, use shadcn primitives that ship disabled-friendly) with readOnly or disabled
  • Display values from useActiveSlide() (TanStack Query cache)

Captions

  • CaptionLayer.tsx: renders word spans from mockTimings.ts (fixture)
  • Word spans styled per bundle CSS: display: inline-block, transition: color 0.2s ease
  • Active word highlighted by GSAP timeline built from timings (seek-driven, same pattern as preview engine)

Audio

  • ExternalAudio.tsx: mounts <audio> with id, volume, loop; subscribes to HyperFrames player timeupdate; sets currentTime = syncPos(t, track) per tick
  • Two instances: ext-music (volume 0.08, loop true) and ext-voiceover (volume 1.0, loop false)

AI Dock (mock)

  • scenarios.ts: 3 hardcoded conversation arrays (changeTitle, rewriteHtml, addSlide), each with user msg + assistant stream tokens + EditProposal shape
  • AIDock.tsx: composes ChatThread + Composer (with + button + Popover)
  • ChatThread.tsx: renders messages; typewriter effect for streaming tokens (setTimeout chain)
  • EditProposal.tsx: inline unified diff (use diff lib or hand-rolled renderer); Accept/Reject buttons
  • Composer.tsx: shadcn Popover trigger +; shadcn Input + Button for send; Send returns canned "Mock mode — real AI ships in P6" reply
  • ScenarioPicker.tsx: 3 menu items in the Popover
  • Accept/Reject → toast() (shadcn sonner) with mock message

Wiring

  • editor.tsx: render <StageCanvas/>, <TimelinePanel/>, <PropertiesPanel/>, <CaptionLayer/>, <AIDock/> into the 6 PanelSlots from P1
  • Caption layer mounts over the stage (absolute positioned)

Verification

Manual:

  • Load project → slide-0 on stage; timeline shows 3 clips with correct widths; props show fixture field values; AI tab shows welcome message
  • Press Play → HF timeline animates; playhead line moves; at slide boundary, stage swaps; audio plays
  • React DevTools Profiler during playback → stage does NOT re-render per frame (only Playhead + TransportBar time)
  • Open AI tab → welcome → tap + → pick scenario → typewriter plays → diff renders → tap Accept → toast appears
  • Resize to mobile → layout swaps; tool buttons open Sheets with same content

Automated:

  • pnpm teststampSafe preserves $& value
  • pnpm testcumulativeStarts([4,5,3], 0) returns {starts:[0,4,9], total:12}
  • pnpm testscale(1920,1080,800,450) returns 0.416667
  • pnpm testmusicPos(75, {duration:30}) returns 15; voicePos(75, {duration:70}) returns 70
  • pnpm testHfRenderer.renderAt before load throws "SlideRenderer.render_at() called before load()" (per bundle §9.1 invariant)
  • pnpm test — AI scenario playback: each scenario renders expected message + proposal shape
  • pnpm check passes
  • pnpm build succeeds; main chunk reasonable (HF renderer should be code-split if heavy)

Dependencies

  • Requires: P0 (MSW + schemas + provider shells), P1 (Studio shell + PanelSlots + shadcn primitives)
  • Unblocks: P3 (flips field disabled off; adds EditBus runtime), P4 (extends timeline + captions with voiceover-driven timings), P6 (replaces mock scenarios with provider)

Risks / open questions

  • HF player load latency. First paint may show blank stage while HF initializes. Mitigation: shadcn Skeleton placeholder until renderer.duration() returns a value.
  • usePlayhead.getState() inside rAF is the critical perf invariant — verify with the React Profiler that no components re-render per frame. If they do, the cause is almost always an accidental usePlayhead() (no selector) somewhere.
  • HF player integration specifics (<hyperframes-player> vs custom element vs iframe) need runtime verification. The SlideRenderer interface insulates editor code; only hfRenderer.ts changes if HF's mount API differs from docs.
  • Mock scenario realism. Hardcoded flows are visual only; don't try to exercise the lint or mutation paths — those land in P3/P5/P6.
  • Mobile slide reel + HF player. Swiping between slides on mobile should pause the rAF loop briefly to avoid seeking the player mid-swap. Test on real device.

Phase P3 — Editing: properties + timeline (mutation API)

All fields editable; image upload via SHA-256 ref; slide reorder/add/remove via dnd-kit (touch + mouse). Every mutation goes through EditBus — the same path AI will use in P6.

Problem

P2 ships a read-only studio. Enabling edits naively breaks the AI-first-class invariant and several perf/correctness contracts:

  1. Components that own their writes. If <TextareaField> calls setQueryData directly or holds local state and pushes on blur, the AI Dock (P6) has no path to apply the same edit. The whole "AI is just another client" architecture collapses into per-feature integrations.
  2. Re-stamping per keystroke destroys GSAP state. A naive binding rewrites the slide HTML and re-mounts the HF player on every character. The GSAP timeline resets to frame 0 mid-typing. Bundle §F is explicit: live-bind = textContent patch on a ref, NOT a re-stamp.
  3. dnd-kit sensor mismatch. Default sensor is mouse-only. Mobile drag fails silently. Conversely, touch sensor on desktop steals clicks. Need both sensors with activation constraints (movement threshold).
  4. No undo/redo story. Without a structured edit log, undo is impossible or hacky (snapshot diffing). Users editing video expect undo; AI making bad proposals REQUIRES undo.
  5. Image blobs in field state. Storing base64 in slide.fields.img bloats the query cache, breaks JSON serialization, and dedupes nothing. Bundle §5.3 mandates SHA-256 refs.

Solution

This phase establishes the mutation API every edit (human or AI) routes through:

  • EditBus runtime. A single provider exposes dispatch(op) and an events subscription. Every mutation goes through it. The bus applies the op to the TanStack Query cache AND emits an EditEvent on the bus. Two consumers benefit immediately: useUndoRedo (history stack) and P6's ChatThread (system pings).
  • Op creators in shared/edit/ops.ts. Pure functions: setField(slideId, fieldId, value), reorderSlides(order), addSlide(afterId?, layoutId), removeSlide(slideId), setTransition(slideId, transition | null), setAsset(slideId, fieldId, ref). Each returns a typed EditOp that dispatch consumes. This is the exact shape AI proposals will produce in P6 — no translation layer.
  • Live-bind via useLiveBind. A hook that takes a slide ID + field ID + the bound text node's ref. On input: dispatches setField AND writes ref.current.textContent = value directly. NO re-render of the slide tree. GSAP state survives. Structural changes (add/remove/reorder/layout swap) trigger a full re-stamp via shared/lib/stamp.ts.
  • dnd-kit with PointerSensor + TouchSensor. Pointer for desktop drag; Touch for mobile. Both with activationConstraint: { distance: 8 } so a tap doesn't start a drag. Sensors configured in useSlideDnd.
  • Undo/redo from EditEvent stream. useUndoRedo keeps past+future stacks of EditEvents. undo() pops past → applies inverse → pushes to future. Inverse computed from the op type (e.g. inverse of setField is setField with previous value; inverse of addSlide is removeSlide; etc.).
  • Image upload = client-side SHA-256. useAssetUpload(file) computes crypto.subtle.digest('SHA-256', bytes) → formats as sha256:... → stores blob in in-memory AssetStore (P7 promotes to IndexedDB) → dispatches setAsset(slideId, 'img', ref). Field stores ref only.

Deliverable

A reviewer can:

  1. On desktop: click the Title textarea in Properties → type → preview updates without re-mounting the slide (GSAP timeline state preserved; React Profiler shows Properties + Stage stay stable, only the bound text node changes).
  2. Drag a slide clip in the timeline (mouse) → reorder; clips snap to new positions; slides[] array reorders in the cache; playhead range updates.
  3. On mobile: long-press a clip → drag handles appear → drag to reorder (touch); tap empty timeline area → "Add slide" sheet.
  4. Click + Add slide (rail or timeline) → new slide appended with default layout; clip appears in timeline.
  5. Click slide clip's menu → Remove → slide deleted; timeline updates; active slide shifts to neighbor.
  6. Drag an image onto a FileInput field → SHA-256 computes → thumbnail appears → field shows ref; same image dropped twice → dedupes (same ref).
  7. Press ⌘Z → last edit reverts (whatever it was: field edit, reorder, add, remove). ⌘⇧Z → redo.
  8. React DevTools: while typing in a field, the stage does NOT re-render (verifies the ref-write pattern).
  9. pnpm test → every op round-trips through EditBus, applies correctly, emits the right EditEvent; undo/redo inverts correctly.

Files added / modified

src/
├── components/ui/
│   ├── input.tsx                            # NEW (shadcn add)
│   ├── textarea.tsx                         # NEW
│   ├── label.tsx                            # NEW
│   ├── select.tsx                           # NEW
│   └── context-menu.tsx                     # NEW (clip ⋯ menu)
├── features/
│   ├── properties/
│   │   ├── hooks/
│   │   │   └── useLiveBind.ts               # NEW — ref write + dispatch
│   │   └── components/
│   │       ├── TextareaField.tsx            # MODIFIED — disabled=false, wired to useLiveBind
│   │       ├── FileInput.tsx                # MODIFIED — drop handler → SHA-256 → dispatch
│   │       ├── VoiceoverField.tsx           # MODIFIED — disabled=false (TTS pipeline in P4)
│   │       └── TransitionControl.tsx        # MODIFIED — Select bound to setTransition
│   ├── timeline/
│   │   ├── hooks/
│   │   │   └── useSlideDnd.ts               # NEW — dnd-kit sensors + reorder handler
│   │   └── components/
│   │       ├── Clip.tsx                     # MODIFIED — draggable + context menu
│   │       ├── AddSlideButton.tsx           # NEW
│   │       └── ClipContextMenu.tsx          # NEW — Remove / Duplicate / Properties
│   └── assets/
│       └── hooks/
│           ├── useAssetUpload.ts            # NEW — crypto.subtle digest
│           └── useAssetStore.ts             # NEW — in-memory Map<ref, Asset>
├── shared/
│   ├── edit/
│   │   ├── EditBus.tsx                      # MODIFIED — runtime: dispatch + emit + subscribe
│   │   ├── ops.ts                           # MODIFIED — runtime op creator implementations
│   │   ├── applyOp.ts                       # NEW — pure: (project, op) → project
│   │   ├── inverseOp.ts                     # NEW — pure: (op, project) → inverse op
│   │   └── useUndoRedo.ts                   # NEW — history stacks + keyboard shortcut
│   ├── store/
│   │   └── history.ts                       # NEW — Zustand: {past:[], future:[]}
│   └── lib/
│       ├── ref.ts                           # NEW — sha256: formatter from asset_library bundle
│       └── assetStore.ts                    # NEW — singleton Map<ref, {blobUrl, bytes, mime}>
└── package.json                             # MODIFIED — add @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities

Key decisions

  • Op shape is the AI contract. The exact EditOp discriminated union P3 implements is what P6's applyPatch produces from AI proposals. No adapter layer. If a new op type is added in P6 (e.g. setSlideHtml from P5), it extends the same union.
  • applyOp is pure. (project, op) → newProject. The EditBus calls this, then queryClient.setQueryData. Pure function = trivially testable + reusable for undo inverse computation.
  • Live-bind = ref write, not setState. This is the bundle §F invariant. useLiveBind(slideId, fieldId, textRef) registers an input handler that: (1) dispatches setField to update the cache, (2) writes textRef.current.textContent = value to patch the DOM directly. The slide's GSAP timeline never sees a re-mount.
  • Structural changes do re-stamp. Add/remove/reorder/layout-swap dispatch the op AND trigger a full re-stamp of the affected slides via stamp(). The HF player re-mounts. This is correct: structural changes alter composition, not content.
  • dnd-kit sensors with distance constraint. PointerSensor with distance: 8 lets a click land without starting a drag; TouchSensor with delay: 200, tolerance: 8 lets a tap register before drag activates. Both register on the same DndContext.
  • Undo/redo via op inverse. Each op type has a known inverse (setField ↔ setField with prev; addSlide ↔ removeSlide; reorderSlides ↔ reorderSlides with prev order; etc.). inverseOp(op, projectBefore) returns the inverse op. useUndoRedo keeps two stacks and dispatches inverses.
  • SHA-256 via Web Crypto. crypto.subtle.digest('SHA-256', bytes) → hex string → sha256: prefix. Sync formatting, async hashing. Asset blob lives in memory Map for P3; P7 persists to IndexedDB.

Tasks

shadcn adds

  • pnpm dlx shadcn@latest add input textarea label select context-menu
  • Verify all pass pnpm check

EditBus runtime

  • EditBus.tsx: Provider holds subscribers: Set<(e: EditEvent) => void>; dispatch(op) calls applyOp(project, op)queryClient.setQueryData(['project', id], result) → emits EditEvent to subscribers
  • useEditBus() hook returns { dispatch, subscribe }
  • Mount <EditBusProvider> inside <QueryClientProvider> in __root.tsx

Op creators + applyOp

  • ops.ts: implement creators — setField, reorderSlides, addSlide, removeSlide, setTransition, setAsset
  • applyOp.ts: pure reducer; one case per op kind; returns new project (immutable)
  • inverseOp.ts: one case per op kind; reads prev value from projectBefore
  • Vitest: each op round-trips (apply then apply-inverse === identity)

Live-bind

  • useLiveBind(slideId, fieldId, textRef): returns onChange handler
  • Handler: (1) editBus.dispatch(setField(...)), (2) textRef.current.textContent = value
  • Wire TextareaField to use it; pass textRef from PropertiesPanel that holds the slide composition
  • Verify: typing in Title → preview text updates → React Profiler shows NO re-render of Stage or slide composition

Image upload (in-memory)

  • shared/lib/ref.ts: refFor(bytes) → 'sha256:' + hex
  • shared/lib/assetStore.ts: in-memory Map<ref, {blobUrl, bytes, mime}>; store(bytes) returns ref
  • useAssetUpload(file): reads File.arrayBuffer()crypto.subtle.digestrefForassetStore.store → returns ref
  • FileInput.tsx: drag-and-drop zone + shadcn Input type=file; on drop → upload → editBus.dispatch(setAsset(...)) → show thumbnail
  • Vitest: dropping same bytes twice → returns same ref; field value is sha256:...

Timeline editing

  • useSlideDnd: configure DndContext with PointerSensor({activationConstraint:{distance:8}}) + TouchSensor({activationConstraint:{delay:200,tolerance:8}})
  • Clip.tsx: useSortable({id: slideId}); drag handle on hover; context menu trigger via button
  • onDragEnd: compute new order → editBus.dispatch(reorderSlides(newOrder))
  • AddSlideButton: dispatches addSlide({afterId: lastSlideId, layoutId: defaultLayout})
  • ClipContextMenu (shadcn ContextMenu): Remove / Duplicate / Properties-open
  • Remove flow: editBus.dispatch(removeSlide(slideId)) → if active slide removed, shift active to neighbor

Undo/redo

  • shared/store/history.ts: Zustand store {past: EditEvent[], future: EditEvent[]}
  • EditBus subscribes internally: every emitted event → push to past, clear future
  • useUndoRedo: undo() pops past → reads projectBefore from cache → dispatch(inverseOp(op, projectBefore)) → pushes op to future
  • redo() symmetrical
  • Global keyboard: ⌘Z / ⌘⇧Z (or Ctrl-Y); register via useEffect on window
  • Optional: undo/redo buttons in TransportBar

Field wiring

  • Flip disabled={false} on all Properties field components from P2
  • VoiceoverField accepts input but shows note "TTS pipeline ships in P4"
  • TransitionControl (shadcn Select): options = inherit (default) + transition types; dispatches setTransition(slideId, value | null)

Verification

Manual:

  • Type in Title → preview updates → DevTools Profiler confirms Stage did NOT re-render
  • Drag clip in timeline (mouse) → reorders; cache updates (verify via React Query DevTools)
  • On mobile (DevTools touch emulation or real device): long-press clip → drag works
  • Add slide → clip appears, default content renders
  • Remove slide → clip disappears; if it was active, neighbor becomes active
  • Drag image to FileInput → thumbnail appears; same image twice → no duplicate
  • ⌘Z → reverses last action (whatever type); ⌘⇧Z → redoes
  • AI tab still shows mock chat (no AI changes yet, but the slot remains visible)

Automated:

  • pnpm testapplyOp(project, setField(...)) returns project with updated field
  • pnpm testapplyOp then applyOp with inverseOp returns original project
  • pnpm testreorderSlides op moves id to correct index
  • pnpm testaddSlide then removeSlide round-trips
  • pnpm testrefFor returns sha256: + 64 hex chars
  • pnpm test — EditBus dispatches emit EditEvent with correct actor: 'human', op shape
  • pnpm check passes

Dependencies

  • Requires: P2 (read-only studio + queries + slide data in cache)
  • Unblocks: P4 (setVoiceover op), P5 (setSlideHtml op), P6 (AI dispatches same ops), P7 (setAsset op already implemented here; P7 adds the library UI)

Risks / open questions

  • Ref write vs React reconciliation race. If React re-renders the slide composition for an unrelated reason (e.g. parent state change), the bound text node may get recreated and the ref-write target is stale. Mitigation: the ref is set in a useEffect cleanup-and-reset; verify with a stress test (rapid edits + parent re-render).
  • crypto.subtle requires HTTPS or localhost. Dev server is localhost — fine. Production deployment must be HTTPS. Document.
  • dnd-kit + scroll containers. If the timeline is inside a scrollable container, dnd-kit needs measuring.droppable config. Verify on mobile where the timeline lives in a Sheet.
  • Undo across query cache invalidation. If an edit triggers a refetch (it shouldn't — all P3 edits are cache mutations), the undo could fight the refetch. Enforce: all P3 ops use setQueryData, never invalidateQueries.
  • setVoiceover exists but does nothing in P3 (no TTS pipeline yet). The field accepts text and stores it; P4 wires the actual generation.

Phase P4 — Voiceover + captions

Voiceover textarea triggers mock TTS pipeline (deterministic durations); captions render word-by-word with GSAP direct-color tweens; 4 caption styles. Lint predicates block banned patterns in CI.

Problem

P3 stores voiceover text but doesn't generate anything. Wiring real TTS+captions introduces several sharp edges:

  1. Durations are MEASURED, never authored. Per bundle §5.3, slide.duration comes from ffprobe on the rendered TTS audio. A duration input field would be a lie — the user can't know it until TTS runs. UI must reflect this: no duration field; duration is a derived value that updates when voiceover regenerates.
  2. Captions are word-level karaoke, not sentence highlights. Bundle captions_karaoke is explicit: per-word timing by character ratio. Sentence-level highlighting is wrong for this product.
  3. BANNED patterns must be unenforceable. AGENTS.md CRITICAL RULES forbid transform / scale() / font-size / text-shadow changes on .word--active, and forbid GSAP className: tweens. A linter that runs in CI (not just in-component) is the only way to catch a regression introduced by a careless edit.
  4. Python's marker injection doesn't translate to React. The current stack uses <!-- CAPTION_LAYER -->, /* CAPTION_CSS */, // CAPTION_TIMELINE markers and string-replaces them. In React, captions are real components rendered into the stage — no markers.
  5. Tier-1 AI needs voiceover as a first-class op. P6 will let AI rewrite narration. setVoiceover must be a real EditBus op that triggers the pipeline, not just a field write.
  6. Smart timing: no manual target_starts. Bundle voiceover.py computes slide N's start as prev_end + gap_between_slides (0.8s default). The UI shouldn't ask the user for start times.

Solution

This phase adds the mock TTS pipeline and the caption rendering layer, both as reusable modules:

  • Mock TTS endpoint. MSW handler POST /api/tts takes {slides: [{id, text, voice}]} and returns {timings: [{slideId, duration, sentenceTimings}]}. Durations are deterministic from a hash of the text — same text always returns the same duration. This stands in for the real edge-tts + ffprobe + ffmpeg pipeline until P7 wires the Python backend.
  • useVoiceover hook. Triggers when voiceover text changes (debounced 500ms). Dispatches setVoiceover (updates slide.voiceover.text in cache) AND kicks the TTS pipeline → on completion, dispatches setDuration for each affected slide → timeline clips resize → captions regenerate.
  • Caption timings from timeWordsByCharRatio. Port verbatim from captions_karaoke bundle. Invariants: Σ ratioᵢ === 1.0, Σ durᵢ === sentenceDur, endᵢ === startᵢ + durᵢ. Vitest enforces all three.
  • GSAP direct color tweens, seek-driven. Per bundle: tl.to(wordSelector, { color: '#ffea00', duration: 0.15, ease: 'power2.out' }, wordStart) then tween back to dim. Timeline built once per sentence (useRef), registered, seek-driven by playhead.t via the same rAF loop as the preview engine.
  • React renders captions directly. No <!-- CAPTION_LAYER --> markers. <CaptionLayer timings={...}/> is a React component that renders word spans and manages its own GSAP timeline. Mounts absolutely-positioned over the stage.
  • Lint predicates in shared/lib/. lintCaptionCSS(css) flags banned patterns in .word--active {} blocks. lintCaptionJS(js) flags className: tweens. Both run in Vitest as part of CI; bundle captions_karaoke ships fixtures of bad CSS/JS to assert.
  • 4 caption styles. highlight (default, yellow), neon (cyan glow), editorial (white serif), eco-green (green). Implemented as 4 CSS files selected via data-caption-style attribute. Switch via shadcn Select in Properties (root-level field).

Deliverable

A reviewer can:

  1. Open slide-1's Properties → VoiceoverField (amber textarea) → type narration text.
  2. Wait 500ms (debounce) → mock TTS pipeline runs (loading skeleton on the field) → returns measured duration → timeline clip for slide-1 resizes; slide-2's start time shifts accordingly (smart timing).
  3. Caption layer renders word spans; press Play → words highlight in sequence (#ffea00rgba(255,255,255,0.4)); seek-by-scrub updates the active word instantly.
  4. Open Properties → Caption Style select → switch highlightneon → colors update (cyan glow on active word).
  5. Mobile: same flow in the bottom Sheet.
  6. Open a PR introducing a .word--active { transform: scale(1.15) } rule → CI fails the lint test.
  7. pnpm test → all 5 caption helpers match pinned fixtures; lintCaptionCSS/lintCaptionJS flag banned patterns; timeWordsByCharRatio invariants hold.

Files added / modified

src/
├── components/ui/
│   └── select.tsx                           # (added in P3; reused)
├── features/
│   ├── voiceover/
│   │   ├── hooks/
│   │   │   └── useVoiceover.ts              # NEW — debounce + TTS pipeline trigger
│   │   └── lib/
│   │       ├── splitSentences.ts            # NEW — boundary detection (naive or compromise lib)
│   │       └── computeTimings.ts            # NEW — port smart-timing from export_pipeline bundle
│   ├── captions/
│   │   ├── components/
│   │   │   ├── CaptionLayer.tsx             # MODIFIED — real timings, GSAP timeline
│   │   │   └── CaptionStylePicker.tsx       # NEW — shadcn Select for 4 styles
│   │   ├── lib/
│   │   │   ├── timeWordsByCharRatio.ts      # NEW — port from captions_karaoke bundle
│   │   │   ├── buildCaptionHtml.ts          # NEW — for export pipeline (P7) reuse
│   │   │   ├── buildCaptionTimelineJs.ts    # NEW — same
│   │   │   ├── lintCaptionCSS.ts            # NEW — flags banned CSS patterns
│   │   │   └── lintCaptionJS.ts             # NEW — flags className: tweens
│   │   └── styles/
│   │       ├── highlight.css                # NEW — default #ffea00
│   │       ├── neon.css                     # NEW — cyan
│   │       ├── editorial.css                # NEW — white serif
│   │       └── eco-green.css                # NEW — green
│   └── properties/
│       └── components/
│           ├── VoiceoverField.tsx           # MODIFIED — debounce + useVoiceover + loading state
│           └── CaptionStylePicker.tsx       # MODIFIED — root-level field, dispatches setTheme
├── shared/
│   ├── api/
│   │   └── msw/
│   │       ├── handlers.tts.ts              # NEW — POST /api/tts mock
│   │       └── ttsFixtures.ts               # NEW — deterministic hash → duration
│   ├── edit/
│   │   └── ops.ts                           # MODIFIED — adds setVoiceover, setDuration, setCaptionStyle
│   └── lib/
│       └── textHash.ts                      # NEW — sync hash for deterministic mock durations
└── routes/projects.$projectId/
    └── editor.tsx                           # MODIFIED — wire CaptionLayer over Stage; mount StylePicker

Key decisions

  • Mock TTS is deterministic. textHash(text) → number (sync, e.g. FNV-1a). mockDuration = 2 + (hash % 4000) / 1000 → 2–6 seconds range. Same text always produces the same duration → no test flakiness.
  • useVoiceover is debounced. Dispatching on every keystroke would re-run TTS constantly. 500ms debounce + a "Regenerate" button for manual trigger. Show shadcn Skeleton over the field while pipeline runs.
  • setVoiceover op triggers the pipeline, not just a field write. The op carries the text; applyOp updates the cache; a separate effect in useVoiceover notices the cache change and kicks the TTS call. On TTS completion, a new setDuration op updates each affected slide.
  • Smart timing is computed, not stored. computeTimings(durations, gap=0.8) returns {starts, total}. Timeline reads durations from per-slide cache and computes starts on the fly. No target_starts field in index.json (matches bundle §voiceover).
  • Caption timings live in the rendering layer. useMemo(() => timeWordsByCharRatio(sentence, start, dur), [sentence, start, dur]) per slide. No store. Timings re-derive from voiceover text + measured duration.
  • 4 styles via data-caption-style attribute. No JS branching. CSS files use [data-caption-style="neon"] .word--active { color: ... }. Switching is a single attribute write.
  • Lint predicates are exported from shared/lib/ — same pattern as P5's lintHtml.ts. P6 (AI Tier-2) won't generate captions (those come from voiceover), but the predicates guard against human HTML edits that introduce banned patterns.
  • buildCaptionHtml and buildCaptionTimelineJs ship here for P7 export pipeline reuse — the export step needs string outputs, not React components.

Tasks

Pure helpers (port + test)

  • timeWordsByCharRatio(sentence, sentenceStart, sentenceDur) → WordTiming[]: char count → ratio → start/dur/end per word. Fixture from captions_karaoke_output.txt.
  • buildCaptionHtml(timings, emphasisIdx[]): HTML string with <span class="word" data-i="N">. Used by export.
  • buildCaptionTimelineJs(timings): JS string with GSAP direct color tweens. Used by export.
  • lintCaptionCSS(css): regex/AST scan of .word--active block; flag transform:, scale(, font-size:, text-shadow:. Returns {ok: bool, firedRule?: string}.
  • lintCaptionJS(js): flag className: in gsap.to(...) calls.
  • computeTimings(durations, gap): prefix-sum with gap; returns {starts, total}.
  • splitSentences(text): naive ./!/? split + Vietnamese support (keep . inside numbers).
  • textHash(text): FNV-1a 32-bit; sync; deterministic.

Mock TTS endpoint

  • MSW handler POST /api/tts: input {slides:[{id, text, voice}]}; for each slide, split sentences → for each sentence, compute mockDuration = 2 + (textHash(sentence) % 4000) / 1000; return {timings:[{slideId, duration: sum(sentenceDurations), sentenceTimings:[{text,start,dur}]}]}
  • Add 200–800ms artificial delay to simulate network + TTS work

EditBus ops

  • setVoiceover(slideId, text, voice?): updates slide.voiceover.text (and optional voice)
  • setDuration(slideId, duration): updates slide.duration (read-only field — this op is the only path that mutates it; user never types a duration)
  • setCaptionStyle(style): updates root.theme.caption_style

useVoiceover hook

  • Subscribe to active slide's voiceover text
  • Debounce 500ms; on change → call MSW /api/tts with all slides (batch — per bundle §voiceover)
  • On response → dispatch setDuration for each slide; emit voiceoverRegenerated event (so timeline + captions re-derive)
  • Show loading state (shadcn Skeleton over VoiceoverField + spinner in timeline clip)

CaptionLayer (real)

  • Reads active slide's voiceover + duration
  • useMemo over timeWordsByCharRatio for the active sentence (or all sentences if pre-compute is cheap)
  • Renders <span class="word" data-i={i}>word</span> per word
  • Builds GSAP timeline once via useRef: for each word, tl.to(selector, {color: ACTIVE_COLOR, duration: 0.15, ease:'power2.out'}, wordStart) then tl.to(selector, {color: DIM_COLOR, duration: 0.15, ease:'power2.in'}, wordEnd)
  • Timeline registered as window.__timelines['__CAPTIONS__'] (or local ref)
  • rAF loop (same pattern as preview engine) reads playhead.t and calls tl.time(localTime)
  • data-caption-style attribute on root div; CSS file import per style

Caption styles

  • 4 CSS files, each scoped via [data-caption-style="X"]
  • highlight (default): .word--active { color: var(--caption-active) } (the reserved token from P0)
  • neon: cyan + subtle glow (NOTE: text-shadow only on .word, NOT on .word--active — banned)
  • editorial: serif font, white active
  • eco-green: green active
  • Verify each: no transform, scale, font-size, text-shadow on .word--active

CaptionStylePicker

  • shadcn Select with 4 options (icons + labels)
  • On change → editBus.dispatch(setCaptionStyle(value))
  • Mount in Properties panel as a root-level field (project-wide, not per-slide)

VoiceoverField

  • shadcn Textarea with amber ring (per AGENTS.md styling convention)
  • Debounce handler wired to useVoiceover
  • Loading state: shadcn Skeleton overlay or spinner in corner
  • voice selector (shadcn Select with vi-VN-HoaiMyNeural etc. — all Neural suffix per bundle pitfall #1)

Verification

Manual:

  • Type voiceover text → 500ms later, loading → duration appears → timeline clip resizes → caption layer renders words
  • Play → words highlight in sequence (yellow on/off); seek → active word updates instantly
  • Switch caption style → colors update (no jank, no transform shift on the active word)
  • Mobile: same flow in Sheet
  • Voice id select → all options end in Neural

Automated:

  • pnpm testtimeWordsByCharRatio returns timings with Σ ratio === 1.0, Σ dur === sentenceDur, monotonic starts/ends
  • pnpm testlintCaptionCSS flags .word--active { transform: scale(1.15) } (R1: transform)
  • pnpm testlintCaptionCSS flags .word--active { text-shadow: ... } (R2: text-shadow)
  • pnpm testlintCaptionCSS allows .word { text-shadow: ... } (base style OK)
  • pnpm testlintCaptionJS flags tl.to(el, { className: '+=word--active' })
  • pnpm testcomputeTimings([4,5,3], 0.8) returns {starts:[0, 4.8, 10.6], total: 16.4}
  • pnpm testtextHash('hello') returns same value across runs
  • pnpm test — MSW /api/tts returns deterministic durations for same input text
  • pnpm check passes

CI gate:

  • New Vitest test that imports all 4 caption CSS files and runs lintCaptionCSS on each → all must pass

Dependencies

  • Requires: P3 (EditBus runtime, applyOp, setField) for setVoiceover/setDuration/setCaptionStyle ops
  • Unblocks: P6 (AI Tier-1 can rewrite narration via setVoiceover), P7 (export pipeline uses buildCaptionHtml/buildCaptionTimelineJs)

Risks / open questions

  • Sentence splitting for Vietnamese. Vietnamese uses the same sentence-end punctuation as English (.!?) but the naive split can break on decimal numbers, abbreviations, etc. For P4 a naive split is fine; refinement deferred. Document the limitation in a comment.
  • Caption GSAP timeline lifecycle on slide swap. When the stage swaps slides, the caption timeline must rebuild for the new slide's voiceover. Verify the useRef cleanup runs and a new timeline builds; verify window.__timelines (if used) doesn't leak.
  • Mock TTS delay variance. 200–800ms range is realistic but may flake tests that don't await. Use vi.useFakeTimers or explicit await.
  • --caption-active token in non-highlight styles. The token from P0 is reserved for caption use; neon/editorial/eco-green use their own colors. Verify CI grep still allows them (they're inside features/captions/**).
  • buildCaptionHtml / buildCaptionTimelineJs are unused until P7. That's fine — they're ported now to lock the contract; export pipeline consumes them.

Phase P5 — Per-slide HTML editor + LintGate

CodeMirror 6 lazy-loaded; R1–R4 lint predicate in shared/lib; accept/revert gate; placeholder gutter markers. The lint module is the same one P6 AI Tier-2 proposals will pass through.

Problem

The slide index.html is a bare <template> — editing it is the Tier-2 AI surface (RFC §7) and the power-user surface. Several constraints make this harder than dropping in a textarea:

  1. HF renders <html>-wrapped templates BLANK. Verified HF v0.7.3 (bundle bare_template). A power user (or AI) pasting a "complete HTML document" silently produces a blank slide. The editor must refuse such edits before they reach the workspace.
  2. Tier-2 AI (P6) must use the same lint as humans. If lint lives inside the editor component, AI has to import the component or reimplement the rules. Lint must be a pure module importable from anywhere.
  3. CodeMirror 6 is ~400KB. Loading it on app boot kills initial perf. The editor is a per-slide surface opened on demand — must be route-split.
  4. Mobile HTML editing is hostile. A standard dialog with a code editor on a 6-inch screen is unusable. Mobile needs a dedicated full-screen sheet with collapsible preview.
  5. Accept must be atomic. A half-applied edit (e.g. HTML swapped but fields not re-stamped) leaves the slide broken. Revert must leave the workspace byte-identical to prior — no partial state.
  6. __FIELD__ placeholders must survive. A power user editing <h1>__TITLE__</h1> could accidentally delete the placeholder. The editor should warn (gutter marker) but NOT block — severed binding is a pitfall, not a lint error (per bundle html_editor_surface).

Solution

This phase builds the HTML editor as a thin component over a pure lint module, lazy-loaded and gated:

  • shared/lib/lintHtml.ts — pure, sync, no React. Four rules: R1 exactly one <template>, R2 no <html>/<head>/<body> outside template, R3 extracted content has data-composition-id, R4 no Tailwind (cdn.tailwindcss.com / @tailwind / @apply). Returns { ok: boolean, firedRule?: { id: 'R1'|'R2'|'R3'|'R4', message: string } }. Imported by both HtmlEditor (this phase) and the AI Dock (P6).
  • CodeMirrorLazy via dynamic import. HtmlEditor renders a shadcn Skeleton until the user opens the editor; only then does import('@uiw/react-codemirror') fire. CodeMirror is excluded from the main chunk. pnpm build report must confirm.
  • LintGate wrapper component. Takes prior: string, edited: string, children. Renders the lint result as a shadcn Alert (success or error with the fired rule). Gates the Accept button: disabled when !result.ok. Accept calls editBus.dispatch(setSlideHtml(slideId, edited)). Revert restores edited = prior.
  • Two surfaces: desktop Dialog, mobile full-screen Sheet. Both render the same HtmlEditor content; only the wrapper differs. Mobile adds a "Preview" toggle that collapses the editor to show the stage above.
  • Placeholder gutter markers. A CodeMirror decoration plugin highlights __SLIDE_ID__, __TITLE__, __BODY__, __IMAGE__, __STEP__ with a distinct background + tooltip "Placeholder for ${id} field." Warns on deletion but doesn't block save.
  • setSlideHtml op added. Pure op carrying {slideId, html}. applyOp swaps the slide's HTML in the cache; structural re-stamp follows because changing HTML is structural.

Deliverable

A reviewer can:

  1. On desktop: click the HTML tab (right panel) → CodeMirror loads (skeleton briefly visible) → slide HTML appears with syntax highlighting + placeholder gutter markers.
  2. Edit the <h1> text → lint runs live → no rule fires → Accept button enables → click Accept → slide re-renders with new HTML → setSlideHtml event emits.
  3. Wrap content in <html>...</html> → R2 fires immediately → shadcn Alert shows "R2: html wrapper outside template" → Accept disabled.
  4. Add <script src="cdn.tailwindcss.com"></script> → R4 fires → Accept disabled.
  5. Delete __TITLE__ placeholder → gutter marker disappears from the next line; tooltip "Severed binding" appears; save is NOT blocked.
  6. On mobile: open HTML via tool button → full-screen Sheet opens → editor fills viewport → tap "Preview" toggle → stage renders above editor.
  7. React Profiler: CodeMirror does NOT mount until HTML tab is opened at least once.
  8. pnpm build → CodeMirror chunk separate from main; main chunk size unaffected.
  9. pnpm test → R1–R4 each catch their fixture case; setSlideHtml op round-trips.

Files added / modified

src/
├── components/ui/
│   ├── alert.tsx                            # NEW (shadcn add)
│   ├── tabs.tsx                             # (already in P1)
│   └── sheet.tsx                            # (already in P1)
├── features/
│   └── html-editor/
│       ├── components/
│       │   ├── HtmlEditor.tsx               # NEW — composes CodeMirror + LintGate
│       │   ├── CodeMirrorLazy.tsx           # NEW — dynamic import wrapper
│       │   ├── LintGate.tsx                 # NEW — pure lint + Alert + Accept/Revert
│       │   ├── PlaceholderGutter.tsx        # NEW — CodeMirror decoration plugin
│       │   └── DesktopHtmlDialog.tsx        # NEW — shadcn Dialog wrapper
│       └── lib/
│           └── placeholderDecorations.ts    # NEW — scan for __*[A-Z0-9_]*__
├── shared/
│   ├── lib/
│   │   └── lintHtml.ts                      # NEW — R1–R4 pure predicates
│   └── edit/
│       └── ops.ts                           # MODIFIED — adds setSlideHtml
└── package.json                             # MODIFIED — @uiw/react-codemirror, @codemirror/state, @codemirror/view, @codemirror/lang-html, @codemirror/lint

Key decisions

  • lintHtml.ts is the contract. It lives in shared/lib/, not in features/html-editor/. P6 imports it directly to gate AI Tier-2 proposals. No coupling between AI and the editor component.
  • Pure functions, deterministic output. lintHtml(html: string): LintResult — same input always returns same output. No side effects, no React. Testable in isolation. (Mirrors applyEdit(prior, edited) → {decision: 'ACCEPT'|'REVERT', workspace, firedRule} from bundle html_editor_surface.)
  • First failing rule wins. Rules run in order R1→R2→R3→R4. First failure is returned; subsequent rules don't run. Matches bundle behavior.
  • R1: exactly one <template>. Use countTag(src, 'template') === 1. Rejects 0 (no template) or >1 (multiple).
  • R2: no <html>/<head>/<body> outside template. String-scan: extract template content first, then check the surrounding src for those tags. Verified HF v0.7.3 behavior — wrapper causes blank render.
  • R3: extracted content has data-composition-id. After extracting template content, verify the attribute exists (any value). Catches stripped-down templates.
  • R4: no Tailwind. Grep for cdn.tailwindcss.com, @tailwind, @apply. Per RFC §16: Tailwind forbidden inside composition HTML.
  • Accept = atomic dispatch. setSlideHtml carries the full new HTML string. applyOp swaps in cache; the editor unmounts (or shows "Saved" state); the stage re-mounts the slide with new HTML. No partial state.
  • Revert = edited = prior. Pure local state reset. Workspace never saw the edited value.
  • Placeholder gutter warns but doesn't block. Per bundle: a deleted __FIELD__ is a severed binding (the field won't render), which is a UX pitfall, not a structural error. The editor surfaces it via decoration; lint does NOT include placeholder-existence in R1–R4.
  • Mobile = full-screen Sheet. side="bottom" with h-[100vh] (or h-svh for small viewport). Editor and a collapsible Preview toggle (stage renders above the editor). The HTML tab on mobile opens this sheet directly.

Tasks

shadcn adds

  • pnpm dlx shadcn@latest add alert
  • Verify

Dependencies

  • Add: @uiw/react-codemirror, @codemirror/state, @codemirror/view, @codemirror/lang-html, @codemirror/lint, @codemirror/language
  • Verify pnpm install

Pure lint module

  • shared/lib/lintHtml.ts:
    • extractTemplateContent(src): string (string-scan, no DOM lib)
    • countTag(src, tag): number
    • hasHtmlWrapper(src): boolean
    • extractAttribute(inner, attr): string | null
    • lintHtml(src): { ok: boolean, firedRule?: { id, message } }
  • Vitest fixtures from html_editor_surface_output.txt: each R1–R4 fires on its bad fixture; the canonical good fixture passes all 4

setSlideHtml op

  • ops.ts: setSlideHtml(slideId, html) → { kind: 'setSlideHtml', slideId, html }
  • applyOp.ts: case setSlideHtml → swap slideHtml[slideId] in project cache
  • inverseOp.ts: reads projectBefore.slideHtml[slideId] for undo

CodeMirrorLazy

  • const CodeMirror = lazy(() => import('@uiw/react-codemirror'))
  • <Suspense fallback={<Skeleton className="h-96"/>}>
  • Wrap with basicSetup + html() language extension + our PlaceholderGutter plugin
  • Verify pnpm build shows CodeMirror in a separate chunk

PlaceholderGutter

  • CodeMirror ViewPlugin that scans line text for /__[A-Z0-9_]+__/g
  • Decoration: Decoration.mark with class cm-placeholder (CSS: subtle bg + underline)
  • Hover tooltip via hoverTooltip: shows "Placeholder for ${id} field"
  • On decoration count decrease vs prior parse → show Tooltip "Severed binding" on the line below

LintGate

  • LintGate({ prior, edited, onAccept, onRevert, children })
  • const result = useMemo(() => lintHtml(edited), [edited])
  • If result.ok: shadcn Alert (success, "Ready to apply")
  • Else: shadcn Alert (destructive, ${result.firedRule.id}: ${result.firedRule.message})
  • Accept button: disabled={!result.ok}; onClick → editBus.dispatch(setSlideHtml(slideId, edited))onAccept()
  • Revert button: always enabled; onClick → onRevert() (parent resets edited = prior)

HtmlEditor

  • Local state: edited (string), initialized from slide's prior HTML
  • Renders CodeMirrorLazy with value={edited} + onChange={setEdited}
  • Below editor: <LintGate prior={slide.html} edited={edited} onAccept={close} onRevert={reset}/>
  • Toolbar: format-on-save (optional), placeholder reference popover

Desktop wrapper

  • shadcn Dialog opened when user clicks HTML tab → "Open in dialog" or inline-in-tab decision (recommend inline-in-tab for desktop; dialog for "maximize")
  • Inline tab: editor fills the right-panel Tab content area
  • Maximize button → opens Dialog with full-screen editor

Mobile wrapper

  • shadcn Sheet side="bottom" with h-[100svh] (full-screen)
  • Header: slide title + Preview toggle + Close
  • Body: editor (flex-1) OR stage preview (collapsible via toggle)
  • Footer: LintGate (sticky)

Wiring

  • HTML tab in right panel renders <HtmlEditor slideId={activeSlideId}/> (desktop)
  • HTML tool button on mobile opens the Sheet with same component

Verification

Manual:

  • Click HTML tab → skeleton briefly → CodeMirror loads → syntax-highlighted HTML appears
  • Edit valid HTML → no Alert → Accept enabled → click → slide re-renders → undo restores prior
  • Wrap in <html>...</html> → R2 Alert appears → Accept disabled
  • Add <script>cdn.tailwindcss.com</script> → R4 Alert
  • Delete __TITLE__ placeholder → gutter marker disappears → tooltip warns → save still allowed
  • Mobile: open HTML tool → full-screen sheet → Preview toggle collapses editor
  • React Profiler: open other tabs first → CodeMirror chunk NOT loaded; open HTML tab → chunk loads once

Automated:

  • pnpm testlintHtml(goodFixture).ok === true
  • pnpm testlintHtml(badR1Fixture).firedRule.id === 'R1' (and R2, R3, R4 fixtures)
  • pnpm testextractTemplateContent returns correct inner for canonical fixture
  • pnpm testsetSlideHtml op round-trips through EditBus; inverse restores prior HTML
  • pnpm test — placeholder regex matches __SLIDE_ID__, __TITLE__, __A_FIELD__; doesn't match __lowercase__ or __123__
  • pnpm build — main chunk does NOT include CodeMirror; separate chunk ~400KB loads on HTML tab open
  • pnpm check passes

Dependencies

  • Requires: P3 (EditBus, dispatch pattern, applyOp)
  • Unblocks: P6 (lintHtml is the gate AI Tier-2 proposals pass through)

Risks / open questions

  • CodeMirror 6 + React 19 + Vite 8 compatibility. @uiw/react-codemirror is the most React-friendly wrapper; verify it works with React 19. If not, fall back to codemirror vanilla + a thin React wrapper.
  • CodeMirror decoration plugin complexity. Placeholder markers via ViewPlugin are well-documented but fiddly. If too costly in P5, ship without decorations and add them in a follow-up — the lint gate is the critical path; decorations are polish.
  • HTML <template> extraction without a DOM library. Bundle bare_template.ts uses string-scan; we port that. Edge cases (whitespace, attributes with > inside quotes) need fixture coverage.
  • Mobile code editing UX. Even with full-screen sheet, editing HTML on a phone is painful. The mobile HTML tool is provided for parity but the UX is "best-effort." Document; consider disabling on small viewports in a follow-up.
  • setSlideHtml triggers a full re-stamp. Per bundle §F, structural changes (which HTML edits are) require re-stamp + HF player re-mount. Verify the stage correctly tears down and rebuilds.

Phase P6 — AI Dock (Tier-1 + Tier-2)

Wire AI slot to mock streaming provider; chat with proposals; Tier-1 JSON patches + Tier-2 HTML swaps; both gated by existing EditBus ops + lintHtml; inline diff + expand; Settings panel for provider switch; human edits surface as system pings.

Problem

The mock AI Dock from P2 is cosmetic — it shows hardcoded flows and toasts on Accept. Promoting it to the real AI surface introduces the hardest integration problems in the project:

  1. Mock toasts ≠ real edits. If Accept in P6 still only shows a toast, the AI Dock is a demo forever. But wiring Accept to dispatch real edits naively creates a parallel mutation path that bypasses undo, lint, and audit. The "AI is just another client" invariant from P0 must be enforced, not aspirational.
  2. Real providers require keys, streaming, and abstraction. Hardcoding OpenAI in component code means switching providers rewrites the dock. The AIProvider interface from P0 has to actually shape the implementation, not just sit in a type file.
  3. AI bypassing EditBus = audit breaks. If AI proposals mutate the cache directly (queryClient.setQueryData from inside the dock), the EditEvent stream (and therefore undo/redo, system pings, audit log) misses them. Every AI edit MUST dispatch through editBus.dispatch().
  4. AI bypassing lintHtml = broken slides. A Tier-2 proposal that wraps content in <html> would render blank (per HF v0.7.3 / bundle bare_template). AI must pass through the SAME R1–R4 gate P5 enforces for humans.
  5. Diff UX must serve two shapes. Tier-1 edits are JSON patches (fields.title: "old" → "new"). Tier-2 edits are full HTML swaps. A diff viewer that handles only one is incomplete. Both need inline rendering in chat AND expandable side-by-side.
  6. Human edits are invisible to the AI session. If the chat only shows assistant turns, the user can't tell what state the project is in relative to the AI's last proposal. The chat must surface human edits as system pings ("You changed slide-1.title").
  7. API keys in localStorage. Necessary for a BYO-key local-first tool, but plaintext keys are a security concern. Need minimal mitigation (storage prefix, warning in UI, optional sessionStorage alternative).

Solution

This phase wires the existing slot, op, and lint infrastructure into a complete AI surface:

  • AIProviderContext formalizes the P0 types. <AIProviderProvider> mounts in __root.tsx; useAIProvider() returns the active provider. Provider impls:
    • EchoProvider (mock, default) — keyword-routed canned proposals. Same 3 scenarios as P2 (Change title / Rewrite HTML / Add slide) plus more (Remove slide / Change transition / Add voiceover / etc.). Streams tokens via setTimeout chains; returns EditProposals matching the EditOp shapes from P3/P5.
    • OpenAIProvider, AnthropicProvider, OllamaProvider (stubs) — throw "Not implemented in P6" when called. Implementations land post-P6; the registry and abstraction land now.
  • Settings → AI panel. shadcn Dialog from the AppShell settings menu. Pick provider (echo default / openai / anthropic / ollama), paste API key, choose model. Stored in localStorage with prefix ovk:ai:. Mock always available; real providers gated on key presence.
  • Every proposal carries an op shape, not a freeform edit. Tier-1 proposals produce RFC 6902 JSON Patches applied to the slide's index.json (via applyPatch). Tier-2 proposals carry a full HTML string passed to setSlideHtml. Both map cleanly to existing EditBus ops.
  • Accept ALWAYS dispatches via editBus. No direct queryClient.setQueryData. Accept on a Tier-1 title change calls editBus.dispatch(setField(slideId, 'title', newValue)) — exactly what a human keyboard edit calls. The EditEvent it emits flows to undo/redo, system pings, and audit uniformly.
  • Tier-2 proposals gated by shared/lib/lintHtml.ts. When a proposal arrives, the dock runs lintHtml(proposal.patch.html) immediately. If it fails, the proposal renders with Auto-rejected: ${rule.id} and Accept is disabled. No path for AI to write broken HTML.
  • Inline unified diff + expand to side-by-side dialog. EditProposal renders a compact unified diff (a few lines max) inline in the chat bubble. "Expand" opens a shadcn Dialog with full side-by-side via diff lib + syntax highlighter. Both render JSON (Tier-1) and HTML (Tier-2) cleanly.
  • ChatThread subscribes to EditBus. Every EditEvent (whether actor: 'human' or actor: 'ai:provider') appends a system ping to the chat. Human edits render as dimmed bubbles: "You changed slide-1.title". AI proposals render as assistant bubbles with diff + Accept/Reject.
  • ContextPins inject into system prompt. Composer has a "pin" button per active context: current slide, a field, an asset. Pins serialize into the next request's system prompt: "You are editing slide-1 with fields {title, body}. The current title is 'Eco Bottle'." Real providers use this; EchoProvider ignores it (returns canned scenarios based on keyword).

Deliverable

A reviewer can:

  1. Open AI slot → see welcome message + 3-scenario picker (Echo provider by default). Type "change slide-1 title to 'Hello'" → Echo matches keyword → streams response with proposal → inline unified diff shows title: "Eco Bottle" → "Hello" → click Accept → slide title updates in preview; system ping "AI changed slide-1.title" appears in chat; ⌘Z undoes.
  2. Type "rewrite slide-2 with bolder layout" → Echo returns Tier-2 HTML proposal → lintHtml runs → if pass, Accept enabled → click → slide HTML swaps → slide re-renders.
  3. Trigger a "wrap in <html>" proposal (e.g. via a hidden test scenario) → lintHtml fails R2 → proposal auto-rejected with rule surfaced in chat → Accept disabled.
  4. Manually edit slide-3's title in Properties (human path) → chat shows system ping "You changed slide-3.title" → AI is now "aware" of the change for the next prompt.
  5. Open Settings → AI → switch Echo → OpenAI → paste key → close → type a message → OpenAIProvider returns "Not implemented in P6" error in chat (graceful). Switch back to Echo → works again.
  6. Mobile: open AI tool → Sheet opens → all features available (chat, picker, diff, accept) — CapCut-style.
  7. pnpm testapplyPatch RFC 6902 conformance; provider registry pattern; lint integration; op-shape validation.

Files added / modified

src/
├── components/ui/
│   ├── popover.tsx                          # NEW (shadcn add)
│   ├── scroll-area.tsx                      # NEW
│   ├── badge.tsx                            # NEW
│   ├── separator.tsx                        # NEW
│   └── accordion.tsx                        # NEW (Settings AI panel sections)
├── features/
│   └── ai/
│       ├── components/
│       │   ├── AIDock.tsx                   # MODIFIED — wire to AIProviderContext
│       │   ├── ChatThread.tsx               # MODIFIED — subscribe to EditBus for system pings
│       │   ├── Message.tsx                  # MODIFIED — user/assistant/system-ping variants
│       │   ├── EditProposal.tsx             # MODIFIED — real diff + lint integration
│       │   ├── DiffPreview.tsx              # NEW — inline unified diff
│       │   ├── DiffDialog.tsx               # NEW — side-by-side expand view
│       │   ├── Composer.tsx                 # MODIFIED — ContextPins + send
│       │   ├── ContextPins.tsx              # NEW — pin slide/field/asset
│       │   ├── ScenarioPicker.tsx           # MODIFIED — picks from EchoProvider scenarios
│       │   └── AIProviderSettings.tsx       # NEW — Dialog with provider select + key input
│       ├── hooks/
│       │   ├── useAIChat.ts                 # NEW — manages messages + stream subscription
│       │   └── useEditProposal.ts           # NEW — accept/reject/revise logic
│       ├── providers/
│       │   ├── registry.ts                  # NEW — Map<ProviderId, AIProvider factory>
│       │   ├── EchoProvider.ts              # NEW — keyword-routed mock streaming
│       │   ├── OpenAIProvider.ts            # NEW — stub throws "Not implemented in P6"
│       │   ├── AnthropicProvider.ts         # NEW — stub
│       │   └── OllamaProvider.ts            # NEW — stub
│       ├── diff/
│       │   ├── renderDiff.ts                # NEW — unified diff renderer (uses diff lib)
│       │   └── applyPatch.ts                # NEW — RFC 6902 JSON Patch apply
│       └── lib/
│           ├── scenarios.ts                 # MODIFIED — scenarios now return EditProposal shapes
│           └── systemPrompt.ts              # NEW — serialize ContextPins → system prompt
├── shared/
│   ├── ai/
│   │   ├── types.ts                         # MODIFIED — formalize EditProposal (Tier-1 patch | Tier-2 html)
│   │   ├── AIProviderContext.tsx            # NEW — Context + Provider + useAIProvider
│   │   └── storage.ts                       # NEW — localStorage helpers with ovk:ai: prefix
│   └── edit/
│       └── EditBus.tsx                      # MODIFIED — actor field on EditEvent: 'human' | 'ai:provider'
└── package.json                             # MODIFIED — diff (npm), @ai-sdk/provider (optional)

Key decisions

  • EditProposal carries an op, not a freeform mutation. Two shapes:
    type EditProposal =
      | { tier: 1; target: {kind:'slide', slideId}; patch: JsonPatch[]; rationale: string }
      | { tier: 2; target: {kind:'slide', slideId}; html: string; rationale: string };
    Accept maps Tier-1 patches to one or more setField/setVoiceover/setTransition ops; Tier-2 maps to setSlideHtml. No new op shapes invented for AI.
  • RFC 6902 JSON Patch for Tier-1. Standard op: replace|add|remove + path + value. applyPatch consumes patches and translates to EditBus ops. Vitest asserts conformance against the RFC's examples.
  • EchoProvider is keyword-routed. Maps phrases to scenario generators: "change title" → proposal with replace patch on /fields/title; "rewrite html" → proposal with new HTML string; etc. Deterministic, offline, no tokens.
  • Real providers stubbed, not implemented. The hard problem in P6 is the integration shape (provider abstraction, op translation, lint gating, diff UX). Real provider HTTP/streaming code is a separate concern. Stubs throw a clear error so the registry pattern is testable.
  • Accept dispatches the SAME ops a human triggers. A proposal for setField(slide-1, title, 'Hello') calls editBus.dispatch({kind:'setField', slideId:'slide-1', fieldId:'title', value:'Hello', actor:'ai:echo'}). The cache mutation, the EditEvent emission, the system ping, the undo history entry — all uniform with human edits.
  • lintHtml gates Tier-2 BEFORE Accept is enabled. The dock calls lintHtml(proposal.html) synchronously when the proposal arrives. If it fails, the proposal renders with a red Auto-rejected: R2 badge and Accept stays disabled. AI literally cannot write broken HTML through this surface.
  • System pings via EditBus subscription. ChatThread subscribes to EditBus events on mount. Each event appends a system Message: actor: 'human' → "You changed slide-1.title"; actor: 'ai:echo' → "AI proposed slide-1.title change (accepted)". Pings are dimmed (neutral-500) so they read as transcript, not turn.
  • ContextPins serialize to system prompt. [slide:slide-1] becomes "Active slide: slide-1\nCurrent fields: {title: 'Eco Bottle', body: '...'}\n". Real providers consume this; EchoProvider ignores it (keyword routing). The serialization is in systemPrompt.ts, pure function.
  • API keys stored under ovk:ai: prefix. localStorage.getItem('ovk:ai:openai:key'). UI warns "Keys stored locally in plaintext. Clear after use on shared machines." Optional: sessionStorage toggle for ephemeral sessions.
  • Inline diff = compact unified; expand = side-by-side dialog. Inline shows max 8 lines with +/- markers. Click "Expand" → shadcn Dialog with react-diff-viewer or hand-rolled side-by-side. Both syntax-highlight via shiki or codemirror (reuse P5 lazy load if reasonable).

Tasks

shadcn adds

  • pnpm dlx shadcn@latest add popover scroll-area badge separator accordion
  • Verify

Dependencies

  • Add: diff (npm) for unified diff computation
  • Optional: react-diff-viewer-continued for side-by-side (or hand-rolled)
  • Optional: shiki for syntax highlighting in diffs

AIProviderContext

  • shared/ai/AIProviderContext.tsx: Context + Provider + useAIProvider() hook
  • Provider reads selected provider id + key from localStorage (ovk:ai:providerId, ovk:ai:${id}:key)
  • Instantiates provider from registry[id]
  • Mount in __root.tsx

Provider registry + impls

  • registry.ts: Map<ProviderId, { id, label, factory: (config) => AIProvider }>
  • EchoProvider.ts: implement stream(messages, ctx) → AsyncIterable<AIStreamEvent>. Token-by-token via await new Promise(r => setTimeout(r, 20)). On keyword match in last user msg → emit tokens of canned response + emit {type:'proposal', edit: scenario.generate(ctx)}.
  • OpenAIProvider.ts, AnthropicProvider.ts, OllamaProvider.ts: stubs that throw "Not implemented in P6 — provider abstraction ships here; HTTP wiring post-P6". Surface error to chat gracefully.

Scenarios → EditProposal

  • Each scenario (changeTitle, rewriteHtml, addSlide, removeSlide, changeTransition, addVoiceover) returns an EditProposal:
    • Tier-1: [{ op:'replace', path:'/fields/title', value: generated(ctx) }]
    • Tier-2: { html: generatedHtml(ctx) } (with __FIELD__ placeholders preserved)
  • Scenarios read ctx.activeSlide for context-aware values

applyPatch (RFC 6902)

  • applyPatch(slideIndex, patches) → slideIndex: implement replace, add, remove (skip move/copy/test for P6)
  • Path parsing: /fields/title['fields', 'title']
  • Translate patches → EditBus ops:
    • /fields/X replace → setField
    • /voiceover/text replace → setVoiceover
    • /transition replace → setTransition
    • Catch-all: warn "unsupported patch path" + reject proposal
  • Vitest RFC 6902 §4.1–4.3 examples

useAIChat

  • State: messages: Message[], streaming: boolean, error: string | null
  • send(text, contextPins): append user message → set streaming → call provider.stream(messages, serializePins(pins)) → for await event: tokens append to current assistant message; proposal appends an EditProposal message; done → streaming false
  • revise(proposalId, feedback): re-send with feedback in user message
  • Error handling: catch + set error state → render as error Alert in chat

useEditProposal

  • accept(proposal): if Tier-1 → translate patches → dispatch each via editBus.dispatch(op, actor:'ai:echo'); if Tier-2 → dispatch setSlideHtml(slideId, html); mark proposal as accepted (badge)
  • reject(proposal): mark as rejected (collapsed, line-through)
  • revise(proposalId, feedback): calls useAIChat.revise

EditProposal component

  • Header: tier 1/tier 2 badge + target (slide-1) + rationale
  • Body: <DiffPreview proposal/> (inline unified)
  • If Tier-2: run lintHtml(proposal.html) on mount; if !ok → red Alert + Auto-rejected badge; Accept disabled
  • Footer: Accept / Reject / Revise buttons (shadcn Button)
  • Expand button → opens <DiffDialog proposal/>

DiffPreview + DiffDialog

  • renderDiff(proposal): compute unified diff (Tier-1 patches shown as field path: old → new; Tier-2 shown as unified line diff via diff lib)
  • DiffPreview: compact inline (max 8 lines, + green / - red)
  • DiffDialog: shadcn Dialog with side-by-side render

ChatThread + EditBus subscription

  • On mount: editBus.subscribe(handleEvent)
  • handleEvent(e): append system Message { role:'system', actor: e.actor, op: e.op }
  • Render system pings with dimmed style: "You changed slide-1.title" / "AI changed slide-2 html"
  • Cleanup subscription on unmount

ContextPins

  • Composer + button opens shadcn Popover with two columns: pin current slide / pick field
  • Pinned items render as shadcn Badge chips above the input; click X to unpin
  • serializePins(pins) → string for system prompt

Composer

  • shadcn Input (auto-resizing textarea) + Send Button (icon)
  • + Popover trigger for ContextPins + ScenarioPicker (Echo provider only — picker collapses for real providers)
  • Disabled while streaming; show shadcn Skeleton for in-flight assistant message

AIProviderSettings

  • Opened from AppShell Settings menu → AI
  • shadcn Dialog with form: provider Select, key Input (password type), model Input, "Save" button
  • On save: write localStorage (ovk:ai:providerId, ovk:ai:${id}:key, ovk:ai:${id}:model)
  • Warning Alert: "Keys stored locally in plaintext"
  • "Clear" button to wipe stored keys

EditBus actor field

  • Extend EditEvent to include actor: 'human' | 'ai:${providerId}'
  • All P3 op creators default actor: 'human'; AI accept paths pass actor: 'ai:echo' (or current provider)
  • Backward compat: existing P3–P5 dispatches don't need to change (default applies)

Verification

Manual:

  • Default (Echo provider): type "change slide-1 title to Hello" → streams response → proposal renders → Accept → slide updates → system ping "AI changed slide-1.title" → ⌘Z undoes
  • "Rewrite slide-2 bolder" → Tier-2 proposal → if lint passes → Accept → slide HTML swaps
  • Hidden test scenario: trigger <html>-wrapped proposal → lint fails R2 → Auto-rejected badge → Accept disabled
  • Manually edit slide-3 title in Properties → system ping "You changed slide-3.title" appears
  • Settings → AI → switch to OpenAI → enter key → close → send message → "Not implemented in P6" error in chat → switch back to Echo → works
  • Mobile: AI tool button → Sheet opens with full chat + proposals + accept

Automated:

  • pnpm testapplyPatch(obj, [{op:'replace', path:'/a/b', value:1}]) returns obj with /a/b === 1
  • pnpm testapplyPatch(obj, [{op:'remove', path:'/a/b'}]) returns obj with /a/b deleted
  • pnpm test — Tier-2 proposal with bad HTML: lint rejects, Accept path not called
  • pnpm test — EchoProvider streams tokens + emits proposal for keyword "change title"
  • pnpm test — EditBus dispatch with actor: 'ai:echo' emits EditEvent with same actor
  • pnpm test — ChatThread subscription: dispatch event → system message appended
  • pnpm test — registry pattern: switching provider id returns different impl
  • pnpm check passes

Dependencies

  • Requires: P3 (EditBus + applyOp), P5 (lintHtml exported in shared/lib)
  • Unblocks: P7 (AI can suggest assets via setAsset, already implemented in P3)

Risks / open questions

  • applyPatch is intentionally limited to replace/add/remove. If a real provider emits move/copy/test, we reject with a warning. Real provider integration (post-P6) may need to extend this.
  • Streaming UX with React 19. Async iterables + setState per token can re-render the chat thread frequently. Mitigation: batch tokens (e.g. append every 30ms), use useRef for the in-flight string + flush on interval.
  • API key plaintext storage. Local-first means we can't encrypt with a server-managed key. Mitigations: warning in UI, sessionStorage toggle for ephemeral use, document in README. Long-term: WebCrypto with a user-supplied passphrase.
  • diff lib for HTML produces noisy output. HTML Tier-2 diffs can be huge. Consider: collapse unchanged regions, max-height with scroll, syntax highlight via shiki (lazy-loaded).
  • Real provider wiring scope. Post-P6 work. The stubs make the integration boundary explicit. When real providers land, they implement AIProvider.stream() per the contract; the dock, op translation, lint gating, and diff UX are all reusable.
  • Tier-2 proposals should preserve __FIELD__ placeholders. If AI produces HTML with literal values instead of placeholders, the binding is severed. Not a lint error (per P5 decision) but a quality issue. Consider a warning badge if placeholders are missing from the proposed HTML.

Phase P7 — Asset library + export pipeline

AssetDropzone with SHA-256 content addressing; full AssetLibrary panel/dialog; 6-step export pipeline UI streaming mock events; aHash render cache.

Problem

P3 ships minimal asset upload (drag image onto a FileInput → SHA-256 → ref lands in slide field), but the studio lacks an asset management surface and the export flow. Several gaps remain:

  1. No way to browse/reuse assets. P3 dedupes via SHA ref but the user has no UI to see "what assets do I already have." Every image drop is a blind action.
  2. Asset blobs vanish on refresh. P3's in-memory Map<ref, Asset> is fine for the session but loses everything on reload. Production needs persistence.
  3. No export flow. The 6-step pipeline (assemble → stamp → voiceover → captions → render → progress) from bundle export_pipeline has no UI. Users can't get an MP4 out.
  4. Re-rendering unchanged slides wastes compute. Bundle visual_determinism specifies aHash + Hamming ≤ 5 for cache hits. Without this, every export re-runs npx hyperframes render from scratch.
  5. AI can't suggest assets. P6's AI Dock has no setAsset-producing scenario because there's no library to suggest from. The op exists (P3), but no scenario exercises it.
  6. buildCaptionHtml/buildCaptionTimelineJs from P4 are unused. The export pipeline consumes them; until P7 wires export, they're dead code.

Solution

This phase ships the asset library and export pipeline as the final two production surfaces, plus the render cache:

  • AssetStore promoted to IndexedDB. idb-keyval for a thin wrapper. Map<ref, Asset> becomes idb.get(ref)/idb.set(ref, asset). Survives reload. Asset shape: { ref, blob, mime, size, createdAt }. Blob URL generated on demand via URL.createObjectURL.
  • AssetLibrary panel + dialog. Desktop: dedicated Assets tab in right panel (or separate dialog from rail). Mobile: Assets tool button → bottom Sheet. Renders grid of AssetCards (thumbnail + size + ref). Drag-to-field reuses P3's drop logic. Search by ref prefix or upload date.
  • AssetDropzone reusable component. Wraps the drag-and-drop + crypto.subtle digest + assetStore.set + setAsset dispatch. Used by both FileInput (P3) and AssetLibrary (P7).
  • Export pipeline as async event stream. POST /api/export (MSW mock) returns SSE-style events: assemble:done, stamp:done, voiceover:done (with timings), captions:done, render:progress (frame N/total), render:done (with mp4 url). ExportDialog renders a step list with per-step status + a global progress bar.
  • aHash render cache. shared/lib/aHash.ts + hamming.ts port from bundle visual_determinism. Before kicking the render step, compute aHash(newFrame) for each slide; compare to cached hash; if hamming ≤ 5, mark slide as cache HIT and skip. Cache stored in IndexedDB alongside assets.
  • setAsset EditBus op wired into EchoProvider. P6's AI Dock gains a new scenario: "Suggest image for slide-1" → returns an EditProposal with a setAsset op pointing at an existing ref from the library. (Mock: picks the most-recent asset.)
  • Export uses real buildCaptionHtml/buildCaptionTimelineJs from P4. The export step calls these to generate the caption layer + GSAP timeline JS as strings, which the assembler writes into the root index.html markers (per AGENTS.md <!-- CAPTION_LAYER --> / // CAPTION_TIMELINE pattern). This is the only place in the React app that uses the Python-style marker injection — it's the export-target spec.

Deliverable

A reviewer can:

  1. Drag an image onto AssetDropzone (in Properties field OR the AssetLibrary panel) → SHA-256 computed → asset thumbnail appears in the library → field shows sha256:... ref. Drop the same image again → dedupes (existing thumbnail, no new entry).
  2. Refresh the page → asset library still shows all previously dropped assets (IndexedDB persistence).
  3. Open AssetLibrary tab on desktop → grid of assets → search "eco" filters by filename or ref prefix → drag an asset to slide-2's image field → ref updates.
  4. On mobile: tap Assets tool → Sheet opens with the grid → tap an asset → "Apply to slide-2 image" sheet.
  5. Click Export button in AppShell → ExportDialog opens → click "Start export" → 6-step list ticks through assemble → stamp → voiceover → captions → render (with frame progress bar) → done → result MP4 (mock blob) plays in a preview; download link appears.
  6. Click Export again without changes → cache HITs logged → render step shows "Skipped (no visual change)" → completes faster.
  7. AI Dock: type "use the most recent image for slide-3" → EchoProvider returns setAsset proposal → Accept → slide-3 image updates.
  8. pnpm test → aHash/hamming match pinned fixtures; export step order enforced; caption marker strings generated correctly.

Files added / modified

src/
├── components/ui/
│   ├── progress.tsx                         # NEW (shadcn add)
│   └── aspect-ratio.tsx                     # NEW (for AssetCard thumb)
├── features/
│   ├── assets/
│   │   ├── components/
│   │   │   ├── AssetLibrary.tsx             # NEW — grid + search
│   │   │   ├── AssetDropzone.tsx            # NEW — reusable drag-drop + digest
│   │   │   ├── AssetCard.tsx                # NEW — thumbnail + ref + size
│   │   │   └── AssetUploader.tsx            # NEW — file picker alternative to drop
│   │   └── hooks/
│   │       ├── useAssetStore.ts             # MODIFIED — IndexedDB-backed (idb-keyval)
│   │       ├── useAssetUpload.ts            # MODIFIED — uses AssetDropzone logic
│   │       └── useAssetSearch.ts            # NEW — filter by ref prefix / date
│   ├── export/
│   │   ├── components/
│   │   │   ├── ExportButton.tsx             # NEW — AppShell toolbar trigger
│   │   │   ├── ExportDialog.tsx             # NEW — shadcn Dialog with step list + progress
│   │   │   ├── ProgressBar.tsx              # NEW — wraps shadcn Progress
│   │   │   ├── StepList.tsx                 # NEW — 6 steps with status icons
│   │   │   └── ExportResult.tsx             # NEW — MP4 preview + download
│   │   ├── hooks/
│   │   │   └── useExportJob.ts              # NEW — subscribe to mock SSE stream
│   │   └── lib/
│   │       ├── computeTimings.ts            # NEW — port from export_pipeline bundle
│   │       ├── parseProgress.ts             # NEW — parse mock HF stdout events
│   │       ├── hostDiv.ts                   # NEW — generate HF host div string
│   │       └── assembleWorkspace.ts         # NEW — write HF workspace layout for export
│   └── ai/
│       └── lib/
│           └── scenarios.ts                 # MODIFIED — add suggestAsset scenario
├── shared/
│   ├── api/msw/
│   │   ├── handlers.export.ts               # NEW — POST /api/export SSE-style mock
│   │   └── exportFixtures.ts                # NEW — canned event stream
│   ├── lib/
│   │   ├── aHash.ts                         # NEW — port from visual_determinism bundle
│   │   ├── hamming.ts                       # NEW
│   │   └── renderCache.ts                   # NEW — IndexedDB-backed aHash cache
│   └── edit/
│       └── ops.ts                           # (no changes — setAsset from P3 reused)
└── package.json                             # MODIFIED — idb-keyval

Key decisions

  • IndexedDB via idb-keyval. Thinnest possible wrapper (get/set/del/keys). Survives reload. No IndexedDB schema migrations to manage. Asset shape: { ref, blob: Blob, mime, size, createdAt }. Blob URL generated lazily via URL.createObjectURL and revoked on unmount.
  • AssetDropzone is the single entry point for asset ingest. Used by FileInput (P3 retrofits to use it), AssetLibrary (drag into the panel), and the AI Dock (Accept on a setAsset proposal validates the ref exists in the store). One component, three call sites.
  • AssetLibrary = grid + search. No folders, no tags — just SHA-ref + mime + date. Search filters by ref prefix (first 12 chars) or filename (if available from upload). Sort by date desc default.
  • Export pipeline = mock SSE stream. MSW handler POST /api/export accepts project payload, returns a stream of ProgressEvents with 100–500ms delays between steps. Step order enforced (assemble → stamp → voiceover → captions → render → done). useExportJob(jobId) subscribes via EventSource (or fetch + ReadableStream).
  • render:progress events carry frame counts. {step:'render:progress', frame:120, total:900}. ProgressBar computes frame/total. Final render:done carries {url:'blob:...'} for preview.
  • aHash + Hamming for render cache. Per bundle visual_determinism: 8×8 grayscale → mean → bit grid → Hamming distance. Threshold ≤ 5 for HIT. Cache stored in IndexedDB: Map<slideId+editHash, {frameHash, outputUrl}>. Before render step: compute current frame's aHash, compare to cache, skip on HIT.
  • Export consumes buildCaptionHtml / buildCaptionTimelineJs (from P4). The assembler writes the generated strings into root index.html markers (<!-- CAPTION_LAYER -->, // CAPTION_TIMELINE). This is the ONLY place marker injection happens in the React app — it's the export-target spec, per AGENTS.md.
  • assembleWorkspace writes HF's expected layout. index.html (root host) + compositions/slide-N.html (each slide's HTML verbatim) + assets/ + voiceover/captions injected. Per bundle export_pipeline step 1. In P7 this assembles in-memory; the actual npx hyperframes render call is mocked.
  • setAsset scenario added to EchoProvider. "Suggest image for slide-3" / "Use most recent asset" → proposal with setAsset(slideId, 'img', mostRecentRef). Accept dispatches the op (already implemented in P3).

Tasks

shadcn adds

  • pnpm dlx shadcn@latest add progress aspect-ratio
  • Verify

Dependencies

  • Add: idb-keyval (IndexedDB wrapper)
  • Optional: browser-image-compression for upload size guard

Pure helpers (port + test)

  • shared/lib/aHash.ts: mean(frame), aHash(frame) (8×8 bit grid), per bundle visual_determinism. Fixtures: distance(A,A')=0, distance(A,B)=1, distance(A,C)=32.
  • shared/lib/hamming.ts: hamming(h1, h2) via popcount of XOR. Strict > for bit comparison (per bundle).
  • features/export/lib/computeTimings.ts: port from export_pipeline bundle. start = prev_end + GAP(0.8).
  • features/export/lib/hostDiv.ts: generate the host div string with data-composition-src, data-start, data-duration, position:absolute;inset:0;z-index:100-idx, class="clip".
  • features/export/lib/parseProgress.ts: parse mock HF stdout events (Rendering frame 120/900...) → ProgressEvent.
  • features/export/lib/assembleWorkspace.ts: write root index.html + compositions/slide-N.html (each slide verbatim) + assets + caption markers (using P4's buildCaptionHtml/buildCaptionTimelineJs).
  • Vitest: each helper matches pinned fixture from _output.txt.

AssetStore → IndexedDB

  • useAssetStore.ts: backed by idb-keyval. store(blob, mime) computes SHA via crypto.subtleref = 'sha256:' + hexidb.set(ref, {ref, blob, mime, size, createdAt}) → returns ref.
  • get(ref) → returns asset; URL.createObjectURL(blob) for display.
  • list() → returns all assets sorted by createdAt desc.
  • has(ref) → boolean.
  • Backward compat: if in-memory cache from P3 has unsaved entries, flush to IndexedDB on first P7 load.

AssetDropzone

  • Renders a drop target + hidden <input type="file">
  • onDrop(files): for each file → useAssetStore.store(file.blob, file.type)editBus.dispatch(setAsset(slideId, fieldId, ref)) (when wired to a field) OR just stores (when in library ingest mode)
  • Show shadcn Skeleton while hashing (10–500ms for typical images)
  • Mobile: tap = file picker (no native DnD); desktop: both work

AssetLibrary

  • Grid of AssetCards using shadcn AspectRatio for thumbnails
  • Search input (shadcn Input) → filter by ref prefix or filename
  • Drag asset onto a Properties FileInput → reuses AssetDropzone logic in drop mode
  • Mobile: tap asset → sheet "Apply to: [slide.field select]" → confirm

Export pipeline mock

  • handlers.export.ts: POST /api/export accepts {projectId, slides, rootConfig}; returns a stream of events:
    • {step:'assemble', status:'done'} (50ms)
    • {step:'stamp', status:'done'} (30ms)
    • {step:'voiceover', status:'done', durations:[...]} (300ms)
    • {step:'captions', status:'done'} (50ms)
    • {step:'render', status:'progress', frame:0, total:900}{frame:120,...} → ... → {frame:900, status:'done', url:'blob:mock-mp4'} (200ms between frames, batched)
    • {step:'done', url:'blob:mock-mp4'}
  • Export uses assembleWorkspace to compute what would be written; logs to console for verification

useExportJob

  • Subscribes to the mock stream via fetch + ReadableStream (or EventSource)
  • State: { status: 'idle' | 'running' | 'done' | 'error', currentStep, progress: {frame, total}, events: ProgressEvent[], outputUrl?: string }
  • Before render step: check useRenderCache for HITs; log skipped slides
  • On done: store outputUrl for preview + download

ExportDialog

  • shadcn Dialog triggered by ExportButton in AppShell
  • StepList renders 6 steps with icons (pending/running/done/error) per bundle's JobState
  • ProgressBar shows global progress (frame/total) during render step
  • Per-step expandable details (e.g. voiceover shows computed durations; captions shows line count)
  • On done: ExportResult shows HTML5 <video src={outputUrl} controls> + download link

Render cache

  • shared/lib/renderCache.ts: IndexedDB-backed Map<editKey, {frameHash: number[][], outputUrl: string}>
  • editKey = slideId + ':' + JSON.stringify(slide.fields) + ':' + slide.html.length (cheap hash for "did the slide change")
  • Before render step: for each slide, capture frame via renderer (mock: just hash a placeholder), compute aHash, compare to cached; if hamming ≤ 5 → cache HIT, reuse cached outputUrl
  • Render cache hits logged in the dialog ("slide-2: cache HIT, skipped render")

EchoProvider suggestAsset scenario

  • On keyword "use image" / "suggest asset" / "add picture" → query assetStore.list() → take first → return EditProposal with setAsset op for current slide's img field
  • If no assets in store → return assistant message "No assets uploaded yet. Drop an image in the Assets tab first."

Verification

Manual:

  • Drop image → SHA computed → thumbnail in library + field ref; same image again → dedup
  • Refresh → assets persist
  • Search filters work
  • Export → 6 steps tick through → result MP4 (mock blob) plays; download works
  • Export again with no edits → render step shows "cache HIT, skipped" → completes faster
  • AI Dock "use most recent image" → proposal → Accept → field updates
  • Mobile: assets sheet + export dialog both work

Automated:

  • pnpm testaHash + hamming pinned fixtures (distance 0, 1, 32)
  • pnpm testcomputeTimings([4,5,3], 0.8) returns {starts:[0,4.8,10.6], total:16.4}
  • pnpm testhostDiv('slide-0', 0, 0.5, 5.0) returns expected string with position:absolute;inset:0
  • pnpm testparseProgress('Rendering frame 120/900') returns {frame:120, total:900}
  • pnpm testassembleWorkspace writes correct compositions/slide-N.html files (verbatim slide HTML)
  • pnpm testassembleWorkspace injects caption markers using buildCaptionHtml/buildCaptionTimelineJs
  • pnpm test — AssetStore round-trip: store → list → get → has all consistent
  • pnpm test — Render cache: storing a hash + querying returns HIT for same/within-threshold; MISS for very different
  • pnpm test — EchoProvider suggestAsset: returns setAsset proposal with valid ref
  • pnpm check passes

Dependencies

  • Requires: P3 (setAsset op, AssetStore foundation), P4 (buildCaptionHtml/buildCaptionTimelineJs), P6 (EchoProvider for suggestAsset scenario)
  • Unblocks: — (final phase)

Risks / open questions

  • IndexedDB quota. Browsers cap storage (typically 50MB–1GB+ depending on origin). Large video projects with many HD image assets can hit the cap. Mitigation: show usage in the library UI; warn near cap; allow user-purged assets. Out of scope for P7 to implement purge UI.
  • URL.createObjectURL leaks. Each call holds memory until revoked. Asset cards must revoke URLs on unmount. Verify with Chrome DevTools heap snapshots.
  • Mock export realism. The 6-step stream is timed to look real but doesn't actually run npx hyperframes render. When the real backend lands (post-P7), the same useExportJob consumes the real stream — only MSW handler changes.
  • aHash on real frames requires the renderer's capture API. P7's SlideRenderer doesn't expose frame capture yet (it's part of the future own-renderer, RFC §9.3). Mock returns placeholder hashes; real cache HIT detection lands with the own renderer.
  • assembleWorkspace writes to memory; not a real FS. The actual file writing happens server-side when the real backend lands. P7's job is to verify the assembly logic — the bytes that would be written.
  • Real backend wiring (post-P7). Swap MSW handlers for real fetch calls; everything upstream is identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment