From 6d8f4d8bfae27e8fc6caf1cbc65cf0fdf7a85db8 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 17 Jul 2026 18:34:43 -0500 Subject: feat(gallery): colour-policy system, chip-toggle accents, widgets-per-row Every widget's colour is either the consumer's to pick or locked for a reason, and I kept classifying cards wrong from memory during the colour pass. So the policy is a declared, checked property now. Six kinds. Accent and screen are free and take colour chips. Coded, emissive, relational and material are locked and must not. Each classified builder carries a POLICY record naming the kind, why this widget is bound that way, and what it may change and stay authentic (the range that actually existed, like the CRT phosphors that shipped). Eight are classified. The rest are the worklist. The page shows all of it from that one source. A card recovers its builder from the arrow it was handed and reads GW..POLICY, so the badge, the spec-sheet catalog and the index tally can't drift from the code. A probe holds it both ways: the recolour mechanism and the accent kind imply each other, so a locked card can't grow chips and an accent card can't forget to declare. The chip toggle is the first consumer of a shared accent family (red, amber, green, white, vfd) from GW.accentStyles, so the next card that wants those five gets them from one definition rather than a copy. Two smaller changes ride along because they touch the same header. The size toggle becomes WIDGETS/ROW, an explicit 1-4 column count instead of S/M/L zoom, and the policy panel sits level-right of the index. --- docs/prototypes/panel-widget-gallery.html | 139 ++++++++++++++++++----- docs/prototypes/widgets.js | 171 ++++++++++++++++++++++++++--- tests/gallery-probes/probe.mjs | 176 +++++++++++++++++++++++++++--- 3 files changed, 428 insertions(+), 58 deletions(-) diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 25b10c6..95b3719 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -56,7 +56,10 @@ h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} .szbar .lab{color:var(--steel);font-size:.68rem;letter-spacing:.22em;text-transform:uppercase;margin-right:4px} /* masthead row: description left, index panel right */ -.mastrow{display:flex;gap:28px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap} +.mastrow{display:flex;gap:20px;align-items:flex-start;justify-content:flex-end;flex-wrap:wrap} +.mastintro{flex:1 1 340px} +#polpanel{max-width:250px} +#polpanel .pnote{color:var(--dim);font-size:.72rem;line-height:1.5;margin:2px 0 8px} .toc{position:relative;flex:0 0 auto;min-width:220px;padding:14px 20px 13px;border-radius:12px; background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.4)} @@ -69,16 +72,25 @@ h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} .toc .scr{position:absolute;width:5px;height:5px;border-radius:50%; background:radial-gradient(circle at 35% 30%,#524d43,#161310);box-shadow:0 .5px 0 rgba(255,255,255,.08)} html{scroll-behavior:smooth} -/* size toggle scales the widget stage only; card text stays at its own (slightly raised) size */ -body[data-size="2"] .stagew{zoom:1.7} -body[data-size="3"] .stagew{zoom:2.4} +/* WIDGETS/ROW sets an explicit column count (1-4); the stage zoom scales with it + so a widget fills its column — fewer per row, bigger widgets. The card text + stays at its own (slightly raised) size, untouched by the zoom. */ +body[data-cols="1"] .grid{grid-template-columns:1fr} +body[data-cols="2"] .grid{grid-template-columns:repeat(2,1fr)} +body[data-cols="3"] .grid{grid-template-columns:repeat(3,1fr)} +body[data-cols="4"] .grid{grid-template-columns:repeat(4,1fr)} +body[data-cols="1"] .stagew{zoom:2.8} +body[data-cols="2"] .stagew{zoom:2.1} +body[data-cols="3"] .stagew{zoom:1.55} +body[data-cols="4"] .stagew{zoom:1.15} /* intrinsically tiny widgets get an extra bump so they do not float in an empty stage */ .stagew.boost{zoom:1.5} -body[data-size="2"] .stagew.boost{zoom:2.55} -body[data-size="3"] .stagew.boost{zoom:3.6} -body[data-size="2"] .grid{grid-template-columns:repeat(auto-fill,minmax(380px,1fr))} -body[data-size="3"] .grid{grid-template-columns:repeat(auto-fill,minmax(520px,1fr))} -body[data-size="2"] .wrap,body[data-size="3"] .wrap{max-width:none} +body[data-cols="1"] .stagew.boost{zoom:4.2} +body[data-cols="2"] .stagew.boost{zoom:3.15} +body[data-cols="3"] .stagew.boost{zoom:2.3} +body[data-cols="4"] .stagew.boost{zoom:1.7} +/* a single column shouldn't span the whole monitor; 2-4 keep the standard 1320 wrap */ +body[data-cols="1"] .wrap{max-width:960px} .secnote{color:var(--dim);font-size:.74rem;margin-top:.5rem;max-width:86ch} .palgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(176px,1fr));gap:10px;margin-top:1rem} @@ -103,7 +115,15 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} .wname{color:var(--cream);font-size:.95rem;font-weight:700;display:flex;align-items:center;gap:8px} .wname .no{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.7rem;padding:0 5px;font-weight:400} /* validation lamp: off = not done, amber = in progress, green = done; click cycles, state persists */ -.vlamp{margin-left:auto;flex:0 0 auto;width:10px;height:10px;border-radius:50%;cursor:pointer; +/* colour-policy badge: green = free (consumer picks), amber = locked (a stated + reason it can't change), dim = not yet classified. The whole point is scanning + the page and seeing at a glance what the colour pass has and hasn't reached. */ +.cpol{margin-left:auto;flex:0 0 auto;font-size:.56rem;letter-spacing:.06em;text-transform:uppercase; + padding:1px 5px;border-radius:3px;border:1px solid var(--wash);color:var(--steel);cursor:default;font-weight:400} +.cpol[data-free="true"]{color:var(--pass);border-color:rgba(116,147,47,.5)} +.cpol[data-free="false"]{color:var(--gold-hi);border-color:rgba(255,190,84,.4)} +.cpol[data-pol="none"]{opacity:.45} +.vlamp{flex:0 0 auto;width:10px;height:10px;border-radius:50%;cursor:pointer; background:#221f1b;border:1px solid #33302b} .vlamp[data-v="amber"],.vtally .vdot[data-v="amber"],#vaudit .vdot[data-v="amber"]{background:var(--gold);border-color:#7d5c16;box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} .vlamp[data-v="green"],.vtally .vdot[data-v="green"],#vaudit .vdot[data-v="green"]{background:var(--pass);border-color:#4a5c22;box-shadow:0 0 6px 1px rgba(116,147,47,.6)} @@ -171,13 +191,13 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} - +
archsetup · dupre panel family · live kit

Widget gallery — the instrument-console kit, fully driveable

-
+

Every control + display idiom in the dupre faceplate language, merged into one live console — the base kit, the candidate batch, and the growing reference batch (R: modeled on period hardware), all rendering from the same tokens the net / bt / sound panels use. @@ -185,10 +205,11 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} readouts show state or a number. Every widget is operable: drag the faders, knobs and gauges; click the switches, keys and lamps. Each card carries a live readout that tracks what you do. The trace displays run a live signal; the pointer types drive from your drag.

-
widget size - - - +
widgets/row + + + +
+
@@ -226,14 +254,14 @@ const $ = id => document.getElementById(id); const {svgEl,polar,dragX,dragY,dragDelta,seg7,buildBars,VUDB,vuDb,SCREEN_FAMS}=GW; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; document.querySelectorAll('.szbar .key').forEach(b=>b.addEventListener('click',()=>{ - document.body.dataset.size=b.dataset.sz; - localStorage.setItem('gv-size',b.dataset.sz); + document.body.dataset.cols=b.dataset.cols; + localStorage.setItem('gv-cols',b.dataset.cols); document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k===b)); })); -(function(){const s=localStorage.getItem('gv-size'); - if(!s||!/^[123]$/.test(s))return; - document.body.dataset.size=s; - document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k.dataset.sz===s));})(); +(function(){const s=localStorage.getItem('gv-cols'); + if(!s||!/^[1234]$/.test(s))return; + document.body.dataset.cols=s; + document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k.dataset.cols===s));})(); /* boost the intrinsically tiny widgets (audit: content under 10% of the stage) */ const BOOST=['01','07','08','12','13','N14','18']; @@ -684,6 +712,15 @@ const INFO={ origin:'PDP-11/70 and IMSAI front panels.',difficulty:'Intuitive to watch, expert to read.', prefer:'A spinner says busy; blinkenlights say HOW busy and with what rhythm.', period:'1965-80 minicomputer era; the icon of computing at work.'}, +'R58':{input:'Click a character to move the stylus, then pull the lever to print it. Or click the plate and type: keys move the stylus, Enter is the lever. Selecting never prints.', + solves:'Free text where the operator must SEE the whole character set and commit deliberately — the plate is the documentation, and nothing lands by accident.', + use:'Specialty. Shines where a stray keypress must not enter anything, and where the available characters are worth showing rather than remembering.', + limits:'Two acts per character, so it is the slowest text control in the kit by design. No space cell (the real machine has a separate space key); no 1 or 0 (type them with lowercase l and capital O).', + origin:'AEG Mignon Model 4 (1924), the best of the index typewriters. Plate transcribed from the reference photo; layout and zones are declared tables.', + difficulty:'Obvious to read, slow to use. The two-handed grammar is the point, not an obstacle.', + prefer:'Deliberate commitment matters more than speed, or the character set itself needs to be on display. R57 is the fast alternative and R16 the digits-only one.', + period:'1903-1934 (index typewriters); the idiom predates the keyboard becoming standard.', + ref:'../../working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg'}, 'R57':{input:'Click keys to type, or click the plate and type on the keyboard (letters, digits, space, Backspace, Enter). DEL takes back one character, CLR wipes the lot, ENT commits.', solves:'Free alphanumeric text on a panel that has no keyboard — the one job the rest of the kit cannot do.', use:'Specialty. Shines where a panel must accept a string it could not know in advance: a passphrase, an SSID, a callsign, a label.', @@ -1158,10 +1195,31 @@ addEventListener('keydown',e=>{if(e.key==='Escape')auditStop();}); /* card(host, no, name, htmlOrBuild, note) — the declarative card record. htmlOrBuild: a legacy stage-HTML string, or a builder function (stage, rd) => handle that instantiates a GW.* widget into the stage; rd(txt) writes the card readout. */ +/* Recover a card's builder from the arrow it was handed — the first GW.( + in the source — and read its POLICY record off it. Single source: the record + lives on GW..POLICY in widgets.js and the card only displays it, so the + page can't drift from the code. Unclassified cards read '—', which is the point: + the gallery becomes the colour pass's visible worklist. */ +function cardPolicy(html){ + if(typeof html!=='function') return null; + /* Assumes the FIRST GW.( in the arrow is the builder — true because every + card is (st,rd)=>GW.builder(st,{...}) and helpers are destructured, called + bare. An arrow that referenced a second GW.( before its builder would + mis-derive; none do. A wrapped or renamed builder reads '—' rather than + wrong, which is safe. */ + const m=html.toString().match(/GW\.(\w+)\s*\(/); + return (m && GW[m[1]] && GW[m[1]].POLICY) || null; +} function card(host, no, name, html, note){ const isBuild=typeof html==='function'; + const P=cardPolicy(html), kind=P&&P.kind; + const free=kind&&GW.POLICIES[kind]&&GW.POLICIES[kind].free; + const polTip=kind?(GW.POLICIES[kind].gist||''):'policy not yet classified'; const c=document.createElement('div'); c.className='card'; c.id='card-'+no; - c.innerHTML=`
${no}${name}
`+ + c.dataset.cpol=kind||'none'; + c.innerHTML=`
${no}${name}`+ + `${kind||'—'}`+ + `
`+ `
${isBuild?'':html}
`+ `
${note}
`; const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green']; @@ -1173,6 +1231,13 @@ function card(host, no, name, html, note){ const inf=(typeof INFO!=='undefined')&&INFO[no]; if(inf){const d=document.createElement('details'); d.className='winfo'; let rows=''; + if(P){ + rows+=`
policy
${kind} — ${GW.POLICIES[kind].gist}
`; + rows+=`
why
${P.why}
`; + rows+=`
authentic
${P.authentic}
`; + } else { + rows+=`
policy
unclassified — colour pass pending
`; + } for(const [k,lbl] of INFO_FIELDS) if(inf[k]) rows+=`
${lbl}
${inf[k]}
`; if(inf.ref) rows+=`
reference
`; d.innerHTML=`spec sheet
${rows}
`; @@ -1182,6 +1247,28 @@ function card(host, no, name, html, note){ return c; } function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;} +/* Colour-policy tally: free / locked / unclassified across all cards, so the + index shows how far the colour pass has reached the way the validation tally + shows the walk. Free = accent+screen; locked = the four protected policies; + the rest await classification. Clicking a row jumps through its cards. */ +const FREEP=['accent','screen'], LOCKP=['coded','emissive','relational','material']; +const PJUMP={free:0,locked:0,none:0}; +function updatePolTally(){ + const el=$('ptally'); if(!el)return; + const cards=[...document.querySelectorAll('.card')]; + const grp=p=>FREEP.includes(p)?'free':LOCKP.includes(p)?'locked':'none'; + const n={free:0,locked:0,none:0}; + cards.forEach(c=>{n[grp(c.dataset.cpol)]++;}); + el.innerHTML= + `
free${n.free}
`+ + `
locked${n.locked}
`+ + `
unclassified${n.none}
`+ + `
total${cards.length}
`; + el.querySelectorAll('.vrow[data-g]').forEach(row=>row.addEventListener('click',()=>{ + const g=row.dataset.g, hits=cards.filter(c=>grp(c.dataset.cpol)===g); + if(!hits.length)return; const i=PJUMP[g]++%hits.length; + location.hash=''; location.hash=hits[i].id;})); +} /* drag helpers, seg7, buildBars live in widgets.js (GW) */ /* ============ CONTROLS ============ */ @@ -1210,7 +1297,7 @@ card(C,'06','Segmented selector', 'pick one of a few. Timer type, layout mode. Click a segment; readout names the choice. The lit segment ships in amber, green, or red — the accent chips below switch it.'); card(C,'07','Chip toggle', (st,rd)=>GW.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), - 'inline binary. A soft toggle inside a line of text. Click to flip; gold when on.'); + 'inline binary. A soft toggle inside a line of text. Click to flip. The lit colour is the consumer\'s to pick from the accent family, because "on" is good news in one panel and a fault in the next — amber by default.'); card(C,'08','Arm-to-fire', (st,rd)=>GW.armButton(st,{onChange:(v,t)=>rd(t)}), 'two-stage confirm. Destructive actions. First click arms (red), second fires. Readout shows the stage.'); @@ -1355,7 +1442,7 @@ card(C,'R57','ABC entry keypad', card(C,'R58','Index typewriter', (st,rd)=>GW.indexPlate(st,{onChange:(v,t)=>rd(t)}), - 'type without a keyboard. One hand walks the stylus over a printed plate of characters, the other pulls the lever, and only the lever prints — hunt as long as you like and nothing happens until the second hand moves. Select and commit on two separate controls, which no other card in the kit does. Both cases live on the plate, so there is no shift key, and it carries what a keyboard skips: accents, fractions, the section mark. After the AEG Mignon Model 4 (1924), whose frequency-ordered keys we did not keep — reading the plate IS the interaction, so ours is alphabetical.'); + 'type without a keyboard. One hand walks the stylus over a printed plate, the other pulls the lever, and only the lever prints — hunt as long as you like and nothing happens until the second hand moves. Select and commit on two separate controls, which no other card in the kit does. The plate is the AEG Mignon Model 4\'s own (1924), transcribed from the reference: both full cases so there is no shift key, the accents and fractions a keyboard skips, and two economies worth noticing. There is no 1 and no 0 — you type them with lowercase l and capital O. And the inversion is positional, not by case: the capitals block is dark discs on a light panel and the lowercase block is its photographic negative, but J and j sit out on the halftone ring, un-inverted, with the punctuation.'); /* ============ METERS & GAUGES ============ */ const M=$('meters'); @@ -1724,11 +1811,13 @@ styleChips('01',GW.slideToggle.STYLES, 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('07',GW.chipToggle.STYLES,[['accent','accent','amber']]); styleChips('25',GW.slideRule.STYLES,[['face','skin','warm']]); /* final tally pass: setV fires per card during build, but each card is still detached at that moment, so the running counts lag by one — recount now */ updateVTally(); +updatePolTally(); diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 70a63b3..5ba1226 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -114,6 +114,53 @@ const SCREEN_FAMS = { const noop = () => {}; +/* ---- the accent family ---- + One named set of lit colours for the widgets whose colour IS their claim: a + chip, a lamp, a badge, a status line. "On" is good in one panel, a warning in + the next and a fault in the one after, so the colour belongs to the consumer + rather than the builder. + Deliberately NOT applied to widgets whose colour is the object rather than a + state — the nixie (the palette is explicit that neon is only ever orange), the + flip-disc's yellow, the dekatron's glow, a red needle — nor to the ones where + the colour is a standard rather than a preference: three greens on the landing + gear means down and locked, and a breaker's red/amber/green is its trip + semantics. Recolouring those doesn't restyle them, it makes them lie. + accentStyles(varName) builds a STYLES axis driving one CSS custom property, so + each consumer names its own var and the family stays single-source. */ +const ACCENTS = { + amber: 'var(--gold)', + green: 'var(--pass)', + red: 'var(--fail)', + white: 'var(--cream)', + vfd: 'var(--vfd)', +}; +GW.accentStyles = varName => Object.fromEntries( + Object.entries(ACCENTS).map(([name, colour]) => [name, { dot: colour, vars: { [varName]: colour } }])); + +/* ---- policy ---- + Every widget's colour is either the consumer's to pick or locked for a reason, + and the reason is one of these six kinds. Declared per builder (GW..POLICY) + so it's a checked property rather than something the next person recalls — the + colour pass kept turning up cards where "is this one a standard?" was answered + from memory and answered wrong. The two FREE kinds get chips; the four LOCKED + ones must not, and the probe enforces the split. + Policy is a property of the WIDGET, not the colour: the same vfd cyan is a free + accent on a chip and a locked emissive identity on the marquee. A few cards are + mixed (a free display with a coded red-line) and declare per element in time. + Each builder's POLICY is a record { kind, why, authentic }: + kind — one of the six below. + why — why THIS widget is bound that way, in its own terms. + authentic — what may change and still be true to the reference (the range + that actually existed), or 'nothing' when the colour is fixed. */ +GW.POLICIES = { + accent: { free: true, gist: 'A lit state whose meaning varies by panel; the consumer picks from the accent family.' }, + screen: { free: true, gist: 'A display whose phosphor was made in several real colours; the consumer picks from the screen family.' }, + coded: { free: false, gist: 'Colour is meaning fixed by an external standard; a recolour misleads a trained operator (landing-gear three-greens, breaker trip).' }, + emissive: { free: false, gist: 'Colour is what the physical source emits and no other was made; unrecognisable otherwise (nixie neon, dekatron glow).' }, + relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same widget; the set is a scale or legend (VU zones, crossed needles).' }, + material: { free: false, gist: 'Colour of a static physical part, not a state — nothing to parameterise as a signal (needle, brass bezel, knife blade).' }, +}; + /* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*). opts.onStyle / offStyle / offText / thumb pick a named style per axis from GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's @@ -444,9 +491,33 @@ GW.indexPlate = function (host, opts = {}) { const paper = svgEl(s, 'text', { x: 22, y: 31, 'font-size': 13, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--scr-hi, #241d12)' }); - /* the index plate: cream, rounded, characters in printed rings like the Mignon's */ + /* the index plate: cream, rounded, characters in printed rings like the Mignon's. + The ring area carries a halftone screen, which is what the real plate prints + its outer characters onto. */ + if (!document.getElementById('ixHalf')) { + const defs = s.ownerDocument.querySelector('#gw-defs') || svgEl(s, 'defs', {}); + const pat = svgEl(defs, 'pattern', { id: 'ixHalf', width: 4, height: 4, patternUnits: 'userSpaceOnUse' }); + svgEl(pat, 'rect', { width: 4, height: 4, fill: 'url(#ixPlate)' }); + svgEl(pat, 'circle', { cx: 1, cy: 1, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 }); + svgEl(pat, 'circle', { cx: 3, cy: 3, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 }); + } svgEl(s, 'rect', { x: PX - 8, y: PY - 10, width: COLS * CW + 14, height: ROWS * CH + 16, rx: 7, - fill: 'url(#ixPlate)', stroke: '#8d8268', 'stroke-width': 1.5 }); + fill: 'url(#ixHalf)', stroke: '#8d8268', 'stroke-width': 1.5 }); + /* the inverted blocks, drawn under the cells: a light panel for the capitals, + a dark one for the lowercase — the plate's photographic-negative trick */ + const ZFILL = { caps: '#e9e0bb', lower: '#231d13' }; + const zoneAt = (r, c) => { + const z = GW.indexPlate.ZONES.find(z => + r >= z.rows[0] && r <= z.rows[1] && c >= z.cols[0] && c <= z.cols[1]); + return z ? z.zone : 'ring'; + }; + GW.indexPlate.ZONES.forEach(z => { + svgEl(s, 'rect', { + x: PX + z.cols[0] * CW - 1, y: PY + z.rows[0] * CH - 1, + width: (z.cols[1] - z.cols[0] + 1) * CW + 2, height: (z.rows[1] - z.rows[0] + 1) * CH + 2, + fill: ZFILL[z.zone] || 'none', + }); + }); let sel = null, buf = ''; /* null-prototype: cells is keyed by the plate's characters, and a plain {} would @@ -476,11 +547,20 @@ GW.indexPlate = function (host, opts = {}) { L.forEach((row, r) => row.forEach((c, i) => { if (!c) return; const x = PX + i * CW + CW / 2, y = PY + r * CH + CH / 2; - const g = svgEl(s, 'g', {}); g.setAttribute('class', 'ix-cell'); g.dataset.c = c; + /* disc and glyph invert together, by zone: dark disc + light glyph inside the + capitals block, the negative of that in the lowercase block, light-on-dark + everywhere on the ring. */ + const z = zoneAt(r, i); + const DISC = { caps: '#231d13', lower: '#f2ecd6', ring: '#f2ecd6' }; + const INK = { caps: '#f2ecd6', lower: '#231d13', ring: '#231d13' }; + const g = svgEl(s, 'g', {}); g.setAttribute('class', 'ix-cell'); + g.dataset.c = c; g.dataset.zone = z; g.style.cursor = 'pointer'; - const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: '#f7f2df', stroke: '#7a6a3e', 'stroke-width': 1.4, 'stroke-opacity': .35 }); - svgEl(g, 'text', { x, y: y + 3.6, 'text-anchor': 'middle', 'font-size': 9.5, 'font-weight': 600, - 'font-family': 'var(--mono)', fill: '#241d12' }).textContent = c; + const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: DISC[z], + stroke: z === 'caps' ? '#e9e0bb' : '#7a6a3e', 'stroke-width': 1.4, 'stroke-opacity': .35 }); + svgEl(g, 'text', { x, y: y + 3.6, 'text-anchor': 'middle', + 'font-size': /[a-z]/.test(c) || /[A-Z]/.test(c) ? 9.5 : 8.5, 'font-weight': 600, + 'font-family': 'var(--mono)', fill: INK[z] }).textContent = c; cells[c] = { ring, x, y }; g.addEventListener('click', () => press(c)); })); @@ -550,14 +630,33 @@ GW.indexPlate = function (host, opts = {}) { so a letter and its case sit in the same row six columns apart. Digits and the punctuation ring keep the Mignon's edges. Rewrite this table to relayout the plate: nothing below reads it except the renderer. */ +/* The Mignon Model 4's own plate, transcribed from the reference photo + (working/retro-stereo-widgets/references/2026-07-16-mignon-aeg-detail.jpg). + Faithful first, iterate second — Craig's call, and the right order: the layout + has things in it worth understanding before replacing. + Two of its economies are load-bearing and easy to "fix" by accident: + - There is no 1 and no 0. You type them with lowercase l and capital O. + - J and j live out on the ring, not in the case blocks, which is why the + inversion below is positional and not "capitals are dark". + The two dashes differ: a long one top-right, a hyphen bottom-right. */ GW.indexPlate.LAYOUT = [ - ['A','B','C','D','E','F', 'a','b','c','d','e','f'], - ['G','H','I','J','K','L', 'g','h','i','j','k','l'], - ['M','N','O','P','Q','R', 'm','n','o','p','q','r'], - ['S','T','U','V','W','X', 's','t','u','v','w','x'], - ['Y','Z','Ä','Ö','Ü','§', 'y','z','ä','ö','ü','ß'], - ['1','2','3','4','5','6', '7','8','9','0','½','¼'], - ['.',',',';',':','!','?', "'",'"','(',')','-','+'], + ['&','(',')',':','"','!', '?',"'",'ä','ö','ü','—'], + ['§','P','F','U','G','Q', 'p','f','u','g','q',';'], + ['J','V','I','N','A','B', 'v','i','n','a','b','j'], + ['/','L','D','E','T','M', 'l','d','e','t','m',','], + ['%','K','O','S','R','Z', 'k','o','s','r','z','='], + ['¾','Y','C','H','W','X', 'y','c','h','w','x','+'], + ['½','¼','2','3','4','5', '6','7','8','9','.','-'], +]; +/* The plate's two inverted regions, as rectangles over LAYOUT rather than a rule + about characters. On the real plate the capitals block is dark discs on a light + panel and the lowercase block is its photographic negative, while everything on + the outer ring stays light-on-halftone — including the capital J, which is why + no case-based rule can describe this. Move a block, move its rectangle. + rows and cols are inclusive [start, end] indices into LAYOUT. */ +GW.indexPlate.ZONES = [ + { rows: [1, 5], cols: [1, 5], zone: 'caps' }, + { rows: [1, 5], cols: [6, 10], zone: 'lower' }, ]; /* Both tables are DERIVED from the layout, never maintained beside it: relaying the plate must not leave a keybinding aimed at a character it no longer has. @@ -663,12 +762,23 @@ GW.chipToggle = function (host, opts = {}) { const chip = document.createElement('span'); chip.className = 'chip'; chip.textContent = opts.label || 'discoverable on'; host.appendChild(chip); + const setStyle = (axis, name) => { + const o = (GW.chipToggle.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) chip.style.setProperty(k, v); + }; + setStyle('accent', opts.accent || 'amber'); let on; const set = v => { on = !!v; chip.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; chip.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); - return { el: chip, get: () => on, set }; + return { el: chip, get: () => on, set, setStyle }; }; +/* The chip's lit colour, from the shared accent family. Gold was hardcoded, which + let the chip say exactly one thing — but "on" is good in one panel, a warning in + the next, and a fault in the one after. The colour is the claim, so it belongs + to the consumer. */ +GW.chipToggle.STYLES = { accent: GW.accentStyles('--chip-on') }; /* 08 arm-to-fire — two-stage confirm for destructive actions */ GW.armButton = function (host, opts = {}) { @@ -4417,7 +4527,7 @@ const GW_CSS = ` .key.off{opacity:.4} .chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} -.chip.on{color:var(--gold);border-color:var(--gold)} +.chip.on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))} .badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} .badge.red{background:var(--fail);color:var(--cream)} @@ -4888,6 +4998,37 @@ function ensureCss() { if (document.head) ensureCss(); else document.addEventListener('DOMContentLoaded', ensureCss); +/* Policy classification (see GW.POLICIES). This is the colour pass's worklist made + explicit: every card gets a record here as we review it — the kind it's bound + by, why in its own terms, and the range it may vary and stay authentic. This + round covers the cards already touched; the rest are unclassified on purpose, + the review still to do card by card, not a memory dump to fill now. Assigned + after the builders exist, in one place, so it reads as a catalogue. */ +GW.slideToggle.POLICY = { kind: 'accent', + why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the widget’s.', + authentic: 'The on-colour from the accent family (red, amber, green, white, vfd), plus the pill and thumb finishes. The switch form stays.' }; +GW.segmented.POLICY = { kind: 'accent', + why: 'The active segment signals a mode whose meaning depends on the panel it sits in.', + authentic: 'The accent colour of the active segment. The segment count and labels are the deployment’s, not a colour choice.' }; +GW.chipToggle.POLICY = { kind: 'accent', + why: 'A filter chip’s on-state reads as good, warning or fault entirely by where it is used.', + authentic: 'The lit colour from the accent family. The chip keeps its inline weight and dotted underline.' }; +GW.dataMatrix.POLICY = { kind: 'screen', + why: 'LED and LCD dot-matrix modules were sold in several emitter colours, so no one colour is definitive.', + authentic: 'The screen family (amber, green, red, blue, vfd, white) — each a panel that was actually built. Not an arbitrary hue.' }; +GW.roundCrt.POLICY = { kind: 'screen', + why: 'CRT phosphors were manufactured in more than one colour (P1 green, P3 amber, white), so the trace colour is a real choice.', + authentic: 'The screen family, limited to phosphors that existed. The face tint follows the phosphor.' }; +GW.waveRegion.POLICY = { kind: 'screen', + why: 'A backlit editor LCD was made in several tints; the ink colour is the panel’s, not fixed.', + authentic: 'The screen family. The waveform and region handles recolour with the screen, staying legible on it.' }; +GW.radarSweep.POLICY = { kind: 'screen', + why: 'PPI radar scopes ran amber and green phosphors both; neither is the one true colour.', + authentic: 'The screen family, phosphors that shipped. The sweep and afterglow track the chosen screen.' }; +GW.abcKeypad.POLICY = { kind: 'screen', + why: 'The entry window is a screen like any other; its phosphor was made in several colours. (Mixed card: the keys are fixed-function and not a colour choice.)', + authentic: 'The window’s screen family. The keycap colours are functional and stay put.' }; + Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS }); window.GW = GW; })(); diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index 5e95096..905d758 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -65,21 +65,136 @@ try { const errs = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200)); - // 1. defaults: size=2 (M), card count - ok('default size 2', await evl(`document.body.dataset.size`) === '2'); + // 1. card count (the widgets/row default is checked in 1h) const cards = await evl(`document.querySelectorAll('.card').length`); ok('111 cards', cards === 111, `got ${cards}`); - // 2. zoom actually scales: card visual width at 3x vs 1x - await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`); - const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`); - await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`); - const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`); - ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`); - ok('size chip flips state', await evl(`document.body.dataset.size`) === '1'); - - // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout - await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`); + // 1b. Every card carries a spec sheet. R58 shipped without one and nothing + // noticed — the sheet is the card's actual specification, so a card without + // it is a drawing with no contract. Cheap to forget, cheap to check. + const sheets = await evl(`(()=>{ + const missing = [...document.querySelectorAll('.card')] + .filter(c => !c.querySelector('.winfo')) + .map(c => c.id.slice(5)); + return missing.length ? 'no spec sheet: ' + missing.join(' ') : 'ok'; + })()`); + ok('every card has a spec sheet', sheets === 'ok', sheets); + + // 1c. Card 07's lit colour comes from the shared accent family, not a hardcode. + // The chip said exactly one thing (gold) when "on" is good in one panel, a + // warning in the next and a fault in the one after. Checks the family is + // shared rather than copied: a second consumer must get the same five. + const accents = await evl(`(()=>{ + const want = ['amber','green','red','white','vfd']; + const chips = [...document.querySelectorAll('#card-07 .fc')].map(c => c.title); + const missing = want.filter(w => !chips.includes(w)); + if (missing.length) return 'card 07 missing accents: ' + missing.join(' '); + const built = Object.keys(GW.accentStyles('--x')); + return JSON.stringify(built) === JSON.stringify(want) ? 'ok' : 'family is ' + JSON.stringify(built); + })()`); + ok('card 07 offers the shared accent family', accents === 'ok', accents); + + // 1d. The chip actually recolours, and its default is untouched until asked — + // the same fallback discipline the screen families use. + const chipInk = await evl(`(()=>{ + const chip = document.querySelector('#card-07 .chip'); + const lit = () => getComputedStyle(chip).color; + const before = lit(); + document.querySelector('#card-07 .fc[title="vfd"]').click(); + const after = lit(); + return before === 'rgb(226, 160, 56)' && after === 'rgb(99, 230, 200)' + ? 'ok' : before + ' -> ' + after; + })()`); + ok('card 07 defaults to gold and recolours on click', chipInk === 'ok', chipInk); + + // 1e. The colour-policy backbone. Every widget's colour is either free (the + // consumer picks) or locked for a stated reason, and the colour pass kept + // finding cards where "is this one a standard?" was answered from memory and + // wrong. So policy is declared on the builder and checked here. The invariant + // that matters: the accent recolour mechanism and the 'accent' declaration + // imply each other, so a locked card can't silently sprout accent chips and + // an accent card can't forget to say so. Coverage grows card by card; this + // round classifies the touched set, and the count is reported not gated. + const policy = await evl(`(()=>{ + const KINDS = GW.POLICIES; + if (!KINDS) return 'no POLICIES vocabulary'; + const valid = new Set(Object.keys(KINDS)); + const fns = Object.entries(GW).filter(([,v]) => typeof v === 'function'); + const declared = fns.filter(([,v]) => v.POLICY); + // each POLICY record is complete: a valid kind, a why, an authentic range + const shape = declared.filter(([,v]) => !valid.has(v.POLICY.kind) || !v.POLICY.why || !v.POLICY.authentic) + .map(([k,v]) => k + '(' + v.POLICY.kind + ')'); + if (shape.length) return 'malformed policy record: ' + shape.join(' '); + // A recolour mechanism and an 'accent' kind imply each other, BOTH directions, + // and both directions must recognise the SAME mechanisms — or a locked card + // carrying a multi-colour on-family slips the reverse check. A recolour + // mechanism is a STYLES.accent axis or a STYLES.on family (slideToggle's shape). + const hasMech = v => !!(v.STYLES && (v.STYLES.accent || v.STYLES.on)); + const mechNotAccent = fns.filter(([,v]) => hasMech(v) && (!v.POLICY || v.POLICY.kind !== 'accent')).map(([k]) => k); + if (mechNotAccent.length) return 'recolour mechanism but not declared accent: ' + mechNotAccent.join(' '); + const accentNoMech = declared.filter(([,v]) => v.POLICY.kind === 'accent' && !hasMech(v)).map(([k]) => k); + if (accentNoMech.length) return 'declared accent but no recolour mechanism: ' + accentNoMech.join(' '); + // a screen kind must actually read the screen vars + const screenNoVars = declared.filter(([,v]) => v.POLICY.kind === 'screen' && !v.toString().includes('--scr-')).map(([k]) => k); + if (screenNoVars.length) return 'declared screen but reads no --scr- vars: ' + screenNoVars.join(' '); + return 'ok (' + declared.length + ' classified)'; + })()`); + ok('policy records valid, complete and mechanism-matched', policy.startsWith('ok'), policy); + + // 1f. The policy is on the PAGE, not just in code and this probe. Each card + // shows a badge derived from its builder's COLOR (single source, so the + // page can't drift from the declaration), and the badge marks free vs + // locked vs unclassified. Checks a known classified card renders its policy + // and a locked/unclassified one renders honestly. + const shown = await evl(`(()=>{ + const badge = no => document.querySelector('#card-' + no + ' .cpol'); + const b07 = badge('07'), b10 = badge('R10'); + if (!b07 || !b10) return 'no policy badge on card'; + if (b07.textContent !== 'accent' || b07.dataset.free !== 'true') return 'card 07 badge: ' + b07.textContent + '/' + b07.dataset.free; + if (b10.textContent !== 'screen') return 'card R10 badge: ' + b10.textContent; + // an unclassified card shows the dash, not a stale or invented policy + const un = [...document.querySelectorAll('.cpol')].find(b => b.dataset.pol === 'none'); + if (un && un.textContent !== '—') return 'unclassified badge should read dash: ' + un.textContent; + // the badge text always matches the builder's actual COLOR, never a copy + const drift = [...document.querySelectorAll('.card')].map(c => { + const b = c.querySelector('.cpol'); const want = c.dataset.cpol; + return (want === 'none' ? '—' : want) === b.textContent ? null : c.id; + }).filter(Boolean); + return drift.length ? 'badge drifts from data-cpol: ' + drift.slice(0,3).join(' ') : 'ok'; + })()`); + ok('colour policy is shown on each card, derived not copied', shown === 'ok', shown); + + // 1g. The index tally counts coverage the way the validation tally counts the + // walk, so "how far has the colour pass reached" is a glance, not a grep. + // free + locked + unclassified must sum to the card total. + const ptally = await evl(`(()=>{ + const rows = [...document.querySelectorAll('#ptally .vrow[data-g] .vn')].map(v => +v.textContent); + const total = +document.querySelector('#ptally .vtot .vn').textContent; + const cards = document.querySelectorAll('.card').length; + if (total !== cards) return 'tally total ' + total + ' != ' + cards + ' cards'; + if (rows.reduce((a,b)=>a+b,0) !== total) return 'free+locked+unclassified ' + rows + ' != ' + total; + return 'ok free=' + rows[0] + ' locked=' + rows[1] + ' unclassified=' + rows[2]; + })()`); + ok('colour policy tally sums to the card total', ptally.startsWith('ok'), ptally); + + // 1h. widgets/row: the control sets an explicit column count and the cards + // resize to match. One-per-row cards must be much wider than four-per-row, + // and the count actually reaches the grid. + ok('default is 3 widgets/row', await evl(`document.body.dataset.cols`) === '3'); + await evl(`document.querySelector('.szbar .key[data-cols="1"]').click()`); + const w1col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols1 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + await evl(`document.querySelector('.szbar .key[data-cols="4"]').click()`); + const w4col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); + const cols4 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); + ok('1/row is much wider than 4/row', w1col > w4col * 2.5, `w1=${Math.round(w1col)} w4=${Math.round(w4col)}`); + ok('the grid renders the chosen column count', cols1 === 1 && cols4 === 4, `1->${cols1} 4->${cols4}`); + ok('widgets/row chip flips state', await evl(`document.body.dataset.cols`) === '4'); + + // 3. behavioral, zoomed: fader drag on card 03 changes readout. Run at 2/row, + // not 1/row: at one-per-row the card is wide enough that its controls fall + // outside the 1600px probe window and the dispatched mouse events miss. + await evl(`document.querySelector('.szbar .key[data-cols="2"]').click()`); await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`); await sleep(200); const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`); @@ -87,9 +202,9 @@ try { await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2); await sleep(150); const after = await evl(`document.getElementById('rd-03').textContent`); - ok('fader drag tracks at 3x', before !== after && after !== '—', `'${before}' -> '${after}'`); + ok('fader drag tracks at 2/row', before !== after && after !== '—', `'${before}' -> '${after}'`); - // 4. behavioral at 3x: toggle click on card 01 + // 4. behavioral, zoomed: toggle click on card 01 await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`); await sleep(200); const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`); @@ -98,7 +213,7 @@ try { await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 }); await sleep(150); const t1 = await evl(`document.getElementById('rd-01').textContent`); - ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`); + ok('toggle click responds at 2/row', 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 @@ -569,14 +684,39 @@ try { // keyboard skips. Both cases with no shift key, plus accents, fractions and // the section mark — that coverage is the idea being preserved from the // Mignon, where the key ORDER deliberately is not. + // The plate is the Mignon's own, so the set is its set: both full cases, + // the accents and fractions a keyboard skips — and deliberately NO 1 and NO + // 0, because the machine has you type them with lowercase l and capital O. + // That economy is an old value worth keeping, so it's asserted rather than + // tolerated: adding a 1 key would be a silent departure from the reference. const charset = await evl(`(()=>{ const have = new Set([...document.querySelectorAll('#card-R58 .ix-cell')].map(c => c.dataset.c)); const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz', - ...'0123456789', 'ä','ö','ü','Ä','Ö','Ü','ß','§','½','¼']; + ...'23456789', 'ä','ö','ü','§','½','¼','¾']; const missing = need.filter(c => !have.has(c)); - return missing.length ? 'missing: ' + missing.join(' ') : 'ok'; + if (missing.length) return 'missing: ' + missing.join(' '); + const shouldNotExist = ['1','0'].filter(c => have.has(c)); + if (shouldNotExist.length) return 'plate grew a key the Mignon economises away: ' + shouldNotExist.join(' '); + return 'ok'; + })()`); + ok('R58 carries the Mignon set: both cases, accents, no 1 or 0', charset === 'ok', charset); + + // 14j. The inversion is ZONAL, not per-case. The capitals block is dark discs on + // a light panel; the lowercase block is its photographic negative. But J is + // a capital sitting out on the ring, un-inverted — so a rule of "capitals + // are dark" is wrong, and only position decides. + const zones = await evl(`(()=>{ + const disc = c => { + const g = [...document.querySelectorAll('#card-R58 .ix-cell')].find(e => e.dataset.c === c); + return g ? g.querySelector('circle').getAttribute('fill') : null; + }; + const caps = disc('P'), lower = disc('p'), ringCap = disc('J'), ringPunct = disc('&'); + if (caps === lower) return 'capitals and lowercase discs are the same: ' + caps; + if (ringCap !== ringPunct) return 'J should wear the ring look like punctuation: J=' + ringCap + ' &=' + ringPunct; + if (ringCap === caps) return 'J is inverted, but it sits on the ring, not in the block'; + return 'ok'; })()`); - ok('R58 plate carries both cases, digits and the extended set', charset === 'ok', charset); + ok('R58 inversion is zonal: J is a capital but not inverted', zones === 'ok', zones); // 14e. The layout is DATA, so revising the order is a table edit rather than a // redraw. Craig has already said the keys will change; a layout welded into -- cgit v1.2.3