Document trash, collections, thumbnails, and the UI direction

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>
This commit is contained in:
2026-08-09 20:36:23 +02:00
co-authored by Claude Opus 5
parent 5365123d92
commit 02b66ddfcf
6 changed files with 1335 additions and 61 deletions
+496
View File
@@ -0,0 +1,496 @@
# 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.
1. **The image is the subject.** Chrome recedes; nothing competes with the
photograph for attention or for colour.
2. **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.
3. **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.
4. **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.
`AdjustPanel` must 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.slint` preamble).
---
## 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.rs` reads it and generates `theme.slint` at 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 `.gitignore`d 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.slint` preamble 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` — the `IMAGE`/`ADJUST`/launch headings. One definition, one
place to decide headings are ink rather than accent.
- `Label`, `Value`, `Caption` — text roles, so `text-sm` + `ink-dim` stops
being copy-pasted. `Value` carries 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.widget` becomes `widgets: &'static [WidgetKind]`, in descending
preference. The frontend takes the first it implements and can afford.
- A `WidgetDemand` describing 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 in `dr-ui`.
- `develop.rs` chooses per operation: walk the hint list, take the first whose
demands the current layout satisfies, else fall through to plain scalars.
The existing `ParamRow.kind` string 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 `Section`s — 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 `ParamSlider` so 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 `Button` already 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 meets
`Theme.touch-target` under 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 is
`Theme.ground`.
- A subtle 1px `Theme.rule` border 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:
1. **Flatten the collapse.** Keep the flat `for`, add an `expanded` bool per
op-index held in the panel, and make each non-heading row `visible: false`
and zero-height when its group is collapsed. Simple, no Rust change.
2. **Nest the model.** Have Rust supply `[[ParamRow]]` — one inner model per
operation. Cleaner Slint, but changes the `ParamRow` contract and the
`develop.rs` code 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`) with `clip: true`, so the grid is a
rhythm of images. The filename caption stays.
- Cell background moves to `Theme.ground` or very near it; the frame recedes.
- A **selected** cell gets a persistent accent ring. Add
`in property <int> selected-index` to `LibraryGrid`, defaulting to -1, and
have `library_ui.rs` set 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 `FocusScope` over the grid and a `selection-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. `backend` in the status bar, the
`IMAGE` and `ADJUST` panel headings, and the section headings in
`adjust.slint` all move to `Theme.ink-faint` or `ink-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 `Filmstrip` component in a new `ui/dr-ui/ui/filmstrip.slint`, consuming
the **same** `[LibraryCell]` model and the same `selected-index` as
`LibraryGrid`. 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-library` becomes a *mode* rather than a screen swap: Grid mode and
Develop mode over one shared library state, toggled by a `G`/`D` shortcut
and by the existing buttons. The `can-return-to-library` special case and
the `‹ Library` button 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 build` clean, no new Slint warnings — in particular no binding-loop
warnings, which `app.slint` already 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.