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 /tests | |
| 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.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 88 |
1 files changed, 88 insertions, 0 deletions
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); |
