The room opens folded: the studio's panels and the catalogue's tree both start shut and come back the way they were left

Four asks, one shape: the LIGHT column had a chip that opened the same two
knobs DETAIL & EFFECTS already shows, the WB table was reading as one long
line per preset, the disclosure carets were too small to be the affordance
they are, and both folds — the studio's panels and the catalogue's folder tree
— opened fully every time. What the fold is for is the same in both places: a
column of a dozen controls is a wall to scroll past, and the control being
looked for is named by the head it sits under. So both open folded, and both
remember what was opened.

GRAIN's chip is gone from the LIGHT row. Its strip held a knob `grain` and the
"size" behind it, and both are rows of DETAIL & EFFECTS now — MONOCHROME GRAIN
and GRAIN SIZE — so the chip was a second way to one pair. The strip machinery
stays (`GroupKey 'grain'`, `groupDefs.grain`, `grainInch()`): it is reachable
from nothing today, and taking it out is a diff of its own. What went with the
chip is the `/INCH` readout, which only the strip drew.

The WB presets read as a name on one line and its kelvin under it: the label is
the chip's own label and the kelvin moved from the label into the value slot,
which the grid's rule stacks (`flex-direction: column`, `font-size: 11px`,
`white-space: nowrap`). Seven buttons, 147x39 each, name
box 14px — one line at 11px/1.25 — kelvin at 10px, `scrollWidth` equal to
`clientWidth` on every one of them. The kelvin rides a swatch, so it wears the
fill's own ink rather than the panel's dim grey (`.chip.tinted .val { color:
inherit }`): readableInk already picks #0b0e12 or #ffffff off that fill for
4.5:1, and dimming it a step costs contrast on a mid grey fill. Measured off
the running page: 4.88:1 (CLOUDY) to 5.00:1 (TUNGSTEN), name and kelvin alike.

The panels: `recipescam.develop.open` holds the slugs that are open, comma-
joined, and a slug no panel answers to is dropped on the way in, so a renamed
or removed panel leaves no hole. A visit that touches nothing writes nothing —
the column opens folded from an empty store — and a click on a head writes the
list as it stands. The caret is drawn a size up from the label it sits beside
(17px against an 11px head) and takes full opacity under the pointer, because
a line of uppercase does not read as a control on its own.

The tree: a row's click still opens it and folds it again, but the set it moves
in is inverted — what is *drawn open* rather than what is folded away — which
is what makes the state storable at all (a folded set is the whole tree minus
what is open, and the whole tree is not in that state to be stored). The
column opens with every folder shut, and the remembered set comes back over
it. A first visit under the new rule has no stored set and can still have a
folder remembered from before, so that one case seeds its ancestors: a screen
that opens on a node shrunk away behind a shut row is worse than a
remembered fold. COLLAPSE ALL folds the top-level row too now, which is what
makes it the way back to the column's rest — it was written when a top-level
row could not be folded, and the item is disabled when there is nothing open.
The caret is a leaf on a row with nothing under it, as it was.

`remember.ts` is the two functions both screens now share; Library had its own
copy of them.

The two checks that read these screens were wrong about the new fold and are
right about it now, not the other way round. `library-check` gains the rest
state (`the tree opens with the folders folded away — 1 rows`), walks the
tree a level per click, and reads the visit it left behind off a reload:
`lib-node-CheckRoll:true lib-node-CheckRoll/2026:true .../04:- .../Empty:-`,
with the screen marked on the subfolder. Two of its steps were stale
expectations rather than a fault: the fold-the-tree claim is one click over
four rows once the rows are opened for it, and the folder the screen reopens
on is the last row clicked, not the one clicked before it. `scan-nav-check`
asks the same tree for its deepest folder, so it opens the two rows above it
and folds them back — the reading it is watching does not move with rows.

Verified: `npx tsc --noEmit` clean, `npm run build` clean
(`dist/assets/index-T721P-Nv.js` 711.33 kB, `dist/assets/index-Co_dQD6t.css`
65.69 kB). `scripts/library-check.mjs` — "all checks passed", 55 steps, none
failing — including `the tree opens with the folders folded away — 1 rows,
aria-expanded=false`, `a row draws its own children, not the whole branch — 3
rows`, `and one click folds every row, leaving the frames where they were — 4
rows → 1, lib-node-CheckRoll:false, 2 tiles kept`, `the tree reopens with the
rows that were left open`, `the screen reopens on the folder it was left on —
lib-node-CheckRoll/2026`, `the screen reopens on the frame that was raised`,
and the column `198px, then 198px`. `scripts/scan-nav-check.mjs` — all steps
passed, its tree walk reading
`["lib-node-SlowRoll","lib-node-SlowRoll/2026","lib-node-SlowRoll/2026/04"]`.
In the running page: 5 panel heads and 0 panel bodies at rest with
`recipescam.develop.open` unset, caret `17px` `▸`; the WB table's seven rows
at 147x39 with name 14px one line, kelvin below at 10px, `nowrap`, no
overflow, ink 4.88–5.00:1; the LIGHT chip row `["auto","curve","fix",
"gradient-mask","mono","wb-temp:*","reset-all"]` — no `grp-grain` — with
MONOCHROME GRAIN and GRAIN SIZE still the two rows of DETAIL & EFFECTS; two
heads opened, `wb,effects` stored, and the same two open after a reload with
the other three shut. The deployed bundle was read too: `docker compose build
frontend && docker compose up -d frontend`, and `index-DpYWAhJ3.js` off
`127.0.0.1:8090` carries `recipescam.develop.open` and
`recipescam.library.open` and no `grp-grain`, with the same probe readings
taken against it.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 21:25:18 +07:00
parent 642fccd2b0
commit 2f34c36131
7 changed files with 201 additions and 88 deletions
+41 -42
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSS
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import { api } from './api';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import {
canBrowseFolders,
@@ -48,11 +49,13 @@ interface Node {
count: number;
}
// The three things this screen remembers between visits: the folder that was open,
// the frame that was raised in it, and how much room its column was given.
// What this screen remembers between visits: the folder that was open, the frame
// that was raised in it, how much room its column was given, and which folders of
// the tree were drawn open.
const NODE_KEY = 'recipescam.library.node';
const FRAME_KEY = 'recipescam.library.frame';
const COLUMN_KEY = 'recipescam.library.column';
const OPEN_KEY = 'recipescam.library.open';
const COLUMN_MIN = 88;
const COLUMN_MAX = 460;
const COLUMN_DEFAULT = 118;
@@ -82,23 +85,7 @@ interface Shot {
// the read in flight rather than the object under it.
const shots = new Map<string, Promise<Shot | null>>();
// Private mode throws on localStorage rather than returning null: a screen that
// cannot remember is fine, a screen that cannot load is not.
function remembered(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function keep(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
// The choice just does not outlive the visit.
}
}
export function Library() {
const { t } = useI18n();
@@ -114,10 +101,23 @@ export function Library() {
// Which tree node is open, which frame is up, and which of the two middle
// views (the frame, or every thumbnail of the open node) is on. The open node
// is picked up where the last visit left it.
const [node, setNode] = useState<string | null>(() => remembered(NODE_KEY));
// Rows whose children are folded away. A collapse is a way of looking at the
// tree, not a property of the roll, so it is not remembered between visits.
const [folded, setFolded] = useState<Set<string>>(() => new Set());
const [node, setNode] = useState<string | null>(() => recall(NODE_KEY));
// The rows whose children are drawn. The column opens with every folder shut —
// a roll is a shelf of shelves and its whole shape is not what the screen is
// for — and remembers the ones the reader opened, so the next visit draws the
// tree the way the last one was left.
const [expanded, setExpanded] = useState<Set<string>>(() => {
const saved = recall(OPEN_KEY);
// Nothing remembered means this is the first visit under that rule, and the
// node the last visit left would then open behind a shut row: its own rows
// are opened for it. A visit that folded everything remembers the empty set,
// which is not this, and is drawn as it was left.
if (saved === null) {
const parts = (recall(NODE_KEY) ?? '').split('/');
return new Set(parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/')));
}
return new Set(saved.split(',').filter(Boolean));
});
// Whether the strip draws the whole branch under the open node or only the
// node itself. Deep is how a roll reads — its top level holds no file of its
// own — and shallow is what a folder of folders is for.
@@ -127,7 +127,7 @@ export function Library() {
// studio comes back to the negative they were looking at rather than to the
// first one the node happens to hold. A frame that is gone falls through to
// the first, the way a node that is gone falls back to its folder.
const [up, setUp] = useState<string | null>(() => remembered(FRAME_KEY));
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
const [view, setView] = useState<'preview' | 'grid'>('preview');
// How the thumbnail view reads the open folder: every frame it holds, or only
// the ones that were given this many stars, shot in this year, shot between
@@ -144,7 +144,7 @@ export function Library() {
const [zoom, setZoom] = useState(1);
const [zoomAt, setZoomAt] = useState('50% 50%');
const [colWidth, setColWidth] = useState(() => {
const saved = Number(remembered(COLUMN_KEY));
const saved = Number(recall(COLUMN_KEY));
return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT;
});
// The scan is the tab's, not this screen's — see `scanSession`. This screen is
@@ -160,7 +160,7 @@ export function Library() {
// The folder menu: what a right click opens, and where. No folder means the
// right click landed on the empty part of the column, which is the way in for
// one more folder; `root` means it landed on a row at the top level — the head
// of a tree — which also carries folding the folders nested below the top level.
// of a tree — which also carries folding the whole tree.
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; x: number; y: number } | null>(null);
// Where the drag of the column divider started, and how wide the column was
// then — the pointer only ever reports a position, not a distance moved.
@@ -236,6 +236,9 @@ export function Library() {
useEffect(() => {
keep(COLUMN_KEY, String(colWidth));
}, [colWidth]);
useEffect(() => {
keep(OPEN_KEY, [...expanded].join(','));
}, [expanded]);
// The menu closes on the next click anywhere but its own items, and on Escape.
// Escape is the whole reason this listens on the document instead of sitting
@@ -433,20 +436,15 @@ export function Library() {
[nodes]
);
// The folders nested below a top-level row — what COLLAPSE ALL has to fold. A
// top-level row is not one of them: it is its own fold, since one click on its
// name shuts it, so the menu item would only repeat the row's own click.
const nested = useMemo(() => new Set([...parents].filter((key) => key.includes('/'))), [parents]);
// What the column draws: a folded row takes its whole subtree with it.
// What the column draws: a row whose parent is not open goes with it.
const rows = useMemo(
() =>
nodes.filter((n) => {
const parts = n.key.split('/');
for (let i = 1; i < parts.length; i++) if (folded.has(parts.slice(0, i).join('/'))) return false;
for (let i = 1; i < parts.length; i++) if (!expanded.has(parts.slice(0, i).join('/'))) return false;
return true;
}),
[nodes, folded]
[nodes, expanded]
);
// A remembered subfolder that no longer holds a frame falls back to the folder
@@ -725,7 +723,7 @@ export function Library() {
data-key={`lib-node-${n.key}`}
aria-current={node === n.key}
aria-busy={scanning}
aria-expanded={under ? !folded.has(n.key) : undefined}
aria-expanded={under ? expanded.has(n.key) : undefined}
title={t('lib.nodeHint')}
style={{ paddingLeft: 9 + n.depth * 12 }}
// A row with children opens on the folder and folds it in
@@ -737,7 +735,7 @@ export function Library() {
// reads next, whatever layer it sits in.
jumpScan(relOf(n.key));
if (!under) return;
setFolded((prev) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(n.key)) next.delete(n.key);
else next.add(n.key);
@@ -764,7 +762,7 @@ export function Library() {
}}
>
<span
className={`lib-caret${under ? (folded.has(n.key) ? '' : ' open') : ' leaf'}`}
className={`lib-caret${under ? (expanded.has(n.key) ? ' open' : '') : ' leaf'}`}
aria-hidden="true"
/>
<span className="adm-album-name">{n.label}</span>
@@ -1017,19 +1015,20 @@ export function Library() {
permission has lapsed gets the one thing that can be done about it. */}
{menu ? (
<div className="lib-menu" data-key="lib-menu" style={{ left: menu.x, top: menu.y }} onContextMenu={(e) => e.preventDefault()}>
{/* Fold-the-tree leads the menu of a row at the top level: the folders
nested below it go, one line per folder, and no top-level row ever
leaves the column — the row itself is one click away and its click
already shuts it. */}
{/* Fold-the-tree leads the menu of a row at the top level, and is the
way back to the column's rest: every row shut, the top-level one
included, which is the shape the column opens in. A row's own click
still draws its subtree and folds it again, so this is for a tree
that was walked into rather than for one row. */}
{menu.root ? (
<button
type="button"
className="lib-menu-item"
data-key="lib-menu-collapse"
disabled={nested.size === 0}
disabled={expanded.size === 0}
onClick={() => {
setMenu(null);
setFolded(new Set(nested));
setExpanded(new Set());
}}
>
<span className="lib-menu-icon" aria-hidden="true">▴</span>