From 2f34c3613184220abb67b5a5b03e6d3d5cea68fa Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 29 Sep 2026 21:25:18 +0700 Subject: [PATCH] The room opens folded: the studio's panels and the catalogue's tree both start shut and come back the way they were left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/frontend/scripts/library-check.mjs | 100 +++++++++++++++------ docker/frontend/scripts/scan-nav-check.mjs | 9 ++ docker/frontend/src/App.tsx | 24 ++--- docker/frontend/src/Library.tsx | 83 +++++++++-------- docker/frontend/src/remember.ts | 19 ++++ docker/frontend/src/styles/app.css | 24 ++++- docker/frontend/src/ui/DevelopPanels.tsx | 30 +++++-- 7 files changed, 201 insertions(+), 88 deletions(-) create mode 100644 docker/frontend/src/remember.ts 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() { }} >