aboutsummaryrefslogtreecommitdiff
path: root/tests
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 /tests
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.
Diffstat (limited to 'tests')
-rw-r--r--tests/gallery-probes/probe.mjs88
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);