Skip to content

0028 — Studio Svelte decomposition (componentisation + state extraction)

Status: IMPLEMENTED (2026-07-23). The full decomposition landed on main across the studio-rework arc (MRs !116/!118/!127/!132): the state layer (§3.1) and every new-IA view component (§3.2). Modelled on krites 0014-svelte-ui-refactor.md (APPROVED). The phase split (§3.2) was reworked 2026-07-17 into a 4-tab IA (Reel · Scenes · Preview · Publish) — see 0029 §9; the component decomposition here stands, only the tab that hosts each component changed. The one carve-out is the assistant surface (assistantSession + AssistantDrawer) — scoped out to spec 0032, never part of this decomposition. Ongoing UI reshaping continues under the living 0029 §9, not here. Date: 2026-07-12 Source: ideation 2026-07-12 — port krites' studio decomposition to keryx. keryx's studio (pkg/studio/web/) uses the same stack as krites (Svelte 5.19 runes, Vite 6, Vitest 3, @testing-library/svelte), so the pattern transfers 1:1. Relates: tightly coupled with the UI restyle (0029) — the decomposition targets 0029's §8 information architecture (Command Deck), so the state layer + new component tree land here and their styling lands in 0029 (see §3, the reconciliation). Also 0030 (a record control in MediaGenPanel) and 0032 (the assistantSession + AssistantDrawer).

1. Problem

keryx's studio is 13 components / ~3,650 lines, dominated by monoliths:

Component Lines Bundled concerns (the tangle)
Editor.svelte 866 reel load/save + dirty/baseline tracking, commit/push status, theme load/switch, card CRUD, drag-drop reorder, mobile pane toggles + localStorage flags, validation, and four layout regions (cards · editor · chat · assoc)
Publish.svelte 504 per-platform enablement, copy, approve/ledger state, post fan-out + results
ProjectSwitcher.svelte 309 project list fetch + switch
MediaGen.svelte 299 async generate/poll/gallery/pick + cost cue (already generalised per 0016)
Chat.svelte 295 message transport + render

The problem — per krites' central thesis — is tangled logic, not markup. Extracting only visual sub-components would relocate the monolith into a container's <script>. The real win is extracting the state machines into rune modules with thin components consuming them.

2. Principles (lifted from krites 0014)

  1. Extract state first, then markup. Pull the reactive state + operations into modules, then split the view.
  2. Shared session state lives in a module-level rune class, constructed once in the screen container and passed down as one session propno prop-drilling, no per-field passing, no context gymnastics (the tree is shallow). Inject changing identity via a getter (() => slug) and surface messages via an onToast-style callback.
  3. Separate pure decision logic from the reactive shell. Deterministic maths (card timing, wrapping, validation, DnD reorder, flag persistence) are pure .js — no $state, no DOM — fully unit-testable; the reactive class calls into them.
  4. A concern that isn't core to the container doesn't live in it — feature panels become their own component plus their own state module.
  5. Runes only ($state/$props/$derived/$effect; no export let/$:). Events are on<Thing> callback props, not createEventDispatcher. Leaves are dumb and layout-agnostic.
  6. Naming convention: reactive rune modules are *.svelte.js; pure modules are plain *.js; every lib/ module ships a colocated *.test.js.
  7. A thin container is a consequence, not a goal — verified by module tests, not a max-lines lint rule (there is none; it's a review smell-test).
  8. Omit virtualisation. krites' 4,000-frame Grid/windowing engine (0014 §2) does not transfer — keryx works at tens of cards/reels, not thousands of stills. Skip it entirely.

3. Target structure — decompose once, toward the redesign

Two layers. The state layer is layout-agnostic (a reelSession doesn't care whether the view is today's Editor or the Command Deck), so we decompose once toward the 0029 §8 information architecturenot toward today's layout. The sessions/logic extract behaviour-preserving and survive the redesign untouched; the view components are (re)built to the new IA, and that rebuild is where 0029's restyle lives. (Without this reconciliation we'd decompose to the current shape, then restructure for the redesign — the component surgery twice.)

3.1 The state layer (src/lib/) — extract first, layout-agnostic

Reactive sessions mapped to the reel lifecycle (Author → Produce → Publish), not to today's single Editor:

src/lib/
  api.js                     (existing — keep all I/O here)
  reelSession.svelte.js      SHARED across Author+Produce: active reel, cards, selection,
                             dirty/baseline, save/commit/push, theme, validation
  produceSession.svelte.js   generate/poll/candidate-takes/render/cost (subsumes MediaGen)
  publishSession.svelte.js   per-platform enablement/copy/approve/results/connections/ledger
  assistantSession.svelte.js chat turns + tool-dispatch + per-phase context  (spec 0032)
  timing.js       (pure)     card duration = VO len + lead + tail; crossfade maths
  validation.js   (pure)     storyboard validation (mirrors the Go core)
  reorder.js      (pure)     DnD reorder / move maths
  flags.js        (pure)     localStorage UI-flag get/set
  + colocated *.test.js for each

Selection + reel data are shared across Author and Produce, so they live in one reelSession both surfaces bind to (not isolated per-phase sessions); produceSession layers generation/render on top; publishSession and assistantSession are their own. This maps the phase-tab IA better than the current-UI-shaped editSession would.

3.2 The view components — built to the 0029 §8 IA

Decompose toward the Command Deck surfaces (not today's Editor layout):

  • Chrome: AppShell (tab router) · TopBar (ProjectSwitcher · Author│Produce│Publish · save state · menu) · ReelRail (Reels list + Theme).
  • Author: AuthorViewTimelineStrip, CardGrid, CardInspector, AssociatedPanel.
  • Produce: ProduceViewTimelineStrip + CardGrid (shared with Author, bound to reelSession), PreviewHero, RenderPanel, MediaGenPanel (+ the 0030 record control), ContactSheetPanel, SpendQuotaPanel.
  • Publish: PublishViewPlatformCopy (per-platform), ConnectionsPanel, LedgerPanel.
  • Cross-cutting: AssistantDrawer (spec 0032), bound to assistantSession.

TimelineStrip + CardGrid are the shared spine across Author/Produce — a reuse the current single-Editor layout can't express. Dumb leaves (a card tile, a platform row, a take tile) stay presentational with on<Thing> callback props.

3.3 Sequencing (reconciled with 0029)

  1. Extract the state layer behind the current views first — behaviour-preserving, characterization-tested (R-STU-1). De-risks by proving the logic is unchanged while the old UI still renders.
  2. Build the new-IA view components (§3.2) on that stable state, replacing the old Editor/Publish views — this step legitimately changes the view (it's the redesign), so R-STU-1's "no behaviour change" scopes to the state layer; the new views get fresh component tests + the Playwright e2e (§5.3).

So 0028 delivers the state layer + the new component tree; 0029 delivers their look (tokens, IA styling). Two layers, sequenced state → view — not two rewrites.

4. Requirements

  • R-STU-1 (MUST) No behaviour change in the state layer. The §3.1 extraction (sessions + pure modules) is refactor-only — observable behaviour (endpoints hit, ops, results) is unchanged, pinned by characterization tests written before each extraction (step 1, §3.3). The §3.2 view rebuild to the 0029 IA does change the view by design and is covered by fresh component + Playwright tests, not by R-STU-1.
  • R-STU-2 (MUST) Shared screen state lives in a *.svelte.js rune class passed as one prop; no prop-drilling of individual fields; deterministic maths in pure *.js with colocated tests.
  • R-STU-3 (SHOULD) No component exceeds a review-smell size (~400 lines) as a consequence of logic extraction, not by relocating markup.
  • R-STU-4 (MUST) Replicate krites' Playwright e2e scaffolding for the studio SPA (§5.3) — config, an e2e/ suite, the e2e npm script + @playwright/test dep, and the CI job — as the browser-level safety net for the decomposition (and the harness future UI specs 0029/0030 extend).

5. Testing / DoD

Testing is a first-class deliverable of this refactor, at three layers. just ci green; prettier --check + svelte-check + eslint clean.

5.1 Unit — pure modules (Vitest)

Every pure lib/*.js (timing, validation, reorder, flags, …) ships a colocated *.test.js — deterministic, DOM-free, fast. These are where the extracted decision logic earns its keep (mirrors krites' undo.test.js/selection.test.js/gridLayout.test.js).

5.2 Component — rune classes + components (Vitest + @testing-library/svelte)

Both are already in keryx's package.json. Test-first refactor discipline (krites §4): characterization tests capture current behaviour → extract under green → refactor — so R-STU-1 (no behaviour change) is proven, not asserted. Each *.svelte.js session class gets behavioural tests (state transitions, ops); each extracted component a render/interaction test.

5.3 E2E — Playwright over the built SPA (replicate krites' scaffolding)

Port krites' setup wholesale (~/workspace/phpboyscout/haileys-app/pkg/studio/web/):

  • playwright.config.jstestDir: ./e2e, a webServer that runs npm run build && npm run preview -- --port <p> (e2e runs against the production bundle, the thing that actually ships), baseURL to it, chromium project, fullyParallel, retries: CI?1:0, trace: on-first-retry, forbidOnly in CI.
  • API is mocked via page.route("**/api/v1/…") interceptionno Go backend, no fixtures. The Go side stays covered by godog + the Go unit suite; Playwright verifies only what jsdom can't — real layout, drag-drop reorder, scroll, <audio>/media wiring, the eventual timeline scrubber. Mock keryx's own endpoints (workspaces / reels / cards / vo / music / render / publish), not krites' shoots/frames.
  • e2e/ suite — keryx-relevant flows: open a reel in the Editor, edit + reorder cards, the author→produce loop (generate/pick a take via mocked jobs), the publish surface. Omit krites' 4,000-frame virtualisation perf test (perf.spec.js) — keryx has no mega-grid; keep the scaffolding, not that specific guarantee. (This suite is the harness 0030's recording e2e — with a faked MediaRecorder/getUserMedia — and 0029's visual passes will extend.)
  • package.json — add "e2e": "playwright test" + @playwright/test (devDependency, pinned) and Playwright's chromium install in CI.
  • CI — enable the shared cicd svelte-test component's enable_e2e: true and the RUN_E2E: "true" variable (exactly as krites' .gitlab-ci.yml does) so the Playwright job runs on frontend MRs. Playwright runs its own bundled headless chromium, so it's consistent with this repo's no-GUI-browser rule (Puppeteer stays my ad-hoc local verification tool; Playwright is the committed, CI-run e2e harness).

5.4 Supply chain + docs

Keep the posture (zero runtime deps, pinned + lockfile, npm ci --ignore-scripts) — the studio is go:embeded into a shipped binary, so a compromised dep ships to users. Docs: update the studio component page with the new lib/ architecture and the testing layers.

6. Open questions / decisions

  • D1 — session granularity. Sessions mapped to the reel lifecyclereelSession (shared Author+Produce) · produceSession · publishSession · assistantSession (§3.1) — vs one-per-current-screen or a single god-object. Rec: the lifecycle map — it fits the 0029 phase-tab IA and shares the reel/selection state Author and Produce both need.
  • D2 — scope/phasing. Per §3.3: state layer first (behind current views, characterization-tested), then the new-IA view components. Within the state layer, reelSession (the 866-line Editor's logic) is the highest-value first extraction. Rec: state-first, reelSession leads.
  • D3 — how much pure-core to mirror client-side. Timing/validation exist in Go; the editor duplicates some. Extract the client copies to pure lib/*.js (testable) vs leave inline. Rec: extract the ones the editor actually computes.
  • D4 — relationship to 0029 (reconciled §3). ✅ Resolved: decompose once toward the 0029 §8 IA — state layer (0028, layout-agnostic) then new-IA view components whose styling is 0029. Not "decompose to current shape then restructure." Supersedes the earlier "0028 lands first, 0029 restyles the result" framing.

7. Implementation progress

State layer (§3.1) — COMPLETE. Extracted behaviour-preserving, characterization-tested first (R-STU-1), each behind its still-current view:

Module Extracted from Tests View wired
reorder.js (pure) Editor DnD/move 14
flags.js (pure) Editor localStorage UI-flags 5
reelSession.svelte.js Editor.svelte (the 866-line monolith) 12 ✅ Editor
produceSession.svelte.js RenderPanel.svelte (render/poll/cancel) 6 ✅ RenderPanel
mediaJob.svelte.js MediaGen.svelte (generate/poll/takes/pick/quota) 9 ✅ MediaGen
publishSession.svelte.js Publish.svelte (composer/approve/connections/post) 9 ✅ Publish

Notes on the D3 pure-core decision: timing.js / validation.js are intentionally not extracted — the studio has no client-side timing or storyboard-validation logic today (it only displays validation results the Go core returns and server-side timing); extracting them now would invent behaviour, violating parity/R-STU-1. They land if/when the client computes them. assistantSession.svelte.js is scoped to spec 0032 (the assistant), not this decomposition.

View components (§3.2) — COMPLETE. The new-IA view tree is built on the stable state layer, styled from 0029. The shell slice (below) landed first; the remaining panels — the Produce-right CardMediaPanel/ContactSheet/SpendQuotaPanel and the Publish PlatformCopy/ConnectionsPanel/LedgerPanel, plus the shape→make CardInspector split and the shared TabBar/StatusBadge — all subsequently landed (MRs !127/!132). The only component named in this spec that was not built here is the assistant surface, carved out to 0032.

Component Role Consumes
App.svelte the Command Deck shell — persistent chrome over a rail + phase-routed workspace phase/activeReel/dirty shell state
TopBar.svelte wordmark · Author │ Produce │ Publish phase tabs · save-state chip · theme toggle · menu presentational (raises phase + menu actions)
ReelRail.svelte persistent reel list + create/rename/duplicate/delete api.reels (self-loads)
ReelWorkspace.svelte owns the one shared ReelSession; save bar + status; phase router reelSession.svelte.js
AuthorView.svelte cards · card editor · chat · contact sheet shared ReelSession
ProduceView.svelte PreviewHero (immersive render) + associated content (Associated) shared ReelSession
PreviewHero.svelte immersive-dark 9:16 render stage + controls/progress own ProduceSession (2 tests)
TimelineStrip.svelte proportional cover·body·outro spine, shared selection (Author + Produce) shared ReelSession.timeline (4 tests)
CardGrid.svelte storyboard as visual tiles (badge · snippet · mode · duration · asset dots · ring) shared ReelSession (4 tests)
PublishView.svelte per-platform copy/approve/post (wraps Publish) own PublishSession
Menu.svelte real popover dropdown (vs OverflowMenu's responsive collapse) — (4 tests)

The old Editor.svelte monolith + Library.svelte are removed — the authoring regions moved verbatim into AuthorView (behaviour preserved, R-STU-1), Produce/Publish split to their own phase views (each already owning its session from the §3.1 extraction), and the full-screen library became the always-present rail. RenderPanel.svelte is removed too — its ProduceSession view is now the immersive PreviewHero at the top of Produce (the render engine is unchanged). The TimelineStrip spine now sits above both the Author grid and the Produce hero, drawing duration-proportional segments from real VO-driven timing — a new GET /workspace/{slug}/timeline endpoint feeds ReelSession.timeline (see 0002 R-UI-31; the "add timing to the API" decision, 2026-07-13). Save + theme are now in the chrome (§8): the workspace surfaces the session's save/theme handles up via an onstate callback, so the TopBar hosts the Save action + state and the ReelRail hosts the Theme picker — the session stays owned by the (per-reel-remounted) workspace, no hoist. The reels rail is collapsible (a top-bar toggle, persisted) to reclaim workspace width, and the card list is now the CardGrid — visual tiles (corner index · snippet · mode chip · VO-driven duration · asset dots · ring-selection shared with the timeline), drag-reorder + ↑/↓/✕ preserved.

The remaining panels then landed on later slices (MRs !127/!132): the shape→make split (CardInspector fields-only + CardMediaPanel on Produce-right), the ContactSheet + SpendQuotaPanel, the Publish decomposition (PlatformCopy/ConnectionsPanel/LedgerPanel), and the shared TabBar/StatusBadge. The 4-tab IA rework (0029 §9) then re-homed these components across Reel · Scenes · Preview · Publish without changing the decomposition itself. The only unbuilt piece is the assistant surface (0032).

Gate (main, 2026-07-23): Vitest 170 passed, Playwright green, svelte-check 0 errors, eslint + prettier clean; Go studio pkg + godog + golangci-lint green. Decomposition complete.