aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
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;