Selecting a mask layer silently re-points about thirty controls at that layer's chain. Same panel, same order, same sliders, different meaning — and the only thing that said so was a sentence in the panel above, which a photographer reaching for the exposure slider has no reason to read. An exposure change lands on the whole frame when it was meant for a face, or the reverse; both are silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the dangerous one and it is: the others in that document cost time, this one costs work. The remedy is the classic one for a modal fault — make the mode visible — and the application already had the pattern. Crop arms a canvas interaction, draws an overlay, gives the column one job and is left by the control that entered it. Local masking is the same animal built as a peer panel, and that is what created the ambiguity. So `crop-mode` stops being a bare boolean and becomes one value of a three-state mode, which is the point: two modes could both be on before, and now that is not a state the interface can be in rather than one it is tested against. **One strip, not two.** The mode control was going to sit beside the group strip that filters the adjustments, which is two controls above one column answering the same question — what am I working on. They are one control now, `Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's bottom strip has for the same reason. The two halves are different kinds of state and are drawn differently: a mode is a chip that fills with the accent when it is on, a group is a word with a rule under it. That difference is what lets both be read at once, which they routinely are — picking Light while a mask is selected filters *that layer's* chain and does not leave the mode. Dropping the scope on a group press would be the same fault coming back from the other end, and would make Light mean two things depending on where it was pressed. The strip stays pinned above the develop column rather than moving to the top of the canvas as the document proposed. The half that filters the column belongs to the column, and the photograph is the subject. The canvas keeps one button, which now names the mode it leaves rather than saying "Done" — that was unambiguous with one mode and would not be with two — because the column can be closed on a narrow window and no mode may be inescapable. Entering a mode is a side effect, so Rust owns it rather than the strip writing the property: crop drops the zoom, local turns the overlay on, and leaving clears the selection. That last one is the fix. The "Overlay" and "Select" toggles are gone because they armed things that are simply what the mode *is* — a mode that has to be switched on separately is one you can enter and have do nothing. Escape and the Android back gesture join `back_step` as one `LeaveMode` rather than a second exit concept, and the mode is left before the zoom is: it was entered later, and it is the bigger step back. The heading is where the scope goes. Not a caption beside the panel, the heading *of* the panel that changed — `ADJUST` becomes the layer's name, the same string the selected row in the stack shows. That is the difference between describing a hazard and removing it. **Handles on the photograph.** A linear or radial mask could be created and then not moved, so a radial sat at the centre of the frame at its default size for ever. Three faults stood in the way of drawing one. The first is that a gradient did not render at all until the model had run. The rasteriser was built on the way out of `segment` and the array's size was read *off* the segmentation, so a gradient added to an unsegmented photograph produced nothing — silently, in the same way exports and thumbnails once did: the shader still emits the layer's block and the empty placeholder multiplies it by zero. The proxy size is a property of the photograph. Both are derived from it now, and deliberately at the same size rather than by coincidence, because a subject's distance field is sampled against that array. The second is hit-testing. A handle is drawn in output coordinates and stored in source ones, and between them lie the crop, the zoom, the pan, the straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated on the CPU, kept in that file beside it so that keeping the two in step is one file's problem — a handle mapped through anything less drifts off the mask the moment the view moves, which is exactly what masks are rasterised in source space to avoid. The third is that a drag is a displacement, not a destination. Each handle answers to the movement of the pointer since the press, applied to where the mask was when the press landed. Snapping the handle to the pointer instead jerks it by up to half a touch target on the first press, and the target is finger-sized because a tablet has no hover to reveal a control and no modifier to qualify it. A ramp gets three handles — centre, width, angle. An ellipse gets three too: centre and one per semi-axis, the major one carrying the direction as well as the length, because where an axis is put says both. It had a fourth, and it is gone: standing off the shape by a fixed distance, the rotation arm began outside the photograph at the size a new radial is created at, so the first thing anyone saw was a control they could not reach without first shrinking the mask. Two faults here were found by looking at the screen rather than at the source, both of the kind that cannot be found any other way. A `1px` rule with a size and no position is *centred* by Slint, so the seam between the photograph and the column was a hairline down the middle of the panel, through the histogram and every slider under it — twice, once in `app.slint` and once in `AdjustPanel`. And handing Slint a fresh model for the handles on every pointer event made the repeater rebuild its items, taking the `TouchArea` holding the gesture with them: the handle jumped once and then went dead under a finger that was still down. `develop.rs` carries the same warning about the parameter rows, where it broke slider drags; the model is rewritten in place now. The tests worth having are the ones about ambiguity and about the map. That the same row reads the frame's value, then the layer's, then the frame's again is §1.1 in one assertion. That dragging a handle onto another gradient's matching handle *produces* that gradient closes the loop between the two directions of the framing map, through a view that is cropped, zoomed, panned, straightened and quarter-turned at once — a one-legged map is invisible when the framing is neutral, because then both legs are the identity. Not done here: the histogram still reports the whole frame while the sliders edit a layer. That disagreement is real and is N3's, which this unblocks. The strip has room for a Brush entry beside Crop and Local when the painted masks land in the core, and it needs nothing here but the canvas interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
454 lines
22 KiB
Markdown
454 lines
22 KiB
Markdown
# 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~~ — they have them now (N2a), and
|
||
their handles are the first control in the app designed to be dragged on a
|
||
photograph rather than in a panel. Drawn at 14px so they do not hide the edge
|
||
they sit on, with a full touch target centred on the drawing, which is the
|
||
split `Button` already establishes. Nothing about them is revealed by hover
|
||
and nothing about them is qualified by a modifier: what is drawn is all there
|
||
is.
|
||
|
||
### 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 — **done**
|
||
|
||
**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.
|
||
|
||
**Landed as one strip, not two.** The mode control and the existing group strip
|
||
(`All · Light · Colour`, derived from operation attributes) were going to sit
|
||
beside each other above the same column, which is two controls answering one
|
||
question — *what am I working on*. They are now one:
|
||
|
||
```
|
||
Crop · Local │ All Light Colour
|
||
```
|
||
|
||
Lightroom Mobile's bottom strip mixes Crop and Masking with Light and Colour
|
||
for the same reason, and it reads naturally because from the photographer's
|
||
side they are the same kind of choice.
|
||
|
||
The two halves are **different kinds of state and are drawn differently**: a
|
||
mode is a chip that fills with the accent when it is on, a group is a word with
|
||
a rule under it. That is what lets both be read at once, and both are on at
|
||
once routinely — see below.
|
||
|
||
**Mode and group are independent axes.** Picking `Light` while a mask layer is
|
||
selected filters *that layer's* chain and does not leave local mode. The
|
||
alternative — a group press quietly dropping the scope — would be §1.1's fault
|
||
reintroduced from the other end, and it would make `Light` mean two things
|
||
depending on where it was pressed.
|
||
|
||
**Where it sits.** Pinned above the develop column, where the group strip
|
||
already was, rather than at the top of the canvas. The half that filters the
|
||
column belongs to the column, and moving it onto the photograph would put it
|
||
somewhere the four principles say chrome should not be. The canvas keeps one
|
||
button — now *"Done Cropping"* / *"Done Masking"*, naming the mode it leaves —
|
||
because the column can be closed on a narrow window and no mode may be
|
||
inescapable.
|
||
|
||
**Also landed.** `GeometryPanel`'s Crop button is gone: a second control
|
||
entering the same mode is a second thing that has to agree about which mode the
|
||
view is in.
|
||
|
||
**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.~~ All three, checked on screen as well as in tests — `back_step` has
|
||
one `LeaveMode` step covering both modes, and the enum makes "no two at once"
|
||
unrepresentable rather than merely untested.
|
||
|
||
### N2 — Local mode — **done**
|
||
|
||
**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.
|
||
|
||
**Landed.** The "Overlay" and "Select" buttons are gone; entering the mode does
|
||
both, and `region-picking` is now derived from the mode rather than toggled.
|
||
The masking panel is no longer a panel among peers in the scrolling column — it
|
||
appears only in local mode, which is what takes the column from six panels to
|
||
three there.
|
||
|
||
**The scope is the adjust panel's own heading**, not a caption in the panel
|
||
above it. `ADJUST` becomes the layer's name. That is the difference between
|
||
describing the hazard and removing it: the heading of the thing that changed
|
||
cannot be skipped on the way to a slider, and a caption in a different panel
|
||
routinely was.
|
||
|
||
**What local mode drops from the column**: the capture metadata, the framing
|
||
controls and copy/paste. None is a property of a region within the photograph,
|
||
so all three would be controls in scope of nothing. The histogram stays and
|
||
still reports the whole frame — the disagreement §1.1 names is real and is N3's
|
||
to close; removing the instrument would be a worse answer than an honest one
|
||
that is not yet scoped.
|
||
|
||
**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.~~ Both.
|
||
|
||
### N2a — Gradient handles — **done**
|
||
|
||
Not a numbered workstream when this was written, and it belongs beside N2: the
|
||
canvas half of local mode.
|
||
|
||
`MaskSource::Linear` and `MaskSource::Radial` could be created and then not
|
||
moved, so a radial sat at the centre of the frame at its default size for ever.
|
||
They now carry handles on the photograph — the first controls in the
|
||
application designed to be dragged there rather than in a panel, and D-N2's
|
||
"the live one".
|
||
|
||
Three faults had to be fixed before a handle was worth drawing.
|
||
|
||
**A gradient did not render at all until the model had run.** The mask
|
||
rasteriser was built on the way out of `segment`, and the array's size was read
|
||
*off* the segmentation, so a gradient added to an unsegmented photograph
|
||
produced nothing — silently, because the generated shader still emits the
|
||
layer's block and the empty placeholder multiplies it by zero. The proxy size
|
||
is a property of the photograph; both are now derived from it, deliberately at
|
||
the same size because a subject's distance field is sampled against the array.
|
||
|
||
**A gradient's geometry was measured in raw `0..1` fractions**, so a 45° ramp
|
||
was not at 45° and a radial with equal radii drew an ellipse. Angles and
|
||
distances are now in the frame's isotropic units — y spans `0..1`, x spans
|
||
`0..aspect` — converted in exactly one place, `frame_delta` in `mask.wgsl`.
|
||
Only the *meaning* of the stored numbers changed; the sidecar format did not.
|
||
|
||
**Hit-testing has to go through the framing map.** A handle is drawn in output
|
||
coordinates and stored in source ones, and the two are separated by the crop,
|
||
the zoom, the pan, the straightening and the turns. `Framing::source_at` and
|
||
`Framing::output_at` are `wgsl_prologue` evaluated on the CPU, kept in that file
|
||
beside it so the correspondence is one file's problem.
|
||
|
||
**Handles.** A linear ramp has three — centre, width, angle. A radial has three
|
||
— centre and one per semi-axis, the major one carrying the ellipse's angle as
|
||
well as its length, because where an axis is put says both. A rotation arm was
|
||
tried on the radial and taken out: standing off the shape by a fixed distance,
|
||
it began outside the photograph at the size a new radial is created at.
|
||
|
||
**A drag is a displacement applied to where the mask was when the press
|
||
landed**, not a destination the handle is snapped to. Snapping jerks the handle
|
||
by up to half a touch target on the first press, and the target is finger-sized
|
||
(FR-UI-3).
|
||
|
||
**Two faults found by looking at the screen** rather than by reading the source,
|
||
both of the kind `ui-refinement.md`'s verification section warns about. A `1px`
|
||
rule with a size and no position is *centred* by Slint, so the develop column's
|
||
seam was a hairline down the middle of the panel — twice over, once in
|
||
`app.slint` and once in `AdjustPanel`. And handing Slint a new `ModelRc` for the
|
||
handles on every pointer event made the repeater rebuild its items, taking the
|
||
`TouchArea` holding the gesture with them: the handle jumped once and then went
|
||
dead under a finger that was still down. The model is now rewritten in place.
|
||
|
||
### N3 — Scope-following histogram
|
||
|
||
**Depends on** N2, which has landed, so this is next and is the outstanding
|
||
half of §1.1: the panel now says *which* chain the sliders edit, and the
|
||
instrument beside them still measures the other one.
|
||
|
||
**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.
|