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 /docs/prototypes | |
| 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 'docs/prototypes')
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 2 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 98 |
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; |
