Files
DarkRoom/docs/ui-navigation.md
T
dtourolle acd694c2cf No phone, so stop designing for one
Targets are a 12-inch tablet and a desktop (D15). That removes most of the
navigation question rather than answering it.

`EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024
across in portrait, so both orientations of both targets are the expanded
class. The compact class now fires only when a desktop window is dragged
narrow — graceful degradation, not a second interface. The bottom tool strip
and the one-tool-at-a-time sheet were solving a phone, and there is no phone.

What survives is input, not size, and the architecture had already decided
it: `WidgetDemand::precise_pointing` exists for a television remote, and its
own documentation says touch is fine because hit regions grow to the
modality. Touch changes hit regions, not layout. The rules that fall out are
worth stating because they are easy to violate by accident — no hover-only
affordance and no modifier key may be the sole route to anything, since a
tablet has neither. Local masking already lost its shift-click extend for
exactly this reason.

The guaranteed-wide viewport also pays for a better answer to the extent
problem than hiding things. The complaint was never that the column is long;
it is that the histogram scrolls away from the sliders it reports on.
Collapsing shortens the scroll, pinning removes the problem, and ~260px of
fixed height is affordable on a viewport that is never under 820 wide.
2026-08-22 09:55:09 +02:00

338 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# UI navigation: finding things once there are many
TRACES: FR-UI-1 | FR-UI-3 | FR-UI-5 | FR-DEV-3a | FR-DEV-3c
Successor to `ui-refinement.md`, which asked how the interface should *look*.
This asks how someone finds anything in it. The two are sequenced together at
the end.
## Why now
Local adjustments landed (D14, `segmentation.md` §14) and the develop column
went from four panels to six: image, histogram, geometry, settings, local,
adjust. Adjust alone is ten operations, and the colour mixer contributes
thirty-six parameters by itself. That is already past what one scrolling
column presents well, and the operation set is meant to keep growing —
FR-DEV-3 lists texture, clarity, sharpening and noise reduction as v1, none of
which exist yet.
But the count is the lesser problem. **Local adjustments introduced a mode
without introducing a way to see it**, and that is the part that can lose
someone's work rather than merely slow them down.
---
## 1. The three problems, which are not one problem
### 1.1 Scope — invisible state
Selecting a mask layer silently re-points the adjust panel at that layer's
chain. Same thirty sliders, different meaning, and the only indication is a
caption between the two panels.
Three ways that bites:
- An exposure change lands on the whole photograph when it was meant for a
face, or the reverse. Both are silent; both are discovered later.
- **The histogram does not follow scope.** The instrument the tonal controls
are judged against reports the whole frame while the slider edits a
subject's face. FR-DSP-7 asks the histogram to describe what the
photographer is looking at; under a mask it currently does not.
- Undo interleaves global and local edits with nothing distinguishing them.
This is the classic modal fault, and the classic remedy applies: make the mode
visible, or make it not a mode. See §3.
### 1.2 Extent — six panels in 280px
The column scrolls as one (`ui-refinement.md` Workstream C explains why: a
scroller inside a scroller gives every drag a third thing to be lost to). At
six panels that scroll is long enough that the histogram — the instrument
everything tonal is judged against — is frequently off screen while the
sliders it reports on are being dragged.
### 1.3 View — grid and develop are still separate screens
Diagnosed as `ui-refinement.md` Workstream F and not yet built. Unchanged by
this document, which assumes F lands.
---
## 2. What other programs do
Worth summarising honestly, because all three problems are solved elsewhere
and the solutions have known costs.
### On a desktop, two families
**A collapsible stack.** Lightroom Classic's right panel is a vertical column
of modules — Basic, Tone Curve, HSL, Detail, Lens, Effects — each collapsible,
each reporting whether anything inside it has been touched. Everything is in
one place and in a fixed order, so muscle memory works; the cost is a long
scroll and a lot of triangles.
**Tool tabs.** Capture One puts an icon strip at the top of the tool panel and
gives each tab a curated set of tools; RawTherapee tabs the right-hand panel
the same way. Scroll is bounded and there is a clear sense of place; the cost
is that a control you cannot name is in one of eight places, and switching tabs
loses the context you were comparing against.
**Groups over a stack.** darktable combines both — a row of group icons
filtering a long list of collapsible modules, plus a search box. It is the most
powerful and the most often described as overwhelming, which is worth reading
as a warning about combining mechanisms rather than about either one.
Across all of them, **masking is its own tool**, not a panel among peers.
Lightroom opens a masking tool with its own layer list and its own canvas
overlay; Capture One makes layers a persistent selector at the top of the
adjustments tab. Nobody makes a mask a panel that silently rewires a different
panel — which is what DarkRoom currently does.
### On a phone, one family — and why it does not apply here
Lightroom Mobile, Photomator and VSCO all converge on the same shape: **a
horizontal strip of tool icons along the bottom**, and tapping one replaces a
bottom sheet with that tool's controls. Snapseed goes further — one tool fills
the screen and a vertical swipe chooses the parameter while a horizontal one
sets it.
The convergence is not fashion. Three physical facts drive it:
- **Thumb reach.** A one-handed grip reaches the bottom third. A right-hand
column is a mouse idiom.
- **The image needs the screen.** A 280px column is a fifth of a desktop
window and most of a phone in portrait.
- **There is no hover.** Disclosure triangles and hover-revealed affordances
are worth less; a control is either visible or gone.
**None of the first two apply to DarkRoom's targets**, which are a 12-inch
tablet and a desktop (§3, D-N2). Nobody thumbs a 12-inch tablet one-handed,
and its narrow dimension is not narrow. The third does apply, and is handled
already — see D-N2.
---
## 3. The decisions
### D-N1 — Local adjustment becomes a mode · **DECIDED**
Not a panel that re-points another panel. A mode, in the sense `crop-mode`
already is: it changes what the canvas does, scopes what the column shows, and
is left explicitly.
**Why this shape rather than louder signalling.** The app already has this
pattern and the user already knows it. Crop mode arms a canvas interaction,
draws an overlay, gives the column one job, and exits by the same control that
entered it. Local masking is the same animal — a canvas interaction plus a
scoped panel — and building it as a peer panel is what created §1.1. Making it
a mode removes the ambiguity by construction instead of describing it in a
caption.
It also inherits machinery that exists. `lib.rs` already resolves Escape and
the Android back gesture to "leave the innermost state first" (FR-UI-5); local
mode joins that stack and needs no new exit concept.
In local mode:
- the canvas turns the overlay on and arms click-to-select;
- the column shows the mask stack and, beneath it, the adjustments **scoped to
the selected layer**;
- the header names the scope — the layer, not "adjust";
- leaving returns to the whole photograph, by Escape, by back, or by the mode
control.
**The histogram follows the scope.** Under a mask it reduces over the masked
pixels only. This is FR-DSP-7 read literally — it asks the histogram to
describe what is being looked at — and without it the instrument and the
controls disagree about what they are measuring. Costs a mask term in the
histogram reduction, which already runs per frame over the displayed frame.
### D-N2 — One layout, because both targets are wide · **DECIDED**
The question was whether desktop and Android should diverge. The answer turns
out to be that **neither the platform nor the width axis separates DarkRoom's
targets**, so there is no divergence to build.
**The targets are a 12-inch tablet and a desktop.** No phone, decided
2026-08-22. A 12-inch tablet is roughly 1024 logical pixels across in portrait
and 1400 in landscape; `EXPANDED_MIN_WIDTH` is 820. **Both orientations of both
targets are the expanded class.** The compact class now fires only when a
desktop window is dragged under 820px, which is a case to degrade gracefully
into, not a second interface to design.
**Platform would have been the wrong axis anyway**, and it is worth recording
why so it is not proposed again. A tablet in landscape wants what a desktop
wants; a desktop window dragged narrow wants what a small screen wants.
Splitting on `cfg(target_os)` gives one *physical* situation two answers
depending on which binary it happens to be. `apply_layout_class` already says
this in its own comment — "logical pixels, not a device check" — and it was
right.
**What actually differs between the two targets is input, not size**, and the
architecture has already decided that too. `WidgetDemand::precise_pointing`
exists for a frontend driving a television with a remote, and its own
documentation states the position: *touch is fine, since hit regions grow to
the modality* (FR-UI-7). Touch changes **hit regions, not layout**. A control
is drawn where it belongs and its target grows past its own bounds — which
`Check` and the mask rows already do.
So: **one develop layout, tuned for a wide viewport, with touch targets
throughout.** The consequences worth stating:
- **A guaranteed-wide viewport is an asset.** The extent problem (§1.2) can be
solved by pinning rather than by hiding — see N4.
- **No hover-only affordance may carry meaning.** Hover may *emphasise*; it may
never be the only way to discover a control. The mask rows already obey this
— the eye and the delete target are drawn, not revealed.
- **No modifier key may be required.** A tablet has no shift. Local masking
already lost its shift-click extend for this reason, and nothing should
reintroduce one as the only route to a feature.
- **Anything dragged needs a finger-sized target.** This is the live one:
gradient masks have no on-canvas handles yet, and when they arrive their
handles are the first control in the app designed to be dragged on a
photograph rather than in a panel.
### D-N3 — Collapsible panels, not tool tabs · **OPEN**
For the expanded layout, extend `ui-refinement.md` Workstream C from
sections-inside-adjust to the panels themselves: each collapses to a header
carrying a modified dot, and collapse state survives a drag elsewhere.
**Why this over tabs, on the evidence above.** Tabs need a taxonomy, and the
taxonomy is the problem. `ui-refinement.md` condemns the `starts-group` flag
for being the core telling the panel where sections go, and FR-DEV-3a requires
that adding an operation needs no UI edit. A tab strip built from a hardcoded
op-id → tab table in `dr-ui` breaks the second; one built from a `group:` field
in `ops/*.yaml` risks breaking the first.
There *is* a legitimate route to tabs, and it should be recorded rather than
discovered later: the descriptor could declare an operation's **nature** —
tone, colour, detail, optics — the same shape as `Affects` and `ParamKind`
already take. The core would be saying *what the operation is*, which is its
business, and the frontend would remain free to render that as a tab, a
section heading, or nothing at all. That stays on the right side of §4.3a.
**The recommendation is to defer it.** Ten operations do not need eight tabs,
collapse needs no taxonomy at all, and the nature field is easy to add later
and awkward to remove. Revisit when the operation count passes roughly fifteen
— which FR-DEV-3's outstanding list will reach.
**Open, because it is a taste call**: whether the expanded layout should also
gain tabs, or stay a single collapsible stack indefinitely.
---
## 4. Workstreams
Numbered N to avoid colliding with `ui-refinement.md`'s A–F.
### N1 — The mode strip
**Deliverable.** One control naming the current mode, replacing the implicit
`crop-mode` boolean: **Photo · Crop · Local**. Top of the canvas in expanded,
bottom in compact. The selected mode is the accent's job — it means *active*,
which is exactly this.
`crop-mode` becomes one value of a mode enum rather than its own flag, so the
two modes cannot both be on, which today they can.
**Done when.** Entering crop from the strip does what the crop button did;
Escape and back leave the innermost mode; no two modes are ever active
together.
### N2 — Local mode
**Depends on** N1.
**Deliverable.** Entering local mode turns the overlay on and arms picking
without either being a separate toggle — they are what the mode *is*. The
column shows the mask stack, then the scoped adjustments. The adjust header
names the layer.
Leaving local mode clears the selection so the adjustments are unambiguously
global again.
**Done when.** There is no way to have a mask selected without knowing it, and
the two toggles that currently arm the overlay and picking are gone.
### N3 — Scope-following histogram
**Depends on** N2.
**Deliverable.** The histogram reduction takes an optional mask; in local mode
it reduces over the selected layer's coverage. The panel says which it is
showing, because a histogram of a face is a strange shape and the user should
know why.
**Done when.** Selecting a layer visibly changes the histogram, and leaving
local mode restores the frame's.
### N4 — Collapsible panels
**Depends on** `ui-refinement.md` A. Extends C.
**Deliverable, two halves.**
*Collapse.* Each panel collapses to its header; headers carry the modified dot
C defines; state is keyed by panel identity and survives a slider drag.
Default: histogram open, the rest collapsed until touched.
*Pin.* The histogram and the scope header do **not** scroll. They sit above the
scrolling region, always visible.
Pinning is the half a guaranteed-wide viewport buys, and it addresses §1.2
directly rather than obliquely. The complaint is not that the column is long —
it is that the instrument every tonal control is judged against scrolls away
from the controls it reports on. Collapsing panels shortens the scroll;
pinning removes the problem. Together they cost about 260px of fixed height,
which a target that is never under 820px wide and rarely under 1000 tall can
afford.
**Done when.** The histogram is visible while any tonal slider is being
dragged, at every window size the targets produce, without the user having
scrolled to arrange it.
### N5 — Compact degrades, rather than diverges
**Depends on** N4.
Not a second interface. Below `EXPANDED_MIN_WIDTH` the develop column already
overlays rather than sits beside the canvas, and `apply_layout_class` already
remembers the user's override per class. With N4's collapse in place a narrow
window is a one-panel-at-a-time column by consequence rather than by design.
**Deliverable.** Confirm the narrow case is usable and fix what is not. No
bottom sheet, no second layout, no tool strip along the bottom — those solve a
phone, and there is no phone.
**Done when.** A desktop window dragged to 700px shows the photograph and a
usable column, and nothing is unreachable that was reachable at 1400px.
---
## 5. Sequencing
```
ui-refinement A ──┬── C ──── N4 ──┐
└── D ──── F ├── N5
N1 ── N2 ── N3 ───────────────────┘
```
N1–N3 are independent of `ui-refinement.md` and can start now; they touch the
canvas and the develop column's contents, not its layout. N4 needs C's
`Section`. N5 needs both and should land last, as F does — it is the one that
rearranges everything.
## 6. Invariants, for every workstream
- **FR-DEV-3a.** Adding a pipeline operation must still surface in both
layouts with no UI edit. No file under `ui/` may name an operation.
- **ARCH §4.3a.** Composition is the frontend's decision. The core may declare
what an operation *is*; it may not declare where the panel puts it.
- **FR-UI-3 / FR-UI-7.** Touch changes hit regions, not layout. Every control
keeps a finger-sized target wherever it is drawn, and no hover-only
affordance and no modifier key may be the sole route to anything — a 12-inch
tablet has neither.
- **FR-UI-5.** Escape and the Android back gesture leave the innermost state
first. Every mode added here joins that order.