aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 04:32:39 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 04:32:39 -0500
commit872813892e98d9cc7b8253b02cf9a595b144325a (patch)
treedc6ff1fe57e3a3a7d45a9bc9c184f3f517677f8d
parent5e664af75797cce4e480bed8b71e52e6192a2d57 (diff)
downloadarchsetup-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.html2
-rw-r--r--docs/prototypes/widgets.js98
-rw-r--r--tests/gallery-probes/probe.mjs88
-rw-r--r--todo.org3
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);
diff --git a/todo.org b/todo.org
index f548e03..8a52341 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.
+*** 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