Files
DarkRoom/docs/dev/ui-navigation.md
dtourolle 9ebaa15099 Move copy, paste and presets into the develop top bar
They sat in the develop column under a "SETTINGS" heading, which read as
application settings, and went away with the panel toggle and in the
mask and spot modes. The strip is where undo already is for the same
reason: these act on the whole edit, not on any one panel.

The paste button still names what it would apply. The TransferPanel
component is gone; the Transfer global and its Rust wiring are
unchanged.
2026-09-22 21:37:31 -04:00

42 KiB
Raw Permalink Blame History

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 · PARTLY REVERSED

Reversed for navigation, 2026-09-05, by use. The reasoning below is still right about size and still right that cfg(target_os) is the wrong axis. It is wrong in one place, and the wrong bit is the sentence "touch changes hit regions, not layout". See D-N6.

Reversed for portrait, 2026-09-06, by arithmetic. "Both orientations of both targets are the expanded class" is still true and is no longer the point. It was worked out for a 4:3 panel; the tablet's is 25:16, and on that aspect a column beside the photograph in portrait leaves it a strip. See D-N7, which keeps the layout class and adds an axis D-N2 did not consider.

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-N6 — The groups move to the rail under a finger · DECIDED

Reported from a tablet: the tool rail is "very useful" there, and the same interface with a mouse and keyboard is not ergonomic. That is D-N2's assumption failing in the field, and it is worth being precise about which half failed.

What D-N2 got right. Platform is the wrong axis, and width is the wrong axis. A tablet in landscape wants what a desktop wants; a desktop window dragged narrow wants what a small screen wants. apply_layout_class still decides the layout class from the window, and nothing here changes that.

What it got wrong. It identified input as the real difference between the targets and then concluded that input changes only hit regions. Two controls answering one question — which group of adjustments am I looking at — disprove it:

  • A horizontal strip above the column. One gesture to a target the eye has already found; costs one row of a column with height to spare. It pans when the operation set is rich, so a group can be off the end with nothing saying so — which a pointer user tolerates and a finger user does not discover.
  • A vertical run down the rail. Every entry visible at once, each a finger-sized target, on the edge of the screen the hand is already holding. Costs nothing extra in width, because the rail is already there and already mandated.

Neither is better in general. The first is better with a pointer and the second is better with a finger, which is a divergence on input modality — the axis D-N2 itself named.

The shape. ToolRail grows a second section below a rule: the same adjust-tabs model the strip takes, plus "All". GroupStrip stands down when the rail carries them, so the two are never both on screen and there is no state to keep in step. Mode and group stay independent axes exactly as N1 requires — one entry lit in each section, and picking a group while a tool is held still filters without putting the tool down.

Drawn differently, still. N1 insisted a mode and a filter must not be told apart by the shape of their highlight alone. The tools fill with active-dim and invert their ink; the groups take a bar down the leading edge — the underline from the horizontal strip, turned ninety degrees. The rule between the sections is the second signal.

The rail scrolls now. Its own note argued against a Flickable on the grounds that four entries were written in the file. With the groups in it the list is generated from the operation set, which is exactly the "something the user's data decides" the note excluded it from.

And it is a preference, because the automatic answer is a guess. Neither platform can be asked what the user is actually holding — an Android tablet in a keyboard case is being driven like a desktop, and a touchscreen laptop is whichever its owner says. dr_plat::is_touch_first reports the usual case per platform and dr_types::GroupNavigation lets it be overridden; Settings names what Automatic resolves to on this device rather than leaving it to be found by pressing.

Still open: whether Local is a mode at all. The rail now holds two kinds of entry, and a third reading is available — that Compose and Repair are categories with a canvas gesture attached, while Local edits nothing and instead changes what every other category applies to. That would make it a scope, not a peer of the tools, and would collapse the two sections into one list of seven. It is the tidier model and a much larger change; deferred until the two-section rail has been lived with. §1.1's complaint was that scope was invisible, so this is the same argument arriving from the other end.

D-N7 — The column docks under the photograph on a tall window · DECIDED

D-N2 dismissed portrait with one number: a 12-inch tablet is about 1024 logical pixels across in portrait, which clears EXPANDED_MIN_WIDTH, so portrait is expanded, so there is nothing to design. The number was for a 4:3 panel. The tablet's panel is 3000 × 1920, which is 25:16 — closer to a sheet of A4 than to an iPad — and the same arithmetic on that aspect comes out the other way.

What the photograph gets. Logical size depends on the density Android reports, which nothing in this repository records (N6 measures it). At a scale of 2.0 the window is 960 × 1500 in portrait; at 1.75 it is 1097 × 1714. Take the first, subtract the 60px rail, the 360px column and the 44px status bar, and the canvas beside the column is 540 × 1456 — a strip two and a half times taller than it is wide. Against a column under the canvas, 480px tall:

Photograph Beside the column Under the column Gain
3:2, landscape 540 × 360 900 × 600 2.8× the area
2:3, portrait 540 × 810 651 × 976 1.4× the area

At 1.75 the figures move and the ratios hold (2.4× and 1.3×). So on this panel the dock wins for both orientations of the photograph, not only the landscape frame one would guess it was for. That is what makes it a decision rather than a preference: the column is on the right because the eye's path is tool, photograph, adjustment, and in portrait the photograph in the middle of that path is the thing being starved.

What it is not. N5 said "no bottom sheet, no second layout, no tool strip along the bottom — those solve a phone". Still true of all three. This is not a sheet: the column does not slide over the photograph, it sits beside it on the other axis, with the same contents, the same collapse and the same toggle. It is not a second layout in D-N2's sense: the layout class is still decided by width, the compact class still means what it meant, and a tall narrow desktop window gets exactly what a portrait tablet gets, which is FR-UI-1's rule. And the rail does not move — toolrail.slint argued it never should, and a vertical list of finger-sized entries wants height, which portrait has more of.

The axis is aspect, not width, and it is independent of the class. A 960 wide portrait window is expanded by width and wants the dock; a 1500 wide landscape one is expanded by width and does not. So this is a third property beside layout-class and panel-max-width, set from the same place for the same reason — a size that both derives from and feeds the layout is a binding loop in Slint, and apply_layout_class already measures the window. The window-resized callback reports width alone today and grows a height. The threshold is height above 1.2 × width, with hysteresis wide enough that a window resized across square does not flap.

Slint cannot turn a layout on its side, and does not need to. The develop view is one HorizontalLayout of rail, canvas and column; it becomes a Rectangle whose three children take x, y, width and height from the flag. The column's own VerticalLayout and Flickable are untouched. The alternative — the column subtree declared twice under two ifs — is 400 lines of bindings copied, in a file whose own notes record conditional children in layouts as the shape that has produced binding loops before.

The contents are the real cost. Everything in the column was drawn for a 360px vertical scroll, and the dock is 900 to 1040 wide by about 480 tall. Stretched to that width the stack works — sliders get longer tracks, the histogram divides the width, text wraps — and it is one long scroll in a short box, with the histogram scrolling away from the sliders it serves, which is §1.2 again. The width is two and a half to three of today's columns, so the composition that fits it is three of them side by side (N9): the instruments, the sliders, and the panels the mode adds. That needs the panels declared a second time, which is cheap only once their callbacks stop being forwarded through the window root by hand (N8). The stretched stack ships first as the stopgap (N7), because the photograph gets its area back on day one and the dock's contents can be got right afterwards.

The dock's height is mandated, as the column's width is. panel-width exists because a column that sizes itself to its contents is a photograph that changes size when a caption does; a dock has the same disease on the other axis. dock-height in style.yaml, 480 until N6 says otherwise: room for the pinned instruments (about 260px per N4) and a group of sliders under them, and a 3:2 frame at 900 wide still fits above it at either scale.

The filmstrip stays where it is. It takes its strip off the bottom of the photograph on demand and it keeps doing so; with the dock below it sits between the two. The photograph loses 108px while the roll is open, which is what it loses today, and the roll is not made part of the dock because it is a different kind of thing — navigation, not adjustment — and D-N6 has already been through why two kinds of entry in one control need a rule between them.

Not remembered separately. PanelChoices keeps the user's open-or-closed override per layout class. The dock does not add a class and does not add a remembered state: closing the column in landscape closes the dock in portrait, because it is the same column.

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. Still no sheet and still no strip; but a tall window is not a narrow one, and D-N7 (2026-09-06) puts the column under the photograph there. N6–N9 carry it. This workstream keeps the narrow case.

Done when. A desktop window dragged to 700px shows the photograph and a usable column, and nothing is unreachable that was reachable at 1400px.

N6 — Measure the tablet

Depends on nothing. Hours.

Every figure in D-N7 is computed at a guessed scale factor. The tablet's panel is 3000 × 1920 physical; its logical size is that divided by whatever density Android reports, and the two plausible answers (2.0 and 1.75) put the dock's width at 900 or 1037 and its available height 200px apart.

Deliverable. Log the window's scale factor and logical size once at startup, beside the existing apply_layout_class call, at a level that reaches adb logcat. Run it on the tablet in both orientations. Record the four numbers in D-N7 and, if they move the table, correct it. #30 (NFR-COMPAT-1) wants a reference device named; this is one of the numbers that names it.

Done when. D-N7 cites measured logical sizes, not a scale it assumed, and dock-height has been checked against the measured portrait height.

N7 — The column docks on a tall window

Depends on N6 only for the value of dock-height; the shape does not wait for it.

Deliverable, three parts.

The flag. window-resized reports height as well as width. apply_layout_class derives a third property, column-below, from the aspect with hysteresis (enter above 1.25, leave below 1.15, or thereabouts — the point is that a window resized across square does not flap), and sets it beside layout-class and panel-max-width. It is not a layout class and PanelChoices does not learn about it.

The frame. The HorizontalLayout holding ToolRail, canvas-area and develop-column becomes a Rectangle and each child takes x, y, width and height from the flag. The rail is full height on the left in both cases. With the flag off the geometry is what the layout produced, to the pixel — screenshot before and after and diff them. With it on, the column is dock-height tall and runs from the rail's edge to the window's; the canvas has what is left above it. panel-visible collapses the dock to zero height exactly as it collapses the column to zero width.

The contents, as a stopgap. The column's stack stretches to the dock's width: the Flickable's viewport width follows the dock, the min-width floor that keeps a mandated column honest is still there and is simply not binding. Sliders take the width. Nothing is reflowed; N9 does that.

dock-height goes in style.yaml next to panel-width, with the reasoning D-N7 gives, and is read as Theme.dock-height.

Done when. On the tablet in portrait, a 3:2 photograph is drawn at the width of the canvas, not at the width the old column left it; turning the tablet moves the column back beside it with the same scroll position and the same panels open; a desktop window dragged taller than it is wide does the same; and the screenshot diff in landscape is empty.

N8 — Develop callbacks onto a global

Depends on nothing; can run beside N7.

The develop panels — AdjustPanel, MaskPanel, SpotPanel, ComposePanel, TransferPanel, FocusPanel, HistogramPanel, InfoPanel — each forward their callbacks and take their inputs through the window root, and the instantiation in app.slint that wires one up is twenty to forty lines. N9 needs each of them declared a second time, and copying that wiring is the kind of duplication that drifts.

Deliverable. A Slint global (or one per panel family, if a single one reads badly) carrying the develop callbacks and the inputs the panels bind to. A panel calls the global directly; Rust hooks the global instead of the window. The existing instantiation in the column shrinks to the properties that genuinely differ by placement, which should be none. Readout in adjust.slint is the precedent for a global in this codebase.

The tests in ui/dr-ui/src that drive these callbacks through the window move to the global; count them before starting, so the ticket knows its own size.

Done when. No develop panel's instantiation in app.slint forwards a callback by hand, every existing test passes, and a second instantiation of any panel is under five lines.

N9 — Three columns in the dock

Depends on N7 and N8.

Deliverable. A second composition of the same panels for the dock, in three columns of equal width side by side, each its own scroll:

  1. Instruments — InfoPanel, HistogramPanel, FocusPanel. What the sliders are judged against, pinned by construction: it does not scroll with them because it is not in their column.
  2. Sliders — GroupStrip above AdjustPanel, exactly as in the column. With a group selected this is one screen of sliders; with All it scrolls.
  3. The mode's panels — ComposePanel in photo mode (copy and paste moved to the top bar, 2026-09-22), SpotPanel in repair, MaskPanel in local. Empty otherwise, which is a signal of its own about which mode the view is in (§1.1).

Three columns at 900 wide are 300 each, and at 1037 they are 346: inside the 280–360 band PANEL_MIN_WIDTH's note says the sliders stay accurate over. The dock takes the three-column composition only when a third of its width is at least PANEL_MIN_WIDTH — 840px of dock, which both scales of the tablet exceed. Below that it keeps N7's stretched stack. Two compositions, not three: a dock too narrow for three columns is a desktop window in an odd shape, and N5 says that case degrades.

The panels are declared twice, once per composition, which is what N8 made cheap. Declaring each once and positioning it by hand in both modes was considered and rejected: a column that scrolls is a Flickable, a Flickable's children are its children, and a panel cannot be in two.

Done when. On the tablet in portrait the histogram is visible while any slider in any group is dragged, with no scrolling to arrange it — N4's own criterion, met in the dock by construction; every panel reachable in landscape is reachable in portrait; and the instantiation of each panel in the dock is a handful of lines.


5. Sequencing

ui-refinement A ──┬── C ──── N4 ──┐
                  └── D ──── F     ├── N5
N1 ── N2 ── N3 ───────────────────┘

N6 ── N7 ──┐
           ├── N9
      N8 ──┘

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.

N6–N9 are the portrait dock (D-N7) and run beside the first row rather than after it. N7 is the one that touches the develop view's frame, so it should not land in the same wave as F; N8 touches only plumbing and can. N9 waits for both, and gains from N4 if N4 is in by then — a collapsible panel in a 300px column is worth more than in a 360px one.

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.

7. Place — the other half of "where am I"

§1 asked how someone finds anything. This asks how they stop losing what they already found. Both are navigation; the second is the one nobody notices until it is wrong, and then notices constantly.

7.1 Three failures, one cause

The library grid is gated on an if in the markup, so every route away from it destroys the subtree and rebuilds it on return. The Flickable inside passes its viewport through zero on the way out. Three consequences, reported separately and all the same bug:

  • "Opening Settings and coming back puts me at the top." The guard on the scroll handler tested show-library, which stays true while Settings, Import, People or the launch screen covers the grid. The teardown's scroll-to-zero passed it, and the remembered position was overwritten with 0.
  • "Coming out of develop I lose the photograph I was editing." The position restored was where the grid was, not what was open — and walking the photo roll moves the second a long way from the first.
  • "Launching puts me at the beginning." Nothing was written down at all.

app.slint now computes library-visible once — the same expression the if is spelled from — and Rust reads that rather than show-library. The two cannot drift apart, which is what let them drift in the first place.

7.2 Two positions, and which one wins

Returning from develop has two candidates: resume_at, where the grid was, and the open photograph. They agree in the ordinary case and disagree after a walk along the roll.

The rule is not "pick one". The grid seeks to the remembered position, then reveal()s the keyboard cursor, which is on the open photograph and which moves the viewport as little as will bring it into view. A frame inside the remembered screenful moves nothing; one outside it scrolls exactly far enough. One rule, both behaviours — and the cursor rather than the selection, so a set of forty photographs assembled in the grid survives having one of them opened.

7.3 A place is not a scroll position

What gets written down is the view, the scope, the filter and the photograph — because a position without the filter that produced it names a row of a list that no longer exists. Restoring them has an order for the same reason: scope, then filter, then position, then the view. Each step changes what an ordinal means.

Addressed by remote path and collection UUID, never by an ordinal or a row id. See FR-UI-8 and dr_types::place for why, and library::ordinal_of_path for the one place the ordering is inverted — through the grid's own ORDER BY, taken verbatim, rather than spelled a second time.

7.4 The handover, and when to refuse it

The record travels through .darkroom-derived/place.json, so a session begun on the desktop continues on the tablet. Newest wins; there is nothing to merge.

The interesting decision is the refusal. A place arriving from another device is welcome on the way in and unwelcome the moment the photographer has started working — a grid that jumped somewhere else mid-scroll because a round trip finally landed would have lost their place to the feature meant to keep it. So any scroll, scrub, scope change, filter or opened photograph closes the latch, and a record that arrives after that is still written to disk and simply takes effect at the next launch.

7.5 Two smaller instruments that were saying nothing

Both were "correct" in the sense of not being wrong, and both were useless.

  • The capture-time marker rested greyed at mid-track until the first scroll, on the reasoning that anchoring it would imply a choice the user had not made. But the sidebar's claim is to say when you are, and that is known from the first frame. It is now seeded from wherever the view sits.
  • The photo roll brought the open frame into view by the shortest move, which put it hard against one edge with nothing on that side. It now centres on the first reveal of a develop session and steps minimally thereafter — a one-shot request the strip consumes, so an overlay screen rebuilding the view does not undo a roll the user has scrolled by hand.