diff options
Diffstat (limited to 'docs/prototypes')
| -rw-r--r-- | docs/prototypes/README.org | 52 | ||||
| -rw-r--r-- | docs/prototypes/gallery-widget.el | 4 | ||||
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 413 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 920 |
4 files changed, 856 insertions, 533 deletions
diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org index 1cb75c6..a4f591f 100644 --- a/docs/prototypes/README.org +++ b/docs/prototypes/README.org @@ -15,32 +15,32 @@ point at. affordance for the NETWORKS/NEARBY headers (busy-style throbber + list fade). - [[file:2026-07-03-sound-panel-prototype.html][2026-07-03-sound-panel-prototype.html]] — the audio/pulsemixer console; layout reference for [[file:../specs/2026-07-03-audio-panel-spec.org][the audio-panel spec]]. -- [[file:panel-widget-gallery.html][panel-widget-gallery.html]] — the shared instrument-console - widget kit (lamps, engraved sections, console keys, needle gauges). The - living catalogue: every widget here is the visual + behavioral spec for - its reusable-component ports. All widgets build from [[file:widgets.js][widgets.js]] (below); +- [[file:panel-widget-gallery.html][panel-widget-gallery.html]] — The Dupre Kit: the shared instrument-console + gallery (lamps, engraved sections, console keys, needle gauges). The + living catalogue: every instrument here is the visual + behavioral spec for + its reusable-component ports. All instruments build from [[file:widgets.js][widgets.js]] (below); the gallery page is the reference consumer. - [[file:2026-07-03-waybar-redesign-prototype.html][2026-07-03-waybar-redesign-prototype.html]] — three directions for sprucing up waybar in the dupre instrument-console aesthetic (future work). -* Widget library ([[file:widgets.js][widgets.js]]) +* The Dupre Kit ([[file:widgets.js][widgets.js]]) The whole kit lives in =widgets.js= — a classic script (no modules, no build -step) exposing the =GW= namespace with one builder per gallery card. Load it +step) exposing the =DUPRE= namespace with one builder per gallery card. Load it with =<script src="widgets.js"></script>= and call builders directly. ** Builder contract -=GW.name(host, opts)= → handle ={el, get, set, ...}=. =host= is an empty -element the widget renders into. =opts.onChange(value, text)= fires on every -state change, including the initial paint; =text= is the widget's canonical +=DUPRE.name(host, opts)= → handle ={el, get, set, ...}=. =host= is an empty +element the instrument renders into. =opts.onChange(value, text)= fires on every +state change, including the initial paint; =text= is the instrument's canonical readout string. (Exception: live meters whose original init was silent — they paint on the first =set=/=push=.) ** Styling -Widget-internal CSS ships inside =widgets.js= (the =GW_CSS= block, injected -once as =<style id="gw-css">= at load), so widgets are fully styled on any +Instrument-internal CSS ships inside =widgets.js= (the =DUPRE_CSS= block, injected +once as =<style id="dupre-css">= at load), so instruments are fully styled on any page. The design tokens (=--gold=, =--glow-hi=, the amber family, =--mono=, =--pulse-rate=, …) must be defined on =:root= by the consumer — the gallery's generated =:root= block is the reference; =tokens.json= is the source. @@ -56,60 +56,60 @@ Live meters expose value-driven handles that repaint synchronously and fire the ring buffer) - =set(samples, amp)= for sampled traces (samples normalized) -Widget-owned animation (the R17 scope trace, R31 radar sweep, R44 servo +Instrument-owned animation (the R17 scope trace, R31 radar sweep, R44 servo chase, R52 pseudo-PC, R53 day clock, R10 TIME page) runs inside the builder, gated on =prefers-reduced-motion=. -** Keyboard contract (widgets that take keys) +** Keyboard contract (instruments that take keys) Sibling of the tick contract above, and the same split: focus is an ambient resource like the clock — exactly one thing holds it across the whole page — so *the target owns focus and delivery, the builder declares what it accepts*. -Neither half works alone. Only the page knows which widget is live; only the -widget knows that Backspace means DEL. +Neither half works alone. Only the page knows which instrument is live; only the +instrument knows that Backspace means DEL. *The builder declares* a =KEYS= table on itself, mapping a canonical key name to the argument its handle's =press= takes: -: GW.abcKeypad.KEYS = { A:'A', ..., '0':'0', ..., Space:'SPC', Backspace:'DEL', Enter:'ENT' } +: DUPRE.abcKeypad.KEYS = { A:'A', ..., '0':'0', ..., Space:'SPC', Backspace:'DEL', Enter:'ENT' } One declaration, wired natively per target: - *Web* — the builder attaches a =keydown= listener *to its own focusable - element* (=tabindex=0=, focused on click). =GW.slideRule= is the precedent: + element* (=tabindex=0=, focused on click). =DUPRE.slideRule= is the precedent: arrows step it, scoped to its own element. The browser's focus system does the arbitration, which is why it doesn't fight the gallery's own global Escape handler. - *Emacs* — the SVG region is an image and never sees a keypress, so the mode installs the same table into its keymap and calls =press=. This is why the declaration is a table and not a function over a DOM event: a port that - can't read the widget's intent has to re-derive it, and then the two drift. + can't read the instrument's intent has to re-derive it, and then the two drift. - *waybar* — GTK focus, same shape. Rules, each of them a bug someone will otherwise ship: -- *Never listen on =document= or =window=.* A widget that does fights every +- *Never listen on =document= or =window=.* An instrument that does fights every sibling and the page's own handlers; on the gallery, every keystroke anywhere would type into whichever card bound last. - *=preventDefault= only what has a default worth suppressing* — Space scrolls the page, Backspace can navigate back. Nothing else. - *Let Tab and Escape bubble.* Tab is how the page is navigable at all, and - the gallery's audit stepper owns Escape. A widget that swallows either + the gallery's audit stepper owns Escape. An instrument that swallows either breaks something it can't see. - *=press= filters, it does not trust.* A handle that appends whatever it is handed will cheerfully append "F1". The table is the allowlist. - *Click and key must land in the same place* — both route through =press=, so the two paths cannot drift apart or fire different events. -A widget with no =KEYS= table takes no keys, which is most of them: the kit is +An instrument with no =KEYS= table takes no keys, which is most of them: the kit is click-first and that is what makes it port. Keys are additive, and in Emacs they are the *more* native idiom, not the less. ** Style options -Widgets with named style axes take them as constructor opts backed by a -=STYLES= table on the builder — e.g. =GW.slideToggle(host, {onStyle:'green', -thumb:'brass'})= with the axes enumerated in =GW.slideToggle.STYLES=, and +Instruments with named style axes take them as constructor opts backed by a +=STYLES= table on the builder — e.g. =DUPRE.slideToggle(host, {onStyle:'green', +thumb:'brass'})= with the axes enumerated in =DUPRE.slideToggle.STYLES=, and =handle.setStyle(axis, name)= to restyle a live instance. The gallery's option chips are demo rigs driving =setStyle=. @@ -130,8 +130,8 @@ family, glows, pulse rate). [[file:gen_tokens.py][gen_tokens.py]] regenerates al - [[file:tokens-waybar.css][tokens-waybar.css]] — GTK =@define-color= declarations for the waybar panels - [[file:gallery-tokens.el][gallery-tokens.el]] — an elisp alist for svg.el renderers -[[file:gallery-widget.el][gallery-widget.el]] is the Emacs renderer (proof widget: the needle gauge, -gallery card 10) — it reads gallery-tokens.el and emits the widget as SVG via +[[file:gallery-widget.el][gallery-widget.el]] is the Emacs renderer (proof instrument: the needle gauge, +gallery card 10) — it reads gallery-tokens.el and emits the instrument as SVG via svg.el, so the same look renders inside Emacs. Tests: =tests/gallery-tokens/= (generator, unittest) and =tests/gallery-widgets/= (renderer, ERT), both in =make test-unit=. diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el index 1c96f45..5d69148 100644 --- a/docs/prototypes/gallery-widget.el +++ b/docs/prototypes/gallery-widget.el @@ -85,7 +85,7 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below." svg (dom-node 'defs nil (dom-node 'filter - '((id . "gw-glow") (x . "-75%") (y . "-75%") + '((id . "dupre-glow") (x . "-75%") (y . "-75%") (width . "250%") (height . "250%")) (dom-node 'feGaussianBlur '((in . "SourceGraphic") @@ -119,7 +119,7 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below." :stroke-width "4" :stroke-linecap "round" :opacity "0.55" - :filter "url(#gw-glow)") + :filter "url(#dupre-glow)") (gallery-widget--node svg 'line :class "needle" :x1 (gallery-widget--fmt cx) diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 95b3719..0bf3ab8 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -3,7 +3,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>Panel widget gallery — dupre instrument console (interactive)</title> +<title>The Dupre Kit — retro instrument console (interactive)</title> <style> :root{ /* @tokens:start */ @@ -72,8 +72,8 @@ 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} -/* 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 +/* INSTRUMENTS/ROW sets an explicit column count (1-4); the stage zoom scales with it + so an instrument fills its column — fewer per row, bigger instruments. 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)} @@ -83,7 +83,7 @@ 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 */ +/* intrinsically tiny instruments get an extra bump so they do not float in an empty stage */ .stagew.boost{zoom:1.5} body[data-cols="1"] .stagew.boost{zoom:4.2} body[data-cols="2"] .stagew.boost{zoom:3.15} @@ -188,6 +188,19 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} border:1px solid rgba(255,255,255,.22);box-shadow:0 1px 3px rgba(0,0,0,.5)} .famchips .fc.on{outline:2px solid var(--silver);outline-offset:1px} +/* 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)} +#card-N20 .famchips .fc::after{position:absolute;inset:0;display:grid;place-items:center;font-size:12px;font-weight:700;line-height:1;content:"A"} +#card-N20 .fc[title="dark"]::after{color:#f3e7c5} +#card-N20 .fc[title="white"]::after{color:#ffffff} +#card-N20 .fc[title="light"]::after{color:#1b1813} +#card-N20 .fc[title="paper"]::after{color:#17181a} +#card-N20 .fc[title="mono"]::after{color:#bfc4d0;font-family:var(--mono)} +#card-N20 .fc[title="helv"]::after{color:#bfc4d0;font-family:Helvetica,'Helvetica Neue',Arial,sans-serif} @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} </style> </head> @@ -195,17 +208,17 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} <div class="wrap"> <header class="masthead"> <div class="eyebrow">archsetup · dupre panel family · live kit</div> - <h1>Widget gallery — the instrument-console kit, fully driveable</h1> + <h1>The Dupre Kit — the instrument console, fully driveable</h1> <div class="mastrow"> <div class="mastintro"> <p>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. <b>Controls</b> take input; <b>meters & gauges</b> show a live analog value; <b>indicators & - readouts</b> show state or a number. Every widget is operable: drag the faders, knobs and gauges; click the + readouts</b> show state or a number. Every instrument 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.</p> - <div class="szbar"><span class="lab">widgets/row</span> + <div class="szbar"><span class="lab">instruments/row</span> <button class="key" data-cols="1">1</button> <button class="key" data-cols="2">2</button> <button class="key on" data-cols="3">3</button> @@ -227,7 +240,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} <span class="scr" style="top:6px;left:6px"></span><span class="scr" style="top:6px;right:6px"></span> <span class="scr" style="bottom:6px;left:6px"></span><span class="scr" style="bottom:6px;right:6px"></span> <div class="tt">Policy</div> - <div class="pnote">What each widget's colour is bound by, and what it may change and stay true. Click a row to walk its cards; open a card's spec sheet for the reason and the authentic range.</div> + <div class="pnote">What each instrument's colour is bound by, and what it may change and stay true. Click a row to walk its cards; open a card's spec sheet for the reason and the authentic range.</div> <div class="vtally ptally" id="ptally"></div> </nav> </div> @@ -251,7 +264,7 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} <script src="widgets.js"></script> <script> const $ = id => document.getElementById(id); -const {svgEl,polar,dragX,dragY,dragDelta,seg7,buildBars,VUDB,vuDb,SCREEN_FAMS}=GW; +const {svgEl,polar,dragX,dragY,dragDelta,seg7,buildBars,VUDB,vuDb,SCREEN_FAMS}=DUPRE; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; document.querySelectorAll('.szbar .key').forEach(b=>b.addEventListener('click',()=>{ document.body.dataset.cols=b.dataset.cols; @@ -262,7 +275,7 @@ document.querySelectorAll('.szbar .key').forEach(b=>b.addEventListener('click',( 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) */ +/* boost the intrinsically tiny instruments (audit: content under 10% of the stage) */ const BOOST=['01','07','08','12','13','N14','18']; /* ---- palette: the named instrument colors, grouped by role. No codes shown — names and uses. ---- */ @@ -326,7 +339,7 @@ const PALETTE=[ host.appendChild(tile);}} })(); -/* ---- screen-color families: period-authentic alternates for the screen widgets ---- +/* ---- screen-color families: period-authentic alternates for the screen instruments ---- green = P1 CRT phosphor (radar/scopes; hi reuses --phos, graticule keeps the kit's scope green) amber = P3 phosphor / backlit amber (the gold family the kit already speaks) red = neon orange-red, the nixie's color pulled out as a family @@ -335,7 +348,7 @@ const PALETTE=[ white = monochrome LCD/CRT white Exploration data, deliberately NOT in tokens.json yet — families get tokenized once picked, the same way amber earned its tokens. Values keyed by the CSS custom property they drive. */ -/* SCREEN_FAMS lives in widgets.js (GW.SCREEN_FAMS) */ +/* SCREEN_FAMS lives in widgets.js (DUPRE.SCREEN_FAMS) */ function screenChips(no,el,fams,def){ const cardEl=el.closest('.card'); if(!cardEl)return; const row=document.createElement('div'); row.className='famchips'; @@ -351,9 +364,9 @@ function screenChips(no,el,fams,def){ cardEl.querySelector('.opts').appendChild(row); } /* ---- per-card spec sheets ---- - input how you drive it, incl. the model: click widgets port to every target (Emacs click-regions - included); drag widgets have no Emacs drag equivalent and need a click/key idiom there; - display widgets take no input in real use (gallery interaction is demo only). + input how you drive it, incl. the model: click instruments port to every target (Emacs click-regions + included); drag instruments have no Emacs drag equivalent and need a click/key idiom there; + display instruments take no input in real use (gallery interaction is demo only). period present only when the component is clearly not timeless — use it to keep a console period-consistent (swap a modern part for its historical counterpart or vice versa). */ const INFO={ @@ -948,7 +961,7 @@ const INFO={ origin:'Nixie tubes.',difficulty:'Intuitive.', prefer:'Warmth and era matter more than crispness.', period:'1955-75 hard period: sits with keypads and telegraphs, clashes with VFD and LED seven-seg.'}, -'N20':{input:'Display only; flaps animate on change.', +'N20':{input:'Click steps to the next word. Each cell flips one flap at a time through its charset, so cells arrive staggered; a mid-spin change re-aims the cascade.', solves:'A changed value that announces itself mechanically.', use:'Specialty. Shines for arrivals-board style updates.', limits:'Character set and flap speed are the charm and the constraint.', @@ -1154,16 +1167,23 @@ function gvCopy(btn){ Clicking a row jumps to the next card in that state (cycles), so a count that disagrees with a visual scan can be audited card by card. */ const VJUMP={green:0,amber:0,off:0}; +/* shared tally-row markup for the index panels (validation + policy): the same + dot/label/count row was written out per bucket in both tallies. attr names the + grouping attribute (data-v or data-g); dot is the .vdot state or '' for none. */ +function tallyRow(attr,key,dot,label,n){ + return `<div class="vrow" ${attr}="${key}"><span class="vdot"${dot?` data-v="${dot}"`:''}></span>${label}<span class="vn">${n}</span></div>`; +} +function tallyTotal(n){return `<div class="vrow vtot">total<span class="vn">${n}</span></div>`;} function updateVTally(){ const el=$('vtally'); if(!el)return; const lamps=[...document.querySelectorAll('.vlamp')]; const n={off:0,amber:0,green:0}; lamps.forEach(l=>{n[l.dataset.v]=(n[l.dataset.v]||0)+1;}); el.innerHTML= - `<div class="vrow" data-v="green"><span class="vdot" data-v="green"></span>done<span class="vn">${n.green}</span></div>`+ - `<div class="vrow" data-v="amber"><span class="vdot" data-v="amber"></span>in progress<span class="vn">${n.amber}</span></div>`+ - `<div class="vrow" data-v="off"><span class="vdot"></span>not done<span class="vn">${n.off}</span></div>`+ - `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`+ + tallyRow('data-v','green','green','done',n.green)+ + tallyRow('data-v','amber','amber','in progress',n.amber)+ + tallyRow('data-v','off','','not done',n.off)+ + tallyTotal(lamps.length)+ `<button class="vcopy" type="button">copy for source</button>`; el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{ VJUMP[row.dataset.v]=0; auditNext(row.dataset.v); @@ -1194,30 +1214,31 @@ function auditStop(){const p=$('vaudit');if(p)p.remove();} 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.<name>( + that instantiates a DUPRE.* instrument into the stage; rd(txt) writes the card readout. */ +/* Recover a card's builder from the arrow it was handed — the first DUPRE.<name>( in the source — and read its POLICY record off it. Single source: the record - lives on GW.<builder>.POLICY in widgets.js and the card only displays it, so the + lives on DUPRE.<builder>.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.<name>( 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.<name>( before its builder would + /* Assumes the FIRST DUPRE.<name>( in the arrow is the builder — true because every + card is (st,rd)=>DUPRE.builder(st,{...}) and helpers are destructured, called + bare. An arrow that referenced a second DUPRE.<name>( 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; + const m=html.toString().match(/DUPRE\.(\w+)\s*\(/); + return (m && DUPRE[m[1]] && DUPRE[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 meta=kind&&DUPRE.POLICIES[kind]; + const free=meta&&meta.free; + 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,'"')}">${kind||'—'}</span>`+ `<span class="vlamp" data-v="off"></span></div>`+ `<div class="stagew">${isBuild?'':html}</div><div class="wrd" id="rd-${no}">—</div>`+ @@ -1232,7 +1253,7 @@ function card(host, no, name, html, note){ if(inf){const d=document.createElement('details'); d.className='winfo'; let rows=''; if(P){ - rows+=`<div class="ik">policy</div><div class="iv"><b>${kind}</b> — ${GW.POLICIES[kind].gist}</div>`; + rows+=`<div class="ik">policy</div><div class="iv"><b>${kind}</b> — ${meta.gist}</div>`; rows+=`<div class="ik">why</div><div class="iv">${P.why}</div>`; rows+=`<div class="ik">authentic</div><div class="iv">${P.authentic}</div>`; } else { @@ -1243,7 +1264,7 @@ function card(host, no, name, html, note){ d.innerHTML=`<summary>spec sheet</summary><div class="igrid">${rows}</div>`; c.appendChild(d);} host.appendChild(c); - if(isBuild)c.gw=html(c.querySelector('.stagew'),txt=>setRd(no,txt)); + if(isBuild)c.dupre=html(c.querySelector('.stagew'),txt=>setRd(no,txt)); return c; } function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;} @@ -1260,385 +1281,385 @@ function updatePolTally(){ const n={free:0,locked:0,none:0}; cards.forEach(c=>{n[grp(c.dataset.cpol)]++;}); el.innerHTML= - `<div class="vrow" data-g="free"><span class="vdot" data-v="green"></span>free<span class="vn">${n.free}</span></div>`+ - `<div class="vrow" data-g="locked"><span class="vdot" data-v="amber"></span>locked<span class="vn">${n.locked}</span></div>`+ - `<div class="vrow" data-g="none"><span class="vdot"></span>unclassified<span class="vn">${n.none}</span></div>`+ - `<div class="vrow vtot">total<span class="vn">${cards.length}</span></div>`; + tallyRow('data-g','free','green','free',n.free)+ + tallyRow('data-g','locked','amber','locked',n.locked)+ + tallyRow('data-g','none','','unclassified',n.none)+ + tallyTotal(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) */ +/* drag helpers, seg7, buildBars live in widgets.js (DUPRE) */ /* ============ CONTROLS ============ */ const C=$('controls'); card(C,'01','Slide toggle', - (st,rd)=>GW.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<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)}), + (st,rd)=>DUPRE.consoleKeys(st,{onChange:(i,t)=>rd(t)}), '<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)}), + (st,rd)=>DUPRE.faderH(st,{value:68,onChange:(v,t)=>rd(t)}), '<b>continuous 0-100.</b> Per-device volume, brightness. Drag the gold cap; readout tracks.'); card(C,'04','Vertical fader', (st,rd)=>{let ready=false; const upd=()=>{if(ready)rd('A '+Math.round(a.get())+' · B '+Math.round(b.get()));}; - const a=GW.faderV(st,{value:60,onChange:upd}); - const b=GW.faderV(st,{value:35,onChange:upd}); + const a=DUPRE.faderV(st,{value:60,onChange:upd}); + const b=DUPRE.faderV(st,{value:35,onChange:upd}); ready=true;upd();return {a,b};}, '<b>channel-strip style.</b> A mixer column per device. Drag each fader up or down.'); card(C,'05','Rotary knob', - (st,rd)=>GW.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}), + (st,rd)=>DUPRE.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}), '<b>dial in a value.</b> Volume/gain the analog way. Drag up/down to turn; readout shows the level.'); card(C,'06','Segmented selector', - (st,rd)=>GW.segmented(st,{onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.segmented(st,{onChange:(i,t)=>rd(t)}), '<b>pick one of a few.</b> 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)}), + (st,rd)=>DUPRE.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>inline binary.</b> 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)}), + (st,rd)=>DUPRE.armButton(st,{onChange:(v,t)=>rd(t)}), '<b>two-stage confirm.</b> Destructive actions. First click arms (red), second fires. Readout shows the stage.'); card(C,'09','Lamp row', - (st,rd)=>GW.lampRow(st,{onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.lampRow(st,{onChange:(i,t)=>rd(t)}), '<b>actionable list item.</b> Lamp + name + status. Click to cycle idle → connecting → connected.'); card(C,'24','Rotary selector', - (st,rd)=>GW.rotarySelector(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.rotarySelector(st,{onChange:(v,t)=>rd(t)}), '<b>pick one of N by position.</b> Printed detents, the pointer names the value. Click to turn; readout shows it.'); card(C,'25','Slide-rule dial', - (st,rd)=>GW.slideRule(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.slideRule(st,{onChange:(v,t)=>rd(t)}), '<b>value on a printed scale.</b> A lit pointer glides a warm-backlit strip. Printed numerals are the majors; the units between them carry minor ticks. Click any of them to jump, or focus and press ←/→ (↑/↓) to step a unit; readout shows it. Four faces — warm backlit, chrome, 80s black glass, marantz blue — on the chips below.'); card(C,'N01','Rocker power switch', - (st,rd)=>GW.rocker(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.rocker(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>hard on / off, lit legend.</b> A master power paddle — the pressed half glows. Click to rock.'); card(C,'N02','Transport cluster', - (st,rd)=>GW.transport(st,{mode:'play',animate:!reduced,onChange:(m,t)=>rd(t)}), + (st,rd)=>DUPRE.transport(st,{mode:'play',animate:!reduced,onChange:(m,t)=>rd(t)}), '<b>run / pause / capture.</b> One mode lit at a time, reels turn while it plays. Click a button.'); card(C,'N03','Radio preset bank', - (st,rd)=>GW.presetBank(st,{onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.presetBank(st,{onChange:(i,t)=>rd(t)}), '<b>pick one, the rest release.</b> The car-radio preset row — mechanically exclusive. Click one.'); card(C,'N04','Concentric dual knob', - (st,rd)=>GW.dualKnob(st,{outer:50,inner:50,onChange:(o,i)=>rd('bass '+Math.round(o)+' · treble '+Math.round(i))}), + (st,rd)=>DUPRE.dualKnob(st,{outer:50,inner:50,onChange:(o,i)=>rd('bass '+Math.round(o)+' · treble '+Math.round(i))}), '<b>two values on one spindle.</b> Bass / treble, coarse / fine. Drag the outer ring or inner cap — each is independent.'); card(C,'N05','Rotary encoder + LED ring', - (st,rd)=>GW.encoder(st,{value:7,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.encoder(st,{value:7,onChange:(v,t)=>rd(t)}), '<b>endless dial, lit position.</b> A detentless encoder; the LED arc lights to the level and the value keeps accumulating. Drag to turn.'); card(C,'N06','Keyed mode switch', - (st,rd)=>GW.keySwitch(st,{index:1,onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.keySwitch(st,{index:1,onChange:(i,t)=>rd(t)}), '<b>guarded three-position mode.</b> Off · on · run. The key-bit points at the live position. Click to turn.'); card(C,'N07','Center-detented crossfader', - (st,rd)=>GW.crossfader(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.crossfader(st,{onChange:(v,t)=>rd(t)}), '<b>throw to either side of zero.</b> A fader notched at center — balance, L/R mix. Drag; readout shows the signed value.'); card(C,'N08','Thumbwheel', - (st,rd)=>GW.thumbwheel(st,{value:42,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.thumbwheel(st,{value:42,onChange:(v,t)=>rd(t)}), '<b>ribbed edge-wheel, one value.</b> A knurled wheel with a windowed number. Drag up/down to roll.'); card(C,'N09','DIP-switch bank', - (st,rd)=>GW.dipBank(st,{onChange:(w,t)=>rd(t)}), + (st,rd)=>DUPRE.dipBank(st,{onChange:(w,t)=>rd(t)}), '<b>a bank of hard flags.</b> Hardware slide switches — up is on. Click each to flip; readout is the binary word.'); card(C,'N10','Jog / shuttle wheel', - (st,rd)=>GW.jogWheel(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.jogWheel(st,{onChange:(v,t)=>rd(t)}), '<b>scrub fine, shuttle fast.</b> The edit-deck wheel. Drag to jog the inner wheel; readout shows the position.'); card(C,'R02','Calibrated vernier dial', - (st,rd)=>GW.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}), '<b>precision on a rotating disc.</b> The big lab dial — the calibrated disc turns under a fixed hairline. Drag up/down; readout reads to a tenth. After a 1950s lab-oscillator vernier.'); card(C,'R03','Bat-handle toggle', - (st,rd)=>GW.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>the period power switch.</b> Chrome bat lever on a hex bushing, snaps up ON / down OFF. Click to throw it. After a lab-instrument panel toggle.'); card(C,'R04','Bakelite fluted knob', - (st,rd)=>GW.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}), '<b>the skirted console knob.</b> Scalloped bakelite skirt, glossy dome, amber index over a printed 0-10 scale. Drag up/down to turn. After a vintage console mixer knob.'); card(C,'R05','Filter slider bank', - (st,rd)=>GW.filterBank(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.filterBank(st,{onChange:(v,t)=>rd(t)}), '<b>a wall of band faders.</b> Twelve bands on a screwed faceplate, dB rails both sides. Drag any cap; readout names band and level. Skins split three independent axes — panel (silver hi-fi after the Pioneer SG-9500 / studio black after the Technics SH-8065), cap shape (tall block fader after the Zaxcom Oasis / short ribbed / chrome T), and cap color (black white-index / red / green / blue / amber stripes / chrome / cream). The chips below mix them freely.'); card(C,'R06','Chicken-head selector', - (st,rd)=>GW.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}), '<b>the pointer-lever switch.</b> The tapered bakelite lever IS the indicator — it aims at the engraved position. Click to step through. After a modulator mode switch.'); card(C,'R12','Chrome slot fader', - (st,rd)=>GW.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}), '<b>the console gain slide.</b> A chrome T-handle rides a recessed slot, the dB scale engraved on the plate — gain above zero, cut below. Drag; readout shows signed dB. After a vintage channel-strip output fader.'); card(C,'R14','Spade-pointer tuning knob', - (st,rd)=>GW.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}), '<b>the pointer rides the knob.</b> A knurled bakelite knob carries a bright spade pointer over a scale engraved into the panel itself. Drag up/down to tune. After a 1920s radio receiver dial.'); card(C,'R15','Multi-band dial', - (st,rd)=>GW.multiBandDial(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.multiBandDial(st,{onChange:(v,t)=>rd(t)}), '<b>four scales, one needle.</b> Nested band arcs on a cream face; the active ring reads bold. Drag the left dial to tune; click the bandspread dial to switch bands. After a shortwave receiver dial pair.'); card(C,'R16','Entry keypad', - (st,rd)=>GW.entryKeypad(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.entryKeypad(st,{onChange:(v,t)=>rd(t)}), '<b>a composed entry instrument.</b> Worn keycaps feed the amber display; the lamp watches the buffer; the amber keys confirm and clear. Click the keys. After an industrial keypad prop.'); card(C,'R18','Thumb-slide attenuator pair', - (st,rd)=>GW.thumbSlide(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.thumbSlide(st,{onChange:(v,t)=>rd(t)}), '<b>a lit column of numbers, a thumb tab.</b> The scale glows inside the rail; the cream tab rides beside it and the nearest figure lights. Drag either channel. After a compressor blend/mix pair.'); card(C,'R19','Waveform region editor', - (st,rd)=>GW.waveRegion(st,{start:22,end:76,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.waveRegion(st,{start:22,end:76,onChange:(v,t)=>rd(t)}), '<b>pick a region on the wave.</b> The monochrome LCD shows the sample; drag near either flag to move START or END, and the selection draws bright. After a sampler edit screen.'); card(C,'R20','Drum roller selector', - (st,rd)=>GW.drumRoller(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.drumRoller(st,{onChange:(v,t)=>rd(t)}), '<b>roll to the number.</b> The numbered drum shows through a tall window and the center value sits on an inverted chip. Drag either drum. After an equalizer tone selector.'); card(C,'R21','LED program row', - (st,rd)=>GW.ledRow(st,{index:5,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.ledRow(st,{index:5,onChange:(v,t)=>rd(t)}), '<b>identical keys, one lit dot.</b> The LED above the button carries the selection, not the key itself — the classic program-select row. Click a key; readout names the program. After a digital reverb’s program bank.'); card(C,'R22','Three-position slide', - (st,rd)=>GW.pillSlide(st,{index:1,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.pillSlide(st,{index:1,onChange:(v,t)=>rd(t)}), '<b>a detented pill, three stops.</b> The chrome pill parks at A, AB, or B, and the LED pair below tells the truth — both light on AB. Click a position. After a drum machine variation switch.'); card(C,'R23','Spun-aluminum knob', - (st,rd)=>GW.spunKnob(st,{value:62,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.spunKnob(st,{value:62,onChange:(v,t)=>rd(t)}), '<b>bright metal, machined rings.</b> A spun face with concentric tooling marks in a knurled grip ring, red index over dot ticks. Drag up/down. After a boutique pedal knob, confirmed by an avionics cluster.'); card(C,'R24','Stomp switch + jewel', - (st,rd)=>GW.stompSwitch(st,{on:false,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.stompSwitch(st,{on:false,onChange:(v,t)=>rd(t)}), '<b>the engage pair.</b> A chrome dome stomp and the faceted jewel that reports it — press to engage, the jewel lights; press again to bypass. Click it. After a tremolo pedal footswitch.'); card(C,'R27','Winged gain selector', - (st,rd)=>GW.wingSelector(st,{index:7,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.wingSelector(st,{index:7,onChange:(v,t)=>rd(t)}), '<b>the red T-bar, stepped.</b> A bar-grip handle over a dot ring, snapping between labeled gain detents. Drag up/down to step it. After a classic mic-preamp gain switch.'); card(C,'R28','Rotary disc switch', - (st,rd)=>GW.discSwitch(st,{index:1,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.discSwitch(st,{index:1,onChange:(v,t)=>rd(t)}), '<b>the master power disc.</b> The whole red disc turns, grip bar molded in — a heavy three-position selector for OFF, ON, and COMBINE. Click to step. After a marine battery switch.'); card(C,'R29','Guarded toggle', - (st,rd)=>GW.guardedToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.guardedToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>friction sized to consequence.</b> Guard posts flank the lever and a red collar marks it critical — the avionics way to prevent an accidental throw. Click to throw it deliberately. After a flight-deck switch panel.'); card(C,'R32','Mechanical timer dial', - (st,rd)=>GW.timerDial(st,{minutes:0,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.timerDial(st,{minutes:0,onChange:(v,t)=>rd(t)}), '<b>turn to start, push to stop.</b> Drag the knurled dial to wind minutes onto the fixed red index; it winds back down to OFF on its own (demo runs a minute per second). Click the red knob to stop it dead. After a mechanical interval timer.'); card(C,'R33','Four-way rocker', - (st,rd)=>GW.rockerPad(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.rockerPad(st,{onChange:(v,t)=>rd(t)}), '<b>discrete direction, one press at a time.</b> Click an arrow (the whole quadrant is the target) to step that way; the readout tracks the cursor position it drives. After a remote-control navigation pad.'); card(C,'R34','Four-way toggle selector', - (st,rd)=>GW.fourWayToggle(st,{position:'C',onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.fourWayToggle(st,{position:'C',onChange:(v,t)=>rd(t)}), '<b>a lever that lives in one of four states.</b> Click a quadrant and the ball lever throws to A, B, C or D; the matching corner lamp takes the light. The rocker steps — this one selects. After a test-panel quadrant switch.'); card(C,'R37','Pin routing matrix', - (st,rd)=>GW.pinMatrix(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.pinMatrix(st,{onChange:(v,t)=>rd(t)}), '<b>many-to-many routing you can read.</b> Sources are rows, destinations are columns; click an intersection to seat a pin and the route exists. Every simultaneous route stays visible. After the EMS VCS3 pin matrix.'); card(C,'R38','Dead-man button', - (st,rd)=>GW.deadMan(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.deadMan(st,{onChange:(v,t)=>rd(t)}), '<b>active only while held.</b> Press and hold — the ring arms, the lamp runs, the dwell clock counts. Let go anywhere and it drops to SAFE. The kit has toggles and momentaries; this is the only control whose state IS your grip.'); card(C,'R39','Rotary telephone dial', - (st,rd)=>GW.telephoneDial(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.telephoneDial(st,{onChange:(v,t)=>rd(t)}), '<b>numeric entry embodied as time.</b> Click a hole: the finger wheel winds to the stop and spins back, and the digit lands in the number. Bigger digits take longer, exactly like the pulses did. After a Western Electric dial.'); card(C,'R40','Circuit breaker panel', - (st,rd)=>GW.breakerPanel(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.breakerPanel(st,{onChange:(v,t)=>rd(t)}), '<b>the first control the system can throw.</b> Click a breaker to close or open it; hit TRIP and a fault pops one out to the tripped mid-position, amber collar showing. A tripped breaker must be pulled to reset before it closes again. After an aircraft breaker row.'); card(C,'R41','DSKY verb-noun panel', - (st,rd)=>GW.dsky(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dsky(st,{onChange:(v,t)=>rd(t)}), '<b>a command grammar, not a button per command.</b> VERB is the action, NOUN is the operand: press VERB 3 5 ENTR for the lamp test. Wrong grammar lights OPR ERR. Two keys plus digits reach every function — the Apollo answer to a panel with too few buttons.'); card(C,'R42','Cam-timer program drum', - (st,rd)=>GW.camTimer(st,{position:0,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.camTimer(st,{position:0,onChange:(v,t)=>rd(t)}), '<b>a procedure as a rotating position.</b> The ring is the whole program — fill, wash, rinse, spin — and the pointer is where you are in it. Click to advance (or start from OFF); it then walks itself through the steps. After a washing-machine cam timer.'); card(C,'R48','Knife switch', - (st,rd)=>GW.knifeSwitch(st,{closed:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.knifeSwitch(st,{closed:true,onChange:(v,t)=>rd(t)}), '<b>the visible disconnect.</b> Copper blades either sit in their jaws or hang open in the air — you can SEE the circuit is broken, which is the entire safety argument. Click the handle to throw it. After early power-station switchboards.'); card(C,'R49','Decade switch box', - (st,rd)=>GW.decadeBox(st,{digits:[3,5,0,0],onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.decadeBox(st,{digits:[3,5,0,0],onChange:(v,t)=>rd(t)}), '<b>a number composed a digit at a time.</b> Four skirted knobs, one per decade — drag each to its digit and the total just IS their sum. The thumbwheel does one digit; this is how a lab built a precise value. After a General Radio decade resistance box.'); card(C,'R50','Two-hand safety control', - (st,rd)=>GW.twoHandSafety(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.twoHandSafety(st,{onChange:(v,t)=>rd(t)}), '<b>both hands, deliberately.</b> Click one palm button to arm the window, the other within half a second to cycle the press. Too slow — TIE-DOWN fault, start over. The hardware keeps hands out of the die; the grammar survives on screen. After OSHA press controls.'); card(C,'R51','Voice-loop keyset', - (st,rd)=>GW.voiceLoop(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.voiceLoop(st,{onChange:(v,t)=>rd(t)}), '<b>attention management, not selection.</b> Each loop is independent: click to monitor (green bar, flickering with activity), again to talk (amber, exclusive — one voice out), again to drop. Flight controllers ran a dozen of these at once. After a mission-control comm keyset.'); card(C,'R57','ABC entry keypad', - (st,rd)=>GW.abcKeypad(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.abcKeypad(st,{onChange:(v,t)=>rd(t)}), '<b>letters on a faceplate.</b> A-Z laid out alphabetically, not QWERTY — the industrial convention wherever the operator is not assumed to touch-type. Click the keys, or click the plate and type: it takes the keyboard once focused, and every key routes through the same handle a click does. DEL takes back one character, CLR wipes the lot, ENT commits. R16 enters digits; this is its alphanumeric sibling, and the only card in the kit that takes free text. After fleet and kiosk keypads: the membrane plate\'s colour-coded CLR / ENT, the stainless plate\'s letters-left arrangement and its backspace. Its CANCEL is dropped — that plate is a whole terminal with a transaction to abandon, where this is one control in a panel that owns its own dismiss.'); card(C,'R58','Index typewriter', - (st,rd)=>GW.indexPlate(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.indexPlate(st,{onChange:(v,t)=>rd(t)}), '<b>type without a keyboard.</b> 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 <b>no 1 and no 0</b> — 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'); card(M,'10','Needle gauge', - (st,rd)=>GW.needleGauge(st,{value:50,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.needleGauge(st,{value:50,onChange:(v,t)=>rd(t)}), '<b>analog dial.</b> Throughput, battery, volume. Drag up/down to sweep the needle; readout tracks. <b>Smooth sweep wants a Cairo/GTK area.</b>'); card(M,'11','Stereo VU (LED bar)', - (st,rd)=>GW.vuPair(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.vuPair(st,{onChange:(v,t)=>rd(t)}), '<b>live signal level.</b> The sound panel meter row, peak-hold outline. Runs a live signal; readout shows L/R.'); card(M,'12','Mini signal (4-bar)', - (st,rd)=>GW.miniSig(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.miniSig(st,{onChange:(v,t)=>rd(t)}), '<b>compact activity.</b> Per-row "is this device playing". Live signal; readout shows activity %.'); card(M,'13','Signal ladder', - (st,rd)=>GW.signalLadder(st,{value:3,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.signalLadder(st,{value:3,onChange:(v,t)=>rd(t)}), '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Click to cycle strength; readout shows bars.'); card(M,'14','Linear fuel bar', - (st,rd)=>{const a=GW.fuelBar(st,{value:72,onChange:(v,t)=>rd(t)}); - const b=GW.fuelBar(st,{value:40,onChange:(v,t)=>rd(t)}); + (st,rd)=>{const a=DUPRE.fuelBar(st,{value:72,onChange:(v,t)=>rd(t)}); + const b=DUPRE.fuelBar(st,{value:40,onChange:(v,t)=>rd(t)}); a.set(72);return {a,b};}, '<b>a single 0-100.</b> Battery, disk, download. Drag either bar; warn tint under threshold; readout tracks.'); card(M,'15','Radial ring', - (st,rd)=>GW.radialRing(st,{value:68,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.radialRing(st,{value:68,onChange:(v,t)=>rd(t)}), '<b>percentage as a donut.</b> CPU, battery. Drag up/down to set; conic-gradient tracks the readout.'); card(M,'16','Sparkline', - (st,rd)=>GW.sparkline(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.sparkline(st,{onChange:(v,t)=>rd(t)}), '<b>recent history.</b> Throughput/CPU over the last minute. Live trace; readout shows the current value.'); card(M,'17','Waveform strip', - (st,rd)=>GW.waveStrip(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.waveStrip(st,{onChange:(v,t)=>rd(t)}), '<b>audio waveform / scope.</b> A richer signal view. Live trace; readout shows amplitude. <b>Needs a drawing surface.</b>'); card(M,'N11','Oscilloscope', - (st,rd)=>GW.scope(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.scope(st,{onChange:(v,t)=>rd(t)}), '<b>a live phosphor trace.</b> A waveform swept over a graticule. Live; readout shows Vpp. <b>Needs a Cairo/GTK area for a smooth trace.</b>'); card(M,'N12','Spectrum / EQ bars', - (st,rd)=>GW.eqBars(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.eqBars(st,{onChange:(v,t)=>rd(t)}), '<b>level across bands.</b> A live multi-band bar graph — spectrum, per-core CPU. Live; readout shows the peak band.'); card(M,'N13','Crossed-needle meter', - (st,rd)=>GW.crossNeedle(st,{value:55,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.crossNeedle(st,{value:55,onChange:(v,t)=>rd(t)}), '<b>two readings, one face.</b> Forward + reflected needles cross. Drag up/down to drive power; readout shows both. <b>Round build wants a drawing surface.</b>'); card(M,'N14','Thermometer column', - (st,rd)=>GW.thermometer(st,{value:58,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.thermometer(st,{value:58,onChange:(v,t)=>rd(t)}), '<b>a rising column.</b> The classic mercury tube — temperature, a climbing load. Drag up/down; readout shows the value.'); card(M,'N15','Bourdon pressure gauge', - (st,rd)=>GW.bourdon(st,{value:45,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.bourdon(st,{value:45,onChange:(v,t)=>rd(t)}), '<b>a round dial with a red zone.</b> Needle over a printed arc, redline in terracotta. Drag to set PSI; readout tracks. <b>Round sweep is a drawing-area job.</b>'); card(M,'N16','Strip-chart recorder', - (st,rd)=>GW.stripChart(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.stripChart(st,{onChange:(v,t)=>rd(t)}), '<b>a scrolling pen trace.</b> History scrolls left, the pen writes at the right edge. Live; readout shows the current value. <b>Real trace wants a drawing surface.</b>'); card(M,'N17','Correlation meter (±)', - (st,rd)=>GW.corrMeter(st,{value:58,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.corrMeter(st,{value:58,onChange:(v,t)=>rd(t)}), '<b>deviation from center.</b> The needle rests at 0 and swings ±. Drag left/right; readout shows the signed value.'); card(M,'N18','Battery-cell gauge', - (st,rd)=>GW.battCells(st,{value:62,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.battCells(st,{value:62,onChange:(v,t)=>rd(t)}), '<b>charge as discrete cells.</b> Filled cells count up, the last warn in terracotta. Drag left/right to set; readout shows %.'); card(M,'R01','Moving-coil VU meter', - (st,rd)=>GW.mcVu(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.mcVu(st,{onChange:(v,t)=>rd(t)}), '<b>the classic cream-faced VU.</b> Printed dB arc, red zone past 0, ballistic needle. Runs the live signal; readout shows VU. After a vintage console meter pair.'); card(M,'R07','Round panel meter', - (st,rd)=>GW.roundMeter(st,{value:50,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.roundMeter(st,{value:50,onChange:(v,t)=>rd(t)}), '<b>the porthole lab meter.</b> Cream dial in a round black bezel, printed dB arc, black needle. Drag up/down to drive it; readout shows dB. After a modulator front meter.'); card(M,'R08','Chrome MIN/MAX indicator', - (st,rd)=>GW.chromeMinMax(st,{value:50,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.chromeMinMax(st,{value:50,onChange:(v,t)=>rd(t)}), '<b>the hi-fi trim window.</b> Polished chrome ring, brushed dome, a dark pointer sweeping MIN to MAX over the metal. Drag up/down; readout shows the position. After a hi-fi amplifier level window.'); card(M,'R09','Black-face aviation gauge', - (st,rd)=>GW.aviationGauge(st,{value:52,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.aviationGauge(st,{value:52,onChange:(v,t)=>rd(t)}), '<b>amber on black, zone-marked.</b> The cockpit round: amber ticks and numerals on a black face, painted caution and limit sectors, amber needle. Drag up/down; readout tracks. After a flight-instrument cluster.'); card(M,'R13','Edgewise strip meter', - (st,rd)=>GW.edgeMeter(st,{value:.62,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.edgeMeter(st,{value:.62,onChange:(v,t)=>rd(t)}), '<b>a needle on a vertical scale.</b> The parchment strip reads attenuation down a compressed log scale; the bar rides its edge. Drag up/down; readout shows dB. After a channel-strip gain-reduction meter.'); card(M,'R43','Attitude indicator', - (st,rd)=>GW.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}), '<b>orientation, not a number.</b> The horizon rolls and shifts behind the fixed amber airplane — two axes in one read. Drag it: left/right banks, up/down pitches. The first two-axis instrument in the kit. After a cockpit artificial horizon.'); card(M,'R44','Heading bug + servo needle', - (st,rd)=>GW.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}), '<b>commanded versus actual on one dial.</b> Drag to park the amber bug where you want to go; the needle chases it with honest servo lag. The gap between them IS the information. After an HSI heading bug and synchro repeaters.'); card(M,'R54','Vertical tape instrument', - (st,rd)=>GW.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}), '<b>the scale moves, the index does not.</b> A scrolling tape behind a fixed amber index — your value is always at eye level and the neighborhood above and below stays visible. Drag to spin the tape. After the Ki-57 remote tachometer; modern cockpits tape everything.'); card(M,'R55','Twin-needle gauge', - (st,rd)=>GW.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}), '<b>two values, one housing, mirrored scales.</b> Fuel presses up the left arc, oil up the right, each with its own needle from the shared hub. Drag either half to set that side. Not the crossed-needle (N13) — these are read separately, not at their intersection. After the Ki-57 fuel/oil pressure gauge.'); card(M,'R56','Comfort-zone crossed needles', - (st,rd)=>GW.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}), '<b>two values, one verdict.</b> Temperature and humidity needles cross over a face printed with judgments — the crossing point lands in TOO WARM, TOO DRY, or JUST RIGHT. N13 derives a number from the crossing; this one derives an opinion. Drag each half. After a brass weather-station comfort meter.'); card(M,'R53','Circular chart recorder', - (st,rd)=>GW.chartRecorder(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.chartRecorder(st,{onChange:(v,t)=>rd(t)}), '<b>cyclic time on round paper.</b> The pen sweeps a full day per revolution, so "same time yesterday" is the same angle and daily rhythm draws itself as a flower. Click for fresh paper. The strip chart shows a window; this shows the cycle. After a Partlow round-chart recorder.'); card(M,'R17','Round CRT scope', - (st,rd)=>GW.roundCrt(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.roundCrt(st,{onChange:(v,t)=>rd(t)}), '<b>the round-tube scope.</b> Pale phosphor face in a screwed porthole bezel, dark graticule, a bright live trace. Runs live; readout shows Vpp. After a 1950s bench oscilloscope.'); /* ============ INDICATORS & READOUTS ============ */ const I=$('indicators'); card(I,'18','Status lamp', - (st,rd)=>GW.statusLamps(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.statusLamps(st,{onChange:(v,t)=>rd(t)}), '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. Click any lamp to cycle its state.'); card(I,'19','Badge / tag', - (st,rd)=>GW.badges(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.badges(st,{onChange:(v,t)=>rd(t)}), '<b>a labelled flag.</b> MUTED, AIRPLANE, a band tag. Click a badge to cycle its variant.'); card(I,'20','Tabular readout', - (st,rd)=>GW.tabularReadout(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.tabularReadout(st,{onChange:(v,t)=>rd(t)}), '<b>a precise number.</b> Clock, countdown, volume %. Runs a live countdown; click to pause/resume.'); card(I,'21','Engraved label', - (st,rd)=>GW.engravedLabel(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.engravedLabel(st,{onChange:(v,t)=>rd(t)}), '<b>section divider.</b> The hairline-flanked caps label with a count. Click to bump the count.'); card(I,'22','Output well', - (st,rd)=>GW.outputWell(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.outputWell(st,{onChange:(v,t)=>rd(t)}), '<b>streaming step log.</b> Lamp-per-step with evidence. Click to stream the next step.'); card(I,'23','Toast / status line', - (st,rd)=>GW.toast(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.toast(st,{onChange:(v,t)=>rd(t)}), '<b>transient confirmation.</b> The one-line result after an action. Click to fire the next toast.'); card(I,'26','Nixie tube', - (st,rd)=>GW.nixie(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.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,rd)=>GW.splitFlap(st,{animate:!reduced,onChange:(v,t)=>rd(t)}), - '<b>flips to the new value.</b> Each card drops to the next glyph with the mechanical clack. Auto-flips; click to flip now.'); + (st)=>DUPRE.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:'paper',font:'helv'}), + '<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 cards with dark lettering (default), white ink on black, the kit cream, or the ivory board; face in Helvetica (default) or Berkeley Mono. The slider sets the flap rate.'); card(I,'N21','Seven-segment display', - (st,rd)=>GW.sevenSeg(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.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.'); card(I,'N22','VFD marquee', - (st,rd)=>GW.vfdMarquee(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.vfdMarquee(st,{onChange:(v,t)=>rd(t)}), '<b>scrolling status line.</b> Teal glow, dot-matrix mesh, text tracks across. Scrolls live; click to change the message.'); card(I,'N23','Annunciator panel', - (st,rd)=>GW.annunciator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.annunciator(st,{onChange:(v,t)=>rd(t)}), '<b>a grid of named alarms with the alarm lifecycle.</b> Cells light amber to warn, red to fault; any active alarm flashes MSTR CAUTION until ACK steadies it, a new alarm re-flashes it, RESET clears the board, TEST proves the bulbs. Click cells to raise alarms.'); card(I,'N24','Jewel pilot lamps', - (st,rd)=>GW.jewels(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.jewels(st,{onChange:(v,t)=>rd(t)}), '<b>a faceted glass indicator.</b> The chunky bezel pilot lamp with a real bloom. Click a jewel to cycle red · amber · green · dark.'); card(I,'N25','Tape counter', - (st,rd)=>GW.tapeCounter(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.tapeCounter(st,{onChange:(v,t)=>rd(t)}), '<b>rolling odometer digits.</b> Numbered wheels roll to the next figure. Rolls live; readout shows the total.'); card(I,'N26','Analog clock', - (st,rd)=>GW.analogClock(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.analogClock(st,{onChange:(v,t)=>rd(t)}), '<b>time on a real face.</b> Engraved ticks, three hands, live seconds sweep. Runs live; readout shows the time.'); card(I,'N27','Frequency-dial scale', - (st,rd)=>GW.freqScale(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.freqScale(st,{onChange:(v,t)=>rd(t)}), '<b>a printed log scale.</b> Marks crowd low, spread high — a real logarithmic axis. Drag the pointer; readout shows the frequency.'); card(I,'N28','Patch-bay jack field', - (st,rd)=>GW.patchBay(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.patchBay(st,{onChange:(v,t)=>rd(t)}), '<b>routing shown as patches.</b> A grid of jacks with cables between connected pairs. Click one jack then another to add/remove a cable.'); card(I,'R10','Data matrix readout', - (st,rd)=>GW.dataMatrix(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dataMatrix(st,{onChange:(v,t)=>rd(t)}), '<b>a page of amber fields.</b> The console data block — several labeled values on one lit matrix. Click to cycle pages; readout names the page. After a cockpit data readout.'); card(I,'R11','Warning flag window', - (st,rd)=>GW.warningFlag(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.warningFlag(st,{onChange:(v,t)=>rd(t)}), '<b>the mechanical alarm flag.</b> A striped barber-pole slides into a window when the condition trips — not a lamp, a flag. Click to trip and clear it. After the VIB flag on an altimeter.'); card(I,'R25','Fourteen-segment display', - (st,rd)=>GW.seg14(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.seg14(st,{onChange:(v,t)=>rd(t)}), '<b>segments that spell.</b> The starburst alphanumeric — diagonals and split bars let it write words, not just digits. Click to cycle the word. After a pedal preset display.'); card(I,'R26','Response graph', - (st,rd)=>GW.responseGraph(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.responseGraph(st,{onChange:(v,t)=>rd(t)}), '<b>a curve on labeled axes.</b> Log frequency across, dB up the side, and the amber peak is yours to place — drag it anywhere on the plot. After the response display of a mastering processor.'); card(I,'R30','Telegraph indicator', - (st,rd)=>GW.telegraphIndicator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.telegraphIndicator(st,{onChange:(v,t)=>rd(t)}), '<b>state on a pie of sectors.</b> The pointer and its little flag name the active state, engine-telegraph style. Click to step the state. After an electric indicator dial.'); card(I,'R31','Radar sweep', - (st,rd)=>GW.radarSweep(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.radarSweep(st,{onChange:(v,t)=>rd(t)}), '<b>the rotating scan.</b> A sweep beam circles the bearing ring, contacts bloom as it passes and fade after. Click to mark the current bearing. After a radar PPI scope.'); card(I,'R35','Day-date disc calendar', - (st,rd)=>GW.dayDateCal(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dayDateCal(st,{onChange:(v,t)=>rd(t)}), '<b>two coaxial discs, one index.</b> The outer ring carries the date, the inner disc the weekday; both read where the yellow hand points. Starts on today; click to roll midnight forward and watch the discs step. After a watch day-date movement.'); card(I,'R36','LED dot matrix', - (st,rd)=>GW.dotMatrix(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dotMatrix(st,{onChange:(v,t)=>rd(t)}), '<b>a bitmap, one red dot at a time.</b> 64 discrete LEDs behind a tinted window — segments draw digits, but a matrix draws anything. Click dots to paint the pattern; the readout counts what is lit. After the K4816 pattern generator faceplate.'); card(I,'R45','Flip-disc tile array', - (st,rd)=>GW.flipDisc(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.flipDisc(st,{onChange:(v,t)=>rd(t)}), '<b>the mechanical pixel.</b> Each disc flips between its yellow face and its black back and then STAYS — no power, no glow, state that persists. Click a disc and watch it flip. The dot matrix glows; this one clacks. After transit flip-dot signs.'); card(I,'R46','Dekatron counting ring', - (st,rd)=>GW.dekatron(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dekatron(st,{onChange:(v,t)=>rd(t)}), '<b>a count as a position, and the counting is visible.</b> Each click is a pulse; the neon glow steps one cathode around the ring and carries on wrap. Nixies show the digit — the Dekatron shows the arithmetic. After glow-transfer counter tubes.'); card(I,'R47','Landing gear indicator', - (st,rd)=>GW.gearIndicator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.gearIndicator(st,{onChange:(v,t)=>rd(t)}), '<b>three greens or nothing.</b> A spatial lamp panel: nose and mains each get a light, green only when down and locked; the amber transit pulse means neither state. Click the wheel lever to cycle the gear. After a cockpit gear panel.'); card(I,'R52','Blinkenlights front panel', - (st,rd)=>GW.blinkenlights(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.blinkenlights(st,{onChange:(v,t)=>rd(t)}), '<b>the machine thinking, out loud.</b> Address and data lamps ripple with the live computation; the switch register below is yours — flip bits and watch the pattern change. DIP switches hold config; this shows work happening. After a PDP-11/70 front panel.'); /* ============ CONTROL WIRING ============ */ /* 01 toggle */ -/* 01-09, 24, 25, N01-N10 extracted to widgets.js (GW.*) — wired at their card records */ +/* 01-09, 24, 25, N01-N10 extracted to widgets.js (DUPRE.*) — wired at their card records */ /* ============ METER WIRING (drag-driven) ============ */ -/* 10-17, N11-N18 extracted to widgets.js (GW.*) — wired at their card records */ +/* 10-17, N11-N18 extracted to widgets.js (DUPRE.*) — wired at their card records */ /* ============ INDICATOR WIRING ============ */ -/* 18-23, 26 extracted to widgets.js (GW.*) — wired at their card records */ -/* N20-N28 extracted to widgets.js (GW.*) — wired at their card records */ +/* 18-23, 26 extracted to widgets.js (DUPRE.*) — wired at their card records */ +/* N20-N28 extracted to widgets.js (DUPRE.*) — wired at their card records */ -/* ============ REFERENCE-BATCH (R) WIDGETS — SVG builders ============ */ -/* svgEl, polar, VUDB live in widgets.js (GW) */ +/* ============ REFERENCE-BATCH (R) INSTRUMENTS — SVG builders ============ */ +/* svgEl, polar, VUDB live in widgets.js (DUPRE) */ /* R01 ballistics: the page owns the signal chase; the meter just takes set(t) */ function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12); const t=MH.mcvu.get();MH.mcvu.set(t+(target-t)*(target>t?0.32:0.10));} -/* R02-R06, R12, R14, R15 extracted to widgets.js (GW.*) */ +/* R02-R06, R12, R14, R15 extracted to widgets.js (DUPRE.*) */ @@ -1648,26 +1669,26 @@ function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12); -/* R07-R09 extracted to widgets.js (GW.*) */ +/* R07-R09 extracted to widgets.js (DUPRE.*) */ -/* R10, R11 extracted to widgets.js (GW.*) */ +/* R10, R11 extracted to widgets.js (DUPRE.*) */ -/* R13, R17 extracted to widgets.js (GW.*) */ +/* R13, R17 extracted to widgets.js (DUPRE.*) */ -/* R25, R26, R30, R31 extracted to widgets.js (GW.*) */ +/* R25, R26, R30, R31 extracted to widgets.js (DUPRE.*) */ /* ============ LIVE SIGNAL LOOPS ============ */ /* extracted meters: the page keeps the clock + demo signal, drives card handles */ -const MH={vu:$('card-11').gw,mini:$('card-12').gw,spark:$('card-16').gw,wave:$('card-17').gw, - scope:$('card-N11').gw,eq:$('card-N12').gw,strip:$('card-N16').gw,mcvu:$('card-R01').gw}; -const IH={flap:$('card-N20').gw,seven:$('card-N21').gw,vfd:$('card-N22').gw, - counter:$('card-N25').gw,clock:$('card-N26').gw}; +const MH={vu:$('card-11').dupre,mini:$('card-12').dupre,spark:$('card-16').dupre,wave:$('card-17').dupre, + scope:$('card-N11').dupre,eq:$('card-N12').dupre,strip:$('card-N16').dupre,mcvu:$('card-R01').dupre}; +const IH={flap:$('card-N20').dupre,seven:$('card-N21').dupre,vfd:$('card-N22').dupre, + counter:$('card-N25').dupre,clock:$('card-N26').dupre}; let ph=0; const eqBands=Array.from({length:11},(_,i)=>({v:0.4,ph:i*0.6})); @@ -1690,9 +1711,12 @@ function fastTick(){ /* indicator loops: builders own state + clicks; the page keeps the clocks and the time source (tick contract) */ function tickClock(){const d=new Date();IH.clock.set(d.getHours(),d.getMinutes(),d.getSeconds());} -function tickFlap(){IH.flap.next();} +const FLAP_DWELL_MS=2000; let flapTimer; +// The board advances only after it has settled AND dwelt on the reading — +// a fixed interval would retarget mid-cascade and the panel would never rest. +if(!reduced)IH.flap.onSettle(()=>{clearTimeout(flapTimer);flapTimer=setTimeout(()=>IH.flap.next(),FLAP_DWELL_MS);}); function tickSeven(){IH.seven.tick();} -function tickT(){$('card-20').gw.tick();} +function tickT(){$('card-20').dupre.tick();} function tickVfd(){IH.vfd.tick();} function tickCounter(){IH.counter.set(IH.counter.get()+137);} @@ -1713,7 +1737,7 @@ if(!reduced){ setInterval(fastTick,80); setInterval(tickMcVu,80); setInterval(tickClock,1000);tickClock(); - setInterval(tickFlap,1500); + flapTimer=setTimeout(()=>IH.flap.next(),FLAP_DWELL_MS); setInterval(tickVfd,32); setInterval(tickCounter,1600); setInterval(tickSeven,1000); @@ -1724,29 +1748,29 @@ if(!reduced){ document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused'); } -/* R35, R36 extracted to widgets.js (GW.*) */ +/* R35, R36 extracted to widgets.js (DUPRE.*) */ -/* R43, R44 extracted to widgets.js (GW.*) */ +/* R43, R44 extracted to widgets.js (DUPRE.*) */ -/* R45-R47, R52 extracted to widgets.js (GW.*) */ +/* R45-R47, R52 extracted to widgets.js (DUPRE.*) */ -/* R53-R56 extracted to widgets.js (GW.*) */ +/* R53-R56 extracted to widgets.js (DUPRE.*) */ -/* ---- screen-family chips: first round, the clearly-screen widgets ---- - Defaults match each widget's shipped color (var fallbacks), so no family is applied until a chip is clicked. +/* ---- screen-family chips: first round, the clearly-screen instruments ---- + Defaults match each instrument's shipped color (var fallbacks), so no family is applied until a chip is clicked. The nixie stays chipless on purpose: neon is only ever orange, so alternates would be historically false. */ -screenChips('R10',$('card-R10').gw.el,['amber','green','red','blue','vfd'],'amber'); -screenChips('R17',$('card-R17').gw.el,['green','amber','red','blue','vfd'],'green'); -screenChips('R19',$('card-R19').gw.el,['white','green','amber','blue','vfd'],'white'); -screenChips('R31',$('card-R31').gw.el,['amber','green','red','blue','vfd'],'amber'); -screenChips('N11',$('card-N11').gw.el,['green','amber','red','blue','vfd'],'green'); +screenChips('R10',$('card-R10').dupre.el,['amber','green','red','blue','vfd'],'amber'); +screenChips('R17',$('card-R17').dupre.el,['green','amber','red','blue','vfd'],'green'); +screenChips('R19',$('card-R19').dupre.el,['white','green','amber','blue','vfd'],'white'); +screenChips('R31',$('card-R31').dupre.el,['amber','green','red','blue','vfd'],'amber'); +screenChips('N11',$('card-N11').dupre.el,['green','amber','red','blue','vfd'],'green'); /* all six, where its siblings each carry five: a passphrase window has no reason to prefer one phosphor, so it offers the whole set including the marquee cyan */ -screenChips('R57',$('card-R57').gw.el,['amber','green','red','blue','vfd','white'],'amber'); +screenChips('R57',$('card-R57').dupre.el,['amber','green','red','blue','vfd','white'],'amber'); BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no); if(rd)rd.closest('.card').querySelector('.stagew').classList.add('boost');}); @@ -1762,16 +1786,16 @@ BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no); 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. */ + the instrument has since diverged from. */ function styleChips(no,STYLES,AXES,PRESETS,defPreset){ - const h=$('card-'+no)?.gw; if(!h)return; + const h=$('card-'+no)?.dupre; 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 + combination the instrument 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){ @@ -1805,14 +1829,21 @@ function styleChips(no,STYLES,AXES,PRESETS,defPreset){ groups.forEach(g=>row.appendChild(g)); cardEl.querySelector('.opts').appendChild(row); } -styleChips('01',GW.slideToggle.STYLES, +styleChips('N20',DUPRE.splitFlap.STYLES,[['skin','skin','paper'],['font','font','helv']]); +(()=>{const h=$('card-N20').dupre,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="100" style="width:70px;accent-color:var(--gold);vertical-align:middle"><span class="lab fms">100ms</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);})(); +styleChips('01',DUPRE.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('07',GW.chipToggle.STYLES,[['accent','accent','amber']]); -styleChips('25',GW.slideRule.STYLES,[['face','skin','warm']]); + DUPRE.slideToggle.PRESETS,'panel'); +styleChips('R05',DUPRE.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]); +styleChips('06',DUPRE.segmented.STYLES,[['accent','accent','amber']]); +styleChips('07',DUPRE.chipToggle.STYLES,[['accent','accent','amber']]); +styleChips('25',DUPRE.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 */ diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 5ba1226..e6f02ce 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -1,12 +1,12 @@ -/* widgets.js — retro-instrument widget library (GW namespace). +/* widgets.js — The Dupre Kit: retro-instrument component library (DUPRE namespace). Classic script: load after the token :root block (tokens.json → gen_tokens.py), - before any GW.* call. Works from file:// — no modules, no build step. + before any DUPRE.* call. Works from file:// — no modules, no build step. Contract: each gallery card in panel-widget-gallery.html is the visual + behavioral spec its builder is judged against. Spec: docs/specs/2026-07-12-component-generation-spec.org */ (function () { 'use strict'; -const GW = {}; +const DUPRE = {}; /* ================= shared engine ================= */ @@ -107,19 +107,19 @@ const SCREEN_FAMS = { '--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' }, }; -/* ================= widget builders ================= - Every builder: GW.name(host, opts) → handle. host is an empty element the - widget renders into. opts.onChange(value, text) fires on every state change, - including the initial paint; text is the widget's canonical readout string. */ +/* ================= instrument builders ================= + Every builder: DUPRE.name(host, opts) → handle. host is an empty element the + instrument renders into. opts.onChange(value, text) fires on every state change, + including the initial paint; text is the instrument's canonical readout string. */ const noop = () => {}; /* ---- the accent family ---- - One named set of lit colours for the widgets whose colour IS their claim: a + One named set of lit colours for the instruments 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 + Deliberately NOT applied to instruments 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 @@ -134,60 +134,60 @@ const ACCENTS = { white: 'var(--cream)', vfd: 'var(--vfd)', }; -GW.accentStyles = varName => Object.fromEntries( +DUPRE.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.<name>.POLICY) + Every instrument'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 (DUPRE.<name>.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 + Policy is a property of the INSTRUMENT, 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. + why — why THIS instrument 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 = { +DUPRE.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).' }, + relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same instrument; 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 + DUPRE.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's setStyle(axis, name) restyles a live instance (the gallery chips use it). */ -GW.slideToggle = function (host, opts = {}) { +DUPRE.slideToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw); const setStyle = (axis, name) => { - const o = (GW.slideToggle.STYLES[axis] || {})[name]; + const o = (DUPRE.slideToggle.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v); }; /* 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; + const preset = DUPRE.slideToggle.PRESETS[opts.preset] || DUPRE.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]; + const p = DUPRE.slideToggle.PRESETS[name]; if (!p) return null; - for (const axis of GW.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]); + for (const axis of DUPRE.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]); + for (const axis of DUPRE.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)); @@ -198,8 +198,8 @@ GW.slideToggle = function (host, opts = {}) { 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 = { +DUPRE.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb']; +DUPRE.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' }, @@ -210,7 +210,7 @@ GW.slideToggle.PRESETS = { 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 = { +DUPRE.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)' } }, @@ -259,9 +259,9 @@ GW.slideToggle.STYLES = { 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 = {}) { +DUPRE.consoleKeys = function (host, opts = {}) { const onChange = opts.onChange || noop; - const keys = opts.keys || GW.consoleKeys.DEFAULT_KEYS; + const keys = opts.keys || DUPRE.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; @@ -281,10 +281,10 @@ GW.consoleKeys = function (host, opts = {}) { 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)); + : (keys === DUPRE.consoleKeys.DEFAULT_KEYS ? 1 : 0)); return { el: wrap, get: () => idx, set }; }; -GW.consoleKeys.DEFAULT_KEYS = [ +DUPRE.consoleKeys.DEFAULT_KEYS = [ { label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' }, ]; @@ -303,7 +303,7 @@ GW.consoleKeys.DEFAULT_KEYS = [ already --fail and --pass. - Letters left, digits right (the stainless reference's arrangement). - DEL added, CANCEL dropped. See the layout block below for both. */ -GW.abcKeypad = function (host, opts = {}) { +DUPRE.abcKeypad = function (host, opts = {}) { const onChange = opts.onChange || noop; const MAX = opts.max || 16; const s = stageSvg(host, 'rsvg', 232, 226); @@ -342,7 +342,7 @@ GW.abcKeypad = function (host, opts = {}) { Gated on ACTIONS rather than on KEYS because they are different sets — CLR is a real key on the plate that no keystroke maps to. */ const press = k => { - if (!GW.abcKeypad.ACTIONS.has(k)) return; + if (!DUPRE.abcKeypad.ACTIONS.has(k)) return; if (k === 'ENT') { onChange(buf, buf ? 'ENTER · ' + buf : 'empty'); return; } if (k === 'DEL') { buf = buf.slice(0, -1); render(); onChange(buf, buf || 'empty'); return; } if (k === 'CLR') { buf = ''; render(); onChange(buf, 'cleared'); return; } @@ -408,14 +408,14 @@ GW.abcKeypad = function (host, opts = {}) { /* Keyboard, per the README's keyboard contract. The listener is bound to the pad's own focusable element, never to the document: a global binding would type into this card from anywhere on a 110-card page and fight the gallery's - own Escape handler. GW.slideRule is the precedent. */ + own Escape handler. DUPRE.slideRule is the precedent. */ s.setAttribute('class', 'rsvg kp-pad'); s.setAttribute('tabindex', '0'); s.addEventListener('click', () => s.focus()); s.addEventListener('keydown', e => { if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave shortcuts alone */ const name = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key); - const k = GW.abcKeypad.KEYS[name]; + const k = DUPRE.abcKeypad.KEYS[name]; if (!k) return; /* not on the plate: let it bubble */ /* Spend preventDefault only where there is a default worth killing — Space scrolls the page, Backspace can navigate back. Tab and Escape are never @@ -430,10 +430,10 @@ GW.abcKeypad = function (host, opts = {}) { /* The plate's keys, declared as a table so every target reads the same intent. Deliberately not a function over a DOM event: the Emacs port installs this into a keymap and never sees a keydown, so a function would force it to - re-derive what the widget accepts and the two bindings would drift apart. + re-derive what the instrument accepts and the two bindings would drift apart. This is also press()'s allowlist — press appends whatever it is handed, so without the table a stray 'F1' would land in the buffer as text. */ -GW.abcKeypad.KEYS = (() => { +DUPRE.abcKeypad.KEYS = (() => { const m = {}; for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') m[c] = c; m.Space = 'SPC'; m.Backspace = 'DEL'; m.Enter = 'ENT'; @@ -442,7 +442,7 @@ GW.abcKeypad.KEYS = (() => { /* The plate's whole vocabulary — every argument press accepts, from any caller. A superset of the KEYS values: CLR is on the plate but no keystroke reaches it (Escape is the obvious candidate and belongs to the gallery's audit stepper). */ -GW.abcKeypad.ACTIONS = new Set([ +DUPRE.abcKeypad.ACTIONS = new Set([ ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR', ]); @@ -463,10 +463,10 @@ GW.abcKeypad.ACTIONS = new Set([ same column offset: find the letter, then pick the case. The layout is a table rather than drawing, because Craig has already said the keys will be revisited and a layout welded into the geometry never is. */ -GW.indexPlate = function (host, opts = {}) { +DUPRE.indexPlate = function (host, opts = {}) { const onChange = opts.onChange || noop; const MAX = opts.max || 22; - const L = GW.indexPlate.LAYOUT; + const L = DUPRE.indexPlate.LAYOUT; const COLS = Math.max(...L.map(r => r.length)), ROWS = L.length; /* Width is driven by the plate, not guessed: the lever and CLR live in a gutter to its right. Sized from the layout table so a wider plate can't slide them @@ -495,7 +495,7 @@ GW.indexPlate = function (host, opts = {}) { 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 defs = s.ownerDocument.querySelector('#dupre-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 }); @@ -507,11 +507,11 @@ GW.indexPlate = function (host, opts = {}) { 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 => + const z = DUPRE.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 => { + DUPRE.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, @@ -527,7 +527,7 @@ GW.indexPlate = function (host, opts = {}) { /* The stylus: a cone on a short shaft, hovering over the selected cell. The Mignon's arm reaches back to a pivot, but a full arm drawn here crosses the plate and hides the characters the operator is trying to read — the one thing - this widget must not do. A tip is enough to say "the pointer is here". */ + this instrument must not do. A tip is enough to say "the pointer is here". */ const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'ix-stylus'); svgEl(arm, 'line', { x1: 0, y1: -9, x2: 0, y2: -20, stroke: 'url(#ixSteel)', 'stroke-width': 2.6 }); svgEl(arm, 'circle', { cx: 0, cy: -21, r: 3.4, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .6 }); @@ -601,7 +601,7 @@ GW.indexPlate = function (host, opts = {}) { primary input on this card would skip it and every probe would stay green. Gated on ACTIONS, so it selects nothing it has no cell for. */ const press = k => { - if (!GW.indexPlate.ACTIONS.has(k)) return; + if (!DUPRE.indexPlate.ACTIONS.has(k)) return; if (k === 'PRINT') return print(); if (k === 'CLR') return fresh(); select(k); @@ -617,7 +617,7 @@ GW.indexPlate = function (host, opts = {}) { s.addEventListener('click', () => s.focus()); s.addEventListener('keydown', e => { if (e.ctrlKey || e.metaKey || e.altKey) return; - const k = GW.indexPlate.KEYS[e.key]; + const k = DUPRE.indexPlate.KEYS[e.key]; if (!k) return; e.preventDefault(); /* Enter would submit a form; a cell key has no default worth keeping */ press(k); @@ -639,7 +639,7 @@ GW.indexPlate = function (host, opts = {}) { - 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 = [ +DUPRE.indexPlate.LAYOUT = [ ['&','(',')',':','"','!', '?',"'",'ä','ö','ü','—'], ['§','P','F','U','G','Q', 'p','f','u','g','q',';'], ['J','V','I','N','A','B', 'v','i','n','a','b','j'], @@ -654,7 +654,7 @@ GW.indexPlate.LAYOUT = [ 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 = [ +DUPRE.indexPlate.ZONES = [ { rows: [1, 5], cols: [1, 5], zone: 'caps' }, { rows: [1, 5], cols: [6, 10], zone: 'lower' }, ]; @@ -663,18 +663,18 @@ GW.indexPlate.ZONES = [ Every plate character maps to itself (no case folding — the plate has both, so Shift does the work a shift key would), and Enter is the lever. Space is deliberately absent: there's no space cell yet, so Space isn't ours to claim. */ -GW.indexPlate.KEYS = (() => { +DUPRE.indexPlate.KEYS = (() => { const m = {}; - for (const c of GW.indexPlate.LAYOUT.flat()) if (c) m[c] = c; + for (const c of DUPRE.indexPlate.LAYOUT.flat()) if (c) m[c] = c; m.Enter = 'PRINT'; return m; })(); /* Every argument press accepts. A superset of the KEYS values: CLR is a real control that no keystroke reaches, the same shape as the keypad's. */ -GW.indexPlate.ACTIONS = new Set([...GW.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']); +DUPRE.indexPlate.ACTIONS = new Set([...DUPRE.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']); /* 03 horizontal fader — continuous 0-100 */ -GW.faderH = function (host, opts = {}) { +DUPRE.faderH = function (host, opts = {}) { const onChange = opts.onChange || noop; const f = document.createElement('div'); f.className = 'fader'; f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; @@ -692,7 +692,7 @@ GW.faderH = function (host, opts = {}) { }; /* 04 vertical fader — one channel-strip fader; compose per channel */ -GW.faderV = function (host, opts = {}) { +DUPRE.faderV = function (host, opts = {}) { const onChange = opts.onChange || noop; const f = document.createElement('div'); f.className = 'vfader'; f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; @@ -710,7 +710,7 @@ GW.faderV = function (host, opts = {}) { }; /* 05 rotary knob — drag up/down to turn, -150°..+150° sweep */ -GW.knob = function (host, opts = {}) { +DUPRE.knob = function (host, opts = {}) { const onChange = opts.onChange || noop; const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100; const k = document.createElement('span'); k.className = 'knob'; @@ -728,14 +728,14 @@ GW.knob = function (host, opts = {}) { }; /* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment - color from GW.segmented.STYLES (amber / green / red); defaults match the + color from DUPRE.segmented.STYLES (amber / green / red); defaults match the stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */ -GW.segmented = function (host, opts = {}) { +DUPRE.segmented = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['TIMER', 'ALARM', 'POMO']; const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg); const setStyle = (axis, name) => { - const o = (GW.segmented.STYLES[axis] || {})[name]; + const o = (DUPRE.segmented.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v); }; @@ -748,7 +748,7 @@ GW.segmented = function (host, opts = {}) { return { el: seg, get: () => idx, set, setStyle }; }; /* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */ -GW.segmented.STYLES = { +DUPRE.segmented.STYLES = { accent: { amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } }, green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } }, @@ -757,13 +757,13 @@ GW.segmented.STYLES = { }; /* 07 chip toggle — inline binary inside a line of text */ -GW.chipToggle = function (host, opts = {}) { +DUPRE.chipToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; 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]; + const o = (DUPRE.chipToggle.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) chip.style.setProperty(k, v); }; @@ -778,10 +778,10 @@ GW.chipToggle = function (host, opts = {}) { 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') }; +DUPRE.chipToggle.STYLES = { accent: DUPRE.accentStyles('--chip-on') }; /* 08 arm-to-fire — two-stage confirm for destructive actions */ -GW.armButton = function (host, opts = {}) { +DUPRE.armButton = function (host, opts = {}) { const onChange = opts.onChange || noop; const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again'; const a = document.createElement('button'); a.className = 'arm'; a.textContent = label; @@ -796,18 +796,18 @@ GW.armButton = function (host, opts = {}) { }; /* 09 lamp row — actionable list item: lamp + name + status, click to cycle */ -GW.lampRow = function (host, opts = {}) { +DUPRE.lampRow = function (host, opts = {}) { const onChange = opts.onChange || noop; const name = opts.name || 'WH-1000XM4'; const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']]; const row = document.createElement('div'); row.className = 'lrow'; - row.innerHTML = `<span class="lamp"></span><span class="who"><b></b></span><span class="what"></span>`; + row.innerHTML = `<span class="dupre-lamp"></span><span class="who"><b></b></span><span class="what"></span>`; row.querySelector('b').textContent = name; host.appendChild(row); let idx; const set = i => { idx = i; - row.querySelector('.lamp').className = 'lamp ' + states[i][0]; + row.querySelector('.dupre-lamp').className = 'dupre-lamp' + (states[i][0] ? ' dupre-' + states[i][0] : ''); row.querySelector('.what').textContent = states[i][1]; onChange(i, states[i][1]); }; @@ -817,7 +817,7 @@ GW.lampRow = function (host, opts = {}) { }; /* 24 rotary selector — pick one of five printed detents by position */ -GW.rotarySelector = function (host, opts = {}) { +DUPRE.rotarySelector = function (host, opts = {}) { const onChange = opts.onChange || noop; const values = opts.values || [4, 6, 8, 10, 12]; const fmt = opts.fmt || (v => 'size ' + v); @@ -846,9 +846,9 @@ GW.rotarySelector = function (host, opts = {}) { are the majors; the integer units between them get minor ticks and are selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step one unit. set() takes a stop index; get() returns the value. opts.skin picks - a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue); + a face from DUPRE.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue); setStyle(axis, name) restyles a live instance. */ -GW.slideRule = function (host, opts = {}) { +DUPRE.slideRule = function (host, opts = {}) { const onChange = opts.onChange || noop; const majors = opts.values || [4, 6, 8, 10, 12]; const fmt = opts.fmt || (v => 'pos ' + v); @@ -865,7 +865,7 @@ GW.slideRule = function (host, opts = {}) { const t = document.createElement('span'); t.className = 'tuner'; t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); const setStyle = (axis, name) => { - const o = (GW.slideRule.STYLES[axis] || {})[name]; + const o = (DUPRE.slideRule.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v); }; @@ -902,7 +902,7 @@ GW.slideRule = function (host, opts = {}) { }; /* named faces: dot = swatch color for pickers, vars = --tn-* overrides. warm restates the stylesheet fallbacks so switching back is exact. */ -GW.slideRule.STYLES = { +DUPRE.slideRule.STYLES = { skin: { warm: { dot: 'var(--gold-hi)', vars: { '--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c', @@ -928,7 +928,7 @@ GW.slideRule.STYLES = { }; /* N01 rocker power switch — hard on/off, lit legend */ -GW.rocker = function (host, opts = {}) { +DUPRE.rocker = function (host, opts = {}) { const onChange = opts.onChange || noop; const r = document.createElement('span'); r.className = 'rocker'; r.innerHTML = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`; @@ -942,7 +942,7 @@ GW.rocker = function (host, opts = {}) { /* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing. opts.animate: reels obey play state (default: not prefers-reduced-motion) */ -GW.transport = function (host, opts = {}) { +DUPRE.transport = function (host, opts = {}) { const onChange = opts.onChange || noop; const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches; const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' }; @@ -973,7 +973,7 @@ GW.transport = function (host, opts = {}) { }; /* N03 radio preset bank — mechanically exclusive presets */ -GW.presetBank = function (host, opts = {}) { +DUPRE.presetBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF']; const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank); @@ -986,7 +986,7 @@ GW.presetBank = function (host, opts = {}) { }; /* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */ -GW.dualKnob = function (host, opts = {}) { +DUPRE.dualKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const dk = document.createElement('span'); dk.className = 'dualknob'; dk.innerHTML = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`; @@ -1007,7 +1007,7 @@ GW.dualKnob = function (host, opts = {}) { }; /* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */ -GW.encoder = function (host, opts = {}) { +DUPRE.encoder = function (host, opts = {}) { const onChange = opts.onChange || noop; const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc); const R = 27, cx = 33, cy = 33; @@ -1032,7 +1032,7 @@ GW.encoder = function (host, opts = {}) { }; /* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */ -GW.keySwitch = function (host, opts = {}) { +DUPRE.keySwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['OFF', 'ON', 'RUN']; const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]]; @@ -1058,7 +1058,7 @@ GW.keySwitch = function (host, opts = {}) { }; /* N07 center-detented crossfader — throw to either side of zero */ -GW.crossfader = function (host, opts = {}) { +DUPRE.crossfader = function (host, opts = {}) { const onChange = opts.onChange || noop; const xf = document.createElement('div'); xf.className = 'xfader'; xf.innerHTML = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`; @@ -1076,7 +1076,7 @@ GW.crossfader = function (host, opts = {}) { }; /* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */ -GW.thumbwheel = function (host, opts = {}) { +DUPRE.thumbwheel = function (host, opts = {}) { const onChange = opts.onChange || noop; const w = document.createElement('div'); w.className = 'thumbw'; w.innerHTML = '<span class="thumbwheel"></span><span class="win"></span>'; @@ -1094,7 +1094,7 @@ GW.thumbwheel = function (host, opts = {}) { }; /* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */ -GW.dipBank = function (host, opts = {}) { +DUPRE.dipBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const bits = opts.bits || [true, false, true, true, false, false]; const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank); @@ -1110,7 +1110,7 @@ GW.dipBank = function (host, opts = {}) { }; /* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */ -GW.jogWheel = function (host, opts = {}) { +DUPRE.jogWheel = function (host, opts = {}) { const onChange = opts.onChange || noop; const j = document.createElement('span'); j.className = 'jog'; j.innerHTML = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`; @@ -1127,7 +1127,7 @@ GW.jogWheel = function (host, opts = {}) { }; /* ---- shared SVG defs: gradients/filters referenced by url(#id) are - document-scoped, and several are used across widgets. Builders ensure the + document-scoped, and several are used across instruments. Builders ensure the defs they use; the first caller creates it, later calls are no-ops. ---- */ let defsRoot = null; const defsMade = new Set(); @@ -1161,7 +1161,7 @@ let uidN = 0; function uid(prefix) { return prefix + '-' + (++uidN); } /* R02 calibrated vernier dial — the disc turns under a fixed hairline */ -GW.vernierDial = function (host, opts = {}) { +DUPRE.vernierDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75; gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]); @@ -1191,7 +1191,7 @@ GW.vernierDial = function (host, opts = {}) { }; /* R03 bat-handle toggle — chrome lever throws between lit legends */ -GW.batToggle = function (host, opts = {}) { +DUPRE.batToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44; gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); @@ -1222,7 +1222,7 @@ GW.batToggle = function (host, opts = {}) { }; /* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */ -GW.flutedKnob = function (host, opts = {}) { +DUPRE.flutedKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54; gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]); @@ -1249,7 +1249,7 @@ GW.flutedKnob = function (host, opts = {}) { }; /* R05 filter slider bank — a dense fader wall on a real faceplate. Period skins - as constructor opts backed by GW.filterBank.STYLES, three independent axes: + as constructor opts backed by DUPRE.filterBank.STYLES, three independent axes: panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE (chrome T / short ribbed / tall block fader), and capColor — the cap FINISH (black white-index / color stripes / chrome / cream). opts.style @@ -1257,13 +1257,13 @@ GW.flutedKnob = function (host, opts = {}) { opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name) restyles live (values kept). After the Pioneer SG-9500, the Technics SH-8065, and the Zaxcom Oasis block faders. */ -GW.filterBank = function (host, opts = {}) { +DUPRE.filterBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340]; const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length); while (vals.length < freqs.length) vals.push(30); const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; - const ST = GW.filterBank.STYLES; + const ST = DUPRE.filterBank.STYLES; const native = { silver: { caps: 'chrome', capColor: 'chrome' }, studio: { caps: 'ribbed', capColor: 'red' }, @@ -1352,7 +1352,7 @@ GW.filterBank = function (host, opts = {}) { /* named styles per axis: dot = swatch color for pickers. caps is the cap shape; capColor is the cap finish (body + rib + index line), applicable to any shape. */ -GW.filterBank.STYLES = { +DUPRE.filterBank.STYLES = { panel: { silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null }, studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' }, @@ -1374,7 +1374,7 @@ GW.filterBank.STYLES = { }; /* R06 chicken-head selector — tapered lever aims at the position */ -GW.chickenHead = function (host, opts = {}) { +DUPRE.chickenHead = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]]; const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58; @@ -1405,7 +1405,7 @@ GW.chickenHead = function (host, opts = {}) { }; /* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */ -GW.slotFader = function (host, opts = {}) { +DUPRE.slotFader = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120; const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop); @@ -1442,7 +1442,7 @@ GW.slotFader = function (host, opts = {}) { }; /* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */ -GW.spadeKnob = function (host, opts = {}) { +DUPRE.spadeKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82; const sweep = v => -80 + v / 10 * 160; @@ -1481,7 +1481,7 @@ GW.spadeKnob = function (host, opts = {}) { }; /* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */ -GW.multiBandDial = function (host, opts = {}) { +DUPRE.multiBandDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]]; const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62; @@ -1532,7 +1532,7 @@ GW.multiBandDial = function (host, opts = {}) { }; /* R16 entry keypad — worn keys feed the amber display; lamps watch state */ -GW.entryKeypad = function (host, opts = {}) { +DUPRE.entryKeypad = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 150, 190); gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]); @@ -1570,7 +1570,7 @@ GW.entryKeypad = function (host, opts = {}) { }; /* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab */ -GW.thumbSlide = function (host, opts = {}) { +DUPRE.thumbSlide = function (host, opts = {}) { const onChange = opts.onChange || noop; const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v })); const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122; @@ -1616,7 +1616,7 @@ GW.thumbSlide = function (host, opts = {}) { }; /* R19 waveform region editor — monochrome LCD, draggable S/E flags */ -GW.waveRegion = function (host, opts = {}) { +DUPRE.waveRegion = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85; svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 }); @@ -1666,8 +1666,18 @@ GW.waveRegion = function (host, opts = {}) { return { el: s, get: () => ({ s: S, e: E }), set }; }; -/* R20 drum roller selector — numbered paper drum in a window, center chip reads it */ -GW.drumRoller = function (host, opts = {}) { +/* R20 drum roller selector — numbered paper drum in a window, center chip reads it. + + Contract (everything a consumer needs; no page globals touched): + opts: title (engraved header, default 'EQUALIZER'); channels (array of + { name, v } drums, v 1-10, default HIGH/LOW pair — the layout is + sized for two); onChange(values array, 'NAME v · NAME v') fires on + every set, including the initial paint. + handle: el, get() (values array), set(i, v) — v clamps 1-10; each drum + drags vertically on its own hit strip. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.drumRoller = function (host, opts = {}) { const onChange = opts.onChange || noop; const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v })); const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17; @@ -1710,7 +1720,7 @@ GW.drumRoller = function (host, opts = {}) { }; /* R21 LED program row — exclusive select, the LED above the key carries the state */ -GW.ledRow = function (host, opts = {}) { +DUPRE.ledRow = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate']; const s = stageSvg(host, 'rsvg', 190, 58); @@ -1741,7 +1751,7 @@ GW.ledRow = function (host, opts = {}) { }; /* R22 three-position slide — chrome pill between detents, honest LED pair */ -GW.pillSlide = function (host, opts = {}) { +DUPRE.pillSlide = function (host, opts = {}) { const onChange = opts.onChange || noop; const positions = opts.positions || ['A', 'AB', 'B']; const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76]; @@ -1776,7 +1786,7 @@ GW.pillSlide = function (host, opts = {}) { }; /* R23 spun-aluminum knob — machined rings in a knurled grip, red index */ -GW.spunKnob = function (host, opts = {}) { +DUPRE.spunKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]); @@ -1801,7 +1811,7 @@ GW.spunKnob = function (host, opts = {}) { }; /* R24 stomp switch + jewel — press to engage, the jewel reports */ -GW.stompSwitch = function (host, opts = {}) { +DUPRE.stompSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55; gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]); @@ -1835,7 +1845,7 @@ GW.stompSwitch = function (host, opts = {}) { }; /* R27 winged gain selector — red T-bar over a dot ring, stepped detents */ -GW.wingSelector = function (host, opts = {}) { +DUPRE.wingSelector = function (host, opts = {}) { const onChange = opts.onChange || noop; const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10]; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; @@ -1868,7 +1878,7 @@ GW.wingSelector = function (host, opts = {}) { }; /* R28 rotary disc switch — the whole disc turns between heavy positions */ -GW.discSwitch = function (host, opts = {}) { +DUPRE.discSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]]; const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58; @@ -1903,7 +1913,7 @@ GW.discSwitch = function (host, opts = {}) { }; /* R29 guarded toggle — guard posts + red collar mark the critical throw */ -GW.guardedToggle = function (host, opts = {}) { +DUPRE.guardedToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52; gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); @@ -1942,7 +1952,7 @@ GW.guardedToggle = function (host, opts = {}) { /* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob. Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */ -GW.timerDial = function (host, opts = {}) { +DUPRE.timerDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58; gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]); @@ -2003,7 +2013,7 @@ GW.timerDial = function (host, opts = {}) { }; /* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */ -GW.rockerPad = function (host, opts = {}) { +DUPRE.rockerPad = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55; gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]); @@ -2042,7 +2052,7 @@ GW.rockerPad = function (host, opts = {}) { }; /* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */ -GW.fourWayToggle = function (host, opts = {}) { +DUPRE.fourWayToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]); @@ -2100,7 +2110,7 @@ GW.fourWayToggle = function (host, opts = {}) { }; /* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */ -GW.pinMatrix = function (host, opts = {}) { +DUPRE.pinMatrix = function (host, opts = {}) { const onChange = opts.onChange || noop; const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV']; const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD']; @@ -2128,7 +2138,7 @@ GW.pinMatrix = function (host, opts = {}) { }; /* R38 dead-man button — state exists only while the pointer holds it down */ -GW.deadMan = function (host, opts = {}) { +DUPRE.deadMan = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58; gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]); @@ -2168,7 +2178,7 @@ GW.deadMan = function (host, opts = {}) { }; /* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */ -GW.telephoneDial = function (host, opts = {}) { +DUPRE.telephoneDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63; const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; @@ -2220,7 +2230,7 @@ GW.telephoneDial = function (host, opts = {}) { }; /* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */ -GW.breakerPanel = function (host, opts = {}) { +DUPRE.breakerPanel = function (host, opts = {}) { const onChange = opts.onChange || noop; const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX']; const s = stageSvg(host, 'rsvg press', 160, 110); @@ -2260,7 +2270,7 @@ GW.breakerPanel = function (host, opts = {}) { }; /* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */ -GW.dsky = function (host, opts = {}) { +DUPRE.dsky = function (host, opts = {}) { const onChange = opts.onChange || noop; const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR']; const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el); @@ -2312,7 +2322,7 @@ GW.dsky = function (host, opts = {}) { /* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it. Runs its own reduced-motion-gated 2 s step interval once started. */ -GW.camTimer = function (host, opts = {}) { +DUPRE.camTimer = function (host, opts = {}) { const onChange = opts.onChange || noop; const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL']; const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' }; @@ -2348,7 +2358,7 @@ GW.camTimer = function (host, opts = {}) { }; /* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */ -GW.knifeSwitch = function (host, opts = {}) { +DUPRE.knifeSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 110); gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]); @@ -2383,7 +2393,7 @@ GW.knifeSwitch = function (host, opts = {}) { }; /* R49 decade box — four skirted knobs, one digit each; the value is their sum */ -GW.decadeBox = function (host, opts = {}) { +DUPRE.decadeBox = function (host, opts = {}) { const onChange = opts.onChange || noop; const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1']; const digs = (opts.digits || [3, 5, 0, 0]).slice(); @@ -2419,7 +2429,7 @@ GW.decadeBox = function (host, opts = {}) { }; /* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */ -GW.twoHandSafety = function (host, opts = {}) { +DUPRE.twoHandSafety = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 160, 100); svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); @@ -2462,7 +2472,7 @@ GW.twoHandSafety = function (host, opts = {}) { }; /* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */ -GW.voiceLoop = function (host, opts = {}) { +DUPRE.voiceLoop = function (host, opts = {}) { const onChange = opts.onChange || noop; const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO']; const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el); @@ -2507,16 +2517,23 @@ GW.voiceLoop = function (host, opts = {}) { onChange(value, text) like every other builder. Display-side state that belongs to the instrument (peak-hold, history buffers) lives in here. */ -/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc */ -GW.needleGauge = function (host, opts = {}) { +/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, 'value N%') fires on + every set, including the initial paint. + handle: el, get() (current value), set(v) — no clamping beyond the + drag helper's 0-100; the needle maps value linearly to ±60°. + CSS lives in the "needle gauge" block of DUPRE_CSS; no other styles involved. */ +DUPRE.needleGauge = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 50; - const el = document.createElement('div'); el.className = 'gauge'; - el.innerHTML = `<div class="dial"><div class="arc"></div> - <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div> - <div class="ndl"></div><div class="hub"></div></div><div class="gv"><span>0</span>%</div>`; + const el = document.createElement('div'); el.className = 'dupre-gauge'; + el.innerHTML = `<div class="dupre-dial"><div class="dupre-arc"></div> + <div class="dupre-tk" style="transform:rotate(-60deg)"></div><div class="dupre-tk" style="transform:rotate(0)"></div><div class="dupre-tk" style="transform:rotate(60deg)"></div> + <div class="dupre-ndl"></div><div class="dupre-hub"></div></div><div class="dupre-gv"><span>0</span>%</div>`; host.appendChild(el); - const ndl = el.querySelector('.ndl'), num = el.querySelector('.gv span'); + const ndl = el.querySelector('.dupre-ndl'), num = el.querySelector('.dupre-gv span'); function set(nv) { v = nv; ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; @@ -2528,23 +2545,33 @@ GW.needleGauge = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both */ -GW.vuPair = function (host, opts = {}) { +/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both. + + Contract (everything a consumer needs; no page globals touched): + opts: bars (cells per channel, default 16); onChange([l, r], 'L n · R n') + fires on every set (values 0-1, readout in percent). + handle: el, get() ([l, r] last set), set(l, r). Peak-hold is display-side + state owned in here: each channel's peak cell decays 0.4 cells + per set() call, so the page's tick cadence is the decay clock + (the tick contract — the page owns the clock and the signal). + CSS lives in the "segmented VU / LED bar" block of DUPRE_CSS; no other + styles involved. */ +DUPRE.vuPair = function (host, opts = {}) { const onChange = opts.onChange || noop; const n = opts.bars || 16; - const el = document.createElement('div'); el.className = 'vu'; - el.innerHTML = `<div class="vurow"><span class="ch">L</span><span class="vubar"></span></div> - <div class="vurow"><span class="ch">R</span><span class="vubar"></span></div>`; + const el = document.createElement('div'); el.className = 'dupre-vu'; + el.innerHTML = `<div class="dupre-vurow"><span class="dupre-ch">L</span><span class="dupre-vubar"></span></div> + <div class="dupre-vurow"><span class="dupre-ch">R</span><span class="dupre-vubar"></span></div>`; host.appendChild(el); - const bars = el.querySelectorAll('.vubar'); + const bars = el.querySelectorAll('.dupre-vubar'); bars.forEach(b => buildBars(b, n)); const pkL = { v: 0 }, pkR = { v: 0 }; function paint(bar, l, pk) { const b = bar.children, lit = Math.round(l * n); pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v); for (let k = 0; k < n; k++) { - let c = k < lit ? (k >= n - 2 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : ''; - if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'peak'; + let c = k < lit ? (k >= n - 2 ? 'dupre-clip' : k >= n - 4 ? 'dupre-hot' : 'dupre-on') : ''; + if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'dupre-peak'; b[k].className = c; } } @@ -2558,7 +2585,7 @@ GW.vuPair = function (host, opts = {}) { }; /* 12 mini 4-bar signal — compact activity meter; set(level) */ -GW.miniSig = function (host, opts = {}) { +DUPRE.miniSig = function (host, opts = {}) { const onChange = opts.onChange || noop; const el = document.createElement('span'); el.className = 'sig'; el.innerHTML = '<i></i><i></i><i></i><i></i>'; @@ -2573,11 +2600,19 @@ GW.miniSig = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* 13 signal ladder — stepped 0-4 strength; click cycles */ -GW.signalLadder = function (host, opts = {}) { +/* 13 signal ladder — stepped 0-4 strength; click cycles. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-4, default 3); onChange(v, 'v/4') fires on every set, + including the initial paint. + handle: el, get() (current step), set(v) — bars at or below v light + gold; click advances (v + 1) % 5. + CSS lives in the "signal ladder" block of DUPRE_CSS; the lit/unlit bar + colours are inline (--gold / --wash), no other styles involved. */ +DUPRE.signalLadder = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 3; - const el = document.createElement('span'); el.className = 'ladder'; + const el = document.createElement('span'); el.className = 'dupre-ladder'; el.innerHTML = '<i></i><i></i><i></i><i></i>'; host.appendChild(el); function set(nv) { @@ -2592,7 +2627,7 @@ GW.signalLadder = function (host, opts = {}) { }; /* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */ -GW.fuelBar = function (host, opts = {}) { +DUPRE.fuelBar = function (host, opts = {}) { const onChange = opts.onChange || noop; const warnAt = opts.warnAt ?? 20; let v = opts.value ?? 50; @@ -2612,7 +2647,7 @@ GW.fuelBar = function (host, opts = {}) { }; /* 15 radial ring — percentage donut; drag up/down to set */ -GW.radialRing = function (host, opts = {}) { +DUPRE.radialRing = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 68; const el = document.createElement('span'); el.className = 'ring'; @@ -2631,7 +2666,7 @@ GW.radialRing = function (host, opts = {}) { }; /* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it */ -GW.sparkline = function (host, opts = {}) { +DUPRE.sparkline = function (host, opts = {}) { const onChange = opts.onChange || noop; const n = opts.samples || 40; const hist = Array.from({ length: n }, () => opts.value ?? 0.5); @@ -2650,7 +2685,7 @@ GW.sparkline = function (host, opts = {}) { }; /* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */ -GW.waveStrip = function (host, opts = {}) { +DUPRE.waveStrip = function (host, opts = {}) { const onChange = opts.onChange || noop; const el = document.createElement('span'); el.className = 'wave'; el.innerHTML = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>'; @@ -2671,7 +2706,7 @@ GW.waveStrip = function (host, opts = {}) { }; /* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1 */ -GW.scope = function (host, opts = {}) { +DUPRE.scope = function (host, opts = {}) { const onChange = opts.onChange || noop; const el = document.createElement('span'); el.className = 'scope'; el.innerHTML = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>'; @@ -2688,7 +2723,7 @@ GW.scope = function (host, opts = {}) { }; /* N12 spectrum / EQ bars — set(values) paints one column per band */ -GW.eqBars = function (host, opts = {}) { +DUPRE.eqBars = function (host, opts = {}) { const onChange = opts.onChange || noop; const bands = opts.bands || 11, cells = opts.cells || 9; const el = document.createElement('span'); el.className = 'eq'; @@ -2717,7 +2752,7 @@ GW.eqBars = function (host, opts = {}) { }; /* N13 crossed-needle meter — one drive value, FWD and RFL needles cross */ -GW.crossNeedle = function (host, opts = {}) { +DUPRE.crossNeedle = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 55; const el = document.createElement('div'); el.className = 'crossm'; @@ -2738,7 +2773,7 @@ GW.crossNeedle = function (host, opts = {}) { }; /* N14 thermometer column — mercury rises with the value */ -GW.thermometer = function (host, opts = {}) { +DUPRE.thermometer = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 58; const el = document.createElement('div'); el.className = 'thermo'; @@ -2757,7 +2792,7 @@ GW.thermometer = function (host, opts = {}) { }; /* N15 bourdon pressure gauge — needle over a printed arc with a redline */ -GW.bourdon = function (host, opts = {}) { +DUPRE.bourdon = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 45; const el = document.createElement('div'); el.className = 'bourdon'; @@ -2778,7 +2813,7 @@ GW.bourdon = function (host, opts = {}) { /* N16 strip-chart recorder — scrolling history, pen rides the newest value; push(v) appends, set(samples, current) replaces the trace (values 0..1) */ -GW.stripChart = function (host, opts = {}) { +DUPRE.stripChart = function (host, opts = {}) { const onChange = opts.onChange || noop; const n = opts.samples || 60; const hist = Array.from({ length: n }, () => opts.value ?? 0.5); @@ -2802,7 +2837,7 @@ GW.stripChart = function (host, opts = {}) { }; /* N17 correlation meter — needle rests at 0 and swings ±; drag left/right */ -GW.corrMeter = function (host, opts = {}) { +DUPRE.corrMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; let p = opts.value ?? 58; const el = document.createElement('div'); el.className = 'corr'; @@ -2822,7 +2857,7 @@ GW.corrMeter = function (host, opts = {}) { }; /* N18 battery-cell gauge — charge as discrete cells, the low end warns */ -GW.battCells = function (host, opts = {}) { +DUPRE.battCells = function (host, opts = {}) { const onChange = opts.onChange || noop; const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25; let p = opts.value ?? 62; @@ -2844,7 +2879,7 @@ GW.battCells = function (host, opts = {}) { /* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale; set(t) positions instantly (0..1.02); ballistics belong to the signal owner */ -GW.mcVu = function (host, opts = {}) { +DUPRE.mcVu = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 150, 96); const cx = 75, cy = 112, sweep = t => -43 + t * 86; @@ -2895,8 +2930,17 @@ GW.mcVu = function (host, opts = {}) { return { el: s, get: () => t, set }; }; -/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */ -GW.roundMeter = function (host, opts = {}) { +/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, '+x.x dB') fires on + every set — the readout maps the linear value through the shared + VU law (VUDB/vuDb), like every VU-family meter. + handle: el, get() (current value), set(v) — the needle sweeps -40°..+40° + over the clamped 0-1 position. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the face clip is a per-instance defs entry. */ +DUPRE.roundMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 104); const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80; @@ -2948,7 +2992,7 @@ GW.roundMeter = function (host, opts = {}) { }; /* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */ -GW.chromeMinMax = function (host, opts = {}) { +DUPRE.chromeMinMax = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 104); const cx = 55, cy = 58; @@ -2991,7 +3035,7 @@ GW.chromeMinMax = function (host, opts = {}) { }; /* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */ -GW.aviationGauge = function (host, opts = {}) { +DUPRE.aviationGauge = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 104); const cx = 55, cy = 52, sweep = t => -135 + t * 270; @@ -3045,7 +3089,7 @@ GW.aviationGauge = function (host, opts = {}) { }; /* R13 edgewise strip meter — compressed log scale, the bar rides the edge */ -GW.edgeMeter = function (host, opts = {}) { +DUPRE.edgeMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]]; const s = stageSvg(host, 'rsvg drag', 70, 130); @@ -3086,9 +3130,9 @@ GW.edgeMeter = function (host, opts = {}) { return { el: s, get: () => frac, set }; }; -/* R17 round CRT scope — pale phosphor face; the trace is the widget's own +/* R17 round CRT scope — pale phosphor face; the trace is the instrument's own animation, so it lives here with its reduced-motion gate */ -GW.roundCrt = function (host, opts = {}) { +DUPRE.roundCrt = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 110, 104); const cx = 55, cy = 52; @@ -3098,7 +3142,7 @@ GW.roundCrt = function (host, opts = {}) { const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); }); - /* the face gradient reads screen-family vars from this widget's subtree, + /* the face gradient reads screen-family vars from this instrument's subtree, so it must live in the local defs, not the shared def sink */ const defs = svgEl(s, 'defs', {}); const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' }); @@ -3139,7 +3183,7 @@ GW.roundCrt = function (host, opts = {}) { }; /* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */ -GW.attitudeIndicator = function (host, opts = {}) { +DUPRE.attitudeIndicator = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 130); const cx = 65, cy = 65; @@ -3194,8 +3238,8 @@ GW.attitudeIndicator = function (host, opts = {}) { }; /* R44 heading bug + servo needle — drag parks the command; the needle chases - with honest servo lag (widget-owned animation, reduced-motion gated) */ -GW.headingBug = function (host, opts = {}) { + with honest servo lag (instrument-owned animation, reduced-motion gated) */ +DUPRE.headingBug = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 130); const cx = 65, cy = 65; @@ -3245,8 +3289,8 @@ GW.headingBug = function (host, opts = {}) { }; /* R53 circular chart recorder — a day per revolution; the pen draws the cycle - (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */ -GW.chartRecorder = function (host, opts = {}) { + (instrument-owned clock, reduced-motion paints the full day once); click for fresh paper */ +DUPRE.chartRecorder = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130); const cx = 65, cy = 65; @@ -3296,7 +3340,7 @@ GW.chartRecorder = function (host, opts = {}) { }; /* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */ -GW.verticalTape = function (host, opts = {}) { +DUPRE.verticalTape = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 70, 130); const CX = 38, CY = 65; @@ -3338,7 +3382,7 @@ GW.verticalTape = function (host, opts = {}) { }; /* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */ -GW.twinNeedle = function (host, opts = {}) { +DUPRE.twinNeedle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 120, 110); const cx = 60, cy = 62, R = 44; @@ -3406,7 +3450,7 @@ GW.twinNeedle = function (host, opts = {}) { }; /* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */ -GW.comfortMeter = function (host, opts = {}) { +DUPRE.comfortMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 122); const cx = 65, cy = 60; @@ -3501,10 +3545,19 @@ GW.comfortMeter = function (host, opts = {}) { /* ================= indicators & readouts ================= */ -/* 18 status lamps — one lamp per health state; click any lamp to cycle it */ -GW.statusLamps = function (host, opts = {}) { +/* 18 status lamps — one lamp per health state; click any lamp to cycle it. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array of state indices, default [0,1,2,3,4] — one lamp + per entry; 0 ok · 1 engaged · 2 fault · 3 off · 4 busy); + onChange(states array copy, state name) fires per change and once + at build with a count summary. + handle: el, get() (states copy), set(i, st) — st wraps modulo 5. + CSS lives in the "shared primitives" .dupre-lamp block of DUPRE_CSS + (shared with the output well's step lamps); no other styles involved. */ +DUPRE.statusLamps = function (host, opts = {}) { const onChange = opts.onChange || noop; - const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy']; + const CLS = ['dupre-lamp', 'dupre-lamp dupre-gold', 'dupre-lamp dupre-red', 'dupre-lamp dupre-off', 'dupre-lamp dupre-busy']; const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy']; const states = (opts.states || [0, 1, 2, 3, 4]).slice(); const wrap = document.createElement('span'); @@ -3525,7 +3578,7 @@ GW.statusLamps = function (host, opts = {}) { }; /* 19 badges — labelled flags; click a badge to cycle its variant */ -GW.badges = function (host, opts = {}) { +DUPRE.badges = function (host, opts = {}) { const onChange = opts.onChange || noop; const CLS = ['badge', 'badge red', 'badge ghost']; const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]]; @@ -3550,7 +3603,7 @@ GW.badges = function (host, opts = {}) { /* 20 tabular readout — mm:ss countdown; builder owns the state, the page drives tick() on its own clock (tick contract); click pauses/resumes */ -GW.tabularReadout = function (host, opts = {}) { +DUPRE.tabularReadout = function (host, opts = {}) { const onChange = opts.onChange || noop; const wrap = document.createElement('div'); wrap.style.textAlign = 'center'; wrap.innerHTML = '<div class="readout"></div><span class="u"></span>'; @@ -3569,7 +3622,7 @@ GW.tabularReadout = function (host, opts = {}) { }; /* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9 */ -GW.engravedLabel = function (host, opts = {}) { +DUPRE.engravedLabel = function (host, opts = {}) { const onChange = opts.onChange || noop; const e = document.createElement('span'); e.className = 'engrave'; e.append(opts.label || 'outputs'); @@ -3584,7 +3637,7 @@ GW.engravedLabel = function (host, opts = {}) { /* 22 output well — streaming step log, lamp per step; click streams the next demo step; push([lampCls, name, evidence]) appends programmatically */ -GW.outputWell = function (host, opts = {}) { +DUPRE.outputWell = function (host, opts = {}) { const onChange = opts.onChange || noop; const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']]; const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']]; @@ -3592,7 +3645,7 @@ GW.outputWell = function (host, opts = {}) { const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w); const add = s => { const d = document.createElement('div'); d.className = 'ostep'; - d.innerHTML = `<span class="lamp ${s[0]}"></span><span><b>${s[1]}</b><span class="ev">${s[2]}</span></span>`; + d.innerHTML = `<span class="dupre-lamp${s[0] ? ' dupre-' + s[0] : ''}"></span><span><b>${s[1]}</b><span class="ev">${s[2]}</span></span>`; w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild); }; seed.forEach(add); @@ -3605,7 +3658,7 @@ GW.outputWell = function (host, opts = {}) { /* 23 toast — one-line transient confirmation; click fires the next demo message; fire(msg) shows any message with the fade-in */ -GW.toast = function (host, opts = {}) { +DUPRE.toast = function (host, opts = {}) { const onChange = opts.onChange || noop; const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved']; const t = document.createElement('span'); t.className = 'toastw'; @@ -3621,14 +3674,22 @@ GW.toast = function (host, opts = {}) { return { el: t, fire }; }; -/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */ -GW.nixie = function (host, opts = {}) { +/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments. + + Contract (everything a consumer needs; no page globals touched): + opts: digits (tube count, default 2); value (initial, default 8); + onChange(value, zero-padded string) fires on every set. + handle: el, get() (current value), set(v) — wraps modulo 10^digits; + leading-zero tubes go dark rather than showing 0, like the + hardware. + CSS lives in the "nixie tube" block of DUPRE_CSS; no other styles involved. */ +DUPRE.nixie = function (host, opts = {}) { const onChange = opts.onChange || noop; const digits = opts.digits || 2; const max = Math.pow(10, digits); - const nx = document.createElement('span'); nx.className = 'nixie'; + const nx = document.createElement('span'); nx.className = 'dupre-nixie'; for (let i = 0; i < digits; i++) { - const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu); + const tu = document.createElement('span'); tu.className = 'dupre-tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu); } host.appendChild(nx); let v; @@ -3636,7 +3697,7 @@ GW.nixie = function (host, opts = {}) { v = ((x % max) + max) % max; const s = String(v).padStart(digits, '0'); [...nx.children].forEach((tu, i) => { - tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); + tu.classList.toggle('dupre-off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); tu.querySelector('b').textContent = s[i]; }); onChange(v, s); @@ -3646,45 +3707,215 @@ GW.nixie = function (host, opts = {}) { return { el: nx, get: () => v, set }; }; -/* N20 split-flap — windows flip to the next word with the mechanical clack; - the page drives next() on its own cadence; first paint is silent like the original */ -GW.splitFlap = 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. 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. 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 (100, per-cell rate jittered 0.8x-1.35x so letters finish + at different times, base mutable via setFlapMs); animate (true); + skin ('paper'|'white'|'dark'|'light', card default paper); font ('helv'|'mono', card default 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 DUPRE_CSS; no other styles involved. */ +DUPRE.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 cells = opts.cells || 3; - const f = document.createElement('span'); f.className = 'flap'; - for (let i = 0; i < cells; i++) { - const d = document.createElement('span'); d.className = 'flapd'; d.innerHTML = '<b></b>'; f.appendChild(d); + const rows = opts.rows || 1; + const cells = opts.cells || 4; + const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const clampMs = ms => Math.min(400, Math.max(20, ms | 0)); + let flapMs = clampMs(opts.flapMs || 100); + + 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 = []; // 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>'; + 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); - let idx = 0; - const paint = () => { - const w = words[idx].padEnd(cells + 1, ' '); - [...f.children].forEach((dd, i) => { - const ch = w[i] || ' ', bel = dd.querySelector('b'); - if (bel.textContent !== ch) { - bel.textContent = ch; - if (animate) { dd.classList.remove('flip'); void dd.offsetWidth; dd.classList.add('flip'); } + + // 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 => { + if (S.running) return; + S.running = true; + (async () => { + while (S.cur !== S.target) { + const nk = (S.cur + 1) % chars.length; + 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)' }], flip).finished, + S.fbn.animate([{ transform: 'rotateX(180deg)' }, { transform: 'rotateX(0deg)' }], flip).finished, + ]); + } catch { break; /* cancelled (card torn down mid-flip) */ } + } + S.ftc.textContent = S.fbc.textContent = chars[nk]; + 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 => { + announced = false; + 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. + gridTargets(str).forEach((k, i) => { st[i].target = k; }); + st.forEach(run); + maybeSettle(); // covers a command that changes nothing + }; + 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]; }); }; - const set = i => { idx = ((i % words.length) + words.length) % words.length; paint(); onChange(idx, words[idx].trim()); }; - f.addEventListener('click', () => set(idx + 1)); - paint(); - return { el: f, get: () => idx, set, next: () => set(idx + 1) }; + + // 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. + 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); + }; + 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); + + // 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 = DUPRE.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')); + return { + el: f, get: () => idx, set, next: scramble, setStyle, + setText, chars, reading, onSettle: cb => settleCbs.push(cb), + flapMs: () => flapMs, setFlapMs: ms => { flapMs = clampMs(ms); }, + }; +}; +DUPRE.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: { + paper: { cls: 'flap-paper', dot: '#ffffff' }, + white: { cls: 'flap-white', dot: '#141210' }, + dark: { cls: '', dot: '#141210' }, + light: { cls: 'flap-light', dot: '#f3e7c5' }, + }, + // typeface: the kit's Berkeley Mono, or the grotesque real boards wore + font: { + helv: { cls: 'flap-helv', dot: '#0d0c0b' }, + mono: { cls: '', dot: '#0d0c0b' }, + }, }; /* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); - click adds a minute */ -GW.sevenSeg = function (host, opts = {}) { + click adds a minute. + + Contract (everything a consumer needs; no page globals touched): + opts: secs (initial seconds, default 24:10); onChange(secs, 'mm:ss') + fires on every set. + handle: el, get() (current seconds), set(v) — wraps modulo 3600; + tick() steps one second down (the tick contract — the page owns + the clock); click adds 60. + CSS lives in the "seven-segment" block of DUPRE_CSS; the digit glyphs come + from the shared seg7() engine helper (.seg7, shared with the DSKY). */ +DUPRE.sevenSeg = function (host, opts = {}) { const onChange = opts.onChange || noop; - const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv); + const sv = document.createElement('span'); sv.className = 'dupre-seven'; host.appendChild(sv); let secs; const set = v => { secs = ((v % 3600) + 3600) % 3600; const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0'); - sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]); + sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="dupre-colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]); onChange(secs, mm + ':' + ss); }; sv.addEventListener('click', () => set(secs + 60)); @@ -3692,14 +3923,23 @@ GW.sevenSeg = function (host, opts = {}) { return { el: sv, get: () => secs, set, tick: () => set(secs - 1) }; }; -/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */ -GW.vfdMarquee = function (host, opts = {}) { +/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message. + + Contract (everything a consumer needs; no page globals touched): + opts: msgs (message pool, default demo set); width (window px, 176); + onChange(msg index, 'msg i/n') fires on click-cycle and once at + build. + handle: el, get() (current message index), tick() — advances the scroll + 1.1px and wraps when the text clears the window (the tick + contract — the page owns the clock). + CSS lives in the "VFD marquee" block of DUPRE_CSS; no other styles involved. */ +DUPRE.vfdMarquee = function (host, opts = {}) { const onChange = opts.onChange || noop; const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C']; const W = opts.width || 176; - const m = document.createElement('span'); m.className = 'vfdm'; - m.innerHTML = '<span class="txt"></span><span class="mesh"></span>'; - const t = m.querySelector('.txt'); t.textContent = msgs[0]; + const m = document.createElement('span'); m.className = 'dupre-vfdm'; + m.innerHTML = '<span class="dupre-txt"></span><span class="dupre-mesh"></span>'; + const t = m.querySelector('.dupre-txt'); t.textContent = msgs[0]; host.appendChild(m); let mi = 0, x = W; m.addEventListener('click', () => { @@ -3715,7 +3955,7 @@ GW.vfdMarquee = function (host, opts = {}) { /* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET lifecycle; TEST proves the bulbs then restores the board */ -GW.annunciator = function (host, opts = {}) { +DUPRE.annunciator = function (host, opts = {}) { const onChange = opts.onChange || noop; const CLS = ['acell', 'acell warn', 'acell fault']; const NAMES = ['ok', 'warn', 'fault']; @@ -3761,32 +4001,47 @@ GW.annunciator = function (host, opts = {}) { }; }; -/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */ -GW.jewels = function (host, opts = {}) { +/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array, one jewel per entry: 0 red · 1 amber · 2 green · + -1 dark; default [0,1,2,-1]); onChange(state, name) fires per + change and once at build with a hint. + handle: el, get() (states copy), set(i, st) — st clamps to the lens + range, negatives dark the jewel. State lives in here; the DOM + is a paint of it (was the reverse — the old builder kept state + only in classList/--jc, so nothing could read or drive it). + CSS lives in the "jewel" block of DUPRE_CSS; no other styles involved. */ +DUPRE.jewels = function (host, opts = {}) { const onChange = opts.onChange || noop; const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)']; const NAMES = ['red', 'amber', 'green']; - const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */ + const states = (opts.states || [0, 1, 2, -1]).slice(); /* index into cols; -1 = dark */ const wrap = document.createElement('span'); wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center'; - init.forEach(st => { - const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : ''); - if (st >= 0) j.style.setProperty('--jc', cols[st]); - j.addEventListener('click', () => { - if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; } - const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur); - if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); } - else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); } - }); - wrap.appendChild(j); + const paint = i => { + const st = states[i], j = els[i]; + j.className = 'dupre-jewel' + (st < 0 ? ' dupre-dim' : ''); + if (st >= 0) j.style.setProperty('--jc', cols[st]); else j.style.removeProperty('--jc'); + }; + function set(i, st) { + states[i] = st < 0 ? -1 : Math.min(st, cols.length - 1); + paint(i); + onChange(states[i], states[i] < 0 ? 'dark' : NAMES[states[i]]); + } + const els = states.map((_, i) => { + const j = document.createElement('span'); + j.addEventListener('click', () => set(i, states[i] >= cols.length - 1 ? -1 : states[i] + 1)); + wrap.appendChild(j); return j; }); host.appendChild(wrap); + states.forEach((_, i) => paint(i)); onChange(null, 'click to cycle'); - return { el: wrap }; + return { el: wrap, get: () => states.slice(), set }; }; /* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */ -GW.tapeCounter = function (host, opts = {}) { +DUPRE.tapeCounter = function (host, opts = {}) { const onChange = opts.onChange || noop; const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4; const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct); @@ -3809,14 +4064,22 @@ GW.tapeCounter = function (host, opts = {}) { }; /* N26 analog clock — engraved ticks + three hands; the page owns the time source - and drives set(h, m, s); silent until the first set, like the live original */ -GW.analogClock = function (host, opts = {}) { + and drives set(h, m, s); silent until the first set, like the live original. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange([h, m, s], 'hh:mm:ss') fires on every set. + handle: el, set(h, m, s) — the minute hand carries a seconds fraction + and the hour hand a minutes fraction, so the face reads like a + real movement. Display-only: no state to get, no clock of its + own (the tick contract — the page owns the time source). + CSS lives in the "analog clock" block of DUPRE_CSS; no other styles involved. */ +DUPRE.analogClock = function (host, opts = {}) { const onChange = opts.onChange || noop; - const cl = document.createElement('span'); cl.className = 'clock'; - for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } - cl.insertAdjacentHTML('beforeend', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>'); + const cl = document.createElement('span'); cl.className = 'dupre-clock'; + for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'dupre-tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } + cl.insertAdjacentHTML('beforeend', '<span class="dupre-hh"></span><span class="dupre-mh"></span><span class="dupre-sh"></span><span class="dupre-pin"></span>'); host.appendChild(cl); - const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh'); + const hh = cl.querySelector('.dupre-hh'), mh = cl.querySelector('.dupre-mh'), sh = cl.querySelector('.dupre-sh'); const set = (h, m, s) => { sh.style.transform = `rotate(${s * 6}deg)`; mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`; @@ -3828,7 +4091,7 @@ GW.analogClock = function (host, opts = {}) { /* N27 frequency-dial scale — printed log axis, marks crowd low and spread high; drag the pointer to tune */ -GW.freqScale = function (host, opts = {}) { +DUPRE.freqScale = function (host, opts = {}) { const onChange = opts.onChange || noop; const W = opts.width || 184; const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20]; @@ -3860,7 +4123,7 @@ GW.freqScale = function (host, opts = {}) { /* N28 patch bay — jack grid with SVG cables; click one jack then another to patch or unpatch the pair; redraws itself on window resize */ -GW.patchBay = function (host, opts = {}) { +DUPRE.patchBay = function (host, opts = {}) { const onChange = opts.onChange || noop; const rows = opts.rows || 2, cols = opts.cols || 4; const patch = document.createElement('div'); patch.className = 'patch'; @@ -3904,7 +4167,7 @@ GW.patchBay = function (host, opts = {}) { /* R10 data matrix readout — a page of labeled amber fields; click cycles pages; a page marked live re-renders on the builder's own 1s clock (reduced-motion gated) */ -GW.dataMatrix = function (host, opts = {}) { +DUPRE.dataMatrix = function (host, opts = {}) { const onChange = opts.onChange || noop; const pages = opts.pages || [ ['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']], @@ -3920,7 +4183,7 @@ GW.dataMatrix = function (host, opts = {}) { const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' }); svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 }); }); - /* the background gradient reads screen-family vars from this widget's subtree, + /* the background gradient reads screen-family vars from this instrument's subtree, so it lives in the local defs, not the shared def sink */ const bgId = uid('dmxBg'); const defs = svgEl(s, 'defs', {}); @@ -3956,7 +4219,7 @@ GW.dataMatrix = function (host, opts = {}) { /* R11 warning flag window — the striped mechanical flag slides into the window when the condition trips; click trips and clears it */ -GW.warningFlag = function (host, opts = {}) { +DUPRE.warningFlag = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 120, 64); def('barber', d => { @@ -3985,7 +4248,7 @@ GW.warningFlag = function (host, opts = {}) { /* R25 fourteen-segment display — the starburst alphanumeric that spells words; click cycles the word */ -GW.seg14 = function (host, opts = {}) { +DUPRE.seg14 = function (host, opts = {}) { const onChange = opts.onChange || noop; const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP']; const MAP = { @@ -4043,7 +4306,7 @@ GW.seg14 = function (host, opts = {}) { /* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag places the peak in both axes at once */ -GW.responseGraph = function (host, opts = {}) { +DUPRE.responseGraph = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 190, 110); const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 }; @@ -4111,7 +4374,7 @@ GW.responseGraph = function (host, opts = {}) { /* R30 telegraph indicator — the pointer names the active state on labeled sectors, engine-telegraph style; click steps the state */ -GW.telegraphIndicator = function (host, opts = {}) { +DUPRE.telegraphIndicator = function (host, opts = {}) { const onChange = opts.onChange || noop; const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT']; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length; @@ -4144,7 +4407,7 @@ GW.telegraphIndicator = function (host, opts = {}) { /* R31 radar sweep — the beam circles the bearing ring on the builder's own clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */ -GW.radarSweep = function (host, opts = {}) { +DUPRE.radarSweep = function (host, opts = {}) { const onChange = opts.onChange || noop; const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]]; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; @@ -4217,8 +4480,18 @@ GW.radarSweep = function (host, opts = {}) { }; /* R35 day-date disc calendar — coaxial printed discs rotate so today reads - under the fixed hand; starts on today; click rolls midnight forward */ -GW.dayDateCal = function (host, opts = {}) { + under the fixed hand; starts on today; click rolls midnight forward. + + Contract (everything a consumer needs; no page globals touched): + opts: date (1-31) and day (0-6, SUN=0), both defaulting to today; + onChange([date, day], 'DAY d') fires on every set, including the + initial paint. + handle: el, get() ([date, day]), set(d, w) — rotates both discs so the + commanded pair reads under the fixed hand; click advances date + and weekday together like a midnight rollover. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.dayDateCal = function (host, opts = {}) { const onChange = opts.onChange || noop; const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135; @@ -4274,7 +4547,7 @@ GW.dayDateCal = function (host, opts = {}) { /* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots to paint; starts on the reference K */ -GW.dotMatrix = function (host, opts = {}) { +DUPRE.dotMatrix = function (host, opts = {}) { const onChange = opts.onChange || noop; const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5; const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100']; @@ -4303,7 +4576,7 @@ GW.dotMatrix = function (host, opts = {}) { /* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the color swap at the midpoint; click a disc to flip it */ -GW.flipDisc = function (host, opts = {}) { +DUPRE.flipDisc = function (host, opts = {}) { const onChange = opts.onChange || noop; const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13; const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */ @@ -4332,7 +4605,7 @@ GW.flipDisc = function (host, opts = {}) { /* R46 dekatron counting ring — each click pulses the neon glow one cathode around; the wrap flashes the carry dot */ -GW.dekatron = function (host, opts = {}) { +DUPRE.dekatron = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60; svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 }); @@ -4377,7 +4650,7 @@ GW.dekatron = function (host, opts = {}) { /* R47 landing gear indicator — three greens or nothing; the lever cycles down → transit (amber pulse) → up */ -GW.gearIndicator = function (host, opts = {}) { +DUPRE.gearIndicator = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 140, 110); svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); @@ -4428,7 +4701,7 @@ GW.gearIndicator = function (host, opts = {}) { /* R52 blinkenlights front panel — address/data lamps ripple with a live pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the switch register; flip the SR bits and the pattern changes */ -GW.blinkenlights = function (host, opts = {}) { +DUPRE.blinkenlights = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 170, 100); svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 }); @@ -4476,15 +4749,15 @@ GW.blinkenlights = function (host, opts = {}) { return { el: s, get: () => sr, tick }; }; -/* ---- widget CSS: injected once, grows as builders move in ---- */ -/* widget-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */ -const GW_CSS = ` +/* ---- instrument CSS: injected once, grows as builders move in ---- */ +/* instrument-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */ +const DUPRE_CSS = ` /* ---- shared primitives ---- */ -.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} -.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} -.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} -.lamp.off{background:var(--wash);box-shadow:none} -.lamp.busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite} +.dupre-lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.dupre-lamp.dupre-gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} +.dupre-lamp.dupre-red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.dupre-lamp.dupre-off{background:var(--wash);box-shadow:none} +.dupre-lamp.dupre-busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite} /* standard pulse: 1s ease-in-out — the norm for every pulsing / flashing element */ @keyframes pulse{50%{opacity:.25}} @@ -4554,24 +4827,24 @@ const GW_CSS = ` margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)} /* needle gauge */ -.gauge{width:96px;cursor:ns-resize;touch-action:none} -.gauge .dial{position:relative;height:48px;overflow:hidden} -.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} -.gauge .tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px} -.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); +.dupre-gauge{width:96px;cursor:ns-resize;touch-action:none} +.dupre-gauge .dupre-dial{position:relative;height:48px;overflow:hidden} +.dupre-gauge .dupre-arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} +.dupre-gauge .dupre-tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px} +.dupre-gauge .dupre-ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5); transition:transform .12s cubic-bezier(.3,1.3,.5,1)} -.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} -.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} +.dupre-gauge .dupre-hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} +.dupre-gauge .dupre-gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} /* segmented VU / LED bar */ -.vu{width:170px;display:flex;flex-direction:column;gap:5px} -.vurow{display:flex;align-items:center;gap:7px} -.vurow .ch{color:var(--steel);font-size:.6rem;width:8px} -.vubar{flex:1;display:flex;gap:2px;height:9px} -.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} -.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)} -.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} +.dupre-vu{width:170px;display:flex;flex-direction:column;gap:5px} +.dupre-vurow{display:flex;align-items:center;gap:7px} +.dupre-vurow .dupre-ch{color:var(--steel);font-size:.6rem;width:8px} +.dupre-vubar{flex:1;display:flex;gap:2px;height:9px} +.dupre-vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.dupre-vubar i.dupre-on{opacity:1;background:var(--pass)}.dupre-vubar i.dupre-hot{opacity:1;background:var(--gold)} +.dupre-vubar i.dupre-clip{opacity:1;background:var(--fail)}.dupre-vubar i.dupre-peak{outline:1px solid var(--gold-hi);outline-offset:-1px} /* mini 4-bar signal */ .sig{display:flex;align-items:flex-end;gap:2px;height:18px} @@ -4580,10 +4853,10 @@ const GW_CSS = ` .sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} /* signal ladder (wifi bars) */ -.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer} -.ladder i{width:5px;background:var(--wash);border-radius:1px} -.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px} -.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px} +.dupre-ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer} +.dupre-ladder i{width:5px;background:var(--wash);border-radius:1px} +.dupre-ladder i:nth-child(1){height:6px}.dupre-ladder i:nth-child(2){height:10px} +.dupre-ladder i:nth-child(3){height:14px}.dupre-ladder i:nth-child(4){height:18px} /* linear progress / fuel bar */ .bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none} @@ -4640,7 +4913,7 @@ const GW_CSS = ` /* output well (log step) */ .owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer} .ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} -.ostep .lamp{margin-top:3px;width:7px;height:7px} +.ostep .dupre-lamp{margin-top:3px;width:7px;height:7px} .ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px} /* rotary selector */ @@ -4663,14 +4936,14 @@ const GW_CSS = ` background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s} /* nixie tube */ -.nixie{display:inline-flex;gap:5px;cursor:pointer} -.nixie .tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden; +.dupre-nixie{display:inline-flex;gap:5px;cursor:pointer} +.dupre-nixie .dupre-tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden; background:radial-gradient(circle at 50% 38%,#241a12,#0b0807);border:1px solid #2c261d; display:grid;place-items:center;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} -.nixie .tube b{font-size:26px;font-weight:400;color:#ff9a3c; +.dupre-nixie .dupre-tube b{font-size:26px;font-weight:400;color:#ff9a3c; text-shadow:0 0 6px rgba(255,140,50,.85),0 0 15px rgba(255,120,40,.45)} -.nixie .tube.off b{color:#3a2a1c;text-shadow:none} -.nixie .tube::after{content:"";position:absolute;inset:0;pointer-events:none; +.dupre-nixie .dupre-tube.dupre-off b{color:#3a2a1c;text-shadow:none} +.dupre-nixie .dupre-tube::after{content:"";position:absolute;inset:0;pointer-events:none; background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 32%)} /* ===== candidate-unique CSS ===== */ @@ -4868,23 +5141,42 @@ const GW_CSS = ` .batt .cell.warn.on{background:linear-gradient(180deg,var(--fail),#a04a34)} .batt .nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0} -/* split-flap */ +/* split-flap — four clipped half-panels per cell; the fold happens on + .ftc (falls) and .fbn (lands), driven by the builder's WAAPI flips */ .flap{display:inline-flex;gap:4px;cursor:pointer} -.flapd{width:30px;height:42px;border-radius:5px;position:relative;overflow:hidden; - background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5)} -.flapd b{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream); - text-shadow:0 1px 2px rgba(0,0,0,.7)} -.flapd::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:rgba(0,0,0,.65);box-shadow:0 1px 0 rgba(255,255,255,.03)} -.flapd.flip b{animation:flipdrop .3s ease-in} -@keyframes flipdrop{0%{transform:translateY(-42%) scaleY(.4);opacity:.3}100%{transform:none;opacity:1}} +.flapd{width:30px;height:42px;border-radius:5px;position:relative; + background:linear-gradient(180deg,#211d18,#141210);border:1px solid #34302a;box-shadow:0 2px 4px rgba(0,0,0,.5); + perspective:220px;transform-style:preserve-3d} +.flapd .fh{position:absolute;inset:0;display:grid;place-items:center;font-size:24px;color:var(--cream); + text-shadow:0 1px 2px rgba(0,0,0,.7);backface-visibility:hidden;transform-origin:50% 50%} +.flapd .ftn,.flapd .ftc{clip-path:inset(0 0 50% 0)} +.flapd .fbc,.flapd .fbn{clip-path:inset(50% 0 0 0)} +.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; +.dupre-seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer; box-shadow:inset 0 0 10px rgba(0,0,0,.6)} .seg7{width:22px;height:40px;filter:drop-shadow(0 0 3px rgba(87,211,87,.55))} .seg7.red{filter:drop-shadow(0 0 3px rgba(226,84,63,.55))} -.seven .colon{align-self:center;display:flex;flex-direction:column;gap:9px} -.seven .colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)} +.dupre-seven .dupre-colon{align-self:center;display:flex;flex-direction:column;gap:9px} +.dupre-seven .dupre-colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)} /* DSKY verb/noun panel */ .dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px} @@ -4904,11 +5196,11 @@ const GW_CSS = ` .dsky .pad .key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em} /* VFD marquee */ -.vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer; +.dupre-vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer; background:linear-gradient(180deg,#04100e,#020807);border:1px solid #123028;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} -.vfdm .txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em; +.dupre-vfdm .dupre-txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em; color:var(--vfd);text-shadow:0 0 6px rgba(99,230,200,.65)} -.vfdm .mesh{position:absolute;inset:0;pointer-events:none;opacity:.35; +.dupre-vfdm .dupre-mesh{position:absolute;inset:0;pointer-events:none;opacity:.35; background-image:radial-gradient(rgba(0,0,0,.6) 40%,transparent 41%);background-size:3px 3px} /* annunciator */ @@ -4927,13 +5219,13 @@ const GW_CSS = ` .acell.fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite} /* jewel */ -.jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer; +.dupre-jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer; background:radial-gradient(circle at 36% 30%,rgba(255,255,255,.75),var(--jc) 42%,#3a0d08 100%); box-shadow:0 0 10px 1px var(--jc),inset 0 -2px 3px rgba(0,0,0,.5)} -.jewel::after{content:"";position:absolute;inset:0;border-radius:50%; +.dupre-jewel::after{content:"";position:absolute;inset:0;border-radius:50%; background:conic-gradient(from 0deg,rgba(255,255,255,.12) 0 22deg,transparent 22deg 45deg,rgba(0,0,0,.18) 45deg 67deg,transparent 67deg 90deg); background-repeat:repeat} -.jewel.dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)} +.dupre-jewel.dupre-dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)} /* tape counter */ .counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px; @@ -4949,16 +5241,16 @@ const GW_CSS = ` .counter .redw{background:linear-gradient(180deg,#e7b46a,#cf9440)} /* analog clock */ -.clock{width:78px;height:78px;border-radius:50%;position:relative; +.dupre-clock{width:78px;height:78px;border-radius:50%;position:relative; background:radial-gradient(circle at 46% 40%,#1c1915,#0c0b09);border:2px solid #2c261d;box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} -.clock .tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px} -.clock .hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream); +.dupre-clock .dupre-tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px} +.dupre-clock .dupre-hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream); transform-origin:50% 100%;border-radius:2px} -.clock .mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver); +.dupre-clock .dupre-mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver); transform-origin:50% 100%;border-radius:2px} -.clock .sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi); +.dupre-clock .dupre-sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi); transform-origin:50% 100%;box-shadow:0 0 4px rgba(var(--glow-hi),.5)} -.clock .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)} +.dupre-clock .dupre-pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)} /* frequency-dial scale */ .freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none; @@ -4981,7 +5273,7 @@ const GW_CSS = ` .patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%} .patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round} -/* ===== reference-batch (R) widgets — SVG-first, after period hardware ===== */ +/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */ .rsvg{display:block} .rsvg.drag{cursor:ns-resize;touch-action:none} .rsvg.press{cursor:pointer} @@ -4991,44 +5283,44 @@ const GW_CSS = ` .reel.spin{animation:reelspin 2.6s linear infinite} `; function ensureCss() { - if (document.getElementById('gw-css') || !GW_CSS) return; - const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS; + if (document.getElementById('dupre-css') || !DUPRE_CSS) return; + const st = document.createElement('style'); st.id = 'dupre-css'; st.textContent = DUPRE_CSS; document.head.appendChild(st); } if (document.head) ensureCss(); else document.addEventListener('DOMContentLoaded', ensureCss); -/* Policy classification (see GW.POLICIES). This is the colour pass's worklist made +/* Policy classification (see DUPRE.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.', +DUPRE.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 instrument’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', +DUPRE.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', +DUPRE.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', +DUPRE.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', +DUPRE.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', +DUPRE.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', +DUPRE.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', +DUPRE.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; +Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS }); +window.DUPRE = DUPRE; })(); |
