Commit Graph
21 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 d44bffa4a8 Remember where the photographer was
Opening the application was always a fresh arrival at the beginning of
the library, whatever you had been doing when you closed it.

What is written down is the view, the scope, the rating filter and the
photograph on screen -- the open one in develop, the first visible one in
the grid. Not just a scroll position: a position without the filter that
produced it names a row of a list that no longer exists. Restoring them
has an order for the same reason -- scope, then filter, then position,
then the view -- because each step changes what an ordinal *means*.

Addressed by remote path and collection UUID, never by an ordinal or a
row id. `images.id` and `collections.id` are local to one catalog, and a
grid ordinal is local to one ordering; a record naming either would land
somewhere arbitrary on a second device and after any filter change on
this one. Where the ordinal is needed, `library::ordinal_of_path`
computes it through the grid's own `ORDER BY`, taken verbatim by a window
function rather than spelled a second time as an inequality -- which is
the mistake `grid_order_for` already warns about, and which a manually
ordered collection would make unreadable.

Every failure degrades rather than reports. A collection this device has
not merged leaves the scope at the whole library; a photograph that has
since been deleted falls back to when it was taken, which puts the grid
in the right week; a torn file yields no place and the library opens at
the top. Reopening develop is the one thing that requires an exact match,
because a canvas on a path that no longer resolves is a filename over an
empty frame.

The record lives in `dr-types` beside `Settings` and the store lives here
beside `SettingsStore`, for the reason `dr-types`' manifest gives: a JSON
serialiser in `core/` would be paid for by every crate there. Two files
and two lifetimes, though -- resetting preferences must not forget where
you were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:39:50 +02:00
dtourolleandClaude Opus 5 e7b526c550 Specify face indexing at native resolution, and say what the proxy cost
FR-CULL-8 said detection runs against the thumbnail or proxy tier and
never a full decode, and faces.md §5 said the aligned crop is sampled
from that same proxy. Both are wrong in the same place: they treat
detection and cropping as one resolution problem when they are two, with
opposite answers.

Detection does not care. §4.1 fixes the graph's input at 640x640 and
letterboxes whatever arrives, so a face filling 2% of the frame reaches
the model at 12px whether the buffer handed over is 1024px or 6000px.
Every pixel above the detector's own input is discarded before inference.

The crop cares about nothing else. §5's warp produces the fixed 112x112
ArcFace sees, so source resolution converts directly into whether those
112 pixels were photographed or interpolated. Reading crop_px across the
18,671 faces the proxy-tier implementation stored: 47.3% were upsampled
to reach the embedder, 314 of them by more than 2x, the smallest from 34
source pixels. An upsampled crop does not fail loudly -- it yields a
confident embedding of detail that was never there, and the damage
appears three stages later as clusters that will not separate.

So FR-CULL-8 now specifies four stages with the resolutions named
separately: render native through FR-EXP-9's pipeline, downscale for the
detector, map boxes and landmarks back to native, crop and align from
the native render. The affordability the old rule bought is met instead
by when the pass runs -- background, preempted, resumable -- and the
requirement says plainly what it now costs on a remote library: the
original rather than FR-NC-3's byte range, 412 GB across the reference
library's 19,107 images, so a whole-library pass is a transfer under
FR-NC-6 rather than something that may start on its own.

MIN_CROP_EDGE replaces the MIN_DETECT_EDGE this branch briefly had. Same
number, guarding the quantity that turned out to matter.

faces.md §7b records both measurements, and marks the second as
unexplained rather than dressing it as a finding. Grouped by the buffer
detection ran against, faces per image was 0.078 at 1024 or below and
1.82 at 2048 or better, controlled for file type and size. That gap is
real and reproducible and I cannot account for it, because the letterbox
above says detector input should not matter. M4 is where it gets
settled. The crop measurement does not depend on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:41:20 +02:00
dtourolleandClaude Opus 5 b63ce0290b Wrap the three lines that ran past the column the rest of these documents keep
Prose-only. Three paragraphs added in this branch ran to 105, 113 and 166
columns against a document that wraps at 100 everywhere else, the last because
an edit joined a new sentence onto an existing paragraph's opening line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:25:57 +02:00
dtourolleandClaude Opus 5 8865743db6 Split FR-EXP-1, so its tag stops standing for a deferral as well
FR-EXP-1 listed four things in one sentence: JPEG, PNG, TIFF at both depths,
and AVIF or JPEG XL. Three of them are built, encode, embed an ICC profile, and
are covered by tests that walk every offered format. The fourth is a deliberate
deferral — `export` returns `FormatUnsupported` for AVIF and JPEG XL, and
`the_formats_without_an_encoder_say_so` pins that behaviour in place.

Fused, the requirement could only be tagged dishonestly or not at all, and not
at all is worse: it would delete the register's record of the part that is
finished, which is most of it.

So the v1 scope is now JPEG, PNG and TIFF with the configurability clause, and
AVIF and JPEG XL are stated as post-v1 with the condition that already holds —
either may appear in the settings page before its encoder does, provided
choosing it fails with a typed error naming the format rather than producing a
file. That is what `every_offered_format_either_encodes_or_explains_itself`
exists to guarantee, and it is why a format cannot be added to the picker and
quietly reach an encoder that does not handle it.

No intent is dropped. AVIF and JPEG XL remain wanted; they are now scheduled
rather than silently outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:23:03 +02:00
dtourolleandClaude Opus 5 e0da93c59a Reword FR-RAW-2's mechanism to the one that serves its purpose
FR-RAW-2 required RAW decoding to sit behind "a trait taking a SourceRef, not a
filesystem path". The purpose is met — `dr_decode::decode` takes `&[u8]` and
the crate has no path-based entry point anywhere — and the mechanism is not,
because a decoder taking a SourceRef would be a worse design than the one built.

A SourceRef is opaque. The only thing that turns one into bytes is `Storage`,
which lives in platform/dr-plat, so a decoder taking a SourceRef must take a
Storage with it: retry, permission loss and remote fetching move inside the
decoder, and the decoder becomes constructible only where a Storage exists.
Bytes in, image out, is narrower and more portable — the decoder cannot know
where its input came from, which is the property the requirement wants.

The Nextcloud case is the one a byte-oriented API looks like it should lose,
and it is the clearest illustration that it does not. `dr_decode::HEADER_BYTES`
declares how much of a file the decoder needs in order to read metadata, and
`import.rs` fetches exactly that range through `Storage::read_range` before
calling `dr_decode::metadata`. The decoder states a requirement; the storage
layer satisfies it. A decoder holding its own SourceRef would have had to carry
the range policy itself.

The trait half of the clause is left standing and unmet. There is one decoder
reached through free functions, so "a second implementation may be added
without changing callers" is still outstanding work rather than a description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:22:22 +02:00
dtourolleandClaude Opus 5 11e515a295 Say which state NFR-P11 forbids losing, since one kind is dropped on purpose
NFR-P11's criterion was "no dropped frames, no state loss" across a layout
class transition. Read literally, the interface already fails it, and fails it
by design: `apply_layout_class` clears the user's panel open/closed choices
whenever the class changes, and `PanelChoices` documents why — a choice made in
landscape answers a different question from the one portrait asks, and carrying
it across leaves a 232 px sidebar on a screen with no room for it.

A requirement that the code deliberately contradicts is worse than no
requirement, because the next person to read it either "fixes" the behaviour or
learns to discount the register.

So the criterion now names what must survive — the open image and version, the
selection, scroll position, the in-progress edit and its undo history, the
current mode — and states the exemption with the reasoning attached. Panel
disclosure is a default re-derived per class, not state; the user's
disagreement with it is remembered within the class where it was expressed.

The intent is unchanged: a resize must not cost the photographer anything they
did. It is now possible to write a test for that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:21:32 +02:00
dtourolleandClaude Opus 5 99f9b5b7c5 Strike R5's tiling clauses, which the frame budget argues against
R5 asks that the application work on downscaled proxies for display, and its
criterion stated that in two parts: the display pipeline operates at viewport
resolution, and only visible tiles are computed with panning recomputing only
newly exposed ones. The first is built and pixel-equality tested. The second is
not, and should not be.

This is the same correction FR-DSP-2 already received, applied to the
requirement that FR-DSP-2's tiling clause traces back to. frame-budget.md
measured the case tiling exists for: recomputing the whole 4K viewport costs
4.5 ms of a 16 ms budget, so a perfect tile cache could save at most 4.5 ms in
exchange for a cache keyed by (VersionId, tile, zoom, graph_hash_prefix) that
must stay correct across every parameter change in the graph.

For the one stage that does exceed the budget it is worse than useless. That
stage is a convolution, and a tiled convolution reads a halo per tile: at the
52 px radius measured at 4K, 256 px tiles read (256+104)² taps instead of 256².

The intent is not removed — a display path that does work proportional to the
source image is still forbidden, and that is what the remaining clause says.
What is removed is a mechanism written in as though it were the only way to
get there, and which measurement says is the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:21:06 +02:00
dtourolleandClaude Opus 5 da20d42d33 Merge master: pluggable storage, and a name that anchors
Conflicts were docs/traceability.md alone, and it is generated — so it
was regenerated rather than hand-merged. dr-face was untouched on the
other side; ui/dr-ui/src/faces.rs and identity_ui.rs auto-merged, the
first around recluster's anchoring and the second around load_faces.

Worth recording because the two branches met on the same problem from
different ends. Master's "Let a name hold a group together" is the fix
for the sixteen Catherines — fourteen of them empty — that this branch
found while measuring the library and reported without fixing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 12:30:01 +02:00
dtourolle c102ba9df2 Treat a placeholder as the photograph, not as a one-byte file
The folder connector was pointed at a Nextcloud VFS tree and got three
things wrong, the first of which loses work.

**A dehydrated sidecar read as absent.** `a.drsc` does not exist when the
client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed,
`.ok()` swallowed the `NotFound`, and the sidecar writer took that for
"there is no sidecar yet" and wrote a fresh document over the existing
one. Every edit another device had put there went with it. That function's
own doc comment calls this the exact loss the format's unknown-key
preservation exists to prevent.

**A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this
machine at 121,785 placeholders against 10,267 real files — so a folder
library on a synced tree was ~92% broken rows.

**Identity changed on hydration**, so downloading a photograph looked like
a delete and an add, orphaning its thumbnail and its face rows.

Entries now carry the photograph's own name and a `materialised` flag;
`get` on a stub returns the new `RemoteError::NotMaterialised`, which is
distinct from `NotFound` precisely because the sidecar writer must treat
them differently — it fetches the sidecar and merges, or leaves the entry
queued.

Hydration is a **borrow**. `BorrowPool` records what was on disk before it
asked, so `release_all` dehydrates only what a pass brought and leaves
what the user already had. Reference counted: the thumbnail pass and the
face pass meet on the same RAW, and without counting the first to finish
dehydrates the file the second is reading. A borrow against a plain folder
or a server does nothing, so a pass written for VFS runs everywhere.

Releasing means asking the client to dehydrate and never deleting: a
deletion inside a synced tree propagates to the server and removes the
photograph from every device.

Not a second backend — the capability is per *connection*, not per type,
since the same folder hydrates only while the client runs. The convention
arrives through a detector the registry supplies, so `dr-sync-folder`
still knows nothing about any client's protocol.

ARCH §9.0a records this as an amendment: finding 3 rejected hydration
because it costs 100× a range read, and that comparison assumed a
connector was available. A folder library has none.
2026-08-29 09:57:52 +02:00
dtourolle f12aece07e Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.

A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:

- **Capabilities** — already there, and the reason the engine can drive
  two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
  whatever form its connector addresses, with no server in it. Loads
  every existing config unchanged (`backend` defaults to `nextcloud`,
  `endpoint` is stored under its historical `server` key), and
  `Account::namespace()` reproduces the old catalog directory byte for
  byte, because changing it would abandon a catalog, its thumbnail
  shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
  `ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
  names a connector.

`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.

Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.

`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.

docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
2026-08-29 09:57:52 +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 4a2fcb6d22 Render a film stock on the GPU, and let it take over the rendering
The stock model landed in dr-film with no way to see it. This is the
pipeline node, the two texture bindings it reads, and the end-to-end test
that proves the shader agrees with the model.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:12:56 +02:00
dtourolleandClaude Opus 5 d04087af83 Import into the library, which is on the server
There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.

An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.

What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.

And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.

FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:09:26 +02:00
dtourolleandClaude Opus 5 5945f7420a Say where an uploaded original lands
FR-NC-7 specifies how bytes travel and deliberately has no opinion about
where they go, and nothing else said either — so the one thing a photograph
needs on arrival, a folder, was unwritten.

FR-NC-7a gives it `{yyyy}/{yyyy}-{mm}-{dd}`, and two consequences the
transport mechanics do not supply on their own: expansion is a pure function
of capture metadata, so two devices computing a destination for the same
frame agree; and the template governs placement on upload only, because the
remote library is reachable by other clients and restructuring it under them
is not ours to do.

FR-NC-7b joins import to upload. A move-import must not erase the card until
the upload is confirmed — a card erased against an in-flight transfer is the
one failure in this application with no undo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:00:13 +02:00
dtourolle 610f679881 Reconcile the three branches
Two faults textual merge could not see. Both agents added a `mod tests` to
masks_ui.rs — the module boundary was an artefact of them being written
apart, and the tests do not overlap, so they fold into one. And `segment`
lost its context argument when the work moved to a worker, which a test
written on another branch still passed.
2026-08-22 13:33:55 +02:00
dtourolle acd694c2cf No phone, so stop designing for one
Targets are a 12-inch tablet and a desktop (D15). That removes most of the
navigation question rather than answering it.

`EXPANDED_MIN_WIDTH` is 820 logical pixels and a 12-inch tablet is ~1024
across in portrait, so both orientations of both targets are the expanded
class. The compact class now fires only when a desktop window is dragged
narrow — graceful degradation, not a second interface. The bottom tool strip
and the one-tool-at-a-time sheet were solving a phone, and there is no phone.

What survives is input, not size, and the architecture had already decided
it: `WidgetDemand::precise_pointing` exists for a television remote, and its
own documentation says touch is fine because hit regions grow to the
modality. Touch changes hit regions, not layout. The rules that fall out are
worth stating because they are easy to violate by accident — no hover-only
affordance and no modifier key may be the sole route to anything, since a
tablet has neither. Local masking already lost its shift-click extend for
exactly this reason.

The guaranteed-wide viewport also pays for a better answer to the extent
problem than hiding things. The complaint was never that the column is long;
it is that the histogram scrolls away from the sliders it reports on.
Collapsing shortens the scroll, pinning removes the problem, and ~260px of
fixed height is affordable on a viewport that is never under 820 wide.
2026-08-22 09:55:09 +02:00
dtourolle 12d320cf33 Record what the spec got wrong about the model that exists
docs/segmentation.md §4 priced arm B as costing a C dependency under the
NDK and treated that as most of the difference between the arms. It is not
a cost that has to be paid: `ort`'s `alternative-backend` disables its
linking entirely and `ort-tract` supplies the API from tract, which is pure
Rust. D13's "largest exception the policy would tolerate" turns out not to
be needed, and the answer generalises to the face pipeline — so D13's
runtime half is now answered and only its licensing half is open.

Three findings contradict §4 outright and are recorded as F4-F6 rather than
quietly designed around. There is no ADE20K-trained YOLO, so the shipped
vocabulary selects subjects and not stuff — "select the sky" comes from the
watershed or from nowhere. It is instance segmentation, so it partitions
nothing and two people come back as two instances. And tract cannot parse a
dynamic-shape export, which fixes the input at 640 square and makes tiling
the only route to more semantic resolution.

Arm C ships, but §8's criteria are not what decided it, and saying so
matters more than claiming the process worked. §8 asked for a two-
interaction margin over arm A on a traced corpus. That comparison was never
run: F4 and F5 changed what the arms are, and a model that recognises
subjects but has no word for sky cannot be a selection tool alone, while a
watershed cannot tell a person from the wall behind them. They stopped
being candidates and became complements.

What is *not* done is written down as plainly: the 24-image corpus is
untraced, so M1-M4 have no numbers and "this feels right" has not become
one. M5 is answered on one device only, and region ids now reach the
sidecar — so a cross-vendor divergence would mean a mask written on the
desktop meaning something else on Android. F3 stands.
2026-08-22 08:39:17 +02:00
dtourolleandClaude Opus 5 489465faf0 Show the photograph the way it was taken
Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:37:05 +02:00
dtourolleandClaude Opus 5 fe66eba87f Write down the trash requirement the code already implements
The traceability gate failed on an orphan tag: seventeen sites across
dr-catalog, dr-sync, dr-thumbs and the UI claim FR-CAT-15, and
requirements.md defines FR-CAT-1 through FR-CAT-14. Not a typo and not a
renumbering — the trash was built, designed and documented in the modules
that implement it, and the requirement itself was never written. An orphan
is the gate working: a tag naming an undefined ID would otherwise count as
covered, which is how a matrix comes to report coverage of things nobody
specified.

FR-CAT-15 now says what trash.rs does, in the terms the module already
argues: a soft delete moves the file into `.darkroom-trash/` and the
catalog records that it happened, because a flag alone would not survive
invariant 5.2.4 — the catalog is rebuildable from sources, so a rescan
would find every deleted file still in the library and re-index it. That
is also why the scanner exclusion is part of the requirement rather than
an implementation detail; the folder and the exclusion are one mechanism
and neither works alone. Permanent delete removes the file before the row,
a delete of something already gone counts as success, and the count and
bytes are shown before emptying.

docs/traceability.md is regenerated: 150 requirements, 71 covered, no
orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 14:48:31 +02:00
dtourolleandClaude Opus 5 02b66ddfcf Document trash, collections, thumbnails, and the UI direction
Specs for the work that follows: soft delete via a MOVE that preserves the
remote id, the collection tree and smart collections, the thumbnail store,
and the derived-state folder.

Adds two design documents. ui-refinement.md names the structural gaps
between the v0.1 UI and something that feels like a photo editor.
view-composition.md proposes a view controller for the display layer,
against the 500-line run() that has become one by accretion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:36:23 +02:00
dtourolle 82a5e21ec6 Initial workspace: GPU context, compute pass, adaptive Slint shell
Establishes the v0.1 foundations on both platforms:

- dr-types: SourceRef (never a filesystem path — Android SAF has none),
  Format, Availability, Validator with ETag quote normalisation
- dr-gpu: wgpu device, compute pass writing a storage texture, resize
- dr-ui: Slint shell with FR-UI-1 adaptive layout, computed in Rust to
  avoid a binding loop
- docker/android: pinned toolchain, verified producing API 28 ARM binaries

Measured the cost of the temporary CPU readback path (dr-gpu bench):
compute is 0.06-0.28ms across sizes while readback is 0.63-7.43ms, so
readback is 90-96% of frame time and scales with area. Recorded in
ARCH §6.1 — this is why spike S1 is the priority.

Mitigations pending S1: reuse the staging buffer, apply at most one
resize per frame, and cap render resolution at 2048 on the long edge.

10 tests passing; core crates cross-compile for aarch64-linux-android.
2026-08-09 07:42:05 +02:00