diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-18 04:32:39 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-18 04:32:39 -0500 |
| commit | 872813892e98d9cc7b8253b02cf9a595b144325a (patch) | |
| tree | dc6ff1fe57e3a3a7d45a9bc9c184f3f517677f8d | |
| parent | 5e664af75797cce4e480bed8b71e52e6192a2d57 (diff) | |
| download | archsetup-872813892e98d9cc7b8253b02cf9a595b144325a.tar.gz archsetup-872813892e98d9cc7b8253b02cf9a595b144325a.zip | |
feat(gallery): make the N20 split-flap an honest Solari mechanism
The card's flip was a 0.3s drop-fade that teleported each cell straight to its target character — a suggestion of a split-flap, not one. Since the gallery card is the spec the ports get measured against, the fake would have propagated.
Three behaviors replace it, studied from the HotFX split-flap component and re-derived (its repo carries no license, so no code was copied). The charset is the drum: opts.chars declares the flap order and a changed cell steps one flap at a time through intermediates, never jumping, so cells arrive staggered by travel distance. A set() mid-cascade re-aims the running cells at the new target. The fold is real: two half-panel animations per flip — the current top falls, the next bottom lands — over four clipped layers with backfaces hidden, replacing the drop-fade keyframe.
animate:false (the reduced-motion gate) collapses every move to an instant jump. The handle grows setText, chars, and reading() so probes can sample the displayed state mid-cascade; the default width goes 3 to 4 cells, which stops truncating every demo word. Probe checks were written red first: cascade arrival, intermediate readings, one-flap-at-a-time stepping in charset order, mid-cascade retarget, and the instant animate:false path.
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 2 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 98 | ||||
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 88 | ||||
| -rw-r--r-- | todo.org | 3 |
4 files changed, 166 insertions, 25 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index a31f578..201fa0e 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -948,7 +948,7 @@ const INFO={ origin:'Nixie tubes.',difficulty:'Intuitive.', prefer:'Warmth and era matter more than crispness.', period:'1955-75 hard period: sits with keypads and telegraphs, clashes with VFD and LED seven-seg.'}, -'N20':{input:'Display only; flaps animate on change.', +'N20':{input:'Click steps to the next word. Each cell flips one flap at a time through its charset, so cells arrive staggered; a mid-spin change re-aims the cascade.', solves:'A changed value that announces itself mechanically.', use:'Specialty. Shines for arrivals-board style updates.', limits:'Character set and flap speed are the charm and the constraint.', diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 5ba1226..c5ce2c7 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -3646,33 +3646,79 @@ GW.nixie = function (host, opts = {}) { return { el: nx, get: () => v, set }; }; -/* N20 split-flap — windows flip to the next word with the mechanical clack; - the page drives next() on its own cadence; first paint is silent like the original */ +/* 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 + 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. */ 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 cells = opts.cells || 3; + const cells = opts.cells || 4; + const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const flapMs = opts.flapMs || 70; const f = document.createElement('span'); f.className = 'flap'; + // 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 d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = '<b></b>'; f.appendChild(d); + const d = document.createElement('span'); d.className = 'flapd'; + d.innerHTML = '<b class="fh ftn"></b><b class="fh fbc"></b><b class="fh ftc"></b><b class="fh fbn"></b>'; + f.appendChild(d); + st.push({ + cur: 0, target: 0, running: false, + ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'), + ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'), + }); } host.appendChild(f); - let idx = 0; - const paint = () => { - const w = words[idx].padEnd(cells + 1, ' '); - [...f.children].forEach((dd, i) => { - const ch = w[i] || ' ', bel = dd.querySelector('b'); - if (bel.textContent !== ch) { - bel.textContent = ch; - if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); } + 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 => { + if (S.running) return; + S.running = true; + (async () => { + while (S.cur !== S.target) { + const nk = (S.cur + 1) % chars.length; + S.ftn.textContent = chars[nk]; S.fbn.textContent = chars[nk]; + if (animate) { + try { + await Promise.all([ + S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], + { duration: flapMs, easing: 'ease-in' }).finished, + S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], + { duration: flapMs, easing: 'ease-in' }).finished, + ]); + } catch { break; /* cancelled (card torn down mid-flip) */ } + } + S.ftc.textContent = S.fbc.textContent = chars[nk]; + S.cur = nk; } - }); + S.running = false; + })(); + }; + const setText = str => { + const w = String(str).padEnd(cells, ' ').slice(0, cells); + st.forEach((S, i) => { S.target = ci(w[i]); run(S); }); }; - const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); }; + 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)); - paint(); - return { el: f, get: () => idx, set, next: () => 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); }); + return { + el: f, get: () => idx, set, next: () => set(idx + 1), + setText, chars, reading: () => st.map(S => chars[S.cur]).join(''), + }; }; /* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); @@ -4868,15 +4914,19 @@ const GW_CSS = ` .batt .cell.warn.on{background:linear-gradient(180deg,var(--fail),#a04a34)} .batt .nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0} -/* split-flap */ +/* split-flap — four clipped half-panels per cell; the fold happens on + .ftc (falls) and .fbn (lands), driven by the builder's WAAPI flips */ .flap{display:inline-flex;gap:4px;cursor:pointer} -.flapd{width:30px;height:42px;border-radius:5px;position:relative;overflow:hidden; - background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5)} -.flapd b{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream); - text-shadow:0 1px 2px rgba(0,0,0,.7)} -.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)} -.flapd.flip b{animation:flipdrop .3s ease-in} -@keyframes flipdrop{0%{transform:translateY(-42%) scaleY(.4);opacity:.3}100%{transform:none;opacity:1}} +.flapd{width:30px;height:42px;border-radius:5px;position:relative; + background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5); + perspective:220px;transform-style:preserve-3d} +.flapd .fh{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream); + text-shadow:0 1px 2px rgba(0,0,0,.7);backface-visibility:hidden;transform-origin:50% 50%} +.flapd .ftn,.flapd .ftc{clip-path:inset(0 0 50% 0)} +.flapd .fbc,.flapd .fbn{clip-path:inset(50% 0 0 0)} +.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)} /* 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 905d758..7793c6f 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -873,6 +873,94 @@ try { })()`); ok('R58 ignores keys when unfocused', ixBlur === 'ok', ixBlur); + // 12. N20 split-flap — the flip is a mechanism, not a fade: cells step + // through the declared charset one flap at a time, retarget cleanly, and + // animate:false jumps straight to the target. + const flapApi = await evl(`(() => { + const h = document.querySelector('#card-N20').gw; + return { reading: typeof h.reading, chars: typeof h.chars, set: typeof h.set }; + })()`); + ok('N20 handle exposes reading() and chars', flapApi.reading === 'function' && flapApi.chars === 'string', + JSON.stringify(flapApi)); + + // 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); + + // 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 + window.__n20.n = 0; + window.__n20.timer = setInterval(() => { + window.__n20.samples.push(h.reading()); + // Every 4th tick (keeps the callback light), count only WAAPI-driven + // animations: a CSSAnimation carries animationName, a WAAPI one doesn't, + // so the old flipdrop class swap can't satisfy this. + if (++window.__n20.n % 4 === 0 && + document.querySelector('#card-N20').getAnimations({ subtree: true }).some(a => !a.animationName)) + window.__n20.anims++; + }, 15); + })()`); + await sleep(4200); + 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'] }; + })()`); + ok('N20 cascade arrives at the target word', n20.final.trim() === 'LINK', 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.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. + 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++) { + const a = n20.chars.indexOf(seq[i - 1]), b = n20.chars.indexOf(seq[i]); + if (b !== (a + 1) % n20.chars.length) stepped = seq[i - 1] + '->' + seq[i] + ' is not one flap'; + } + ok('N20 cell 0 steps one flap at a time in charset order', stepped === 'ok', stepped); + } + + // Retarget mid-cascade: aim at word 2, then word 3 while still spinning; must land on 3. + await evl(`(() => { + 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); + })()`); + await sleep(4600); + 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'), + 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 no cascade left running after arrival', n20b.anims === 0, 'live anims ' + n20b.anims); + + // 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 }); + h.set(1); + const r = h.reading(); el.remove(); return r; + })()`); + ok('N20 animate:false jumps straight to the target', n20c.trim() === 'LINK', n20c); + + // Unpark the ticker and restore the demo state it expects. + await evl(`(() => { IH.flap = window.__realFlap; IH.flap.set(0); })()`); + // late exceptions from interactions const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions after interaction', errs2.length === 0); @@ -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. +*** 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. + *** 2026-07-12 Sun @ 12:59:48 -0500 Added the card size toggle (1x/2x/3x, default 3x) Masthead size chips apply CSS zoom per grid; 2x/3x drop the 1320px wrap cap so wide monitors get the room. CDP-verified: 84 cards, no exceptions, fader drag and toggle click both track at 3x (drag helpers are rect-ratio based, so zoom is transparent to them). *** 2026-07-12 Sun @ 13:42:29 -0500 Retuned the scale split per Craig |
