aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes
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 /docs/prototypes
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 'docs/prototypes')
-rw-r--r--docs/prototypes/panel-widget-gallery.html2
-rw-r--r--docs/prototypes/widgets.js98
2 files changed, 75 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;