diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-18 07:51:43 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-18 07:51:43 -0500 |
| commit | 4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab (patch) | |
| tree | 96b992eaf7443050b5e932e66662b74223c257c5 /tests | |
| parent | 872813892e98d9cc7b8253b02cf9a595b144325a (diff) | |
| download | archsetup-4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab.tar.gz archsetup-4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab.zip | |
feat(gallery): grow the split-flap into a randomized multi-row board with skins, faces and a flap-rate slider
The N20 card becomes a three-row, six-cell departure board. Every advance sends each row to a completely different word, drawn at random from a 24-word pool with rows kept mutually distinct, and each cell runs at its own mechanical rate (a fixed 0.8x-1.35x of the base flap time), so letters and whole words finish at genuinely different times. A slider on the card sets the base rate (setFlapMs on the handle). The board advances only after it has settled and dwelt two seconds on the reading: the builder announces settle once per command (onSettle) and the page schedules the next advance from that signal, since a fixed-interval ticker would retarget mid-cascade and the panel would never rest. The chain stays gated off under reduced motion, where the board paints once and rests.
Customization lands as two construction axes on the chip rig: skin (cream-on-black default, true-white ink, the ivory board, or white cards with dark lettering) and face (Berkeley Mono or Helvetica, the grotesque real boards wore). The skin chips are miniature flap cells, a letter in the skin's real ink on its real board colour, and the font chips letter themselves in their actual face, so every choice previews itself. The flap-rate slider rides in the chips row, and the card's amber word readout is gone: the board is the display. The deterministic seam for callers and tests is setText; randomness lives only in the demo path (next/scramble), which re-aims against the last commanded grid rather than in-flight glyphs.
The settle work caught a real ordering bug: setText assigned each cell's target and ran it in one pass, so a cell finishing synchronously saw its neighbours' stale, already-satisfied targets and announced a partial board as settled. Targets now all land before any cell runs. Review caught two more: the dwell chain had escaped the reduced-motion gate (the board self-advanced every two seconds forever under reduced motion), and the skin chips rendered blank because the STYLES entries were strings where the rig paints an object's dot swatch.
Since this is heading for extraction as a regular component, the builder got the full refactor pass: a contract comment covering every opt and the handle surface, small named helpers, no page globals, and all CSS in the one split-flap block. The same bar is filed as a sweep task for every other builder. Probe coverage grows to 78 checks: the reduced-motion rest, the live dwell measurement, the scramble contract, both style axes, the speed api and the swatch legibility.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 198 |
1 files changed, 177 insertions, 21 deletions
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index 7793c6f..7b9ba5e 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -886,16 +886,17 @@ try { // Park the page's 1.5s demo ticker so it can't retarget mid-check // (IH.flap is the ticker's handle reference; the card element keeps the real one). await evl(`(() => { window.__realFlap = IH.flap; IH.flap = { next(){}, set(){}, tick(){} }; })()`); - // Let the board settle on word 0 first — the demo ticker left it anywhere, - // and set(0) itself cascades (there is deliberately no teleport path). - await evl(`document.querySelector('#card-N20').gw.set(0)`); - await sleep(4200); + // Let the board settle on a known grid first — the demo ticker left it + // anywhere, and every command cascades (there is deliberately no teleport + // path). setText is the deterministic seam; next() is random by design. + await evl(`document.querySelector('#card-N20').gw.setText('SYSTEM\\nVOLUME\\nSIGNAL')`); + await sleep(5400); // Start a cascade and sample the displayed reading every 15ms until stable. await evl(`(() => { const h = document.querySelector('#card-N20').gw; window.__n20 = { samples: [], anims: 0 }; - h.set(1); // DNS -> LINK, multi-step distances on several cells + h.setText('VOLUME\\nSIGNAL\\nRECORD'); // multi-step distances on several cells window.__n20.n = 0; window.__n20.timer = setInterval(() => { window.__n20.samples.push(h.reading()); @@ -907,20 +908,20 @@ try { window.__n20.anims++; }, 15); })()`); - await sleep(4200); + await sleep(5400); const n20 = await evl(`(() => { clearInterval(window.__n20.timer); const h = document.querySelector('#card-N20').gw; return { samples: [...new Set(window.__n20.samples)], final: h.reading(), anims: window.__n20.anims, - chars: h.chars, words: ['DNS ','LINK'] }; + chars: h.chars }; })()`); - ok('N20 cascade arrives at the target word', n20.final.trim() === 'LINK', n20.final); + ok('N20 cascade arrives at the commanded grid', n20.final === 'VOLUME\nSIGNAL\nRECORD', JSON.stringify(n20.final)); ok('N20 cascade passes through intermediate readings (never teleports)', - n20.samples.filter(s => s.trim() !== 'DNS' && s.trim() !== 'LINK').length >= 2, + n20.samples.filter(s => s !== 'SYSTEM\nVOLUME\nSIGNAL' && s !== 'VOLUME\nSIGNAL\nRECORD').length >= 2, n20.samples.length + ' distinct states'); ok('N20 flip runs WAAPI animations (a CSS class swap cannot satisfy this)', n20.anims > 0, 'anim samples ' + n20.anims); { - // Cell 0 travels D->L; every observed transition must step +1 through the charset. + // Cell 0 travels S->V; every observed transition must step +1 through the charset. const seq = [...new Set(n20.samples.map(s => s[0]))]; let stepped = seq.length >= 3 ? 'ok' : 'too few states: ' + seq.join(''); for (let i = 1; i < seq.length && stepped === 'ok'; i++) { @@ -935,31 +936,186 @@ try { const h = document.querySelector('#card-N20').gw; window.__n20r = { samples: [] }; window.__n20r.timer = setInterval(() => window.__n20r.samples.push(h.reading()), 15); - h.set(2); setTimeout(() => h.set(3), 180); + h.setText('SIGNAL\\nRECORD\\nONLINE'); setTimeout(() => h.setText('RECORD\\nONLINE\\nSTEREO'), 180); })()`); - await sleep(4600); + await sleep(5800); const n20b = await evl(`(() => { clearInterval(window.__n20r.timer); const h = document.querySelector('#card-N20').gw; - return { final: h.reading(), target: 'WIFI', - sawSync: window.__n20r.samples.some(s => s.trim() === 'SYNC'), + return { final: h.reading().split('\\n')[0], target: 'RECORD', + sawSync: window.__n20r.samples.some(s => s.split('\\n')[0] === 'SIGNAL'), anims: document.querySelector('#card-N20').getAnimations({ subtree: true }).length }; })()`); - ok('N20 retarget mid-cascade lands on the new target', n20b.final.trim() === n20b.target, n20b.final); - ok('N20 retarget re-aims rather than queueing (SYNC never fully forms)', !n20b.sawSync, n20b.sawSync ? 'saw SYNC' : 'ok'); + ok('N20 retarget mid-cascade lands on the new target', n20b.final === n20b.target, n20b.final); + ok('N20 retarget re-aims rather than queueing (SIGNAL never fully forms)', !n20b.sawSync, n20b.sawSync ? 'saw SIGNAL' : 'ok'); ok('N20 no cascade left running after arrival', n20b.anims === 0, 'live anims ' + n20b.anims); + // The demo scramble: next() sends each row to a random pool word — every + // row changes, rows stay mutually distinct, all drawn from the pool. + const n20s = await evl(`(() => { + const el = document.createElement('div'); document.body.appendChild(el); + const pool = ['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','FILTER','OUTPUT']; + const h = GW.splitFlap(el, { rows: 3, cells: 6, words: pool, animate: false }); + const grids = [h.reading().split('\\n')]; + h.next(); grids.push(h.reading().split('\\n')); + h.next(); grids.push(h.reading().split('\\n')); + el.remove(); + for (const g of grids) { + if (new Set(g).size !== 3) return 'rows not distinct: ' + g.join(','); + if (!g.every(w => pool.includes(w.trim()))) return 'word outside pool: ' + g.join(','); + } + for (let i = 1; i < grids.length; i++) + for (let r = 0; r < 3; r++) + if (grids[i][r] === grids[i - 1][r]) return 'row ' + r + ' kept its word: ' + grids[i][r]; + return 'ok'; + })()`); + ok('N20 scramble sends every row to a different pool word', n20s === 'ok', n20s); + // animate:false (the reduced-motion path) jumps straight to the target. const n20c = await evl(`(() => { const el = document.createElement('div'); document.body.appendChild(el); - const h = GW.splitFlap(el, { animate: false }); + const settles = []; + const h = GW.splitFlap(el, { animate: false, onSettle: r => settles.push(r) }); h.set(1); - const r = h.reading(); el.remove(); return r; + const r = h.reading(); el.remove(); + return { r, settles }; + })()`); + ok('N20 animate:false jumps straight to the target', n20c.r.trim() === 'LINK', n20c.r); + ok('N20 settle announces once per command', n20c.settles.length === 1 && n20c.settles[0].trim() === 'LINK', + JSON.stringify(n20c.settles)); + + // The card is a three-row six-letter board; the demo draws random pool words. + const n20d = await evl(`(() => { + const h = document.querySelector('#card-N20').gw; + return { cells: document.querySelectorAll('#card-N20 .flapd').length, + lines: document.querySelectorAll('#card-N20 .flapline').length, + readLines: h.reading().split('\\n').length }; })()`); - ok('N20 animate:false jumps straight to the target', n20c.trim() === 'LINK', n20c); + ok('N20 board is three rows of six cells', n20d.cells === 18 && n20d.lines === 3 && n20d.readLines === 3, + JSON.stringify(n20d)); - // Unpark the ticker and restore the demo state it expects. - await evl(`(() => { IH.flap = window.__realFlap; IH.flap.set(0); })()`); + // Skin axis: dark (cream on black) and the inverse light (black on cream), + // switched by the chip rig via setStyle, computed ink actually inverting. + const n20e = await evl(`(() => { + const h = document.querySelector('#card-N20').gw; + if (typeof h.setStyle !== 'function' || !GW.splitFlap.STYLES || !GW.splitFlap.STYLES.skin) return 'no skin axis'; + const skins = Object.keys(GW.splitFlap.STYLES.skin); + if (!['dark','white','light','paper'].every(k => skins.includes(k))) return 'skins are: ' + skins.join(','); + const ink = () => getComputedStyle(document.querySelector('#card-N20 .fh')).color; + const lum = c => { const m = c.match(/[0-9]+/g).map(Number); return m[0] + m[1] + m[2]; }; + const dark = ink(); + h.setStyle('skin', 'white'); + const white = ink(); + h.setStyle('skin', 'light'); + const light = ink(); + const gotClass = document.querySelector('#card-N20 .flap').classList.contains('flap-light'); + h.setStyle('skin', 'paper'); + const paper = ink(); + const paperClass = document.querySelector('#card-N20 .flap').classList.contains('flap-paper'); + h.setStyle('skin', 'dark'); + const back = ink(); + if (!gotClass) return 'light skin class missing'; + if (!paperClass) return 'paper skin class missing'; + if (white !== 'rgb(255, 255, 255)') return 'white skin ink is ' + white; + if (!(lum(dark) > 400 && lum(dark) < 760 && lum(light) < 300 && lum(paper) < 300)) return 'inks wrong: ' + dark + ' / ' + light + ' / ' + paper; + if (back !== dark) return 'did not restore dark: ' + back; + return 'ok'; + })()`); + ok('N20 skins: cream dark, white ink, ivory board, white board — all switch and restore', n20e === 'ok', n20e); + + const n20fnt = await evl(`(() => { + const h = document.querySelector('#card-N20').gw; + if (!GW.splitFlap.STYLES.font) return 'no font axis'; + const fam = () => getComputedStyle(document.querySelector('#card-N20 .fh')).fontFamily; + const mono = fam(); + h.setStyle('font', 'helv'); + const helv = fam(); + h.setStyle('font', 'mono'); + const back = fam(); + if (!/helvetica/i.test(helv)) return 'helv did not apply: ' + helv; + if (!/berkeley|mono/i.test(mono) || back !== mono) return 'mono wrong or not restored: ' + mono + ' / ' + back; + return 'ok'; + })()`); + ok('N20 font switches between Berkeley Mono and Helvetica', n20fnt === 'ok', n20fnt); + const n20f = await evl(`(() => { + const chips = [...document.querySelectorAll('#card-N20 .famchips .fc, #card-N20 .famchips [title]')].map(c => c.title || c.textContent); + return chips.length ? chips.join(',') : 'no chips'; + })()`); + ok('N20 card offers skin and font chips', + ['dark','white','light','paper','mono','helv'].every(t => n20f.includes(t)), n20f); + const n20h = await evl(`(() => { + const rd = document.querySelector('#card-N20 .wrd'); + const slider = document.querySelector('#card-N20 .famchips input[type=range]'); + if (rd && getComputedStyle(rd).display !== 'none') return 'readout still visible'; + if (!slider) return 'slider not in the chips row'; + return 'ok'; + })()`); + ok('N20 board is the only display: readout hidden, slider rides with the chips', n20h === 'ok', n20h); + + // Flap speed: the handle takes setFlapMs and the card's slider drives it. + const n20v = await evl(`(() => { + const h = document.querySelector('#card-N20').gw; + if (typeof h.setFlapMs !== 'function' || typeof h.flapMs !== 'function') return 'no speed api'; + const before = h.flapMs(); + h.setFlapMs(120); + if (h.flapMs() !== 120) return 'setFlapMs did not take: ' + h.flapMs(); + const slider = document.querySelector('#card-N20 input[type=range]'); + if (!slider) { h.setFlapMs(before); return 'no slider on the card'; } + slider.value = '90'; slider.dispatchEvent(new Event('input', { bubbles: true })); + const viaSlider = h.flapMs(); + h.setFlapMs(before); slider.value = String(before); + return viaSlider === 90 ? 'ok' : 'slider did not drive setFlapMs: ' + viaSlider; + })()`); + ok('N20 flap speed: setFlapMs api + card slider drive the rate', n20v === 'ok', n20v); + + // Unpark the ticker, restore the demo state, and measure the dwell: the + // board must settle, hold the reading ~2s, and only then take the next update. + await evl(`(() => { + IH.flap = window.__realFlap; + const h = document.querySelector('#card-N20').gw; + window.__n20dwell = { settleAt: 0, changeAt: 0, settled: '' }; + h.onSettle(r => { if (!window.__n20dwell.settleAt) { window.__n20dwell.settleAt = performance.now(); window.__n20dwell.settled = r; } }); + window.__n20dwell.timer = setInterval(() => { + const d = window.__n20dwell; + if (d.settleAt && !d.changeAt && h.reading() !== d.settled) { d.changeAt = performance.now(); clearInterval(d.timer); } + }, 50); + h.set(0); + })()`); + await sleep(9000); + const dwell = await evl(`(() => { + const d = window.__n20dwell; clearInterval(d.timer); + return { settleAt: d.settleAt, delta: d.changeAt ? d.changeAt - d.settleAt : -1 }; + })()`); + ok('N20 settles, then dwells ~2s before the next update', + dwell.settleAt > 0 && dwell.delta >= 1800 && dwell.delta <= 4000, 'delta ' + Math.round(dwell.delta)); + + // Skin chips carry visible swatch dots (the rig paints STYLES[..].dot; + // a string-valued STYLES entry leaves both chips transparent and identical). + const n20g = await evl(`(() => { + const chips = [...document.querySelectorAll('#card-N20 .famchips .fc')]; + if (chips.length < 6) return 'chips missing: ' + chips.length; + // each chip is a miniature flap cell: board colour + a letter in the ink + const pair = c => getComputedStyle(c).backgroundColor + '/' + getComputedStyle(c, '::after').color; + const skins = chips.slice(0, 4).map(pair); + if (new Set(skins).size !== 4) return 'skin previews not distinct: ' + skins.join(' '); + if (chips.slice(0, 4).some(c => getComputedStyle(c, '::after').content === 'none')) return 'skin chip has no ink letter'; + const fams = chips.slice(4).map(c => getComputedStyle(c, '::after').fontFamily); + if (!/helvetica/i.test(fams[1]) || fams[0] === fams[1]) return 'font previews wrong: ' + fams.join(' vs '); + const size = parseFloat(getComputedStyle(chips[0]).width); + return size >= 15 ? 'ok' : 'chips too small to read: ' + size; + })()`); + ok('N20 chips are miniature flap cells: board colour, ink letter, real face', n20g === 'ok', n20g); + + // Under prefers-reduced-motion the board paints once and RESTS — the dwell + // chain must stay gated off, or the panel self-advances every 2s forever. + await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + await send('Page.reload'); + await sleep(2500); + const rm1 = await evl(`document.querySelector('#card-N20').gw.reading()`); + await sleep(5200); + const rm2 = await evl(`(() => ({ r: document.querySelector('#card-N20').gw.reading(), anims: document.getAnimations().length }))()`); + ok('N20 rests under reduced motion (no self-advancing dwell chain)', + rm1 === rm2.r && rm2.anims === 0, JSON.stringify({ before: rm1, after: rm2.r, anims: rm2.anims })); // late exceptions from interactions const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); |
