2f34c36131
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>
303 lines
14 KiB
JavaScript
303 lines
14 KiB
JavaScript
// TEMPORARY — the bug this proves: reading a roll used to die the moment the
|
|
// catalogue handed the visitor to the studio, because every internal link was a
|
|
// full page load and the scan lived in that page's memory. Run it, then delete
|
|
// it.
|
|
//
|
|
// node scripts/scan-nav-check.mjs
|
|
import { spawn } from 'node:child_process';
|
|
import { createServer } from 'node:http';
|
|
import { readFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
|
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
|
|
const APP_PORT = Number(process.env.APP_PORT ?? 4186);
|
|
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4320);
|
|
const DELAY = Number(process.env.SCAN_DELAY ?? 900);
|
|
const JPG = 'P1010256.JPG';
|
|
const RAW = 'P1010256.RW2';
|
|
|
|
// Twelve frames: four at the top, four a folder down, four two folders down.
|
|
const names = (prefix) => Array.from({ length: 4 }, (_, i) => `${prefix}${i + 1}.JPG`);
|
|
const ROLL = {
|
|
files: names('a'),
|
|
dirs: { '2026': { files: names('b'), dirs: { '04': { files: names('c'), dirs: {} } } } },
|
|
};
|
|
const TOTAL = 12;
|
|
|
|
let failures = 0;
|
|
function step(name, ok, detail = '') {
|
|
if (!ok) failures++;
|
|
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
|
return ok;
|
|
}
|
|
|
|
const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
|
|
if (!playwright) {
|
|
console.log('SKIP playwright-core is not installed');
|
|
process.exit(0);
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// One frame at a time, however many the page asks for at once: a roll that reads
|
|
// itself in parallel is over before a handover can be timed against it.
|
|
let turn = Promise.resolve();
|
|
const waitTurn = () => {
|
|
const mine = turn.then(() => sleep(DELAY));
|
|
turn = mine;
|
|
return mine;
|
|
};
|
|
|
|
const sampleServer = createServer(async (req, res) => {
|
|
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
|
|
if (!/\.(jpg|rw2)$/i.test(name)) {
|
|
res.writeHead(404).end();
|
|
return;
|
|
}
|
|
const bytes = await readFile(path.join(SAMPLES, /\.rw2$/i.test(name) ? RAW : JPG));
|
|
await waitTurn();
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Length': bytes.length,
|
|
'Access-Control-Allow-Origin': '*',
|
|
});
|
|
res.end(bytes);
|
|
});
|
|
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
|
|
|
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
|
cwd: FRONTEND,
|
|
stdio: 'ignore',
|
|
});
|
|
const base = `http://127.0.0.1:${APP_PORT}`;
|
|
let up = false;
|
|
for (let i = 0; i < 60 && !up; i++) {
|
|
up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false);
|
|
if (!up) await sleep(500);
|
|
}
|
|
if (!up) {
|
|
app.kill('SIGTERM');
|
|
sampleServer.close();
|
|
console.log(`FAIL no preview on ${base}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const browser = await playwright.chromium.launch({
|
|
executablePath: process.env.CHROME || undefined,
|
|
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
|
});
|
|
const context = await browser.newContext();
|
|
|
|
// Documents this tab has loaded, across navigations: the count only moves when a
|
|
// click threw the page away and started a new one.
|
|
await context.addInitScript(() => {
|
|
const n = Number(sessionStorage.getItem('docLoads') ?? '0') + 1;
|
|
sessionStorage.setItem('docLoads', String(n));
|
|
});
|
|
|
|
await context.addInitScript(
|
|
({ origin, roll, folder }) => {
|
|
const bytes = new Map();
|
|
const load = async (name) => {
|
|
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
|
|
return bytes.get(name);
|
|
};
|
|
// A second scan of the same roll reads the same names again; dropping what the
|
|
// stand-in already holds makes it go back to the server for each of them, so
|
|
// a re-reading roll has the same minutes in it a first one does.
|
|
window.__forget = () => bytes.clear();
|
|
const fileHandle = (name) => {
|
|
const handle = { kind: 'file', name, __fake: true };
|
|
Object.defineProperty(handle, 'getFile', {
|
|
value: async () => new File([await load(name)], name, { type: 'image/jpeg' }),
|
|
});
|
|
return handle;
|
|
};
|
|
const dirHandle = (label, node) => {
|
|
// `__fake` on a folder too: the catalogue reads its folders back out of the
|
|
// store after every remount, and a handle that does not say what it is comes
|
|
// back as a plain object with no `queryPermission` — a folder it cannot ask
|
|
// about is a folder it offers to reconnect.
|
|
const handle = { kind: 'directory', name: label, __fake: true };
|
|
Object.defineProperties(handle, {
|
|
values: {
|
|
value: () =>
|
|
[
|
|
...(node.files ?? []).map(fileHandle),
|
|
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
|
][Symbol.iterator](),
|
|
},
|
|
queryPermission: { value: async () => 'granted' },
|
|
requestPermission: { value: async () => 'granted' },
|
|
});
|
|
return handle;
|
|
};
|
|
const root = () => dirHandle(folder, roll);
|
|
window.showDirectoryPicker = async () => root();
|
|
const revive = (row) => {
|
|
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
|
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
|
|
}
|
|
return row;
|
|
};
|
|
for (const method of ['get', 'getAll']) {
|
|
const original = IDBObjectStore.prototype[method];
|
|
IDBObjectStore.prototype[method] = function (...args) {
|
|
const request = original.apply(this, args);
|
|
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
|
|
Object.defineProperty(request, 'result', {
|
|
configurable: true,
|
|
get() {
|
|
const value = descriptor.get.call(this);
|
|
return Array.isArray(value) ? value.map(revive) : revive(value);
|
|
},
|
|
});
|
|
return request;
|
|
};
|
|
}
|
|
},
|
|
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, roll: ROLL, folder: 'SlowRoll' }
|
|
);
|
|
|
|
const page = await context.newPage();
|
|
const indexed = () =>
|
|
page.evaluate(async () => {
|
|
const db = await new Promise((resolve, reject) => {
|
|
const req = indexedDB.open('recipescam-library');
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
return new Promise((resolve, reject) => {
|
|
const req = db.transaction('photos').objectStore('photos').count();
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
});
|
|
|
|
await page.goto(`${base}/library`);
|
|
await page.click('[data-key="lib-add"]');
|
|
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
|
|
step('the scan is running', true);
|
|
|
|
// The rows only reach IndexedDB in one batch at the end of the scan, so the
|
|
// toolbar's own line is what says how far the reading is.
|
|
const progressText = (timeout) =>
|
|
page
|
|
.waitForFunction(() => document.querySelector('[data-key="lib-progress"]')?.textContent?.trim() || null, null, {
|
|
timeout,
|
|
polling: 100,
|
|
})
|
|
.then((h) => h.jsonValue())
|
|
.catch(() => null);
|
|
|
|
const docs = () => page.evaluate(() => Number(sessionStorage.getItem('docLoads') ?? '0'));
|
|
const tiles = () => page.$$eval('[data-key^="lib-thumb-"]', (els) => els.length);
|
|
// The count of documents standing when the page was last settled: every step that
|
|
// follows a link or a frame asserts against this, because a link followed with no
|
|
// scan in flight is *meant* to load the page again.
|
|
let seen = await docs();
|
|
const noReload = async () => (await docs()) === seen;
|
|
|
|
// Hand over to the studio while the roll is still being read, which is the whole
|
|
// point: part of it in, the rest still to come.
|
|
const midway = await progressText(15_000);
|
|
step('the roll is half read when the studio is opened', !!midway, String(midway));
|
|
|
|
await page.click('[data-key="lib-studio"]');
|
|
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
|
|
step('the handover stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
|
|
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
|
|
// The reading belongs to the tab, so the header of the screen that did not start
|
|
// it says it is still going, beside the way back into the catalogue.
|
|
const badge = await page
|
|
.waitForSelector('[data-key="studio-scan-count"]', { timeout: 15_000 })
|
|
.then((el) => el.textContent())
|
|
.catch(() => null);
|
|
step('the studio header shows the reading the tab is doing', /^\d+\/\d+$/.test(badge ?? ''), String(badge));
|
|
|
|
// Back to the catalogue mid-scan: the screen that started the scan is gone and
|
|
// back, and the roll is still being read — the reading belongs to the tab.
|
|
await page.click('[data-key="nav-library"]');
|
|
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
|
|
step('coming back stays in the page too', await noReload(), `${await docs()} document load(s)`);
|
|
const progress = await progressText(15_000);
|
|
step('the catalogue comes back showing the scan it missed', !!progress, String(progress));
|
|
await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 15_000 });
|
|
const tree = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
|
|
step('the tree is there, from the scan that outlived its screen', tree.includes('lib-node-SlowRoll'), JSON.stringify(tree));
|
|
|
|
// Now leave it alone in the studio and let it finish: nothing up there reads the
|
|
// roll, so every frame that lands from here on landed for a screen that is gone.
|
|
await page.click('[data-key="lib-studio"]');
|
|
await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 });
|
|
let indexed_to = 0;
|
|
for (let i = 0; i < 300 && indexed_to < TOTAL; i++) {
|
|
indexed_to = await indexed().catch(() => indexed_to);
|
|
if (indexed_to < TOTAL) await sleep(200);
|
|
}
|
|
step('the whole roll is read with the studio up', indexed_to === TOTAL, `${indexed_to}/${TOTAL} indexed`);
|
|
step('and the studio is still the page it was', await noReload(), `${await docs()} document load(s)`);
|
|
|
|
await page.click('[data-key="nav-library"]');
|
|
await page.waitForFunction(
|
|
(n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n,
|
|
TOTAL,
|
|
{ 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();
|
|
|
|
// Now the reader's own way into the studio, and the noisiest one for this page: a
|
|
// frame opened from a roll that is being read again. The rows are drawn from the
|
|
// catalogue it already has, so this is the one route out of here that the browser
|
|
// could take with no link anywhere in sight.
|
|
const rows = () => page.$eval('[data-key="lib-node-SlowRoll"] .adm-album-count', (el) => Number(el.textContent));
|
|
await page.evaluate(() => window.__forget());
|
|
await page.click('[data-key="lib-node-SlowRoll"]', { button: 'right' });
|
|
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
|
|
const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
|
const rescan = menu.find((k) => /^lib-(rescan|reconnect)-/.test(k));
|
|
step('the roll can be read again from its own menu', !!rescan, JSON.stringify(menu));
|
|
await page.click(`[data-key="${rescan}"]`);
|
|
await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 });
|
|
await sleep(DELAY);
|
|
const early = await rows();
|
|
await sleep(DELAY * 2);
|
|
const later = await rows();
|
|
step('the row counts the reading as it comes, not the catalogue standing still', later !== early, `${early} → ${later} of ${TOTAL}`);
|
|
step('the strip is still the catalogue the roll left behind', (await tiles()) === TOTAL, `${await tiles()} tiles`);
|
|
|
|
await page.click('[data-key="lib-open"]');
|
|
await page.waitForSelector('[data-key="nav-library"]', { timeout: 30_000 });
|
|
step('opening a frame stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`);
|
|
// The studio takes the frame out of the address as soon as it has it, so the
|
|
// route is what is left to say the handover arrived.
|
|
step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url());
|
|
|
|
await page.click('[data-key="nav-library"]');
|
|
await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 });
|
|
step('and the scan it interrupted is still the scan in hand', await noReload(), `${await docs()} document load(s)`);
|
|
const steady = await progressText(15_000);
|
|
step('the catalogue is still reading the roll it was reading', !!steady, String(steady));
|
|
|
|
await browser.close();
|
|
app.kill('SIGTERM');
|
|
sampleServer.close();
|
|
console.log(failures ? `\n${failures} step(s) failed` : '\nall steps passed');
|
|
process.exit(failures ? 1 : 0);
|