Commit Graph
582 Commits
Author SHA1 Message Date
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 09dddde594 Take the photograph another app hands over, and hand an export back
FR-PLAT-AND-6 asks for two things this app did neither of: be a receiver for
image view and share intents, and share exported results out through a
FileProvider. The manifest declared one activity with one MAIN/LAUNCHER filter,
so nothing on the device ever offered DarkRoom for a photograph, and there was
no route out at all — Android has refused file:// URIs between apps since API
24, and a content:// URI needs a provider to be behind it.

Inbound. Three filters now: VIEW for a gallery or a file manager, SEND and
SEND_MULTIPLE for the share sheet, all on image/*. `android_main` reads the
launch Intent before it gives `app` away to Slint, and what comes back is
passed to `dr_ui::run` exactly as argv is on the desktop — `startup_action`
already treats a non-empty list as "the user asked for these specifically",
which is what a share is.

The URIs are copied into the cache before the viewer opens, and that cost is
real: a shared raw file is written once, in full, on the startup path. A
content:// URI is a handle into another app's provider, not a path, and the
decoders take paths; the alternative is teaching the whole read path about
URIs, which is FR-PLAT-AND-1's SAF connector and is not built.

Outbound. ExportProvider serves one directory — getFilesDir(), which is the
same path `internal_data_path` gives the Rust side — and refuses everything
else by canonicalising the request and checking it is inside that root, so
`../` and a planted symlink fail the same test. Not AndroidX's FileProvider,
because AndroidX is a Maven artefact and this build has no resolver; what it
does is a hundred lines and they are here.

The share half has no caller. The provider, the URI grant and the chooser are
all in place, but the control that would invoke them belongs in `ui/dr-ui`, and
wiring it needs an `AndroidApp` the interface can reach. It is documented as
unwired and deliberately not tagged as covering the requirement.

`launchMode="singleTask"` comes with the filters and is not decoration: another
app can now launch this activity while it is running, and the default mode
answers that by creating a second NativeActivity in the same process — a second
android_main, a second Slint backend, a second wgpu device. The cost of the
fix is stated in the manifest: a share arriving while DarkRoom is already open
brings it forward without opening the image, because onNewIntent has no route
through android-activity's event stream.

The Java is Java because Android constructs it: a ContentProvider is
instantiated by the system from its manifest entry, and getIntent() exists only
on an activity object. Both directions live there rather than in JNI so that
what crosses the boundary is two method signatures instead of forty, each of
which is a string checked at run time and nowhere else.

What a test can hold: the declarations. Nothing about an Intent or a
ContentProvider is reachable from `cargo test`, but an intent filter that is
deleted takes the app out of every "open with" menu silently, and an authority
that stops matching its class raises a SecurityException inside somebody else's
app. The tests in lib.rs read the manifest and ExportProvider.java through
`include_str!` and hold both to that, on the host, which is the only place in
the workspace that looks at either file from Rust.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:32:48 +02:00
dtourolleandClaude Opus 5 846a249156 Drain the queue that nothing has ever drained
`jobs` has been a complete durable work queue since the catalog was
written, and nothing has ever taken a job out of it. `claim_next`,
`complete`, `fail` and `recover_orphaned` had no callers outside their own
tests; `enqueue` had three. So the table grew one row per photograph and
kept it forever, and FR-PLAT-AND-3's resumability was a property of code
that never ran.

`runner` is the missing half. It owns no thread, no clock and no policy,
and that is the whole design: on Android the process does not decide when
background work may run. WorkManager does, subject to Doze, battery saver
and FR-NC-6's network constraints, and it revokes permission mid-job by
calling onStopped(). So the runner exposes `run_one` — claim, run, record —
and `drain`, which repeats it against a budget, a deadline and a
cancellation flag the host owns. A `Worker.doWork()` with ten minutes calls
drain with a deadline; a desktop idle pass calls it with none. That is the
seam the Android service plugs into, and it needs no Android to test.

Handlers are supplied from above, because the catalog knows what needs
doing and nothing about how: a thumbnail needs a decoder and a fetch needs
a network stack, neither of which belongs under core/dr-catalog. A runner
claims only kinds some handler declares, so a queue holding work this
device cannot do is left alone rather than failed five times.

Four outcomes, and only two of them are the job's fault. Done deletes the
row; Retry backs off; Abandon gives up now, for a failure no retry can fix;
Interrupted releases the claim with its attempt refunded and ends the
drain, because the host stopped rather than the job — five backgroundings
in a row must not mark good work as failed. Process death is the fifth and
cannot report itself, which is what `recover` is for.

Recovery is called from `show_catalog_now`, which is the one place a
catalog is opened for a session and already returns early if one is open.
It has to be exactly once and before any worker starts: there is no owner
column, so a second pass while a worker held a claim would take it away.
The attempt a dead claim consumed is deliberately kept — a job that takes
the process down with it is indistinguishable from one that fails, and the
attempt counter is the only evidence that survives a death.

The tests cover claiming under contention twice over: sequentially across
two connections, and with four threads on four connections against one
catalog on disk, asserting every job ran exactly once. Plus completion,
backoff, giving up, abandoning, interruption, budget, deadline,
cancellation, and a job orphaned by a simulated crash being reclaimed and
run once rather than lost or repeated.

Not wired to a handler yet, and deliberately not: the only enqueue site
the app actually reaches is the remote scan's, whose thumbnails are already
served by the async grid worker, and `walk`'s two sites are reachable only
from the scan_local example. Inventing a handler to make the plumbing look
used is how a requirement comes to read as covered by code that does not
implement it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:31:47 +02:00
dtourolleandClaude Opus 5 d63872e5a9 Make a claim one statement, and give the queue what a runner needs
The claim was a deferred transaction around a SELECT and an UPDATE, and
under a single connection that is fine. Under two it is not what it looks
like: the SELECT takes only a read lock, the UPDATE tries to upgrade, and
in WAL a worker that read the same snapshot as another gets
SQLITE_BUSY_SNAPSHOT on its write. That is not an error a busy handler can
retry away — the fix is to roll back and start over — so the queue was
"safe" only in the sense that the loser failed loudly instead of taking a
job someone else was holding.

`UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = (SELECT ...)
RETURNING ...` is one statement and so one implicit transaction that takes
the write lock immediately. Two workers serialise, the loser waits out its
busy timeout, and neither can see a row the other already holds. The
existing tests are unchanged by it, because from one connection the two
forms are indistinguishable — which is exactly why it was never noticed.

The rest is the surface a runner has to have and did not:

- `claim_next_matching` takes only kinds a worker can actually do. Without
  it a device with no connector claims `FetchOriginal`, fails it, and pays
  five wakeups and five backoffs per photograph to reach a conclusion known
  before it started. Filtering after a claim cannot work: the claim has
  already marked the row running.
- `abandon` gives up now, for failures no retry can fix. `fail` uses it for
  its own MAX_ATTEMPTS branch, so there is one statement that ends a job.
- `release` hands a claim back with its attempt refunded, for a worker that
  is being stopped rather than a job that is going wrong. `attempts` stands
  in for the owner column the table does not have: it is bumped by every
  claim, so a stale worker's release matches nothing and changes nothing.
- `reap_orphan_subjects` deletes jobs whose photograph is gone. Coalescing
  keeps the table one row per unit of work and nothing ever shrank it when
  the work stopped existing. `ScanFolder` is excluded because its subject
  is a folder id, and joining that against `images` deletes by coincidence
  of numbering — hence `JobKind::subject_is_image`, and `JobKind::ALL` so
  the next kind added cannot quietly fall out of the filter.
- `counts` is the number a foreground service's notification is built from.

One behaviour change worth stating: a kind this build does not recognise is
now parked with an error rather than read as `ExtractMetadata`. The old
`unwrap_or` would have run a job of an unknown kind as some arbitrary known
one, which is worse than not running it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:31:27 +02:00
dtourolleandClaude Opus 5 d70f5c14e0 Name rust-analyzer everywhere the toolchain is installed
Build and test / Desktop (Linux) (push) Failing after 1h18m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 9m41s
Build and test / android-image (push) Successful in 9m41s
Traceability / Requirement traces (push) Successful in 48s
Build and test / Android (aarch64) (push) Successful in 21m54s
VS Code's rust-analyzer extension ships a server newer than 1.92.0
supports and prompts, on every window, to add one. Listing the component
in rust-toolchain.toml makes rustup supply the server matching the pin —
the same thing the pin buys everywhere else.

The cost lands outside the editor, which is why this is four files
rather than one. rustup reconciles that component list against the
installed toolchain on the first cargo call in the work tree and
downloads what is missing, inside whatever job happens to be running. An
unasked-for fetch in the middle of a build step is nobody's line item
and hard to find in a log. So every environment that builds this repo
names it too: baked into the Android image, and in the install step of
each CI job that rolls its own toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 23:21:44 +02:00
dtourolleandClaude Opus 5 2955cad654 Regenerate the matrix over a night's merges
Coverage 59.8% to 67.0% (120/179). Eight of those came from tagging what
was already built; the rest are tonight's features -- FR-CULL-3's peaking
half, FR-CULL-5, FR-PLAT-AND-5.

FR-PLAT-LIN-3 and NFR-COMPAT-2 stay untagged deliberately. Both were
satisfied by a manifest and a document, and there is no source file to
hang a tag on that would fail if the behaviour went away.

This is also the first commit tonight the pre-commit hook has checked.
Every branch used --no-verify, because the hook runs the traceability
build and the branches were under a no-build rule; the matrix each of
them left untouched is regenerated here, once, over all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:21:44 +02:00
dtourolleandClaude Opus 5 7418b040da Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 085ab766b3 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 55cb9b5b30 Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".

It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:26 +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 5226f7223b Group the frames of one moment, by when they were taken and what they look like
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.

Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.

Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.

Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.

Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.

A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.

It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:46 +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 24bf5be574 Compile our own Java into the APK, so the classes Android constructs can exist
The APK's only dex was Slint's. `assemble-apk.sh` found `classes.dex` under
the android-activity backend's build directory and copied it in, and there was
no `javac` step and no `d8` of anything of ours — package.sh's header said so
outright, on the reasoning that the app has no Java because android-activity
calls `android_main` directly.

That reasoning holds for everything the app *calls* and fails for everything
Android *constructs*. A `ContentProvider` is instantiated by the system from
its manifest entry; nothing in the process ever reaches its constructor, so
there is no JNI route to writing one in Rust. The launch `Intent` is the same
shape of problem from the other end: it arrives through `Activity.getIntent()`,
and the activity android-activity hands out is a stock `NativeActivity` rather
than a subclass with room for code. FR-PLAT-AND-6 needs both, and FR-PLAT-AND-4
needs a foreground `Service`, which is a third.

So: everything under `apps/darkroom-android/android/java/` goes through javac
against `android.jar`, and d8 merges the classes with Slint's finished dex into
one `classes.dex`. Merging rather than emitting a second dex keeps the staging
and zip steps as they are — multidex is native at API 28, but two files to keep
in step buys nothing at this size.

The step is skipped when the tree holds no Java, which is the state this commit
leaves it in. Nothing about the APK changes until a `.java` file appears.

`-source 8 -target 8 -bootclasspath android.jar` is not caution about language
features. It is the last combination in which javac allows the boot class path
to be replaced: from `-target 9` the flag is rejected, the platform classes
come from the JDK instead of from android.jar, and the build stays green while
the device raises `NoClassDefFoundError` for a class Android never shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:18 +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
dtourolleandClaude Opus 5 a2a0131693 Merge: face grouping the photographer can tune, people on the filter bar, and a tap that means it
Four fixes to the identity work, and one to the grid.

The name field lets the keyboard go when a name is finished, instead of
leaving it up over the faces the user pressed Enter to get back to.

Filtering to two people at once has worked since people became a selector
term and was unreachable behind a two-screen round trip. It is a tray on
the filter bar now, where filters are.

The merge probability and the smallest group the clusterer will call a
person were constants tuned on one library. They are settings, edited
beside the Regroup button that applies them, with a read-only preview
that answers what they would do to *this* library.

And a hand brushing past a photograph no longer opens it: a finger has to
stay down long enough to have meant it, on a scale between the graze and
the hold that starts a selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:53:13 +02:00
dtourolleandClaude Opus 5 b9891e2c04 Merge master into tablet-selection
Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:52:42 +02:00
dtourolleandClaude Opus 5 23c74d7063 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 22:32:26 +02:00
dtourolleandClaude Opus 5 a4b9deaf96 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 22:32:10 +02:00
dtourolleandClaude Opus 5 2403f355d6 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 22:31:45 +02:00
dtourolleandClaude Opus 5 bbbc23f376 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 22:31:22 +02:00
dtourolleandClaude Opus 5 e027d34b65 Regenerate the matrix over a night's merges
Coverage 59.8% to 67.0% (120/179). Eight of those came from tagging what
was already built; the rest are tonight's features -- FR-CULL-3's peaking
half, FR-CULL-5, FR-PLAT-AND-5.

FR-PLAT-LIN-3 and NFR-COMPAT-2 stay untagged deliberately. Both were
satisfied by a manifest and a document, and there is no source file to
hang a tag on that would fail if the behaviour went away.

This is also the first commit tonight the pre-commit hook has checked.
Every branch used --no-verify, because the hook runs the traceability
build and the branches were under a no-build rule; the matrix each of
them left untouched is regenerated here, once, over all of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:22:01 +02:00
dtourolleandClaude Opus 5 82d9d077b0 Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library
FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and
live for Nextcloud roots, the SAF cause it names does not exist yet.

FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the
same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a
Service or a FileProvider. The container has JDK 17 and build-tools 36;
the build step is what is missing.

Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043
tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud,
dr-plat and dr-ui. The aarch64 target was checked before the branch was
finished but not after; no device was available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:46 +02:00
dtourolleandClaude Opus 5 75fd5619ca Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:38 +02:00
dtourolleandClaude Opus 5 2b812ebe21 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:29 +02:00
dtourolleandClaude Opus 5 6246a2e346 Merge: group the frames of one moment, and let a burst fold away
FR-CULL-5. Frames join a burst when they are adjacent in time and look
like the frame before them -- both, because time alone groups a whole
ceremony and similarity alone groups a studio setup across two days.
Adjacent pairs only, chained; there is no all-pairs step and there must
never be one.

No selection of any kind. The representative is the earliest frame, a
fact about the clock rather than a judgement about the photograph, and a
newly found burst arrives open, so the pass never takes a row off the
screen.

Verified: fmt, clippy --workspace --all-targets -D warnings, 346
dr-catalog tests, 511 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:07:05 +02:00
dtourolleandClaude Opus 5 fc4157a1e0 Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".

It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:07:05 +02:00
dtourolle 91fe6cf300 Merge master into partial-preset-scope
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Desktop (Linux) (push) Failing after 1h17m12s
Build and test / Layer separation (push) Successful in 56s
Traceability / Requirement traces (push) Successful in 1m33s
Build and test / Android (aarch64) (push) Successful in 1h0m38s
# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/ui/app.slint
2026-08-29 22:02:03 +02:00
dtourolleandClaude Opus 5 754ab91347 Bring a Lightroom library across, and start with something in the list
Two halves of the same complaint: a preset sheet that opens on "No
presets yet" is homework, and a photographer with ten years of presets
in Lightroom has no way to bring them.

`dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be
mostly a rename rather than a conversion, because Adobe and this
pipeline already agree: exposure is in stops in both, and contrast, the
four recovery controls, clarity, texture, vibrance and saturation are
all ±100 in both. That is not imitation, it is the convention raw
developers converged on — `highlights_shadows.yaml` cites it in as many
words. Only sharpening needed arithmetic, Adobe's 0…150 against our
0…100.

The white balance does not come across, and says so rather than
guessing. Adobe writes absolute Kelvin for a raw file where ours is a
relative nudge from what the camera recorded, so converting needs the
*target image's* as-shot white balance — exactly what a preset cannot
carry, since the same preset lands on a frame shot at 3200K and one shot
at 7000K. A guess would be wrong on most images and invisibly so.

A folder is read as readily as a file, nested, because that is the shape
an exported preset folder is in and importing ninety files one at a time
is asking someone not to bother.

`dr_pipeline::starter` is six presets a first run begins with, written
against this pipeline in its units and deliberately mild — a starting
point, not a caricature. They are seeded when the library *file* does
not exist rather than when the library is empty, so deleting all six
does not hand them back on the next launch.

Both of these name operations, and `ui_names_no_operation` was right to
stop them living in `ui/`. That test exists because the failure is
silent and cumulative, and it caught exactly what it was written for: a
preset called "Punch" is a statement about contrast, clarity and
vibrance, and a table mapping Adobe's vocabulary to ours is a statement
about the pipeline. Neither is a fact about an interface. So the starter
set went into `dr-pipeline`, and the importer into its own crate —
between two walls, since `dr-pipeline` depends on nothing on purpose and
XMP is real XML not worth hand-rolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:00:53 +02:00
dtourolleandClaude Opus 5 36ea53cceb Merge: stop a subject's name from setting the width of the develop column
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:49:40 +02:00
dtourolleandClaude Opus 5 3e19628324 Stop a subject's name from setting the width of the develop column
The column moved sideways the moment segmentation finished. It takes the
widest minimum any panel declares, each panel publishes
`layout.preferred-width` as that minimum, and `MaskPanel` gained rows
whose width came from the model's output -- so a photograph the user was
looking at jumped because a label said "traffic light".

`overflow: elide` did not prevent it and was never going to. Eliding is
what a `Text` does when it draws; at layout time it still asks for the
width of its whole string, and it is the asking that reaches the column.

Both the subject rows and the mask entries are bounded, because a mask is
itself a segmentation result -- without the second half the column moved
when a subject was clicked instead of when one was found.

The bound is stated for the reason `ChipGrid` declares its width from its
column count rather than from its options (ff6c313): what a panel asks
for must follow from its structure, never from its data. 160px is the
same judgement as that commit's 88px chip, and it is a policy rather than
a measurement -- there is no harness in the tree that measures a panel's
width, and the 482-to-351 figure in ff6c313 was read off the running app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:49:33 +02:00
dtourolle 4a04496c78 Merge: focus peaking, so a frame can be judged without zooming to 100%
FR-CULL-3's peaking half. The raw histogram and raw clipping indicators
remain unbuilt -- what exists is a display histogram tagged FR-DSP-7,
counting AdjustPass's 8-bit output, which reports a highlight as gone
precisely where FR-CULL-3 needs it to report the highlight recoverable.

Verified before merge: fmt clean, clippy --workspace --all-targets
-D warnings green, 11 focus GPU tests, 79 baseline dr-gpu tests, 511
dr-ui tests. The cfg(target_os = "android") arm is unverified -- the
host-target clippy never compiled it.

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

# Conflicts:
#	ui/dr-ui/src/lib.rs
#	ui/dr-ui/ui/app.slint
2026-08-29 21:43:35 +02:00
dtourolleandClaude Opus 5 2168cdd1c4 Mark what is in focus, so a frame can be judged without zooming to 100%
FR-CULL-3's focus peaking. One compute dispatch measures local contrast
in WGSL and writes an overlay texture; on desktop it reaches Slint
through the same zero-copy wgpu import the canvas uses, so nothing
per-pixel touches the CPU on the frame path.

With peaking off the cost is zero and structurally so: focus_overlay
opens with `let settings = self.peaking?;` before the frame is touched,
and clearing drops both overlay textures, so no VRAM is held either.

NFR-P14 is met by construction rather than by measurement -- one
dispatch, no second render, no pipeline compile after session open, and
a test asserting allocations stay at 2 over eight frames. The budget
test asserts 50ms at 4K rather than a tight bound, deliberately: a tight
bound fails on a loaded machine and gets deleted, which is worse than a
loose one that still catches the regression that matters.

TD-1 is amended rather than joined by a TD-6: on Android the overlay
rides the readback that already exists there, roughly doubling that
transfer while peaking is on, and TD-1's own "Done when" removes both
because both are the same missing capability.

Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings
green, which also compiles peaking.slint through dr-ui's build.rs; 11
focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass.
Not verified: the cfg(target_os = "android") arm, which the host-target
clippy never compiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:43:08 +02:00
dtourolleandClaude Opus 5 7ad75dd905 Let a manual collection be put in the order the photographer wants
`collections::set_order` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever
called either. A manual order existed, could not be seen, and could not be
set. This is the half that was missing.

Three pieces, because it needed all three to be visible at all.

The catalog gains `orders_manually` and `members_in_order`. The first is
the rule about *when* a manual order means anything, kept in one place with
one name: a collection must be manual, and it must have no children. A set
shows its descendants' images, and positions are only ever assigned within
one collection — so two children's positions are unrelated integers, and
ordering by them would sort the grid by a coincidence. The existing comment
on `read_cells_scoped` already argued this; now something enforces it.

`members_in_order` returns the *whole* membership rather than the filtered
view, because `set_order` renumbers exactly what it is handed. Reordering a
filtered list would renumber those and leave every hidden image on a stale
position — two images sharing one, and a grid that rearranges itself the
moment the filter comes off.

The grid reads position where the scope qualifies and capture time
everywhere else. Manual order joins the member row rather than testing
membership with `IN`, which is safe from fanning out rows *because* that
branch is a single collection.

The gesture is a DropArea over the viewport, drawn only where a reorder
means something, with a caret in the gap the photographs would go into —
a line between two images rather than a highlight on one, because lighting
up a cell would say the drop replaces it.

The trap worth naming: `DropEvent.position` is in **window** coordinates.
Slint maps it through `map_to_window` when the drag begins and hands every
target the same event untranslated, so a target inside a Flickable has to
subtract its own `absolute-position`. Getting that wrong is invisible until
the grid is scrolled, because at the top the two frames coincide.

`reordered` is pure and names its destination by the image it goes before
rather than by an index, because the grid can only name a gap in what it is
showing and the ids are what survive a window swap. A drop that changes
nothing returns the order untouched: that counter is what a cross-device
merge resolves by, and spending a revision on a no-op makes this device win
an argument it did not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:49:00 +02:00
dtourolleandClaude Opus 5 b52be080ff Merge: say which requirements the code already satisfied, and which it does not
Eight requirements were implemented and untagged; tagging them takes
coverage from 59.8% to 64.2% without a line of feature code. Five more
were refused rather than tagged -- R2's own acceptance criterion still
reads '(figure TBD)', R5 has one of three clauses built, FR-RAW-2 meets
the requirement's purpose but not its stated mechanism.

docs/outstanding.md records what is genuinely unbuilt, so the gap reads
as a decision rather than an oversight. It also names four requirements
the matrix reports as covered that are not, including two covered only
by string literals inside the traceability tool's own test fixtures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:46:08 +02:00
dtourolleandClaude Opus 5 4d0ad0601c Merge: a Flatpak that asks for no filesystem, and the channels v1 ships through
FR-PLAT-LIN-3 and NFR-COMPAT-2. The manifest grants no filesystem
permission of any kind, which is not an oversight: a folder library is
chosen by typing an absolute path, nothing in the tree calls the
FileChooser portal, and a static --filesystem= grant would have made
that design appear to work by not testing it. docs/distribution.md
records what a portal-based picker would take.

No Flatpak has been built -- flatpak-builder is not installed here --
so the finish-args set is reasoned from what the binary links, not
observed to be sufficient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:46:02 +02:00
dtourolleandClaude Opus 5 115653a262 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 20:39:51 +02:00
dtourolleandClaude Opus 5 bb35665bd2 Let a paste carry some kinds of edit and not others
FR-DEV-6 asks for presets "covering a subset of the edit graph". What
landed with the named presets covered two subsets: everything, and
everything but the crop. "Match the colour but not the sharpening" had
no way to be said.

`Scope` is now a set of `Attribute` — the same six kinds every operation
already declares and the develop panel already builds its tabs from. The
photographer ticking "tone and colour" is naming the groups they
navigate by, and neither this module nor the interface has to name an
operation to do it (FR-DEV-3c).

The pleasing part is what left. Framing used to be excluded by an
explicit test against one operation's id; it is now excluded because
Geometry is not in the default set. The special case dissolved into the
general rule, and the argument for it — a crop is a decision about *this*
photograph, and carrying it across forty destroys forty compositions —
is now a statement about a kind of edit rather than about a node. All
thirty-three existing preset tests pass unchanged, which is the evidence
that the generalisation kept its promises.

One decision that is a field rather than a rule, because the two cases
genuinely differ. An operation this build cannot classify — from a newer
version, arriving over sync — travels under "everything" and "everything
but the crop", because those are claims about the whole edit and an
unrecognised operation is part of it (FR-NC-8). It does not travel under
a hand-picked set, because that is a claim about kinds, and an unknown
kind is not one of the kinds that were ticked.

The settings page's "Copy crop and rotation" checkbox is gone, replaced
by the same chips the preset sheet draws. It asked the right first
question — geometry is the kind whose accidental travel destroys work —
but it was the only question a boolean could ask. The field stays in
`Settings`, read exactly once to seed the new set, so anyone who had
ticked it keeps their behaviour.

The chips are deliberately not in the develop column. Six of them there
would set the width of the whole sidebar, which is the bug `ChipGrid`'s
comment records at length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:38:19 +02:00
dtourolleandClaude Opus 5 d3dadbd725 Group the frames of one moment, by when they were taken and what they look like
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.

Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.

Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.

Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.

Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.

A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.

It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:37:54 +02:00
dtourolleandClaude Opus 5 8d5dc423ea Say that all three of FR-CULL-3's bullets are unbuilt, not one
The first draft got this half right and half wrong. It correctly said the
existing histogram and clipping indicators are not FR-CULL-3's, but it
described them as adjacent — as though the work left were mostly focus
peaking and a relocation.

They are not adjacent. The histogram reads AdjustPass's 8-bit output and
counts clipping as r == 255, so it describes the frame the display is
about to show, after the entire develop chain. FR-CULL-3 asks for the
histogram of the sensor data, and gives its reason in the requirement
itself: a rendered image "systematically lies about what is recoverable in
the raw". A readout taken from the render cannot answer that question
however it is presented, which means two of the three bullets need a new
measurement rather than a new placement.

Found by the agent building focus peaking, who had to go looking at the
counters to find out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:36:51 +02:00
dtourolleandClaude Opus 5 1cd58bcaaa Say where a range ends in words, instead of hiding it in a double tap
Touch has had a range gesture for as long as selection mode has: hold to
enter it, then double-tap the far cell. Nobody finds it. It is invisible,
it is unreliable on a grid that scrolls under the second tap, and it
extends from the anchor as it was *before* the two taps moved it — a rule
subtle enough to need two paragraphs of Rust to explain itself.

The deeper problem is the timer. A double tap is bounded by the double-tap
interval, so the two ends have to be on screen together. The ranges that
actually hurt on a tablet are longer than a screenful, and those are
exactly the ones it cannot describe — which is also why a drag-to-select
sweep would not have fixed this, and why it is not what went in.

So: a "Select to…" button arms the range, the strip stops reporting and
starts instructing ("Tap the last photograph — scroll first if you need
to"), and a Cancel puts it down. Nothing is timing the two taps, so the
user may scroll as far as they like between them, and the run is resolved
by the catalog rather than by what happens to be loaded.

An armed range reaches Rust as `cell-pressed`'s shift argument, because
that is what it is: `apply_press` already reads ctrl+shift as "add the run
from the anchor to here", and in selection mode ctrl is already set. So
this needs no Rust state and no third selection policy — one copy of the
rules, in the function that had them.

"Select all" goes in beside it, asked of the catalog for the same reason:
the window is a hundred cells over a library of thousands, and a select-all
that quietly meant "the hundred that are loaded" is a lie the user cannot
see until the export runs. It sets the anchor to the first frame, or a
"Select to…" straight afterwards would reach `apply_press` with no anchor,
take its shift branch, and clear everything it had just taken.

The strip is at four buttons and a count now, so "New collection from
selection" loses its tail — the sheet it opens already says "New collection
holding 12 photographs" across the top.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:31:38 +02:00
dtourolleandClaude Opus 5 ab49280614 Ask for the collection's name before there is a collection to name
"New collection from selection" created the collection under a placeholder
name and then opened the sidebar tree's rename field to correct it.

On a tablet that field is not on screen. The collections panel is always
instantiated — 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 TextInput was created all the same, its `init`
called `self.focus()`, and Android raised the on-screen keyboard for a box
nobody could see. Nothing else on the screen is focusable, so the keyboard
had nowhere to go back to: it stayed up, 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, scrim and dismissal the filing
and keywording sheets use. The field takes the keyboard as the sheet
appears — over a field that is actually drawn, which is the whole
difference — and the card sits a third of the way down rather than centred,
because on a tablet the keyboard is the bottom half of the window.

Nothing reaches the catalog until Create. That also ends a second bug the
old order could not avoid: an abandoned rename used to leave a collection
called "New collection" behind, because creating came first.

`Field` grows a `take-focus()` for this. A function rather than a property:
focus is an event, and bound to a property it would fight whatever took
focus next and re-take it on every unrelated re-evaluation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:31:02 +02:00
dtourolleandClaude Opus 5 0d9f802f88 Regenerate the matrix that this branch is about
Eight requirements gained a tag in this branch's first commit, so the
generated matrix is stale until it is rerun. Coverage moves from 59.8%
(107/179) to 64.2% (115/179), and the untagged list drops from 72 entries
to 64.

Regenerating here rather than leaving it to the pre-commit hook, because
this branch's subject *is* the matrix: a reader comparing the two commits
should see the number move for a stated reason. The eight are R3, R6,
FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and NFR-SEC-3, and
none of them is new work — every one was already satisfied by code that
had simply never said so.

Six of the thirteen tag sites were new `TRACES:` lines rather than
additions to existing ones, so the tag count moves 896 → 902 while the
covered count moves by eight. Orphan tags remain at zero.

Run from source with `cargo run -p traceability -- report`, never from a
prebuilt binary, and note that the tool tracks line numbers — the six
prepended module tags shift every subsequent line in their own files,
which accounts for the churn in this diff that is not a coverage change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:26:07 +02:00
dtourolleandClaude Opus 5 88ef7148d6 Write down what is not built, so the gap reads as a decision
The traceability matrix reports one number and cannot say what it means.
An untagged requirement is either one nobody built or one somebody built
and did not label, and both look identical in the summary table. Eight of
the second kind were tagged in the previous commit. This is the first
kind, written out with the reasoning, so that the distance between the
register and the binary is something a reader can see rather than
reconstruct from a percentage.

Eleven clusters, each verified against the tree rather than inherited
from a survey. Several turned out to be more interesting than "not done":

Plugins are 21 untagged requirements — nearly a third of the shortfall —
and §7 already lists the Plugin API as out of scope for v1 while §3.10
spends 280 lines specifying it. The two that *are* built, FR-PLG-2 and
FR-PLG-2d, are the declarative operation format, which is a plugin system
that resolves at build time. The fix here is an edit to requirements.md,
not code, and D16 has to be answered before any format is called stable.

FR-DSP-2 is not merely unbuilt; it is under challenge. frame_budget.rs
and TD-4 independently argue that tiling costs more than it saves on the
interactive path, so the open question is whether the requirement should
survive, not when it will be met — and the spike that would settle it, S6,
has not run.

NFR-A11Y-1's hard half is done and its easy half is not. LocalizedKey
already keeps display strings out of core/ and labels::resolve is a single
resolution point; what that point does is a hardcoded English match, so a
translation needs a recompile, which is the one thing the requirement
forbids.

The §4.1 performance targets are unverified rather than unmet. §8 requires
a per-commit benchmark suite whose regressions fail the build; there is no
benches/ directory, no criterion, and no benchmark step in any of the
three CI workflows. The one guard that does live in CI skips itself
without a GPU adapter and asserts its CPU half only in release, while CI
tests in dev. Nothing says the targets are missed. It says nobody would
find out.

And three requirements are reported as covered while not being met: R1 and
NFR-OPS-1 are tagged only by string literals inside the traceability
tool's own tests, which it scans along with everything else, and
FR-CAT-13's single tag sits on keyword storage while no XMP is parsed or
written anywhere. CONTRIBUTING already warns that a tag proves a tag
exists; these are the specific ones.

Focus peaking, burst grouping, Flatpak packaging and the Android platform
integration are being built in parallel and are marked in progress rather
than listed as absent, so those lines can be struck as they land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:24:26 +02:00
dtourolleandClaude Opus 5 8165619065 Describe the project the README is actually in front of
It said the status was "early", that v0.1 is a remote library viewer, and
that the zero-copy display path was "not yet working" and its replacement
was the highest priority. All three were true when they were written and
none of them has been true for eight releases.

The zero-copy line was the damaging one, because it is the project's
central architectural constraint and the README stated the opposite of
what happened. Desktop keeps the zero-copy path — the compute pass writes
a texture Slint composites directly — and the readback survives in exactly
one place, the Android develop view, because wgpu's Vulkan swapchain tears
a portrait window on a tablet whose panel is mounted landscape. That is
TD-1, with the on-device measurements and the three things any one of
which would remove it. A reader who took the old text at face value would
have gone looking for a bug that was fixed, and missed a compromise that
was chosen.

"Current state" now says what runs, checked item by item against the tree
rather than from memory: the first draft claimed AVIF and JPEG XL export,
which the settings page offers and dr-export refuses on purpose, and
sixteen declared operations where there are fifteen. Both are the kind of
error this commit exists to remove.

The requirement count moves from 122 to 179, the documentation table
gains the five documents somebody would actually want next, and the
"Not built" paragraph points at docs/outstanding.md rather than leaving
the reader to infer the gap from silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:23:35 +02:00
dtourolleandClaude Opus 5 ab0ef6a26d Say which requirements the code was already satisfying
Thirteen requirements were surveyed as built but untagged. Eight of them
were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and
NFR-SEC-3. Each was read against its full text in requirements.md and
against the code before the tag was added, because a tag that is wrong is
worse than an absent one — it turns a visible gap into an invisible one.

The five that were refused, and why, because the reasoning is the part
worth keeping:

R2 carries "(figure TBD)" in its own acceptance criterion and asks for a
stated prefetch margin and cache-hit rate; neither figure exists anywhere
in the tree and neither quantity is measured, while TD-2 and TD-3 both
describe the thumbnail path falling short of it.

R5 asks for three things and the code does one. The display pipeline does
run at viewport resolution, but "only visible tiles are computed" and
"panning recomputes only newly exposed tiles" need a tile scheduler that
does not exist — and frame_budget.rs currently argues for striking tiled
computation from the interactive path rather than building it.

FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can
be added without changing callers. What exists is free functions over
&[u8]. That meets the requirement's stated *purpose* — the same decoder
serves a local file, a SAF document and a byte range, which is exactly why
it takes bytes — but there is no trait and no second implementation seam,
so the requirement should probably be amended rather than tagged.

NFR-ARCH-1 asks for named executors with stated thread counts.
architecture.md §7.1 states the table; nothing implements it. Workers are
twenty-odd ad-hoc std::thread::spawn sites, each building its own
one-worker tokio runtime, with no decode pool, no GPU-submit executor and
no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an
outcome with no stated means", and that is still true.

NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence
has no module to tag. A tag would point at nothing.

NFR-OPS-3 was the closest call of the eight taken. The store is single,
separate from the catalog, survives a catalog rebuild and does not sync
between devices; it has no version *field*, deliberately, and
settings.rs argues why and names the condition that would need one. The
substance is met and the reasoning is recorded where it belongs.

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