aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-16 09:11:29 -0500
committerCraig Jennings <c@cjennings.net>2026-07-16 09:11:29 -0500
commit6189466dd21f3e19b6bdae9bf74e38f6211ae542 (patch)
tree0ca48eb533345dcd71a2e8933b21070cf1f5bd56
parent910993b25ba1736fc7f5958c53a7cdc523f98cf2 (diff)
downloadarchsetup-6189466dd21f3e19b6bdae9bf74e38f6211ae542.tar.gz
archsetup-6189466dd21f3e19b6bdae9bf74e38f6211ae542.zip
feat(gallery): rework the console-key and slide-toggle colour vocabulary
Console keys read SCAN before LIVE, and LIVE is green. The palette already defines --pass as "run lamps, gear greens, monitor bars, LIVE lamps", so the widget contradicted the language it draws from. Its note asserted the old colours too. The per-key `red` boolean became a `tone` field, since a boolean can't express three states. The slide toggle gains red and warn on its `on` axis, so the engaged state can be the notable one (mute, record, airplane mode). Red was reachable only on the `off` axis before, which paints the disengaged state: the opposite claim. A new onText axis makes the ON ink reachable at all. `on` had bundled background and ink together, so "dark pill, green legend" couldn't be expressed. Presets name a combination (panel, run, armed, caution, dark), which is what a consumer reaches for. The axes are how one gets built.
-rw-r--r--docs/prototypes/panel-widget-gallery.html44
-rw-r--r--docs/prototypes/widgets.js84
-rw-r--r--tests/gallery-probes/probe.mjs140
3 files changed, 251 insertions, 17 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 2a2910b..e64ddd3 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -1187,7 +1187,7 @@ card(C,'01','Slide toggle',
'<b>on / off, the touchscreen way.</b> The one born-digital control in the kit — the smartphone pill toggle the live net/bt/audio panels use. Click to flip; readout shows the state. Physical cousins: the bat-handle (R03) and the rocker (N01).');
card(C,'02','Console key',
(st,rd)=>GW.consoleKeys(st,{onChange:(i,t)=>rd(t)}),
- '<b>physical push button.</b> Mutually exclusive — click one to engage. Gold = live, terracotta = muted.');
+ '<b>physical push button.</b> Mutually exclusive — click one to engage. Green = live, terracotta = muted, gold = any other engaged key.');
card(C,'03','Horizontal fader',
(st,rd)=>GW.faderH(st,{value:68,onChange:(v,t)=>rd(t)}),
'<b>continuous 0-100.</b> Per-device volume, brightness. Drag the gold cap; readout tracks.');
@@ -1657,25 +1657,57 @@ BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no);
m=>ids.has(m)?`<a class="xref" href="#card-${m}">${m}</a>`:m);});})();
/* style chips: shared demo rig for builders with constructor style opts —
- reads a builder's STYLES table and drives setStyle on the live card instance */
-function styleChips(no,STYLES,AXES){
+ reads a builder's STYLES table and drives setStyle on the live card instance.
+ PRESETS (optional) adds a leading group of named combinations: a preset chip
+ applies all axes at once and resyncs the axis chips beneath it, and changing
+ any axis afterwards clears the preset — the chips must never claim a preset
+ the widget has since diverged from. */
+function styleChips(no,STYLES,AXES,PRESETS,defPreset){
const h=$('card-'+no)?.gw; if(!h)return;
const cardEl=h.el.closest('.card');
const row=document.createElement('div'); row.className='famchips';
+ const sel={}, sync={}, groups=[]; let clearPreset=()=>{};
+ /* Re-apply every axis on any change rather than just the one clicked: axes are
+ not independent (onText overrides the ink `on` sets), so a lone setStyle can
+ drop a selection the chips still show as lit — the card would then display a
+ combination the widget isn't in. Applying all of them in AXES order makes
+ the outcome independent of click order. */
+ const applyAll=()=>{for(const [,axis] of AXES) if(sel[axis]) h.setStyle(axis,sel[axis]);};
for(const [label,axis,def] of AXES){
+ sel[axis]=def;
const g=document.createElement('span'); g.className='fgroup';
const lab=document.createElement('span'); lab.className='lab'; lab.textContent=label; g.appendChild(lab);
const chips=[];
for(const [name,o] of Object.entries(STYLES[axis])){
const b=document.createElement('span'); b.className='fc'+(name===def?' on':'');
b.style.background=o.dot; b.title=name; chips.push(b);
- b.addEventListener('click',()=>{h.setStyle(axis,name);
- chips.forEach(x=>x.classList.toggle('on',x===b));});
+ b.addEventListener('click',()=>{sel[axis]=name; applyAll();
+ chips.forEach(x=>x.classList.toggle('on',x===b)); clearPreset();});
g.appendChild(b);}
+ sync[axis]=name=>{sel[axis]=name; chips.forEach(x=>x.classList.toggle('on',x.title===name));};
+ groups.push(g);}
+ if(PRESETS&&h.setPreset){
+ const g=document.createElement('span'); g.className='fgroup';
+ const lab=document.createElement('span'); lab.className='lab'; lab.textContent='preset'; g.appendChild(lab);
+ const chips=[];
+ for(const [name,p] of Object.entries(PRESETS)){
+ const b=document.createElement('span'); b.className='fc'+(name===defPreset?' on':''); b.title=name;
+ /* the on-tone is the preset's identity, so it doubles as the swatch */
+ b.style.background=STYLES.on[p.on].dot; chips.push(b);
+ b.addEventListener('click',()=>{
+ const applied=h.setPreset(name); if(!applied)return;
+ chips.forEach(x=>x.classList.toggle('on',x===b));
+ for(const [axis,style] of Object.entries(applied)) sync[axis]?.(style);});
+ g.appendChild(b);}
+ clearPreset=()=>chips.forEach(x=>x.classList.remove('on'));
row.appendChild(g);}
+ groups.forEach(g=>row.appendChild(g));
cardEl.querySelector('.opts').appendChild(row);
}
-styleChips('01',GW.slideToggle.STYLES,[['on','on','amber'],['off','off','dark'],['off text','offText','white'],['thumb','thumb','light']]);
+styleChips('01',GW.slideToggle.STYLES,
+ [['on','on','amber'],['on text','onText','panel'],['off','off','dark'],
+ ['off text','offText','white'],['thumb','thumb','light']],
+ GW.slideToggle.PRESETS,'panel');
styleChips('R05',GW.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]);
styleChips('06',GW.segmented.STYLES,[['accent','accent','amber']]);
styleChips('25',GW.slideRule.STYLES,[['face','skin','warm']]);
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index c12e5b7..147c782 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -126,30 +126,77 @@ GW.slideToggle = function (host, opts = {}) {
if (!o) return;
for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v);
};
- setStyle('on', opts.onStyle || 'amber');
- setStyle('off', opts.offStyle || 'dark');
- setStyle('offText', opts.offText || 'white');
- setStyle('thumb', opts.thumb || 'light');
+ /* opts.preset names an intent; per-axis opts still win over it, so a caller can
+ take 'armed' and swap only the thumb. setPreset re-applies all four axes at
+ once and returns the axis map so a host UI can resync its own controls. */
+ const preset = GW.slideToggle.PRESETS[opts.preset] || GW.slideToggle.PRESETS.panel;
+ /* AXIS_ORDER is load-bearing, not cosmetic: onText overrides the ink that the
+ `on` style sets, so applying the axes in any other order silently drops it. */
+ const setPreset = name => {
+ const p = GW.slideToggle.PRESETS[name];
+ if (!p) return null;
+ for (const axis of GW.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]);
+ return p;
+ };
+ const pick = { on: opts.onStyle, onText: opts.onText, off: opts.offStyle,
+ offText: opts.offText, thumb: opts.thumb };
+ for (const axis of GW.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]);
let on;
const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); };
sw.addEventListener('click', () => set(!on));
set(opts.on !== undefined ? opts.on : true);
- return { el: sw, get: () => on, set, setStyle };
+ return { el: sw, get: () => on, set, setStyle, setPreset };
+};
+/* named presets — a combination that carries intent, which is what a consumer
+ actually reaches for; the STYLES axes below are how one is built. The `on`
+ tone does the semantic work and the thumb material backs it: a touchscreen
+ pill wears plastic, a run or armed switch wears metal. */
+GW.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb'];
+GW.slideToggle.PRESETS = {
+ panel: { on: 'amber', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
+ run: { on: 'green', onText: 'panel', off: 'dark', offText: 'dim', thumb: 'chrome' },
+ armed: { on: 'red', onText: 'cream', off: 'dark', offText: 'white', thumb: 'chrome' },
+ caution: { on: 'warn', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' },
+ /* dark — neither state lights the pill; the legend alone carries the state,
+ green for on and Control red ("OFF marks") for off. The chrome thumb stays
+ the position affordance, since with both pills dark it is the only one. */
+ dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' },
};
/* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */
GW.slideToggle.STYLES = {
on: {
amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } },
green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } },
+ /* red/warn make the ENGAGED state the notable one — mute, record, airplane
+ mode. Before them the only red was on the `off` axis, which paints the
+ disengaged state: the opposite claim. Palette semantics: --fail is
+ "muted keys", --amber-warn is "warn cells", distinct from panel amber. */
+ red: { dot: 'var(--fail)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-on-brd': 'var(--fail)', '--sw-on-ink': 'var(--cream)' } },
+ warn: { dot: 'var(--amber-warn)', vars: { '--sw-on-bg': 'linear-gradient(180deg,#f7d193,var(--amber-warn))', '--sw-on-brd': 'var(--amber-warn)', '--sw-on-ink': 'var(--panel)' } },
dark: { dot: '#242019', vars: { '--sw-on-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-on-brd': 'var(--slate)', '--sw-on-ink': 'var(--cream)' } },
},
off: {
dark: { dot: '#242019', vars: { '--sw-off-bg': 'linear-gradient(180deg,#15130f,#242019)', '--sw-off-brd': 'var(--slate)' } },
red: { dot: 'var(--fail)', vars: { '--sw-off-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--sw-off-brd': 'var(--fail)' } },
},
+ /* onText overrides the ink the `on` style sets. The two are not independent —
+ ink must contrast with its own background, which is why each `on` atom still
+ carries a sensible default; naming onText is how a caller says "keep the
+ pill, change the legend". That is what makes the `dark` preset possible:
+ both states unlit, the legend colour carrying the state on its own. */
+ onText: {
+ panel: { dot: 'var(--panel)', vars: { '--sw-on-ink': 'var(--panel)' } },
+ cream: { dot: 'var(--cream)', vars: { '--sw-on-ink': 'var(--cream)' } },
+ green: { dot: 'var(--sevgrn)', vars: { '--sw-on-ink': 'var(--sevgrn)' } },
+ },
offText: {
white: { dot: 'var(--cream)', vars: { '--sw-off-ink': 'var(--cream)' } },
+ /* Control red — the palette's "OFF marks" ink, so a red OFF legend is the
+ kit's existing grammar rather than a new claim. */
red: { dot: '#e0523a', vars: { '--sw-off-ink': '#e0523a' } },
+ /* steel is the kit's engraved-caption ink: lets an off toggle recede where a
+ panel carries many of them and eight cream OFFs would all shout equally. */
+ dim: { dot: 'var(--steel)', vars: { '--sw-off-ink': 'var(--steel)' } },
black: { dot: '#14110e', vars: { '--sw-off-ink': '#14110e' } },
},
thumb: {
@@ -160,10 +207,14 @@ GW.slideToggle.STYLES = {
},
};
-/* 02 console keys — mutually exclusive push buttons; {label, red} per key */
+/* 02 console keys — mutually exclusive push buttons; {label, tone} per key.
+ tone picks the engaged look: undefined = gold (the default lit key), 'green' =
+ run green, 'red' = terracotta. Reading order runs safe -> live -> muted, and
+ LIVE takes green because that is what --pass means everywhere else in the kit
+ (the palette names it "run lamps, gear greens, monitor bars, LIVE lamps"). */
GW.consoleKeys = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const keys = opts.keys || [{ label: 'LIVE' }, { label: 'SCAN' }, { label: 'MUTED', red: true }];
+ const keys = opts.keys || GW.consoleKeys.DEFAULT_KEYS;
const wrap = document.createElement('span'); host.appendChild(wrap);
const btns = keys.map(k => {
const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label;
@@ -171,14 +222,24 @@ GW.consoleKeys = function (host, opts = {}) {
});
let idx;
const set = i => {
- idx = i;
- btns.forEach((b, j) => { b.classList.remove('on', 'red'); if (j === i) b.classList.add(keys[i].red ? 'red' : 'on'); });
- onChange(i, keys[i].label);
+ idx = Math.max(0, Math.min(keys.length - 1, i));
+ btns.forEach((b, j) => {
+ b.classList.remove('on', 'green', 'red');
+ if (j === idx) b.classList.add(keys[idx].tone || 'on');
+ });
+ onChange(idx, keys[idx].label);
};
btns.forEach((b, i) => b.addEventListener('click', () => set(i)));
- set(opts.active || 0);
+ /* The default set engages LIVE, not the first key: it is the meaningful
+ resting state for a console and it shows the green. A caller supplying its
+ own keys gets the first one unless it says otherwise. */
+ set(opts.active !== undefined ? opts.active
+ : (keys === GW.consoleKeys.DEFAULT_KEYS ? 1 : 0));
return { el: wrap, get: () => idx, set };
};
+GW.consoleKeys.DEFAULT_KEYS = [
+ { label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' },
+];
/* 03 horizontal fader — continuous 0-100 */
GW.faderH = function (host, opts = {}) {
@@ -4011,6 +4072,7 @@ const GW_CSS = `
.key:hover{color:var(--gold);border-color:var(--gold)}
.key:active{transform:translateY(1px)}
.key.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700}
.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
.key.off{opacity:.4}
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
index 17b3d73..d196377 100644
--- a/tests/gallery-probes/probe.mjs
+++ b/tests/gallery-probes/probe.mjs
@@ -100,6 +100,146 @@ try {
const t1 = await evl(`document.getElementById('rd-01').textContent`);
ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`);
+ // 5. card 02 console keys: reading order and per-key tone. LIVE is green
+ // because --pass is what the kit means by live everywhere else; gold stays
+ // the generic engaged look. Craig's call, 2026-07-16.
+ const order = await evl(`[...document.querySelectorAll('#card-02 .key')].map(b => b.textContent)`);
+ ok('card 02 keys read SCAN, LIVE, MUTED',
+ JSON.stringify(order) === JSON.stringify(['SCAN', 'LIVE', 'MUTED']), JSON.stringify(order));
+
+ const engaged = await evl(`(()=>{
+ const lit = [...document.querySelectorAll('#card-02 .key')].filter(b => /\\b(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length;
+ })()`);
+ ok('card 02 engages LIVE in green by default', engaged === 'LIVE:green', engaged);
+
+ const muted = await evl(`(()=>{
+ const keys = [...document.querySelectorAll('#card-02 .key')];
+ keys.find(b => b.textContent === 'MUTED').click();
+ const lit = keys.filter(b => /\\b(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length;
+ })()`);
+ ok('card 02 MUTED engages red and releases LIVE', muted === 'MUTED:red', muted);
+
+ const rd02 = await evl(`document.getElementById('rd-02').textContent`);
+ ok('card 02 readout tracks the engaged key', rd02 === 'MUTED', rd02);
+
+ // 6. card 01 slide toggle: the new tone atoms exist. red/warn on the `on` axis
+ // let the ENGAGED state be the notable one (mute, record, airplane) — before
+ // these, red was only reachable via the `off` axis, which colours the
+ // disengaged state instead. dim offText lets an off toggle recede in a dense
+ // panel. Craig's call, 2026-07-16.
+ const atoms = await evl(`(()=>{
+ const S = GW.slideToggle.STYLES;
+ const want = { on: ['amber','green','dark','red','warn'], onText: ['panel','cream','green'],
+ off: ['dark','red'], offText: ['white','red','black','dim'],
+ thumb: ['light','dark','chrome','brass'] };
+ const missing = [];
+ for (const [axis, names] of Object.entries(want))
+ for (const n of names) if (!S[axis] || !S[axis][n]) missing.push(axis + '.' + n);
+ return missing.length ? 'missing: ' + missing.join(', ') : 'ok';
+ })()`);
+ ok('slide toggle style atoms present', atoms === 'ok', atoms);
+
+ // 7. presets name intent (a combination), not paint. Every axis a preset names
+ // must resolve in STYLES — a typo here would silently fall through to the
+ // stylesheet default and look "nearly right", which is the worst outcome.
+ const presets = await evl(`(()=>{
+ const P = GW.slideToggle.PRESETS, S = GW.slideToggle.STYLES;
+ if (!P) return 'no PRESETS';
+ const AX = GW.slideToggle.AXIS_ORDER;
+ const bad = [];
+ for (const [name, p] of Object.entries(P)) {
+ for (const ax of AX) {
+ if (!p[ax]) bad.push(name + ' missing ' + ax);
+ else if (!S[ax][p[ax]]) bad.push(name + '.' + ax + '="' + p[ax] + '" not in STYLES');
+ }
+ }
+ return bad.length ? bad.join('; ') : Object.keys(P).join(',');
+ })()`);
+ ok('every preset names a resolvable style on every axis',
+ presets === 'panel,run,armed,caution,dark', presets);
+
+ // 7a. the dark preset: neither state lights the pill, so the legend colour is
+ // the only thing carrying state. Both backgrounds must stay dark while the
+ // inks diverge — if either pill lights, the preset has lost its point.
+ const dark = await evl(`(()=>{
+ const P = GW.slideToggle.PRESETS.dark, S = GW.slideToggle.STYLES;
+ const onBg = S.on[P.on].vars['--sw-on-bg'], offBg = S.off[P.off].vars['--sw-off-bg'];
+ const onInk = S.onText[P.onText].vars['--sw-on-ink'], offInk = S.offText[P.offText].vars['--sw-off-ink'];
+ if (onBg !== offBg) return 'pills differ: on=' + onBg + ' off=' + offBg;
+ if (onInk === offInk) return 'inks identical, state unreadable: ' + onInk;
+ return 'ok';
+ })()`);
+ ok('dark preset keeps both pills dark and the inks distinct', dark === 'ok', dark);
+
+ // 7b. the card claims the preset it is actually in. The widget defaults to
+ // `panel`, so a preset group with nothing lit would assert "no preset
+ // active" — false, and exactly the kind of quiet mislabel this walk exists
+ // to catch. Nothing above this point touches card 01's chips (checks 2-4
+ // only toggle its switch, which does not restyle), so the default still
+ // stands here — but this must stay ABOVE checks 8/8b/9, which do mutate
+ // the chips. Insert preset-touching checks after them, not before.
+ const defaultPreset = await evl(`(()=>{
+ const pg = [...document.querySelectorAll('#card-01 .fgroup')]
+ .find(g => g.querySelector('.lab')?.textContent === 'preset');
+ if (!pg) return 'no preset group';
+ const lit = [...pg.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ return lit.length === 1 ? lit[0].title : 'lit=' + lit.length;
+ })()`);
+ ok('card 01 defaults to the panel preset', defaultPreset === 'panel', defaultPreset);
+
+ // 8. a preset chip drives the widget AND re-syncs the axis chips, so the card
+ // never shows a combination the widget isn't in.
+ const applied = await evl(`(()=>{
+ const card = document.getElementById('card-01');
+ const groups = [...card.querySelectorAll('.fgroup')];
+ const pg = groups.find(g => g.querySelector('.lab')?.textContent === 'preset');
+ if (!pg) return 'no preset group';
+ const armed = [...pg.querySelectorAll('.fc')].find(c => c.title === 'armed');
+ if (!armed) return 'no armed chip';
+ armed.click();
+ const onGroup = groups.find(g => g.querySelector('.lab')?.textContent === 'on');
+ const lit = [...onGroup.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ const brd = card.querySelector('.switch').style.getPropertyValue('--sw-on-brd');
+ return (lit.length === 1 ? lit[0].title : 'lit=' + lit.length) + '|' + brd;
+ })()`);
+ ok('armed preset drives widget and syncs the on chip', applied === 'red|var(--fail)', applied);
+
+ // 8b. axes are not independent: onText overrides the ink `on` sets, so changing
+ // `on` after picking an onText must not silently revert the legend while its
+ // chip still shows lit. Drive it in the hazardous order and check the ink.
+ const orderSafe = await evl(`(()=>{
+ const card = document.getElementById('card-01');
+ const groups = [...card.querySelectorAll('.fgroup')];
+ const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
+ const chip = (l, t) => [...grp(l).querySelectorAll('.fc')].find(c => c.title === t);
+ chip('on text', 'green').click(); // legend green
+ chip('on', 'dark').click(); // then change the pill — must keep it
+ const ink = card.querySelector('.switch').style.getPropertyValue('--sw-on-ink');
+ const lit = [...grp('on text').querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ return ink + '|' + (lit.length === 1 ? lit[0].title : 'lit=' + lit.length);
+ })()`);
+ ok('changing on keeps the chosen onText (chips cannot lie)',
+ orderSafe === 'var(--sevgrn)|green', orderSafe);
+
+ // 9. diverging on one axis clears the preset — the card stops claiming a preset
+ // it is no longer in. Re-establishes its own precondition (preset lit) rather
+ // than inheriting it: the checks above already clicked axis chips, each of
+ // which clears the preset, so asserting lit===0 without re-lighting first
+ // would pass against an already-cleared group and prove nothing — including
+ // against a regression where clearing fired for onText but not for `on`.
+ const diverged = await evl(`(()=>{
+ const groups = [...document.querySelectorAll('#card-01 .fgroup')];
+ const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
+ const litPresets = () => [...grp('preset').querySelectorAll('.fc')].filter(c => c.classList.contains('on')).length;
+ [...grp('preset').querySelectorAll('.fc')].find(c => c.title === 'run').click();
+ const before = litPresets();
+ [...grp('on').querySelectorAll('.fc')].find(c => c.title === 'green').click();
+ return before + '->' + litPresets();
+ })()`);
+ ok('changing an axis clears the preset selection', diverged === '1->0', diverged);
+
// late exceptions from interactions
const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
ok('no exceptions after interaction', errs2.length === 0);