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();
|
||||
|
||||
+14
-10
@@ -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: (
|
||||
<ChipRow
|
||||
grid
|
||||
chips={WB_PRESETS.map((p) => ({
|
||||
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: () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// What a screen remembers between visits. Private mode throws on localStorage
|
||||
// rather than returning null: a screen that cannot remember is fine, a screen
|
||||
// that cannot load is not, so both halves swallow the refusal.
|
||||
|
||||
export function recall(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function keep(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// The choice just does not outlive the visit.
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,16 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.chip-row.grid .chip { justify-content: center; text-align: center; }
|
||||
.chip-row.grid .chip {
|
||||
justify-content: center; text-align: center;
|
||||
/* Two of these share the column's width, so each is its own small card: the
|
||||
standard's name on one line — never wrapped, the names are the point — and
|
||||
the kelvin under it, both a size down from a chip's label. */
|
||||
flex-direction: column; gap: 1px;
|
||||
padding: 4px 6px;
|
||||
font-size: 11px; line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* UNDO/REDO are one glyph each, so the pair reads as a single control. */
|
||||
.header .btn.icon { padding: 4px 9px; font-size: 15px; line-height: 1.2; }
|
||||
/* WATERMARK's own column: its chips carry the longest labels in the app
|
||||
@@ -177,9 +186,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip.tinted.on { border-color: var(--accent); }
|
||||
/* The kelvin rides on a swatch, whose ink was picked for that fill to clear
|
||||
4.5:1 — so a value slot there wears that ink and not the panel's dim grey,
|
||||
which is what a mid grey button cannot carry. It is set a size down from the
|
||||
name instead, which is separation that costs no contrast. */
|
||||
.chip.tinted .val { color: inherit; }
|
||||
/* Edited value, strip closed: the text glows amber, the border does not. */
|
||||
.chip.amber, .chip.amber .val { color: var(--accent); }
|
||||
.chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; }
|
||||
.chip-row.grid .chip .val { font-size: 10px; }
|
||||
.chip .pro, .btn .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
|
||||
/* A button is not a flex row the way a chip is, so the marker carries its own
|
||||
gap off the label it qualifies. */
|
||||
@@ -1318,12 +1333,17 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.dev-panels { display: flex; flex-direction: column; gap: 10px; }
|
||||
.dev-panel { border-top: 1px solid var(--border-soft); padding-top: 8px; }
|
||||
.dev-panel-head {
|
||||
display: flex; justify-content: space-between;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
width: 100%; padding: 0; border: 0; background: transparent;
|
||||
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text); cursor: pointer;
|
||||
}
|
||||
.dev-panel-head:hover { color: var(--accent); }
|
||||
/* The caret is the panel's whole affordance — the head is a line of uppercase
|
||||
and says nothing about being a control — so it is drawn larger than the label
|
||||
it sits beside, and takes the accent while the pointer is on the head. */
|
||||
.dev-caret { font-size: 17px; line-height: 1; opacity: 0.75; }
|
||||
.dev-panel-head:hover .dev-caret { opacity: 1; }
|
||||
.dev-panel-body { display: flex; flex-direction: column; gap: 10px; padding-top: 8px; }
|
||||
/* A row that cannot be edited at all: a PRO knob on the LITE build. It reads as
|
||||
a row, not as a button — the tag is what says the row is not a slider. */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColorAdjustments } from '../../shared/types';
|
||||
import { PARAM_DEFS } from '../../shared/utils/paramDefs';
|
||||
import { keep, recall } from '../remember';
|
||||
import { MiniSlider } from './ChipColumn';
|
||||
|
||||
// One lookup for every row. The defs are split over the three tabs' tables and
|
||||
@@ -82,9 +83,16 @@ function DevRow({
|
||||
);
|
||||
}
|
||||
|
||||
// The studio's right column: every panel's knobs on screen at once, each panel a
|
||||
// disclosure that folds its own rows away. One undo step is the parent's `set`,
|
||||
// so nothing here holds state but which panels are open.
|
||||
// Which panels the studio draws open, under this key. The list is the panels'
|
||||
// slugs, comma-joined; a slug no panel answers to is dropped on the way in, so a
|
||||
// panel that is renamed or taken out does not leave a hole in the column.
|
||||
const OPEN_KEY = 'recipescam.develop.open';
|
||||
|
||||
// The studio's right column: every panel's knobs behind a disclosure that folds
|
||||
// its own rows away, and the column opens with all of them folded — a dozen rows
|
||||
// of sliders is a wall to scroll past, and the knob being looked for is named by
|
||||
// its panel's head. What the reader opens is remembered, so the second visit
|
||||
// opens the way the first was left. One undo step is the parent's `set`.
|
||||
export function DevelopPanels({
|
||||
adj,
|
||||
set,
|
||||
@@ -98,9 +106,15 @@ export function DevelopPanels({
|
||||
onPro?: () => void;
|
||||
slots?: { profile?: ReactNode; wb?: ReactNode; effects?: ReactNode };
|
||||
}) {
|
||||
const [open, setOpen] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PANELS.map((panel) => [panel.slug, true]))
|
||||
);
|
||||
const [open, setOpen] = useState<Record<string, boolean>>(() => {
|
||||
const saved = new Set((recall(OPEN_KEY) ?? '').split(','));
|
||||
return Object.fromEntries(PANELS.filter((panel) => saved.has(panel.slug)).map((panel) => [panel.slug, true]));
|
||||
});
|
||||
const fold = (slug: string, isOpen: boolean) => {
|
||||
const next = { ...open, [slug]: !isOpen };
|
||||
setOpen(next);
|
||||
keep(OPEN_KEY, PANELS.filter((panel) => next[panel.slug]).map((panel) => panel.slug).join(','));
|
||||
};
|
||||
return (
|
||||
<div className="dev-panels">
|
||||
{PANELS.map((panel) => {
|
||||
@@ -112,10 +126,10 @@ export function DevelopPanels({
|
||||
type="button"
|
||||
className="dev-panel-head"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setOpen((o) => ({ ...o, [panel.slug]: !isOpen }))}
|
||||
onClick={() => fold(panel.slug, isOpen)}
|
||||
>
|
||||
{panel.title}
|
||||
<span aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
|
||||
<span className="dev-caret" aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="dev-panel-body">
|
||||
|
||||
Reference in New Issue
Block a user