Commit Graph
22 Commits
Author SHA1 Message Date
dtourolle d489a34190 Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
2026-09-24 23:42:26 -04:00
dtourolle 150e53e878 Link fifty gestures on the help sheet to the manual section that shows them
The sheet could now offer "See it", but no gesture said where to look.

Every GESTURE tag whose move the manual describes names that section:
the white-balance picker, zoom and pan, masks, undo and snapshots,
export, colour labels and ratings, selection, collections, the People
page, thumbnail size. Fifty of the fifty-one; the one left, putting a
single control back to its default, has no section and is too small to
earn one.

Three important gestures had nowhere to land, so the manual gains three
short sections, without pictures for now: Bursts (opening a folded
burst, choosing the frame it shows, and the eyes-open filter), Moving
between photographs (the roll, the arrows and A/D in develop) and
Copying settings (Copy, Paste, Paste to N, and choosing what a copy
carries). The page, the gesture book and docs/gestures.md are
regenerated from them.
2026-09-24 23:25:37 -04:00
dtourolle 84fade99ec Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 21:16:03 +02:00
dtourolle f79a76f2d5 Name the eye pass on the People screen
Once every image has been through the detector and only readings are
left — the state an already-indexed library is in the day the eye models
arrive — the button reads "Read eye state" rather than promising to
index, and the coverage line says what the faces are waiting for.
2026-09-19 14:24:16 +02:00
dtourolle 85cc2b1dcc Trace the eye reading to FR-CULL-8a and the chip to FR-CULL-13
The register grew both clauses the same day this was built: FR-CULL-8a is
the per-face state the reading is, and FR-CULL-13 is the rule that a
signal is shown and filtered and never writes a judgement. The tags,
faces.md §17 and catalog.md now say which is which; FR-CULL-8a records
what of it is built, and that its third model is under the InsightFace
grant by the same decision as the pair.
2026-09-19 14:06:59 +02:00
dtourolle 83f4253b6a Filter the grid to a person with their eyes open
An "Eyes open" chip beside the people chips, offered only while someone
is chosen and dropped when the last person goes, so no term narrows the
grid with nothing on the bar to say so. It compiles the rule in
dr_face::eyes into the person's face subquery — Anna, eyes open, whoever
else is blinking beside her — and drops a frame only on a closed eye that
could be read: sunglasses, eyes too small or soft to read, and faces never
read all pass, so an old library shows everything under the chip until
the measuring pass has run. A test drives the same readings through the
SQL and through the rule and requires them to agree.

The People screen badges a face "Eyes closed", "Sunglasses" or "Eyes
unclear" so the reason a frame is or is not in the grid can be read off
the face; the sweep loads the three models when they are beside the pair
and reads eyes on the indexing and measuring passes from the native
render; the coverage line counts unread faces as work to measure so an
already-indexed library keeps its Index button. The term travels with the
place.
2026-09-19 14:04:35 +02:00
dtourolle 4f31123b0c Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small
faces in every group shot, and a dog embedded a dozen times. Which
trade is right depends on the machine doing the sweep — a desktop left
overnight and a tablet on a battery want different answers — so the
detector is now a per-device setting, Fast / Balanced / Thorough on
the settings page beside the indexing button, persisted with the rest
of the settings file.

A detector is half of a model id. Every face, marker, shard and
calibration is keyed on faces.model_id precisely so that a model change
is a new id and a re-index rather than a silent change under existing
data, and a detector change is a model change: it decides which faces
exist and where the landmarks that align them land. So each choice
names its own pipeline. 500M keeps the bare "w600k_mbf" every existing
library was written under, so an upgrade disturbs nothing; the others
are qualified. Choosing one restarts coverage from zero under the new
id, the sweep re-detects, confirmed names carry across by box overlap,
and the sync shards are keyed by the same id so a peer on another
setting neither adopts nor pollutes them. The library controller
carries the id into the sync the same way it carries the cache budget,
because the sync starts from places that have no settings in reach.

All three shape-fixed exports ship — APK, Arch, Flatpak — since a
tablet has no other way to obtain the one it was not installed with;
the APK grows by twenty megabytes for the choice.
2026-09-11 22:12:53 +02:00
dtourolle 8b3abdb787 Keep each face's quality, and never compare against a poor one
The embedder's raw output has a length, and the length is a reading of
how recognisable the crop was: a blur, an occlusion or a hard profile
comes out short. Normalising threw it away. A short vector sits near
the middle of the sphere and matches a little of everyone, which is how
one bad crop bridges two people in a grouping pass.

So the length is kept — the store now holds the raw vector, re-normalised
on load, with the length beside it as `faces.quality` — and a face under
MIN_GALLERY_QUALITY (14) is a probe: measured against the gallery and
placed where it fits, but never what another face is measured against.
Two probes are never paired, and a probe is nobody's evidence for a
confidence. The People screen shows the number as "Quality 17.3", dimmed
below the floor.

Faces indexed before this stored unit vectors and have no reading; they
are admitted to the gallery, and schema V14 forgets the run marker of
every image holding one so the next indexing pass measures them. A
peer's unmeasured shard faces are not adopted, or a sync would write
that marker back.
2026-09-11 21:50:12 +02:00
dtourolleandClaude Opus 5 d7b851f622 Build the People rail rows you can see, not the ones you cannot
`Flickable { VerticalLayout { for ... } }` instantiates every row. On the
reference library that is 14,268 row subtrees, which measures at 4.2
seconds before a pixel is drawn — and it was paid again on every action,
because every action reloads the model. Roughly half the wait between
clicking "Identity" and the screen appearing was this.

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

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

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

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

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

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

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

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

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

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

Two decisions worth recording because the obvious alternative is wrong:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:07 +02:00
dtourolleandClaude Opus 5 c8e05831f4 Show the confidence, and say which curve it came from
FR-CULL-9 was read as "no fit, no number", so every library without 200
confirmed positive pairs showed "Confidence unavailable" on every
suggestion — which is every library, until enough confirmations exist to
fit one. The confirmations are made on this screen, ranked by the number
it was withholding, so the degraded state was also the permanent one.

There has always been a curve: Calibration::default is the reference
implementation's fitted MBF sigmoid, which is what clustering already
operates at. It is a published operating point, not an invention, and
what the requirement forbids is presenting it *as though it were
measured on this library*. So the percentage is shown, and the screen
says once, above the grid, where the curve came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 09:57:38 +02:00
dtourolleandClaude Opus 5 c81d6865a7 Stop the name field running through the buttons under it
The Identity header was two rows pinned to 32px and 36px. Neither number was big
enough for what the row held: a `Field` is `Theme.touch-target` — 44px — and a
`Button` is `Theme.control-height`.

Slint honours a child's own height and lets it overflow the box the layout gave
it, so the name field drew 44px from the top of a 32px row while the button
strip began at 38px. The overlap was 6px of text box sitting on top of "Confirm
all".

Neither row states a height any more. The first takes the height of what is in
it, and the strip takes the height of a control — read from the theme rather
than from the row inside it, since `actions` sizes itself from the Flickable's
viewport and measuring it back would be a binding loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:30:15 +02:00
dtourolleandClaude Opus 5 af5a13b3f7 Narrow the grid to several people at once, either way
"Show photos" could only ever mean one person. The two questions a photographer
actually asks are "every picture of Anna or Bob" and "the pictures they are both
in", and the second is not reachable by any sequence of single-person filters —
no amount of switching between one person and another finds the frame they
share.

So the filter holds a *set* of people and a mode. `RatingFilter` was already the
right home, as its own doc says: every query path threads it, so the count in
the header and the cells in the grid are narrowed by the same thing, and this
composes with stars, flags and the date range for free.

The union is `EXISTS ... person_id IN (...)`. The intersection counts
**distinct** people per image and compares against the size of the selection —
one subquery rather than one per person, and it does not grow the statement with
the selection. `DISTINCT` is what makes it correct: three faces of Anna in one
frame must not satisfy a filter asking for Anna and Bob, and there is a test
that says so.

Any rather than All is the default. With one person the modes are the same
filter, and adding a second to a union can only ever show more — so a user who
has not noticed the toggle never ends up staring at an empty grid wondering what
they broke. The toggle only appears at two people, because a control that
demonstrably does nothing is a control that teaches the user to ignore it.

Building the set needs no picker of its own: the Identity screen gains "And
also…" beside "Show photos", offered only once the grid is already narrowed to
somebody. Each person is a chip on the filter bar and each chip removes just
that person, so a selection of three can be taken apart one at a time rather
than only cleared wholesale.

`RatingFilter` stops being `Copy`, since it now holds a `Vec`. Every query path
already took it by reference; the casualties were two struct updates and one
`Cell` that becomes a `RefCell`.

484 dr-ui tests pass, including the union, the intersection, that one person
reads the same in both modes, and the repeated-faces trap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:17:59 +02:00
dtourolleandClaude Opus 5 d6380fecc8 Make the People screen a place work can be done
Five faults, all on one screen, and the Slint and Rust halves of each have to
land together.

**Regroup froze the window.** It ran inside the Slint callback, on the UI
thread. It is much faster now, but fast is not bounded — the work grows with the
library, and the one thing that must not grow with the library is how long the
window stops answering. It runs on a worker thread with an mpsc channel and a
250ms poll, like every other long pass in this module, and the button says what
it is doing instead of the window going quiet. Cancellation is dropping the
receiver. Reclustering also prunes the empty groups the previous pass left, so
pressing the button twice no longer fills the rail with "Unnamed (0 faces)".

**The faces were a single row running off the screen.** The comment on the
layout claimed to be a wrapping row; Slint has no flow layout and a
HorizontalLayout does not wrap, so a person with forty faces was a person whose
faces could not be reviewed past the fifth. It is now laid out the way the
library grid lays out thumbnails, with the same arithmetic: choose how many
columns of roughly the requested size fit, then divide the width between them so
the cells fill the row exactly and nothing overhangs.

**The header did not fit a phone.** A 240px name field beside five buttons is
wider than an Android screen — and worse than not fitting, a layout cannot be
narrower than its children's minimums, so the row reported that oversized
minimum upwards and inflated the whole screen. The faces grid is its sibling, so
it would have been measured against a width that was never on the display. The
header is now two rows, the actions sit in a Flickable that scrolls rather than
overflowing, and the rail narrows to 132px on the compact class.

**Strangers crowded out the people who matter.** Most clusters in a real library
are passers-by and other people's guests. "Not interested" sets a group aside;
the rail hides it and says how many are hidden, with one button to bring them
back. Reversible, and never a deletion — see the catalog commit for why.

**A face was a dead end.** Identifying someone and then having no way to see
their photographs is a filing cabinet with no drawer handles. "Show photos"
narrows the library grid to that person and leaves a chip on the filter bar
saying so, which is also how it is cleared. It is a term on `RatingFilter`
rather than a grid scope of its own, exactly as that struct's own doc says new
narrowing terms should be — so the count and the cells are narrowed by the same
thing, and it composes with the others for free. Suggested faces count, not only
confirmed ones, or a freshly grouped person would show an empty grid.

Crops are read from where they are now stored, falling back to cutting one out
of the proxy for faces indexed before that existed.

480 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:21:31 +02:00
dtourolleandClaude Opus 5 57c0cc0d35 Keep the name typed into a cluster when the next one is opened
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.

`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.

And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.

So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.

A counter rather than `changed selected-name`, because the name is not a key:
naming six clusters "Anna" in a row never changes `selected-name`, and the field
would keep the half-typed text from the cluster before. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.

The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.

`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.

An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:32:00 +02:00
dtourolleandClaude Opus 5 725f7bf77f Offer the merge when two people turn out to share a name
Over-clustering is the normal state of a freshly indexed library — FR-CULL-10
says so — which means one person arrives as several groups and the user names
each of them the same thing. Until now that produced several people called Anna
and no way to join them: `identity::merge` existed, `faces::merge_people`
existed with its redirect tombstone, and `merge-into(int)` sat in identity.slint
declared, never emitted and never wired. The screen had a split button and no
merge.

So a rename that collides now offers one. Type a name another person already
carries and a strip appears under the field: "Someone else is already called
Anna (14 faces). Merge them?"

Offered, not performed. `people.uuid` is the identity and the name is not — the
schema comment on that column is explicit that two devices naming the same
cluster independently is the case it was built for — so two people sharing a
name is legal, and folding them together on a keystroke would be the screen
making an identity decision on the user's behalf. That is the thing this screen
spends a whole button avoiding.

The rename always lands first, and declining leaves it exactly as typed. There
is nothing to undo because nothing was done.

Details that are not arbitrary:

The comparison is trimmed and case-insensitive. "anna" on a phone keyboard and
"Anna" on a desktop are one intention, and an offer that appeared only when the
capitalisation matched would read as a bug.

An empty name collides with nothing. Every unnamed cluster renders as "Unnamed
(n faces)"; if that counted as a collision the offer would appear on every
cluster in a fresh library, and accepting it would fold the library into one
person.

The newly-named person folds into the one that already held the name, not the
reverse. The older person is the one other devices have seen and the one whose
confirmations are more likely to be real. Selection follows the merge, because
landing on an empty screen after a successful action reads as a failure.

The offer is retired when the person changes, and when a refresh finds its
target gone — merged from the other side of a sync, or deleted. An offer left
standing would fold whoever happens to be selected now.

A merged-away person is not a namesake: `faces::people` already excludes
redirects, so the offer does not reappear the instant it is accepted.

Four tests over the collision rules, and the existing 467 still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:33:36 +02:00
dtourolleandClaude Opus 5 61c4547b9c Make the Identity Manager a peer of the library and develop
It was reachable only from the library header, which made it a side trip
rather than a mode. It is now reachable from develop's header too, beside
the way back, because that is the same kind of move -- leaving this
photograph for somewhere else in the library -- and a screen you can only
reach from one of the other two is not a peer of them.

Leaving returns to whichever screen opened it, and the button says which.
A back button that read "Library" while returning to develop would be
lying about the one thing a back button has to be right about. The
develop session is only hidden, never torn down, so returning to it costs
nothing and keeps the photographer's place.

The header now matches the other two screens rather than using a close
cross: three screens whose headers disagree read as three applications.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:14:17 +02:00
dtourolleandClaude Opus 5 26a1eb7e28 Record that face detection has run, not just what it found
An image with no faces in it was indistinguishable from one that had
never been looked at, so every landscape, still life and document scan in
the library was re-detected on every pass, for ever. In a real library
that is most of it: on the 23,527-image test library, 64 of the first 110
images indexed contain no face at all.

Schema v9 adds face_index, a run marker per (image, model) carrying the
face count and the proxy edge it read. Keyed on the model, so a model
change puts every image back in the queue by itself.

That makes a coverage figure possible, which is the thing a user actually
wants to see. The audit also splits the outstanding set by whether a
proxy exists, because 23,417 awaiting a proxy and 110 ready to index are
different problems, and telling the user to run indexing again would not
fix the first.

The Identity screen gains Index faces, Stop, and the coverage line.
examples/face_index.rs is the same check and sweep without a window,
which is the right shape for an overnight pass.

Measured on the real library in release: 3.5 images/second, 110 images
and 125 faces in 30 seconds, and a second run correctly finds nothing
left to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:13:41 +02:00
dtourolleandClaude Opus 5 10b10569e2 Add the Identity screen
A third top-level screen beside the library and develop, because naming a
cluster and pulling a stranger out of it are tasks with their own rhythm
and need the whole window.

The screen is designed around the clustering being wrong, which is
FR-CULL-10 rather than pessimism: grouping over-merges on siblings, on
parents and children, and on the same person a decade apart. So Split off
sits next to Confirm all rather than behind a menu, the confirm/reject
pair is on the face itself, and a group the system found is drawn
differently from a person the user has vouched for.

Splitting rejects before it confirms. Without that the next clustering
pass suggests the face straight back and the user's correction becomes an
argument they keep having.

Face crops come from the proxies the grid already built, one decode per
image rather than per face -- a group photograph holding six faces of one
family is one JPEG.

Where the calibration is not fitted the screen says confidence is
unavailable instead of printing a percentage that looks measured, which
is FR-CULL-9's rule at the point it becomes visible.

The verdict controls use drawn icons, not tick and cross characters:
ui/icons.slint exists because those render as tofu on Android.

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