Skip to content

0024 — Settings, re-segmented into vertical tabs

  • Status: IMPLEMENTED (2026-07-16) — open questions RESOLVED: Q1 → go granular (per-concern leaves grouped under rail headers, not three mega-tabs — "we'll add settings, not remove them"), Q2 → yes, extend Icon.svelte, Q3 → default to Image. See §7 for the shipped shape. First of the post-0022 chrome trilogy (0024 settings tabs · 0025 notifications bell · 0026 help panel). Build order 0024 → 0025 → 0026; each is independently shippable.
  • Why now: the "Pro Dark" studio (0022) stabilised the chrome, and the global Settings modal (GlobalSettings.svelte, 518 lines) is now the most cluttered surface in it — one long scroll stacking six unrelated sections (default cull profile, object removal, eye detection, AI review, updates, shut down). A photographer looking for "where do I paste my API key" scrolls past thresholds and eraser backends to find it. Segmenting it makes each concern findable and shrinks the reading surface.
  • Depends on: 0022 (the token system + modal styling this reuses), the existing settings API (/api/v1/settings/{profile,face,remove,review}, /api/v1/version, /api/v1/update, /api/v1/shutdown — all unchanged).
  • Non-goals: no new settings, no API change, no behaviour change. This is a layout re-segmentation of exactly the controls that exist today. No new cull judgement (0006), no provider change (0005). Per-shoot cull overrides (Settings.svelte) are out of scope — this is the global modal only.

1. What we have

GlobalSettings.svelte is a single scrolling .modal with six <section>s in DOM order: Default cull profile (8 threshold sliders), Object removal (ONNX inpaint enable + backend + runtime path), Eye detection (ONNX face enable + runtime path), AI review (cloud provider + keychain key + model), Updates (version + check), Shut down (danger confirm). Each section owns its own load/save against its own endpoint; there is no shared "save all" — saves are already per-section. That independence is what makes tabbing them safe: a tab is just a section (or group) shown one at a time.

2. The tab rail — granular leaves under group headers (Q1 RESOLVED)

Review decision: the more granular the better — settings will grow, not shrink, so each concern gets its own selectable leaf rather than being lumped into a "Models & downloads" mega-tab. The rail stays legible by grouping leaves under quiet section headers; a new setting later is just a new leaf under the right group (that's the scalability the granularity buys).

A vertical rail on the left (grouped), a scrolling panel on the right — one leaf shown at a time:

Group Leaf Icon Contains (existing section, verbatim)
Judgement Image aperture Default cull profile (the 8 threshold sliders)
Models & providers Object removal eraser Inpaint eraser enable · backend · runtime path
Eye detection focus Face-model enable · runtime path
AI review ai Cloud provider · keychain key · model
System Updates download Version + check for updates
Shut down power Stop-the-server danger confirm

Six leaves, three groups; every control from today's six sections lands in exactly one leaf, none moved out of the modal. Rationale: Judgement acts on pixels (thresholds); Models & providers is one leaf per heavy backend — each local ONNX model with its own runtime-path/auto-download notes, and the cloud critic with its key — so "where does this model live / where's my key" is a direct click, and adding a future provider is a new leaf here; System is app lifecycle, updates and shutdown kept distinct (different intents).

3. Shape & structure

  • Two-pane modal. Left: a vertical list of tab buttons (icon + label, active state, keyboard/arrow navigable, role="tab"); right: the active tab's panel. Reuse the 0022 .modal/.scrim shell, --surface/--line tokens, and the existing field styles — this should read as the same modal, re-organised.
  • Split the monolith, one component per leaf. Extract the shell into GlobalSettings.svelte (owns the rail, the active-leaf $state, the group→leaf structure, the scrim/header/Esc, and nothing else); each leaf becomes its own small component — SettingsImage, SettingsObjectRemoval, SettingsEyeDetection, SettingsAIReview, SettingsUpdates, SettingsShutdown — owning only its own load/save (moving the existing code, not rewriting it). One-component-per-leaf is deliberate: it's what makes "add a setting" a purely additive change (new leaf component + one rail entry), matching the granular intent. Drops the 518-line file to a thin shell + focused leaves.
  • The rail structure is data. Define the groups/leaves as a small array ({group, leaf, id, icon, component}) the shell maps over, so adding a leaf is one array entry, not markup surgery.
  • Deep-link (optional, cheap). Let the parent open the modal on a chosen tab (a prop / openSettings(tab)), so 0025's "update available" notification and 0026's help links can jump straight to Update & shutdown or Models.
  • Responsive. On a narrow viewport the rail collapses to a horizontal strip above the panel (the modal already caps at 540px; the rail must not force horizontal scroll — 0022 responsive rule).

4. Requirements

  • R-SET-1 Every control present before the change is present after it, in one of the three tabs, wired to the same endpoint with the same payload. No setting added or removed.
  • R-SET-2 Per-section save semantics are preserved — each save button saves only its section and shows its own "Saved ✓"; no cross-tab "save all".
  • R-SET-3 Tabs are keyboard-operable (tab into the rail, arrow between tabs, Esc closes the modal) and carry role="tab"/aria-selected for a11y; both themes meet WCAG AA (0022).
  • R-SET-4 The parent can open the modal focused on a named tab (for 0025/ 0026 deep-links); default is Image.
  • R-SET-5 No API/behaviour change — a request-diff of every save before/after is byte-identical; existing settings e2e/unit tests pass unchanged (updated only for the new selectors).

5. Testing

  • Playwright: open Settings → assert the six grouped leaves in the rail; switch leaves → assert the right pane renders and the others don't; save on each leaf → assert the same request fires as today; Esc/arrow-key navigation.
  • Unit: the shell renders the active leaf's component; deep-link prop selects the leaf; the rail is built from the leaf array.
  • Existing GlobalSettings selectors in current specs updated to the tabbed DOM.

6. Open questions — RESOLVED

  • Q1 — granularity → RESOLVED: go granular. Per-concern leaves grouped under rail headers (§2), not three mega-tabs: settings will grow, and a granular rail scales additively. (Rejected the "Models & downloads" lump in favour of one leaf per backend.)
  • Q2 — icons → RESOLVED: yes. Extend Icon.svelte (0023) with the rail glyphs (aperture, eraser, download, powerfocus/ai already exist), one icon vocabulary across the studio.
  • Q3 — tab persistence → RESOLVED: default to Image. Deep-links (R-SET-4) cover jumping elsewhere; no last-used-tab persistence for now.

7. As built (IMPLEMENTED 2026-07-16)

The 518-line GlobalSettings.svelte is now a ~135-line shell: a data-driven rail (groups → leaves, {group,leaf,id,icon,comp}) + an active-leaf $derived (a local override wins over the openTab deep-link) rendering the current leaf's component. Each concern is its own component under pkg/studio/web/src/settings/SettingsImage, SettingsObjectRemoval, SettingsEyeDetection, SettingsAIReview, SettingsUpdates, SettingsShutdown — owning only its own load/save against the unchanged endpoint. Six leaves under Judgement / Models & providers / System; opens on Image; role="tablist"/tab/tabpanel, roving tabindex, arrow-key nav, Esc to close. Icon.svelte gained aperture, eye, download, power (reusing remove/ai); shared form styles live under .setleaf in app.css. App.svelte gained openSettings(tab) + openTab/settingsTab for 0025/0026 deep-links.

Verified: 24 Playwright e2e (4 new: rail leaves, one-pane-at-a-time, per-leaf save fires the same PUT, Esc/arrow nav), 71 vitest unit, svelte-check 0 errors 0 warnings (also cleared 3 pre-existing label-a11y warnings via for/id), eslint + prettier clean. Screenshotted live (headless) in both themes — grouped rail, gold-accented active leaf + icons, gold checkbox. No Go change beyond re-embedding the built bundle; no API/behaviour change (R-SET-5).