Skip to content

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): ingest is a pure filename index (no decode), but the cull/analysis pass already decodes every frame (analyze/quality calls img.Bounds()), so capturing dimensions there is genuinely free. Store per-frame width/height in the regenerable Analysis cache (beside Hashes in pkg/shoot), and have the studio listFrames merge them into the frame JSON (frameView in pkg/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:

  1. Layout engine — a pure function (items[{aspect}], containerWidth, config) → { rects: [{x,y,w,h}], totalHeight }, with swappable strategies:
  2. uniform — fixed lattice (today's fixed-cell look);
  3. masonry — column-packed, variable-height tiles (the 0018 inspiration look);
  4. 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.
  5. Windowing core — layout-agnostic: binary-search the rects for the first visible one, render that slice + a bounded overscan, position each tile with translate over a full-height spacer, on a passive scroll listener. Supports vertical (grid) and horizontal (the loupe filmstrip reuses the same core) axes.

  6. 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.

  7. 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-size on tiles as a cheap complement to windowing.
  • Loupe preloading: preload index ± 1 (configurable to ±2) via new 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 with npm ci and --ignore-scripts (neutralises the postinstall attack 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 in src/lib/ with colocated .test.js (matching the existing src/lib/region.js + api.js).

  • cullSession.svelte.js — the heart: frames (now incl. dimensions), verdicts, ratings, filter, derived counts, undo/redo stacks, and persist. Pure of DOM.
  • selection.svelte.js — multi-select set, anchor/shift-range, selectAll, bulkVerdict.
  • shortcuts.js — the Lightroom-style keymap (P/X/15/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 existing src/lib/api.js.
  • sse.js — the cull-progress EventSource stream, as a rune store.
  • gridLayout.js — the pure layout engine (§2.2); framework-agnostic, unit-tested in isolation at src/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 an objectRemoval.svelte.ts module 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), consuming gridLayout.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()); no export let / $:.
  • Keep <svelte:window onkeydown={…} /> for shortcuts, routed through shortcuts.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", "U undoes 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

  1. Dimensions flow end-to-end: per-frame width/height captured 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).
  2. State extracted: cullSession / selection / shortcuts / api / sse / gridLayout live in their own modules; object-removal and AI-review are their own components+modules. Review.svelte is a thin container (well under 400 lines) by virtue of the logic having moved, verified by the module tests — not by hiding markup.
  3. Custom multi-layout grid: Grid supports 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.
  4. Zero new runtime dependencies; supply-chain hardening (§2.5) is in CI (pinned + lockfile + npm ci --ignore-scripts + audit gate).
  5. 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-0019 review): dropped TanStack/svelte-virtual in 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.