From 4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 18 Jul 2026 07:51:43 -0500 Subject: 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. --- docs/prototypes/panel-widget-gallery.html | 30 ++++- docs/prototypes/widgets.js | 170 +++++++++++++++++++++---- tests/gallery-probes/probe.mjs | 198 ++++++++++++++++++++++++++---- todo.org | 3 + 4 files changed, 354 insertions(+), 47 deletions(-) diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 201fa0e..b8e70bc 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -188,6 +188,18 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} border:1px solid rgba(255,255,255,.22);box-shadow:0 1px 3px rgba(0,0,0,.5)} .famchips .fc.on{outline:2px solid var(--silver);outline-offset:1px} +/* N20's chips are miniature flap cells: the board colour comes from the rig's + inline dot; the letter shows the actual ink (skins) or the actual face (fonts) */ +#card-N20 .wrd{display:none} +#card-N20 .famchips .fc{width:17px;height:20px;border-radius:4px;position:relative;border:1px solid #4a453c} +#card-N20 .famchips .fc::before{content:"";position:absolute;left:1px;right:1px;top:50%;height:1px;background:rgba(128,128,128,.35)} +#card-N20 .famchips .fc::after{position:absolute;inset:0;display:grid;place-items:center;font-size:12px;font-weight:700;line-height:1;content:"A"} +#card-N20 .fc[title="dark"]::after{color:#f3e7c5} +#card-N20 .fc[title="white"]::after{color:#ffffff} +#card-N20 .fc[title="light"]::after{color:#1b1813} +#card-N20 .fc[title="paper"]::after{color:#17181a} +#card-N20 .fc[title="mono"]::after{color:#bfc4d0;font-family:var(--mono)} +#card-N20 .fc[title="helv"]::after{color:#bfc4d0;font-family:Helvetica,'Helvetica Neue',Arial,sans-serif} @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} @@ -1565,8 +1577,8 @@ card(I,'26','Nixie tube', (st,rd)=>GW.nixie(st,{onChange:(v,t)=>rd(t)}), 'a single warm-glowing numeral. One lit digit per tube, leading zeros dark. Click to increment the count.'); card(I,'N20','Split-flap display', - (st,rd)=>GW.splitFlap(st,{animate:!reduced,onChange:(v,t)=>rd(t)}), - 'flips to the new value. Each card drops to the next glyph with the mechanical clack. Auto-flips; click to flip now.'); + (st)=>GW.splitFlap(st,{rows:3,cells:6,words:['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','STATUS','FILTER','TUNING','NEEDLE','SWITCH','TOGGLE','DIMMER','CHROME','MODULE','OUTPUT','SCREEN','COPPER','DYNAMO','PISTON','MAGNET','BEACON','RADIAL','BRIDGE'],animate:!reduced}), + 'flips to the new value. A three-row, six-cell board; every advance sends each row to a different random word from the pool, and each cell runs at its own mechanical rate, so letters and whole words finish at different times. After settling it dwells two seconds, then advances. Click to advance now. Skins: cream-on-black (default), true white ink, the ivory board, or white cards with dark lettering; face in Berkeley Mono or Helvetica. The slider sets the flap rate.'); card(I,'N21','Seven-segment display', (st,rd)=>GW.sevenSeg(st,{onChange:(v,t)=>rd(t)}), 'a lit-segment number. Green segments, distinct from the nixie glow. Live countdown; click to add a minute.'); @@ -1698,7 +1710,10 @@ function fastTick(){ /* indicator loops: builders own state + clicks; the page keeps the clocks and the time source (tick contract) */ function tickClock(){const d=new Date();IH.clock.set(d.getHours(),d.getMinutes(),d.getSeconds());} -function tickFlap(){IH.flap.next();} +const FLAP_DWELL_MS=2000; let flapTimer; +// The board advances only after it has settled AND dwelt on the reading — +// a fixed interval would retarget mid-cascade and the panel would never rest. +if(!reduced)IH.flap.onSettle(()=>{clearTimeout(flapTimer);flapTimer=setTimeout(()=>IH.flap.next(),FLAP_DWELL_MS);}); function tickSeven(){IH.seven.tick();} function tickT(){$('card-20').gw.tick();} function tickVfd(){IH.vfd.tick();} @@ -1721,7 +1736,7 @@ if(!reduced){ setInterval(fastTick,80); setInterval(tickMcVu,80); setInterval(tickClock,1000);tickClock(); - setInterval(tickFlap,1500); + flapTimer=setTimeout(()=>IH.flap.next(),FLAP_DWELL_MS); setInterval(tickVfd,32); setInterval(tickCounter,1600); setInterval(tickSeven,1000); @@ -1813,6 +1828,13 @@ function styleChips(no,STYLES,AXES,PRESETS,defPreset){ groups.forEach(g=>row.appendChild(g)); cardEl.querySelector('.opts').appendChild(row); } +styleChips('N20',GW.splitFlap.STYLES,[['skin','skin','dark'],['font','font','mono']]); +(()=>{const h=$('card-N20').gw,row=document.querySelector('#card-N20 .famchips');if(!h||!row)return; + const gr=document.createElement('span');gr.className='fgroup'; + gr.innerHTML='flap70ms'; + const sl=gr.querySelector('input'),ms=gr.querySelector('.fms'); + sl.addEventListener('input',()=>{h.setFlapMs(+sl.value);ms.textContent=sl.value+'ms';}); + row.appendChild(gr);})(); styleChips('01',GW.slideToggle.STYLES, [['on','on','amber'],['on text','onText','panel'],['off','off','dark'], ['off text','offText','white'],['thumb','thumb','light']], diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index c5ce2c7..e11862c 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -3649,38 +3649,72 @@ GW.nixie = function (host, opts = {}) { /* N20 split-flap — an honest Solari mechanism. Each cell owns a stack of flaps in `chars` order (data, like R58's LAYOUT) and can only advance one flap at a time, so a changed reading cascades through intermediates and - cells arrive staggered by travel distance. set() mid-cascade re-aims the - running cells at the new target; animate:false (the page's reduced-motion - gate) collapses every move to an instant jump. One flip is two half-panel + cells arrive staggered by travel distance. setText() mid-cascade re-aims + the running cells; animate:false (the page's reduced-motion gate) + collapses every move to an instant jump. One flip is two half-panel animations: the current top falls (rotateX 0 -> -180) while the next - bottom lands (180 -> 0), backfaces hidden, crease preserved. The page - drives next() on its own cadence; first paint is silent like the original. */ + bottom lands (180 -> 0), backfaces hidden, crease preserved. First paint + is silent (no cascade on load), like the original. The page drives next() + on its own cadence. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (1) x cells (4) grid; chars (flap order, default space+A-Z+0-9); + flapMs (70, per-cell rate jittered 0.8x-1.35x so letters finish + at different times, base mutable via setFlapMs); animate (true); + skin ('dark'|'white'|'light'|'paper'); font ('mono'|'helv'); + words (the demo pool, at least rows+1 entries: next() sends every + row to a different random word, mutually distinct; set(i) pins the + top row for determinism); onChange(idx|-1, top word) fires at + command time. get() is the last set() index, -1 after a scramble. + handle: el, get(), set(i), next(), setText(multi-line string), + reading() (displayed grid, rows joined with newline), + setStyle(axis, name), chars, flapMs()/setFlapMs(ms) (base flap + rate, clamped 20-400), onSettle(cb) — cb(reading) fires once per + command when every cell has arrived (dwell hook). + CSS lives in the "split-flap" block of GW_CSS; no other styles involved. */ GW.splitFlap = function (host, opts = {}) { const onChange = opts.onChange || noop; const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN']; const animate = opts.animate !== undefined ? !!opts.animate : true; + const rows = opts.rows || 1; const cells = opts.cells || 4; const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - const flapMs = opts.flapMs || 70; - const f = document.createElement('span'); f.className = 'flap'; + let flapMs = Math.min(400, Math.max(20, opts.flapMs || 70)); + + const ci = ch => Math.max(0, chars.indexOf(ch)); + const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w); + const wordAt = k => words[((k % words.length) + words.length) % words.length]; + // Four stacked half-panels per cell, painting order = stacking order: // top-next (revealed as the current top falls), bottom-current (covered as // the next bottom lands), top-current (the falling flap), bottom-next (the // landing flap, resting folded away at rotateX(180) with its back hidden). - const st = []; - for (let i = 0; i < cells; i++) { + const st = []; // row-major cell states + const settleCbs = []; if (opts.onSettle) settleCbs.push(opts.onSettle); + let announced = true; // no announcement for the silent first paint + const allSettled = () => st.every(S => !S.running && S.cur === S.target); + const makeCell = () => { const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = ''; - f.appendChild(d); st.push({ cur: 0, target: 0, running: false, + // Each cell's mechanism has its own character: a fixed per-cell jitter + // (0.8x-1.35x of the base rate) so letters finish at genuinely + // different times, beyond what travel distance alone staggers. + jitter: 0.8 + 0.55 * Math.random(), ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'), ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'), }); + return d; + }; + const f = document.createElement('span'); f.className = 'flap' + (rows > 1 ? ' flap-rows' : ''); + for (let r = 0; r < rows; r++) { + const line = rows > 1 ? document.createElement('span') : f; + if (rows > 1) { line.className = 'flapline'; f.appendChild(line); } + for (let c = 0; c < cells; c++) line.appendChild(makeCell()); } host.appendChild(f); - const ci = ch => Math.max(0, chars.indexOf(ch)); - const paintCell = (S, k) => { S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k]; }; + // One cell's cascade: flip one place through the charset until the (live) // target is reached. A retarget while running just moves the goalpost. const run = S => { @@ -3694,9 +3728,9 @@ GW.splitFlap = function (host, opts = {}) { try { await Promise.all([ S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], - { duration: flapMs, easing: 'ease-in' }).finished, + { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished, S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], - { duration: flapMs, easing: 'ease-in' }).finished, + { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished, ]); } catch { break; /* cancelled (card torn down mid-flip) */ } } @@ -3704,22 +3738,99 @@ GW.splitFlap = function (host, opts = {}) { S.cur = nk; } S.running = false; + maybeSettle(); })(); }; + // Announce once per command, when the whole board has arrived. Consumers + // use it to dwell on the settled reading before commanding the next update. + const maybeSettle = () => { + if (announced || !allSettled()) return; + announced = true; + const r = reading(); + settleCbs.forEach(cb => cb(r)); + }; + + // Map a multi-line string onto the grid; missing lines/cells pad with space. + const gridTargets = str => { + const lines = String(str).split('\n'); + return st.map((S, i) => ci(fit(lines[(i / cells) | 0] || '', cells)[i % cells])); + }; const setText = str => { - const w = String(str).padEnd(cells, ' ').slice(0, cells); - st.forEach((S, i) => { S.target = ci(w[i]); run(S); }); + announced = false; + const ls = String(str).split('\n'); + lastGrid = Array.from({ length: rows }, (_, r) => fit(ls[r] || '', cells)).join('\n'); + // Assign every target before running any cell: a cell that finishes + // synchronously (animate:false) must not see its neighbours' stale, + // already-satisfied targets and announce a partial board as settled. + gridTargets(str).forEach((k, i) => { st[i].target = k; }); + st.forEach(run); + maybeSettle(); // covers a command that changes nothing }; + const silent = str => gridTargets(str).forEach((k, i) => { + const S = st[i]; S.cur = S.target = k; + S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k]; + }); + + // The words demo: every advance sends each row to a completely different + // word, drawn at random from the pool; rows stay mutually distinct. set(i) + // pins the top row (deterministic callers, tests); next() scrambles all. let idx = 0; - const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(words[idx]); onChange(idx, words[idx].trim()); }; - f.addEventListener('click', () => set(idx + 1)); - // first paint lands silently on word 0 — no cascade on load - words[0].padEnd(cells, ' ').slice(0, cells).split('').forEach((ch, i) => { st[i].cur = st[i].target = ci(ch); paintCell(st[i], st[i].cur); }); + let lastGrid = null; // the last commanded grid — scramble picks against + // commands, not in-flight glyphs mid-cascade + const rowWords = () => (lastGrid || reading()).split('\n'); + const pickNew = taken => { + const open = words.filter(w => !taken.includes(fit(w, cells))); + return fit(open[(Math.random() * open.length) | 0] || wordAt(0), cells); + }; + const gridFor = top => { + const out = []; + const prev = rowWords(); + for (let r = 0; r < rows; r++) + out.push(r === 0 && top !== null ? fit(top, cells) : pickNew(out.concat(prev[r]))); + return out.join('\n'); + }; + const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(gridFor(wordAt(idx))); onChange(idx, wordAt(idx).trim()); }; + const scramble = () => { idx = -1; const g = gridFor(null); setText(g); onChange(-1, g.split('\n')[0].trim()); }; + f.addEventListener('click', scramble); + + // skin: flap polarity — dark (cream on black, the default) or the inverse + // light board (black on cream). A construction axis, not an accent. + const setStyle = (axis, name) => { + const ax = GW.splitFlap.STYLES[axis]; const o = ax && ax[name]; + if (!o) return; + Object.values(ax).forEach(v => { if (v.cls) f.classList.remove(v.cls); }); + if (o.cls) f.classList.add(o.cls); + }; + if (opts.skin) setStyle('skin', opts.skin); + if (opts.font) setStyle('font', opts.font); + + // first paint: consecutive pool words, silent (no cascade on load) + silent(Array.from({ length: rows }, (_, r) => fit(wordAt(r), cells)).join('\n')); + const reading = () => Array.from({ length: rows }, + (_, r) => st.slice(r * cells, (r + 1) * cells).map(S => chars[S.cur]).join('')).join('\n'); return { - el: f, get: () => idx, set, next: () => set(idx + 1), - setText, chars, reading: () => st.map(S => chars[S.cur]).join(''), + el: f, get: () => idx, set, next: scramble, setStyle, + setText, chars, reading, onSettle: cb => settleCbs.push(cb), + flapMs: () => flapMs, setFlapMs: ms => { flapMs = Math.min(400, Math.max(20, ms | 0)); }, }; }; +GW.splitFlap.STYLES = { + // board polarity + ink: dark board with the kit's cream ink (default), + // dark board with true-white ink, or the inverse ivory board + // dot: the board colour; the gallery card letters each chip in the skin's + // ink so the swatch is a miniature flap cell, not an ambiguous colour dot + skin: { + dark: { cls: '', dot: '#141210' }, + white: { cls: 'flap-white', dot: '#141210' }, + light: { cls: 'flap-light', dot: '#f3e7c5' }, + paper: { cls: 'flap-paper', dot: '#ffffff' }, + }, + // typeface: the kit's Berkeley Mono, or the grotesque real boards wore + font: { + mono: { cls: '', dot: '#0d0c0b' }, + helv: { cls: 'flap-helv', dot: '#0d0c0b' }, + }, +}; /* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); click adds a minute */ @@ -4927,6 +5038,21 @@ const GW_CSS = ` .flapd .fbn{transform:rotateX(180deg)} .flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;z-index:5; background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)} +/* multi-row board: column of flap lines */ +.flap-rows{flex-direction:column;gap:5px} +.flapline{display:inline-flex;gap:4px} +/* white skin — the dark board with true-white ink (the default ink is cream) */ +.flap-white .flapd .fh{color:#fff} +/* helvetica — the grotesque face real Solari boards wore */ +.flap-helv .flapd .fh{font-family:Helvetica,'Helvetica Neue',Arial,sans-serif;font-weight:600} +/* paper skin — true-white cards, dark lettering */ +.flap-paper .flapd{background:linear-gradient(180deg,#ffffff,#e6e6e3);border-color:#b6b6b2} +.flap-paper .flapd .fh{color:#17181a;text-shadow:0 1px 0 rgba(255,255,255,.5)} +.flap-paper .flapd::after{background:rgba(0,0,0,.28);box-shadow:0 1px 0 rgba(255,255,255,.35)} +/* light skin — the inverse board: ivory flaps, near-black ink, softer crease */ +.flap-light .flapd{background:linear-gradient(180deg,#f7eed3,#dccfa8);border-color:#a89c7e} +.flap-light .flapd .fh{color:#1b1813;text-shadow:0 1px 0 rgba(255,255,255,.45)} +.flap-light .flapd::after{background:rgba(0,0,0,.3);box-shadow:0 1px 0 rgba(255,255,255,.3)} /* seven-segment */ .seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer; 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'); diff --git a/todo.org b/todo.org index 8a52341..d656788 100644 --- a/todo.org +++ b/todo.org @@ -81,6 +81,9 @@ Scrolling the date module used to cycle its text format (=date-cycle= over full/ :LAST_REVIEWED: 2026-07-13 :END: Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work, so it runs on the =gallery-upgrades= branch (squash merge to main after Craig's UI confirmation + tweaks). Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm after the merge. +*** TODO Extraction-readiness bar for every gallery component :refactor:design: +Craig's standing directive (2026-07-18, set while finishing the split-flap): every =GW.*= builder should meet the bar the split-flap now sets, since these become regular components. The bar: a contract comment documenting every opt and the full handle surface; no page globals touched (page owns cadence via handles/callbacks, e.g. =onSettle=); all component CSS in one named =GW_CSS= block; refactored until no opportunity worth doing remains (small named helpers, no duplication); construction axes declared via =STYLES= where the component has them. Sweep the existing builders against that list, fix the gaps, and make the bar a stated convention in the widgets.js header or README so new builders inherit it. Overlaps the component-generation spec's extraction phase — reconcile there rather than doing the work twice. + *** 2026-07-18 Sat @ 04:32:20 -0500 Made the N20 split-flap an honest Solari mechanism =GW.splitFlap= rebuilt from the drop-fade fake: charset-as-drum stepping (=opts.chars= is the flap order, one flip at a time through intermediates, staggered arrival), re-aim-not-queue retargeting, and the real two-half-panel fold (WAAPI, backfaces hidden), with =animate:false= collapsing to instant jump for reduced motion. Handle grew =setText=/=chars=/=reading()=; default width 3 → 4 cells. Nine probe checks written red-first (arrival, intermediates, one-flap stepping, re-aim discriminator, instant path); technique studied from HotFX and re-derived — no license on their repo, nothing copied (reference filed in =working/retro-stereo-widgets/references/=). Review: sound; its two test-strength notes addressed in the same change. -- cgit v1.2.3