Specs for the work that follows: soft delete via a MOVE that preserves the remote id, the collection tree and smart collections, the thumbnail store, and the derived-state folder. Adds two design documents. ui-refinement.md names the structural gaps between the v0.1 UI and something that feels like a photo editor. view-composition.md proposes a view controller for the display layer, against the 500-line run() that has become one by accretion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23 KiB
UI refinement: toward a Lightroom-shaped darkroom
TRACES: FR-UI-1 | FR-UI-3 | FR-DEV-3a | FR-CAT-4
Why
The v0.1 UI proved the architecture: capability-driven controls, a windowed grid, a GPU canvas with no CPU round trip. What it has not yet done is feel like a photo editor. The gaps are structural rather than cosmetic, and this document names them so they can be closed independently.
Four principles guide every change below.
- The image is the subject. Chrome recedes; nothing competes with the photograph for attention or for colour.
- Colour is a signal, not a decoration. The accent means modified or active. Everywhere it currently means "heading" or "chrome", it is spending a signal on noise.
- Navigation is continuous. Moving between images should not be a change of screen. Lightroom's filmstrip is the mechanism; modal view switching is what it replaces.
- Density is earned. A panel shows what has been touched; everything else collapses out of the way.
Non-goals
- No new pipeline operations, and no change to how operations reach the panel.
AdjustPanelmust still learn its contents from the capability model and must still name no operation (FR-DEV-3a). - No change to the catalog schema, the sync layer, or the render path.
- No light theme. The ground stays dark (see
theme.slintpreamble).
Workstream S — The style layer
Prerequisite for everything else that touches colour. Lands before B–F.
S1 — Near-neutral palette
Problem. The original palette was a warm "darkroom safelight" brown —
ground #14120F, R twelve points above B, and the same cast through every
surface and ink. That biases the work. Simultaneous contrast pushes perception
of the image away from its surround, so warm chrome makes a neutral
photograph read cool; the photographer corrects toward warm to compensate and
every export drifts yellow. The file's own preamble had the right instinct —
a light UI biases judgement — and stopped one step short. Warmth biases it
too, and more quietly, because a warm cast reads as cosy rather than
wrong.
Done. theme.slint now carries near-neutral greys with a 2–3 point cool
lift (pure R=G=B reads as dead; a trace of cool reads as instrument), and the
red accent is replaced by an achromatic active family. warn-ink is the
only hue left — a caution is genuinely a different kind of thing from an
active state.
Migration shims alias accent/accent-dim/accent-hover onto the new
tokens, so the tree compiles while call sites migrate. They are temporary.
Delete them once grep -rn 'Theme.accent' ui/ is empty.
S2 — style.yaml as the token source
Problem. Tokens live in Slint, so tuning a palette means editing a language file, and nothing else — docs, tooling, a future export theme — can read them.
Deliverable. ui/dr-ui/style.yaml becomes the source of truth for
colours and lengths: the whole current token set, nothing more.
ui/dr-ui/build.rsreads it and generatestheme.slintat compile time. Zero runtime cost, and a malformed file is a build error rather than a failure in front of a photographer mid-edit.- The generated file carries a "do not edit" banner naming its source, and
must land somewhere
.gitignored or clearly marked generated — a hand-edited generated file is a bug that hides for weeks. - The prose survives. The reasoning in today's
theme.slintpreamble and its per-token comments is the most valuable thing in the file. YAML comments carry across into the generated Slint, or the generator emits them from structured fields. A token set with the why stripped out is a downgrade, however tidy the pipeline. - Debug-only live reload behind a feature flag: re-read the YAML at startup so a palette can be tuned without a full rebuild. Off in release, where the generated constants are what ship.
Not in the YAML. Semantic components (S3). PanelHeading binds colour,
size, weight and letter-spacing into one concept — that is Slint, not data.
YAML holds leaf values; components compose them.
Dependency. Adds a YAML parser to the UI's build-dependencies. Note that
serde_yaml was deprecated in 2024; prefer a maintained alternative.
S3 — Semantic components
Problem. theme.slint says what surface is. It says nothing about what
a panel heading is — so every file re-derives one. IMAGE, ADJUST, the
six launch-screen headings, library.slint:267 each independently spell out
colour, size, weight and letter-spacing for a single concept. That is why one
accent reached forty call sites: there was no single place to change it.
Deliverable. widgets.slint grows from three primitives into the style
layer:
PanelHeading— theIMAGE/ADJUST/launch headings. One definition, one place to decide headings are ink rather than accent.Label,Value,Caption— text roles, sotext-sm+ink-dimstops being copy-pasted.Valuecarries the modified state, since a value that differs from its default is the one thing worth spotting at a glance.Panel— surface + rule + padding, currently rebuilt in four places.Field— the launch screen's text input with its focus border.
The rule this establishes. Files consume components. Raw Theme.* is for
composing a component, not for styling a call site. A new colour literal or
a bare Theme.ink-faint in a screen file is a signal that a component is
missing.
Sweep. Migrating call sites onto these components removes the accent
references as a side effect — the forty sites collapse into a handful of
component definitions. That is the point: fix the cause, not the symptom.
Delete the S1 shims when the grep comes back empty.
Done when. grep -rn 'Theme.accent' ui/ is empty, the shims are gone, no
screen file styles a heading inline, and the UI carries no hue outside
warn-ink.
Workstream P — Plural presentation hints
Implements ARCH §4.3a. Touches core/dr-pipeline and ui/dr-ui/src/develop.rs;
no Slint change beyond what falls out of it.
Problem. Presentation.widget is a single WidgetKind. An operation can
name one preferred control and nothing else, so a curve cannot say "a curve
editor is best, a parametric band control would do, and sliders are fine" and
let the frontend choose. Since layout class already drives compact-vs-expanded,
this is not hypothetical: a curve editor is comfortable in a 280px panel and
unusable in a 120px one, and today the frontend has no sanctioned way to
decide that — its only options are the named widget or nothing.
Deliverable.
Presentation.widgetbecomeswidgets: &'static [WidgetKind], in descending preference. The frontend takes the first it implements and can afford.- A
WidgetDemanddescribing what a widget inherently requires — candidates: two-dimensional direct manipulation, precision pointing, a minimum count of simultaneous values. No pixels, no breakpoints, no DPI, no platform names. Those are frontend thresholds and live indr-ui. develop.rschooses per operation: walk the hint list, take the first whose demands the current layout satisfies, else fall through to plain scalars. The existingParamRow.kindstring is where exhaustive matching is currently lost — the choice must happen in Rust against the enum, before flattening.- The tone curve declares
[Curve]with its real demands. Nothing else needs to change; operations wanting sliders still say nothing at all.
Verification. The existing the_curve_collapses_to_a_single_row test in
develop.rs covers the happy path. Add its complement: with a layout that
cannot satisfy the curve's demands, the same capability must produce ten
addressable scalar rows and remain fully editable. That test is the contract —
it is what makes the fallback real rather than aspirational.
Do not let a demand grow a min_width. If one seems necessary, the
demand vocabulary is wrong; widen the vocabulary, not the abstraction.
Workstream M — The colour mixer is unreadable
Found in use, not in review. The panel currently renders the mixer as
thirty-six anonymous sliders reading Hue 0 / Sat 16 / Lum 0 twelve times
over, with nothing saying which band any row belongs to. Three separate
defects meet here.
M1 — Band identity is lost (a bug, not a style issue)
labels.rs has no param.mixer.* entries, so all thirty-six keys fall
through to a default that yields the bare channel name. The core is not at
fault: it declares param.mixer.orange.sat, and BANDS carries key: "orange" with hue: 30.0. The identity is present in the capability and
discarded at resolution.
Deliverable. Resolve mixer keys to their band. A row reads Orange · Sat,
or Sat under a band heading — M3 decides which.
M2 — Bands carry their centre hue as capability data
Deliverable. ParamDescriptor gains an optional band hue in degrees, set
by band_params! from BANDS. The UI converts degrees to a swatch.
Why this is not a §4.3a violation. A band's centre hue is a fact about
the operation — the mixer genuinely acts on the 30° band, and that number is
what it acts on. The core says "this parameter belongs to the band centred at
30°". It does not say what colour to draw, at what saturation or lightness, or
whether to draw a swatch at all. Those conversions are presentation and live
in dr-ui; the swatch's saturation and lightness belong in style.yaml.
The line to hold: a hue in degrees is data. A hex colour in a descriptor would be the core deciding appearance, and is forbidden.
Swatches are the one sanctioned exception to the achromatic palette. A swatch is not chrome — it is data identifying which hue band a row edits, exactly as an image is data. That is categorically different from an accent decorating a heading, which is what the palette rule forbids. Keep them small and let them identify, never dominate.
M3 — Structure: twelve collapsible bands
Deliverable. The mixer renders as twelve Sections — one per band, titled
by band name, carrying its swatch — each holding Hue, Sat and Lum. Collapsed
by default; the modified dot (Workstream C) shows which bands hold an edit
without expanding them.
This needs no new WidgetKind: it is Workstream C's sections applied to a
grouping the frontend derives from parameter ids. Depends on C.
M4 — A single-parameter operation should not cost a heading
Vibrance and Saturation each render a section heading above one slider, spending two lines and a visual break on one control. An operation whose parameters number one wants to be a named row, not a group containing one.
Deliverable. The panel collapses a single-parameter operation into one row labelled by the operation. Derived frontend-side from the parameter count — the core says nothing about it, per §4.3a.
Done when. Every mixer row says which band it edits; bands collapse with modified state visible while collapsed; Vibrance and Saturation are one row each; and no hex colour appears in any descriptor.
Workstream V — Vertical density
The panel spends too much height on too little information. Reported from use, and it compounds Workstream M: at thirty-six mixer rows the waste is measured in whole screens.
The arithmetic. ParamSlider is a fixed 46px carrying an 11px label and a
3px track. The track region is Theme.touch-target / 2 — 22px — which is a
finger-sized allowance drawn for a pointer, and the label occupies a line of
its own above it. Every section heading adds a further Theme.gap (12px)
spacer plus a 2px rule. Twelve mixer bands at three rows each is roughly
1650px of panel, most of it air.
The cause is layout, not spacing tokens. Shaving pixels uniformly would compress the readable parts along with the waste. The row is stacked when it could be inline: name left, value right, track beneath — which is what Lightroom does, in about 32px.
Deliverable.
- Rework
ParamSliderso label and value share one line and the track sits under them. Target ~32px per row, down from 46px. - The drawn track shrinks; the TouchArea does not. FR-UI-3 is about the
finger, and
Buttonalready establishes the pattern — draw at control height, grow the hit target past the ink and centre it. A denser panel must not become a less touchable one. - Section heading spacing comes from one token, not an inline
Rectangle { height: Theme.gap }. A spacer rectangle written inline is how the panel ended up with spacing nobody can adjust centrally. - Re-check the compact layout class after the change: rows that work at 280px may crowd at narrower widths, where the touch overhang also matters most.
Constraint. Density is not the goal; legibility per pixel is. If a row gets shorter and harder to read, it has failed. The value readout in particular carries the modified signal and must stay scannable.
Done when. A parameter row is ~32px, hit targets still meet FR-UI-3 under touch, heading spacing is tokenised, and the panel reads as easily at the new density as the old.
Workstream A — Shared chrome primitives
Problem. Buttons are hand-rolled Rectangle + TouchArea pairs in
app.slint, library.slint, and launch.slint, at three different sizes
(64×20, 110×28, 88×28) with three near-identical hover/press treatments. Any
consistency in the chrome is currently coincidental.
Deliverable. A new ui/dr-ui/ui/widgets.slint exporting:
Button—text,enabled,primary(bool),clicked(). Height meetsTheme.touch-targetunder compact layout and may be denser when expanded. Press and hover states derive from theme tokens, not literals.IconButton— square, for toolbar affordances that carry a glyph.Section— a collapsible container:title,modified(bool),expanded(in-out bool), a default child slot. Draws the disclosure triangle and the modified dot. Workstream C consumes this.
Every existing hand-rolled button is replaced by Button. The visual result
should be a narrower range of sizes than today, not a wider one.
Theme additions. theme.slint gains what the widgets need and no more:
radius-sm/radius, a hover and pressed surface token, and a
modified token aliased to accent. Adding tokens is preferred over
literals appearing in widget bodies.
Done when. No TouchArea inside a Rectangle styled as a button remains
in app.slint, library.slint, or library.slint's header. cargo build
clean, app launches, every button still fires its callback.
Workstream B — Canvas presentation
Problem. The canvas fills its container edge to edge. The image reads as a texture rather than a print, and there is no visual separation between the photograph and the panel beside it.
Deliverable. In app.slint's canvas-area:
- Inset the image by a margin that scales with the layout class — generous
when
expanded, tighter when compact, never zero. The surrounding field isTheme.ground. - A subtle 1px
Theme.ruleborder on the image bounds, so a dark photograph does not bleed into the dark ground. This requires knowing the fitted rectangle, not the container — if that proves awkward in Slint, a shadow or a very slightly lighter mat behind the image is an acceptable substitute. Pick one and say which in the summary. - Empty and error states keep their current copy and centring.
Constraint. canvas-resized must continue to report the drawable pixel
size — the render target follows the image area, not the container. Getting
this wrong shows up as a soft or stretched image, so verify the reported size
changes when the margin does.
Done when. The image sits in a visible field with margin, the border or mat is present, and resizing the window still produces a crisp canvas.
Workstream C — Collapsible adjust sections
Problem. AdjustPanel renders every parameter of every operation, always
expanded. This is tolerable at today's operation count and unusable at
fifteen. Lightroom's right panel is a stack of collapsible modules whose
headers report whether anything inside has been touched.
Deliverable. Rework the for row[i] in root.rows body in adjust.slint
to group by operation and wrap each group in Section (Workstream A).
The hard part is that rows is a flat model with a starts-group flag —
Slint cannot easily nest a for inside a group boundary derived at runtime.
Two viable approaches; pick one and justify it briefly:
- Flatten the collapse. Keep the flat
for, add anexpandedbool per op-index held in the panel, and make each non-heading rowvisible: falseand zero-height when its group is collapsed. Simple, no Rust change. - Nest the model. Have Rust supply
[[ParamRow]]— one inner model per operation. Cleaner Slint, but changes theParamRowcontract and thedevelop.rscode that builds it.
Approach 1 is likely correct for this pass; prefer it unless it proves unworkable.
Modified indicator. A group is modified when any row in it has
value != default-value.
This must be derived in dr-ui, not supplied by the core (ARCH §4.3a).
A group_modified flag on a descriptor would be the core deciding the panel
has groups at all, which is a composition decision. develop.rs already holds
both the capabilities and the live values, so it can aggregate per operation
while flattening — that is frontend-side derivation and stays on the right
side of the line. What it must not do is ask the core for the answer.
The same reasoning condemns the existing starts-group flag, which is the
core telling the panel where to draw section breaks. It predates this contract;
fold it into the same pass and derive grouping from op-index changes instead.
Also. The per-group reset should live on the section header, alongside
the existing global reset.
Invariant. This file must still name no operation. Collapse state is keyed
by op-index, never by label.
Done when. Sections collapse and expand, collapsed state survives a slider
drag elsewhere in the panel, headers show a modified dot that appears and
disappears as values move off and back to default, and adding an operation to
the pipeline still requires no edit to adjust.slint.
Workstream D — Grid refinement
Problem. Cells are boxes first and images second: image-fit: contain on
a square cell leaves landscape shots floating in dead space, the cell surface
contrasts with the ground so the grid reads as a rhythm of rectangles, and
there is hover state but no selection state.
Deliverable. In library.slint:
- Cells crop to fill (
image-fit: cover) withclip: true, so the grid is a rhythm of images. The filename caption stays. - Cell background moves to
Theme.groundor very near it; the frame recedes. - A selected cell gets a persistent accent ring. Add
in property <int> selected-indextoLibraryGrid, defaulting to -1, and havelibrary_ui.rsset it when a cell is clicked. Hover stays distinct from selection — a dimmer treatment. - Keyboard navigation: arrow keys move the selection, Enter opens it. This
needs a
FocusScopeover the grid and aselection-moved(int)callback.
Done when. The grid reads as images rather than boxes, the current image is unambiguous, and arrows plus Enter navigate it without the mouse.
Workstream E — Chrome hierarchy
Problem. StatusBar mixes three unrelated things: navigation (‹ Library), identity (filename, position), and spike telemetry (fps, adapter,
backend, layout-class). The telemetry earned its place while assumption A1 was
open; it is now permanent furniture competing with the photograph.
Deliverable.
- The top strip carries identity and navigation only: filename, position, and the library affordance.
- Telemetry moves behind a toggle — a keyboard shortcut (suggest
`) that reveals a small diagnostics overlay in a corner of the canvas, carrying backend, adapter, fps, and layout class. Default off. - The accent stops being used for chrome.
backendin the status bar, theIMAGEandADJUSTpanel headings, and the section headings inadjust.slintall move toTheme.ink-faintorink-dim. After this pass, accent should appear only on: modified values, the modified dot, the curve line, slider fill, selection, and progress.
Done when. A fresh launch shows no fps counter and no accent-coloured
chrome; ` toggles the diagnostics overlay; every previously-visible
diagnostic is still reachable.
Workstream F — Filmstrip and unified view
The big one. Depends on A (for Button) and D (for cell treatment and
selection). Should land last.
Problem. Library and Develop are mutually exclusive screens
(show-library in app.slint). Every move between images is a change of
screen. Lightroom's continuity comes from the grid never fully leaving: it
collapses to a filmstrip along the bottom of the develop view, and clicking a
neighbour is navigation, not a mode change.
Deliverable.
- A
Filmstripcomponent in a newui/dr-ui/ui/filmstrip.slint, consuming the same[LibraryCell]model and the sameselected-indexasLibraryGrid. Horizontal, ~90px tall, scrolls to keep the selection visible. - Develop gains the filmstrip along its bottom edge, visible when a library is open (i.e. when the current model is non-empty). Command-line file sets get it too — they are also a list of images.
- Clicking a filmstrip cell loads that image. Arrow keys drive both the filmstrip and the existing next/prev, which become the same action.
show-librarybecomes a mode rather than a screen swap: Grid mode and Develop mode over one shared library state, toggled by aG/Dshortcut and by the existing buttons. Thecan-return-to-libraryspecial case and the‹ Librarybutton both disappear.
Windowing constraint. The filmstrip and the grid must share one windowed
model, not hold two. LibraryController currently keys its WINDOW on grid
scroll position; the filmstrip's window follows the selection instead. This
is the genuinely hard part of the workstream — the window must move as
selection walks past its edge, and thumbnail requests must not thrash when it
does. Resolve this explicitly rather than by widening WINDOW.
Done when. Selecting an image in the grid enters develop with the
filmstrip showing neighbours; arrows walk the filmstrip and load images;
G/D toggles modes with selection preserved in both directions; a
17k-image library still holds a bounded number of live cells and does not
re-fetch thumbnails on every keystroke.
Sequencing
A (primitives) ──┬── C (sections)
├── B (canvas) ── E (chrome)
└── D (grid) ──┐
├── F (filmstrip)
┘
A, B, D, and E are independent of each other once A lands; C depends on A;
F depends on A and D. B and E both touch app.slint, so they should not run
concurrently.
Verification, all workstreams
cargo buildclean, no new Slint warnings — in particular no binding-loop warnings, whichapp.slintalready comments on at length and which can panic at runtime.- The app launches and reaches the grid.
- No workstream may break FR-DEV-3a: adding a pipeline operation must still surface in the panel with no UI edit.