Commit Graph
279 Commits
Author SHA1 Message Date
dtourolle 3085ec4d2e Leave the stars on screen on a touch device
**Hover is not something a finger does, but Slint reports it anyway.**
`has-hover` goes true for any pointer event carrying a position, a touch
press included, and false again on the `Exit` that follows the release.

So the rating strip did appear on a tablet — for exactly the length of a
tap. It flashed on under the finger, vanished as it lifted, and the tap
carried on through to the cell and opened the photograph. An unjudged
frame could not be rated from the grid at all. The previous fix stopped
the strip disappearing when a *pointer* moved onto it; this is the same
symptom with a different cause, and hover was the wrong signal in the
first place.

The strip now stands open where the session is a touch one. That is
seeded from the platform rather than inferred, because inference needs a
press to reach a cell and a quick flick never delivers one — the Flickable
claims the gesture before the delay it would forward after — and a
control that only appears once the finger is down has appeared too late
to aim at. The grid still latches on the first non-zero touch id it sees,
which is what covers a touchscreen on the desktop.

One-way on purpose: a tablet with a mouse plugged in keeps the strips
once touched, which is the harmless direction to be wrong in. The
alternative is chrome that comes and goes as the user changes hands.
2026-08-21 22:35:14 +02:00
dtourolle 2fba685e16 Make a pinch zoom the grid and nothing else
Two faults left over from making the gesture reach the grid at all.

**It still opened photographs.** Checking the finger id stops the
synthetic release Slint emits when the *second* finger lands, but not the
other end of the gesture: lifting one finger of two leaves the other one
down, and Slint replays that survivor as a fresh `Pressed` on whatever is
under it — which is how it hands the pointer back to ordinary handling.
Under it is a cell. So the cell was selected, and lifting that last finger
was a complete, well-formed click on the same finger that pressed. No
part of the event stream distinguishes it from a real tap, so the grid now
remembers that a pinch just happened: a latch raised when the gesture
starts and lowered a beat after it ends, during which cells take neither
presses nor clicks.

The press that *opens* a pinch is undone rather than suppressed — it has
already happened by the time a second finger makes it a pinch. Undoing it
has to be exact, or a cancel that restored the selection but left the
anchor moved would make the next shift-click select a run from a cell
nobody pointed at, so capture and restore are a tested pair.

**And it was not smooth.** Two reasons. The pinch was thresholded into ±1
steps of 25%, so the grid lurched and then sat still; it now takes the
ratio since the last update and tracks the fingers, with the drawn cell
still landing on whole column counts because the columns divide the
width. And `zoom-cells` was the one geometry change still reloading
inline — a full catalog re-read, 360-row model rebuild and thumbnail
batch per step, on the thread drawing the frame. It goes through the same
settle timer as the rest now.
2026-08-21 22:03:46 +02:00
dtourolle 6ae0af3f72 Swipe up in develop for the photo roll
Develop opens one photograph. The grid handed over a path and nothing
else, so `index` and `total` were pinned to "1 of 1" on the way in and the
only route to the next frame was back to the library, find your place,
tap again. Fine once; intolerable through a set of forty, which is the
situation the develop view exists for.

The roll is the grid's already-loaded window along the foot of the
canvas. Swipe up to bring it out, swipe down to put it away — the sheet
gesture, already in the hands of anyone who has used a phone — and a
handle is drawn at the edge so the gesture is discoverable rather than
folklore, and so a pointer, which has no swipe to make, has a way in.

`SwipeGestureHandler` wraps the strip rather than sitting over or under
it, which is what it is built for: it delays a press the way a Flickable
does, forwards it to the children if no swipe develops and claims it once
one does, so a tap reaches the thumbnail and a drag does not. It covers
only the band along the bottom — above that, a drag still belongs to the
photograph, for panning and for the crop.

Picking goes through the same path a cell click does, so the outgoing
edit is persisted before the next image loads. The strip marks what is
open and scrolls to keep the mark in view.

The position readout now says where in the *library* the open photograph
sits rather than "1 of 1". Set after the open rather than before, since
the generic open path resets it — and given as the library ordinal, not
the row in the loaded window, which is an artefact of how much has been
paged in and would jump about as the window moves.
2026-08-21 21:10:44 +02:00
dtourolle c72f197880 Stop a row of buttons deciding how wide the grid is
**A Slint layout cannot be narrower than its children's minimums.** Given
less room than they need it lays them out at those minimums, lets the row
run past the edge — and reports the oversized minimum upwards.

That second half is what made this more than cosmetic. The header, the
filter chips and the grid are siblings in one VerticalLayout, so the
widest row's minimum became the whole view's minimum: `LibraryGrid` was
laid out wider than the window. The grid then measured itself against
that inflated box and sized its columns to fill space that was off the
screen, so the right-hand column was cut by the edge no matter what the
tiling arithmetic did. Fourteen filter chips do not fit across 768
logical pixels, so that was every tablet in portrait.

It also explains why opening the collections sidebar did not reflow the
grid. The view was already pinned at a minimum wider than the window, so
taking 232px away for the sidebar could not shrink it — it just clipped
more of it.

Each of those rows is now a horizontal Flickable. A Flickable's own
minimum is nothing, since it exists to be smaller than what it holds, so
none of them can inflate anything — and the controls past the edge became
reachable instead of merely absent. Applied to all four: the library
header, the filter chips, the compact action row disclosed by "More", and
the develop strip.
2026-08-21 21:00:49 +02:00
dtourolle d2c909414c Stop zooming rebuilding the grid twice a frame
A pinch is not one zoom step, it is a stream of them, and every step
changes both the column count and the capacity — two reports. Each report
re-queried the catalog, rebuilt all 360 rows of the model, re-read the
badges and ratings for every one of them and spawned a thumbnail batch,
synchronously, on the thread trying to draw the frame. Twice per step.
That is why zooming juddered while scrolling the same grid is smooth: a
scroll reloads a few times per screenful, a zoom reloaded twice a frame.

None of that work is urgent, because none of it is about which
photographs are on screen. The window holds the same images however they
are laid out — the model already has them, and the cells re-flow from
`columns` and `cell-size` with Rust not involved at all. What the reload
actually recomputes is which cells begin a row, so the month headings
land correctly, and which thumbnail size class to ask for now. Both can
wait for the gesture to finish, so both are now coalesced behind a single
settle timer: replacing the timer drops the previous one, and only the
last report of a run lives long enough to fire.

The anchor is captured on the first report of a run rather than read when
the timer fires. As the grid re-flows the viewport keeps its pixel offset
while the rows move underneath it, so the view drifts and reports the
drift; reading the anchor at the end would faithfully return to wherever
it had wandered. Taking it at the start returns to the photograph the
user was looking at when they started the gesture.
2026-08-21 20:44:56 +02:00
dtourolle 12a457b8d0 Tile the thumbnails to fill the width
The cells were drawn at whatever pixel size the class asked for and the
remainder was left as a bare strip down the right-hand side — up to one
short of a full column of nothing, which on a phone is a quarter of the
screen.

It also had the two quantities depending on each other the wrong way
round. The column count was derived from the fixed cell size, so the two
could disagree about how much room there was and the last column could
start inside the viewport and end outside it.

Solving for the cell removes both at once. Pick how many columns of
roughly the requested size fit — rounded, not floored, because the size
class is a request rather than a measurement and a width nine tenths of
the way to another column should take it — then make those columns share
the width. `columns` cells and the `columns + 1` gaps around them come to
exactly the grid's width, so there is no remainder to strand and nothing
can overhang.

The size class still decides what the user gets, since it is what the
column count is chosen from. It just no longer dictates the pixel, so the
cell flexes a few percent either way to make the row come out even.
2026-08-21 20:44:48 +02:00
dtourolle 90d6e551b3 Give the develop strip room for its controls on a tablet
The render backend, the layout class and the frame rate are developer
readouts, and they were sitting in the middle of the one strip that also
carries the way out of develop, the undo pair, the panel toggle and
export. A HorizontalLayout given less width than its children's minimums
does not shrink them — it runs off the end.

A tablet in portrait is 768 logical pixels, which is not enough, so
everything from the panel toggle rightwards was pushed off the right-hand
edge. Below the breakpoint the develop column is *also* closed by
default, so a toggle that could not be reached meant the column could not
be opened at all — and copy and paste live in that column. That is the
whole of "I have no idea how to copy a setting on Android and apply it to
other images": there was no way to.

The three readouts now collapse to zero width below the breakpoint. Width
and not an `if`, because this strip is inside the layout that `expanded`
feeds and a conditional child here is the shape that has already caused
binding loops in this file — and because a `visible: false` child still
takes its slot in a layout, so hiding alone would have freed nothing.
2026-08-21 20:38:48 +02:00
dtourolle 1980fda737 Show the library on launch instead of scanning first
A launch does not have to discover the library. The catalog from the last
run is on disk, complete, with its thumbnails in the shards beside it —
exactly the state offline mode already leans on when the server cannot be
reached.

Every launch that *could* reach the server threw that away. The catalog
handle was only opened when the scan reported Done, so the grid sat on
"Scanning…" over an empty EmptyState for as long as a recursive WebDAV
walk of the whole tree takes. On a real library that walk is essentially
the whole startup time, and it was spent hiding a grid that was ready
before it began.

The catalog is now opened and the first window loaded before the scan
thread is spawned — before, so the schema migration cannot race the
worker opening the same file, and so the first thumbnail batch is already
in flight while the walk runs. The scan still replaces all of it the
moment it lands; it just no longer gates the first paint on the network.

A first run has nothing to open, which stays silent: `Catalog::open`
creates the file, the grid reads an empty catalog, and the empty state
goes on saying "Scanning…" — which is true, and which an error here would
contradict.
2026-08-21 20:35:54 +02:00
dtourolle 80f1a210cc Keep the view pointing at the same photograph when the columns change
Two faults behind "the gallery randomly glitches to blank and needs a
scroll to reset it", and behind the scrolling that skips.

Cells are drawn at their absolute place in the library, so the row a
photograph sits on is `index / columns`. When `columns` changes every
cell moves — and the Flickable's `viewport-y` did not move with them. The
view was left pointing at a row that now holds entirely different
photographs, typically thousands of images from the ones the loaded
window covers, so the grid drew nothing at all. It stayed that way until
a scroll reported a first-visible row and dragged the window back under
the view, which is exactly the reset the user found. None of its triggers
are rare: a resize, the collections sidebar opening, a zoom step, or
turning the tablet over.

The last first-visible ordinal names the photograph being looked at, so
it is now sent back through `scroll-to` and the view lands on that same
photograph at whatever row it now occupies.

The second fault is the window-move test. At either end of a scope the
window is pinned — the first screenful cannot be centred further back
than zero, the last cannot start past the last full screenful — so the
margin test was unsatisfiable there and every row crossed in the first or
last quarter of a window re-read the catalog, rebuilt the model and
issued a thumbnail batch to arrive at the offset it already held. On a
library of twenty-odd thousand that is a stutter at the top and the
bottom of every collection, which is where a cull begins and ends.

The decision is now a rule with tests rather than four lines inside the
scroll handler: both of its ways of being wrong are invisible in the code
and obvious on a tablet.
2026-08-21 20:34:01 +02:00
dtourolle deabac0923 Carry a photograph's thumbnail across a reload
Work in progress found uncommitted in the tree, committed as its own
change so the fixes that follow can be read separately. Not authored in
this session; the description below is written from the diff.

`load_window` rebuilds every row, and a scroll reloads once the view has
travelled a quarter of the loaded window — so three quarters of the cells
being rebuilt are the same photographs already on screen. Rebuilding them
empty blanked the grid to `Theme.ground` and refilled it a beat later,
once a worker had re-read and re-decoded each one from the store. That is
the black flash on every screenful of scrolling, and on a column change,
a zoom step, a filter and a return from develop.

Thumbnails are now held by `image_id` across the swap — a refcount per
cell, no pixels move — along with the "no preview" verdict, which is an
answer about the file worth keeping for the same reason. `requested` is
rebuilt from what the new model actually holds rather than cleared, so a
carried cell is not fetched again while one newly scrolled in still is.
The size class each cell's pixels came from is tracked alongside, so a
grid zoomed past that class still asks for the sharper one.

Also guards the whole `scrolled` and `columns-changed` handlers on
`show-library` rather than just the resume latch: a Flickable being torn
down passes its viewport through zero, which was indistinguishable from a
fling to the top and reloaded the window against the first rows of the
catalog every time an image was opened.
2026-08-21 20:31:48 +02:00
dtourolle 4d8196174c Let the grid be pinched instead of opening an image
Two separate faults, both only reachable with a second finger.

The gesture never arrived. `ScaleRotateGestureHandler` was sized `100%`
inside the Flickable, which is the Flickable's own height, not its
viewport's — so the handler was one screenful tall at the top of a
viewport thousands of rows long. A pinch is delivered to whatever lies
under the midpoint of the two fingers, so it landed on the handler only
while the grid was scrolled to the very top and found nothing anywhere
else. Sized to `viewport-height` now, exactly as `zoom-catcher` above it
already is.

And the attempt opened a photograph. When a second finger lands Slint
closes the first one's gesture by synthesising a `Released` at its
position — that is how a Flickable is persuaded to let go of a scroll it
has already claimed. A TouchArea cannot tell that release from a real one
and fires `clicked`, so every pinch opened whichever image the first
finger was resting on.

The finger id separates them: the synthetic release carries the id of the
finger that *arrived*, never the one that pressed. `clicked` fires before
the pointer event that names the finger, so it now only raises a flag and
the `up` handler decides. A mouse reports 0 for both, leaving the desktop
path exactly as it was.
2026-08-21 20:30:20 +02:00
dtourolle 892da2662a Keep the stars on screen long enough to click one
The rating strip was a sibling of the cell's TouchArea, declared after it
so that hit-testing reached the stars first. That half worked: a star
click set a rating without also opening the image.

The other half did not. Hover is tracked per TouchArea, and Slint sends
`Exit` to any item that drops out of the hit path. The strip taking the
pointer is exactly that — `cell-touch` left the path, `has-hover` went
false, and `show-empty` went with it. On an unrated cell the stars are
drawn *only* on hover, so they vanished as the pointer arrived at them;
the click then landed on the cell behind and opened the image. Reported
as "the star menu disappears when I click on an image", which is
precisely what it does.

Nesting the strip inside `cell-touch` keeps both halves. Children are
hit-tested before the element containing them, so a star still wins the
click and still ends the walk before `cell-clicked` runs. And an ancestor
stays on the item stack: it gets no `Exit`, and while a child holds the
grab it is handed the event filter and never the event. Hover therefore
holds for as long as the pointer is anywhere in the cell.
2026-08-21 20:29:10 +02:00
dtourolle 76a751f125 Measure the grid against the grid, not the whole view
`cell-size`, `columns` and `visible-rows` were all derived from the
LibraryGrid's own width and height. The cells are not drawn in that box:
the capture-time axis is a sibling of the grid, 96px of it, and the
header takes another 44px off the top.

So `columns` counted the timeline as room for thumbnails and fitted one
more column than there was space for. The last column started inside the
Flickable and ended outside it — clipped, with no sideways scroll to
reach it. At the 180px size class on a phone in portrait the timeline is
a quarter of the screen, which is most of a column.

`visible-rows` was wrong the same way and fed `capacity`, so every window
over-fetched by the ratio of the header to the viewport.

Both now read `grid-area`, the layout the cells actually live in. That is
a descendant, and reading a descendant's geometry is the shape that
causes binding loops elsewhere in this UI — but not here: nothing derived
from these feeds back into the layout. The cells are placed absolutely
inside the Flickable and a Flickable's layout constraints are a bare
`stretch: 1` that its viewport cannot influence.
2026-08-21 20:28:12 +02:00
dtourolleandClaude Opus 5 3cfa78cde2 Give the app a face and a name on the launcher
There was no icon anywhere, and on Android that was not a missing line in the
manifest. `aapt2 link` was being handed a manifest and nothing else, so the
APK carried no res/ and no resources.arsc — there was no table for an
`@mipmap/...` reference to resolve against even if one had been written.
Packaging now compiles the resource tree first and links the result in, which
is the two steps aapt2 insists on: link reads compiled input only, never a
directory.

That absent table is also why the launcher caption was blank, which had
looked like a second, separate bug. `android:label="DarkRoom"` was there and
correct the whole time, and Settings' App info read it fine; the launcher
could not, because resolving a label goes through the package's Resources and
there were none to open. Nothing about the label changed here. It came back
with the table under it.

`android:icon` then names one drawable for both icon generations, because the
`anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive
icon and its three layers; the third of those, monochrome, is what lets
Android 13 recolour it rather than drop the app out of the themed set. Below
26 the same name lands on a density-matched PNG. `roundIcon` is deliberately
absent — a launcher old enough to read it is one that would ignore the
adaptive XML, and minSdk is 28.

The desktop icon is one `@image-url` on the window, and the only raster asset
in a UI that is otherwise entirely Path. The reasoning at the top of
icons.slint does not reach it: that is about glyphs a font might not carry,
and this image is never drawn by us at all. It goes to the window manager,
which wants pixels and composites them unmasked, so it is pre-shaped with
rounded corners rather than square the way the Android layers are.

Which exposed Slint's resource default. An `@image-url` compiles down to the
absolute path it had on the build machine, to be opened at runtime — already
wrong for Android, where the build happens under /work inside a container and
no such directory exists on the device, and wrong silently, as an image that
loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches
nothing else, since every glyph is a Path.

Verified on a device: the APK installs and the home screen draws both the
icon and "DarkRoom" under it, where before it had neither. In the link step
the adaptive icon resolves at all six densities and resources.arsc lands
uncompressed, which API 30 requires and the existing zipalign preserves. On
the desktop by reading _NET_WM_ICON off the running window — 256x256, as
handed over. Where that actually shows is narrower than it sounds, and the
comment says so: Wayland ignores the property in favour of matching app_id
against an installed .desktop file, which this repo does not install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:25:22 +02:00
dtourolleandClaude Opus 5 f1cd6ed5b3 Make the drop decision a rule that can be tested
Build and test / Desktop (Linux) (push) Failing after 57m40s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 27s
🐳 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 9m28s
The gesture is Slint's and cannot be driven from a test — synthetic drags
do not reach a DragArea at all — but the decision it leads to is where
this can actually go wrong, and it was buried in a callback.

`decide_drop` names the three outcomes and the order that separates them:
an image drag always fills the payload, so photographs pressed after a
row was clicked are still filed rather than read as a rearrangement. A row
dropped on itself is a no-op here rather than a cycle error from the
catalog, and an empty drag with nothing remembered — a file from another
application — leaves the tree alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:12:11 +02:00
dtourolleandClaude Opus 5 6acc73a9fa Drag a collection onto another to nest it
The tree could be built nested but never rearranged: `set_parent` existed,
with its cycle check and its tests, and nothing in the UI called it. A
collection created in the wrong place stayed there.

Each row is already a drop target, so it becomes a `DragArea` too —
wrapped at the instantiation site the way the grid's cells are, which
keeps the row's own TouchArea nested underneath and a click still
selecting. `allow-move`, not copy: a collection has one parent, unlike a
photograph, which is filed in as many collections as you like.

The drop is handed only the target's id, so the source is remembered from
the press that precedes the drag — Slint builds the payload through a
`pure` binding, which must not have side effects. An image drag always
fills `dragging`, so an empty payload with a remembered row is
unambiguously a rearrangement; the row is taken rather than read, or a
later empty drop would move a collection nobody touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:54:50 +02:00
dtourolleandClaude Opus 5 edcaf42ded Show only the photographs taken in the period you are looking at
🐳 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 57m33s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 29s
Build and test / Android (aarch64) (push) Failing after 9m28s
The library could be narrowed by rating, flag and availability, but not
by when a photograph was taken — so finding a fortnight meant scrolling
to it and holding position.

The range rides on `RatingFilter` for the reason `local_only` already
does: every query path threads that one struct, so the count in the
header cannot claim a total the grid does not draw. Undated images are
excluded whenever either end is set — they cannot be inside or outside a
span, and drawing them made the range look as though it had not applied.

Taken from the timeline rather than typed into two date fields. Finding
the period is what the histogram is for, and having found it the user
should not have to read the dates off the axis and key them back in.
The histogram keeps drawing the full extent while the range is on, or
there would be nowhere to widen back out from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:38:26 +02:00
dtourolleandClaude Opus 5 d2d5d6f22b Keep the selection visible when the grid scrolls under it
Scrolling rebuilds every cell, and a fresh cell carries `selected: false`.
`sync_badges` and `sync_ratings` refilled what the rebuild cleared;
nothing refilled the selection, so the ticks vanished on every scroll.

The selection itself was never lost — it is a set of image ids and
survives untouched — which made this worse than losing it: the header
buttons still acted on forty photographs the user could no longer see
were held.

`load_window` reaches the selection through a `Weak` handle set at
wiring. Weak because the two controllers are joined only through the
window, and an `Rc` each way would leak both; absent, the grid draws
nothing selected, which is what it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:30:37 +02:00
dtourolleandClaude Opus 5 02d629922f Draw the date histogram over the collection you are looking at
Build and test / Desktop (Linux) (push) Failing after 57m25s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 27s
🐳 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 9m45s
The timeline counted the whole library whatever the grid was showing, so
opening a collection left a fortnight in Arosa as one column of a
fifteen-year axis — an axis describing photographs that were not on
screen.

Scope the buckets and the span to the same collection and rating filter
the grid uses. `timeline_range` counts `images` alone and cannot express
the membership join, so the scoped query lives beside the other scoped
readers in the UI and shares their descendants-of-scope rule.

`catalog_span` now delegates to the same scoped reader. Zoom and scrub
measured the full library while the bars were scoped, so a scrub could
land on an instant the collection did not contain and send the view
somewhere the user had not asked to go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:15:59 +02:00
dtourolleandClaude Opus 5 09f6cf8c0f Name the sidecar the drain could not deliver
The write path learned to say which file the server refused; the drain path —
the one that runs for work queued while offline — still reported only a count.
That is the path that matters most, because everything it carries was made
with no connection and exists on one device.

With it named, the failure on the tablet is:

    draining PhotosRaw/2026/2026-08-03/_MG_9221.drsc:
    permission denied

on a credential that pushes the catalog to the same library root in the same
pass. So this is not the account and not the app password: one path is
writable and another is not, which points at the server — a read-only share
over that folder, or a file access control rule on the extension — rather than
at anything this side can retry its way out of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:21:00 +02:00
dtourolleandClaude Opus 5 6a7ed37aed Say which sidecar the server refused, and where
A queued sidecar failing to upload was reported as a count and a reason —
"1 queued sidecar(s) still undelivered: permission denied" — with the path
only at debug level, which the app filters out by default.

That is unsynced user work: a rating or an edit that exists on one device and
nowhere else. Which photograph it belongs to, and which path the server
refused, is the whole of what makes the failure actionable, and without it a
403 on a single file reads the same as a whole library failing to sync.

Observed on the tablet, where writes to the derived folder succeed on the same
credential — the catalog pushes fine — while one sidecar beside its image is
refused. That combination says the path matters and the account does not, so
the path is the thing worth printing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:11:30 +02:00
dtourolleandClaude Opus 5 dea826811e Render a blown highlight white instead of magenta
Every clipped sky came out bright pink. Measured, not guessed: developing
_MG_8596.CR2 and looking at the export, the subject renders correctly and only
the saturated region is wrong.

A fully clipped pixel reaches the shader as (1, 1, 1) — three photosites that
stopped counting, carrying no colour at all. The as-shot multipliers are not
neutral, so balancing sends it to (1.93, 1.00, 1.68) on this body, and the
camera matrix turns that into R 2.88, G 0.51, B 2.03. Red and blue clip at
one; green, whose matrix row is far less positive-heavy, does not. Red and
blue high with green low is magenta.

Nothing upstream was at fault, which is why the two previous attempts missed
it: the white balance is correct, the matrix is correct, and the sensor
normalisation is correct. The input simply was not a colour, and correct
arithmetic on a non-colour produces a confident wrong answer.

So saturation is detected before the balance is applied — the last 1.5% of
range, smoothstepped rather than switched, because a hard threshold draws a
visible rim around every highlight and a backlit edge on skin is where that
shows. Above it the pixel is pulled to the neutral of its own brightness, so
it keeps its luminance and loses only the cast.

Verified end to end on the file itself: the sky is white, the skin, the black
dresses and the stone are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:17:15 +02:00
dtourolleandClaude Opus 5 d2024da368 Scroll the develop column as one, histogram and all
Only the sliders scrolled. The capture metadata, the histogram, the geometry
controls and copy-and-paste all sat above them in a fixed layout, so on a
280px column in portrait they took the height the sliders needed — and the
histogram, which is the instrument the sliders are judged against, could
neither be scrolled to nor scrolled past.

The Flickable moves out of `AdjustPanel` and around the whole column. Nesting
one inside the other was not an option: a slider drag already has to be won
against one scroller, and a second would give it a third thing to be lost to.

`slider-dragging` becomes an `out` property for the same reason. The
arbitration is unchanged and still necessary — a track stands the scroller
down as soon as a finger touches it, or every attempt to drag a slider would
scroll the column instead — but the scroller obeying it now lives a level up.
The scroll gutter stays where it is, as padding inside the content: it is what
guarantees somewhere to put a thumb that means "scroll" and nothing else, and
it matters more now that it serves the entire column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:53:04 +02:00
dtourolleandClaude Opus 5 c9c5c43aa1 Carry the library across when its home moves
The previous commit moved durable data out of Android's cache directory, and
on its own that would have been an upgrade that quietly discarded work. The
app looks in the new location, finds nothing, and rescans a library of tens of
thousands of images over the network — while the old copy, including every
offline rating and edit that had not yet synced, sits in a directory the
system is free to delete.

So the account's directory is moved once at startup, before anything opens a
store. A rename rather than a copy: both are inside the app's own data on one
filesystem, so it is atomic and cannot half-finish. An existing destination
wins and the move is skipped — that covers a second run and a fresh install,
and in neither case may this overwrite live data.

A failed move is logged, not fatal. The cost is a rescan, which is
recoverable; refusing to start is not.

Two tests, against ordinary directories rather than the platform's idea of a
cache: one puts an unsynced sidecar in the old location and asserts it is
readable in the new one afterwards, the other pins that live data is never
overwritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:32:20 +02:00
dtourolle 4e62b89d17 Merge branch 'android-collections'
Build and test / Desktop (Linux) (push) Failing after 18m53s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 1m4s
🐳 Android image / Build and push (push) Successful in 6s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Failing after 9m40s
Touch multi-selection in the grid, filing a selection into a collection
without a drag, and taking a collection offline from a held row.
2026-08-17 12:27:49 +02:00
dtourolleandClaude Opus 5 d913e50948 Select photographs with a finger, and take a collection with you
Two things a tablet could not do. Both existed for a pointer and had no
touch form at all, which on Android meant the collection sidebar was
somewhere to look at rather than somewhere to file into.

**Selecting more than one.** Ctrl-click and shift-click are the only ways
into a multi-selection, and touch has neither. Holding a cell now enters
selection mode, where a tap toggles — reported to Rust as a ctrl-press, so
it goes through the same `apply_press` as everything else rather than
growing a second copy of the selection rules. A double tap takes the run
between where selecting began and there: the touch form of shift-click,
and the reason the anchor from *before* the double tap has to be
remembered, since both of its taps move the anchor onto the cell being
tapped. A "Select" button does the same thing where a gesture would go
undiscovered (FR-UI-4).

**Filing without a drag.** A one-finger drag beginning in the grid belongs
to the Flickable that scrolls it — that is the arbitration working, not a
bug to route around — so the selection can now be filed from a sheet
listing the sidebar's own rows. Copy by default, as the drag has always
been; moving out of the collection being shown is a switch, because it is
the one that takes something away.

**Taking a collection offline.** The machinery was there and reachable only
by scoping the grid to a collection and finding a button behind a
disclosure. Holding a collection's name now asks the question directly, and
the tray on a row and the header button ask the same one — three
affordances doing two different things is how a user comes to avoid all
three. The question is asked rather than a toggle flipped because both
answers are expensive: one downloads gigabytes, the other deletes them, and
the counts and sizes go in the buttons where they are read before the tap.

`Cache::release` is new and is the destructive half `unpin` deliberately is
not. "Remove the local copies" is asked by someone whose device is full,
and withdrawing a promise while leaving the bytes for a future eviction to
notice is not an answer to it. It unpins before forgetting, or the next pin
fetch would dutifully download everything it just deleted.

The sidebar's trays read `tier_actual`, never `tier_desired`: the question
is whether these will open on the aeroplane, and a pin whose download has
not run yet answers no.

TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:27:29 +02:00
dtourolleandClaude Opus 5 b8e9793908 Keep offline work out of a directory Android empties
The catalog, the sidecar cache and the export outbox were all landing in the
app's cache directory on Android, where the system deletes them without asking
under storage pressure.

`catalog_path` derived its base from `XDG_DATA_HOME` or `HOME`, and neither is
set on Android, so it fell through to `temp_dir()` — which resolves there to
`/data/user/0/<pkg>/cache`. Confirmed on the tablet: the app logs its catalog
under `cache/darkroom/...`.

What sits beside that catalog is not disposable. `sidecars/` is the commit
point for every rating and edit made with no connection — the whole mechanism
that makes offline culling safe — and `outbox/` holds exports the user has
already been told succeeded. A day of sorting on a train, evicted by an OS
housekeeping pass before it ever reached the server, is the worst failure this
application can have, and it would leave no error and no trace.

The fallback is now `SessionStore::data_dir()`, the persistent per-app
directory the Android entry point establishes before any store opens — the
same one credentials and sessions already use. Desktop is untouched: the XDG
data location is still preferred, so nobody's catalog moves.

Two tests. One asserts no durable path contains `/cache/` or `/tmp/`; the
other pins the outbox to the catalog's parent, because three call sites derive
their location that way and a change here moves all of them at once.

Found while verifying that offline browsing works on the tablet with wifi
disabled — which it does: the app cold-starts with no network, reports the
scan failure without crashing, and serves its grid from the local store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:23:18 +02:00
dtourolleandClaude Opus 5 44f0a4971b Show the folder picker on the platform that needs it most, and upload at once
Two faults, both of my own making, reported from the tablet as "I cannot
select a location" and "it does not upload".

**The picker button was gated on `target-selected == 1`.** That index was
Remote's position while both targets were offered. Making the target list
platform-aware narrowed Android's to Remote alone, so Remote became index 0
and the button disappeared — on the one platform where the picker is the
*only* way to set a destination, since a device folder is not reachable there
at all. It is gated on a boolean derived from the target now. An index into a
list whose length varies is not a fact about the target, and writing it as one
is what made a correct change break the thing it was meant to fix.

**A queued export waited for a sync pass.** Staging first is deliberate — an
export is finished on disk the moment it is written, and offline is then just
a longer queue — but nothing drained the outbox until the next sync, so
"Queued for Exports" sat unchanged and read, fairly, as an upload that never
happened. A finished batch that wrote anything now drains immediately. The
sync-pass drain stays: the first makes an upload feel immediate, the second is
what eventually delivers the exports made in a tunnel.

Committed without the parallel session's in-flight collection work, which is
mid-save and does not compile; verified by stashing it and building this tree
alone. 281 dr-ui tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:01:12 +02:00
dtourolleandClaude Opus 5 a8b28136a6 Merge the batch export, and settle the seven-branch merge
Resolves the last of the parallel work. Two conflicts worth recording,
because both were semantic rather than textual:

`render_for_export` gained a colour space on master while the batch branch
was rewriting the single-image export path around it. Kept both: the batch
request supersedes the synchronous path, and the space still has to be chosen
at render time because the conversion happens in the shader before the clip to
0..1. `render_open_frame` takes it as an argument rather than reaching for a
controller it does not hold.

The map-wait moved into `readback::await_mapping` on one branch while another
was editing the constant it used, so `READBACK_POLL_LIMIT` survived the merge
with no callers. Removed rather than left for clippy to find later.

1164 tests pass, clippy clean, fmt clean. Traceability 53.0% -> 54.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:11:44 +02:00
dtourolle cfff6a3302 Merge branch 'zero-copy-display'
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	ui/dr-ui/src/develop.rs
2026-08-17 10:04:14 +02:00
dtourolle 9fc8721fa8 Merge branch 'histogram'
# Conflicts:
#	ui/dr-ui/src/lib.rs
2026-08-17 10:00:27 +02:00
dtourolleandClaude Opus 5 7d3c8c521f Export a whole selection, on a thread that is not the interface's
The export button rendered, resampled and encoded a 24 MP frame on the UI
thread and the window was dead for all of it. That was written down as a known
compromise, on the grounds that a batch is what makes the wait intolerable
rather than merely noticeable. This is the batch, so the compromise comes due.

The grid's selection now exports (FR-EXP-7). A worker thread takes a clone of
the `GpuContext` — an `Arc` pair over a device and a queue — and opens each
photograph for itself: fetch, sidecar, decode, demosaic, render at full size,
resample, sharpen, encode, write. Nothing of that touches the interface, which
keeps drawing throughout, and the progress goes where every other background
job's does: one row in the activity register, with a count and a bar.

Why the worker does not borrow the session it could have had. A
`DevelopSession` owns the `AdjustPass` the canvas renders from, so handing it
to a worker would stop the develop view drawing for the length of the batch —
the same freeze, moved. Opening a session per image instead costs a
`Demosaicer` and an `AdjustPass` each time round, and the pipeline cache is
per-pass so the composed shader is recompiled per image rather than once for
the run. Against a full-resolution decode, render and encode that is a few
percent, and it keeps this file out of the pipeline `develop` owns. A reusable
export pass is the obvious next economy if a profile ever says so.

The open image is the exception, and it is why the develop button is not simply
a one-image batch. Its edit lives in the interface's session and may not have
reached a sidecar yet, so a worker that re-opened the file would export the
saved version rather than the one on screen. That frame is therefore rendered
by the caller and handed over as `Source::Rendered`; everything after the
render — the Lanczos reduction, the encode, the write, which is the larger half
of the wait and all of its variance — still leaves the UI thread. So the
develop export is no longer synchronous, but it is not fully off-thread either,
and the doc comment says so rather than claiming otherwise.

Cancellation (NFR-ARCH-3) is an `AtomicBool` read between stages, and the
export button becomes the cancel button while a run is live — a batch that
could only be stopped by not touching the selection would be a trap. Waits on
another worker use `recv_timeout` rather than `recv`, so a cancelled batch
sitting on a forty-megabyte download gives up within 100 ms instead of when the
transfer finishes. The honest bound is worse than that: a frame already in
render has no interior stopping point, so the worst case is one image. Closing
that needs the render itself to become interruptible, which is NFR-ARCH-2's
scheduler and not a finer poll here.

Failures are per image and typed (NFR-ARCH-4). One unreadable body, one folder
that cannot be written, one server that went away — each is a message on the
channel, a line in the log, and a count in the summary, and the batch carries
on. A run with any failure keeps its row until it is cleared, because that is
the row somebody came to the list to find; a cancelled run does not, because
they asked for it.

Two collisions that look alike and are not. `CollisionPolicy` is the user's
answer to "a file of this name was already there", and Overwrite is a fine
answer to that. It is not an answer to "the frame I exported four seconds ago
was also called this" — two folders in a library each holding an IMG_0001 is
ordinary — so a name the run has already issued is always stepped past whatever
the policy says about the folder. Both halves are held by tests.

Supporting changes, each smaller than it sounds. `open_session` comes out of
`load_bytes` so the worker shares the JPEG-versus-RAW routing rather than
carrying a copy that would drift; the half that builds a `slint::Image` stays
behind, where it belongs. `LibraryController::selected_image_paths` answers
from the catalog rather than from the loaded window, because selection is by id
and survives a scrub — a selection made before scrolling routinely names
photographs no row holds. `cache_context_for` takes an id for the same reason,
so a batch reads the originals cache instead of re-downloading three hundred
files. `format_date` is shared so `{date}` and the timeline agree about what
day a photograph was taken.

Left undone, deliberately: the batch is sequential, where FR-EXP-7 asks for all
available cores. Four full-resolution frames in flight is tens of megabytes
each and a straightforward way to exhaust a tablet, and the GPU is shared with
the interface in any case. Also undone: exporting with a chosen preset rather
than the current export settings — that is FR-EXP-5's machinery, which does not
exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:59:57 +02:00
dtourolleandClaude Opus 5 0233df4bf2 See what the highlights are doing: a live histogram (FR-DSP-7)
Exposure, blacks and whites were set by eye. Nothing said a highlight had
blown — the canvas shows white where a channel is at 250 and white where it is
at 255, and the difference is the whole question.

**Counted on the GPU, not on the readback.** There is a full frame sitting in
CPU memory on every canvas update right now — `AdjustPass::read_output`, the
bridge spike S1 removes — and walking it would have been thirty lines and no
shader. FR-DSP-7 states the mechanism and not just the feature: "these derive
from a GPU-side reduction into a small buffer. Per-frame CPU readback of image
data is prohibited." A histogram founded on the bridge would be correct today
and deleted by S1, and would meanwhile be the reason the bridge could not go.
What crosses the bus here is 4104 bytes whatever the image size.

The reduction tallies into workgroup memory first and merges once per
workgroup. A photograph is not noise: a clear sky puts tens of thousands of
adjacent pixels in one bin, and contending for that single global atomic
serialises the dispatch.

**On the settled frame only.** `render_now` already knows whether a gesture is
still moving — `draft` is the flag `redraw` derives from `was_coalesced` — so
the dispatch and its transfer happen once when the slider stops rather than on
each of the forty frames a drag emits. Nothing is lost: a histogram flickering
past under a finger is not a reading anyone takes. FR-DSP-7 requires exactly
this, that it not extend the FR-DSP-3 frame budget.

Luma is weighted in 8.8 fixed point — 54, 183, 19, summing to 256 exactly —
rather than in floats. Not thrift: it makes the shader's arithmetic
reproducible bit for bit, which is what lets the test below be an `assert_eq`
against a CPU count rather than a tolerance. ARCH §6.13's line about integer
state, applied where it happens to also be free.

**What the numbers were checked against.** A flat frame must put all 4096
pixels in one bin and one only. A 256-wide ramp must occupy every level with
exactly the same count, which is what catches an off-by-one in the
quantisation — a `floor` where a rounding was needed shifts the whole
photograph one bin left and looks like nothing at all. And a 101x37 frame of
seeded pseudo-random pixels — deliberately not a multiple of the 16x16
workgroup, so the edge tiles run off the image — is compared slot for slot
against a second, obvious CPU implementation. Exact equality, no tolerance.
The CPU version is a deliberate reimplementation rather than shared code: the
bugs worth catching here are ones shared code would commit identically on both
sides.

Above that, the presentation arithmetic is unit-tested headless, because it is
where a wrong answer is invisible. A histogram of the wrong shape looks exactly
as plausible as one of the right shape. So: 64 columns because it divides 256
and an uneven fold draws an even ramp as a comb; the peak excludes the end
columns, or a night scene scaled against its own black spike is a flat line
with no information in it; heights are clamped into the plot; and "0%" is kept
distinct from "<0.1%" and from "—", since an indicator reading "clipped" over
a figure reading "none" is a panel contradicting itself.

Clipping counts a *pixel* with any channel at an extreme, not a channel. Any,
because a blown red has no gradation left in it however much green and blue
still hold — and it is the saturated highlight, the sunset and the red jersey,
that clips first and recovers worst. Per pixel, because counting channels can
report 200% of a frame clipped, and a percentage above 100 is a readout nobody
trusts again.

Two affordances for it, which NFR-A11Y-3 asks for: a bar standing at the end
of the plot the tones are piling against, and a figure saying how much. Either
alone reads.

The panel sits directly under the capture metadata and above every control,
because it is what the controls are judged against. It is hand-built rather
than generated, and ARCH §4.3a is untroubled: a histogram is not an operation
— no parameters, changes nothing, answers a question rather than asking one —
and nothing in it reads a parameter out of a descriptor.

Three plot colours and a neutral luma trace join the palette. That is the
swatch's exception rather than a second one: a per-channel histogram has to
say which channel, and no achromatic treatment distinguishes red from blue, so
the hue is data exactly as the image beside it is. Held well back from full
strength for the reason the theme preamble gives.

The bounded, non-parking map wait moves out of `AdjustPass` into
`readback::await_mapping`, shared with the histogram's transfer. Thirty lines
of load-bearing reasoning about frozen interfaces and lost devices, and two
copies of it would have drifted.

The histogram describes the frame on the canvas, so it is in the output colour
space FR-DSP-7 asks for, and when zoomed it describes the visible region — a
photographer inspecting a highlight at 4x is asking about that highlight. A
device that cannot build the reduction loses the histogram and keeps the
photograph.

Still to do for FR-DSP-7: the pixel colour readout under the cursor.

324 tests pass, clippy and fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:55:40 +02:00
dtourolleandClaude Opus 5 cf8f5b632f Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.

Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.

**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.

dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).

**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.

**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.

**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.

`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.

**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.

Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:

- the adjust target satisfies every condition Slint's import checks, asserted
  in the crate that owns the descriptor, because a descriptor that drifts
  fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
  again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
  AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
  the repaint actually depends on.

The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.

**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.

56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:54:00 +02:00
dtourolleandClaude Opus 5 4b36ca66aa Render an export into the colour space its file will claim
The colour-managed export branch left one call site deliberately unfixed, and
this is it. `render_for_export` composed with the default sRGB shader, so a
Display P3 export failed with an accurate error rather than producing a
mislabelled file — the right way to leave a half-finished path, and no way to
leave it.

The space is chosen at render time because that is the only time it can be:
the conversion happens in the shader, before the clip to 0..1, so by the time
pixels reach an encoder they are in exactly one space and the only honest
thing left is to label them. `Frame::in_space` carries which, and a mismatch
between what was rendered and what was asked for stays a typed error.

Also regenerates the traceability matrix over the four merged branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:34:57 +02:00
dtourolle 8e330a24e9 Merge branch 'undo-redo' 2026-08-17 09:26:23 +02:00
dtourolleandClaude Opus 5 8ea92545df Stop the export folder forgetting itself, and let Android reach one
Three faults, compounding into an export that could not be made to work on a
tablet at all and a destination that appeared to reset on its own.

**Switching target destroyed the destination.** One field held both a
filesystem path and a remote folder, so changing the target had to clear it —
`/home/x/Exports` carried to the server would have offered to create a folder
called `home` at the library root. The consequence was that merely looking at
the other option threw away the destination already chosen, which reads,
correctly, as a setting that will not stick. There are two fields now. Each
target remembers where it was pointed and switching is free; `active_destination`
picks between them so no caller can reach for the wrong one.

**The library root read as "unset".** The picker opens at the root, so
confirming it where it opens stored an empty string — indistinguishable from
"ask each time" and looking exactly like the picker had done nothing. Empty
now means the library root for a server destination, which is a real folder
and the one the photographs are already in; it means "ask" only for a device
folder, where no path is worth assuming. The page labels it so.

**Android defaulted to a target it cannot use.** A device folder there means
the Storage Access Framework, which provides no filesystem path (ARCH §6.9)
and is not implemented — so the default target could never succeed however the
destination was filled in. The export button said "no export folder is set",
the settings page offered no way to choose one, and the only way out was to
guess that the other target was the working one. The device target is now
absent from `ExportTarget::available()` on Android and the default there is the
server, which needs no platform work at all. A settings file carrying an
unreachable target — copied from a desktop, say — is corrected on read rather
than left to fail at the last step.

Seven tests, each named for the fault it prevents returning. The compatibility
one matters most: a file written before `remote_destination` existed keeps its
device path and gains an empty remote one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:25:15 +02:00
dtourolleandClaude Opus 5 7f524d2fd0 Give a mis-drag a way back
Develop edits now save themselves to a sidecar the moment you leave the
image, so until this there was no way to undo one — the mistake was
persisted and the only recourse was to remember the old number.

The history is a stack of snapshots, because the edit graph is already
plain data: `Preset::capture` reduces it to what differs from default and
`Preset::apply` puts it back, so undo is those two calls and nothing else.
A command object per action, with an inverse beside it, would have been a
second thing every operation had to register — and operations are declared
in YAML precisely so that a new one needs no code written for it. A
snapshot cannot fall behind them.

The interesting part is coalescing. A slider drag emits an event per frame
and must be one step, not forty. Nothing in the interface reports a gesture
boundary — the same wall the render coalescing hit, and it is answered the
same way rather than by threading a "finger is down" out of every slider,
curve point and crop handle. What stands in for the boundary is the control
plus recency: changes to the same control within 700 ms amend one step.
Which control is "the same" is asked of the graph, not listed: an operation
whose declared presentation claims a parameter is one where a single
gesture moves several — a curve point carries an x and a y — so those
coalesce as one widget. Nothing in the history names the tone curve.

The compromise, and it is a real one: a control let go of and picked up
again within the window is one step rather than two. Buying the other
answer costs a gesture-boundary signal on every control, which is more
surface than the difference is worth.

The stack is bounded at 64 states for NFR-RES-1 — a develop session stays
open for hours. Sixty-four rather than a byte cap: what is being bounded is
steps a photographer would want back, and a byte cap would give the
elaborate edit the shallowest history, which is exactly backwards.

The session owns its history and every mutator records into it, so the
callbacks in `lib.rs` cannot change the edit and forget to — with a dozen
generic callbacks that would have been one press of undo away from wrong
every time a control was added. Opening a photograph makes its stored edit
the floor rather than a step: it is not work done in this sitting, and an
undo reaching behind it would discard a previous session's edit and then
save that on the way out.

Not yet done, from FR-DEV-5: history is per-session and in memory, and
there are no named snapshots. What mattered was that a saved mis-drag had
no way back at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:03:34 +02:00
dtourolleandClaude Opus 5 2330ed25e9 Let a grouped slider be dragged, by flattening the panel that drew it
Build and test / Desktop (Linux) (push) Failing after 1h4m48s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 9m43s
White balance, highlights and shadows, and the mixer took a press, jumped once,
and went dead under the finger. Exposure and contrast dragged perfectly — which
is what made it read as a slider bug rather than a layout one.

The panel nested. A group's head row drew the *whole* group, repeating over
`row.group-len` and indexing back into `root.rows`; every other row drew
nothing. So the inner repeater's model was read off the head row, and depended
on that row's identity. Moving any parameter in the group rewrites that row —
its own value changed, or `group-modified` flipped for its neighbours —
which re-evaluated the repeater, rebuilt its items, and destroyed the
`TouchArea` holding the live gesture. A lone parameter had no inner repeater,
so the five single-parameter operations were never affected.

Now each row draws only itself, so an update touches one control and nothing
structural. The facet heading comes off `starts-facet`, which Rust already
marks on the first row of a run, and the group heading is drawn by the row that
heads it. It also retires the old hazard of a head row building every control
in its group — thirty-six live TouchAreas behind the mixer's twelve visible
ones.

The same identity hazard reached the rows themselves through `ModelRc`, which
compares by identity rather than contents: a fresh empty model per row per call
made every row differ from itself, so `sync_rows` rewrote all of them on every
event. `points` now shares one empty model as `choices` already did.

Both are held by tests, because the failure is invisible in a still — every
value is right and the panel looks perfect. Curve rows are excluded: their
points model carries live coordinates, is rebuilt by design, and `sync_rows`
writes values through the existing model rather than swapping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:23:01 +02:00
dtourolleandClaude Opus 5 40d4e439a9 Drop the parameter the offline sidecar writer never used
`write_one_sidecar` took an `Option<&NextcloudBackend>` it ignored. It was a
leftover from the shape the write path had before online and offline separated
into two functions: the offline one records to the cache and queues, and has no
server to talk to by definition. A parameter that is always `None` and always
unused says the opposite — that there is a case where it is `Some` — and the
next reader has to check.

The closure it was threaded through is renamed to say what it does rather than
how it is called. `run(None)` needed the reader to know what `None` meant;
`queue_all()` is the sentence.

Also regenerates the traceability matrix, which now records FR-CAT-9's queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:17:31 +02:00
dtourolleandClaude Opus 5 cb1d2be240 Choose the export folder by walking the server, not by typing it
The destination for a Nextcloud export was a text field. Nobody recalls the
exact spelling of a path three levels down, and getting it wrong does not
fail — `create_dir` makes whatever was typed, so a misremembered folder
becomes a new one at the root and the exports are somewhere nobody looks.

So it is picked the way the library root is picked, using the same
`FolderBrowser` model the launch screen drives: up, into, and "use this
folder", confirming the folder currently *shown* rather than one selected in
the list. Same rule in both places, so the phrase means one thing.

The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a
near-twin of the launch screen's, because that one reaches into the
`LaunchController` for its session and reports onto the launch screen's error
line, while this one is handed credentials and writes to the settings page.
Factoring them together needs a function taking both controllers or a trait
implemented twice to abstract two call sites — more machinery than the twenty
lines it saves. What matters is shared already: navigation behaves identically
because both drive the same model.

The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`,
because listing a remote folder needs credentials and the settings page holds
no session on purpose — it is reachable before a library is opened and must
not depend on one existing. With no account the picker says to sign in first,
rather than showing an empty list that reads as a server with no folders.

Details that are decisions rather than accidents: the picker opens at the
library root rather than at whatever half-typed path is in the field, which
would list nothing and look broken. The listing area is a fixed 180px, since a
folder with sixty children would otherwise push the rest of the settings page
off the bottom. "Up" is disabled at the root rather than hidden, so the row
does not jump as the user navigates. A failed listing leaves the picker open
on the folder it was showing — where the user had got to is not something to
discard over a dropped request. And the chosen folder saves immediately like
every other setting on a page that has no Save button.

The poll timer lives on the controller for the reason `LaunchController` keeps
its own there: a `slint::Timer` stops when dropped, so one local to the
function that starts it would be collected before the listing arrived.

Carries in-flight work from a parallel session — a segmentation pass in
dr-gpu, a sidecar cache, and the develop panel's continuing changes.

1020 tests pass, fmt clean. One clippy warning remains and is not mine:
`sidecar_cache::dir` is unused while that work is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:12:01 +02:00
dtourolleandClaude Opus 5 e00c99b864 Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is
the button, and the place the bytes go.

**Everything is staged first.** An export bound for the server is written to a
local outbox and uploaded afterwards; offline is not a special case, it is the
same path with a drain that finds the server absent. Doing it the other way —
upload directly, stage only on failure — makes the failure path the one that
is rarely exercised and always broken, and a network drop mid-batch leaves
some exports existing and some not with nothing recording which. Staged first,
an export is finished the moment it is written and the upload is a promise
kept later.

The outbox sits beside the catalog rather than under the cache. dr_catalog's
cache already draws that line: passive entries are a convenience and go under
LRU, pinned ones are a promise and never do. An export awaiting upload is a
promise — the user was told it succeeded — and sweeping it for disk would
destroy the only copy. Bytes are written before the destination record, so a
kill between the two leaves an orphan the drain ignores rather than a record
pointing at nothing.

The status line says "Queued for Exports/2026", never "Exported to Nextcloud",
until it has actually landed. There is a test asserting that wording, because
the tempting shorter sentence is a claim the app cannot keep.

The drain runs on the sync pass, before the shards: a thumbnail shard can be
rebuilt from the originals and the catalog is an index, but a queued export
exists nowhere else.

`DevelopSession::render_for_export` renders the framed size rather than reusing
the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding
that would hand the user a soft, screen-sized file with nothing to say anything
had been lost (FR-EXP-9).

One compromise, recorded rather than hidden: the export runs synchronously on
the UI thread, so the window is unresponsive for the few hundred milliseconds
a full-resolution render and encode takes. Moving a DevelopSession and its GPU
pass to a worker is a larger change than one button earns, and it is batch
export that makes the wait intolerable rather than merely noticeable.

Still missing: the Nextcloud folder *picker*. The destination is typed into
Settings for now. `FolderBrowser` in launch.rs is already the reusable model
for it — it browses a remote tree and nothing about it is specific to choosing
a library root — but wiring it into the settings page needs a listing worker
and browser UI there, which is its own piece of work.

Carries in-flight work from a parallel session — presets, the develop copy and
paste, and the node schema's `presentation` and `enum` support. One misplaced
callback in settings_ui.rs is moved from `render` to `wire`: registered in
`render` it borrowed a `&SettingsController` into a 'static closure and would
not compile, and that file's own docs say render pushes properties while wire
connects callbacks.

992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:58:17 +02:00
dtourolleandClaude Opus 5 69b12e327f Let framing say it wants the canvas, instead of the panel knowing
The generated panel opened with a special case:

    if op.id == dr_pipeline::framing::ID { continue; }

and a paragraph explaining that framing's eight parameters are eight bad
controls — four crop edges you would have to type coordinates into, a "rotate"
slider running 0..3, two switches — so `GeometryPanel` presents them as the
gestures they are instead.

Every word of that is true, and none of it was the frontend's to know. It is a
fact about the operation, and ARCH §4.3a is explicit that a frontend deciding
things by *naming a stage* is the boundary being crossed: a second frontend
would have had to learn the same special case, and nothing in the capability
output said why it existed.

Framing now declares a `Presentation` preferring `WidgetKind::CropOverlay`,
with a demand of two-dimensional dragging and — deliberately — no precise
pointing, since FR-UI-7 grows the handles to the modality and a crop is
forgiving. The widget owns all eight parameters rather than only the rect: a
frontend taking this on takes the whole framing control surface, and leaving
rotation and the flips behind would scatter them into the generated panel
underneath a crop control that already exists.

The panel's rule is now general. `supported` answers whether a kind is
implemented *anywhere* — drawn in the panel like the tone curve, or hosted on
the canvas like the crop — and `is_on_canvas` settles which afterwards, so the
two cannot disagree about the same kind. Any stage preferring an on-canvas
widget is skipped, with nothing named. A frontend that implements neither still
gets the eight sliders: tedious, complete, and the guarantee the whole hint
mechanism rests on.

**The tests were asserting against the wrong thing.** `rows_of` was a
hand-written simulation of the row generator, complete with its own copy of the
framing skip, so the suite was checking a second implementation kept in step by
hand. It was not in step: giving framing a presentation changed the real panel
and the simulation disagreed, which is exactly how a green suite hides a
regression. It now calls `rows_from`, and `rows_of_unfiltered` is gone.

`framing_is_not_generated_as_sliders` survives but asserts by routing rather
than by counting — no row may carry framing's capability index — so it cannot
be satisfied by two miscounts cancelling out. Alongside it, an invented stage
preferring a widget this frontend lacks, falling back to one the canvas hosts,
must also be skipped: if that ever needs a name added to pass, the special case
has grown back.

Not included: moving the crop overlay's markup out of app.slint into
controls.slint. It is cosmetic next to the above and app.slint is in another
session's working set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:29:23 +02:00
dtourolleandClaude Opus 5 0a331c717e Give the controls a vocabulary, and let a node ask for one
widgets.slint set the rule — screens consume components, and a bare `Theme.*`
at a call site means a component is missing — and it set it for chrome only.
The controls never got the same treatment, so they were written wherever they
were first needed and copied from there.

**The slider was private to the develop panel.** `SliderTrack`, with the
fifty-line preamble explaining how it wrests a drag away from a Flickable,
lived inside adjust.slint and no other screen could reach it. It shows: export
quality is a 1-to-100 value, and the settings page offered a free-text box for
it, with the range written in a hint and enforced nowhere. `to-float()` answers
0 for anything it cannot parse, so a typo saved a quality of 0 and the page
displayed the 0 back as though it had been asked for.

The tick-box was written twice, in launch.slint and settings.slint, from the
same 18px box and the same handler; the second carried a comment deferring the
lift until a third caller appeared. The label-and-hint header was written three
times inside settings.slint alone.

controls.slint is the input layer beside widgets.slint's chrome layer, and the
constraint that makes it reusable is that **nothing in it knows about
`ParamRow`** — that struct is the develop panel's flattening of the capability
model, and a control that imported it could only ever be used by the develop
panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in
the panel that owns the model. 658 lines came out of the three screens.

`SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer
presentation of a bounded scalar, and quality is its first adopter. It commits
on gesture end rather than on every movement, because the settings page saves
to disk on change and a two-second drag is a couple of hundred writes where a
text field committed once. The develop panel keeps the live stream — that is
what its pipeline is for — so `SliderTrack` now reports both.

**The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify
more than was built: an ordered preference list of widgets rather than one, the
demands a widget makes, and kinds beyond scalar and bool.

- `Presentation.widgets` is now a list, walked by `choose`, falling back to
  plain sliders. Falling off the end is not an error, and there is a test
  asserting an operation asking only for an unimplemented widget still yields
  one control per parameter.
- `WidgetDemand` carries what a widget inherently needs — two-dimensional
  dragging, precise pointing — and no pixels, breakpoints or platform names.
- `WidgetKind` grows to the specified set. There is deliberately no `Colour`
  *kind*: a colour is three numbers, and a value type that is not an `f32`
  would reach through the graph, the uniform block and the sidecar format to
  buy what `ColourWheel` over three scalars already describes. Every widget
  here is a hint over ordinary scalars, which is what keeps the fallback
  honest.
- `ParamKind::Enum` is the one new shape, and it fits because a variant index
  is exact in binary32. `kind: enum` with a `variants:` list works in
  `ops/*.yaml`, so a node declaring one gets a segmented control with no UI
  file edited — which is the promise ops/mod.rs already makes.

The panel's dispatch was duplicated: a lone parameter and a grouped one each
wrote out their own list of kinds, so `enum` would have had to be added twice
and a kind added to one would appear or vanish depending on how many parameters
its operation happened to declare. `ParamControl` is now the only such chain.

`rows_from` is free-standing rather than a method, which is what lets the
FR-DEV-3c acceptance test requirements.md asks for actually be written: an
operation the frontend has never heard of, appearing in a generated panel, with
no GPU in sight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:21:35 +02:00
dtourolleandClaude Opus 5 e7130ff891 Give the library header somewhere to put six buttons
A tablet in portrait is 1920 physical pixels at density 400 — 768 logical,
which is below the 820 breakpoint, so the library was already taking the
compact layout. The compact header simply was not compact: one flat
HorizontalLayout holding a menu button, a title, three status readouts and
six buttons.

Slint's HorizontalLayout has no wrap and no overflow. Given less width than
its children want it shrinks each to its minimum and lets the rest run past
the edge, so "Change library" arrived as "Change li…", the readouts elided to
nothing useful, and the row overflowed anyway.

The six actions move into a `HeaderActions` component drawn in one of two
places: inline in the header when expanded, and in a disclosure row under it
behind a "More" button when compact. One component rather than two copies,
because the alternative is six buttons with their visibility rules and
callbacks written twice, and the copy that rots is the one behind a
disclosure nobody opens while testing.

The row closes itself when the window widens, so rotating to landscape does
not leave a stray toolbar behind. "More" is a word rather than an ellipsis
glyph: "⋯" sitting in a header of elided labels reads as one more truncation,
which is the failure this change exists to remove.

`LibraryGrid` takes `expanded` to decide, from the window width rather than
the device (FR-UI-1) — so a narrow desktop window gets the same treatment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:04:06 +02:00
dtourolleandClaude Opus 5 9e47133304 Run the formatter over the shard-naming change
Build and test / android-image (push) Canceled after 1m38s
Build and test / Android (aarch64) (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Canceled after 1m34s
Build and test / Layer separation (push) Canceled after 0s
🐳 Android image / Build and push (push) Canceled after 1m38s
Traceability / Requirement traces (push) Successful in 1m2s
`cargo fmt --all -- --check` is a CI gate and 9a51cc8 landed two files past
it, so the build has been red on master since regardless of what came after.
Whitespace only — a wrapped `Ok(...)` and two `assert_eq!`s split across
lines. No logic is touched and the tests are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:29:28 +02:00
dtourolleandClaude Opus 5 151dcc3c02 Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.

**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.

Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.

Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.

Three things are honest rather than done:

  - **Colour space.** sRGB only. The shader encodes and clips to sRGB before
    this crate sees a pixel, so tagging a file Display P3 would claim a gamut
    it does not contain. Refused with a typed error instead of mislabelled;
    honouring it is a pipeline change (FR-EXP-2).
  - **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
    enough to change what a batch feels like, and the settings page offers
    both because FR-EXP-1 lists them — so asking for one says so rather than
    writing a JPEG under a .avif name.
  - **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
    because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
    white lands on 65535 rather than a quarter-percent grey. Making it mean
    what it says needs the composer told what format to write.

Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.

Also here:

  - `AdjustPass::export_pixels`, ungated where `read_output` is behind a
    feature. The two are the same transfer and opposites in intent: reading
    pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
    against, while reading them back to encode a JPEG is the only way a file
    has ever been made. Separate methods so the instrumentation can count one
    without counting the other.
  - `ExportTarget`, so a destination can be a folder on the server. On Android
    that is the only destination needing no platform work whatsoever — a PUT
    against create_dir, already on the RemoteBackend trait, behaving
    identically on both platforms. Switching target clears the destination,
    since a path is not a remote folder and carrying one across would offer to
    create a folder called `home` at the library root.

Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.

Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:26:37 +02:00
dtourolleandClaude Opus 5 b08e94405c Give the activity row a name Slint will accept
`ActivityItem inherits VerticalLayout` declared `in property <ActivityRow>
row`, and every element that can sit in a GridLayout already carries a `row`
for its grid placement — so the declaration was an override of a built-in
rather than a new property, and the compiler refused it. dr-ui had not built
since.

Renamed to `job`, which is also the better word: the property is one
background job, and `row` described where it was drawn rather than what it
holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:33:45 +02:00
dtourolleandClaude Opus 5 9a51cc88d6 Give a shard's remote name the client that wrote it
Build and test / Desktop (Linux) (push) Failing after 50s
Build and test / Layer separation (push) Successful in 24s
🐳 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 9m39s
Shard ids are per store: every client fills its own numbering from 0, so
"shard 3" names different thumbnails on every device. The derived sync
published them into a flat shard-NNNN.sqlite namespace anyway, which left
two clients writing one name.

Both failures that follow were live. On upload, a client's open shard
overwrote a peer's file of the same id — content the peer still believed
was published and would never restore, because its own copy was sealed and
the name existed. On download, the loop skipped any remote id it already
held locally, which is the only safe reading of a name that says nothing
about who wrote it, so a client holding shards 0..5 never fetched the
peer's 0..5 at all. Between them, two populated clients exchanged almost
nothing: only shards numbered above the other's highest. A fresh device
worked, having no local shards to collide with, which is why this went
unnoticed — it is exactly the case the feature was written for.

The name is now shard-<client>-NNNN.sqlite. The client id is minted per
store in index.sqlite, beside the numbering it qualifies rather than in
settings: a store deleted and rebuilt restarts at shard 0 and must not
claim the remote names its predecessor wrote. Since our own ids now say
nothing about what we have taken from others, index.sqlite also keeps a
ledger of adopted remote names and the size each had when merged. A size
rather than a flag, because a peer's sealed shard never returns but its
open one grows, and re-merging the grown copy is how the thumbnails it
gained since arrive.

Flat names already on servers still parse, reporting no owner, so each
client adopts them once, and nothing is written under that form again. One
whose id and byte size match a local shard is that client's own earlier
upload by the same identity argument the upload path already makes for
sealed shards, so the rename does not cost every client a re-download of
its whole store. Older builds ignore the new names and stop receiving
shards until updated; their own uploads are still adopted, so nothing is
lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:15:12 +02:00
dtourolleandClaude Opus 5 f7e8cc99b1 Call them marks, not glyphs, now that they are drawn
The last comment left over from the icon change in 65e6a96, which moved
the star strip off Text and onto drawn paths. "Glyph" now names something
this file no longer has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:14:58 +02:00