aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 07:51:43 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 07:51:43 -0500
commit4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab (patch)
tree96b992eaf7443050b5e932e66662b74223c257c5 /docs/prototypes/widgets.js
parent872813892e98d9cc7b8253b02cf9a595b144325a (diff)
downloadarchsetup-4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab.tar.gz
archsetup-4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab.zip
feat(gallery): grow the split-flap into a randomized multi-row board with skins, faces and a flap-rate slider
The N20 card becomes a three-row, six-cell departure board. Every advance sends each row to a completely different word, drawn at random from a 24-word pool with rows kept mutually distinct, and each cell runs at its own mechanical rate (a fixed 0.8x-1.35x of the base flap time), so letters and whole words finish at genuinely different times. A slider on the card sets the base rate (setFlapMs on the handle). The board advances only after it has settled and dwelt two seconds on the reading: the builder announces settle once per command (onSettle) and the page schedules the next advance from that signal, since a fixed-interval ticker would retarget mid-cascade and the panel would never rest. The chain stays gated off under reduced motion, where the board paints once and rests. Customization lands as two construction axes on the chip rig: skin (cream-on-black default, true-white ink, the ivory board, or white cards with dark lettering) and face (Berkeley Mono or Helvetica, the grotesque real boards wore). The skin chips are miniature flap cells, a letter in the skin's real ink on its real board colour, and the font chips letter themselves in their actual face, so every choice previews itself. The flap-rate slider rides in the chips row, and the card's amber word readout is gone: the board is the display. The deterministic seam for callers and tests is setText; randomness lives only in the demo path (next/scramble), which re-aims against the last commanded grid rather than in-flight glyphs. The settle work caught a real ordering bug: setText assigned each cell's target and ran it in one pass, so a cell finishing synchronously saw its neighbours' stale, already-satisfied targets and announced a partial board as settled. Targets now all land before any cell runs. Review caught two more: the dwell chain had escaped the reduced-motion gate (the board self-advanced every two seconds forever under reduced motion), and the skin chips rendered blank because the STYLES entries were strings where the rig paints an object's dot swatch. Since this is heading for extraction as a regular component, the builder got the full refactor pass: a contract comment covering every opt and the handle surface, small named helpers, no page globals, and all CSS in the one split-flap block. The same bar is filed as a sweep task for every other builder. Probe coverage grows to 78 checks: the reduced-motion rest, the live dwell measurement, the scramble contract, both style axes, the speed api and the swatch legibility.
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js170
1 files changed, 148 insertions, 22 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index c5ce2c7..e11862c 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -3649,38 +3649,72 @@ GW.nixie = function (host, opts = {}) {
/* 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
+ cells arrive staggered by travel distance. setText() mid-cascade re-aims
+ the running cells; 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. */
+ bottom lands (180 -> 0), backfaces hidden, crease preserved. First paint
+ is silent (no cascade on load), like the original. The page drives next()
+ on its own cadence.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: rows (1) x cells (4) grid; chars (flap order, default space+A-Z+0-9);
+ flapMs (70, per-cell rate jittered 0.8x-1.35x so letters finish
+ at different times, base mutable via setFlapMs); animate (true);
+ skin ('dark'|'white'|'light'|'paper'); font ('mono'|'helv');
+ words (the demo pool, at least rows+1 entries: next() sends every
+ row to a different random word, mutually distinct; set(i) pins the
+ top row for determinism); onChange(idx|-1, top word) fires at
+ command time. get() is the last set() index, -1 after a scramble.
+ handle: el, get(), set(i), next(), setText(multi-line string),
+ reading() (displayed grid, rows joined with newline),
+ setStyle(axis, name), chars, flapMs()/setFlapMs(ms) (base flap
+ rate, clamped 20-400), onSettle(cb) — cb(reading) fires once per
+ command when every cell has arrived (dwell hook).
+ CSS lives in the "split-flap" block of GW_CSS; no other styles involved. */
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 rows = opts.rows || 1;
const cells = opts.cells || 4;
const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
- const flapMs = opts.flapMs || 70;
- const f = document.createElement('span'); f.className = 'flap';
+ let flapMs = Math.min(400, Math.max(20, opts.flapMs || 70));
+
+ const ci = ch => Math.max(0, chars.indexOf(ch));
+ const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w);
+ const wordAt = k => words[((k % words.length) + words.length) % words.length];
+
// 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 st = []; // row-major cell states
+ const settleCbs = []; if (opts.onSettle) settleCbs.push(opts.onSettle);
+ let announced = true; // no announcement for the silent first paint
+ const allSettled = () => st.every(S => !S.running && S.cur === S.target);
+ const makeCell = () => {
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,
+ // Each cell's mechanism has its own character: a fixed per-cell jitter
+ // (0.8x-1.35x of the base rate) so letters finish at genuinely
+ // different times, beyond what travel distance alone staggers.
+ jitter: 0.8 + 0.55 * Math.random(),
ftn: d.querySelector('.ftn'), fbc: d.querySelector('.fbc'),
ftc: d.querySelector('.ftc'), fbn: d.querySelector('.fbn'),
});
+ return d;
+ };
+ const f = document.createElement('span'); f.className = 'flap' + (rows > 1 ? ' flap-rows' : '');
+ for (let r = 0; r < rows; r++) {
+ const line = rows > 1 ? document.createElement('span') : f;
+ if (rows > 1) { line.className = 'flapline'; f.appendChild(line); }
+ for (let c = 0; c < cells; c++) line.appendChild(makeCell());
}
host.appendChild(f);
- 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 => {
@@ -3694,9 +3728,9 @@ GW.splitFlap = function (host, opts = {}) {
try {
await Promise.all([
S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }],
- { duration: flapMs, easing: 'ease-in' }).finished,
+ { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished,
S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }],
- { duration: flapMs, easing: 'ease-in' }).finished,
+ { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished,
]);
} catch { break; /* cancelled (card torn down mid-flip) */ }
}
@@ -3704,22 +3738,99 @@ GW.splitFlap = function (host, opts = {}) {
S.cur = nk;
}
S.running = false;
+ maybeSettle();
})();
};
+ // Announce once per command, when the whole board has arrived. Consumers
+ // use it to dwell on the settled reading before commanding the next update.
+ const maybeSettle = () => {
+ if (announced || !allSettled()) return;
+ announced = true;
+ const r = reading();
+ settleCbs.forEach(cb => cb(r));
+ };
+
+ // Map a multi-line string onto the grid; missing lines/cells pad with space.
+ const gridTargets = str => {
+ const lines = String(str).split('\n');
+ return st.map((S, i) => ci(fit(lines[(i / cells) | 0] || '', cells)[i % cells]));
+ };
const setText = str => {
- const w = String(str).padEnd(cells, ' ').slice(0, cells);
- st.forEach((S, i) => { S.target = ci(w[i]); run(S); });
+ announced = false;
+ const ls = String(str).split('\n');
+ lastGrid = Array.from({ length: rows }, (_, r) => fit(ls[r] || '', cells)).join('\n');
+ // Assign every target before running any cell: a cell that finishes
+ // synchronously (animate:false) must not see its neighbours' stale,
+ // already-satisfied targets and announce a partial board as settled.
+ gridTargets(str).forEach((k, i) => { st[i].target = k; });
+ st.forEach(run);
+ maybeSettle(); // covers a command that changes nothing
};
+ const silent = str => gridTargets(str).forEach((k, i) => {
+ const S = st[i]; S.cur = S.target = k;
+ S.ftn.textContent = S.fbc.textContent = S.ftc.textContent = S.fbn.textContent = chars[k];
+ });
+
+ // The words demo: every advance sends each row to a completely different
+ // word, drawn at random from the pool; rows stay mutually distinct. set(i)
+ // pins the top row (deterministic callers, tests); next() scrambles all.
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));
- // 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); });
+ let lastGrid = null; // the last commanded grid — scramble picks against
+ // commands, not in-flight glyphs mid-cascade
+ const rowWords = () => (lastGrid || reading()).split('\n');
+ const pickNew = taken => {
+ const open = words.filter(w => !taken.includes(fit(w, cells)));
+ return fit(open[(Math.random() * open.length) | 0] || wordAt(0), cells);
+ };
+ const gridFor = top => {
+ const out = [];
+ const prev = rowWords();
+ for (let r = 0; r < rows; r++)
+ out.push(r === 0 && top !== null ? fit(top, cells) : pickNew(out.concat(prev[r])));
+ return out.join('\n');
+ };
+ const set = i => { idx = ((i % words.length) + words.length) % words.length; setText(gridFor(wordAt(idx))); onChange(idx, wordAt(idx).trim()); };
+ const scramble = () => { idx = -1; const g = gridFor(null); setText(g); onChange(-1, g.split('\n')[0].trim()); };
+ f.addEventListener('click', scramble);
+
+ // skin: flap polarity — dark (cream on black, the default) or the inverse
+ // light board (black on cream). A construction axis, not an accent.
+ const setStyle = (axis, name) => {
+ const ax = GW.splitFlap.STYLES[axis]; const o = ax && ax[name];
+ if (!o) return;
+ Object.values(ax).forEach(v => { if (v.cls) f.classList.remove(v.cls); });
+ if (o.cls) f.classList.add(o.cls);
+ };
+ if (opts.skin) setStyle('skin', opts.skin);
+ if (opts.font) setStyle('font', opts.font);
+
+ // first paint: consecutive pool words, silent (no cascade on load)
+ silent(Array.from({ length: rows }, (_, r) => fit(wordAt(r), cells)).join('\n'));
+ const reading = () => Array.from({ length: rows },
+ (_, r) => st.slice(r * cells, (r + 1) * cells).map(S => chars[S.cur]).join('')).join('\n');
return {
- el: f, get: () => idx, set, next: () => set(idx + 1),
- setText, chars, reading: () => st.map(S => chars[S.cur]).join(''),
+ el: f, get: () => idx, set, next: scramble, setStyle,
+ setText, chars, reading, onSettle: cb => settleCbs.push(cb),
+ flapMs: () => flapMs, setFlapMs: ms => { flapMs = Math.min(400, Math.max(20, ms | 0)); },
};
};
+GW.splitFlap.STYLES = {
+ // board polarity + ink: dark board with the kit's cream ink (default),
+ // dark board with true-white ink, or the inverse ivory board
+ // dot: the board colour; the gallery card letters each chip in the skin's
+ // ink so the swatch is a miniature flap cell, not an ambiguous colour dot
+ skin: {
+ dark: { cls: '', dot: '#141210' },
+ white: { cls: 'flap-white', dot: '#141210' },
+ light: { cls: 'flap-light', dot: '#f3e7c5' },
+ paper: { cls: 'flap-paper', dot: '#ffffff' },
+ },
+ // typeface: the kit's Berkeley Mono, or the grotesque real boards wore
+ font: {
+ mono: { cls: '', dot: '#0d0c0b' },
+ helv: { cls: 'flap-helv', dot: '#0d0c0b' },
+ },
+};
/* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick();
click adds a minute */
@@ -4927,6 +5038,21 @@ const GW_CSS = `
.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)}
+/* multi-row board: column of flap lines */
+.flap-rows{flex-direction:column;gap:5px}
+.flapline{display:inline-flex;gap:4px}
+/* white skin — the dark board with true-white ink (the default ink is cream) */
+.flap-white .flapd .fh{color:#fff}
+/* helvetica — the grotesque face real Solari boards wore */
+.flap-helv .flapd .fh{font-family:Helvetica,'Helvetica Neue',Arial,sans-serif;font-weight:600}
+/* paper skin — true-white cards, dark lettering */
+.flap-paper .flapd{background:linear-gradient(180deg,#ffffff,#e6e6e3);border-color:#b6b6b2}
+.flap-paper .flapd .fh{color:#17181a;text-shadow:0 1px 0 rgba(255,255,255,.5)}
+.flap-paper .flapd::after{background:rgba(0,0,0,.28);box-shadow:0 1px 0 rgba(255,255,255,.35)}
+/* light skin — the inverse board: ivory flaps, near-black ink, softer crease */
+.flap-light .flapd{background:linear-gradient(180deg,#f7eed3,#dccfa8);border-color:#a89c7e}
+.flap-light .flapd .fh{color:#1b1813;text-shadow:0 1px 0 rgba(255,255,255,.45)}
+.flap-light .flapd::after{background:rgba(0,0,0,.3);box-shadow:0 1px 0 rgba(255,255,255,.3)}
/* seven-segment */
.seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer;