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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user