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)¶
- Extract state first, then markup. Pull the reactive state + operations into modules, then split the view.
- Shared session state lives in a module-level rune class, constructed once in the
screen container and passed down as one
sessionprop — no prop-drilling, no per-field passing, no context gymnastics (the tree is shallow). Inject changing identity via a getter (() => slug) and surface messages via anonToast-style callback. - 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. - 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.
- Runes only (
$state/$props/$derived/$effect; noexport let/$:). Events areon<Thing>callback props, notcreateEventDispatcher. Leaves are dumb and layout-agnostic. - Naming convention: reactive rune modules are
*.svelte.js; pure modules are plain*.js; everylib/module ships a colocated*.test.js. - A thin container is a consequence, not a goal — verified by module tests, not a
max-lineslint rule (there is none; it's a review smell-test). - 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 architecture — not 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:
AuthorView→TimelineStrip,CardGrid,CardInspector,AssociatedPanel. - Produce:
ProduceView→TimelineStrip+CardGrid(shared with Author, bound toreelSession),PreviewHero,RenderPanel,MediaGenPanel(+ the0030record control),ContactSheetPanel,SpendQuotaPanel. - Publish:
PublishView→PlatformCopy(per-platform),ConnectionsPanel,LedgerPanel. - Cross-cutting:
AssistantDrawer(spec0032), bound toassistantSession.
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)¶
- 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.
- 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
0029IA 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.jsrune class passed as one prop; no prop-drilling of individual fields; deterministic maths in pure*.jswith 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, thee2enpm script +@playwright/testdep, and the CI job — as the browser-level safety net for the decomposition (and the harness future UI specs0029/0030extend).
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.js—testDir: ./e2e, awebServerthat runsnpm run build && npm run preview -- --port <p>(e2e runs against the production bundle, the thing that actually ships),baseURLto it,chromiumproject,fullyParallel,retries: CI?1:0,trace: on-first-retry,forbidOnlyin CI.- API is mocked via
page.route("**/api/v1/…")interception — no 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 harness0030's recording e2e — with a fakedMediaRecorder/getUserMedia— and0029'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: trueand theRUN_E2E: "true"variable (exactly as krites'.gitlab-ci.ymldoes) 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 lifecycle —
reelSession(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-lineEditor's logic) is the highest-value first extraction. Rec: state-first,reelSessionleads. - 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.