The reader opens a roll of a few thousand frames and holds Ctrl+Shift+R while
it is being read — over a reading that takes minutes, that is the one thing
they do. What came back was a reading that started again at the top: every
folder listed a second time, the toolbar counter back at zero, the frames that
were already in the catalogue walked over so that their size and their write
time could say they had not moved.
None of that was in the catalogue, because the catalogue only holds the frames
that were read. What was lost with the page was the reading's position: the
folders the walk had been through, the folders still in its queue, and the
frames it had found and not yet read. The catalogue alone can never answer
where a reading was.
The position is now written to session storage as the reading goes — walked,
pending and the frames in hand — and read back on the way in. Session storage
and not local storage, because a position belongs to the tab: the tab that
reloads carries on from the frame it stopped at, and a tab opened beside it
starts a reading of its own. The key goes when the reading finishes, and goes
with the folder when the folder is removed.
The frames in hand are the part worth spelling out. A frame a lane is in the
middle of, and a frame whose row is in the batch and not yet in the catalogue,
are on neither side of the line the position is written on: the catalogue does
not hold them and the walk will not find them again, so both are written into
the queue and read again. They also were counted when they left the queue, and
the count is written with them — else the reloaded reading would count them a
second time. The counter now carries on from where it was instead of restarting
from zero.
One thing was hidden behind the other: the stand-in folder the check hands the
page had no `getFileHandle` and no `getDirectoryHandle`, so the path that asks
the root for the frames a position names threw and answered nothing — and the
reading then walked the roll from the top, exactly the behaviour the check was
meant to catch. The check's folder answers both now, the way the browser's does.
Verified:
library-check.mjs — 45 steps, all passed, two of them new. The reading is
stopped on the frame it is reading (`check.hold`), the tab is reloaded, and
it comes back at "Scanning 3/3 — 0 new…" — the same line it was stopped at,
not a reading starting over — with the position still holding 4 folders
walked, an empty queue and 3 frames in hand. Listing 3 tiles and letting the
key go, the reading finishes the roll: 3 tiles in the strip, the catalogue
written, the position cleared, and every folder listed once
({"2026":1,"CheckRoll":1,"Empty":1,"04":1}) where a reading that started
over lists each of them twice.
scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean, vite build clean.
ponytail: the position is the tab's, so a reload keeps it and a new tab does
not — the tab that reloaded is the one the reader is looking at, and a second
tab reading the same roll from the top costs a walk and no bytes, because a
frame that has not moved is dropped on its size and its time. The position is
written at the batch beat, so a reload reads at most one batch again, and the
frames in hand are read again on purpose: their rows were never stored.
Co-authored-by: PenguinHarness <noreply@penguin.local>
A reader with a large roll ran into three things at once, and they were one
thing: a reading is dropped the moment the tab goes, and the second one over
the same folder took as long as the first.
The catalogue was never emptied — `scanFolder` has no delete anywhere in it —
but it read every frame again. The test that was meant to skip a frame that has
not moved compared the frame's *shutter* time with the file's write time
(`seen.taken === file.lastModified`), two numbers that are equal only by
accident: a still's EXIF date is when the picture was taken, not when the file
was written. So a rescan of any indexed roll went back to the disk for every
file, decoded every frame and wrote it back — which is what reads as "it threw
the index away and started over", and it cost the same minutes the first read
did. A frame that cannot say when it was taken was worse off: it falls back to
the file's own time, so it matched, was skipped forever, and never picked up an
edit.
A row now carries `mtime`, the write time the browser reports for the file, and
a frame is skipped on the same size and the same write time — which is what the
comment over that line always claimed. A row filed before the field existed has
no `mtime` and is read one last time. On the 36-frame roll the bench serves (24
JPEG 8.2MB + 12 RAW 22.6MB, two levels deep):
first reading 5209ms
the same roll again 2887ms 24/36 frames read again
first reading 5320ms
the same roll again 603ms 0/36 frames read again
And the screen starts that reading itself. Opening LIBRARY on a roll whose
reading ended when the app did now walks it again on the way in — and again
when the tab is raised — so the frames it never got to are read with no one
asking, and frames that landed in the folder since are picked up by the same
walk. The scan belongs to the tab and the walk skips what the catalogue already
holds, so a frame that has not moved is a name, a size and a time and nothing
else; the run that does it says nothing in the toolbar, the ring on the row and
the progress line are the report.
The folder menu's commands lead with a mark of their own — fold ▴, rename ✎,
scan ↻, forget ✕, reconnect ⚿, add + — the way the tool rail and the view
switch already do: a column of marks reads at a glance where a block of
uppercase does not.
Verified:
library-check.mjs — 43 steps, all passed, six of them new: the folder menu's
three marks, the row menu's four, the add-only menu's one, the refused
folder's lone reconnect carrying its ⚿, a reading cut short that goes on by
itself (three rows back, and the bytes read are the two frames the
catalogue had lost, not the one it still held), and the folder read again
from its own menu. The stand-in folder now carries `__fake` on both handle
kinds — a folder handle that does not is one the screen cannot ask about
after a reload, which is a folder it offers to reconnect — and the frames
it hands out report one write time instead of `Date.now()` per call, which
is what a real handle does and what a frame is skipped on.
scan-nav-check.mjs — all passed, the row still counting the reading as it
comes rather than the catalogue standing still. roll-walk-check.mjs — all
passed. frontend tsc --noEmit clean.
ponytail: nothing watches the folder, so a roll that changes under a screen
left open is picked up on the next visit or the next raise, not on the change —
a FileSystemObserver when the browsers ship one. A frame is skipped on size and
time alone, so an edit that keeps both is invisible until that frame is read
again; the row's own rescan is the way to ask for exactly that.
Co-authored-by: PenguinHarness <noreply@penguin.local>
Two things the studio could not say about itself.
The preset the header has open was painted in the dim text colour, the same grey
as the page name beside it — so the one label up there that changes with the look
read as chrome. It now takes the theme's own accent, the colour the brand mark
and the open tab already carry, which means it follows both the light/dark mode
and the accent group the reader picked, out of the CSS token and with no colour
written into the component.
And an image has no other mark on it: once the frontend tarball is loaded on the
NAS as `:latest`, nothing on the box says which revision came off. The rail now
names the build at its foot — quiet, mono, wrapping rather than widening the
column, and gone under 860px where the rail is the phone's scrolling tab bar
instead of a column.
The name is stamped into the bundle at build time: vite.config reads VERSION
from the environment (`docker compose build frontend --build-arg
VERSION=$(git rev-parse --short HEAD)`) and falls back to the package version
plus the minute it was built, so two builds of the same tree are never the same
name. `ARG VERSION=` in the Dockerfile is the knob; the bare `npm run build` —
dev server, check scripts — still stamps its own time, and the dev server passes
nothing at all, so the line only draws when there is something to say.
Verified:
library-check.mjs — 37 steps, all passed, the two new ones reading the header
off the running studio: the rail foot names the build (0.1.0+202609281504)
and the preset chip's computed colour is the brand's accent, not a grey —
rgb(206, 117, 9) amber, rgb(93, 24, 191) after switching to violet.
scan-nav-check.mjs and roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean. Live 8090 on index-… matching dist/: /, /library and /app 200 with 0
console errors.
ponytail: the name is the package version plus a caller-supplied tag, and nothing
bumps the package version, so the tag is the whole identity — have the release
job write it into package.json if the numbers ever need to mean something.
Two things the preview owed a reader.
The frame raised in the strip outlived nothing: leaving for the studio and
coming back dropped the choice and the node opened on its first negative
instead. It is now remembered beside the folder and the column width — the
same one-line store, read on the way in, written on the way out — and a
frame that is gone from the catalogue falls through to the first, the way
a node that is gone falls back to its folder.
A wheel over the frame now magnifies it. The listener goes on the element
rather than through onWheel, because React's own wheel is passive and this
one has to hold the page still while it zooms; the origin is the point
under the pointer, which is what keeps a reader's aim on the thing being
looked at, and the step comes from how far the wheel turned so a notched
mouse and a trackpad travel the same. Six times is where it stops — the
thumbnail behind the preview has no more pixels to give — and a frame that
has just come up is fitted again.
library-check: 35 steps, the two new ones raising a nested frame, reloading
on it, and ticking the wheel up and back down.
Co-authored-by: PenguinHarness <noreply@penguin.local>
The column carried the name of the folder its tree belongs to, painted
above the rows. The rows already say it — the head of the tree is a row
like any other — so the heading only said it twice, and because it was
not a row it stayed on screen when the tree was folded away: the one
label left with nothing under it. The right click that raised it now
finds the row itself, which is where a reader aims anyway.
The label, its menu hook, its translation and its rule go; the tree, the
row menus and fold-all keep working as before.
Co-authored-by: PenguinHarness <noreply@penguin.local>
COLLAPSE ALL folded the top-level row as well, and that row is already its own
fold: one click on its name shuts it and takes the whole subtree with it. Folding
it from the menu hid every row of the tree — the item repeated a click the reader
already had, while the one thing a click there cannot reach, the folders nested
below, was the part that got lost with it.
So the item folds the folders nested below the top level: every row with a
subfolder under it that is not itself a folder picked at the top level. The
top-level row stays drawn and stays open, its own subfolders are drawn folded,
and no top-level row ever leaves the column. `nested` is computed off `parents`
once, and the item is disabled on a folder with nothing nested under it.
Verified:
library-check.mjs — 35 steps, all passed. The fold step now reads the rows the
click leaves behind instead of a row count: the fully open roll (4 rows)
becomes exactly CheckRoll aria-expanded=true, CheckRoll/2026
aria-expanded=false, CheckRoll/Empty (a leaf, so no aria-expanded) —
CheckRoll/2026/04 is away, the top-level row is not — with the strip's 2
tiles unmoved and the row menu still leading with lib-menu-collapse.
frontend tsc --noEmit clean. Live 8090 on index-DAIpyDNV.js matching dist/:
/, /library and /app 200 with 0 console errors.
ponytail: with several folders picked, one COLLAPSE ALL folds the nested rows of
all of them, not only the row that was clicked — no top-level row is hidden
either way; scope it to the clicked folder's prefix when a column routinely
holds many picked folders.
COLLAPSE ALL lived only on the column's own name — the label above the tree —
and that label scrolls with the list it heads: on a roll read deep the column is
scrolled past it, so the item looked like it only appeared once the top-level row
had been folded to bring the list back to the top. The row is where the pointer
already is, and it already carries the folder's menu.
The top-level folder row now leads its own menu with COLLAPSE ALL — rename,
rescan and remove follow it, and a subfolder row is unchanged, since folding a
whole tree is what a row that a tree hangs from can do and a row inside one
cannot. Both entry points run the same one: `root` on the menu state now means
"the head of a tree", the column's own name or the row of a folder picked at the
top level, and the menu draws the item first and then whatever the subject
itself has — the folder's items, or, on the empty part of the column, ADD
FOLDER. The empty part keeps ADD FOLDER alone: it is not the head of a tree.
Verified:
library-check.mjs — 35 steps, all passed. The new step right-clicks
lib-node-CheckRoll with the tree fully open (4 rows) and gets back exactly
[lib-menu-collapse, lib-rename-CheckRoll, lib-rescan-CheckRoll,
lib-drop-CheckRoll]; a right click on the column's name still answers with
lib-menu-collapse alone; the subfolder menu above is still the three folder
items with no fold in it; the empty column still offers lib-menu-add alone.
The fold itself is unchanged and still measured by opening the root back up:
4 rows → 1, and 3 back, with CheckRoll/2026/04 still away.
frontend tsc --noEmit clean. Live 8090 on index-DEvUJDNY.js matching dist/:
/, /library and /app 200 with 0 console errors.
ponytail: the column's name is still the second way to the same item and still
scrolls with the list — left alone because the row is now the one that matters;
make the name sticky when a roll routinely fills the column past one screen.
A roll read to the bottom of its dates is a long column of indented rows, and
the only way to shut it was one row at a time — the click that folds a row also
opens it, which is the right trade for reading and a poor one for putting away.
The column's own name is now the whole tree's control: a right click there opens
a menu whose one item is COLLAPSE ALL / THU GỌN TẤT CẢ, and it folds every row
that has something under it at once — the root's own row included, so the tree
comes back to one line per picked folder and the subfolder two deep is away with
the rest. The menu is the folder menu's shape at a third subject rather than a
second menu: one `root` flag on the state that already knows where a right click
landed, and the same Escape, same click-away, same clamp to the window edge.
The frames do not move with the rows: folding is a way of looking at the tree,
and the strip already reads the open folder, which the fold leaves alone. The
name keeps reading as the folder it belongs to — the folder's label, with the
hint appended to its title — and the item is disabled rather than hidden when
nothing in the column has children, so a fresh folder does not open a menu with
a dead line in it.
Verified:
library-check.mjs — 34 steps, all passed, 2 new: a right click on the root
name offers exactly [lib-menu-collapse] while the name still reads "Roll A",
and one click takes the fully open roll (4 rows: the picked folder, its
subfolder, the subfolder under that, and one holding no frame) to a single
row. The fold is measured against the ruler of opening the root back up:
the 3 rows that return are CheckRoll, CheckRoll/2026 and CheckRoll/Empty —
CheckRoll/2026/04 is still shut, which is what says the deep row went with
the fold and not just the root. The strip held its 2 tiles throughout, and
the folder the visit was on (lib-node-CheckRoll/2026) is what the reload
below still reopens on.
scan-nav-check.mjs — all steps passed; roll-walk-check.mjs — passed.
frontend tsc --noEmit clean. Live 8090, served asset index-DZFlTBDG.js
matching dist/: /, /library and /app all 200 and 0 console errors.
ponytail: only folding, no unfold-all to match — the click on a row already
opens it and the root's own row is one click away; add the pair when the column
routinely holds many picked folders and opening them one by one stops being
cheap.
The tree column now walks every folder under the one you picked instead of
its first level, so a roll of dated folders inside dated folders is walked to
the bottom. A folder being read turns a ring on its own row, the column is
titled with the folder the tree belongs to, and a divider beside it drags the
width, which is remembered along with the folder the screen was left on.
A folder's own menu gains a rename, which paints a label over the folder
without moving it — the frame ids and the recipes hang off the directory's
name, not the label. The empty part of the column answers a right click
with ADD FOLDER, the FOLDERS heading goes (the count already rides on
each row), and the column narrows to 118px so the stage takes the room.
Fold the hint, ADD FOLDER and the two view switches onto a single row,
with the switches drawn as icons, and move a folder's rescan and remove
onto a right click — they belong to the folder, so they are asked for
rather than always on screen.
The library page was a flat wall of thumbnails. It now reads like the admin
pictures tab: a folder tree on the left, the picked node's frame on the stage,
that node's frames in the strip below, and a chip pair to swap the stage for a
wall of every thumbnail in the node.
Folders are walked recursively (dot/@-prefixed names skipped, an unreadable
subfolder is dropped rather than failing the scan), so a roll with a dated
subfolder keeps both levels. Node counts include the subfolders underneath.
A RAW studio that cannot see a folder is one photo at a time. /library
now takes a folder through Chromium's directory picker, keeps the handle
in IndexedDB so the folder is there on the next visit, and walks it into
a grid: one thumbnail per frame, the frame's own date, and the recipe it
was last graded with. Nothing is uploaded and nothing is read twice —
the RAW itself is opened only when a tile is clicked, at which point the
studio develops it and the recipe comes back on top. The studio files
every change back against the frame, debounced, so reopening a RAW is
not doing the grade again.
Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and
a copy where a develop is a full decode of every pixel, and off
createImageBitmap for anything else. A RAW with no preview inside it
gets a placeholder tile rather than a minute of decoding per file.
node scripts/library-check.mjs
ok both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2
ok thumbnail for P1010256.JPG — 40400 bytes, jpeg=true
ok thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true (LibRaw preview)
ok studio developed the frame from its handle
ok the address was handed back — url=/app (no ?lib= left behind)
ok the look was filed back against the frame — baseFilter=none, 19 knobs
ok the tile says the frame is edited