Commit Graph
187 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 c4ddcbe0f7 Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting
coefficients from a lens name, a focal length and an aperture — with no
dependents anywhere in the workspace. The three corrections it feeds now
exist in the graph, so this connects the two and finishes the chain.

The coefficient structs stay duplicated. `dr-pipeline` is organised around
having no dependencies so its codegen is testable without a device or a
database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of
profile data. Neither crate can convert to the other, so the conversion goes
above both, in `develop.rs`, which is the only place that sees them together.

Both traits grow the same defaulted door. The optical corrections do not sit
on the same side of the fetch — distortion and CA rewrite coordinates and are
`Warp`s, vignetting applies a gain to the pixel already there and is an
ordinary node — and fanning a profile out by which trait each happens to
implement would make the caller reason about that distinction. Each correction
takes its own share of the whole profile instead, and `set_lens_profile` walks
both lists identically.

The lookup happens in `set_source_metadata` rather than in its caller, because
that is the one place a session is told which file it came from. Doing it
there makes it unforgettable, in the shape `FilmRebake` already uses for the
other derived thing — and, more to the point, makes *clearing* unforgettable:
a session that opened a second photograph while still holding the first one's
profile would correct it for the wrong optics, invisibly, in a way that looks
exactly like the lens.

It needs the whole shot and not just a name. Distortion is interpolated across
a zoom's focal range and vignetting depends strongly on aperture — a fast
prime can be two stops down in the corners wide open and clean by f/8 — so a
lookup missing either returns coefficients measured for a shot nobody took.
Missing any of the three refuses rather than guesses.

A profile is derived, not persisted: it comes from the file's EXIF and a
database, so it is not a parameter, not in the sidecar and not undoable. What
is an edit is the manual trim beside it, which each correction composes with
the measurement — so a photographer can lean on it, override it, or work
without one.

`InfoPanel` gains a lens line, and it distinguishes three cases rather than
two. `dr-lens` states the rule it exists for: an automatic correction that
silently did nothing is worse than one the user can see is unavailable. A
session with no header draws nothing, a header naming no lens reads "Lens not
recorded", and a lens the database has never heard of reads "· no profile".
Collapsing the last two would send somebody hunting for a profile that was
never missing — which, for third-party and adapted glass, is the ordinary case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:11:32 +02:00
dtourolleandClaude Opus 5 8e7b1350bf Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves
from `[tone, colour]` to `[effect]`.

Two categories were doing the wrong job. "Geometry" describes what crop,
straighten and the quarter turns do to coordinates — but it describes lens
distortion correction exactly as well, and that is not a compositional
choice at all. Naming the attribute for the photographer's decision is what
separates it from `Optics`: one is what the lens did, the other is what
they chose. The maths the two have in common is not the thing worth
filing them under.

A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in
the Light group beside exposure and again in Colour beside white balance —
two places, neither of which is where anyone looks for it. It is neither:
`Effect` is defined in this same file as "applied rather than corrected — a
look, not a fix", which is what a stock is. That it moves tone and colour
is true of every look, and is not what the attribute is for.

`from_name` still accepts "geometry" on the way in. That string is
persisted in `develop.copy_attributes`, and an entry it fails to parse is
not an error — `presets::scope_for` logs it and drops it — so without the
alias an existing settings file would have quietly narrowed what a paste
carries. `name` writes the current spelling, so the file migrates itself
the first time it is saved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:03 +02:00
dtourolleandClaude Opus 5 f6c9343bcc Ask the pixels where the edge is, not just what belongs
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h18m38s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 50s
Build and test / Android (aarch64) (push) Failing after 30s
The colour gate decided *what* was in a category and had no way to decide
*where* its edge fell. A colour test has no notion of an edge. So a refined
sky lost its flag and kept the model's twenty-pixel-blocky outline, and no
setting of the control could move that outline onto the horizon.

This adds the second half: a **marker-based watershed**. The mask is eroded
to give two markers, and the flood runs in the ribbon left between them,
meeting along the most expensive line it can find. The cost is a sum of terms
exactly as docs/segmentation.md §2 specifies — the photograph's own edges, and
the colour model's disagreement.

## Why this is not the watershed §15 threw away

That path failed because the merge *ladder* collapsed: 45,808 basins reduced
to one region plus specks. There is no ladder here. Markers prevent
over-segmentation by seeding rather than by merging afterwards, so the one
component that broke is the one component this does not have.

The markers are also better than the textbook's. scikit-image derives them by
thresholding the gradient — guessing where objects are — where these come from
a model that knows what sky is. Marker selection is what normally goes wrong
with this method, and it was already solved.

## The gate still runs, and it runs first

A flood cannot replace the colour gate. It only refines contours that already
exist, and there is no contour around a flag precisely because the model never
noticed one — the flag in the tests sits forty-five pixels from the boundary
against a ribbon of six. The tests caught this; the first version of this
commit had the flood standing in for the gate and the flag stayed.

So the gate goes first and *creates* the contour, and the flood then puts every
contour — the horizon and the new hole alike — onto a real edge.

## Erosion that does not delete flagpoles

Eroding by a cell and a half destroys anything thinner than three cells: a
mast, a bare branch, and equally a strip of sky between two of them. Those
would be left unseeded and the flood would fill them from whichever side
surrounds them, so a flagpole would come back — and come back *confident*.

Erosion therefore stops at the ridge of the distance transform. Whatever would
otherwise vanish keeps a one-pixel seed down its centre, floored at
`min_thickness` so a hot pixel does not qualify. That floor also moves the
signal-versus-noise decision out of colour space, where it was a share of a
fitted distribution nobody can picture, and into image space, where it is a
width in pixels a photographer can see.

## Two modelling errors the outward test found

Both were invisible while the refinement could only subtract, because the gate
was multiplied by weights that were already zero outside the mask. The moment
the boundary could move outward they decided the answer.

**A diagonal covariance is wrong along a gradient.** Sky moves along all three
opponent features together — luminance up, red-green drifting, blue-yellow
down — so treating them as independent charges a colour two deviations along
that gradient three times over. Measured: sky fifteen rows past the sample
scored 11.6 against a threshold of 11.34, so the model refused the very thing
it was refining. The fit now carries a full 3x3 covariance, inverted by
cofactors rather than by a dependency (D13, the NDK).

**Eroded seeds understate the spread, always, in a known direction.** The
sample is drawn from the middle of a category and never from its edge, so for
anything with a gradient the colours nearest the boundary are exactly the ones
left out. The broad mode is therefore fitted wider than its sample by
`SHOULDER`. Same pixel: Mahalanobis 5.9 uncorrected, 1.5 corrected — the
difference between refusing the horizon and reaching it. Only the broad mode
is widened; the tight ones are what discriminate.

## What was given up

Strict subtractivity. It bounded the damage and kept `scene.rs`'s partition
true for free, and it had to go: a mask that may only shrink can sharpen a
horizon inward but never outward, so wherever the coarse contour sat inside the
true edge, the error survived every setting of the control.

The travel bound replaces it. Everything beyond the ribbon is already a marker,
so the flood never reaches it — not "can only remove" but "can only move this
far", and the distance is the model's own uncertainty. That single bound also
retires the connectivity test, the reachability radius and the separate
additive path that an outward-growing rule would have needed. A blue car below
the horizon cannot be gained, not because a rule forbids it, but because the
flood is never there.

`the_colour_gate_only_removes` keeps the older property where it still holds;
`the_flood_cannot_travel_further_than_the_ribbon` holds the new one across the
whole travel of the control.

## Cost

The flood visits only unlabelled pixels, so confining it to the ribbon is not
an optimisation added on top — it is what a seeded flood does. A ribbon of a
few tens of pixels around one contour is a small part of a proxy.

The distance transform is no longer cached, because it has to be measured from
the mask as the gate leaves it and the gate moves with the control. That is one
transform plus one flood per change of the control, against a precompute that
runs the model once.

Verified: fmt clean, clippy --workspace -D warnings clean, 63 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:29:12 +02:00
dtourolleandClaude Opus 5 8bf5e13faf Centre the photo roll on the frame it opens with
The roll brought the open photograph into view by the shortest move,
which is right for stepping along it and wrong for the first look: a
frame near either end of the loaded window arrived hard against an edge,
with nothing on that side to give it any context.

It now centres on the first settle of a develop session and steps
minimally after that. A one-shot request that the strip itself clears --
the only thing that knows the request has been honoured is the code
honouring it -- rather than something recomputed on creation, because the
strip is created far more often than a session begins: leaving develop
for Settings and coming back rebuilds it, and re-centring then would undo
a roll the user had scrolled by hand.

Raised on the two ways into develop from the grid, and not on a pick
along the roll, which is a step within a session rather than the start of
one.

Centring is clamped to the ends: the third photograph of a window cannot
be centred without scrolling empty space in beside it, and a strip that
begins with a gap reads as broken rather than as centred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:35:55 +02:00
dtourolleandClaude Opus 5 0ff1e01ec3 Come back from develop on the photograph you were editing
Leaving develop returned to where the *grid* was, which after a walk
along the photo roll can be a thousand rows from the frame you had just
finished. So the one photograph you were certainly interested in was the
one the grid came back without.

Two positions, and the rule is not to pick one of them. The grid seeks to
the remembered position, then reveals the keyboard cursor -- which is now
put on the open photograph, and which moves the viewport as little as
will bring its row into view. A frame inside the remembered screenful
moves nothing at all; one outside it scrolls exactly far enough. One
rule, both behaviours.

The cursor rather than the selection, deliberately: `place_cursor` also
rewrites the selection, and a set of forty photographs assembled in the
grid must survive having one of them opened.

`reveal()` now also runs on the grid's `init`, since `cursor-row` is
initialised rather than changed when the subtree is rebuilt and no
handler would otherwise fire. Both it and the roll's centring defer while
the element has no height yet -- `init` runs before layout, where a
height of zero makes every row look off screen -- and a latch brings the
first real height back to the cursor without letting every later resize
haul the viewport around.

The capture-time marker follows the same move, for the same reason:
`load_window` rebuilds the axis only when the scope, the filter or the
total has changed, and none of them has. It is the same library seen from
a different row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:35:23 +02:00
dtourolleandClaude Opus 5 53dc2d171e Say where the grid is on the capture-time axis from the first frame
The sidebar's marker rested greyed at mid-track until the first scroll or
scrub. The reasoning was that anchoring it would imply a choice the user
had not made -- but that reads the marker as reporting an intention, and
it does not. The sidebar's whole claim is to say *when* you are, and that
is known from the first frame: the grid is at the top of the library, or
wherever it was last left.

So a launch opened with the marker halfway down an axis whose visible
photographs were all from the wrong end of it. Dimmed rather than absent,
which made it look like a reading rather than the absence of one.

Seeded in `refresh_timeline` -- the one place that decides what the
marker says, and the one that runs on every route which builds the axis
-- and only when nothing has claimed it, so a scroll or a scrub still
speaks for itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:34:11 +02:00
dtourolleandClaude Opus 5 eed27eb36d Keep the grid's place when another screen covers it
Opening Settings, Import or People and coming back landed at the top of
the library however deep in it you had been.

The grid is gated on an `if` in the markup, so every route away from it
destroys the subtree and rebuilds it. A Flickable being destroyed passes
its viewport through zero on the way out, and that reaches
`on_library_scrolled` looking exactly like the user having flung the grid
to the top. The handler already guarded against it -- but on
`show-library`, which means "the library rather than develop" and stays
true while any of those four screens replaces the window. So the guard
covered the develop route and none of the other three: `resume_at` was
overwritten with 0 on the way out, and the position was gone before
anything could restore it.

The condition the `if` is actually spelled with is now computed once, in
`app.slint`, and Rust reads that. The two cannot drift apart again
because there is only one of them.

That fixes the overwrite. The second half is that nothing replayed the
position on the way back in: `on_back_to_library` does it by hand, and
Settings, Import, People and the launch screen do not go through it.
Rather than teaching three more modules to call it, `scroll-to` is now
kept current on every scroll. It is read by `seek()`, which runs on a
token change and on `init`, so writing it without bumping the token
cannot move the grid on screen -- and is exactly what the next grid reads
when it is built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:33:34 +02:00
dtourolleandClaude Opus 5 4dc954f01a Take the photo roll's grab band off the buttons that end a mode
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 58s
Build and test / Layer separation (push) Successful in 45s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m0s
Build and test / Android (aarch64) (push) Failing after 31s
"Done Cropping", "Done Repairing", "Done Masking" and "Fit" float over
the foot of the canvas. So does the photo roll's swipe handler, and a
gesture handler is not a layout box — it is an input surface. A press
inside one is delayed, then offered to that handler's own children and
to nothing else: `input_event_filter_before_children` returns
`DelayForwarding`, which aborts the hit-test traversal outright, and the
replay afterwards visits only the handler's subtree. Everything behind
it is never asked, hover included.

The band was `strip-height + reach` — 136px along the bottom — whether
the roll was out or away. So the button that ends a mode was drawn, was
lit, and did nothing for as long as a library was open, which is the
whole time anybody is developing from one. The tool rail kept working
because it is a sibling of the canvas rather than behind the roll, which
is exactly why this looked like two dead buttons rather than a dead
region.

The band now goes where the roll goes. The handler carries the strip
instead of standing still while the strip animates inside it: closed,
only `reach` is on screen and the rest hangs below the window where
nothing can press it; open, it still covers the thumbnails, which is
what lets a swipe down anywhere across them put the roll away. The
180ms travel moved from the strip onto the handler, so the drawn
positions in both states are what they were.

The controls are then positioned against that band rather than against
the bottom of the canvas, and ride up with the strip when it comes out.
Reordering them in front of the roll would have been the other fix, and
it is the wrong one — the band would become the thing that cannot be
reached, and a gesture nobody can start is worse than a button with a
second way out.

`roll-strip` and `roll-reach` are tokens now, because two files have to
agree on where that band is for either of them to keep out of it.

The bottom of the photograph comes back with it: the crop's lower
handles and a repair placed near the bottom edge were inside the same
136px and had the same fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:42:17 +02:00
dtourolleandClaude Opus 5 d48e9f6033 Open the catalog on a worker, so a launch is not a page-by-page read
The second thing standing between `android_main` and the first
`poll_events`, and the one that grows with the library rather than with
the APK.

`library_ui::open` called `show_catalog_now`, which called
`Catalog::open_verified`. That runs `PRAGMA quick_check`, which reads
every page of the database, and then `Catalog::open`, which takes a full
SQLite backup of the file before a migration and rewrites its structure
afterwards. On a 50,000-image library that is tens of megabytes of I/O
on a tablet's flash, and it happened before the window had painted
anything — so on Android it was counted against the five seconds the
input dispatcher allows, and on the desktop it was a launch that sat on
a blank window.

`dr_catalog::recovery`'s own module documentation says the check is
affordable "at startup, where a failure has a user in front of it who
can answer a question". That was the intent and it was not true: there
was no interface yet in which to ask. Now there is, because the open
happens on a worker and the answer arrives on a channel drained by a
timer — the same shape the scan, the thumbnails and the login already
use.

The gate the synchronous call provided is kept, and is the reason the
scan moved with it. `Catalog::open` succeeds on a damaged file whose
header survived, so a scan running beside an unanswered recovery
question writes ETags and image rows into damaged pages and turns a
catalog that had a backup into one where the backup is the only copy
left. So the scan now starts from the drain, on the two answers that
permit it, and not at all on `Corrupt`. `library-scanning` stays true
throughout, which hides the Rescan button and stops the gate being
merely advisory.

What the user sees while it runs is a third empty state. The grid
already refused to conflate "still scanning" with "scanned, found
nothing"; "opening the library" is a third answer and it gets its own
sentence, because a grid saying "Scanning…" while nothing is on the
network is the same kind of lie the other two were separated to avoid.

`show_catalog_now` stays, unchanged and blocking, for `recovery_ui`.
That call site has the event loop running, has just replaced the file
under a `forget_catalog`, and has `recovery-busy` on screen — the same
reasoning `recovery_ui::answer` already gives for doing its file copy in
place. The part both paths share is now `adopt_catalog`.

One consequence worth naming: the cache-usage figure on the settings
page was read at startup from a catalog that is no longer open by then.
It moves to the page's `on_open` closure, beside the face coverage,
which is read there for exactly the same reason — it is only ever looked
at while that page is on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:41:34 +02:00
dtourolleandClaude Opus 5 4efab496c2 Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
The evidence was being gathered and spent immediately at one strictness
nobody could see or change. This makes it a control.

`MaskLayer::refine` is shaping, like the feather, and it lives on the layer
for the layer's reason: two layers may sit on the same category and want
different amounts of it, and the model ran once for both.

## What the segmentation now stores

`CategorySummary` keeps the **coarse** mask and the `Refinement` beside it,
rather than a refined mask. That is what gives the control an off position
that is bit-for-bit the model's own weighting, and what stops a strictness
change from needing the model again.

`category_mask_at` borrows at zero and wherever no refinement could be
fitted, so a layer nobody has touched costs nothing over the old path.

The fit moved to the far side of the orientation permutation. The verdict is
a per-pixel field over the same grid as the mask it gates, so fitting it
upright would mean permuting a proxy-sized buffer afterwards to match — a
second rotation, and a second chance to get one wrong. One logit cell is the
same number of pixels either way: the letterbox scales by the longer edge and
a permutation does not change which edge that is.

The two frames became a `Frames` struct rather than six parameters. This
function reads the picture twice for opposite purposes — the model needs it
upright or it recognises far less, the refinement needs the sensor's grid —
and a transposed pair produces a plausible mask over slightly the wrong
pixels, which is the failure this module is most prone to.

## It rebuilds the field, and the signature says so

`refine` is mixed into `subject_signature`. Unlike a feather, which is read
off a field that is already correct, this changes which pixels are in the
mask at all — so it changes the coverage the field is measured from. Omitting
it is the bug where the slider moves and nothing happens until some unrelated
control invalidates the cache.

That puts it in the same cost class as a close or an open, which is why the
row takes `SliderRow::changed` — already once-per-gesture, since that row
takes `SliderTrack`'s `committed` internally — rather than a live stream.

The slider is offered only where there is something to move: a category
source, *and* a refinement the frame actually gave enough to fit. A control
that moves and does nothing is worse than an absent one.

## Two defaults that are deliberately different

A layer added from the panel starts at 4.0, because a category's edges are
twenty proxy pixels wide before anything is done to them and a photographer
adding a sky mask wants the sky rather than the sky plus every chimney in it.

A layer read from a sidecar with no `refine` key starts at **zero**. A file
written before this control existed has to render as it did then, and a
default of 4 on absence would quietly re-grade every stored category mask in
the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off`
holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to
the scale — past the top of it every colour fails and the mask deletes
itself, which reads as lost work rather than as a bad file.

`MAX_REFINE` is dr-pipeline's own constant mirroring
`dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this
crate holds the description of an edit and must not depend on the crate that
runs a model. dr-ui is where the two meet, and the only place that converts.

Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline
and 60 dr-segment tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:40 +02:00
dtourolleandClaude Opus 5 d7b851f622 Build the People rail rows you can see, not the ones you cannot
`Flickable { VerticalLayout { for ... } }` instantiates every row. On the
reference library that is 14,268 row subtrees, which measures at 4.2
seconds before a pixel is drawn — and it was paid again on every action,
because every action reloads the model. Roughly half the wait between
clicking "Identity" and the screen appearing was this.

Slint's compiler has a virtualising path for a `for`; it is what makes
`std-widgets`' `ListView` cheap. It keys on the parent element's base
being *named* `ListView` and exposing the five lengths its layouting code
writes back, and a custom base is explicitly allowed. So `widgets.slint`
grows one, and `std-widgets` stays out of the file that establishes our
style. Measured on the real screen: 200,000 rail rows now render in 62ms.

Three things had to be true together, and each was silent on its own.

**The row height must be constant.** The rail hid a set-aside person with
`height: cond ? 52px : 0px`, a height that reads the model — so the
layout cannot place row N without building rows 0..N, and Slint builds
them all. The filtering moves to Rust, where the toggle was already
reloading anyway.

**The list must not be wrapped.** It carries its own stretch and preferred
size, having no natural height to offer; a Rectangle in between hands the
layout that Rectangle's constraints, which are taken from the list and
are therefore nothing.

**The panel must let it fill.** `Panel` lays its children out with
`alignment: start`, which gives each its preferred height — right for a
column of sliders, wrong for anything that scrolls. Hence `Panel.fill`.

Get any of them wrong and the rail renders empty, with no error and a
model full of people. All three were, in turn, before a headless render
of the real screen showed a blank rail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:30:16 +02:00
dtourolle 328fda6f7c Merge: mask a whole category, not just one instance
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	apps/darkroom-desktop/Cargo.toml
#	docs/traceability.md
2026-08-30 16:37:31 +02:00
dtourolle 80f0a0eeac Merge branch 'master' into feat/library-toolbar
# Conflicts:
#	docs/gestures.md
#	docs/traceability.md
2026-08-30 14:18:21 +02:00
dtourolleandClaude Opus 5 677146883f Decide what the header gives up when it cannot hold everything
Three faults, all invisible in the source and all found by looking at
the window.

`alignment: start` on the selection bar. Slint only hands space to
`horizontal-stretch` children under the default `stretch` alignment;
under `start` every child takes its preferred width and the stretch is
ignored without complaint. That was harmless while the bar held four
controls. Now that it holds every selection verb it is the difference
between a count that gives way and a row that can only scroll, so the
alignment goes and the stretch does its job.

The title collapsed to "…". An eliding Text has a minimum of nothing,
so once the buttons had taken their own minimums the title was the
cheapest thing in the row to give away — the window stopped saying
which library was open while the byte counts beside it stayed. It gets
a 90px floor.

And the status line does not get one. After the sidebar takes its 232,
a 1100pt window leaves this header about 868, and the buttons want
most of that before a character is drawn — so something must degrade,
and the order is the whole question. A floor here bought a readable
status by pushing Settings off the right edge, reachable only by
knowing to flick-scroll, which is precisely the fault the row's own
Flickable comment warns about. A control you cannot see is worse than
a sentence you cannot finish. The status line is also the most
redundant thing in the header — the sidebar states the library's count
and the filter chips state it again — so it is what gives, and it
grows back the moment there is room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:05:24 +02:00
dtourolleandClaude Opus 5 7f350e8c4c Offer the categories in the masks panel
Everything before this was reachable only from an example that writes PPMs.
This is the part a photographer can touch: a list under the subjects, click
one, get a mask layer for every pixel of that category.

## Under the subjects, and the order is the argument

Clicking the photograph is how a local adjustment usually starts, so the
things the model *found* come first. A category is the move you reach for
deliberately — grade the sky, not this one bird — and putting it second says
so without a word of explanation.

Coverage is shown for the same reason a subject's score is: it tells the
photographer whether a category is worth a click before they spend one
finding out.

## Not a new tab, which is what was asked for

A develop tab is derived from `Attribute`, not declared — the tabs exist
because operations claim an attribute, and no amount of Slint adds one. A
seventh attribute would have meant duplicating every adjustment once per
category, and the combinatorics get silly by the third.

Reached through masks instead, a category composes with every adjustment
that already exists, and inherits feather and falloff rather than needing
its own. What was described as "per-category sliders with feather and decay"
is exactly what this is; only the door is different.

## Verified as far as it can be here

Compiles, populates, round-trips, 560 dr-ui tests green. **Not clicked** —
synthetic input is blocked on this setup, so how it looks and feels is
unverified and wants a human at the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:34 +02:00
dtourolleandClaude Opus 5 d70dcf78d1 Merge: recover a damaged catalog, and capture a crash locally
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:45:18 +02:00
dtourolle c48896bd95 Merge: touch selection and drag, from the gallery-selection branch
Verified before merge: fmt clean, clippy -D warnings clean, 563 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 13:45:18 +02:00
dtourolleandClaude Opus 5 700e592b48 Correct the breakpoint note, which still counted six buttons
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:21:38 +02:00
dtourolleandClaude Opus 5 b34e786f01 Give the drag a pick-up, so it stops losing to the scroll
Dragging a photograph out of the grid worked about half the time, and
nothing on the screen explained the other half.

`DragArea` and `Flickable` do arbitrate, but not evenly. The Flickable
claims any press that travels more than eight pixels along its own axis
within half a second of landing, and holds that claim until the finger
lifts. So a drag toward the sidebar only ever began two ways: a flick
sideways clean enough that the finger never wandered eight pixels
vertically, or a wait of half a second before moving at all. Both are
real gestures and neither was written down.

The wait is now the gesture, and it has a mark. The long press that
already turns on selection mode also picks the photograph up: a ring
opens around the cell and the grid stops scrolling under it, so from
that moment the drag is the only thing the finger can be doing. The cue
can only arrive after the ambiguity has passed, which is the right way
round — when the photograph lifts, dragging it works.

Two details worth naming. The hold is now armed even when selection mode
is already on; it used to be skipped there, on the grounds that there
was no mode left to switch on — but that is precisely the state a
forty-image drag starts from, so the one gesture that most needed a
pick-up was the one with none. And the ring is drawn after the cell
loop rather than on the cell: z-order inside a `for` is loop order, so a
cell grown past its bounds would stand over two neighbours and be cut
off by the other two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:20:56 +02:00
dtourolleandClaude Opus 5 c7e1822f77 Put the two collection actions in the collections panel
"Keep offline" and "Change library" were buttons in the library
header, in a row that is otherwise entirely library-wide. Both refer
to the tree instead.

"Keep offline" could only ever mean the scoped collection, while
sitting nowhere near the tree that says which that is — and while the
sidebar already offered the same question twice, on each row's tray
and on a held row. It now sits under the tree, in the panel whose
selection decides what it acts on, in the same place and shape the
trash already gives Restore and Empty. It stays a labelled control
rather than being dropped for the tray: a 26px row's tray is a small
thing to hit, and "Kept offline" spelt out for the collection you are
looking at is the discoverable version.

"Change library" was wedged between Sync and Rescan, two buttons that
act on the library you already have. Reading it as one of that group
is a way to lose a scan by aiming badly. It is now the last thing in
the panel, under the tree it replaces wholesale, behind a rule.

On a tablet both are one tap further away, behind the sidebar toggle
that leads the header. That is the panel a user is already in when
they scope the grid to a collection, so it is where they are when
either of these becomes the thing they want.

The grid keeps `pin-done`/`pin-total` and its progress bar: the
transfer is worth reporting wherever it was started from, including a
row the grid is not scoped to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:19:53 +02:00
dtourolleandClaude Opus 5 f4fbabf18b Say what the grid is showing in one line, not four
The header carried four readouts beside the title: the image count,
the selection count, the scan's status and where in the library the
visible window sits. Each had `horizontal-stretch: 1`, which is what
actually lets a Text shrink in Slint — so between them they claimed
about a third of a 768px header and left the buttons to scroll off the
end of it.

The selection count goes to the bar at the foot of the grid, beside
the buttons that act on it. The other three are one subject and are
now one sentence with separators, sharing one stretch.

Two of them were also saying the same words while a sweep ran:
"indexing 300 / 12 480" appeared both as the status and, redundantly,
in place of the window position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:16:20 +02:00
dtourolleandClaude Opus 5 18063152f9 Ask where these photographs go once, not twice
The selection bar had "Add to collection" and "New collection" side by
side. Both answer the same question — where do these go? — and the bar
asked it before showing the list that decides it: a user who wants a
collection they already have and a user who wants a new one press
different buttons before either has seen what exists.

"New collection…" moves into the filing sheet, under the list of
collections. That is where you look after failing to find the one you
wanted, and it is the only place the choice can be made informed.

It also fixes the sheet's empty state, which said "Make one with + in
the sidebar" — advice that cannot be followed on a tablet, where the
sidebar is instantiated but not drawn. The first collection can now be
made from the sheet that noticed there were none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:14:47 +02:00
dtourolleandClaude Opus 5 8510a2f6a7 Give the selection one bar instead of two ends of the window
"What can I do with these twelve photographs?" was answered in two
places. Six buttons in the header — Add to collection, Keywords,
Presets, Paste to N, Export N, Remove from collection — and four more
on the floating bar at the foot of the grid, beside the count that
says what they would act on.

The header half was the worse of the two. Those six appeared and
disappeared from the middle of the row as photographs were picked, so
Sync, Settings and everything beside them slid several hundred pixels
sideways at the exact moment a hand was already travelling toward one.
On a tablet the header scrolls sideways, so they were often not on
screen at all.

All six move to the bar. It now reads left to right as shaping the
selection — Clear, Select all, Select to… — then acting on it, with a
gap between the two thoughts. The header keeps only what belongs to
the library, and changes only when the library does.

Two consequences worth stating. The bar holds ten controls in the
worst case, so it scrolls sideways like every other row in this view,
for the reason set out on the header's Flickable: a layout given less
width than its children need overruns rather than shrinking, and the
buttons past the edge are simply gone. And the bar now stays up for a
running export whatever the selection has since become, because Cancel
export lived on a button that used to have its own `|| exporting`
escape hatch — the grid's viewport inset follows the same condition so
the last row of thumbnails is never trapped underneath.

`settings-summary` went with them: threaded from the window into the
grid and into HeaderActions, and never once drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:13:11 +02:00
dtourolleandClaude Opus 5 6974604e84 Merge: say what each control is, so a screen reader can use one
NFR-A11Y-2 went from five accessible-* declarations in the whole
interface -- all five on the colour mixer's swatch row -- to
seventy-three, on twelve shared components and four screens. The one that
mattered is SliderTrack: the most-used control in the application, until
now unnamed, and now carrying a label, a formatted readout and
increment/decrement/set-value, so it is adjustable rather than merely
readable. Fixing the shared components covered library.slint's
twenty-seven buttons and eleven chips without editing that file at all.

NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr()
calls, about a tenth of the interface's strings. Slint's translate()
returns the original when no bundle is active, so a converted string and
a literal behave identically today and each remaining screen is an
independent commit.

Two accessibility defects are recorded rather than fixed, as TD-6 and
TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at
best) and rule reaches 3:1 on none. Fixing either re-derives the palette
beside a photograph, which wants a screenshot and an opinion.

Verified: fmt, clippy -D warnings, 563 tests including a new integration
test that walks the markup and fails on an unnamed control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:38 +02:00
dtourolleandClaude Opus 5 8e4e24ad09 Say what the drag payload actually is: the arming, not the cargo
`drag-payload` was documented as "called when a drag starts, so it
always reflects the selection as it is at that moment". Neither half is
true, and the comment is the only reason anyone would believe the drop
reads it.

`DragArea` tests `data.is_empty()` in its event filter — on every
pointer event, the first one included, which arrives long before there
is a drag. And a Slint binding that calls a callback has no dependency
to be invalidated on, so it is evaluated once, when a finger first lands
on that cell, and cached for the life of the cell. What it answers is
therefore always an empty selection.

None of the drop handlers read it; every one of them reads `dragging`,
which `drag-started` fills in at the moment that matters. What this
callback does is keep the `DragArea` armed, and it manages that only
because `set_user_data` is called unconditionally — an empty `Vec` is
still user data. Guarding that call, which reads as an obvious tidy-up,
would silently stop the grid dragging at all.

Comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:50:10 +02:00
dtourolleandClaude Opus 5 8848bbcff3 Stop drawing a hover ring on a device that cannot hover
A cell drew a 1px ring while the pointer was over it, in the same colour
as the 2px ring that means "selected". On a tablet there is no pointer,
and `has-hover` is not the harmless no-op that implies: Slint raises it
on a touch press and lowers it on the `Exit` that normally follows the
release — but the release that ends a pinch carries no `Exit` at all,
and neither does a finger lifting while a second one is still down.

So resizing the thumbnails, which is a pinch, left a ring around
whichever cell each finger had come down on. The grid then showed boxes
around photographs that were not selected, a pixel thinner than the ones
that were, with nothing to tell them apart.

Hover chrome has no meaning once there has been a finger, so it is not
drawn: the same `touched` latch the rating strip already keys off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:36:46 +02:00
dtourolleandClaude Opus 5 8eeb9ba0f6 Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and
none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree,
`Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing
else, and corruption therefore surfaced as whatever rusqlite error the first
unlucky query happened to produce — "database disk image is malformed"
attached to a thumbnail refresh, elided into a 34px banner, over an empty
grid saying "No images found · Check the library folder". Two messages that
disagreed, and no way forward but deleting catalog.sqlite by hand.

The property that makes the second offer real was already here and load-
bearing: the catalog is an index, not a source of truth, rebuildable from
sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and
lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL
database. What was missing was the check, the type, and the conversation.

Four pieces:

**The type.** `CatalogError::Corrupt`, and — the part that makes it worth
having — a hand-written `From<rusqlite::Error>` that classifies rather than
wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they
arise, so a background job that trips over the damage first reports the same
thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY`
deliberately do not: a dropped network mount is a different problem, and
telling someone to rebuild their index would be a wrong answer delivered
confidently.

**The check.** `Catalog::open_verified`, `quick_check` before the open rather
than after, because opening runs migrations and a damaged catalog with an
intact header would otherwise have structure rewritten on top of structure
that is already wrong. Bound to `open_verified` and not to `open`: the check
reads every page, which is affordable once at startup where a user can answer
a question, and not affordable on the dozens of opens a session's background
tasks make.

**The backup.** NFR-R2's second clause, taken between `configure` and
`migrate` in `Catalog::open`. A migration is the one routine operation that
rewrites table structure, so it is the likeliest way this file becomes
unreadable, and it is the last moment the pre-migration state exists to be
copied. Three generations, through SQLite's backup API after a TRUNCATE
checkpoint — never `fs::copy`, which on a WAL database backs up a state older
than the catalog and possibly torn. A failure to take the copy is logged, not
raised: a full disk must not be what makes a library unopenable.

**The conversation.** The first line of the dialogue is that the photographs
and the edits are safe, before the diagnosis, because that is the question the
user is actually asking. Then the two offers, which are *not* interchangeable
and are not presented as if they were: a restore keeps collections, and a
rebuild cannot, because a manual collection is a set of images assembled by
hand and nothing in the filesystem records it (docs/catalog.md §8.1). The
labels say so, and the rebuild does not take the affirmative styling while a
restore is on the table.

One thing that is a fix rather than a feature: `show_catalog_now` now gates
the scan. `Catalog::open` succeeds on a file whose header survived, so the
scan that used to start immediately afterwards would write folder ETags and
image rows into damaged pages in the seconds while the user was still reading
the question — turning a file that had a backup into one where the backup is
the only copy left.

Restore also deletes the damaged catalog's `-wal` and `-shm`. That step is
easy to leave out and fatal to leave out: a journal belonging to the old file,
sitting beside the new one under the same name, is replayed into it on the
next open. That is not a restore, it is a fresh corruption with the evidence
gone.

Tested by corrupting a fixture catalog — 500 images and a collection, then
every page past the second overwritten — and driving both branches. The
restore is asserted on the collection, because a collection is precisely what
distinguishes the two paths; the rebuild on the damaged file being kept and
the next open producing an empty catalog at the current schema. Plus the
`SQLITE_NOTADB` presentation, a damaged backup being refused rather than
installed, and a v1 catalog whose pre-migration backup comes back reading
v1 rather than v11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:34:12 +02:00
dtourolleandClaude Opus 5 46706fe622 Let the tool rail be pressed, not only read
The rail is the develop view's primary navigation and reached the platform as
four pieces of static text. The labels got through — a `Text` announces itself
— so a screen reader could read "Photo, Crop, Local, Repair" and had no way to
learn that any of them could be pressed, or which one was currently held. The
one control that decides what a click on the photograph does was a caption.

Each entry now declares itself a checkable button carrying the tool's own
word, with the held state reported rather than left to the fill. Checkable is
unconditional here, unlike `Button`'s: a rail entry is always a held-or-not
state, so an unheld one should say "not pressed" rather than pass for an
ordinary button.

The action repeats the click handler's expression rather than calling it. A
`TouchArea`'s `clicked` is raised by the pointer and cannot be raised from a
binding, so the alternative is a function wrapping two lines — and the
duplicated ternary sits four lines below the original where the two cannot
drift out of sight of each other.

Worth recording for the next control like this: `accessible-*` on an element
inside a `for` does work, despite the accessibility pass skipping repeated
elements. `process_repeater_components` runs first and moves the bindings into
a real component whose root is not repeated; what the later pass skips is the
empty placeholder left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:27:10 +02:00
dtourolleandClaude Opus 5 80298403f7 Name the launch screen's entries, and let its folder rows be pressed
A heading above a text field names it on screen and names nothing to the
platform: Slint associates the two only if something says so, and nothing did.
So the four entries a user signs in through — server, username, app password,
folder — reached AT-SPI as unnamed boxes with a separate piece of static text
floating above each. The word is now written twice, deliberately, and the
second copy is the one attached to the control being typed into.

FolderRow is the more consequential half. It is a Rectangle with a TouchArea
over it, which is a button to the user and a decorated box to the platform —
its label got through, because the `Value` inside is a Text, while the fact
that the row could be entered at all did not. The folder picker was therefore
readable and not navigable, which for the screen that chooses where the whole
library lives is the difference between using the application and not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:58 +02:00
dtourolleandClaude Opus 5 7409cb7767 Make the launch screen translatable, and say how the rest follows
`@tr(` appeared zero times in 14,482 lines of markup. Every string the user
reads was a literal, so NFR-A11Y-1 was not partly done or done badly — there
was nothing to extract and nothing a translator could have been given.

The mechanism turns out to cost almost nothing, and the reason is worth
stating because it decides the order of the work: a string written `@tr("Sign
in")` is already correct in a build with no translation at all. Slint's
`translate()` formats the original and hands it back when neither delivery
path is active, so a converted string and a literal are the same string until
someone writes a `.po`. That means the interface can be converted a screen at
a time rather than in one 14,000-line commit that nobody can review, and every
intermediate state is shippable.

So the delivery half is wired and left inert. `build.rs` asks for bundled
translations only once `lang/<lang>/LC_MESSAGES/dr-ui.po` exists — the first
catalogue anyone commits turns it on with no build-system change, and until
then a checkout with no `lang/` builds exactly as it did. Bundling rather than
the `gettext` feature because of Android: under ARCH §6.9's storage model
there is no path a `.mo` could sit at that the app can reach, and no C library
to link it against. The extraction command and the one flag that must not be
passed to it are recorded in the module docs.

The launch screen is converted whole: 32 calls covering every heading, button,
caption and placeholder. It goes first because it is the screen a user cannot
get past — an unreadable preferences page can be ignored, an unreadable sign-in
cannot. Four kinds of literal are deliberately left alone and the file says
which: the product name, example values whose shape is the message, `..`, and
the path separator.

Two things this leaves open, recorded rather than papered over. The headings
carry their own capitals, because `PanelHeading` draws what it is handed — so
a translator supplies "SERVEUR", not "Serveur", and styling in the string is a
real cost now paid rather than a surprise later. And `@tr()` is markup only:
the operation and parameter labels NFR-A11Y-1 names explicitly resolve in
`labels.rs`, in Rust, because the core may not depend on a localisation
library — those need a second mechanism, and it is not built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:16 +02:00
dtourolleandClaude Opus 5 8e4befa727 Say what each control is, so a screen reader can use one
The whole interface carried five accessible-* declarations in 14,482 lines of
markup, all of them on a single row of the colour mixer, and nothing said so.
Every other control — every button, every tick-box, every chip, and the slider
the develop panel builds thirty-six of for the mixer alone — reached AT-SPI
and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a
user in that position; it is whether the application can be used at all.

The annotations go on the shared components rather than on the screens, which
is the same argument widgets.slint was written to make one layer down: a
control named where it is used is a control unnamed everywhere it is used
next. Twelve components now declare a role, a name and — where the control
does something — the action assistive technology invokes to do it. Three
screens were touched, and only where the component could not know the answer.

SliderTrack is the one that mattered most and the one that could not be fixed
from inside itself. It is handed four numbers and knows nothing about what
they mean, so it takes a `label` and a formatted `readout` and every wrapper
passes down what it was already drawing. A test asserts that every
instantiation does, because a track added without one announces "slider, 0.35"
and looks perfectly correct in a screenshot.

It also gains increment, decrement and set-value. A slider that can only be
dragged is a slider a pointer is a modifier for, which is the objection
ui-navigation D-N2 makes about hover-only affordances with the argument run
one step further; these three are what a screen reader drives a slider with,
and they commit as well as change — one nudge is a whole gesture, so a caller
that persists on `committed` must hear about it.

Two decisions worth recording because the obvious alternative is wrong:

`active` on Button and IconButton is deliberately not announced from the
component. It says a toggle is on and says nothing about whether a control
that is *off* is a toggle at all, so announcing it would report every button
in the application as an unpressed toggle. The four call sites that mean a
toggle say so themselves, which Slint permits because the role is inherited.

SwatchSlider's row-level role is removed rather than kept. It was the one
control that had a label, and now that the track underneath it has one too the
two would nest — a slider inside a slider, the outer holding the value and the
inner holding the actions that can change it. The row stands down and hands
the same strings to the control that owns the gesture.

The test reads the markup the way darkroom-android's manifest test reads its
XML: there is no accessibility tree without a window, so what it defends is
the failure that actually happens — a role or a name lost in a refactor, which
compiles, renders identically, and is invisible to everyone not using a screen
reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:22:32 +02:00
dtourolleandClaude Opus 5 e22945a62d Tag the colour-independent status that was already built and unrecorded
NFR-A11Y-3 — no status conveyed by hue alone — read as untagged, and
outstanding.md said "no compliance work found". Both were wrong. Five places
already implement it, and four of them name the requirement in a comment
explaining the design; what none of them had was a TRACES line.

- `histogram.rs::percentage` states clipping as a figure and keeps `<0.1%`
  distinct from `0%`, so the text cannot say "none" while the marker beside it
  is lit.
- `histogram.slint`'s ClipReadout is the other half: a marker that appears and
  disappears rather than changing tint, and the figure next to it. Either alone
  reads.
- `library.slint`'s star strip is a solid star against an outline, differing in
  shape and luminance, over an achromatic palette.
- `library.slint`'s FlagMark is a tick against a cross, and a reject also dims
  its whole cell.
- `peaking.slint`'s colour chips say "Red" and "Cyan". A control for choosing
  between hues, presented only as hues, is unusable by exactly the person most
  likely to need it.

The tag is honest about being wider than the evidence, and outstanding.md now
records both gaps. Only the clipping clause has a test that would fail if the
behaviour were removed; the three Slint components are argued rather than
asserted. And the requirement's first named example — catalog colour labels —
has no interface at all: `label` is a nullable column nothing writes or shows.
That clause is untestable rather than satisfied, and closes when the label UI
is built with a shape from the start.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:19:48 +02:00
dtourolleandClaude Opus 5 ef07e6ca3e Give the canvas tools a rail of their own, and the column one width
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Crop, Local and Repair were chips at the head of the develop column, sharing a
row with the adjustment groups and told apart from them by the shape of their
highlight. Three things followed from that, and only the last is cosmetic: the
column closes, so the way out of a mode went away with the way in — hence the
duplicate "Done Cropping" over the canvas; the chips are generated from the
operation set, so the widest thing in the sidebar was a row nobody had chosen
the contents of; and a mode and a filter are different kinds of state wearing
one control.

They are a fixed 60px rail down the left now, generated from a single table in
toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant;
nothing in app.slint is touched to add one. What is left of the strip is the
group filters, so it is GroupStrip.

The column stops measuring itself. Every panel published a content-width and
declared it as min-width, and the column took the largest — which spent the
photograph's pixels on whatever happened to be widest, and moved the image
sideways when switching tools swapped one set of panels for another. It is
panel-width now, one number in style.yaml.

That number is 360 and it is measured, not picked: the contents report a
minimum of 344 in every mode, and they do not compress below it because a Text
that does not elide reports the same minimum as preferred. 320 was tried and
sliced Paste down the middle. The Flickable's viewport is floored at the
layout's minimum rather than its preferred width for the same reason — content
that is never told how much room it has cannot adapt to having less.

Removing the eight content-width declarations repairs three comments an
earlier edit had spliced sentences into. The raw histogram's note on keeping
its hint short is rewritten rather than dropped: an over-long hint no longer
widens the column, it pushes the column's minimum past the width it has and
clips the panel, which makes that constraint sharper rather than obsolete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:59:50 +02:00
dtourolleandClaude Opus 5 9e519eb8a6 Make the thin ring the only mark a selected photograph carries
Two treatments said "selected" and neither said it well.

A selected cell got a 2px border and a lifted fill. The border is drawn
on the outside of a cell whose content sits 6px in, so it ate into the
thumbnail: selecting appeared to nudge the photograph. Inside it, a
second thin ring marked the anchor — the end a shift-click measures from
— and that ring was the clearest thing on the cell, so it read as *the*
selection to everyone who had not written it.

Worse, the ring outlived what it described. An anchor survives a
deselection, so a thin box sat around the last photograph touched with
nothing selected at all, indistinguishable from a cell that had stayed
behind. That is the one thing a selection cue must never be: ambiguous
about whether something is selected.

So the ring is now what it already looked like. One mark, drawn inside
the cell and 4px clear of its edge, so it never touches the thumbnail and
never changes a dimension — selecting adds ink and moves nothing. Two
pixels rather than one, because it is now carrying the whole cue across
forty cells at arm's length against a thumbnail of any brightness. The
outer border is hover alone.

The anchor keeps no mark, and loses nothing it was earning: the bar says
"Tap the last photograph" while a range is armed, which answers the
question the ring existed to answer. The ordinal still lives in the
controller and still decides where a range extends from; what is gone is
the claim that the user needs to see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:47:32 +02:00
dtourolleandClaude Opus 5 b120ce48ec Float the selection bar over the grid instead of above it
Selecting the first photograph inserted a 40px row into the view's
vertical flow, so every cell in the grid moved down by it. The act of
selecting shifted the thing being selected out from under the finger —
and a second tap aimed at the neighbour landed on the row below it,
which is the worst possible response to a gesture whose whole job is to
say "this one".

The bar is a floating one now, at the foot of the view. Nothing above it
is re-laid out, so selecting changes what is drawn and never where.

The grid's viewport grows by the same 40px while the bar is there rather
than the Flickable shrinking, which is what keeps that change invisible
too: every cell stays exactly where it was and there is simply further to
scroll, so the last row can be brought clear of the bar instead of being
trapped under it.

It also swallows presses that land on it. A bar floating over the grid is
a bar a thumb can reach for and miss into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:47:20 +02:00
dtourolle f41edc03ff Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 09:24:37 +02:00
dtourolleandClaude Opus 5 1cd5ab6815 Put the gesture reference in the application
The document the previous commit generates is for somebody reading the
repository. The person who needs it most is holding a tablet, has just
discovered that a hold does something, and has nowhere to ask what else
does.

So the same scan writes a table the application draws: a "Gestures"
button beside Settings, a sheet with the same scrim and dismissal as the
ones that file and name, and every gesture grouped by where it applies
with its touch, pointer and keyboard routes side by side. Not the `why` —
that is the argument for the design and belongs in the document; on a
phone-sized card it would bury the one line the sheet was opened to read.

The sheet's file knows nothing about what a gesture is. It draws the rows
it is handed, and the rows come from the generated table, because a help
screen with its text typed into it is a second description of one
behaviour — and the second description is always the one that goes stale.
The commit before this deleted a gesture; a hand-kept sheet would still
be describing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:30:21 +02:00
dtourolle 4b212089d2 Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 00:25:42 +02:00
dtourolleandClaude Opus 5 df90dd95a2 Merge: a histogram that reads the sensor, beside the one that reads the frame
FR-CULL-3's other two bullets. What existed was a display histogram
tagged FR-DSP-7, counting AdjustPass's 8-bit output with r == 255
clipping counters -- it says a highlight is gone precisely where this
requirement needs it to say the highlight is recoverable.

The new reduction runs over the demosaiced scene-linear texture on a
stops-below-saturation axis: camera-native, unbalanced, unmatrixed,
uncurved, normalised by the sensor's own black and white levels, so 1.0
is saturation by construction. Four series, and the fourth is the
brightest channel rather than luma, because a weighted sum of unbalanced
values is a number about nothing. Cached per photograph, not per frame:
nothing downstream of the demosaic can move a count.

Both readings are legitimate and answer different questions, so the
panel offers a choice rather than replacing one with the other.

ARCH 5.5 is amended to match. It specified a pre-demosaic reduction;
retaining the CFA samples costs 48 MB at 24 MP and 120 MB at 60 MP
resident on every photograph opened, whether or not anyone looks at the
histogram, on the platform ARCH 6.2 exists for. The spec now records two
reductions, why the more complete one was not worth its cost, and what
the cheaper one cannot answer: it counts pixels not photosites, it
cannot see above white, and it is measured after the CFA pattern is gone.

Verified: clippy -D warnings clean, 98 dr-gpu tests, 556 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:14:34 +02:00
dtourolleandClaude Opus 5 31a3580f9d Extract the gesture vocabulary from the code that implements it
Every gesture the application has was documented in the comment beside
the `TouchArea` that implements it. Excellent comments, and unreachable
by anyone not reading the source — which is the FR-UI-4 failure in a
different costume: a gesture nobody can find is a feature only its author
knows about.

Writing them out again in a hand-kept help page is the failure this
avoids. Two descriptions of one gesture drift, and it is always the prose
that drifts: the code is exercised every time somebody uses the
application and the page is exercised never. A help screen confidently
describing a double tap the grid stopped honouring last week is worse
than no help screen — and the grid did stop honouring one, in the commit
before this.

So the comment beside the implementation stays the only copy, and a
`GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md`
for a reader, and a Rust table for the application to draw a help sheet
from. Both committed, both gated, so neither can quietly stop describing
the code.

It lives in the traceability crate because it is the same operation on
the same input — walk the tree, pull structured tags out of comments,
render, fail if the committed artefact has moved. Only the vocabulary is
new. It scans `ui` and `apps` alone: a gesture needs an interface to be
performed on, and excluding `tools` is also what stops the scanner
extracting its own worked examples as broken gestures.

Fifteen gestures so far, across the library grid and the People screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:49 +02:00
dtourolleandClaude Opus 5 20c368d3fc Give each touch gesture one meaning, and give tap-to-open back
I broke opening a photograph. The dwell added in "Tell a tap on a
photograph from a hand going past" required a finger to stay down 120 ms,
and a deliberate tap is routinely quicker than that — so the grid stopped
opening anything. Duration was the wrong discriminator: a tap and a brush
are the same length.

**Travel is what separates them, and a graze is by definition a moving
contact.** A press now records where it landed and the release compares:
within 12px it is a tap, beyond that the hand was going somewhere else.
No dwell, so no deliberate tap can be refused, and the rule is the same
for a finger and a mouse — one rule instead of two, and the `touch`
argument the dwell needed goes away with it.

Two real conflicts went with it, because a gesture set that overlaps
itself is unlearnable however each half is documented.

**A drag was also a hold.** Grabbing a cell and moving inside 450 ms left
the hold timer armed underneath the drag, so it fired mid-gesture and put
the grid into selection mode nobody asked for — the drag finished into a
mode that changed what every later tap meant. Starting a drag now cancels
it, exactly as a pinch already did.

**A double tap was also a range.** In selection mode two taps on one cell
selected everything back to where selecting began: no visible state, no
warning, from a thing a hand does by accident. "Select to…" does that job
and announces itself first, so the double tap is gone and two taps are
now two toggles that land where they started. `extend_to_row` went with
it — a second range implementation that only the double tap reached,
where every other range goes through `apply_press`.

The resulting vocabulary, one meaning each: tap opens, tap-and-slide does
nothing, hold starts selecting, drag files, two fingers resize, and while
selecting a tap only ever toggles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:37 +02:00
dtourolle 4b6c110816 Count the sensor's own numbers, so a cull can see headroom the render hides
FR-CULL-3's remaining two bullets. What existed was a *display* histogram
tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit
code value, and counts clipping as `r == 255`. Its own documentation says a
clipped bin means "a highlight that is actually gone rather than one the
transform might still recover", which is the opposite of what a culling
decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the
explicit grounds that a rendered image "systematically lies about what is
recoverable in the raw", and a readout that measures the render cannot answer
that however it is presented.

So this is a second instrument beside the first rather than a setting on it.
Both are true; they are true about different things; the panel offers both
behind a chip row and the words travel with the numbers, because a raw
saturation figure drawn under a heading saying Highlights would be mislabelled
exactly where the difference matters.

**What is reduced over, and what it cost to decide.** ARCH §5.5 specified the
pre-demosaic CFA samples. This reduces over the demosaiced scene-linear
texture instead, and §5.5 is amended to record the choice rather than let the
specification and the code disagree in silence. The texture is camera-native —
unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black
and white levels, so 1.0 is saturation by construction and the distribution
below it is the headroom question with no calibration to carry. Retaining the
CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently
drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether
or not anyone looks at the histogram, on a platform §6.2 exists because memory
is scarce on.

Three things it therefore cannot say, written into the module docs and into
§5.5 rather than left to be discovered: it counts pixels not photosites, so a
saturated site drags its interpolated neighbours up and per-channel clipping
is smeared by about a demosaic kernel; it cannot see above white, because
demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon
6D reads to 16383 against a declared 15070) so "at saturation" and "a stop
past it" share a bin; and it is measured after the CFA pattern is gone, so it
can name which colour clipped in the reconstructed image but not which
photosite went first.

The axis is stops below saturation, 16 bins per stop over 256 bins — the same
bin count the display reduction uses, so the fold into drawable columns is
shared and a divergence between the two plots would have to be deliberate. A
linear axis spends half its width on the top stop, which is why nobody has
ever drawn a useful linear raw histogram. The fourth series is the brightest
channel rather than luma: these values are unbalanced, so any weighted sum of
them is a number about nothing, and the brightest channel is the one that
saturates first and so the one the headroom question is actually about.

It is a property of the file and not of the render, which has two
consequences. It is computed once per photograph and cached — nothing
downstream of the demosaic can move a count in it — so a cull does not pay the
display histogram's per-frame cost three thousand times. And it describes the
whole frame rather than the visible region, deliberately opposite to
DevelopSession::histogram: a crop changes what is on screen and changes
nothing about what the sensor recorded.

Tags are on the reduction, the type, its constructor and the presentation
arithmetic, each of which has a test that fails if the behaviour goes. The
Slint panel and the push from lib.rs keep their reasoning as prose: nothing
asserts them, and a tag would claim coverage the assertions are not making.
2026-08-29 23:36:21 +02:00
dtourolleandClaude Opus 5 e5db849f04 Mark a burst in the grid, and let it be folded away
The counterpart to the grouping: where the signatures come from, and how a
group reaches a cell.

Signatures are computed from the 256px thumbnails dr-thumbs already holds --
vastly more resolution than a 9x8 reduction can use -- so a library that has
been browsed, or that has synced somebody else's shards, has already paid for
them and no RAW is decoded for this. The consequence is stated rather than
hidden: an image with no thumbnail gets no signature and never joins a burst.
That is self-correcting, and it is why the pass runs when the thumbnail sweep
finishes rather than on a timer. Nothing happens at import and nothing happens
at query time.

The mark is drawn as a child of the cell's TouchArea, for the same reason the
star strip is: a click on it must not also reach `cell-clicked` and throw the
user into develop, and children are hit-tested before the element they sit in.
It is never hidden on hover the way the stars are -- a collapsed burst stands
in for frames that are not on screen, and something has to say so whether or
not a pointer is nearby.

Folding changes what the grid's *query* returns rather than what its cells
draw, because the grid is a window over an ordered query and the frames a fold
hides are mostly not loaded. So the predicate joins VISIBLE in every query
that lists or counts cells -- the window, the header's count, the run a
shift-click resolves, and the ordinal a scrub lands on -- under the discipline
VISIBLE's own comment sets out: present in four places of five is worse than
absent, because the counts disagree with the cells and neither looks wrong on
its own. There is a test for exactly that.

`the_window_read_walks_the_ordering_index` now includes the burst clause. It
asserts on the query plan while holding its own copy of the query, so left
alone it would have gone on reporting green against a query the grid no longer
runs. If the clause costs `images_grid_order` and puts the sort back, that
fails here rather than becoming jitter someone measures in six months.

The pass keeps its own drain timer in a thread-local instead of taking fields
on the library controller, so everything the feature needs to run lives in one
file and the screen that starts it holds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:26 +02:00
dtourolleandClaude Opus 5 d0b671d4db Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:46 +02:00
dtourolleandClaude Opus 5 e465ff0c80 Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became
a selector term, and it was effectively unreachable. The only control
that could add a second person lived on the People screen, behind
selecting them there, and it appeared only once the grid was already
narrowed to somebody — so "photographs with both of them" needed a
two-screen round trip the user had to guess at.

A filter belongs on the filter bar. A "People" chip there opens a tray of
everyone the library knows; tapping a name adds or removes them, and the
any/all chip beside it — already there, and already the thing nobody
found — now has something to sit next to that explains it. The caption
leads the row so a pair of chips means something before either is
pressed.

The tray is a strip under the bar rather than a popup, the way the
develop column's film picker is: the view scrolls as one, so an inline
strip is taller content and not a second overlay to dismiss. It scrolls
horizontally for the same hard reason the bar above it does — a layout
cannot be narrower than its children's minimums, and forty people would
otherwise set the minimum width of the whole view.

The roster is built on open, not kept in step: indexing and regrouping
change who exists, and a list cached at startup would be stale for
exactly the user who has just been naming people. Named first, then by
how much of them the library holds — the catalog orders by face count
alone, which puts a dozen unnamed strangers ahead of the two people the
user actually cares about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 6d5de21fb7 Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel
was already answered — the Flickable claims the pointer and the press is
cancelled — but a contact that neither travels nor lasts reaches a
TouchArea as an ordinary press and release, and it was landing the user
in develop.

So a finger now has to stay down for `TAP_MIN_MS` before letting go
counts as opening anything. That is the floor under a tap where the 450 ms
`HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above
is a hold that starts a selection. One scale, three gestures.

Only a finger is held to it. A mouse click is a discrete decision made by
a button and is routinely over in thirty milliseconds, so `cell-pressed`
now reports whether a finger did it — the same finger-id convention the
pinch arbitration beside it already uses — and the dwell applies to touch
alone.

A graze still *selects* the cell it landed on, because the press already
did that. That is the right failure mode: something visible and
reversible rather than a silent nothing, and rather than develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 e40f2bfee9 Let go of the keyboard when a name is finished
Pressing Enter on a person's name committed it and then kept the field
focused, so on a tablet the on-screen keyboard stayed up over the faces
the user had pressed Enter to get back to. The name looked accepted and
the screen looked stuck.

`Field` grows `release-focus()`, the other half of the `take-focus()` it
already had, and the Identity screen calls it from `accepted`. A function
rather than a property for the reason the existing one gives: focus is an
event, and bound to a property it would fight anything else that took it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 f5edd49b6b Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.

Three pieces, because it could not be fewer:

`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.

Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.

The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.

`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:06 +02:00
dtourolleandClaude Opus 5 9d11554c71 Make the range gesture visible, and offer the whole grid at once
Touch has had a range gesture for as long as selection mode has: double-tap the
far end. It is invisible, it is unreliable on a grid that scrolls under the
second tap, and it extends from the anchor *before* the two taps moved it — a
rule subtle enough that the code needs two paragraphs to explain it to itself.
Nobody who was not told about it has ever used it.

"Select to…" is the same operation with state you can see. Press it, the strip
stops reporting and says "Tap the last photograph", and the next cell taken is
the far end. It reaches Rust as shift on `cell-pressed`, so it lands in
`apply_press` as the ctrl+shift it already is, and there is no third selection
policy to keep in step with the other two.

This is deliberately not the sweep gesture. A drag that paints cells can only
reach what is on screen, and the ranges that hurt on a tablet are longer than a
screenful — between the two taps here the user may scroll as far as they like,
and the run is resolved by the catalog rather than by what happened to be
loaded. A sweep is still worth having for short runs; it is not what this
should have rested on.

"Select all" beside it, asked of the catalog for the same reason: a select-all
that quietly meant "the hundred cells that happen to be loaded" is a lie the
user cannot see until the export runs.

The double-tap stays. It is tested, and an accelerator that costs nothing is
worth keeping for whoever has already learnt it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:01 +02:00
dtourolleandClaude Opus 5 d1f3b97245 Ask for the collection's name where the keyboard can reach it
"New collection from selection" created the collection under a placeholder
name and then opened the rename field in the sidebar tree.

On a tablet the sidebar is not on screen. It is instantiated all the same —
app.slint collapses it to zero width and `visible: false` rather than using an
`if`, because an `if` there is a layout loop Slint panics on — so the rename
field was created, its `init` took focus, and Android raised the on-screen
keyboard over a box nobody could see. Nothing else on the screen is focusable,
so the keyboard had nowhere to go: it stayed, the name could not be typed, and
the collection was already written under the name the user did not want.

Asked in a sheet instead, on the same card as the filing and keywording sheets,
before anything is written. That also fixes what was hiding behind it: an
abandoned rename used to leave a "New collection" in the tree, because the
collection existed before the name did.

`Field` gains `take-focus()` so a sheet whose field is the only thing to do in
it can answer the keyboard for the user — a function rather than a property,
because focus is an event and a bound property would re-take it on every
unrelated re-evaluation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:17:40 +02:00