Commit Graph
652 Commits
Author SHA1 Message Date
dtourolle 2d878c2117 Offer the film stock in its own group, and let its list scroll itself
Two faults in one control, both reported from the tablet.

The stock picker appeared in every group. It is not a parameter, so it is not
a row, so the filter that hides every other control when a group is chosen
never saw it — "Kodachrome" sat at the top of Light, of Colour and of Detail
alike. Three places it does not belong, and the one it does no more prominent
than the rest. The descriptor has said `Effect` and only `Effect` since the
film moved there; nothing was asking it.

So the panel now asks. It cannot ask directly — a generated panel may not know
which operation a control belongs to — so the session answers, from what the
operation declares it is about, and a stock re-declared as something else would
move on its own. The flag is recomputed when the group changes as well as when
the film does, which is the half that would have made it stale exactly when it
mattered.

And the open list was unbounded, so it made the develop column taller and the
column scrolled as one: reaching Velvia dragged every slider below it off the
screen, an answer given once pushing aside the controls used constantly. It now
scrolls within a bounded height of its own.

That viewport is counted rather than measured, for the reason the tool rail
records a few files away: a viewport that asks a layout how tall it wants to be,
while the layout takes its height from the viewport, is a cycle Slint settles by
handing back the height it was given — and the content is then clipped in
silence rather than scrolling. Every row here is one fixed height, so
multiplying is exact.

The group rule has a test. The scrolling does not, and cannot: it is a layout,
and a layout fault is invisible to the compiler and to every assertion that can
be written about it.
2026-09-07 20:42:15 +02:00
dtourolle 4c217c9be6 Show what a control does to a photograph, one parameter at a time
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 56s
Build and test / Layer separation (push) Successful in 37s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 54s
Build and test / Android (aarch64) (push) Successful in 26m29s
A node that has just been declared can be read, reasoned about and tested, and
none of that answers the question a photographer asks first: what does moving
this do to the picture. Colour grading, dehaze and the range masks were all
argued into the tree on their behaviour and none of them had been *looked* at.

So two diagnostics. `sweep` walks one operation from its minimum to its maximum
and writes a frame per step; `rangesweep` does the same for a mask band, which
is not an ordinary parameter — it lives on a layer, is rasterised by its own
pass, and only becomes visible through whatever adjustment the layer carries,
so it gets two stops down to make the selection legible.

`sweep` names no operation. The id arrives as a string and the parameters and
their ranges come from the graph's own capabilities, so a node declared
yesterday sweeps on the same terms as one that shipped a year ago — the
property `ops/README.md` promises, used rather than asserted.

Three things it learned the hard way and now records. It renders through
`render_detailed` unconditionally, because the fused path refuses a shader
composed with a detail stage rather than rendering it wrongly, and that call
falls through when there is no such stage. It takes `SWEEP_HOLD`, because a
parameter grouped under one widget is not meaningful alone: a hue with no
strength behind it renders the same frame every time, which reads as a broken
node rather than a correctly declared neutral. And it bounds the output, since
a 25 MP frame is a 75 MB PPM and a sweep is hundreds of them.

Both read a rendered file as readily as a raw one, so a JPEG can stand in where
no raw is to hand — on the terms `from_rgba8` documents, with the controls
still working and their neutral being what the camera left rather than what the
sensor recorded.
2026-09-07 20:01:08 +02:00
dtourolle e44cb8cbe0 Regenerate the traceability matrix for the rebased line numbers 2026-09-07 20:01:01 +02:00
dtourolle 0a5eab0487 Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.

So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.

There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.

`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.

Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
2026-09-07 20:01:01 +02:00
dtourolle dd14243dba Lay the develop view out by coordinate, so the column can sit below
Slint cannot turn a layout on its side, and that is what D-N7 asks for. So
the HorizontalLayout holding the rail, the canvas and the develop column
becomes a plain Rectangle and each of the three states its own x, y, width
and height. With `column-below` false those come out where the layout put
them to the pixel — a HorizontalLayout has no spacing or padding of its own,
the rail and the column took their declared widths at the two edges, and the
canvas was the only child that stretched.

The alternative was the column subtree declared twice under two `if`s, which
is four hundred lines of bindings copied, in a file whose own notes record a
conditional child in a layout as the shape that has produced binding loops
here before.

The column is the same column either way: the same contents, the same
Flickable, the same toggle. `panel-visible` collapses the dock's height
exactly as it collapsed the column's width, so `column-width` and
`dock-height` are each zero unless the column is both open and on that axis,
and the canvas can subtract both without asking which case it is in. The
stack inside stretches to the dock's width on its own — a layout that is the
direct child of a Rectangle fills it, and the Flickable's viewport was
already bound to its own width. Nothing is reflowed; N9 does that.

`dock-height` is mandated in style.yaml for the reason `panel-width` beside
it is, on the other axis: a dock that sizes itself to its contents is a
photograph that changes height when a caption wraps. 480 until N6 measures
the device.

The seam follows the column round: a hairline down its left edge beside the
photograph, along its top edge under it, so it stays between the two.
2026-09-07 20:00:50 +02:00
dtourolle 5f0b11c1f4 Ask the window how tall it is, and say when the column belongs below
D-N7 puts the develop column under the photograph on a tall window, and the
axis it turns on is aspect rather than width: a 960-wide portrait tablet is
expanded by width and wants the dock, a 1500-wide landscape desktop is
expanded by width and does not. So this cannot be folded into the layout
class, and it is not remembered per class either — closing the column in
landscape closes the dock in portrait, because it is the same column.

`window-resized` reported width alone and now reports both, from a
`shell-height` that subtracts the safe-area insets exactly as `shell-width`
subtracts them: on Android the strips the status and navigation bars occupy
are on the axis being measured, so the aspect of the window and the aspect of
the space the interface actually gets are not the same number.

`column_below` is the decision, with two thresholds rather than one. It is
read on every resize event, and a single threshold means a window dragged
along its own diagonal crosses it several times a second while the pointer is
still down. Entering at 1.25 and leaving at 1.15 is a dead band no plausible
drag re-crosses.

The comment on EXPANDED_MIN_WIDTH claimed a tablet in portrait gets the
compact layout. It does not — its panel is about 960 logical pixels across,
which clears 820 — and that mistaken example is the one D-N2 reasoned from.
Corrected in the same breath, since this is the commit that says what
portrait actually changes.
2026-09-07 20:00:50 +02:00
dtourolle 30468c4c69 Put the window-metrics doc on the function it describes
The comment explaining why both coordinate systems go on one line was
written for `log_window_metrics` and sat above `window_metrics_level`,
where it read as the start of that function's much longer note. Two
doc blocks ran into each other and the one that prints had none.
2026-09-07 20:00:50 +02:00
dtourolle 428d8c4a51 Say what size the window actually is, in both coordinate systems
Every figure in D-N7's table was computed at a guessed scale factor. The
tablet's panel is 3000 by 1920 physical and nothing in this repository
has ever recorded the density Android reports for it, so the dock's
width is either 900 or 1037 and its available height is 200px either
way. N6 asks for the measurement; this is the line that carries it.

Beside the existing `apply_layout_class` call, because that is where the
window is already being asked for its size and its scale, and again on
every resize, so turning the tablet over records the other orientation
in the same logcat. Both coordinate systems on one line: a logical size
cannot be checked when the scale is the thing in doubt, and a physical
size that does not divide by the scale printed next to it says the
reading is of something other than the panel.

The level is not fixed, because none of the three obvious choices works.
`android_main` caps the facade at info, so debug never leaves the
device and a debug-only line answers nothing. A drag emits a resize per
frame and each accepted record is also appended to the on-disk log, so
info on every resize is not a diagnostic. And the first reading is not
the settled one: on X11 the window reports 0x0, then 360x320 at scale
1.0, then 1100x720 at scale 2.0, so reporting only the first would put a
number in logcat that is not the window's.

So the pair that decides is the scale factor and the orientation --
exactly what N6 is asking for, and exactly what a drag leaves alone. A
window with no area is not a reading and records nothing. Every later
change to either half, the scale resolving or the tablet turning over,
is a new answer and goes out at info; everything else is debug.
2026-09-07 20:00:49 +02:00
dtourolle 2361b2d4ef Tablet portrait is the expanded layout class, not the compact one
FR-UI-1's table has put tablet portrait in the compact class since the
register was written. D-N2 settled that it does not belong there: a
12-inch tablet is about 1024 logical pixels across in portrait and
EXPANDED_MIN_WIDTH is 820, so both orientations of both targets are
expanded, and compact fires only on a desktop window dragged narrow. The
code has always agreed - apply_layout_class reads the window's width and
nothing else - so the register was the last place still saying otherwise.

What portrait actually needed was a different axis. D-N7 docks the
develop column under the photograph on a tall window, set from the
window's aspect and independent of the layout class: a 960-wide portrait
window is expanded and wants the dock, a 1500-wide landscape one is
expanded and does not. That is a placement rather than a mode, so the
clause about the transition being continuous stands as written, and the
requirement now records the side the column takes as its own property.

FR-UI-2 gains one clause for the same reason. It said modality affects
control sizing and affordances "not layout", which D-N6 reversed: under
touch the group selector leaves the strip above the column for the tool
rail, and groups-in-rail in toolrail.slint is what moves it.
2026-09-07 20:00:40 +02:00
dtourolle de32c04a68 Dock the develop column under the photograph on a tall window
D-N2 dismissed portrait with one number: a 12-inch tablet is about 1024
logical pixels across, which clears the expanded breakpoint. That was
worked out for a 4:3 panel. The tablet's is 3000 by 1920, and on that
aspect a column beside the photograph in portrait leaves it a strip 540
wide and 1456 tall: a 3:2 frame gets 540 by 360 where a column below it
would give 900 by 600, and the portrait frame gains too.

D-N7 records the decision: a third property beside the layout class,
derived from the window's aspect with hysteresis, that lays the same
rail, canvas and column out on the other axis. Not a sheet, not a second
layout, and the rail does not move. N6 measures the device the numbers
were guessed for, N7 does the frame with the stack stretched as a
stopgap, N8 moves the develop callbacks onto a global so the panels can
be declared twice cheaply, and N9 is the three-column composition the
dock's width is actually for. N5's "no strip along the bottom" is struck
where D-N7 reverses it and kept where it does not.
2026-09-07 20:00:40 +02:00
dtourolle e13d3a54fc Watch the mask tools work, rather than reading that they do
A still frame cannot show what makes these tools right or wrong. What
matters is how the mask *moves*: whether a stroke lands where the finger
went, whether a subtraction takes away only what it covers, whether an erase
inside a correction punches through the selection underneath. Every one of
those is a sequence, and the test suite asserts single pixels.

So this renders the sequences. A synthetic photograph, one frame per step of
each mode — painting, erasing, joining a part and taking it out again,
inverting, and sweeping the edge controls — as PPM, which ffmpeg turns into
a GIF in one line. It runs headless, needs no RAW and no model, and takes a
few seconds.

It is also the honest answer to "show me it working" while the tools are
still being wired to a finger: this is the pipeline itself, not a mock-up of
it, and a fault in the fold shows here as a frame that looks wrong.
2026-09-07 20:00:40 +02:00
dtourolle df741a8a49 Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a
shoulder, leaking into the hair — and FR-DEV-3's edge controls move the
*whole* boundary, so no value of feather or dilation fixes two errors that
go opposite ways. What fixes them is a second selection joined to the first,
and a layer that held exactly one source had nowhere to put one. The brush
the core has had all along was reachable from no control in the application.

A layer is now an ordered list of parts. Each names a source and how it
joins the mask before it — added to it, or taken out of it — and carries its
own edge treatment, because a model's soft coverage and a stroke painted
where it stopped short do not want the same feather. Invert and opacity stay
on the layer, where the composed shader already reads them.

The sidecar grows `[part]` blocks and nothing else. A layer of one part
writes exactly the bytes it always did; a mask block with no part blocks
after it reads back as one part; and a stroke, a join or a source this build
cannot read costs that part rather than the layer. So every sidecar in every
library still parses to the edit it always was.

On the device the parts fold into the layer's one slice, so eight layers
still cost eight channels: union is a `max` blend and subtraction is the
erase blend the brush already used. A part is drawn into a scratch texture
before it is joined, and that is not incidental — an erase stroke means a
hole in *that part*, not a hole in the mask, and drawn straight onto the
accumulator it would punch through the subject underneath. A layer of one
part skips all of it and takes the path it always took.

In the interface: a part list under the selected layer with a chip saying
which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip
with the brush's size, hardness and flow, and a drag on the photograph that
paints. Pressing Paint on a mask that cannot hold a stroke joins a part that
can, rather than explaining that a subject is not a brush. A whole stroke is
one step in the history.

The edge controls now shape the part that is selected rather than the layer,
which is the one behaviour change to an existing control: with a correction
selected, the feather slider softens the correction and leaves the model's
mask alone.
2026-09-07 20:00:40 +02:00
dtourolle 9ede23073d Specify the tools that edit a mask once the model has drawn it
The auto masks arrive in a second and cannot then be changed by a pixel: no
brush, no way to cut one selection out of another, no way to drag a boundary
that stopped inside a shoulder, and no way to see the alpha a layer actually
produces — the canvas overlay draws what the model detected, not the mask.

docs/mask-editing.md is how that closes. A layer stops holding one source and
holds an ordered list of parts, each naming how it joins the mask before it,
so painting on an auto mask, subtracting, and intersecting a subject with a
luminance band are all one mechanism. Notes what the tree already has (the
whole brush is written and reachable from no control), what the set operations
actually cost (three blend states, no new texture), why the edge push is a
warp rather than a local morphology, and why painting has to draw
incrementally. Ends with the four decisions the build needs first.
2026-09-07 19:59:44 +02:00
dtourolle 1e171c6d31 Let a collection be picked up, rearranged, and emptied after the fact
Collections could be made and filled and never reorganised. Nesting had
a drag; un-nesting had nothing, in either direction — "All photographs"
refused every drop, which is right for a photograph and wrong for a
collection, which has a top level to be returned to. So a collection put
inside another was in there permanently. Right-click deleted an *empty*
collection outright and refused otherwise, which is wrong in both
directions at once: destructive with no confirmation, and no way at all
to delete a collection that held anything without emptying it by hand,
child by child. And a photograph could only leave the collection the
grid was scoped to, since that is the only one a button in the header
can name — the cell's badge says a photograph is in three collections
and never which three.

Three ways in, one vocabulary:

**Hold a row.** The tree is inside a Flickable, which claims any drag
beginning inside it, so with a finger a drag on a row is a scroll until
something says otherwise. The hold is that something. It lifts the row —
drawn before anything moves, so the gesture says it has been understood
— and then what the user does decides which of two things they meant:
move, and it is a rearrangement; let go, and it is the row menu. The
same fork the grid already uses to tell hold-to-select from drag-to-file.
`decide_release` is that fork, and it is tested, because getting it
wrong one way puts a sheet over every tidied tree and the other way
makes the menu unreachable by touch.

**The row menu.** Rename, new collection inside, move to top level,
keep offline, delete. Deleting asks once when there is anything to lose
and says what survives: the photographs stay in the library, and nested
collections move up rather than going with it — which is what the
catalog does, and what a user would never assume. An empty collection
goes on the first press, because a dialogue about losing nothing is how
people learn to dismiss dialogues.

**"Collections…" on a selection.** Every collection the selection is
filed in, each with a count — "3 of 40", so nobody takes forty
photographs out of a collection thirty-seven were never in — and a way
out of any of them without navigating there first.

The long press used to open the offline question by itself. That
question is one item in this menu now: there is one hold per row, and
while it was spent on a single action nothing else the tree can do had
a touch route at all. Nothing is lost — the tray on the row keeps its
tap, and the question gains a full-width control in place of a 30px
icon in a row shorter than the touch minimum.

The row-press handler moves to `collections_ui` with the rest of what a
collection row does; it lived in `library_ui` only because it opened
that prompt.
2026-09-07 19:59:44 +02:00
dtourolle 577bbd82b0 Return the action the drag offered, not the one this row prefers
Slint negotiates a drag action between source and target, and the
runtime clamps whatever `can-drop` returns against the set the source
allowed: an action outside it becomes `none`. Both drop targets here
named a constant instead of echoing what was on offer, and each named
the wrong one for half its traffic.

A collection row takes two kinds of payload. Photographs come from a
DragArea allowing `copy`; a collection being nested comes from one
allowing `move`. The row asked for `copy` unconditionally, so images
filed correctly and every collection dropped on a collection was
refused — nesting by drag has never worked. The trash had the same
fault mirrored: it insisted on `move` while the grid's cells allow only
`copy`, so it refused every photograph dragged to it.

Neither failure had anything to see. A clamped action is delivered as a
refusal, which looks exactly like a target that declined on purpose, so
the drag simply did nothing and left no error to search for.

`decide_drop`'s Reparent branch was tested and passing throughout. It
tests the decision, not the negotiation, and nothing was reaching it.
2026-09-07 19:59:44 +02:00
dtourolle bac5801618 Ask which collections a selection is filed in, and how much of it
`collections_for_image` answers this for one photograph and has no
counts, which is enough to badge a cell and not enough to offer a
removal: with forty selected and three of them in "Iceland", a sheet
that says only "Iceland" invites the user to take all forty out of a
collection thirty-seven were never in. `membership_of` returns the
count alongside the name so the row can say "3 of 40".

Chunked over the image list rather than one `IN (...)`, because the
list is a selection and a select-all makes it as large as the library —
past SQLite's bound-parameter cap on exactly the gesture most likely to
produce it. Counts are summed across chunks, so the answer is the one
the unchunked query would have given.

Smart collections are excluded by construction: they have no member
rows, so there is nothing a removal could do.
2026-09-07 19:59:44 +02:00
dtourolle af89433aee Offer the lens profile as a tick box, since applying it silently reads as absent
The develop panel's Optics group is three manual sliders: distortion, chromatic
aberration and lens vignetting. The automatic correction was already there — the
file's EXIF lens is matched against the bundled Lensfun database on open and the
coefficients are fanned out to all three — but nothing in the interface said so
except a line of grey text under the camera reading "· corrected", and there was
no way to decline it. From the outside that is indistinguishable from the
feature not existing, which is how it was read.

`dr-lens` states the rule this breaks: an automatic correction that silently
does nothing is worse than one the user can see is unavailable. The caption
satisfied the letter of it and not the point — a photographer looking for
"apply the lens profile" found three sliders and no switch.

So the profile is now a control. It is a capability rather than a flag on the
session, because everything a photographer sets travels one road: the capability
list feeds the generated panel, `Preset` captures it, the sidecar stores it and
the undo stack replays it. A bool on the side would have needed adding to each
of those four by hand and would have been forgotten in at least one — which is
exactly how the mask stack came to be missing from the history.

It is on by default, which is what `switch_on` is for: the coefficients are a
measurement of the lens that took the photograph, so accepting them is neutral
and declining them is the edit. The sidecar therefore stores nothing for the
ordinary case and the correction still happens.

The switch appears only where a profile was matched. A tick box on a photograph
whose lens the database has never heard of would be a control that looks
available and does nothing, which is the failure the rule above names rather
than an instance of following it — those photographs are told "· no profile" in
words instead, and one whose box is unticked now says "· profile off", which is
a third fact and not either of the other two.

Two things had to be built underneath. `ParamKind::Bool` was in the core's
closed enum and mapped to a row kind here, and had no control behind it in
`adjust.slint`: a parameter declaring itself a switch was flattened into a row
that drew nothing at all. Nothing shipped had one until now, so the gap cost
nothing and was invisible. And `Check` self-toggled, which is right for a
settings page that owns its value and wrong for a panel row that is a view of
the edit graph — the click would have answered by replacing the binding with a
literal, and the next undo or pasted preset would have moved the value with the
tick left where the finger put it. It now takes `controlled`, and the generated
row uses it.

The manual sliders are unchanged and still trim whatever the profile leaves, so
switching it off is "correct this by hand" rather than "stop correcting".
2026-09-07 00:27:47 +02:00
dtourolle 2cd49d1cb7 Measure the rail by counting it, so it can scroll instead of clipping
With the adjustment groups in the rail, a short window silently lost them.
At a 1500x680 window the rail drew Photo, Compose, Local, Repair, the seam and
"All", and then stopped: Optics, Light, Colour, Effects and Detail were not
scrolled off, they were gone, with nothing on screen to say so. The develop
view's primary navigation, unreachable by any means.

The Flickable was put here to prevent exactly that, and it could not, because
its viewport asked the layout how tall it wanted to be while the layout was
already taking its height from the viewport. Slint settles that cycle by
handing back the height it was given, so `max(self.height, preferred-height)`
could never exceed `self.height` and there was never anything to scroll.

Counting breaks the cycle. Every entry here is a fixed height by construction —
a tool is `rail-entry-height`, a group is a touch target — so the content is
six plus four fifty-fours plus a gap plus a touch target for each group and
"All", which is exact rather than an estimate and depends on nothing that
depends on it. Four tools and five groups come to 498, against the 340 a
680-pixel window leaves at 2x, and the difference is now scrollable rather
than absent.

Found by shrinking the window with the groups forced into the rail. The
interaction itself is unverified: synthetic input does not reach a Slint
window on this desktop, and the tablet was disconnected, so what is confirmed
is the arithmetic and the clipping it explains, not the scrolling it should
restore.
2026-09-07 00:17:08 +02:00
dtourolle 3dc7c184ee Optimise for release only, since every edit pays for a dev build
Dependencies were built at `opt-level = 2` even in dev, because wgpu and
image decoding are slow without it. That is still true, and it is what this
gives up: a debug run of the app, and the decode- and GPU-heavy tests, are
slower than they were.

What it buys is that nothing has to be optimised before it can be compiled.
That cost was paid on every edit, in every worktree, whether or not anything
was ever run — and there are sixteen worktrees, each with its own target
directory and no shared cache, so it was paid sixteen times over.

`[profile.release]` is untouched: `lto = "thin"` and `codegen-units = 1`
still apply where the speed is actually wanted.

If one crate turns out to be the one that makes a test unbearable, raise
that crate alone rather than restoring the blanket rule; the manifest says
how.

Incremental compilation is now on as well, but that lives in
`.cargo/config.toml`, which is untracked and per-checkout — so it is a local
change on this machine, not part of this commit.
2026-09-06 20:17:53 +02:00
dtourolle 3f6dbce2aa Name a lone control after its operation, so three cannot all read "Amount"
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m31s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h15m4s
Build and test / Layer separation (push) Successful in 37s
Traceability / Requirement traces (push) Failing after 1m28s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 1h3m13s
The Detail group ended with three consecutive sliders labelled "Amount" and
nothing to tell them apart. They are dehaze, clarity and texture: each declares
exactly one parameter, and `ops/README.md` tells an author to reach for the
`amount` kind first, so all three named it the same thing.

The panel withholds a heading from a group of one, and the reasoning it gives
is sound — a lone control names itself, and the group reset it loses costs
nothing because the slider already resets on double-click. But that argument
rests on the parameter being named after what it does. It holds for exposure,
contrast, vibrance, saturation and brilliance, whose single parameter shares
the operation's name, and it fails for the three whose parameter is called
after its kind rather than its subject.

So a lone parameter now takes its operation's label — the name the withheld
heading would have carried. For the five that already agreed, nothing changes.

Found on the tablet, and only there: every row was correct, every label
resolved, and the panel was still unusable. The test that guards it asserts
over the real chain rather than a fixture, because the fault was a property of
what is actually declared — a fixture would have had to be written to
reproduce it, and would then only have proved itself.
2026-09-06 19:01:54 +02:00
dtourolle 124b2d99c6 Take the first control that moves the picture, not the first one drawn
`showing_the_original_leaves_the_edit_exactly_as_it_was` set row zero to its
maximum and then asserted the photograph was modified. It was not, and the
test failed on its own premise rather than on the thing it exists to check.

`EditGraph::capabilities` puts the lens corrections at the head of the list,
matching where they sit in the shader. Those carry profile coefficients rather
than parameters, so with no profile loaded a slider on one is a control with
nothing behind it: the graph stays neutral and the premise assertion fires.
The test was written against a panel whose first row happened to be an
adjustment, and it stopped being one.

So it now walks the rows until it finds a control that actually changes the
edit, which is what it meant by "row zero" all along. Still addressed by
index, so it still names no operation, and it no longer depends on where in
the chain the first *adjustment* happens to sit.
2026-09-06 19:01:53 +02:00
dtourolle 3994caba12 Write down the develop gestures, since only their author knew them
FR-UI-4 says a gesture with no visible counterpart is a feature only its
author knows about, and the vocabulary the application actually publishes had
two sections in it — the library grid and people. Develop had none. Every one
of its gestures was documented in the comment beside the `TouchArea` that
implements it, which is where the previous sixteen were before this scanner
existed, and unreachable to anybody not reading the source.

Thirteen now carry tags: magnify by pinch or wheel, pan a magnified frame,
fit and 1:1, hold to see the original, sample a neutral, undo and redo, step
through the folder, reset one control, show or hide a mask layer, choose a
group of adjustments, and copy and paste the settings. Each has a pointer and
a touch route, so none of them is keyboard-only.

Three bindings were genuinely missing and are added here rather than merely
described. Ctrl+C and Ctrl+V for the settings clipboard, which the Settings
panel's own comment has claimed existed for as long as the panel has and
nothing bound; and [ and ] to step through the adjustment groups. The groups
are whatever the operation set declares itself to be about, so there are as
many as the pipeline has and no key can name one of them — stepping is the
binding that survives a node being added, and "everything" is part of the
cycle rather than a way out of it.

Two are described and not bound. Resetting a control from the keyboard and
toggling a mask layer from the keyboard both need a notion of which control
or layer has focus, and the generated panel has none — the rows are a model
the repeater rebuilds, and inventing a focus ring for them is a larger change
than a keyboard shortcut. Both are reachable by pointer and by finger, and
the tags say so rather than promising a key that is not there.
2026-09-06 19:01:52 +02:00
dtourolle 901f51e6c4 Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since
it was written, and only the first half existed. `WidgetKind::WhitePoint` was
in the vocabulary and `develop::supported` answered false for it, so the node
degraded to two sliders — correct behaviour that had quietly become the only
behaviour. Sampling a neutral is the first move of the global tonal pass and
every colour judgement afterwards is measured against where the grey was put,
so guessing at two sliders until a wall stops looking green is the wrong way
round.

The awkward part is that a picker genuinely needs to know how far a hundred
units of temperature move red against blue, and that number is declared in
the node's own file. So the inversion lives in `dr_pipeline::neutral` rather
than in the interface: the canvas hands over a colour, the core finds the
operation that asked to be driven by a pixel and bisects its declared
response until the sample comes back grey. Nothing in `ui/` names white
balance, and nothing holds a second copy of a response that would be wrong
the first time somebody adjusted the range. A bisection rather than a
closed-form inverse because only monotonicity is part of the bargain — the
expression is free to become a table tomorrow.

The result is rounded to the precision the control is drawn at, which is not
cosmetic: unrounded, sampling something already neutral lands a
ten-thousandth off zero, and the photograph comes back modified with an undo
step for a correction of nothing.

On the panel side this needed one distinction the generated path was
missing. `is_on_canvas` was being read as "and so the panel draws nothing for
it", which is right for a crop — four edge fractions are not controls anyone
drags in a list — and wrong for an eyedropper, which *writes* temperature and
tint and leaves them exactly the controls a photographer reaches for next.
So a sampling widget keeps its sliders and puts the affordance that arms the
canvas in the group's heading, built like the reset beside it. One click, one
sample, one history step: `Edit::Action` never coalesces, and there is no
hover preview to fill the stack with temperatures nobody chose.

Declaring the presentation also groups temperature and tint under one undo
step, where they were two. That follows from what `Presentation` means and
reads correctly — white balance is one decision — but it is a change, and
worth saying so.
2026-09-06 19:01:52 +02:00
dtourolle 2584b9ecbc Hold one key to see the photograph before you touched it
FR-DEV-7 asks for the current edit against the unedited original and nothing
implemented it. What the develop view had was history navigation, which
*changes* the edit rather than previewing against it — so the only way to
look was to undo, look, and redo, and that puts two real steps on the stack
at exactly the moment a photographer suspects they have overcooked a frame
and is least sure of what they are doing.

Holding the "Before" button, or backslash, renders the graph with every
adjustment stripped and hands it straight back afterwards: the same
suspend-render-restore shape the crop overlay already uses to show an
uncropped frame and an export uses to suspend the zoom. Nothing is recorded,
no rows are re-synced, and the photograph is still modified when the key
comes up — the panel goes on describing the edit the photographer has,
because only the canvas is answering a question.

The framing deliberately stays on. A held comparison is a question about
tone and colour, and re-cropping the canvas under someone's thumb would move
the detail they are comparing; worse, the zoom is a rectangle of the *framed*
image, so dropping the crop at 4× would quietly show a different part of the
photograph rather than the same part unedited. What the crop took away is
already compared in Compose, which shows the whole frame.

Not a split screen: that halves the working image on the tablet this column
was sized for, and the comparison photographers describe making is a flick
back and forth rather than two pictures side by side. Press-and-hold is one
gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants,
and it has no mode to be stranded in — the button reports both edges, so a
press the system cancels puts the original down too.
2026-09-06 19:01:51 +02:00
dtourolle 9b674a88d8 Let the photographer look at the pixels, and keep looking
Noise reduction and capture sharpening are judgements about individual
pixels, and at a fitted view several of the file's pixels are averaged into
each one on screen. The frame therefore looks cleaner and softer than it is,
the photographer corrects for a softness the display invented, and
over-sharpening is the documented result. Nothing in the develop view
reached 1:1 at all: the wheel and the pinch zoom by ratios, the double tap
dropped straight to fit, and the only readout was a percentage nobody was
aiming at.

So the double tap now does what FR-UI-4 always said it did — toggle fit and
1:1 — and the zoom readout, which used to be a dead "Fit" button on a fitted
photograph, becomes the way in when there is nothing to clear. Z does the
same from the keyboard, and the back gesture goes out through the same
toggle so putting the magnifier down really puts it down. 1:1 is computed
from the file's own resolution against the viewport rather than fixed at
some multiple, because that is the only version of it that answers the
question the two detail controls are asking.

The point being inspected and whether the magnifier is up are held beside
the session rather than in it, on the argument focus peaking already makes:
a session is one photograph and this is a way of looking at a folder of
them. Checking the same eye across forty portraits is the reason to reach
1:1 in the first place, and a magnification that reset with the session
would make that forty zooms and forty pans instead of forty keystrokes. It
stays a viewing state throughout — the view is kept out of `is_active`,
`output_size`, the sidecar and the export, and an export still suspends it —
so none of this reaches the file.
2026-09-06 19:01:50 +02:00
dtourolle 43652bd613 Say where the positives really come from, not where they were going to
`calibrate.rs` claimed its positive pairs came from user confirmations "then
burst siblings, since FR-CULL-5 already groups bursts", and repeated it beside
the pair floor: "the positives are bootstrapped from bursts and a handful of
early confirmations". Neither is true and neither ever has been. Nothing in
the workspace pushes a burst pair into a `Pairs`; nothing pushes any pair at
all outside this file's own tests. The sentence was written while both halves
of docs/faces.md §8.1 were being planned together, describing a source that
was going to exist, and it has read since as a description of what the code
does.

The distinction matters more here than in most comments, because this file is
the one place in the subsystem allowed to say what a similarity *means*. A
reader who believes the fit is drawing on bursts believes a young library is
gathering positives on its own, which is precisely the opposite of the state
FR-CULL-9 legislates for — a library with no fit, no valid calibration, and a
reference curve it must not present as a measurement of itself. The floor of
200 positive pairs looks arbitrary under the wrong story and obvious under the
right one: confirmations arrive one at a time, from a person.

So the comment now says what is here — a positive is a pair confirmed onto one
person, and there is no second source — and keeps the burst idea where it
belongs, as §8.1's proposal, with the two reasons it is not in the code: this
crate is handed cosines and cannot see a catalog, and the purity of a burst
pair is a thing to measure before it is a thing to trust.

No behaviour changes; the arithmetic is untouched.
2026-09-06 19:01:50 +02:00
dtourolle 1c5c55b4c9 Let the photographer say which frame the burst stands for
`choose_representative` has been in the catalog since the grouping landed,
with tests behind it and nothing calling it. So the frame a folded burst drew
was always the earliest one, and the only way to disagree was to open the
group and leave it open — which is to say there was no way to disagree at all,
because a burst that stays open is a burst that was never collapsed.

The earliest frame is the right default and it is deliberately not a
judgement: nothing here scores a photograph, and FR-CULL-5 names the failure
that rule avoids. But the whole point of a burst is that one of the twelve is
better than the other eleven, and the person who knows which is the one
looking at them.

So a ring on each frame of an open group, ticked on the one the group folds
to. It is drawn only while the burst is open, because that is the one moment
the alternatives are on screen to be compared — offering the choice on a
folded burst would be asking about frames it is hiding. Bottom right, opposite
the count in the other corner, clear of the flag and the collection badge and,
deliberately, of the trash target: a slip between the ring and the fifth star
sets a rating, which is the harmless direction for an ambiguous press.

The mark stays live on the frame that already wears it. A disabled TouchArea
would let the press fall through to the cell behind it, so tapping the one
ring that is ticked would have opened the photograph — and pressing it is a
thing the user may mean anyway: it records the choice the default was making
silently, which then survives a regroup that finds an earlier frame.

Choosing repaints the badges instead of reloading the window, which is what
separates it from folding a group up. Folding changes what the grid's query
returns; this changes only which cell wears the tick, and the tick has to
leave the frame that was carrying it, so the whole window is refilled in the
one statement `sync_badges` already runs.

The gesture is documented where FR-UI-4 requires it to be documented: in a
tagged comment beside the control, which is the only copy. The gesture book,
the gesture document and the requirements matrix are regenerated from the tree
alongside it.
2026-09-06 19:01:49 +02:00
dtourolle 5fa4c0772b Speak the sidecar format every other editor already reads
FR-CAT-13 asked for standard XMP and nothing in the tree parsed or wrote a
byte of it. `keywords.rs` mentioned `dc:subject` in a comment about what a
keyword's text is for, `dr-export`'s metadata module said "neither is read by
`dr-decode` today" about its own half, and `dr-preset-xmp` reads a different
file for a different requirement. So a library imported from Lightroom could
come in and never go back out: a one-way door, which is not a thing a
photographer walks their archive through.

`core/dr-xmp` reads and writes the properties the requirement names —
`dc:subject`, `lr:hierarchicalSubject`, `xmp:Rating`, `xmp:Label` and the IPTC
core fields — from whichever shape the file happens to use. A property may
arrive as an attribute or as an element, inside a Bag, a Seq, an Alt or no
container at all, because the specification is not what wrote the file; so one
collector takes whatever is in a property and the declared shape decides only
how many values survive. `xmp:Rating="-1"` is modelled as Adobe's rejection
rather than folded into zero stars, since DarkRoom keeps those on two axes and
the mapping belongs where both are visible.

Writing is a rewrite rather than a serialisation, and that is the whole design.
An XMP sidecar is a shared document: the file beside a raw carries somebody
else's `crs:` settings and comments and namespaces, and rendering our record
over it would be data loss on every photograph but the first. The rule is
stated once, in the crate documentation and in `PROPERTIES`: DarkRoom owns
exactly those properties, identified by namespace URI and never by prefix, and
nothing else in the document. Everything unowned is copied through byte for
byte. A `Description` left empty once our properties come out of it is
withdrawn, which is what keeps a rewrite idempotent instead of adding a husk to
the file on every save.

Precedence is settled conservatively, because a standard XMP carries no
revision and no device and there is nothing in it to order two edits by.
Keywords union, following the rule `dr_catalog::merge` already makes for
assignments; every other field is taken only where DarkRoom holds none,
following `Version::merge`'s judgement rule, and a genuine disagreement is
reported rather than resolved so a caller can offer the reload the requirement
asks for. What is deliberately left open — when a reload may happen without
asking — is written down in the module rather than picked silently.

No new dependency: quick-xml was already in the tree for WebDAV and for
Lightroom presets. Nothing above the crate calls it yet, and `outstanding.md`
now says so along with the two smaller gaps, GPS and the filename convention.
2026-09-06 19:01:48 +02:00
dtourolle 68ebf5d78b Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
2026-09-06 19:01:48 +02:00
dtourolle 81b1ae8c42 Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family —
`detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and
`capture_sharpen.rs` — and no such node existed. Every one of them was
describing the family by listing clarity, texture and a control the
photographer could not reach.

Haze is the one degradation the controls already in the chain cannot
remove, and the reason is spatial rather than tonal. Scattering
composites an airlight over the scene in proportion to distance, so the
lift is per-pixel: a black point that clears the mountains crushes the
foreground, and a contrast curve that clears the mountains does the
same. So the node has to estimate the transmission at every pixel, which
is the dark-channel prior — the local minimum over the channels and over
a patch is the airlight that has been added there — and then invert the
scattering model with it.

The airlight is taken as neutral and as unit, which removes the one part
of the published method this stage cannot perform. Estimating it
properly is a whole-frame reduction, and the detail chain has none: it
hands each pass the pass before it. It is also unnecessary, because
white balance is the first node in the chain and has already driven the
illuminant to grey, so only the magnitude is unknown — and an unknown
magnitude on the veil is a scale factor on the amount slider, which the
photographer is setting by eye regardless.

The patch is a fraction of the frame's shorter edge, through
`RenderScale::frame_fraction`, and never a count of pixels. It has to be
wide enough to contain something dark and narrow enough that what it
measures is still local, and both of those are statements about how much
of the composition it covers — so it must cover the same proportion of
the picture on a proxy as in the export, or the file is sharpened for a
patch three times narrower than the one that was tuned on screen.

Affording it needs an identity a Gaussian does not have. Erosions
compose by adding their structuring elements, so the minimum over a run
of d followed by the minimum over k points spaced d apart is the exact
minimum over the whole kd window. At the square root that is 16 taps
rather than 61 at 4K, and it is the same filter rather than an
approximation of one — which is the difference from the strided kernel
`local_contrast` refuses, where sampling an image that is not
band-limited aliases into the base and comes back as mottling.

It runs first among the compositional detail nodes, at order 125: after
noise reduction, because dividing by a transmission below one amplifies
the noise in the veiled distance by exactly the factor it recovers the
contrast by, and before clarity and texture, coarse before fine, so that
their base is computed on the picture the veil has left rather than on a
modelling about to be divided out.

What it cannot honour is the placement dehaze most wants. It shifts
colour — it subtracts a grey term and rescales, so saturation changes
wherever the veil is thick — and the colour work would ideally be
correcting the picture that leaves here. The detail stage runs as a
group after every point operation, because a neighbourhood pass is a
separate dispatch over a texture the fused pass has finished writing, so
an order placing this node ahead of `vibrance` would be a lie the chain
cannot tell. Interleaving would mean splitting the fused pass in half
around it, at the cost of a second full-frame dispatch and intermediate
for every edit in the catalogue whether it dehazes or not. The
declaration records that rather than leaving it to be rediscovered.

FR-DEV-18 is added to the requirements register alongside it. The tag
had nowhere to point, and an orphan tag fails the traceability gate
rather than quietly counting for nothing.
2026-09-06 19:01:47 +02:00
dtourolle 7c3e1d2c54 Let the shadows and the highlights carry a colour the picture never had
The colour mixer is the only chromatic control in the chain, and it can only
turn a hue that is already in the frame. Ask it for cool shadows against warm
highlights and it has nothing to take hold of: the shadows of a correctly
balanced photograph are near enough neutral that there is no band there to
turn, and a monochrome conversion hands it a picture with no hue in it at all.
Split toning is the oldest look in the book and every developer worth comparing
against ships it; there was no way to reach it from here.

So colour_grading, declared like any other node — a hue and a strength for the
shadows, the midtones and the highlights, and a global cast over the frame. It
targets a tonal range rather than a hue, which is the whole difference between
the two controls: it puts colour where none was rather than turning what it
finds. It sits at 105, after the mixer has had the last word on the colours
that are in the picture and before the detail stage.

The mechanism is one helper. Three cosines 120 degrees apart are the hue wheel
written directly as an RGB direction, and their sum is zero at every angle, so
exp2 turns them into three gains whose product is exactly one — a cast tilts
the balance without moving the level. A grade that doubled as an exposure
change is the failure that has the photographer chasing brightness with a
colour slider, and it is corrected with a control that cannot reach it. The
three tonal weights partition the scale rather than overlapping, the midtones
being whatever the two ends leave, so setting all three to one hue is exactly
the global cast and a split tone does not colour its own midtones as a side
effect of its halves meeting. Full strength is half a stop on the leading
channel, the ceiling white balance already holds itself to.

Neutral is declared rather than inferred, which is what `active:` is for. A hue
with no strength behind it is a direction with no distance, so under the
default rule nudging one would have put the node into every fused shader for a
change nobody can see. Summing the strengths is zero exactly when all four are,
and they cannot go negative to cancel each other. The opposite reading —
neutral as "nothing has been touched" — fails the other way round: red is hue
zero, so a grade toward red never moves a hue off its default and would never
have been applied at all.

It asks for a colour wheel, the widget the descriptor vocabulary has been
carrying with no operation behind it. Nothing draws one yet, and that is fine
by construction: the panel takes the first widget it implements and falls
through to sliders otherwise, so this arrives as eight ordinary controls that
work. Each parameter is named for its own range for exactly that reason — in a
flat list, four sliders called "Hue" are four controls nobody can tell apart.

FR-DEV-12 is written into requirements.md beside it. A TRACES tag naming a
requirement that is not defined there is an orphan, and the traceability gate
fails on those rather than quietly counting them. The label catalogue gets one
line for the operation's display name; the eight parameters derive correctly
and are left to.
2026-09-06 18:51:40 +02:00
dtourolle efa9d84aad Correct the lens first and settle the grain last
`Attribute::ALL` has claimed since it was written to be roughly the order a
photographer works in, and 474dcf0 moved `Compose` to the front on exactly that
argument. Both ends went on contradicting it.

`Optics` sat fifth, so the column offered the lens corrections after the tones
they change. Removing a vignette brightens the frame; an exposure judged before
that correction has to be judged again after it, which is the definition of the
wrong order. `Detail` sat fourth, so sharpening and noise reduction — the only
work here that depends on everything above it, and the only work that cannot be
judged at fit view at all — were offered before the lens had even been put
right.

So `Optics, Compose, Tone, Colour, Effect, Detail`. `Optics` leads even
`Compose` because it is not a decision about the photograph at all: it is
undoing what the equipment did, a property of the capture rather than a choice.
`Effect` after `Colour` is a look laid over a settled picture, and is the one
slot that is genuinely arguable — a spectral film simulation declares `renders`
and replaces the base curve, which is a case for treating it as foundational
instead, and e235e99 filed film under `Effect` only days ago. The doc comment
records that tension rather than pretending to settle it; an array of six
cannot say "last, except when it is first".

`decl::Attr::ALL` moves with it. It is the second spelling of one vocabulary,
compiled by `build.rs` where `descriptor` is not visible, and the agreement
test in `declared/mod.rs` zips the two positionally — that test is what caught
the last reorder, and it would have caught this one.

Nothing persists a position in this list, which is what makes the reorder safe
rather than merely tidy. `Scope` packs one bit per attribute indexed by
`Attribute::ALL`, but `bits` is private, has no accessor and no `serde`; what
reaches a settings file is `develop.copy_attributes`, a list of names read back
through `Attribute::from_name`. A photographer's copy scope survives untouched
— only the order the names happen to be written in changes.

6a97fdf is why this is worth a commit now rather than a shrug: the
contradiction was harmless while the list only fed a row of chips nobody reads
in order, and stopped being harmless when the same list began driving a column
read top to bottom.

The matrix follows the two files' shifted line numbers.
2026-09-05 21:17:56 +02:00
dtourolleandClaude Opus 5 59917c5183 Call the tool Compose, since that is what its panel says
Benchmarks / CPU and I/O (per commit) (push) Successful in 14m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h20m20s
Build and test / Layer separation (push) Successful in 51s
Traceability / Requirement traces (push) Successful in 2m8s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Successful in 1h5m51s
The rail entry read "Crop" while the panel it opens is headed COMPOSE and the
button leaving it said "Done Cropping". One mode, three names, and the odd one
out was named after a single control rather than after the decision — which is
what made cropping look like a category of its own in the first place.

Straightening, the quarter turns and the flips are already in that panel, and
perspective will be. `ViewMode.crop` keeps its name: it identifies a canvas
interaction, which is exactly what it still is.

Found by looking at the running application rather than by reading, which is
also how the two halves of this were noticed to disagree at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:29:08 +02:00
dtourolleandClaude Opus 5 e235e99cce Move the film to Effect in the descriptor that is actually read
An earlier commit claimed to move `film_sim` from `[tone, colour]` to
`[effect]` and did not. It edited `ops/film_sim.yaml`, where `attributes:` is
read, validated against the vocabulary, and then dropped: a `rust:` node
publishes its own descriptor, and the type still said tone and colour. The
stock went on appearing in the Light group beside exposure and again in Colour
beside white balance, exactly as before, and every test passed.

Nothing caught it because nothing could. The declaration parsed, the parity
tests compare ids rather than attributes, and an operation filed under the
wrong groups renders perfectly. It surfaced only on screen, as a missing
Effects tab — which is indistinguishable from a category that genuinely has
nothing in it, and is precisely how `Optics` looked for as long as it was
empty.

So three changes rather than one:

`FilmSim`'s descriptor declares `Attribute::Effect`, which is the move the
earlier commit described.

`attributes:` joins the keys a `rust:` node may not carry, beside `params`,
`uniforms`, `wgsl`, `helpers`, `define` and `label`. The rule was already
written — "its descriptor comes from the type" — and attributes were the one
field that slipped past it. A key that is silently ignored is worse than one
that is rejected, because it reads as though it worked; the eight hand-written
declarations lose a line that never did anything.

And a test asserts that every attribute the chain carries reaches the tab
strip. That is the property that was actually broken, and its failure mode is
invisible from every direction: the controls exist, they are in the shader,
and there is no way to filter to them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:28:59 +02:00
dtourolleandClaude Opus 5 6a97fdf6f9 Put the adjustment groups in the rail where a finger is driving
Reported from the tablet: the tool rail is very useful there, and the same
interface under a mouse and keyboard is not. That is `ui-navigation.md` D-N2's
central assumption failing in use, and the interesting part is which half of
it failed.

D-N2 was right that platform is the wrong axis and width is the wrong axis: a
tablet in landscape wants what a desktop wants, and a desktop window dragged
narrow wants what a small screen wants. `apply_layout_class` still decides the
layout class from the window and nothing here changes that. What D-N2 got
wrong is the sentence "touch changes hit regions, not layout" — it identified
input as the real difference between the targets and then assumed that
difference could never reach the layout.

Two controls answer one question — which group of adjustments am I looking at
— and neither is better in general. A horizontal strip above the column is one
gesture to a target the eye has already found, and it pans when the operation
set is rich, so a group can sit off the end with nothing saying so: a pointer
user tolerates that, a finger user never discovers it. The same list down the
rail is every entry visible at once, each finger-sized, on the edge of the
screen the hand is already holding, and it costs no width because the rail is
already there.

So `ToolRail` grows a second section, and `GroupStrip` stands down when it
does. The two are never both on screen, which is why they can share
`adjust-tab-picked`: Rust is not told which was pressed and has no reason to
want to. Mode and group stay independent axes as N1 requires — one entry lit
in each section, and choosing a group while a tool is held still filters
without putting the tool down.

They stay drawn differently, which N1 also required. The tools fill with
`active-dim` and invert their ink; the groups take a bar down the leading
edge — the strip's underline turned ninety degrees — so a lit entry says which
kind of state it is without the reader having to remember which section it was
in. The rule between the sections is the second signal.

The rail scrolls now. Its own note argued against a Flickable because "this
list is four entries written in this file"; with the groups in it the list
comes from the operation set, which is exactly the "something the user's data
decides" that note excluded this control from.

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

D-N6 records the reversal beside the decision it reverses, including the half
that still stands and the question it opens: whether Local is a mode at all,
or a scope that would collapse the two sections into one list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:14:03 +02:00
dtourolleandClaude Opus 5 474dcf0bf6 Let Compose lead the attributes, as their own doc always said
`Attribute::ALL` claims to be "roughly the order a photographer works in" and
then listed framing fifth, behind tone, colour and detail. Framing is the
first decision made about a photograph and the one every later judgement is
made inside — there is no sense balancing tones across a frame about to lose a
third of its width.

The contradiction was harmless while the list only fed a row of chips nobody
reads in order. It stops being harmless now that the same list drives a column
read top to bottom.

`declared::Attr::ALL` moves with it. The two are separate spellings of one
vocabulary and a test asserts they agree, which is what caught this rather
than the order silently disagreeing between the YAML front end and the crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 18:13:44 +02:00
dtourolleandClaude Opus 5 1ad35e2b87 Read the library's sidecars, so a cull done elsewhere arrives
Judgements only ever travelled outward. A rating went to the catalog and to the
photograph's sidecar, the sidecar reached the server, and there it stopped: the
scan indexes files, `derived_sync` exchanges thumbnails, face shards and
collections, `dr_catalog::merge` reconciles everything in a catalog except
`versions.rating` and `versions.flag`, and the one sidecar reader that existed
ran when a single photograph was opened in develop and handed its answer to the
develop graph. `JobKind::ReadSidecar` was declared for exactly this when the job
queue was written and was never enqueued or handled anywhere.

The grid draws `versions.rating`. So a day of culling on the tablet could not
reach the laptop by any path the application had, and the laptop's catalog says
so plainly: 23,568 images, one of them judged.

`pull_sidecars` closes it, off the back of work the scan already does.
`dr_sync::scan` reports the `.drsc` files it meets in listings it was making
anyway — no extra request, and a directory whose ETag is unchanged is still
pruned before it is listed at all. A new `sidecars` table records the ETag of
each one this device has taken in, so the fetch is one GET per sidecar that
genuinely changed rather than one per photograph. A library nobody has edited
costs nothing.

The judgement is taken rather than maximised. The sidecar is the authoritative
store and the fuse has already settled any contest between devices on
`revision`, so lowering a rating from four to one on the tablet lowers it here —
taking the larger would have refused every demotion the photographer ever made,
which is most of what a second pass over a shoot is. A zero is the exception: it
means *never judged*, not "judged zero", so a sidecar carrying none cannot erase
a star this device holds. That is `merge_judgement`'s asymmetry and it carries
the same known cost — clearing a rating does not propagate.

A sidecar names a stem, so both halves of a RAW-and-JPEG pair are judged: they
are one photograph (FR-CAT-11) sharing one document, and judging only one of
them would leave the grid disagreeing with itself over which it drew. The `LIKE`
that finds them is a filter, not the decision — `sidecar_path` is applied to
every candidate, because a folder is entitled to contain a `%` and a rating
landing on the wrong frame would be silent and permanent.

Failing to read one is not a failure to scan: the ETag goes unrecorded, the
ratings already here stay where they are, and the next scan tries again. The
count is reported to the status line as well as the log, because a grid that
silently gains three hundred stars is indistinguishable from one that has gone
wrong — and because while this number was structurally zero there was nothing to
tell the photographer their cull had not arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:14:03 +02:00
dtourolleandClaude Opus 5 ca2a135e28 Let two devices name the same photograph's version the same way
A version's uuid is the identity a cross-device merge keys on, and it was
minted at random, per catalog, per image. Two devices indexing one Nextcloud
library therefore held two different uuids for the same photograph — so the
sidecar they shared collected a `default = 1` block each, `Version::merge` was
never handed a matching pair to reconcile, and an afternoon's culling on the
tablet did not exist as far as the laptop was concerned.

`crate::merge` has said so in a comment since it was written: version uuids do
not reconcile across devices, a uuid-keyed join unions nothing, so keywords are
landed on the local default version instead. It named the problem and worked
around it. `rating`'s own comment asserted the opposite — that generating the
uuid here was what made it a cross-device identity — and `library::amend`
repeated the claim. Uniqueness was never the difficulty; agreement was.

`derived_version_uuid` computes it from `oc:fileid` instead. The server assigns
that integer, every client pointed at the library sees the same one, and it
survives a server-side rename and move — the three properties that already made
`ASSIGN_BY_FILE_ID` prefer it to a content hash. The layout is a UUIDv8 (RFC
9562, an application-defined form) carrying all sixty-four bits verbatim across
the variable fields with a fixed tag in the node field, so the mapping is
injective by construction rather than by a hash's good behaviour, and a uuid in
a sidecar can be read back to the file it belongs to by eye.

A library with no server behind it has no shared identity to derive and keeps a
generated one. The split is still reachable there if the folder is synced by
something else; `Sidecar::fuse_default_versions` repairs that case rather than
preventing it.

Deriving it for new rows alone would have fixed nothing — every image in an
existing library already has a version, so every one of them would have carried
on writing to its own rival identity. `align_default_version_uuids` moves them,
and runs from `schema::backfill` on every catalog open. It selects on the tag
in SQL, so a catalog already realigned matches no rows and writes nothing, and
it declines rather than fails where a virtual copy already holds the target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:13:58 +02:00
dtourolleandClaude Opus 5 601c984894 Fold a photograph's rival default versions back into one
Picking the newer of two default versions stopped the wrong edit being shown,
but it did not close the split: the losing version stayed in the file, and a
device holding disjoint work — a crop made here, an exposure change made there
— still contributed only one of the two.

Worse, the next write made it larger. `amend` looks its version up by uuid,
neither of the two was ours, so the miss minted a *third* `default = 1` block
and the file grew one rival per device per photograph.

`Sidecar::fuse_default_versions` folds them down. The version with the highest
`(revision, modified)` is the accumulator and every other default is merged
into it as the remote, which is what makes the fold order-independent —
`Version::merge` raises its own revision to `max + 1` as it goes, so merging a
chain in ascending order stops being ascending after the first step and a third
device would be dropped. Contested values resolve to the winner, disjoint keys
survive from both sides because the merge is key-wise, and ratings come across
under `merge_judgement`, so a device that never judged the frame cannot erase
one that did.

The result is a function of the file's bytes alone, so two devices that fuse
independently reach the same document and converge instead of overwriting each
other.

Called wherever a sidecar is parsed:

- `amend`, with the write's own uuid, so the fold lands on the identity this
  device is about to use and the lookup below it hits instead of missing.
- `spawn_sidecar_fetch`, so opening a photograph shows everything done to it
  rather than whichever half won.
- `drain_one`, because `merge_into` reconciles by uuid and would otherwise
  publish the split rather than resolve it.
- `presets::load_local` and `save_local` — a local sidecar's folder may be
  synced by something else entirely, and gets the same split.

A file with one default under the expected uuid comes back byte-identical, so
this costs nothing on the ordinary write and no sidecar is uploaded merely for
having been read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:13:53 +02:00
dtourolleandClaude Opus 5 98fcf8e98e Ask which edit is newer, not which uuid sorts first
A photograph edited on two devices ends up with two `[version]` blocks in one
sidecar, both marked `default = 1`. Five of the thirty-eight sidecars in the
local cache are in that state right now.

`default_version` answered with the first `is_default` it met in map order,
and the map is keyed on uuid — so which device's work the photographer saw was
decided by which randomly minted uuid happened to sort lower. On
`IMG_20130625_0033` that is `0545c20a` over `679fe872`: a four-star rating from
the twenty-first of August standing in front of the one-star made on the
thirtieth, with nothing anywhere saying the newer judgement existed.

Resolved by `(revision, modified)` instead, which is the discriminator
`Version::merge` already uses — revision first so that a device with a skewed
clock cannot win by claiming a later timestamp (FR-NC-8), and the timestamp
only to break an exact tie.

This makes the reader pick the right one. It does not make the two converge:
the edit that lost is still in the file, and a device that holds disjoint work
— a crop here, an exposure change there — still only contributes one of them.
That is the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:13:49 +02:00
dtourolleandClaude Opus 5 a56baa9042 Let rustfmt have the assertion it reflowed
The closure taken down to `&dyn Operation` needed its call site re-wrapped,
and I wrapped it by hand rather than letting rustfmt decide: it fits on one
line at the workspace width. `cargo clippy` was run on the change and
`cargo fmt --check` was not, which is the whole of how it got through — the
two catch different things and CI runs both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:37:46 +02:00
dtourolle f0ee53ec09 Merge: the optical corrections, connected at last
Four files' worth of lens correction existed, was tested, and had never
touched a photograph. `compose_warps` had no callers, `dr-lens` had no
dependents, and `ops/vignetting.rs` had no declaration in `ops/` — so
`Attribute::Optics` was a category the tab strip could only ever filter out
for having no rows in it.

Distortion, chromatic aberration and lens vignetting now render, carry their
parameters through the sidecar and the undo stack, and take their coefficients
from the Lensfun database when the file names a lens it knows. The panel says
which of "no lens recorded" and "no profile for this lens" it is, because an
automatic correction that silently did nothing is worse than one visibly
unavailable.

One real bug on the way: `lens.rs` and `framing.rs` both documented the
shader's `p` as corner-normalised, and it is not — its length at the corner is
`0.5 * length(aspect)`, about 0.901 on a 3:2 frame. Every Lensfun polynomial
would have been evaluated short of where it was fitted, by a factor varying
with the aspect ratio, which reads as a correction that is merely too weak.

The category vocabulary moved with it. `Attribute::Geometry` is `Compose` —
named for the photographer's decision rather than for the maths it shares with
the lens corrections — and a film stock stopped claiming to be both tone and
colour, which had put "Kodachrome" in two groups it belongs to neither of.
2026-09-05 15:35:44 +02:00
dtourolleandClaude Opus 5 2841eaf9a1 Take the layer-chain test's closure down to &dyn Operation
`clippy::borrowed_box` is denied by the workspace lint set, and the closure
added with the optics exclusion took `&Box<dyn Operation>` — a borrow of the
box rather than of the thing in it, which says nothing the plain trait object
does not.

Caught by `cargo clippy --workspace --all-targets -- -D warnings`, which is
what CI runs and what the workspace tests do not: a lint on test code only
appears when the tests are compiled as a clippy target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:14:15 +02:00
dtourolleandClaude Opus 5 c4ddcbe0f7 Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting
coefficients from a lens name, a focal length and an aperture — with no
dependents anywhere in the workspace. The three corrections it feeds now
exist in the graph, so this connects the two and finishes the chain.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:11:32 +02:00
dtourolleandClaude Opus 5 a1165ef182 Put the coordinate-domain lens corrections into the graph
`lens.rs` has held a `Warp` trait, a composer and two implementations —
distortion and lateral chromatic aberration — since they were written, and
`compose_warps` was called by nothing outside its own tests. The corrections
existed, were correct, and never touched a photograph.

`EditGraph` now holds them, and `compose_full` emits them between the framing
prologue and the fetch. Distortion first, then CA: each warp receives the
position the previous one produced, and lateral CA is a magnification about
the optical axis of the *undistorted* frame, so measured on a barrel-distorted
one it would be fitted to a radius no profile describes.

They reach the panel the way framing already does — through `capabilities`.
That was the one open question and existing practice answered it: framing is
also not an `Operation`, also has parameters a photographer sets, and also
arrives through that list. Because `Preset::capture` walks the same list, the
sidecar, the clipboard and the undo stack carry a warp's parameters with
nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a).

`state()` destructures `EditGraph` field by field precisely so that a new
field cannot be forgotten, and it was not.

Chromatic aberration is the only thing that samples per channel, and
`splits_channels` is what keeps everything else from paying for it. Red and
blue are fetched from positions green is not — green is the reference and
never moves, so a wrong correction still leaves one channel sharp rather than
softening all three. With no CA in the chain the single-fetch path is emitted
instead.

The interpolating sampler is now chosen by framing *or* an active warp. Asking
framing alone would have nearest-neighboured a distortion correction on an
unstraightened frame, and that aliasing reads as a bad profile rather than as
a missing filter.

The warps go in the geometry invalidation key rather than the colour one: they
decide which source pixel a colour is read from, so a tile cached across a
distortion change would keep drawing the previous correction. The pipeline
cache needs nothing new — `hash_source` already covers the generated body, and
uniform values never enter it, so arming a warp recompiles and dragging it
does not. Both are asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:03:16 +02:00
dtourolleandClaude Opus 5 e17b909d41 Connect the lens vignetting correction to the pipeline
`ops/vignetting.rs` has carried a complete descriptor, polynomial, helper
and test suite without an entry in `ops/`, so it was never in `chain()`.
It reached no photograph and no panel, and `Attribute::Optics` was an empty
category in consequence — filtered out of the tab strip for having no rows,
by a chain that had never been given its only member.

Declaring it needs the one thing the operation was written against and
which did not exist. `wgsl_body` reads `radius`, and the module claimed
"the composer publishes `radius` in the shader prologue for exactly this
reason". It did not. `sample_source` now does, in both sampling branches,
beside the `source_px` it already published for the same class of caller.

It is corner-normalised there, which is the part that is easy to leave out.
`p` spans ±0.5·aspect, so its length at the corner is 0.5·length(aspect) —
about 0.901 on a 3:2 frame, not 1. Lensfun's polynomials are fitted against
a corner radius of 1, so passing `length(p)` straight in evaluates every one
of them short of where it was measured, by a factor that changes with the
aspect ratio. It would have read as a correction that is simply too weak,
which is indistinguishable from a bad profile. Both `lens.rs` and
`framing.rs` asserted the normalisation `p` does not have; corrected.

`order: 5` puts the correction ahead of the tonal stages, and the ordering
is load-bearing rather than tidy. Recovering a corner means dividing by an
attenuation below one — about two stops for a fast prime wide open — so run
after the highlights have been rolled off and clipped, the lift has nowhere
to go and the corners posterise instead of brightening.

`layer_chain` now drops `Optics` as well as the neighbourhood operations.
A local vignetting slider would have worked, which is what makes it worth
excluding: `radius` measures from the centre of the whole photograph and a
mask cannot move the optical axis, so it would lay a frame-centred radial
ramp across the picture and multiply it by the mask. The existing exclusion
covers operations that move and do nothing; this one covers an operation
that moves and does something its name does not promise. The rule both
share is now written down.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:34:03 +02:00
dtourolleandClaude Opus 5 95e854b5a2 Regenerate the gesture vocabulary over the library work
Traceability / Requirement traces (push) Successful in 1m57s
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m42s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Successful in 5s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / Android (aarch64) (push) Successful in 1h5m40s
Build and test / Layer separation (push) Successful in 48s
Build and test / Desktop (Linux) (push) Failing after 1h12m34s
`Traceability` stayed red after the matrix was regenerated, on its other
gate: `gestures-check`. Same cause, second artefact. `docs/gestures.md`
cites each gesture by `file:LINE`, and the library UI work moved the two
selection-mode gestures down a hundred lines — 3923 -> 4032 and
3940 -> 4049 in `ui/dr-ui/ui/library.slint`. Nothing about the gestures
themselves changed.

`ui/dr-ui/src/gesture_book.rs` was already current, so this is the doc
alone: 70 files scanned, 16 gestures, 2 places, gate PASS.

Worth knowing for next time: `tools/ci-local.sh traceability` runs the
self-test, the coverage gate and the matrix, but not `gestures-check`, so a
clean local run does not prove this workflow green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:06:31 +02:00
dtourolleandClaude Opus 5 c1069ce07e Release 0.10.1
Benchmarks / CPU and I/O (per commit) (push) Successful in 13m30s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / android-image (push) Successful in 3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Layer separation (push) Successful in 46s
Traceability / Requirement traces (push) Failing after 1m36s
Build and test / Desktop (Linux) (push) Failing after 24h17m53s
Build and test / Android (aarch64) (push) Canceled after 0s
A bug fix release. Nothing here changes what DarkRoom is for since 0.10.0;
it changes how often it does the thing it already claimed to do.

The edits that went missing. A subject or category layer was stored as
identity alone, on the reasoning that the pixels were reproducible by
re-running the model — true, but nothing re-runs one except a photographer
pressing "find subjects". So a reopened photograph rendered without its
local adjustments and then saved that state back, and a batch export wrote
three hundred files without the edits their photographer had made, over a
log warning. The coverage now travels in the sidecar. Export also learned
to write the photograph rather than the canvas, and to carry the
photograph's header when the export is made from develop.

Face indexing was reading previews. It ran against a proxy and then
recorded the result as though it had seen the photograph, so faces smaller
than the proxy could resolve were not missed, they were *concluded absent*.
Indexing now runs on the native render, runs made against proxies too small
to find a face are forgotten rather than trusted, and a sweep that fails
everything says so instead of reporting a clean pass.

Where you were. The photo roll opens on the frame it opened with, develop
returns you to the photograph you were editing, the grid keeps its place
when another screen covers it, and the photographer's position now travels
between devices rather than being rediscovered on each.

Startup. The catalog opens on a worker and the bundled models unpack on
one, so a launch is no longer a page-by-page read on the way to the first
frame; the app says it is starting before there is anything to say it with.
The People rail builds the rows you can see, keeps portraits off the
blocking path, and withholds the empty groups that used to fill it.

Segmentation gained the half it was missing: the colour gate decided what
belonged to a category and had no way to decide where its edge fell, so a
refined sky kept the model's blocky outline no matter how the control was
set. A marker-based watershed now puts each contour onto a real edge, and
the refinement is a per-layer slider.

Coverage 70.4% -> 70.6% (127/180).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 21:33:16 +02:00
dtourolleandClaude Opus 5 dabf63ed6d Regenerate the matrix over the watershed and sidecar work
`Traceability` has been red on master. The matrix records every tag as
`file.rs:LINE`, so it goes stale two ways at once, and both happened here:

  - the `cargo fmt --all` sweep (710fcbc) moved lines under tags that did
    not otherwise change, which is drift with no change of meaning;
  - the segmentation and mask-storage work added tags and a requirement,
    which is drift that means something.

Regenerated: 332 -> 334 files, 1058 -> 1086 tags, 179 -> 180 requirements
defined, coverage 70.4% -> 70.6% (127/180).

No code changes. The pre-commit hook regenerates this file whenever a
taggable source file is touched, so the way it gets stale is a commit made
with `--no-verify`.

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