diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-16 09:11:24 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-16 09:11:24 -0500 |
| commit | 910993b25ba1736fc7f5958c53a7cdc523f98cf2 (patch) | |
| tree | 000b49d3a0c62f3bc82fd554c76e133a6eefbf38 | |
| parent | 59dd61b446319696099619709ca3bcd7902a7f08 (diff) | |
| download | archsetup-910993b25ba1736fc7f5958c53a7cdc523f98cf2.tar.gz archsetup-910993b25ba1736fc7f5958c53a7cdc523f98cf2.zip | |
feat(gallery): bake widget validation progress into the source
The validation walk's per-card state lived only in browser localStorage, so a cache clear would lose it and the other machine never saw it. VSTATUS now carries the record in source, read as a fallback behind localStorage. The live walk wins on the machine doing it, and a deliberate un-check beats a baked green, because 'off' is a stored value like any other.
I build the export's text by hand rather than through JSON.stringify, which hoists canonical integer keys ('12' ahead of '01') and would reshuffle the whole block on every bake.
Copying it out never touches the clipboard. navigator.clipboard is denied outright on file://, and execCommand returns true while writing nothing. So the export lands in a selected textarea instead: a selection is readable as the PRIMARY selection, and that's the one path that works here.
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 76 | ||||
| -rw-r--r-- | tests/gallery-probes/probe-vstatus.mjs | 209 |
2 files changed, 283 insertions, 2 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 9740c46..2a2910b 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -126,6 +126,10 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} background:#221f1b;border:1px solid #33302b} .vtally .vn{margin-left:auto;color:var(--cream);font-variant-numeric:tabular-nums} .vtally .vtot{border-top:1px solid var(--wash);margin-top:3px;padding-top:4.5px;color:var(--steel)} +.vtally .vcopy{margin-top:5px;width:100%;font:inherit;font-size:.7rem;letter-spacing:.04em; + background:transparent;color:var(--steel);border:1px solid var(--wash);border-radius:3px; + padding:2.5px 0;cursor:pointer} +.vtally .vcopy:hover{color:var(--gold-hi);border-color:var(--gold)} .stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px; min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap} .wrd{color:var(--gold-hi);font-size:.8rem;letter-spacing:.06em;font-variant-numeric:tabular-nums; @@ -1039,6 +1043,72 @@ const INFO={ const INFO_FIELDS=[['input','input'],['solves','solves'],['use','use'],['limits','limits'], ['origin','origin'],['difficulty','difficulty'],['prefer','prefer when'],['period','period']]; const VLAMP_TITLES={off:'validation: not done',amber:'validation: in progress',green:'validation: done'}; +/* VSTATUS — the baked validation record: the durable half of the walk. + Lamp clicks write localStorage, which is per-browser-profile and dies with a + cache clear, so the walk's progress is baked back into source periodically + (the "copy for source" control under the tally emits this block; paste it + here). Only amber/green are listed — off is the default and stays implicit. + Precedence is localStorage first, VSTATUS second: the live walk wins on the + machine doing it, and the baked record fills in on a fresh profile, after a + clear, or on the other daily driver. 'off' is a stored value like any other, + so deliberately un-checking a card beats a baked green rather than reverting + to it on reload. */ +const VSTATUS={ + "01": "green", + "06": "green", + "25": "green", + "R05": "green", + "12": "green", + "R07": "amber", + "18": "green", + "R10": "green", + "R11": "green", + "R52": "amber" +}; +/* GVexport — the live lamp state in VSTATUS shape, ready to paste back above. + Reads the lamps rather than localStorage so what you copy is what you see. + Builds the text by hand rather than via JSON.stringify: stringify orders + canonical integer keys ('12') numerically ahead of everything else ('01' has + a leading zero, 'R05' isn't numeric), which would reshuffle the baked block + on every bake and bury each walk's real change in diff noise. Card order is + DOM order, so the block grows in place. */ +function GVexport(){ + const rows=[]; + document.querySelectorAll('.vlamp').forEach(l=>{ + const v=l.dataset.v; + if(v!=='off') rows.push(' "'+l.closest('.card').id.slice(5)+'": "'+v+'"');}); + return '{\n'+rows.join(',\n')+'\n}'; +} +/* Surface the export as a selected textarea. This deliberately does NOT try to + reach the clipboard, because on this setup (Chrome 150, native Wayland, no + clipboard manager) neither clipboard path works from a file:// page: + - navigator.clipboard.writeText rejects NotAllowedError, "Write permission + denied" — a file origin can't hold the clipboard-write permission. + - document.execCommand('copy') returns TRUE and writes an empty string. The + clipboard ends up owned, advertising text/plain, serving nothing. Chrome + sets the PRIMARY selection but not CLIPBOARD; a hand-typed Ctrl+C in the + page behaves the same way, so it is not specific to execCommand. + JS can't detect any of this — readText is denied too — so claiming "copied" + would be a lie the code cannot check. Selecting the text is the one thing + that reliably works: a selection IS the X/Wayland PRIMARY selection, readable + with `wl-paste --primary`. Verified twice against the live browser. + The blur teardown is safe: Chrome keeps serving PRIMARY after the textarea is + gone (observed — the export read back fine both times, each after switching + away from the browser, which is what fires the blur). So the selection does + NOT have to stay on screen to survive; the handler only avoids leaving a + textarea sitting on the page. What does clobber PRIMARY is selecting text + anywhere else, which no handler here can prevent. */ +function gvCopy(btn){ + const flash=m=>{btn.textContent=m;setTimeout(()=>btn.textContent='copy for source',2200);}; + const ta=document.createElement('textarea'); + ta.value=GVexport(); + ta.style.cssText='position:fixed;left:12px;bottom:12px;z-index:70;width:320px;height:170px;'+ + 'font:12px ui-monospace,monospace;background:#14120f;color:#e8dcc0;border:1px solid #7d5c16;'+ + 'border-radius:4px;padding:6px'; + document.body.appendChild(ta); ta.select(); + flash('selected ▸'); + ta.addEventListener('blur',()=>ta.remove()); +} /* index tally: recounts every card lamp; setV calls it on every state change. Clicking a row jumps to the next card in that state (cycles), so a count that disagrees with a visual scan can be audited card by card. */ @@ -1052,10 +1122,12 @@ function updateVTally(){ `<div class="vrow" data-v="green"><span class="vdot" data-v="green"></span>done<span class="vn">${n.green}</span></div>`+ `<div class="vrow" data-v="amber"><span class="vdot" data-v="amber"></span>in progress<span class="vn">${n.amber}</span></div>`+ `<div class="vrow" data-v="off"><span class="vdot"></span>not done<span class="vn">${n.off}</span></div>`+ - `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`; + `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`+ + `<button class="vcopy" type="button">copy for source</button>`; el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{ VJUMP[row.dataset.v]=0; auditNext(row.dataset.v); })); + el.querySelector('.vcopy').addEventListener('click',e=>gvCopy(e.target)); } /* audit stepper: a row click jumps to the first card in that state and docks a floating pill; clicking the pill advances through the rest without scrolling @@ -1090,7 +1162,7 @@ function card(host, no, name, html, note){ `<div class="opts"></div><div class="wnote">${note}</div>`; const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green']; const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];updateVTally();}; - setV(localStorage.getItem(VKEY)||'off'); + setV(localStorage.getItem(VKEY)||VSTATUS[no]||'off'); lamp.addEventListener('click',e=>{e.stopPropagation(); const v=VSTATES[(VSTATES.indexOf(lamp.dataset.v)+1)%3]; setV(v);localStorage.setItem(VKEY,v);}); diff --git a/tests/gallery-probes/probe-vstatus.mjs b/tests/gallery-probes/probe-vstatus.mjs new file mode 100644 index 0000000..aefe43b --- /dev/null +++ b/tests/gallery-probes/probe-vstatus.mjs @@ -0,0 +1,209 @@ +// CDP probe for the gallery's baked validation statuses (VSTATUS) — no playwright. +// Usage: node probe-vstatus.mjs +// +// Runs against a throwaway --user-data-dir so localStorage starts empty every run: +// the whole point of VSTATUS is what renders when there is no local state, so a +// probe that inherited a profile's leftover gv-* keys would test nothing. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const PORT = 9334; +const URL_FILE = '/home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; +const URL = 'file://' + URL_FILE; +const PROFILE = mkdtempSync(join(tmpdir(), 'gallery-vstatus-')); + +const chrome = spawn('google-chrome-stable', [ + '--headless=new', `--remote-debugging-port=${PORT}`, + `--user-data-dir=${PROFILE}`, + '--no-first-run', '--no-default-browser-check', + '--window-size=1600,1200', URL, +], { stdio: 'ignore' }); + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +let ws, id = 0; +const pending = new Map(); +const events = []; + +async function connect() { + for (let i = 0; i < 40; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); + const page = list.find(t => t.type === 'page' && t.url.startsWith('file')); + if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; } + } catch { /* retry */ } + await sleep(250); + } + if (!ws) throw new Error('no page target'); + await new Promise(r => ws.onopen = r); + ws.onmessage = m => { + const d = JSON.parse(m.data); + if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } + else if (d.method) events.push(d); + }; +} +function send(method, params = {}) { + return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); }); +} +async function evl(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }); + if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text)); + return r.result.result.value; +} +async function reload() { + await send('Page.reload'); + await sleep(1200); +} +const lampOf = no => `document.querySelector('#card-${no} .vlamp').dataset.v`; + +const fails = []; +const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); }; + +try { + await connect(); + await send('Runtime.enable'); + await send('Page.enable'); + await sleep(1500); + + const errs = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200)); + + // 1. VSTATUS exists and is a plain record of no -> state + const shape = await evl(`(()=>{ + if (typeof VSTATUS === 'undefined') return 'undefined'; + if (typeof VSTATUS !== 'object' || VSTATUS === null || Array.isArray(VSTATUS)) return 'not-an-object'; + const bad = Object.entries(VSTATUS).filter(([,v]) => !['amber','green'].includes(v)); + return bad.length ? 'bad-values:' + JSON.stringify(bad.slice(0,3)) : 'ok'; + })()`); + ok('VSTATUS is a no->state record of amber/green only', shape === 'ok', shape); + + // 2. every baked entry names a real card + const orphans = await evl(`Object.keys(VSTATUS).filter(no => !document.getElementById('card-'+no))`); + ok('no baked entry names a missing card', orphans.length === 0, orphans.join(', ')); + + // 2b. the baked block's SOURCE TEXT is in DOM order — it is hand-pasted from + // GVexport, and a block written in some other order reshuffles wholesale on + // the next bake. This reads the file rather than the live object on purpose: + // JS hoists canonical integer keys ('12' before '01') on any object, so + // Object.keys can never report DOM order and asserting it would be + // unsatisfiable. Only the text on disk carries the property that matters. + const src = readFileSync(URL_FILE, 'utf8'); + const block = src.match(/const VSTATUS=\{([\s\S]*?)\n\};/); + if (!block) { + ok('VSTATUS source block is findable', false, 'regex did not match'); + } else { + const textKeys = [...block[1].matchAll(/"([^"]+)"\s*:/g)].map(m => m[1]); + const domOrder = await evl(`[...document.querySelectorAll('.vlamp')].map(l => l.closest('.card').id.slice(5))`); + const want = domOrder.filter(no => textKeys.includes(no)); + ok('VSTATUS source text is in DOM order', + JSON.stringify(textKeys) === JSON.stringify(want), + `baked=${JSON.stringify(textKeys)} want=${JSON.stringify(want)}`); + } + + // 3. with empty localStorage, baked entries render as their baked state + const mismatched = await evl(`Object.entries(VSTATUS) + .filter(([no,v]) => document.querySelector('#card-'+no+' .vlamp').dataset.v !== v) + .map(([no,v]) => no+':want='+v+',got='+document.querySelector('#card-'+no+' .vlamp').dataset.v)`); + ok('baked entries render on a fresh profile', mismatched.length === 0, mismatched.slice(0, 5).join(' ')); + + // 4. a card with no baked entry renders off + const unbaked = await evl(`(()=>{ + const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); + return l ? l.closest('.card').id.slice(5) + '=' + l.dataset.v : 'none'; + })()`); + ok('an unbaked card defaults to off', unbaked === 'none' || unbaked.endsWith('=off'), unbaked); + + // 5. the tally's done count equals the green lamps on screen + const tally = await evl(`(()=>{ + const green = document.querySelectorAll('.vlamp[data-v="green"]').length; + const row = document.querySelector('.vtally .vrow[data-v="green"] .vn'); + return [green, row ? +row.textContent : -1]; + })()`); + ok('tally done count matches green lamps', tally[0] === tally[1], `lamps=${tally[0]} tally=${tally[1]}`); + + // 6. localStorage wins over the default. Holds with an empty VSTATUS, so this + // check is live from the first commit rather than waiting on a baked block. + const any = await evl(`(()=>{ + const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); + return l ? l.closest('.card').id.slice(5) : null; + })()`); + if (any) { + await evl(`localStorage.setItem('gv-${any}','green'); ''`); + await reload(); + const set = await evl(lampOf(any)); + ok('localStorage sets an unbaked lamp', set === 'green', `card ${any} -> ${set}`); + await evl(`localStorage.removeItem('gv-${any}'); ''`); + await reload(); + const cleared = await evl(lampOf(any)); + ok('clearing an unbaked lamp returns it to off', cleared === 'off', `card ${any} -> ${cleared}`); + } + + // 6b. localStorage beats baked, both ways — a deliberate un-check must survive a + // baked green, and clearing must fall back rather than stick at off. + const target = await evl(`Object.keys(VSTATUS)[0] || null`); + if (target) { + await evl(`localStorage.setItem('gv-${target}','off'); ''`); + await reload(); + const got = await evl(lampOf(target)); + ok('localStorage off overrides a baked entry', got === 'off', `card ${target} -> ${got}`); + await evl(`localStorage.removeItem('gv-${target}'); ''`); + await reload(); + const back = await evl(lampOf(target)); + const want = await evl(`VSTATUS['${target}']`); + ok('clearing localStorage falls back to baked', back === want, `card ${target} -> ${back}, want ${want}`); + } else { + console.log('SKIP baked-override checks — VSTATUS is empty (nothing baked yet)'); + } + + // 7. the export affordance reproduces what is on screen, in VSTATUS shape + const round = await evl(`(()=>{ + if (typeof GVexport !== 'function') return 'no-GVexport'; + let parsed; + try { parsed = JSON.parse(GVexport()); } catch (e) { return 'not-json: ' + e.message; } + const live = {}; + document.querySelectorAll('.vlamp').forEach(l => { + const v = l.dataset.v; if (v !== 'off') live[l.closest('.card').id.slice(5)] = v; + }); + return JSON.stringify(parsed) === JSON.stringify(live) ? 'ok' + : 'export=' + JSON.stringify(parsed).slice(0,120) + ' live=' + JSON.stringify(live).slice(0,120); + })()`); + ok('GVexport reproduces the live lamp state', round === 'ok', round); + + // 7b. exported keys follow card order. JSON.stringify would hoist canonical + // integer keys ('12') above the rest ('01', 'R05'), reshuffling the baked + // block on every bake; the export builds its text by hand to avoid it. + const order = await evl(`(()=>{ + const keys = [...GVexport().matchAll(/"([^"]+)":/g)].map(m => m[1]); + const dom = [...document.querySelectorAll('.vlamp')] + .filter(l => l.dataset.v !== 'off').map(l => l.closest('.card').id.slice(5)); + return JSON.stringify(keys) === JSON.stringify(dom) ? 'ok' + : 'export=' + JSON.stringify(keys) + ' dom=' + JSON.stringify(dom); + })()`); + ok('exported keys follow card order', order === 'ok', order); + + // 8. export tracks a click, so a mid-walk copy is current + const clicked = await evl(`(()=>{ + const l = document.querySelector('.vlamp'); + const no = l.closest('.card').id.slice(5); + l.click(); + const v = l.dataset.v; + const parsed = JSON.parse(GVexport()); + return (v === 'off' ? !(no in parsed) : parsed[no] === v) ? 'ok' : 'stale: lamp=' + v + ' export=' + JSON.stringify(parsed[no]); + })()`); + ok('GVexport tracks a lamp click', clicked === 'ok', clicked); + + const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); + ok('no exceptions after interaction', errs2.length === 0); +} catch (e) { + console.error('PROBE ERROR: ' + e.message); + fails.push('probe-error'); +} finally { + // Wait for Chrome to actually exit before removing its profile — kill() only + // signals, and a browser still flushing its profile dir makes rmSync throw + // ENOTEMPTY, which would fail an otherwise-green run. + chrome.kill(); + await Promise.race([new Promise(r => chrome.once('exit', r)), sleep(3000)]); + rmSync(PROFILE, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} +process.exit(fails.length ? 1 : 0); |
