0014 — Svelte UI Refactor: Componentisation & Performance¶
Status: APPROVED (2026-07-11) — ready to implement; Stage 0 of 0019 §9.1.
Last updated: 2026-07-11 (revised after the 0019 review + the multi-layout grid decision — see §7)
1. Purpose¶
The studio UI, particularly Review.svelte, has grown into a ~1,500-line monolith. It
does not just render a lot of markup — it interleaves ~10 distinct concerns in one
file: object-removal canvas/brush (0008), inpaint provider state, the AI critic
(0009), core cull state, undo/redo, multi-select + bulk ops, keyboard handling,
export/XMP, burst compare, cull-profile apply, and the SSE progress stream. It also
renders the grid of up to 4,000 frames with a plain {#each} and no virtualisation,
violating the performance requirements in 0001 §10 and 0003 R-UI-10.
This spec refactors the studio UI to maximise performance (virtualisation + bounded
image memory) and componentisation (state extracted into modules, not just markup
split out), driven test-first. It is Stage 0 of the 0019 near-term plan — the
foundation the Stage 1 UX features and the Stage 2 redesign both stand on.
Key principle (from the 0019 review): the monolith's problem is tangled logic, not
markup. Extracting only visual components would relocate the monolith into
Review.svelte's <script> and let "Review < 400 lines" be gamed. The real win is
extracting the state machines into rune modules (§3.1) and having thin components
consume them.
2. Performance¶
2.1 Data prerequisite — per-frame dimensions (must land first)¶
Every variable layout below (masonry, justified rows) positions a tile from its
intrinsic aspect ratio, so the grid needs each frame's pixel width/height (+ EXIF
orientation) known up front. That is what lets the layout be computed
deterministically with no DOM measurement — and measurement is precisely the
scroll-jump trap that makes masonry virtualisation painful. These fields are not
currently in the frame payload (name/verdict/rating/reasons/cluster/best
only).
- Prerequisite slice (backend):
ingestis a pure filename index (no decode), but the cull/analysis pass already decodes every frame (analyze/qualitycallsimg.Bounds()), so capturing dimensions there is genuinely free. Store per-framewidth/heightin the regenerableAnalysiscache (besideHashesinpkg/shoot), and have the studiolistFramesmerge them into the frame JSON (frameViewinpkg/studio). Use the displayed aspect — i.e. the served preview's orientation-corrected dimensions — so portrait frames lay out correctly. - Independently required by the reasons-heatmap UX feature (
0017#5), which maps issue coordinates onto the image — so this is shared groundwork, not grid-only cost. - Fallback: a frame with no cached dimensions (pre-cull or a legacy shoot) defaults to 3:2 so layout degrades gracefully rather than measuring the DOM.
2.2 The custom grid — one component, pluggable layout, zero dependencies¶
Rather than a uniform-only virtualiser (which the Stage-2 redesign would outgrow, forcing
a rewrite), build one custom Grid component that supports multiple layouts behind a
shared core — hand-rolled, no third-party library, keeping the frontend at zero
runtime npm deps (§2.5). Two layers:
- Layout engine — a pure function
(items[{aspect}], containerWidth, config) → { rects: [{x,y,w,h}], totalHeight }, with swappable strategies: - uniform — fixed lattice (today's fixed-cell look);
- masonry — column-packed, variable-height tiles (the
0018inspiration look); - justified rows — constant row height, variable width, filled to the row width (Google-Photos/Flickr style — strongest for scanning a cull, since equal heights ease left-to-right comparison). Because aspect ratios are known (§2.1), the full layout is computed in O(n) with no DOM measurement.
-
Windowing core — layout-agnostic: binary-search the
rectsfor the first visible one, render that slice + a bounded overscan, position each tile withtranslateover a full-height spacer, on a passive scroll listener. Supports vertical (grid) and horizontal (the loupe filmstrip reuses the same core) axes. -
Default layout is deferred to the Stage-2 redesign (
0019§9.1): ship the engine with all three strategies; choose the shipped default — and an optional user toggle (uniform / masonry / justified) — then. No architecture is locked to that choice. - Invariant (the §4 test target): rendered tile-node count stays bounded (≈ visible + overscan) for every layout, regardless of shoot size — never O(4,000).
2.3 Target capabilities (TanStack Virtual's feature set, emulated selectively)¶
The custom grid emulates the parts of TanStack Virtual that earn their place and deliberately skips the rest (we adopt the ideas, not the dependency — §2.5):
| Capability | In scope | Note |
|---|---|---|
| Windowing, vertical and horizontal | ✅ | horizontal = the loupe filmstrip reuses the core |
| Overscan buffer | ✅ | bounded; smooth scroll |
scrollToIndex + alignment (start / center / auto) |
✅ | keyboard culling: centre the selected / next-reject frame |
| ResizeObserver → relayout | ✅ | window resize, sidebar/panel toggle, grid pinch-zoom (0017 #6) |
| Scroll-position restoration | ✅ | grid → loupe → grid without losing place |
| Variable item sizes | ✅ via known aspect ratios (§2.1), not DOM measurement | |
measureElement (dynamic DOM measure) |
❌ | the scroll-jump trap; unnecessary and worse for images |
| Lanes / fixed columns | ✅ | subsumed by the layout engine |
| Sticky / pinned items (section headers) | 🔶 design-for, wire later | future time-chapters (0016 #8) / cluster grouping |
| Custom range extractors | ❌ | over-engineering for our needs |
| Padding start/end, gap | ✅ | trivial config |
2.4 Bounded image memory — the real 4,000-frame killer¶
Virtualisation caps DOM nodes, but decoded bitmaps are what actually blow up memory on a
full shoot. 0003 R-UI-10/§243 already require this; restate it here as a hard part of
the refactor:
- Server-sized preview JPEGs only, never full originals — already available via
api.previewUrl(shoot.id, name, "thumb", …). ✓ loading="lazy"+decoding="async"on every tile<img>.- Evict off-screen images: windowed-out tiles leave the DOM (so the browser can free
their decoded bitmaps); do not keep 4,000
<img>alive with hidden CSS. content-visibility: auto+contain-intrinsic-sizeon tiles as a cheap complement to windowing.- Loupe preloading: preload
index ± 1(configurable to ±2) vianew Image()+.decode()to eliminate flashing during rapid keyboard navigation, without pinning the whole filmstrip in memory.
2.5 JavaScript supply-chain hardening¶
The studio is go:embeded into a signed, notarized .dmg (0010) — a compromised JS
dependency ships as malware inside an Apple-notarized app. The frontend supply chain is
part of krites' distributed trust boundary, so it is hardened as a first-class
requirement (belongs in CI, which 0010 established):
- Stay at zero runtime dependencies wherever feasible (the hand-rolled grid keeps it so).
- Pin exact versions (no
^/~) and commit the lockfile; CI installs withnpm ciand--ignore-scripts(neutralises thepostinstallattack vector, the dominant npm-compromise vein). - Gate CI on
npm audit(and/or a Socket/OSV check); review every dependency bump deliberately — no automated merge of version bumps. - Before adopting any new package, check it live against OSV / GitHub advisories / socket.dev — advisories move faster than any spec.
3. Componentisation & Svelte best practices¶
Dismantle the monolith by extracting state first, then markup. Shared session state
lives in module-level rune stores (.svelte.ts) or context — not prop-drilling.
3.1 State modules (Svelte-5 rune modules) — do these first¶
Convention: the studio frontend is plain JavaScript (no TypeScript) — modules are
.js, rune modules are.svelte.js, shared modules live insrc/lib/with colocated.test.js(matching the existingsrc/lib/region.js+api.js).
cullSession.svelte.js— the heart: frames (now incl. dimensions), verdicts, ratings, filter, derived counts, undo/redo stacks, andpersist. Pure of DOM.selection.svelte.js— multi-select set, anchor/shift-range,selectAll,bulkVerdict.shortcuts.js— the Lightroom-style keymap (P/X/1–5/arrows), debounced so a held arrow key doesn't thrash the API.api.js— the fetch client (verdicts, ratings, export, XMP, previews, profile) — extends the existingsrc/lib/api.js.sse.js— the cull-progressEventSourcestream, as a rune store.gridLayout.js— the pure layout engine (§2.2); framework-agnostic, unit-tested in isolation atsrc/lib/gridLayout.js.
3.2 Feature components pulled out of the cull container¶
These are not core-cull concerns and should not sit in Review.svelte at all:
- Object removal (
0008):InpaintMask.svelte(canvas +rmDown/rmMove/rmUp) plus anobjectRemoval.svelte.tsmodule for the region/preview/accept state. - AI review (
0009):ReviewPanel.svelte+ its availability/result state module.
3.3 Core cull component tree¶
Review.svelte(thin container): wires the modules to the layout; orchestration only (target: well under 400 lines because the logic left for the modules, not because markup was hidden).Grid.svelte: the custom multi-layout virtualised window (§2.2), consuminggridLayout.js+ the windowing core.Tile.svelte: one thumbnail — score badge, verdict/rating/flag overlay, reasons affordance, selection state.Loupe.svelte: single-image view + adjacent-image preloading (§2.4); filmstrip via the horizontal windowing core.Filmstrip.svelte,Toolbar.svelte,Filters.svelte,Inspector.svelte(score sidebar),ExportBar.svelte,CompareBurst.svelte(side-by-side cluster compare).
This decomposition is what makes the Stage 1 UX features (survey mode, lights-out, focus peaking, reasons heatmap) drop in by composing modules rather than editing a monolith.
3.4 Svelte 5 rules¶
- Runes only (
$state,$derived,$effect,$props()); noexport let/$:. - Keep
<svelte:window onkeydown={…} />for shortcuts, routed throughshortcuts.ts; debounce rapid handlers. - Use snippets (
{#snippet}) for repeated in-file markup. - Reactivity granularity: per-tile state stays local so one verdict/rating edit does not re-diff all 4,000 frames or re-run the layout engine.
4. Testing approach (test-first)¶
This is a refactor (structure changes, behaviour is preserved), so the flow is: characterization test (capture current behaviour) → extract module/component under green → refactor.
4.1 Unit & component (Vitest + @testing-library/svelte — both already present)¶
- Characterization first: before extracting a concern, write tests that pin the
monolith's current behaviour (e.g. "shift-click selects the range", "
Uundoes the last verdict") as the safety net. - Then per-unit behaviour: each extracted module/component ships its own behavioural
tests (e.g.
cullSession— "undo restores the prior verdict";Loupe— "right-arrow fires next"). - Layout engine (
gridLayout.js) is pure → unit-test directly: for each strategy (uniform / masonry / justified), assert rect positions,totalHeight, column balance, and no-overlap for a fixture of known aspect ratios. - Virtualisation invariant test: programmatically scroll a 4,000-item mock (per layout) and assert the rendered tile-node count stays bounded — this, not a frame-rate feel, is the provable virtualisation gate.
4.2 End-to-end (Playwright)¶
- Core flows: "navigate + cull a burst", "bulk-reject a cluster", "apply an inpaint mask", "switch grid layout". These must pass to call the refactor done.
- Performance trace: a Playwright + CDP run over a large mock shoot asserting a jank budget (bounded long-tasks) with real preview image loads — not empty divs.
5. Definition of Done¶
- Dimensions flow end-to-end: per-frame
width/heightcaptured during the cull/analysis pass (Analysis cache) and served in the frame payload (displayed aspect); the grid lays out from them with no DOM measurement (pre-cull/legacy frames fall back to 3:2). - State extracted:
cullSession/selection/shortcuts/api/sse/gridLayoutlive in their own modules; object-removal and AI-review are their own components+modules.Review.svelteis a thin container (well under 400 lines) by virtue of the logic having moved, verified by the module tests — not by hiding markup. - Custom multi-layout grid:
Gridsupports uniform + masonry + justified via the layout engine, with the emulated capabilities of §2.3; the virtualisation invariant holds for every layout (rendered tile-node count bounded across a full programmatic 4,000-frame scroll); images lazy-load, off-screen tiles are evicted, memory stays flat. - Zero new runtime dependencies; supply-chain hardening (§2.5) is in CI (pinned +
lockfile +
npm ci --ignore-scripts+ audit gate). - Tests green: characterization + per-unit behavioural tests (incl. the pure layout-engine tests) pass; Playwright core flows + the perf trace pass.
6. Sequencing note¶
Stage 0 of 0019 §9.1. Runs on the post-0011 studio (GTB v0.29.0, controls-managed
http + auth) and must refactor against the current surface — including the 0012
global-settings panel and the 0009/0008 feature code now living in the monolith.
Suggested slice order: (a) the §2.1 dimensions prerequisite (backend) → (b) state
modules + characterization tests → © the gridLayout engine + windowing core (pure,
unit-tested) → (d) Grid/Tile/Loupe on the core → (e) the remaining
components + object-removal/AI-review extraction. Landing this cleanly is what unblocks
Stage 1 (UX features) and Stage 2 (the redesign).
7. Revision history¶
- 2026-07-11 (multi-layout grid decision + approval): upgraded the grid from
uniform-only to a custom pluggable layout engine (uniform / masonry / justified) over
a shared windowing core — building for the redesign now to avoid a later rewrite; added
the per-frame dimensions prerequisite (the enabler for measurement-free variable
layout, also needed by
0017#5) and the §2.3 target-capability set distilled from TanStack Virtual's features. Status → APPROVED. - 2026-07-10 (post-
0019review): dropped TanStack/svelte-virtualin favour of a hand-rolled zero-dependency virtualiser (supply-chain + footprint); reframed componentisation around state-module extraction (was markup-only); added the bounded image-memory strategy and JS supply-chain hardening; replaced the ">80% coverage" and "60fps with mocked items" gates with the DOM-node-bound virtualisation invariant, characterization-first testing, and a real-image perf trace.