Commit Graph
1017 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 1d38015a7b Put git-lfs in the Android image, which never had it
Build and test / Desktop (Linux) (push) Successful in 1h21m48s
Build and test / Layer separation (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 10m40s
Build and test / android-image (push) Successful in 10m40s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 33m49s
The Android job has never fetched the model. Not because of the header
collision the desktop job hit -- that one is fixed and the desktop job
now pulls all 11 MB -- but because the image has no git-lfs at all:

    git: 'lfs' is not a git command. See 'git --help'.

The fetch step dies on its first line, `git lfs install --local`, before
any of the auth handling runs. The build then panics in dr-segment's
build script with a message telling you to run `git lfs install && git
lfs pull` -- advice that could not have worked, because the client it
names was never in the image to run.

Both jobs failing their fetch step at the same time made this look like
one bug with one cause. It was two, in two different images, and the
desktop one was noisier: it had a client, so it got as far as an HTTP
error worth reading. The android one had nothing to say beyond the name
of a missing command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 06:52:50 +02:00
dtourolleandClaude Opus 5 56bdd457dd Stop the test build filling the runner's disk
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Build and test / Desktop (Linux) (push) Successful in 1h58m2s
Build and test / Layer separation (push) Successful in 2m3s
Traceability / Requirement traces (push) Successful in 31s
Build and test / Android (aarch64) (push) Failing after 22m34s
The desktop job died mid-link with LLVM reporting "IO failure on output
stream", which reads like a compiler crash and is not one: underneath it
is `No space left on device`. The runner ran out of disk while linking.

Worth knowing what it was spending it on. `target/debug` was 24 GB
against `target/release`'s 2.6 GB -- the test build is roughly ninety
percent of the footprint -- and of that, 15 GB was debug info in
`debug/deps` and 3.6 GB was incremental state. Neither buys anything
here. Nothing attaches a debugger to a CI run, and incremental
compilation exists to make the second build in a working tree fast,
which is not a thing a fresh checkout ever has.

With both off the same tree is 3.3 GB, `debug/deps` 2.8 GB, and the test
binaries build unchanged. Backtraces keep function names and lose file
and line numbers; if a failure ever needs those, DEBUG=1 gives line
tables back for a fraction of the 15 GB.

A `df -h` either side of the expensive steps, so the next time this
happens it says so in one line rather than as an error from LLVM.

This is a mitigation and it should not be mistaken for the fix. It bounds
what this job asks for; it cannot help if the runner is full of anything
else, and 24 GB of build output is not obviously the largest thing on a
host that also keeps every cached target directory this workflow has ever
saved. If it fails here again, the disk needs looking at on draco-x86.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:13:32 +02:00
dtourolleandClaude Opus 5 834b219c3f Publish the Android APK as a build artefact
Build and test / Desktop (Linux) (push) Failing after 1h12m6s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Successful in 23s
🐳 Android image / Build and push (push) Successful in 10m59s
Build and test / android-image (push) Successful in 11m2s
Build and test / Android (aarch64) (push) Failing after 22m52s
The android job proved the app links for aarch64 and then threw the
result away. There was no APK anywhere in CI and no upload step in the
repo at all, so a green run left nothing anybody could install -- the
artefact list was empty by construction, not by failure.

It now assembles the APK with the script package.sh uses and uploads it.
The .so comes from the build the API-level check already ran; -o only
adds a copy of it where the packaging step looks, so this costs one copy
rather than a second twenty-minute cross-compile.

The signing key is the part worth being careful about. KEYSTORE points at
a mktemp directory rather than its default under target-android, because
that directory is precisely what actions/cache saves and restores -- the
default would have written a private key into the build cache and kept it
there. Nothing but the .apk is uploaded. A fresh debug key each run is
the right trade for an artefact meant to reach a test device: the only
thing a stable key buys is installing over a previous build without
uninstalling first, and a key that survives in cache storage to buy it is
a bad exchange.

if-no-files-found: error because the failure being guarded against is a
green run with an empty artefact list, which reads as success right up
until somebody goes looking for the file.

Debug-signed, arm64-v8a only -- the ABI the job already builds. Neither
is a release story; this is a build you can install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:38:20 +02:00
dtourolleandClaude Opus 5 5741ec5e00 Lift the APK assembly out of package.sh so CI can run it too
package.sh does two things: it decides how the host reaches the image, and
it assembles an APK once inside it. Only the first half is host-specific.
CI already runs in that image, so the second half was about to be copied
into a workflow step -- two copies of aapt2/zipalign/apksigner ordering,
drifting apart at whatever rate the toolchain moves.

So it moves to docker/android/assemble-apk.sh, which assumes it is inside
the image and takes its paths from the environment, because the callers
disagree about them: the container mounts the repo at /work, the runner
checks it out wherever it likes. Every default reproduces what package.sh
did, so the host path is unchanged.

Two things stop being hard-coded on the way. The build-tools version and
the compile SDK are resolved from what is installed rather than written
out as 36.0.0 and android-36 -- the versions are Dockerfile ARGs, and a
second copy is a second thing to miss when they move. --min-sdk-version
now comes from that same ARG instead of a literal 28, which is the number
the API-level check in CI already reads.

The intermediates are removed at the end. They were harmless in a cache
directory nobody looks at; beside a published artefact they are four more
files for a glob to pick up by mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:38:08 +02:00
dtourolleandClaude Opus 5 8b07a90e71 Regenerate the traceability matrix the clippy pass moved
Build and test / Desktop (Linux) (push) Failing after 1h12m3s
Build and test / Layer separation (push) Successful in 28s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 24s
Build and test / Android (aarch64) (push) Failing after 25m11s
`traceability-check` fails on master: the committed matrix does not match
what the generator produces, so the gate that exists to keep the two in
step is the thing reporting they are not.

Nothing was traced or untraced. Coverage is 51.4% (91/177) before and
after, the same 604 tags against the same 177 requirements; every one of
the 32 changed rows is a line number that moved when the clippy warnings
were cleared -- `adjust.rs:2150` is now 2164, 632 is 651, 751 is 770.
The matrix links to lines, so touching a file above a tag rewrites its
row without changing what it says.

Regenerated with `cargo run -p traceability -- report`, which is what the
failing step tells you to run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:21:13 +02:00
dtourolleandClaude Opus 5 606f85df34 Send the LFS object endpoint one Authorization header, not two
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h12m2s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 31s
Build and test / Android (aarch64) (push) Failing after 36m17s
The model fetch has been failing on every run with

  LFS: Client error: .../info/lfs/objects/0672d7a7...

which reads like a rejected credential and is not one. The object is on
the server and downloads fine; what fails is the shape of the request.

`git lfs pull` makes two calls. The first, to `/info/lfs/objects/batch`,
succeeds -- and Gitea answers it with a short-lived `Bearer` JWT scoped
to that one object, for git-lfs to use on the second. git-lfs sends that
JWT *and* the `Authorization` header this step had installed in git
config, and two `Authorization` headers is a 400 from Gitea. Hence a
client error on the object one step after the batch call it just made
successfully, which is what made this look like an auth problem rather
than a duplication.

Confirmed directly against the server: the JWT alone on that URL is a
200, the JWT plus any second `Authorization` is a 400, and a lone token
header that is merely wrong is a 401 -- so the scheme was never the
issue. `lfs: true` on the checkout fails the same way and for the same
reason, because actions/checkout persists a header of its own; the
comment here blaming a credential the endpoint would not accept was
wrong on both counts.

So the headers are stripped -- checkout's included, since nothing later
in either job talks to the remote -- and the token is handed to git-lfs
as an ordinary credential instead. It authenticates the batch call and
leaves the per-object JWT alone.

This is what fails the Android job today: the build script sees a
133-byte pointer and panics by design, which is the message it is
supposed to give and the one nobody could act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:55:26 +02:00
dtourolleandClaude Opus 5 56978fdf35 Clear the clippy warnings that were failing CI before this branch
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 9m6s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 23s
Build and test / Android (aarch64) (push) Failing after 22m38s
Nothing here is film simulation. These are lints that fail master today,
under the -D warnings CI runs with, mostly from a toolchain that learned
new ones rather than from anybody's code -- `is_multiple_of` and the
derivable `Default` did not exist as lints when this was written.

They are fixed rather than allowed, and by hand rather than by trusting
`cargo clippy --fix` wholesale: its automatic pass split a derive in two
and left a stray blank line, which is the sort of thing that is correct
and still wrong to commit.

The four that needed a decision rather than a rewrite:

  - The distance transform's inner loop writes through its iterator now.
    `q` stays, because it is the position the parabola is evaluated at as
    well as the index it is written to -- the lint is about the write.
  - `to_source` and `to_proto` take `self` by value. Their receiver is
    `Copy`, so this is the same machine code and the honest signature.
  - The export path's return type is five levels deep and now has a name,
    plus a line saying why the `Option` wraps the `Result`: `None` is
    cancellation, which is not a failure and has no error to report.
  - A test fills a range instead of looping over one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:35:02 +02:00
dtourolleandClaude Opus 5 3b5952769b Emit floats an f32 can hold, and drop the format! that formats nothing
CI runs cargo fmt --check and clippy -D warnings, and this branch had
never been through either. Both would have failed it.

The bulk was the generated colour tables: eight significant figures where
an f32 carries about 7.2, so the eighth is noise that rounds away at
compile time and clippy's excessive_precision says so 109 times over.
Fixed in the generator rather than only in the file, so it stays fixed --
and the file is trimmed in place rather than re-derived, because
regenerating it needs a colour-science stack that has nothing to do with
the defect.

The format! in the composer is mine too, from extracting the rendering
tail: the braces in it were escaped because the text used to live inside a
larger template, and once extracted the escapes are noise and the call
formats nothing.

Also here, and clearly not mine: an unused import and a shadowed binding
in dr-gpu, and an unused import in a test. They are pre-existing --
clippy has been failing on master before this branch existed, on lints
like is_multiple_of that arrived with a toolchain rather than with
anyone's code. Fixed because CI cannot go green around them, and called
out because a merge commit is a bad place to quietly edit someone else's
crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:28:14 +02:00
dtourolle 6925aa2a86 Merge master into film-simulation
🐳 Android image / Build and push (push) Successful in 0s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 59s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Successful in 1m1s
Build and test / Android (aarch64) (push) Failing after 22m44s
Master gained the library's index paging while this branch was building
the film simulation, and the two met in library_ui.rs. Only the generated
traceability matrix conflicted; it is regenerated here rather than
hand-resolved, which is what it is for.
2026-08-25 20:34:28 +02:00
dtourolleandClaude Opus 5 6d18517d28 Ship every stock that exists, black and white included
Three profiles was what the first cut needed to prove the model. This is
the rest of the open data: 23 camera stocks and 9 papers, which is all of
spektrafilm.

Black and white was the gap, and it turned out not to be a gap in the
data -- it was a gap in where I looked. Upstream's `main` has 28 colour
profiles and nothing monochrome; `dev` has three more, and they are
Tri-X, Double-X and the 2302 print film they go onto. So the answer to
"do we have B&W" was yes all along, and it needed the dev branch rather
than a fortnight digitising Ilford's datasheet graphs by eye. Those three
are pinned to `dev` per stock; the colour stocks stay on the released
branch.

A monochrome profile is single-channel -- one emulsion, not three -- and
spreading that one layer across all three is exact rather than an
approximation: three layers with identical sensitivity and identical
curves respond identically, which is what one layer does. The dye is the
trap. The renderer *sums* the three layers' contributions, so replicating
it unchanged renders every frame three times too dense -- neutrally, and
therefore plausibly. A third each reconstructs the single emulsion, and
two tests hold both halves: that the densities stay equal, and that they
sum to one emulsion and not three.

Double-X and 2302 ship five curves apiece, measured at five development
times -- 4 to 12 minutes for Double-X. That is push and pull processing as
measured data. The standard 6.5 minutes is what ships; the rest is in the
upstream file waiting for a control to ask for it.

Two stocks are `support: film` and are nevertheless what a negative is
printed *onto*: the cine projection films 2383 and 2393, which the
Vision3 stocks print to. Filtering the picker on support alone offered a
projection stock as something to load in a camera, so it filters on stage,
with a test saying so.

The picker had to change shape twice over. Chips were right for three
stocks and off the edge of a 280px column at twenty-four, and the column
that replaced them was a thousand pixels standing between the
photographer and every slider below. It is a disclosure now: one row
carrying the answer, opened to change it, closed again on choosing. That
is the opposite of the argument this panel used to take the lids off its
sliders, and deliberately so -- an instrument you compare wants to be
visible, and a list you consult once wants to be out of the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:30:11 +02:00
dtourolleandClaude Opus 5 3330f350a4 Move the timeline marker from the window the grid already read
Build and test / Desktop (Linux) (push) Failing after 1m1s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 22s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 22m19s
The last of the per-scroll queries, and the strangest of them: this one got
slower the further down the library you had scrolled.

The marker has to follow every scroll event or it advances in jerks while the
photographs beside it move smoothly — that part is right and stays. What was
wrong is that each event asked the catalog `LIMIT 1 OFFSET n`, and that is not
a seek: SQLite reaches row `n` by producing and discarding the `n` rows before
it. 0.02 ms near the top of the library, 0.7 ms at twenty thousand, per row
crossed, on the thread drawing the frame. A flick therefore got choppier the
longer it went on.

The window the grid has already read holds the answer, and since the loaded
window now covers the whole view, the row is nearly always in it. So this is a
vector index at the position the ordinal has in the window, and the query
survives only as the fallback for a row outside it — briefly, after a scrub or
a keyboard jump, before the load lands.

The fallback is also the less correct of the two, which is worth recording
rather than quietly keeping: it counts in a dated-only ordering while the
argument is a grid row, so the two disagree wherever undated frames sit in
between. It is kept because a marker about to be corrected is not worth a
second index, and because being wrong there is what it always did. The window
path has no such disagreement — it reads the very cell the row belongs to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:01:51 +02:00
dtourolleandClaude Opus 5 0f231afe85 Stop re-answering questions about the library every time the window moves
The timeline's bars, the filter chips' counts, the "on this device" count and
whether the scoped collection is pinned all describe the *library*. None of
them can change because the view scrolled. `load_window` recomputed all four
every time the window moved, which is several times per screenful.

Together that is a `MIN`/`MAX`, a `GROUP BY`, two counts, and under a
collection three more queries — about 8 ms of SQLite on the thread that is
trying to draw the frame, for four answers that were already on screen and
already right.

They are now keyed on what they actually depend on: the scope, the filter,
whether this is the trash, and the total. The total earns its place as the
change detector as much as for the scrollbar — a scan landing, a delete or a
restore all move it, and it was already read on every load.

What a total cannot see is a rating edited under an unchanged count. That is
covered, and deliberately not by widening the key: `apply_judgement` already
refreshes the chips itself, because it has to report what actually landed
rather than what was asked for. Same for the axis — a zoom, a pan, a scrub and
dates arriving from the thumbnail worker each call `refresh_timeline`
directly. Skipping the recompute here cannot leave anything stale on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:01:39 +02:00
dtourolleandClaude Opus 5 6d6ef8d34b Page the grid along an index instead of sorting the library each time
Scrolling jittered, and this was the largest single reason. Every window the
grid loads is `ORDER BY ... LIMIT n OFFSET k`, and neither half of that was
being answered the cheap way.

**The sort.** `GRID_ORDER` leads with `captured_at IS NULL`, so undated frames
fall to the end. No ordinary index answers that — the leading term is an
expression, not a column — so SQLite sorted the whole library into a temp
b-tree on every window read, then threw away the first `k` rows of it. Schema
V7 indexes the expression exactly as the query writes it, partial on the same
`shadowed_by IS NULL AND trashed_at IS NULL` the grid filters by, so the read
becomes a walk along the index.

**The join.** `LEFT JOIN remote` was paged *after* it was joined, so reading
280 cells at offset 20,000 first seeked into `remote` for all 24,000 rows and
then discarded 23,720 of them. The file ids are now fetched for the 280 rows
that survived — the shape the badge and rating reads already use, one query for
the window rather than one per cell.

Measured together on 24,000 images at offset 20,000: **15.2 ms → 0.36 ms**,
inside a scroll handler that has 16.7 ms to draw a frame.

The test asserts on the query plan rather than on a duration, because there is
no other symptom. A `GRID_ORDER` edited out of step with the index, or a column
added back that drags `remote` in again, both still return exactly the right
cells — just after sorting the library — and the jitter would come back with
nothing to point at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:01:04 +02:00
dtourolleandClaude Opus 5 baa8957e80 Let a photographer choose the film, and remember which one
The stock model rendered correctly and nothing could ask for it. This is
the picker, and the sidecar key that makes the choice outlive the session.

How the choice persists was the open question, and the answer was already
written down twice in sidecar.rs: `rating` is a top-level key "because a
rating is not an edit", and `masks` are one "because a layer is not a
scalar". A stock is that kind of thing -- a choice of material, not a
number a slider moves -- so it is a top-level key too.

It stores the **id**, not an index. Stocks are files that users add, so an
index would mean installing a profile silently changed which film every
existing photograph had been developed on. A name this build has no
profile for still round-trips untouched, because the alternative is that
syncing to an older phone quietly un-develops the picture.

Only the names travel. Turning one back into tables needs the profile
database, which dr-pipeline deliberately does not link, so `Version::apply`
clears the film and the session re-bakes -- after the parameters, because
the bake reads the film's own exposure sliders and the print balance is
solved against them. That is also why moving those sliders rebuilds the
lookup where no other control in the panel does: an enlarger's filtration
depends on how the negative was exposed.

The panel keeps its rule. It still names no operation and still generates
every control from a declared parameter kind; the stock gets a bespoke
control beside those, exactly as the mask stack does, and for the same
reason. The film's exposure and print exposure arrive as ordinary
generated sliders.

Two defaults worth stating. Picking a colour negative prints it, because
an unprinted one is an orange strip and offering that as the first thing
somebody sees after choosing Portra reads as a bug rather than as a
choice -- the toggle is there for anyone who wants the scan. And a paste
carries no film: a preset is a parameter map, and a stock is not a
parameter, so pasting one would paste a choice the clipboard never took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:57:41 +02:00
dtourolleandClaude Opus 5 4a2fcb6d22 Render a film stock on the GPU, and let it take over the rendering
The stock model landed in dr-film with no way to see it. This is the
pipeline node, the two texture bindings it reads, and the end-to-end test
that proves the shader agrees with the model.

The design point is that a film simulation is not an adjustment. Every
other node changes a picture; this one makes it. A stock's characteristic
curve does the camera profile's base curve's job -- from measurements
rather than from a curve somebody drew -- so running both renders the
scene twice: the camera's rendering, and then a film's rendering of that.
It looks like neither, and it reads as a colour-management bug with no
colour-management bug to find.

So `Operation::renders` is new. A node declaring it takes camera RGB and
hands back linear sRGB, and the composer emits neither the base curve nor
the conversion out of camera space. Both halves move together, and the
composer keeps them as one string precisely so that getting half of it
right is impossible.

The tables are not parameters, for the reason vignetting's coefficients
are not: they are measurements. dr-pipeline declares the layout as a plain
struct and keeps its no-dependency property; the two crates share no types
on purpose. `EditGraph::set_film_tables` offers them to every node rather
than to the one that wants them, because knowing which concrete type is
which is what the graph is organised not to know.

Bindings 4 and 5 follow the masks precedent: declared unconditionally so
one bind group layout serves every generated shader, bound to 1x1
placeholders when no stock is loaded. Both are interpolated by hand with
textureLoad -- this pipeline binds no sampler, and adding one for two
lookups would cost a binding in every shader. Uploads are keyed on content
so an unchanged stock does not push half a megabyte across the bus per
frame.

The end-to-end test earned its place immediately: it found the density
lookup being filled z-fastest while a 3D texture upload wants x-fastest,
so the red and blue axes were transposed. Green matched exactly, which is
what that bug looks like -- a plausible photograph of the wrong colour,
and one that every unit test on either side of the seam passes. dr-film
now pins the layout in a test that needs no device, and states it where
the field is declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:12:56 +02:00
dtourolleandClaude Opus 5 b6a95e1965 Simulate a film stock from its measurements, not from someone's grade
FR-DEV-3f asks for look emulation and proposes HaldCLUT import to inherit
the free film-simulation ecosystem. This takes the other road for the
stocks where the measurements exist: run the physics.

A stock here is its manufacturer's own datasheet -- spectral sensitivity,
characteristic curves, dye densities. Light exposes three emulsion layers,
the layers develop to densities, the densities are dyes that absorb, and
what is left is what reaches the eye. A colour negative comes out orange
and upside down because that is what a colour negative is; it becomes a
photograph when a paper profile prints it, with the enlarger's filtration
solved rather than dialled.

What that buys over a LUT is that the parameters stay physical. Opening up
a stop moves the picture along the film's real characteristic curve,
shoulder and all, instead of scaling a number baked at one exposure. The
data cost runs the other way too: a stock is 17 kB of published
measurements where one HaldCLUT is 800 kB of one person's grade.

It looks like it needs a spectral integration per pixel. It does not, and
that is the whole design:

  - Exposure is a 3x3 matrix. The reconstructed scene spectrum is linear
    in the sRGB triple, so the integral collapses into nine numbers,
    exactly -- no approximation.
  - The characteristic curve is three 1D functions, sampled exactly.
  - Everything after that -- dye absorption, the print through the
    negative, the paper, the viewing illuminant, the adaptation -- takes
    exactly three numbers in, so it bakes into one 32^3 lookup.

Per pixel: a matrix multiply, three curve taps, one fetch. Splitting the
curve out of the 3D lookup rather than baking one LUT over exposure is
measured, not assumed: the curve carries the sharp shape and the dye
mixing is smooth, so folding them together would need three times the
resolution for the same error. At 32^3 the worst error is 0.003 in linear
sRGB, under one 8-bit code value, and a test says so.

No wgpu dependency, deliberately, and the same isolation argument dr-lens
makes: the model is plain f32 with a documented layout, so every property
worth asserting is asserted on the CPU. Binding it to a texture is dr-gpu's
job and is not done here yet.

The expected values in tests/ came from a Python prototype running against
a different colour-science stack. Agreement to three decimals is evidence
about the model rather than about one implementation of it -- a transposed
matrix or a mispasted observer row would pass every unit test and fail
that one.

Profiles are converted from spektrafilm by Andrea Volpato, CC BY-SA 4.0.
The converter is in the tree and runnable, so what was changed from
upstream is auditable rather than taken on trust; profiles/CHANGELOG.txt
records it, including the one deliberate deviation -- Mallett & Yuksel's
1 kB basis instead of Hanatos's 4 MB table, which costs accuracy at the
gamut edge and saves four megabytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:44:54 +02:00
dtourolleandClaude Opus 5 940058c78a Keep git-lfs's own hooks, now that the hook path is ours
🐳 Android image / Build and push (push) Successful in 0s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 8m57s
Build and test / Layer separation (push) Successful in 25s
Traceability / Requirement traces (push) Successful in 22s
Build and test / Android (aarch64) (push) Failing after 22m23s
`core.hooksPath` points at `.githooks` for the traceability hook, and that
redirects *every* hook — including the four git-lfs installs for itself. They
were written there by `git lfs install` and left untracked, which is the worst
of both: present for whoever ran it, absent for everyone else.

`pre-push` is the one that matters. It is what uploads LFS objects, so without
it a push can land a pointer on the server with nothing behind it — which is
exactly the failure CI has been hitting from the other side, and not a state to
risk creating by accident.

Committed rather than regenerated per clone, because `git lfs install` writes
to `.git/hooks` by default and would miss the redirect entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 21:24:07 +02:00
dtourolleandClaude Opus 5 dce1e66746 Show which cell a shift-click is measuring from
The gesture had a hidden operand. A range runs from the anchor — the last cell
plainly clicked — to the cell shift-clicked, and nothing on screen said which
one the anchor was. A user who could not tell where the range was being measured
from had no way to predict what it would take and no clue why a wrong one came
out wrong; often the anchor is not on screen at all, which is itself the answer
to "why did that select so much".

The anchor is marked with an inner ring, drawn inside the selection ring rather
than in a colour of its own: it has to stay legible against a thumbnail of any
brightness, and a hue would read as a second kind of selection. It is an
ordinal, so it marks a row only while the photograph it names is in the loaded
window — off screen it marks nothing, which is the honest answer, and `anchor`
rides in the cell model beside `selected` so both are pushed by the one pass
that already keeps the grid in step with the selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:50:46 +02:00
dtourolleandClaude Opus 5 54f9cb54fb Take the whole run a shift-click names, not the part that happens to be loaded
Shift-clicking two photographs selected only the cells between them that were
in the loaded window. The grid is a window of a hundred or so over a library of
twenty thousand, and `apply_press` resolved the range against that window —
`ids[lo_row..=hi_row]`, clamped to what was there. Everything else in the run
had no id anywhere in the UI, so it was silently dropped. The user cannot see
that: the selection count is off screen along with the photographs, and the
gesture only announces itself when the drop files a dozen images instead of two
hundred.

The two ends are *ordinals*, and only the catalog knows what lies between them.
`read_ids_span` asks it, through the same predicates, the same rating filter and
the same ordering the window itself is read with — an ordinal names a photograph
only relative to an ordering, so a run taken through any other one is a run
through a different library. That ordering is now a constant, `GRID_ORDER`,
shared by the window, the trash's own order beside it, and the run: capture time
first, with the file name breaking ties and nothing more. A card written by two
cameras interleaves names that have nothing to do with each other, and what
"everything between these two" means to a photographer is a stretch of an
afternoon.

The query is reached through a closure handed to `CollectionsController` at
wiring time rather than a catalog handle, because the scope and the filter that
bound the run belong to the grid's controller. `apply_press` stays a pure
function of what it is given, which is what keeps the selection rules testable
with no library open — and the tests pass a run that reads a plain slice. Where
there is nothing to ask, the loaded window is still used: a poorer answer than
the catalog's and a far better one than a gesture that appears to do nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:50:21 +02:00
dtourolleandClaude Opus 5 22325415b1 Fetch what is on screen before what is not
The other half of the blank bottom row, on a library still filling its
thumbnail store: the cells were in the model, and nobody had asked the server
for them yet.

A batch is fetched one image at a time, two round trips each, and it is
abandoned wholesale the moment the window moves. Issued in model order, the
front of that queue was the quarter-window of cells sitting *above* the view —
which nobody is looking at — and the back of it was the bottom of the screen
and the screenfuls below. So the last rows of the grid waited behind three
quarters of a window's worth of fetches for photographs off screen, and every
scroll threw the queue away and started over from above the view again. For as
long as the scrolling continued, the bottom of the grid could be starved.

The rows still address the model they were built against; only the order they
are asked for in changes. On screen first, in reading order, then the rows
below the view, then the rows above it. Below before above because that is
where the view is going — scrolling back over cells already fetched is served
from the store, and from `requested` without a fetch at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:49:12 +02:00
dtourolleandClaude Opus 5 7c8e433911 Load the window around the whole view, not around its first cell
The bottom row of the grid was often blank, at a scroll position the user
could sit at indefinitely. Two numbers decided when the loaded window follows
the view, they lived in different languages, and they disagreed.

The grid loaded three screenfuls and Rust held the window still until the
first visible cell was three quarters of the way through them. Three quarters
of three screenfuls is 2.25, and the view itself is one screenful tall — so
the bottom of the screen had already travelled a quarter of a screenful past
the last loaded cell before anything moved. Those rows are not in the model,
so nothing is drawn for them.

The same margin was wrong upward, and exactly so. The window is placed a
quarter of itself behind the view, and the margin then declared the view too
close to the top at precisely that distance: every single row scrolled upward
re-read the catalog, rebuilt all 360 cells and re-queried their badges and
ratings, and so did the row after it.

So the grid now reports what it shows — a screenful, counting the row the
scroll position has cut in half, which `visible-rows` alone undercounts and
which is exactly the row reported missing — and Rust owns the rest: four
screenfuls loaded, placed a quarter back, and moved once the view comes within
half a screenful of an edge of them. One decision in one place, and the test
now walks the view the length of the library and asserts the window covers it
at every step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:58 +02:00
dtourolleandClaude Opus 5 33847a0bbc Open one GPU device for the tests, and stop the checkout dying over LFS
Build and test / Desktop (Linux) (push) Failing after 9m18s
Build and test / Layer separation (push) Successful in 26s
Traceability / Requirement traces (push) Failing after 25s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 22m46s
Two CI failures, unrelated except that both were mine.

**The test binary faulted under parallel threads.** Every GPU test opened its
own `GpuContext`, and `cargo test` runs on as many threads as there are cores —
so a full run asked the driver to bring up a dozen Vulkan devices at once and
died with SIGSEGV. Serially it passed, which made it look like flakiness rather
than a fault in the harness. One device now, behind a `OnceLock`: a
`GpuContext` is an `Arc<Device>` and an `Arc<Queue>`, so sharing it is a
refcount, and the losers of the race block until the winner is done. 416 tests
now pass in parallel, in half the time twenty-six devices took.

**`lfs: true` on the checkout made the checkout fail.** The intent was right —
the model is in LFS, a plain checkout writes a 133-byte pointer, and the build
script panics on it — but on this server `git lfs fetch` is rejected at
`/info/lfs/objects/<oid>` with a client error: the credential `actions/checkout`
installs for git is not one the LFS endpoint accepts. So a fetch problem
presented as a checkout problem and took the whole job with it.

The object is on the server; a clean clone over SSH with `git lfs install
--local` pulls all 11 MB of it. It is now its own step with an explicit token,
and `continue-on-error` so a credential problem cannot masquerade as a broken
checkout — if it fails, the build still runs and fails with the build script's
own message, which names the real problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:36:34 +02:00
dtourolleandClaude Opus 5 a3785ba55d Say which version this is: 0.6.0
Build and test / Desktop (Linux) (push) Failing after 2m24s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 7s
Traceability / Requirement traces (push) Successful in 1m5s
Build and test / Android (aarch64) (push) Failing after 4s
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.6.0, so a bug report naming a version names one commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.6.0
2026-08-23 19:59:30 +02:00
dtourolleandClaude Opus 5 6b5ffc6a93 Anchor the delete on what the view was showing, not on the window's start
Build and test / Desktop (Linux) (push) Failing after 2m33s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 22s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 6s
The grid still jumped. Anchoring on `offset` was wrong for a reason this file
already states, a few hundred lines away: "the first *visible* ordinal, not the
window's start: the loaded window deliberately begins a quarter of a screen
above the view, so its first cell is one the user cannot see."

Two things made `offset` the wrong number. It is a screen-quarter above what is
being looked at, and by the point this runs it has already been re-clamped
against the new, smaller total — so seeking to it moved the view somewhere the
photographer had not been. A smaller jump than the original, and the same fault.

`resume_at` is what the grid last reported as its first visible image, which is
the photograph the person is actually looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:38:27 +02:00
dtourolleandClaude Opus 5 cd64166b15 Keep the view on the photographs after some of them are deleted
Build and test / Desktop (Linux) (push) Failing after 2m21s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Deleting made the grid go blank and jump somewhere arbitrary. Two causes, both
of them the viewport being left behind by everything else that moved.

The Flickable is sized to the *whole* library so its scrollbar is a real
address into twenty thousand images. Delete some and that content gets shorter,
which leaves a view near the end scrolled past what now exists — cells sitting
above a viewport looking at empty space. Slint does not pull a Flickable back
on its own. (The clamp for that went in with the previous commit.)

The jump is the other half. Cells are drawn at their absolute place in the
library, `(i + offset) / columns`, and a delete re-clamps `offset` downward so
the loaded window still fills. Nothing touches `viewport-y`, so the same scroll
position now addresses different photographs and the grid appears to leap
somewhere unrelated.

`restore_position` re-anchors on the ordinal the view was showing, clamped into
what is left. Not on the deleted image's own position, which no longer exists,
and not on the top of the library, which would throw the scroll position away
on every delete — after removing one frame from a wall of twenty thousand, the
one you want next is the one that just moved into its place.

Only on a shrink, and the shrink is detected by reading `library-total` before
overwriting it. Re-anchoring on every load would fight a scrub, which sets
exactly this property to go where the user asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:03:15 +02:00
dtourolleandClaude Opus 5 fa4ad6e2d6 Drag the date range on the axis it is chosen from
The range could be turned on with a finger and not aimed with one. Its two
ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to
name days already drawn on the axis a thumb away; and the chip that seeds them
takes its span from the timeline's zoom and pan, which are a wheel and a middle
button. A touch screen has neither, so on Android the filter was a switch with
no aim.

The band is now on the timeline. Two ends with grips, dragged along the bars,
released to filter — the histogram was already how a period is found, and this
makes it how a period is stated. Both ends snap to whole days, which is what
the typed fields mean, what `show_range` reads back out, and a floor under a
range dragged shut. The fields stay for what dragging cannot do: name an exact
day, and say in words what the range is.

For that to work the axis had to stop following the range. Redrawn to the band,
it moved the ground under the very handles doing the narrowing, and there was
nothing outside the range left to widen back into.

While there: a fixed number of equal bins instead of calendar buckets. Between
one calendar unit and the next the bar count is free to wander by a factor of
twelve, so zooming in halved it two steps out of three — the same picture drawn
wider until it jumped back to fine. Equal bins also include the empty ones, so
a bar's position on the track and the date under it are finally the same
quantity; before, a library with gaps drew a February six months wide and the
marker, the band and a click all pointed somewhere else. The count is a
setting, 32 or 64, because the right answer is a question about the screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:42:42 +02:00
dtourolleandClaude Opus 5 b0b6dd559a Show what is selected, and let a collection be made of it
Build and test / Desktop (Linux) (push) Failing after 2m37s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Failing after 6s
Three things a selection needed and did not have.

**Seeing it.** The count existed — "12 selected" — in the header row, which
scrolls sideways. On a tablet it sat past the right-hand edge along with every
button beside it, so a selection was something you could make and then not
see. A selection you cannot see is one you act on by accident.

**Putting it down.** The only way to clear one was "Done", which also leaves
select mode — so after filing forty photographs the next forty began by
re-entering a mode the user had not meant to leave. Clearing is now its own
action and keeps the mode.

**Filing it somewhere new.** Making a collection of a selection took four
steps: create one, find it in the tree, select the photographs again because
creating it changed the scope, then add them. It is one press, which is how a
selection is usually meant — it is gathered *because* it is going somewhere.

The new collection is created at the top level rather than inside the current
scope, unlike the tree's "+". A selection can be gathered from anywhere,
including across collections, so filing it under whichever one happens to be
open would put it somewhere its contents did not come from. It opens straight
into its name field, for the reason `collection_new` already does: the
placeholder name is nobody's choice, and making the user find the rename
afterwards is asking them to finish a job we started.

All of it on its own strip beside the date range's, appearing only while there
is a selection — the third control this session that was invisible for being
put in a row that scrolls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:57:35 +02:00
dtourolleandClaude Opus 5 ffc40c42d2 Regenerate the matrix where the tags are changed, not after the push
Build and test / Desktop (Linux) (push) Failing after 2m25s
Build and test / Layer separation (push) Successful in 28s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 4s
The traceability gate has failed on six commits in a row, every time for the
same reason: someone added a `TRACES:` tag and did not regenerate
docs/traceability.md. The gate is right to fail — a matrix that disagrees with
the tree is worse than none, because it is read as current — but it says so
after a push, on a commit that is otherwise fine, and by then the tag and the
matrix are two separate things to remember instead of one.

A pre-commit hook regenerates it and stages it, so the two travel together.
Enabled with `core.hooksPath`, which is a local setting: run

    git config core.hooksPath .githooks

in a fresh clone, or the hook sits there doing nothing.

Only runs when something that can carry a tag is staged, and says nothing
unless it changed the matrix — a hook that prints on every commit is one people
start passing `--no-verify` to. If the report cannot run at all it leaves the
matrix alone and lets the commit through: refusing to commit because a build is
broken would be a worse failure than the one it prevents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:01:44 +02:00
dtourolleandClaude Opus 5 f9c7aba8b6 Regenerate the traceability matrix
Build and test / Desktop (Linux) (push) Failing after 2m34s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Six commits added nineteen TRACES tags between them and none regenerated the
matrix, so the gate failed on every one of them — including the commit the
release tag points at. The gate is doing its job: a matrix that disagrees with
the tree is worse than none, because it is read as current.

Coverage is unchanged at 50.3%; what moved is where each requirement is
tagged, which is the half of the file that is actually consulted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.5.0
2026-08-23 15:00:45 +02:00
dtourolleandClaude Opus 5 fb4b05fb6f Let the date range be opened before there is a date range
Build and test / Desktop (Linux) (push) Failing after 2m16s
Build and test / Layer separation (push) Successful in 23s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 6s
Pressing "limit to range" did nothing, and the reason was two early returns
that sat above the line which shows the controls. If the catalog was not open,
or nothing in it carried a capture date, the handler returned before
`set_library_range_active`, so no state changed and nothing appeared.

Capture dates are read from EXIF as thumbnails load, so a freshly opened
library has none — the button was inert on exactly the libraries where someone
is most likely to go looking for a date, and it failed by doing nothing at all,
which is the hardest failure to report.

Underneath that was a smaller mistake with the same shape: whether the controls
showed was read from whether a range was set. The fields are how a range gets
set, so requiring one before they appear is a door locked from the inside. The
panel has its own state now, and the timeline span is a seed for the fields
rather than a precondition for them — no span means two empty fields waiting to
be typed into, which is a way to choose a range rather than a refusal to offer
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:15:34 +02:00
dtourolleandClaude Opus 5 5a9beedca6 Give the range's ends a row of their own, not a corner of someone else's
Build and test / Desktop (Linux) (push) Failing after 2m32s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Failing after 1m3s
Build and test / Android (aarch64) (push) Failing after 7s
Third attempt at one control, and each failure was a different reason it could
not be seen.

It narrowed to the whole library, because it took its span from a timeline zoom
that is zero until someone zooms. The fields that fixed that went into the chip
row, which scrolls sideways, so they sat past the right-hand edge. And moving
them "below the chips" put them inside the same `Rectangle` — which stacks its
children at the origin rather than laying them out, so they were drawn over the
chips inside a strip 34px tall, unconstrained in width and clipped in height.

A `Rectangle` is not a layout. The row is a sibling of the chips' strip in the
header's `VerticalLayout` now, with a height of its own and the width of the
window: two 108px fields, a "to", and a warning when what was typed is not a
date — about 354px, against 768 on a tablet in portrait.

Left-aligned and inset by the same gap the chips use, so the two rows begin on
one vertical line instead of a few pixels apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:06:37 +02:00
dtourolleandClaude Opus 5 db43d8ec8b Put the range's ends where a tablet can see them
"Limit to range" looked broken a second time, for a second reason. The ends
were added to the filter chip row, and that row scrolls: its own comment
records that fourteen chips do not fit across 768 logical pixels, "so that is
every tablet in portrait". Two date fields and a caption went straight past the
right-hand edge, into the part of the row that has to be panned to.

So the fix for a control that appeared to do nothing was itself invisible, and
pressing the chip still looked like it did nothing.

They have their own line now, below the chips and outside the Flickable, and it
exists only while a range does. Nothing competes with it for width, and nothing
has to be panned to reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:58:56 +02:00
dtourolleandClaude Opus 5 3a42c63b5b Format the refine tests the way the gate asks for it
Build and test / Desktop (Linux) (push) Failing after 3m36s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 1m7s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
Build and test / Android (aarch64) (push) Failing after 7s
`cargo fmt --check` is a required step and the mask-refine work landed with a
test body it disagrees with. Whitespace only — kept as its own commit so it can
be skipped wholesale rather than read for a change that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:24:48 +02:00
dtourolleandClaude Sonnet 5 b3641b5307 Let a subject's mask be refined, and let several masks be edited at once
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.

## Refine mask

A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.

The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.

## Multi-select

`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:17:07 +02:00
dtourolleandClaude Sonnet 5 e6b01226eb Let the segmentation export script pick its own input size
Comparing a larger model against a larger input size meant re-exporting at
resolutions other than the shipped 640, and the script only ever wrote that
one number. `IMGSZ` is now a second positional argument, defaulted to 640 so
every existing call is unchanged.

The experiment this was built for found bigger input a net loss on its own
merits — yolo26n-seg and yolo26s-seg at 1280 both lost track of large,
frame-filling subjects (a bus's box shrank and its score nearly halved)
in exchange for catching small or partially-occluded ones tiling already
handles. Nothing shipped from it, but the ability to re-run that comparison
is worth keeping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:16:51 +02:00
dtourolleandClaude Opus 5 4b7648082e Let a date range be stated, and draw the axis at the scale it deserves
"Limit to range" did nothing, and the reason was not visible from the button.
It took its span from the timeline's zoom, which is zero until someone zooms —
so `zoomed_span` returned the whole library and the filter narrowed to
everything. The chip lit up and the grid did not change.

The range has ends now, shown and typed as `YYYY-MM-DD`. Seeding them from the
timeline is kept, because zooming to a fortnight and pressing the chip is the
fast path; the fields say which fortnight it landed on and let it be corrected.
Ends given backwards are swapped rather than refused — there is exactly one
range between two days — and the closing day is included, since "to the 5th"
means the whole of the 5th and a range ending at its midnight contains none of
it. `parse_date` refuses anything that is not a date rather than guessing at an
order, because the alternative is a library silently filtered to a span nobody
asked for.

The axis then follows the range. It used to keep drawing the full extent while
a range was on, because it was the only way back out; the typed ends are the
way back out now, so it is free to show what was asked about.

And bucket size is chosen by how many bars it makes rather than by fixed
cut-offs. Each zoom step halves the span, so under thresholds the bar count
halved with it until a boundary was crossed: fifteen years went 15 bars, 8,
then 46, 23, 11, and finally 6. Zooming in made the picture coarser, which is
the opposite of what zooming is for. Aiming at forty bars keeps the count in
the same neighbourhood at every level, and the test asserts the property
directly — halving a span never coarsens the bucket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:40:14 +02:00
dtourolleandClaude Opus 5 fbf286d504 Keep the mask when the photograph is closed
A local adjustment survived until the session ended and then did not exist.
Nothing reported it, because nothing had failed.

The sidecar format has carried masks since they were added, `Version::apply`
restores them into the graph, and `Version::update` captures them — that work
landed complete and was never called. The autosave writes `copy_settings()`,
which is a `Preset`: a map from (operation, parameter) to a number. A mask is
not a parameter. It is a rule about *where*, with a chain of its own, so it
fell outside the only thing being written, and the read path had nothing to
read.

The save now carries the stack beside the preset, on both the local and the
remote path. Deliberately not merged but replaced wholesale: this is the stack
as it stands, so a layer the user deleted has to leave the file too. Merging
two devices' stacks is `Sidecar::merge`'s job and belongs to sync (FR-NC-9).

A paste still carries no masks, and the `Option` is how that is said. Settings
travel between photographs; a mask does not, because it is drawn against one
frame and describes nothing on another — and `Scope` cannot express that,
since it filters parameters and a mask is not one.

The test fails against the old save path with "the mask must come back with
the photograph", which is the whole of the defect: not a crash, not an error,
just an edit that was not there in the morning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:29:41 +02:00
dtourolleandClaude Opus 5 f4c7afb74c Say which version this is: 0.5.0
Build and test / Desktop (Linux) (push) Failing after 3m8s
Build and test / Layer separation (push) Successful in 1m32s
Traceability / Requirement traces (push) Failing after 2m56s
🐳 Android image / Build and push (push) Successful in 24m43s
Build and test / android-image (push) Successful in 24m45s
Build and test / Android (aarch64) (push) Failing after 6s
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.5.0, so a bug report naming a version names one commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:48:03 +02:00
dtourolleandClaude Opus 5 5807b67693 Caption a photograph with its name, not with how it is stored
A grid cell is about four words wide and `.CR2` spent one of them saying
something the photographer already knows: in a RAW library every frame ends
the same way, so the extension distinguishes nothing while taking room from
the part that does. The caption elides from the end under pressure, so an
extension can push the digits that actually identify a frame off the visible
part of its own label.

Dropped in the view, not in the model. `LibraryCell::name` keeps the true
filename and `remote_path` the full path, because both are used to find the
file again and a stem is not a filename.

The case it is wrong for, recorded rather than discovered later: a library
holding `IMG_1234.CR2` beside `IMG_1234.JPG` now shows two cells captioned
`IMG_1234`. They remain two rows with two thumbnails and two entries in the
info panel, and a RAW+JPEG pair is usually one photograph anyway — but the
caption alone no longer separates them.

Only the last dot goes, and only when something precedes it: `2026.08.23-a.dng`
keeps its dates, and `.hidden` keeps its leading dot, because that dot is how
the name starts rather than an extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:46:40 +02:00
dtourolleandClaude Opus 5 a8969c149e Move the instrumentation off the header and into About
The render backend, the layout class and the frame rate sat permanently in a
44px strip that also carries the only way out of develop, the undo pair, the
panel toggle and the export button. They cost about 200 logical pixels, and a
`HorizontalLayout` given less width than its children's minimums does not
shrink them — it runs off the end.

There was already a breakpoint hiding them below 820px, which is why a tablet
in portrait (768) looked fine and landscape (1200) did not: above the
breakpoint the readouts came back and pushed the header off the right-hand
edge. A breakpoint that hides a problem at one size and not another is a
workaround, and this removes the reason for it rather than moving it.

They are diagnostics — read once when something looks wrong, and then not
again — so they are in Settings under ABOUT, beside the version, which is
where someone goes when they have a bug to report rather than a photograph to
edit. The version is there for the same reason and comes from
`CARGO_PKG_VERSION`, so the line cannot disagree with the binary showing it.

The frame rate keeps its warning hue. It is the number that says whether the
zero-copy display path is holding up, which is the assumption the whole
display design rests on, and that is worth colour wherever it is shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:41:17 +02:00
dtourolleandClaude Opus 5 7a5e1adf51 Merge what two tiles saw of one subject, instead of picking a side
"Look closer" tiles the frame so a small subject reaches a fixed 640x640 model
at its own size. A tile sees only the part of an object inside it, so an object
on a seam produces two *partial* masks — neither of them the object.

This kept the higher-scoring one and discarded the other, which quietly threw
away what tiling had just been paid 2.8 seconds for: a bird with its tail cut
off at a tile edge, described by whichever tile happened to hold more of the
bird. Both halves existed; one survived.

They are unioned now, and the overlap is what makes that sound. At 25% every
pixel is seen by at least one tile at full resolution and pixels near a seam by
two, so the pointwise maximum is the better estimate everywhere rather than a
compromise: where one tile saw a pixel its opinion is the only one there is,
and where both did, the higher value came from the tile with more context
around it. A maximum of soft coverage also stays soft, which is what `prior.rs`
weights merges by and what a mask layer's edge treatment needs.

Each quantity gets the operation that suits it: maximum for coverage, union
for the box, and the higher score rather than a blend — the score is shown to a
photographer and means "how sure the model is this is a bird", so averaging in
a tile that saw a wingtip would make a confident detection look doubtful for
straddling a seam.

The test fails against the old rule with "pixel 4 was seen by a tile and must
survive the merge", which is the whole defect in one line: not a crash, not a
duplicate, just a plausible mask missing half its subject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:14:53 +02:00
dtourolleandClaude Opus 5 5a0a9719eb Set the version once, in one place, for every artefact
The workspace read 0.1.0 for two releases, so every desktop binary reported a
version two releases stale. The APK was worse: `AndroidManifest.xml` states no
version at all, so a device showed `versionName=null` and `versionCode=0` while
the library inside the APK knew exactly what it was. A version edited by hand
in several files is a version that is wrong in at least one of them.

`tools/set-version.sh` is now the only thing that sets one. It takes the
version from the latest git tag, or is told, and writes the two files that must
state it before anything is built: the workspace `Cargo.toml`, from which every
crate inherits, and `packaging/PKGBUILD`, which pacman reads before a build
exists. It refreshes `Cargo.lock`, because members appear there by version and
CI builds `--locked`. `--commit` commits the result.

Android is not in that list on purpose. `package.sh` reads the version out of
`Cargo.toml` and hands it to `aapt2 link`, so the APK cannot drift from the
binary it contains — there is no third file to forget. `versionCode` has to be
one increasing integer, which a semantic version is not, so it is packed as
MAJOR*10000 + MINOR*100 + PATCH: ordered the way Android requires, and readable
at a glance.

A version that is not MAJOR.MINOR.PATCH is refused rather than coerced. It is
a contract with whoever reads a bug report, and silently turning "0.4" into
something else is worse than being asked to type it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:09:03 +02:00
dtourolleandClaude Opus 5 7fcb8dc107 Give the develop column room, and the mask a way out of the way
Three faults reported together, and they share a shape: each is something the
panel decided on the photographer's behalf.

**The column was 280px on every screen.** That width was chosen for a tablet,
where the column is a large fraction of the display and every pixel of it is
taken from the photograph. On a desktop window the mode strip alone — two
modes, a separator, "All", and a chip per attribute the operation set declares
— does not fit, so it scrolled sideways. A control you have to pan to reach is
one you do not know is there. 380px when the window is classed expanded, 280px
when it is not; driven off `layout-class` because reading the window width
inside the layout that sets it is a binding loop.

**The overlay could not be hidden.** An earlier "Overlay" button was removed
for a good reason — it *armed* the overlay, so local mode could be entered and
still show nothing. Hiding is the opposite need and was never served: a mask is
judged against the photograph beneath it, and that photograph is exactly what
the overlay covers. `overlay-hidden` is kept separate from `overlay-on` so a
recompute cannot switch the overlay back on under someone who just turned it
off.

**The masks were coarse because the model saw the subject small.** The graph's
input is a fixed 640x640 and every frame is letterboxed into it, so a bird
200px across in a 1600px proxy reaches the model at 80px. `Tiling::Grid` has
been implemented and tested since the segmentation spike and defaulted off,
because it costs one inference per tile — 2.8s for a 3x2 grid against 470ms.
Now offered as "Look closer (slower)", which says what it costs, rather than
spending it on every image or on none.

The tiling choice enters the segmentation signature. A mask stores the
signature its region ids index into, and a tiled run finds different instances
in a different order; sharing a signature would silently reinterpret a layer
built against the coarse pass — a wrong mask rather than a stale one, and
nothing announces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:54:35 +02:00
dtourolleandClaude Opus 5 ec8582032e Fetch the model CI has been building without
Both failing jobs failed for the same reason, and it was not the reason either
of them appeared to fail. Desktop reported a Clippy failure and Android
reported the API-level check; both were `dr-segment`'s build script panicking
because `models/yolo26n-seg.onnx` was a 133-byte LFS pointer rather than the
11 MB model.

No checkout step asked for LFS objects. `.gitattributes` predicted this exactly
— "a clone without git-lfs gets a ~130-byte pointer file where the model should
be" — and the build script fails loudly by design rather than embedding a
pointer and failing at inference. That design worked; nothing was reading the
message.

It stayed hidden because the Android job built `-p dr-gpu`, which never reaches
dr-segment. Building the app crate does, which is how one fix surfaced another.

Only the two jobs that compile get `lfs: true`. `layering` runs `cargo tree`
and builds nothing, so it has no reason to fetch 11 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:44:09 +02:00
dtourolleandClaude Opus 5 b7bd2356be Regenerate the traceability matrix
The gate regenerates it and fails if the result differs from what is
committed, which is what it is for: a matrix that disagrees with the tree is
worse than none, because it is read as current.

Stale since the detail stage landed — 160 files and 436 tags then against 180
and 521 now. Coverage 49.1% to 50.3%, and no orphan tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:26:59 +02:00
dtourolleandClaude Opus 5 e25f0ad2c6 Let the launcher find an icon for the window
The icon was already embedded and had been all along: `app.slint` sets it and
`build.rs` compiles it into the binary with `EmbedFiles`. That is not what a
Wayland compositor looks at. It ignores a client-set icon entirely, matches
the surface's `app_id` against installed `.desktop` files, and takes the icon
from the one whose basename agrees. Nothing set an app id and no desktop entry
existed, so GNOME had nothing to resolve and drew the placeholder.

Both halves are needed and neither substitutes for the other: the embedded
icon is what X11 and the window itself use, and the desktop entry is what the
overview, the dash and alt-tab use.

The app id, the `.desktop` basename and the installed icon's filename are one
string in three places — `paris.tourolle.darkroom`, the same reverse-DNS name
the Android manifest already uses, because it is one application. Change one
without the others and the icon disappears again with nothing logged, so each
file says so where the string appears.

The PKGBUILD builds from the checkout rather than a release tarball, so
`makepkg -si` installs what is being worked on. Install verified by inspection
of the built package: binary, desktop entry, and the icon under
hicolor/256x256 by the name the entry asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:26:04 +02:00
dtourolleandClaude Opus 5 2a4a48c0fb Release 0.4.0
Build and test / Desktop (Linux) (push) Failing after 8m39s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 22m44s
Not a patch: this carries a data-loss fix and a feature.

Collection membership merged on `images.content_hash`, which the schema
computes only for import dedup and reconnect — never in a scan. On a synced
library it is NULL for every image, so the union matched nothing and the
catalog push that follows a merge overwrote whatever the other device had
uploaded. Collections arrived named and empty on every device, and each sync
destroyed the other's membership. Keyed on `oc:fileid` now, which every scanned
image has. Verified on a 23,000-image library: 247 memberships recovered, and a
round trip between tablet and desktop confirmed in both directions.

Card import (FR-CAT-10, FR-CAT-11, FR-NC-7a, FR-NC-7b) arrives with it: the
ingest engine, the write half of the storage abstraction, removable-volume
detection, duplicate detection in two tiers, dated folders on both sides, and
thumbnails made while the originals are still in hand. Uploads always go to the
library on the server; what lands on this machine is a staging copy the app
removes once the upload is confirmed, and a move-import empties the card only
of photographs the server has acknowledged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:23:53 +02:00
dtourolleandClaude Opus 5 85aae25d0f Let the header readouts shrink instead of pushing the controls off screen
Found uncommitted in the working tree and committed as its own change rather
than folded into the sync work beside it: the three header readouts gain
`overflow: elide` and `horizontal-stretch: 1`, which is what actually lets a
Text shrink under pressure in Slint. Without the stretch they kept their full
natural width and pushed the sidebar toggle and the More button past the right
edge on a narrow window, reachable only by knowing to flick-scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:22:30 +02:00
dtourolleandClaude Opus 5 11a63fc7a8 Refresh the collection tree when a sync brings membership
New-York gained 127 photographs from the tablet and the sidebar went on showing
no number beside it.

Two reasons, and the second is why the first was never noticed. The sync's
completion handler reloads the grid and never rebuilds the tree — the scan
already does, and the sync merges the same tables. And the condition it reloads
under was `collections_gained > 0`, which counts collections, not members: a
sync that files 127 photographs into a collection both devices already had
gains no collection at all, so the count was zero and nothing refreshed.

`SyncReport` now carries `members_gained` from the merge report, and either one
rebuilds the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:10:40 +02:00
dtourolleandClaude Opus 5 1297e3259b Stop claiming the app crates cannot cross-compile
The note above the core-crate check said the UI and app crates would join
"once the Android shell exists". They already had. Building `darkroom-android`
for aarch64 takes 4m23s and produces `libdarkroom.so`, linked for Android 28 —
which is what the step below now checks, and what a device installs.

Rewritten to say what the core check is actually for: a fast, link-free gate
that fails early and names a smaller suspect, ahead of the full build.

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