aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 07:56:44 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 07:56:44 -0500
commit3da976bd0287ebb632b0d8475f2e780d1b10e1b8 (patch)
tree6c2d69e2c4e6a1db5da24c8ca97206ad7fe31157
parent4d8e8f5fc88fb09cb498b1a0a3e572aaa6e771ab (diff)
downloadarchsetup-3da976bd0287ebb632b0d8475f2e780d1b10e1b8.tar.gz
archsetup-3da976bd0287ebb632b0d8475f2e780d1b10e1b8.zip
feat(gallery): white-ink default, 85ms base rate, ID badges as anchors
The split-flap's default skin becomes the white ink, listed first in the chip row, with the kit cream one click away. The base flap rate moves to 85ms, and the probe pins both defaults so a drive-by change can't silently flip them. Every card's ID badge is now a real anchor to its own card, so a click deep-links the URL and a right-click copies it. One shared rule keeps the badge look. The refactor sweep for extraction-readiness closes out: the flip duration and the ms clamp each live in one place, grid normalization is one helper shared by setText and the silent first paint (which now records itself as the last commanded grid, ending the null fallback), the state declarations sit above everything that references them (the same declared-later shape that caused a load crash earlier in the branch), and a stale two-skin comment caught up with the four skins. Behavior-preserving: all 79 probe checks stay green.
-rw-r--r--docs/prototypes/panel-widget-gallery.html11
-rw-r--r--docs/prototypes/widgets.js50
-rw-r--r--tests/gallery-probes/probe.mjs30
3 files changed, 55 insertions, 36 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index b8e70bc..5db6fab 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -190,6 +190,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1}
/* N20's chips are miniature flap cells: the board colour comes from the rig's
inline dot; the letter shows the actual ink (skins) or the actual face (fonts) */
+a.no{text-decoration:none;cursor:pointer}
#card-N20 .wrd{display:none}
#card-N20 .famchips .fc{width:17px;height:20px;border-radius:4px;position:relative;border:1px solid #4a453c}
#card-N20 .famchips .fc::before{content:"";position:absolute;left:1px;right:1px;top:50%;height:1px;background:rgba(128,128,128,.35)}
@@ -1237,7 +1238,7 @@ function card(host, no, name, html, note){
const polTip=meta?(meta.gist||''):'policy not yet classified';
const c=document.createElement('div'); c.className='card'; c.id='card-'+no;
c.dataset.cpol=kind||'none';
- c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}`+
+ c.innerHTML=`<div class="wname"><a class="no" href="#card-${no}">${no}</a>${name}`+
`<span class="cpol" data-pol="${kind||'none'}" data-free="${kind?free:''}" title="${polTip.replace(/"/g,'&quot;')}">${kind||'—'}</span>`+
`<span class="vlamp" data-v="off"></span></div>`+
`<div class="stagew">${isBuild?'':html}</div><div class="wrd" id="rd-${no}">—</div>`+
@@ -1577,8 +1578,8 @@ card(I,'26','Nixie tube',
(st,rd)=>GW.nixie(st,{onChange:(v,t)=>rd(t)}),
'<b>a single warm-glowing numeral.</b> One lit digit per tube, leading zeros dark. Click to increment the count.');
card(I,'N20','Split-flap display',
- (st)=>GW.splitFlap(st,{rows:3,cells:6,words:['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','STATUS','FILTER','TUNING','NEEDLE','SWITCH','TOGGLE','DIMMER','CHROME','MODULE','OUTPUT','SCREEN','COPPER','DYNAMO','PISTON','MAGNET','BEACON','RADIAL','BRIDGE'],animate:!reduced}),
- '<b>flips to the new value.</b> A three-row, six-cell board; every advance sends each row to a different random word from the pool, and each cell runs at its own mechanical rate, so letters and whole words finish at different times. After settling it dwells two seconds, then advances. Click to advance now. Skins: cream-on-black (default), true white ink, the ivory board, or white cards with dark lettering; face in Berkeley Mono or Helvetica. The slider sets the flap rate.');
+ (st)=>GW.splitFlap(st,{rows:3,cells:6,words:['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','STATUS','FILTER','TUNING','NEEDLE','SWITCH','TOGGLE','DIMMER','CHROME','MODULE','OUTPUT','SCREEN','COPPER','DYNAMO','PISTON','MAGNET','BEACON','RADIAL','BRIDGE'],animate:!reduced,skin:'white'}),
+ '<b>flips to the new value.</b> A three-row, six-cell board; every advance sends each row to a different random word from the pool, and each cell runs at its own mechanical rate, so letters and whole words finish at different times. After settling it dwells two seconds, then advances. Click to advance now. Skins: white ink on black (default), the kit cream, the ivory board, or white cards with dark lettering; face in Berkeley Mono or Helvetica. The slider sets the flap rate.');
card(I,'N21','Seven-segment display',
(st,rd)=>GW.sevenSeg(st,{onChange:(v,t)=>rd(t)}),
'<b>a lit-segment number.</b> Green segments, distinct from the nixie glow. Live countdown; click to add a minute.');
@@ -1828,10 +1829,10 @@ function styleChips(no,STYLES,AXES,PRESETS,defPreset){
groups.forEach(g=>row.appendChild(g));
cardEl.querySelector('.opts').appendChild(row);
}
-styleChips('N20',GW.splitFlap.STYLES,[['skin','skin','dark'],['font','font','mono']]);
+styleChips('N20',GW.splitFlap.STYLES,[['skin','skin','white'],['font','font','mono']]);
(()=>{const h=$('card-N20').gw,row=document.querySelector('#card-N20 .famchips');if(!h||!row)return;
const gr=document.createElement('span');gr.className='fgroup';
- gr.innerHTML='<span class="lab">flap</span><input type="range" min="30" max="160" value="70" style="width:70px;accent-color:var(--gold);vertical-align:middle"><span class="lab fms">70ms</span>';
+ gr.innerHTML='<span class="lab">flap</span><input type="range" min="30" max="160" value="85" style="width:70px;accent-color:var(--gold);vertical-align:middle"><span class="lab fms">85ms</span>';
const sl=gr.querySelector('input'),ms=gr.querySelector('.fms');
sl.addEventListener('input',()=>{h.setFlapMs(+sl.value);ms.textContent=sl.value+'ms';});
row.appendChild(gr);})();
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index e11862c..0126949 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -3659,9 +3659,9 @@ GW.nixie = function (host, opts = {}) {
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
+ flapMs (85, 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');
+ skin ('white'|'dark'|'light'|'paper', card default white); 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
@@ -3679,7 +3679,8 @@ GW.splitFlap = function (host, opts = {}) {
const rows = opts.rows || 1;
const cells = opts.cells || 4;
const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
- let flapMs = Math.min(400, Math.max(20, opts.flapMs || 70));
+ const clampMs = ms => Math.min(400, Math.max(20, ms | 0));
+ let flapMs = clampMs(opts.flapMs || 85);
const ci = ch => Math.max(0, chars.indexOf(ch));
const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w);
@@ -3692,7 +3693,14 @@ GW.splitFlap = function (host, opts = {}) {
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
+ let idx = 0; // last set() index; -1 after a scramble
+ let lastGrid = ''; // the last commanded grid — the demo picks against
+ // commands, not in-flight glyphs mid-cascade
const allSettled = () => st.every(S => !S.running && S.cur === S.target);
+ const fitGrid = str => Array.from({ length: rows },
+ (_, r) => fit(String(str).split('\n')[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');
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>';
@@ -3726,11 +3734,10 @@ GW.splitFlap = function (host, opts = {}) {
S.ftn.textContent = chars[nk]; S.fbn.textContent = chars[nk];
if (animate) {
try {
+ const flip = { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' };
await Promise.all([
- S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }],
- { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished,
- S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }],
- { duration: Math.round(flapMs * S.jitter), easing: 'ease-in' }).finished,
+ S.ftc.animate([{ transform: 'rotateX(0deg)' }, { transform: 'rotateX(-180deg)' }], flip).finished,
+ S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], flip).finished,
]);
} catch { break; /* cancelled (card torn down mid-flip) */ }
}
@@ -3757,8 +3764,7 @@ GW.splitFlap = function (host, opts = {}) {
};
const setText = str => {
announced = false;
- const ls = String(str).split('\n');
- lastGrid = Array.from({ length: rows }, (_, r) => fit(ls[r] || '', cells)).join('\n');
+ lastGrid = fitGrid(str);
// 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.
@@ -3766,18 +3772,18 @@ GW.splitFlap = function (host, opts = {}) {
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];
- });
+ const silent = str => {
+ lastGrid = fitGrid(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;
- let lastGrid = null; // the last commanded grid — scramble picks against
- // commands, not in-flight glyphs mid-cascade
- const rowWords = () => (lastGrid || reading()).split('\n');
+ const rowWords = () => lastGrid.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);
@@ -3793,8 +3799,8 @@ GW.splitFlap = function (host, opts = {}) {
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.
+ // construction axes (skin, font): swap this axis's classes per STYLES.
+ // Polarity and typeface are construction, not accent — no colour policy.
const setStyle = (axis, name) => {
const ax = GW.splitFlap.STYLES[axis]; const o = ax && ax[name];
if (!o) return;
@@ -3806,12 +3812,10 @@ GW.splitFlap = function (host, opts = {}) {
// 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: scramble, setStyle,
setText, chars, reading, onSettle: cb => settleCbs.push(cb),
- flapMs: () => flapMs, setFlapMs: ms => { flapMs = Math.min(400, Math.max(20, ms | 0)); },
+ flapMs: () => flapMs, setFlapMs: ms => { flapMs = clampMs(ms); },
};
};
GW.splitFlap.STYLES = {
@@ -3820,8 +3824,8 @@ GW.splitFlap.STYLES = {
// 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' },
+ dark: { cls: '', dot: '#141210' },
light: { cls: 'flap-light', dot: '#f3e7c5' },
paper: { cls: 'flap-paper', dot: '#ffffff' },
},
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index 7b9ba5e..051d575 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -1000,28 +1000,29 @@ try {
const h = document.querySelector('#card-N20').gw;
if (typeof h.setStyle !== 'function' || !GW.splitFlap.STYLES || !GW.splitFlap.STYLES.skin) return 'no skin axis';
const skins = Object.keys(GW.splitFlap.STYLES.skin);
+ if (skins[0] !== 'white') return 'white must list first, got: ' + skins.join(',');
if (!['dark','white','light','paper'].every(k => skins.includes(k))) return 'skins are: ' + skins.join(',');
const ink = () => getComputedStyle(document.querySelector('#card-N20 .fh')).color;
const lum = c => { const m = c.match(/[0-9]+/g).map(Number); return m[0] + m[1] + m[2]; };
- const dark = ink();
- h.setStyle('skin', 'white');
- const white = ink();
+ const white = ink(); // the card's default skin is white ink
+ if (white !== 'rgb(255, 255, 255)') return 'default ink is not white: ' + white;
+ h.setStyle('skin', 'dark');
+ const cream = ink();
h.setStyle('skin', 'light');
const light = ink();
const gotClass = document.querySelector('#card-N20 .flap').classList.contains('flap-light');
h.setStyle('skin', 'paper');
const paper = ink();
const paperClass = document.querySelector('#card-N20 .flap').classList.contains('flap-paper');
- h.setStyle('skin', 'dark');
+ h.setStyle('skin', 'white');
const back = ink();
if (!gotClass) return 'light skin class missing';
if (!paperClass) return 'paper skin class missing';
- if (white !== 'rgb(255, 255, 255)') return 'white skin ink is ' + white;
- if (!(lum(dark) > 400 && lum(dark) < 760 && lum(light) < 300 && lum(paper) < 300)) return 'inks wrong: ' + dark + ' / ' + light + ' / ' + paper;
- if (back !== dark) return 'did not restore dark: ' + back;
+ if (!(lum(cream) > 400 && lum(cream) < 760 && lum(light) < 300 && lum(paper) < 300)) return 'inks wrong: ' + cream + ' / ' + light + ' / ' + paper;
+ if (back !== white) return 'did not restore the white default: ' + back;
return 'ok';
})()`);
- ok('N20 skins: cream dark, white ink, ivory board, white board — all switch and restore', n20e === 'ok', n20e);
+ ok('N20 skins: white default first, cream, ivory board, white board — all switch and restore', n20e === 'ok', n20e);
const n20fnt = await evl(`(() => {
const h = document.querySelector('#card-N20').gw;
@@ -1057,6 +1058,7 @@ try {
const h = document.querySelector('#card-N20').gw;
if (typeof h.setFlapMs !== 'function' || typeof h.flapMs !== 'function') return 'no speed api';
const before = h.flapMs();
+ if (before !== 85) return 'default flap rate is ' + before + ', want 85';
h.setFlapMs(120);
if (h.flapMs() !== 120) return 'setFlapMs did not take: ' + h.flapMs();
const slider = document.querySelector('#card-N20 input[type=range]');
@@ -1117,6 +1119,18 @@ try {
ok('N20 rests under reduced motion (no self-advancing dwell chain)',
rm1 === rm2.r && rm2.anims === 0, JSON.stringify({ before: rm1, after: rm2.r, anims: rm2.anims }));
+ // Every card's ID badge is its own anchor: click it and the URL carries
+ // the card's deep link.
+ const anchors = await evl(`(() => {
+ const bad = [...document.querySelectorAll('.card .wname .no')]
+ .filter(n => n.tagName !== 'A' || n.getAttribute('href') !== '#' + n.closest('.card').id)
+ .map(n => n.textContent);
+ const count = document.querySelectorAll('.card .wname a.no').length;
+ if (bad.length) return 'not anchors: ' + bad.slice(0, 5).join(' ');
+ return count === document.querySelectorAll('.card').length ? 'ok' : 'anchor count ' + count;
+ })()`);
+ ok('every card ID badge links to its own anchor', anchors === 'ok', anchors);
+
// late exceptions from interactions
const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
ok('no exceptions after interaction', errs2.length === 0);