diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-17 18:34:43 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-17 18:34:43 -0500 |
| commit | 6d8f4d8bfae27e8fc6caf1cbc65cf0fdf7a85db8 (patch) | |
| tree | 007fd7c97871d0d23ae8db3afcf9951c1d48dcbe /tests/gallery-probes/probe.mjs | |
| parent | 6bcd0da82c505a2ec22268adfced0004401b74a9 (diff) | |
| download | archsetup-6d8f4d8bfae27e8fc6caf1cbc65cf0fdf7a85db8.tar.gz archsetup-6d8f4d8bfae27e8fc6caf1cbc65cf0fdf7a85db8.zip | |
feat(gallery): colour-policy system, chip-toggle accents, widgets-per-row
Every widget's colour is either the consumer's to pick or locked for a reason, and I kept classifying cards wrong from memory during the colour pass. So the policy is a declared, checked property now. Six kinds. Accent and screen are free and take colour chips. Coded, emissive, relational and material are locked and must not. Each classified builder carries a POLICY record naming the kind, why this widget is bound that way, and what it may change and stay authentic (the range that actually existed, like the CRT phosphors that shipped). Eight are classified. The rest are the worklist.
The page shows all of it from that one source. A card recovers its builder from the arrow it was handed and reads GW.<builder>.POLICY, so the badge, the spec-sheet catalog and the index tally can't drift from the code. A probe holds it both ways: the recolour mechanism and the accent kind imply each other, so a locked card can't grow chips and an accent card can't forget to declare.
The chip toggle is the first consumer of a shared accent family (red, amber, green, white, vfd) from GW.accentStyles, so the next card that wants those five gets them from one definition rather than a copy.
Two smaller changes ride along because they touch the same header. The size toggle becomes WIDGETS/ROW, an explicit 1-4 column count instead of S/M/L zoom, and the policy panel sits level-right of the index.
Diffstat (limited to 'tests/gallery-probes/probe.mjs')
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 176 |
1 files changed, 158 insertions, 18 deletions
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index 5e95096..905d758 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -65,21 +65,136 @@ try { 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. defaults: size=2 (M), card count - ok('default size 2', await evl(`document.body.dataset.size`) === '2'); + // 1. card count (the widgets/row default is checked in 1h) const cards = await evl(`document.querySelectorAll('.card').length`); ok('111 cards', cards === 111, `got ${cards}`); - // 2. zoom actually scales: card visual width at 3x vs 1x - await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`); - const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`); - await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`); - const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`); - ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`); - ok('size chip flips state', await evl(`document.body.dataset.size`) === '1'); - - // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout - await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`); + // 1b. Every card carries a spec sheet. R58 shipped without one and nothing + // noticed — the sheet is the card's actual specification, so a card without + // it is a drawing with no contract. Cheap to forget, cheap to check. + const sheets = await evl(`(()=>{ + const missing = [...document.querySelectorAll('.card')] + .filter(c => !c.querySelector('.winfo')) + .map(c => c.id.slice(5)); + return missing.length ? 'no spec sheet: ' + missing.join(' ') : 'ok'; + })()`); + ok('every card has a spec sheet', sheets === 'ok', sheets); + + // 1c. Card 07's lit colour comes from the shared accent family, not a hardcode. + // The chip said exactly one thing (gold) when "on" is good in one panel, a + // warning in the next and a fault in the one after. Checks the family is + // shared rather than copied: a second consumer must get the same five. + const accents = await evl(`(()=>{ + const want = ['amber','green','red','white','vfd']; + const chips = [...document.querySelectorAll('#card-07 .fc')].map(c => c.title); + const missing = want.filter(w => !chips.includes(w)); + if (missing.length) return 'card 07 missing accents: ' + missing.join(' '); + const built = Object.keys(GW.accentStyles('--x')); + return JSON.stringify(built) === JSON.stringify(want) ? 'ok' : 'family is ' + JSON.stringify(built); + })()`); + ok('card 07 offers the shared accent family', accents === 'ok', accents); + + // 1d. The chip actually recolours, and its default is untouched until asked — + // the same fallback discipline the screen families use. + const chipInk = await evl(`(()=>{ + const chip = document.querySelector('#card-07 .chip'); + const lit = () => getComputedStyle(chip).color; + const before = lit(); + document.querySelector('#card-07 .fc[title="vfd"]').click(); + const after = lit(); + return before === 'rgb(226, 160, 56)' && after === 'rgb(99, 230, 200)' + ? 'ok' : before + ' -> ' + after; + })()`); + ok('card 07 defaults to gold and recolours on click', chipInk === 'ok', chipInk); + + // 1e. The colour-policy backbone. Every widget's colour is either free (the + // consumer picks) or locked for a stated reason, and the colour pass kept + // finding cards where "is this one a standard?" was answered from memory and + // wrong. So policy is declared on the builder and checked here. The invariant + // that matters: the accent recolour mechanism and the 'accent' declaration + // imply each other, so a locked card can't silently sprout accent chips and + // an accent card can't forget to say so. Coverage grows card by card; this + // round classifies the touched set, and the count is reported not gated. + const policy = await evl(`(()=>{ + const KINDS = GW.POLICIES; + if (!KINDS) return 'no POLICIES vocabulary'; + const valid = new Set(Object.keys(KINDS)); + const fns = Object.entries(GW).filter(([,v]) => typeof v === 'function'); + const declared = fns.filter(([,v]) => v.POLICY); + // each POLICY record is complete: a valid kind, a why, an authentic range + const shape = declared.filter(([,v]) => !valid.has(v.POLICY.kind) || !v.POLICY.why || !v.POLICY.authentic) + .map(([k,v]) => k + '(' + v.POLICY.kind + ')'); + if (shape.length) return 'malformed policy record: ' + shape.join(' '); + // A recolour mechanism and an 'accent' kind imply each other, BOTH directions, + // and both directions must recognise the SAME mechanisms — or a locked card + // carrying a multi-colour on-family slips the reverse check. A recolour + // mechanism is a STYLES.accent axis or a STYLES.on family (slideToggle's shape). + const hasMech = v => !!(v.STYLES && (v.STYLES.accent || v.STYLES.on)); + const mechNotAccent = fns.filter(([,v]) => hasMech(v) && (!v.POLICY || v.POLICY.kind !== 'accent')).map(([k]) => k); + if (mechNotAccent.length) return 'recolour mechanism but not declared accent: ' + mechNotAccent.join(' '); + const accentNoMech = declared.filter(([,v]) => v.POLICY.kind === 'accent' && !hasMech(v)).map(([k]) => k); + if (accentNoMech.length) return 'declared accent but no recolour mechanism: ' + accentNoMech.join(' '); + // a screen kind must actually read the screen vars + const screenNoVars = declared.filter(([,v]) => v.POLICY.kind === 'screen' && !v.toString().includes('--scr-')).map(([k]) => k); + if (screenNoVars.length) return 'declared screen but reads no --scr- vars: ' + screenNoVars.join(' '); + return 'ok (' + declared.length + ' classified)'; + })()`); + ok('policy records valid, complete and mechanism-matched', policy.startsWith('ok'), policy); + + // 1f. The policy is on the PAGE, not just in code and this probe. Each card + // shows a badge derived from its builder's COLOR (single source, so the + // page can't drift from the declaration), and the badge marks free vs + // locked vs unclassified. Checks a known classified card renders its policy + // and a locked/unclassified one renders honestly. + const shown = await evl(`(()=>{ + const badge = no => document.querySelector('#card-' + no + ' .cpol'); + const b07 = badge('07'), b10 = badge('R10'); + if (!b07 || !b10) return 'no policy badge on card'; + if (b07.textContent !== 'accent' || b07.dataset.free !== 'true') return 'card 07 badge: ' + b07.textContent + '/' + b07.dataset.free; + if (b10.textContent !== 'screen') return 'card R10 badge: ' + b10.textContent; + // an unclassified card shows the dash, not a stale or invented policy + const un = [...document.querySelectorAll('.cpol')].find(b => b.dataset.pol === 'none'); + if (un && un.textContent !== '—') return 'unclassified badge should read dash: ' + un.textContent; + // the badge text always matches the builder's actual COLOR, never a copy + const drift = [...document.querySelectorAll('.card')].map(c => { + const b = c.querySelector('.cpol'); const want = c.dataset.cpol; + return (want === 'none' ? '—' : want) === b.textContent ? null : c.id; + }).filter(Boolean); + return drift.length ? 'badge drifts from data-cpol: ' + drift.slice(0,3).join(' ') : 'ok'; + })()`); + ok('colour policy is shown on each card, derived not copied', shown === 'ok', shown); + + // 1g. The index tally counts coverage the way the validation tally counts the + // walk, so "how far has the colour pass reached" is a glance, not a grep. + // free + locked + unclassified must sum to the card total. + const ptally = await evl(`(()=>{ + const rows = [...document.querySelectorAll('#ptally .vrow[data-g] .vn')].map(v => +v.textContent); + const total = +document.querySelector('#ptally .vtot .vn').textContent; + const cards = document.querySelectorAll('.card').length; + if (total !== cards) return 'tally total ' + total + ' != ' + cards + ' cards'; + if (rows.reduce((a,b)=>a+b,0) !== total) return 'free+locked+unclassified ' + rows + ' != ' + total; + return 'ok free=' + rows[0] + ' locked=' + rows[1] + ' unclassified=' + rows[2]; + })()`); + ok('colour policy tally sums to the card total', ptally.startsWith('ok'), ptally); + + // 1h. widgets/row: the control sets an explicit column count and the cards + // resize to match. One-per-row cards must be much wider than four-per-row, + // and the count actually reaches the grid. + ok('default is 3 widgets/row', await evl(`document.body.dataset.cols`) === '3'); + await evl(`document.querySelector('.szbar .key[data-cols="1"]').click()`); + const w1col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols1 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + await evl(`document.querySelector('.szbar .key[data-cols="4"]').click()`); + const w4col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols4 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + ok('1/row is much wider than 4/row', w1col > w4col * 2.5, `w1=${Math.round(w1col)} w4=${Math.round(w4col)}`); + ok('the grid renders the chosen column count', cols1 === 1 && cols4 === 4, `1->${cols1} 4->${cols4}`); + ok('widgets/row chip flips state', await evl(`document.body.dataset.cols`) === '4'); + + // 3. behavioral, zoomed: fader drag on card 03 changes readout. Run at 2/row, + // not 1/row: at one-per-row the card is wide enough that its controls fall + // outside the 1600px probe window and the dispatched mouse events miss. + await evl(`document.querySelector('.szbar .key[data-cols="2"]').click()`); await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`); await sleep(200); const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`); @@ -87,9 +202,9 @@ try { await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2); await sleep(150); const after = await evl(`document.getElementById('rd-03').textContent`); - ok('fader drag tracks at 3x', before !== after && after !== '—', `'${before}' -> '${after}'`); + ok('fader drag tracks at 2/row', before !== after && after !== '—', `'${before}' -> '${after}'`); - // 4. behavioral at 3x: toggle click on card 01 + // 4. behavioral, zoomed: toggle click on card 01 await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`); await sleep(200); const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`); @@ -98,7 +213,7 @@ try { await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 }); await sleep(150); const t1 = await evl(`document.getElementById('rd-01').textContent`); - ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`); + ok('toggle click responds at 2/row', t0 !== t1, `'${t0}' -> '${t1}'`); // 5. card 02 console keys: reading order and per-key tone. LIVE is green // because --pass is what the kit means by live everywhere else; gold stays @@ -569,14 +684,39 @@ try { // keyboard skips. Both cases with no shift key, plus accents, fractions and // the section mark — that coverage is the idea being preserved from the // Mignon, where the key ORDER deliberately is not. + // The plate is the Mignon's own, so the set is its set: both full cases, + // the accents and fractions a keyboard skips — and deliberately NO 1 and NO + // 0, because the machine has you type them with lowercase l and capital O. + // That economy is an old value worth keeping, so it's asserted rather than + // tolerated: adding a 1 key would be a silent departure from the reference. const charset = await evl(`(()=>{ const have = new Set([...document.querySelectorAll('#card-R58 .ix-cell')].map(c => c.dataset.c)); const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz', - ...'0123456789', 'ä','ö','ü','Ä','Ö','Ü','ß','§','½','¼']; + ...'23456789', 'ä','ö','ü','§','½','¼','¾']; const missing = need.filter(c => !have.has(c)); - return missing.length ? 'missing: ' + missing.join(' ') : 'ok'; + if (missing.length) return 'missing: ' + missing.join(' '); + const shouldNotExist = ['1','0'].filter(c => have.has(c)); + if (shouldNotExist.length) return 'plate grew a key the Mignon economises away: ' + shouldNotExist.join(' '); + return 'ok'; + })()`); + ok('R58 carries the Mignon set: both cases, accents, no 1 or 0', charset === 'ok', charset); + + // 14j. The inversion is ZONAL, not per-case. The capitals block is dark discs on + // a light panel; the lowercase block is its photographic negative. But J is + // a capital sitting out on the ring, un-inverted — so a rule of "capitals + // are dark" is wrong, and only position decides. + const zones = await evl(`(()=>{ + const disc = c => { + const g = [...document.querySelectorAll('#card-R58 .ix-cell')].find(e => e.dataset.c === c); + return g ? g.querySelector('circle').getAttribute('fill') : null; + }; + const caps = disc('P'), lower = disc('p'), ringCap = disc('J'), ringPunct = disc('&'); + if (caps === lower) return 'capitals and lowercase discs are the same: ' + caps; + if (ringCap !== ringPunct) return 'J should wear the ring look like punctuation: J=' + ringCap + ' &=' + ringPunct; + if (ringCap === caps) return 'J is inverted, but it sits on the ring, not in the block'; + return 'ok'; })()`); - ok('R58 plate carries both cases, digits and the extended set', charset === 'ok', charset); + ok('R58 inversion is zonal: J is a capital but not inverted', zones === 'ok', zones); // 14e. The layout is DATA, so revising the order is a table edit rather than a // redraw. Craig has already said the keys will change; a layout welded into |
