diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 76e3570..200f3f3 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -350,13 +350,37 @@ for (const name of [JPG, RAW]) { step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`); } +// The tree opens with every folder shut — the column is the folder that was +// picked and nothing under it until it is asked for — and one click on a row +// draws its subtree, the click after that folds it again. +const shutRows = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); +const shutAria = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded'); +step( + 'the tree opens with the folders folded away', + shutRows === 1 && shutAria === 'false', + `${shutRows} rows, aria-expanded=${shutAria}` +); + // The tree: the picked folder, with the subfolder a frame sits in under it, each -// carrying the frames at or below it. The RAW is the nested one. +// carrying the frames at or below it. The RAW is the nested one. One row's click +// draws that row's children and no more, so the walk goes down a row at a time. +await page.click('[data-key="lib-node-CheckRoll"]'); +await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, { + timeout: 10_000, + polling: 50, +}); +const oneDeep = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); +step('a row draws its own children, not the whole branch', oneDeep === 3, `${oneDeep} rows`); +await page.click('[data-key="lib-node-CheckRoll/2026"]'); +await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, { + timeout: 10_000, + polling: 50, +}); const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent]) ); step( - 'the tree draws the roll as folders under folders', + 'and the row below it draws the roll as folders under folders', JSON.stringify(nodes) === JSON.stringify([ ['lib-node-CheckRoll', '3'], @@ -368,8 +392,8 @@ step( JSON.stringify(nodes) ); -// A row with children folds its subtree on the same click that opens it, and the -// frames it holds stay in the strip — the tree narrows, the pictures do not. +// The same click again folds its subtree, and the frames it holds stay in the +// strip — the tree narrows, the pictures do not. await page.click('[data-key="lib-node-CheckRoll"]'); await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, { timeout: 10_000, @@ -388,18 +412,19 @@ await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node timeout: 10_000, polling: 50, }); -step('the same click draws it open again', true, '4 rows'); +step('the same click draws it open again', true, '4 rows, the deep row still open'); // Show subfolders or not: the strip follows the open folder alone, or the whole // branch under it. The switch is the reader's and the tree does not change with // it — folding is about rows, this is about frames. +const beforeSwitch = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); await page.click('[data-key="lib-subs"]'); await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 10_000, polling: 50 }); const shallow = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); const shallowStage = await page.locator('[data-key="lib-stage-meta"]').innerText(); step( 'the switch leaves the open folder its own frames', - shallow === 4 && /P1010256\.JPG/.test(shallowStage), + shallow === beforeSwitch && /P1010256\.JPG/.test(shallowStage), `${shallow} rows, stage ${shallowStage.split('\n')[0]}` ); await page.click('[data-key="lib-subs"]'); @@ -518,18 +543,21 @@ step('and that one command carries its mark too', blankMarks.length === 1 && mar await page.keyboard.press('Escape'); step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null); -// The head of the tree is the whole tree's control: a right click on the row of a -// folder picked at the top level folds the folders nested below the top level. The -// top-level row itself stays open — one click on its name already shuts it, so the -// menu item would only repeat that click — and -// the frames in the strip are the strip's business and do not move with the rows. -// The subfolder opened above carries a fold of its own, so it is opened first: -// the claim is what ONE click does to every row at once, not to one already shut. -await page.click('[data-key="lib-node-CheckRoll/2026"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, { - timeout: 10_000, - polling: 50, -}); +// The head of the tree is the column's own control: a right click on the row of a +// folder picked at the top level folds the whole tree — every row shut, the +// top-level one included, which is the shape the column opens in — and the frames +// in the strip are the strip's business and do not move with the rows. The claim +// is what ONE click does to every row, so the item is asked for with the tree open +// at each level: the subfolder is opened here if the click before it shut it. +for (const key of ['lib-node-CheckRoll', 'lib-node-CheckRoll/2026']) { + if ((await page.getAttribute(`[data-key="${key}"]`, 'aria-expanded')) !== 'false') continue; + await page.click(`[data-key="${key}"]`); + await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length >= 3, null, { + timeout: 10_000, + polling: 50, + }); +} +await page.waitForSelector('[data-key="lib-node-CheckRoll/2026/04"]', { timeout: 10_000 }); const deepRows = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length); const deepTiles = await page.$$eval(thumbs, (els) => els.length); // The row is where a reader right-clicks, so fold-the-tree leads that row's menu. @@ -544,7 +572,7 @@ step( const rowMarks = await menuMarks(); step('and its four commands each lead with a mark of their own', rowMarks.length === 4 && marked(rowMarks), JSON.stringify(rowMarks)); await page.click('[data-key="lib-menu-collapse"]'); -await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, { +await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, { timeout: 10_000, polling: 50, }); @@ -553,17 +581,27 @@ const ended = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => `${e.dataset.key}:${e.getAttribute('aria-expanded') ?? '-'}`) ); step( - 'and one click folds the subfolders, leaving the top-level row open', - deepRows === 4 && - flatTiles === deepTiles && - ended.join(' ') === 'lib-node-CheckRoll:true lib-node-CheckRoll/2026:false lib-node-CheckRoll/Empty:-', + 'and one click folds every row, leaving the frames where they were', + deepRows === 4 && flatTiles === deepTiles && ended.join(' ') === 'lib-node-CheckRoll:false', `${deepRows} rows → ${ended.length}, ${ended.join(' ')}, ${flatTiles} tiles kept`, ); // The divider drags the column, and what the visit leaves behind — the width the -// column was left at and the folder that was open — is what the next visit opens -// with. The right click on the empty column answered with a folder, not with a -// folder opened, so the open node is still the subfolder chosen above. +// column was left at, the folder it was left on, and the rows that were drawn open +// — is what the next visit opens with. Folding the tree left every row shut, so +// the visit that is left behind is a walk back down it: the row is opened (which +// is also the row that marks the screen) and the subfolder under it is opened the +// same way, and both are left that way. +await page.click('[data-key="lib-node-CheckRoll"]'); +await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, { + timeout: 10_000, + polling: 50, +}); +await page.click('[data-key="lib-node-CheckRoll/2026"]'); +await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, { + timeout: 10_000, + polling: 50, +}); const gripBox = await (await page.$('[data-key="lib-grip"]')).boundingBox(); await page.mouse.move(gripBox.x + 4, gripBox.y + 120); await page.mouse.down(); @@ -574,7 +612,17 @@ await page.reload(); await page.waitForSelector(thumbs, { timeout: 120_000 }); const reopened = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', (e) => e.dataset.key); +const reopenedRows = await page.$$eval('[data-key^="lib-node-"]', (els) => + els.map((e) => `${e.dataset.key}:${e.getAttribute('aria-expanded') ?? '-'}`) +); step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`); +step( + 'the tree reopens with the rows that were left open', + reopenedRows.length === 4 && + reopenedRows.join(' ') === + 'lib-node-CheckRoll:true lib-node-CheckRoll/2026:true lib-node-CheckRoll/2026/04:- lib-node-CheckRoll/Empty:-', + reopenedRows.join(' ') +); step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where); // The frame that was raised is remembered too: a reader who comes back finds the diff --git a/docker/frontend/scripts/scan-nav-check.mjs b/docker/frontend/scripts/scan-nav-check.mjs index 1ce9c45..a3751d8 100644 --- a/docker/frontend/scripts/scan-nav-check.mjs +++ b/docker/frontend/scripts/scan-nav-check.mjs @@ -247,8 +247,17 @@ await page.waitForFunction( { timeout: 60_000, polling: 200 } ).catch(() => {}); step('every frame ends up as a thumbnail', (await tiles()) === TOTAL, `${await tiles()} tiles`); +// The tree opens folded, so the folder two down is a row only once the rows above +// it are opened: the walk goes down it and comes back, which leaves the screen on +// the roll the steps below read it as. +await page.click('[data-key="lib-node-SlowRoll"]'); +await page.waitForSelector('[data-key="lib-node-SlowRoll/2026"]', { timeout: 15_000 }); +await page.click('[data-key="lib-node-SlowRoll/2026"]'); +await page.waitForSelector('[data-key="lib-node-SlowRoll/2026/04"]', { timeout: 15_000 }); const grown = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key)); step('the tree ends up holding the deepest folder of the roll', grown.includes('lib-node-SlowRoll/2026/04'), JSON.stringify(grown)); +await page.click('[data-key="lib-node-SlowRoll/2026"]'); +await page.click('[data-key="lib-node-SlowRoll"]'); // Nothing was scanning at that last click, so that one did load the page again — // which is the whole point of the guard. Everything below counts from here. seen = await docs(); diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index ac35834..24485e0 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2767,11 +2767,14 @@ export function Workspace() { // row is what is NOT a knob. AUTO, which is an action (see autoTune); // TONE CURVE, which opens the graph on the photo (ToneCurvePanel) rather // than a ruler in the last column, and glows amber once the graph is off - // the diagonal; and the three tools that take the pointer or a strip of - // their own — FIX's brushes, GRADIENT MASK's shapes, MONOCHROME and the - // GRAIN strip. WB and FX are panels of this tab now, so their rows live - // here: a chip that opened the same record the panel already shows would - // be a second way to one knob. + // the diagonal; and the three tools that take the pointer — FIX's + // brushes, GRADIENT MASK's shapes, MONOCHROME. WB and FX are panels of + // this tab now, so their rows live here: a chip that opened the same + // record the panel already shows would be a second way to one knob. + // + // GRAIN has no chip of its own for that reason: its two knobs are the + // MONOCHROME GRAIN and GRAIN SIZE rows of DETAIL & EFFECTS below, and the + // chip that opened a strip of the same two was a second way to them. return [ { key: 'auto', label: 'AUTO', onClick: () => void autoTune() }, { @@ -2809,10 +2812,6 @@ export function Workspace() { }, }, { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, - // GRAIN is a strip of its own — amount, size, and the count they add up - // to — so it is one chip here and its two knobs are rows of DETAIL & - // EFFECTS. - groupChip('grain'), ]; case 'hsl': { // The mixer is PRO: the picker, the eight bands and the three @@ -3046,12 +3045,17 @@ export function Workspace() { // preset recognisable without reading its name. Keyed `wb-temp:` rather than // `temp:`, because the strip's own TEMP row can be open beside it and two // buttons may not answer to one key. + // + // The name is the button's label and the kelvin is its value slot, which the + // grid's own rule stacks: the standard's name on one line, its cast under it + // in the same ink a size down, the pair no wider than the name. wb: ( ({ key: `wb-temp:${p.key}`, - label: `${p.label} · ${p.kelvin}K`, + label: p.label, + value: `${p.kelvin}K`, active: wbValue() === p.key, ...wbSwatch(p.kelvin), onClick: () => { diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 6166bd2..ca8c82f 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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>(); -// 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(() => 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>(() => new Set()); + const [node, setNode] = useState(() => 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>(() => { + 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(() => remembered(FRAME_KEY)); + const [up, setUp] = useState(() => 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() { }} >