From 055aceb0ea41c394f32f49baa95b8d072f5f0fd0 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 18 Jul 2026 18:05:55 -0500 Subject: feat(gallery): bring the CRT-through-engraved ten to the extraction bar roundCrt, attitudeIndicator, headingBug, chartRecorder, verticalTape, twinNeedle, comfortMeter, badges, tabularReadout, and engravedLabel each get the contract comment. The three DOM builders take dupre- prefixes across their markup and CSS blocks: badges (dupre-badge with the kit-wide dupre-red plus a new dupre-ghost), tabularReadout (dupre-readout and dupre-unit), and engravedLabel (dupre-engrave and dupre-cnt). The rename surfaced a dead rule: .readout .u selects a descendant, but the unit caption is a sibling, so its style never applied. The caption now styles through a standalone .dupre-unit rule. The builders now enforce their documented domains at build, not only in set(): twinNeedle and comfortMeter clamp their initial opts through set(), headingBug normalizes opts.value to 0..360, and badges coerces junk variants through one norm() shared by build and set(). Two probe checks cover the gates and drive the renamed DOM. Extraction audit: 94/111 contract comments. --- docs/prototypes/widgets.js | 164 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 129 insertions(+), 35 deletions(-) (limited to 'docs/prototypes') diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index dc37bd4..bd5c79f 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -3746,7 +3746,17 @@ DUPRE.edgeMeter = function (host, opts = {}) { }; /* R17 round CRT scope — pale phosphor face; the trace is the instrument's own - animation, so it lives here with its reduced-motion gate */ + animation, so it lives here with its reduced-motion gate. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(amp, 'Vpp N') fires on every animation tick, including + the initial paint — the demo waveform is instrument-owned. + handle: el, tick() — one animation step (the reduced-motion path paints + once and never ticks again on its own). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate, except the + face gradient, which reads screen-family vars from this instrument's + subtree and so lives in a local defs. */ DUPRE.roundCrt = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 110, 104); @@ -3797,7 +3807,16 @@ DUPRE.roundCrt = function (host, opts = {}) { return { el: s, tick }; }; -/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */ +/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag. + + Contract (everything a consumer needs; no page globals touched): + opts: bank (initial degrees, -60..60, default 0); pitch (initial + degrees, -20..20, default 0); onChange({bank, pitch}, 'BANK … + · PITCH …') fires on every set, including the initial paint. + handle: el, get() ({bank, pitch}), set(bank, pitch) — both clamp to + their ranges; the stage itself is a 2D drag surface. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.attitudeIndicator = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 130); @@ -3853,7 +3872,16 @@ DUPRE.attitudeIndicator = function (host, opts = {}) { }; /* R44 heading bug + servo needle — drag parks the command; the needle chases - with honest servo lag (instrument-owned animation, reduced-motion gated) */ + with honest servo lag (instrument-owned animation, reduced-motion gated). + + Contract (everything a consumer needs; no page globals touched): + opts: value (initial commanded heading in degrees, default 90 — + normalized to 0..360); onChange({cmd, act}, 'CMD … · ACT …') + fires on every set and on every servo step. + handle: el, get() ({cmd, act}), set(v) — v normalizes to 0..360; + dragging the stage moves the command bug, the needle chases. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.headingBug = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 130); @@ -3884,7 +3912,7 @@ DUPRE.headingBug = function (host, opts = {}) { fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6 }); svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); - let cmd = opts.value ?? 90, act = cmd; + let cmd = ((opts.value ?? 90) % 360 + 360) % 360, act = cmd; const draw = () => { bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`); needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`); @@ -3904,7 +3932,15 @@ DUPRE.headingBug = function (host, opts = {}) { }; /* R53 circular chart recorder — a day per revolution; the pen draws the cycle - (instrument-owned clock, reduced-motion paints the full day once); click for fresh paper */ + (instrument-owned clock, reduced-motion paints the full day once); click for fresh paper. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(hour, 'HH:MM · N%') fires on every pen step — the demo + day-cycle signal is instrument-owned. + handle: el, get() (current hour, 0..24), reset() — fresh paper, pen to + 00:00; clicking the stage does the same. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.chartRecorder = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130); @@ -3954,7 +3990,16 @@ DUPRE.chartRecorder = function (host, opts = {}) { return { el: s, get: () => hour, reset }; }; -/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */ +/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive. + + Contract (everything a consumer needs; no page globals touched): + opts: value (initial units, 5..35 on the printed RPM x100 scale, + default 24); onChange(value, 'RPM N') fires on every set, + including the initial paint. + handle: el, get() (units), set(v) — v clamps 5..35; the stage drags + vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.verticalTape = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 70, 130); @@ -3996,7 +4041,16 @@ DUPRE.verticalTape = function (host, opts = {}) { return { el: s, get: () => v, set }; }; -/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */ +/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles. + + Contract (everything a consumer needs; no page globals touched): + opts: fuel (initial, 0..4, default 2.4); oil (initial, 0..4, default + 3.1); onChange([fuel, oil], 'FUEL n.n · OIL n.n') fires on every + set, including the initial paint. + handle: el, get() ([fuel, oil]), set(fuel, oil) — both clamp 0..4; + each half of the stage drags its own needle vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.twinNeedle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 120, 110); @@ -4045,7 +4099,7 @@ DUPRE.twinNeedle = function (host, opts = {}) { }; const nF = needle('#e0523a'), nO = needle('#bfc4d0'); svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); - let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1; + let vF, vO; const draw = () => { nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`); nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`); @@ -4060,11 +4114,21 @@ DUPRE.twinNeedle = function (host, opts = {}) { }; half(0, () => vF, v => vF = v); half(60, () => vO, v => vO = v); - draw(); + set(opts.fuel ?? 2.4, opts.oil ?? 3.1); return { el: s, get: () => [vF, vO], set }; }; -/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */ +/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts. + + Contract (everything a consumer needs; no page globals touched): + opts: temp (initial °F, 40..100, default 72); humidity (initial %, + 0..100, default 45); onChange([temp, humidity], 'NF · N% RH · + VERDICT') fires on every set, including the initial paint. + handle: el, get() ([temp, humidity]), set(temp, humidity) — both clamp + to their scales; the left half of the stage drags temp, the + right half humidity. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ DUPRE.comfortMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 122); @@ -4137,7 +4201,7 @@ DUPRE.comfortMeter = function (host, opts = {}) { const nT = needle(), nH = needle(); nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`); nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`); - let vT = opts.temp ?? 72, vH = opts.humidity ?? 45; + let vT, vH; const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right'; const draw = () => { nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`); @@ -4154,7 +4218,7 @@ DUPRE.comfortMeter = function (host, opts = {}) { }; half(0, () => vT, v => vT = v, 40, 100); half(65, () => vH, v => vH = v, 0, 100); - draw(); + set(opts.temp ?? 72, opts.humidity ?? 45); return { el: s, get: () => [vT, vH], set }; }; @@ -4192,15 +4256,25 @@ DUPRE.statusLamps = function (host, opts = {}) { return { el: wrap, get: () => states.slice(), set }; }; -/* 19 badges — labelled flags; click a badge to cycle its variant */ +/* 19 badges — labelled flags; click a badge to cycle its variant. + + Contract (everything a consumer needs; no page globals touched): + opts: items (array of [label, variant] pairs, variant 0 gold · 1 red · + 2 ghost, default a three-badge demo set); onChange(variants array + copy, label) fires per change and once at build with a count + summary. + handle: el, get() (variants copy), set(i, v) — v wraps modulo 3. + CSS lives in the .dupre-badge block of DUPRE_CSS; no other styles + involved. */ DUPRE.badges = function (host, opts = {}) { const onChange = opts.onChange || noop; - const CLS = ['badge', 'badge red', 'badge ghost']; + const CLS = ['dupre-badge', 'dupre-badge dupre-red', 'dupre-badge dupre-ghost']; + const norm = v => (((v | 0) % CLS.length) + CLS.length) % CLS.length; const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]]; - const vars = items.map(it => it[1]); + const vars = items.map(it => norm(it[1])); const wrap = document.createElement('span'); - const els = items.map(([txt, v], i) => { - const b = document.createElement('span'); b.className = CLS[v]; b.textContent = txt; b.style.cursor = 'pointer'; + const els = items.map(([txt], i) => { + const b = document.createElement('span'); b.className = CLS[vars[i]]; b.textContent = txt; b.style.cursor = 'pointer'; b.addEventListener('click', () => set(i, vars[i] + 1)); wrap.appendChild(b); if (i < items.length - 1) wrap.appendChild(document.createTextNode(' ')); @@ -4208,7 +4282,7 @@ DUPRE.badges = function (host, opts = {}) { }); host.appendChild(wrap); function set(i, v) { - vars[i] = ((v % CLS.length) + CLS.length) % CLS.length; + vars[i] = norm(v); els[i].className = CLS[vars[i]]; onChange(vars.slice(), items[i][0]); } @@ -4217,13 +4291,24 @@ DUPRE.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 */ + drives tick() on its own clock (tick contract); click pauses/resumes. + + Contract (everything a consumer needs; no page globals touched): + opts: secs (initial seconds, wraps modulo 3600, default 24:10); run + (initial running state, default true); unit (caption under the + digits, default 'timer'); onChange(secs, 'running'/'paused') + fires on every set, including the initial paint. + handle: el, get() (secs), set(v) — v wraps modulo 3600; tick() — one + second down when running (the page's clock drives it); clicking + the digits pauses/resumes. + CSS lives in the .dupre-readout block of DUPRE_CSS; no other styles + involved. */ DUPRE.tabularReadout = function (host, opts = {}) { const onChange = opts.onChange || noop; const wrap = document.createElement('div'); wrap.style.textAlign = 'center'; - wrap.innerHTML = '
'; - const out = wrap.querySelector('.readout'); - wrap.querySelector('.u').textContent = opts.unit || 'timer'; + wrap.innerHTML = '
'; + const out = wrap.querySelector('.dupre-readout'); + wrap.querySelector('.dupre-unit').textContent = opts.unit || 'timer'; host.appendChild(wrap); let secs, run = opts.run !== undefined ? !!opts.run : true; const draw = () => { @@ -4236,12 +4321,21 @@ DUPRE.tabularReadout = function (host, opts = {}) { return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } }; }; -/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9 */ +/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9. + + Contract (everything a consumer needs; no page globals touched): + opts: label (caption text, default 'outputs'); count (initial count, + default 3); onChange(count, 'count N') fires on every set, + including the initial paint. + handle: el, get() (count), set(v); clicking the label cycles the count + 1..9. + CSS lives in the .dupre-engrave block of DUPRE_CSS; no other styles + involved. */ DUPRE.engravedLabel = function (host, opts = {}) { const onChange = opts.onChange || noop; - const e = document.createElement('span'); e.className = 'engrave'; + const e = document.createElement('span'); e.className = 'dupre-engrave'; e.append(opts.label || 'outputs'); - const c = document.createElement('span'); c.className = 'cnt'; e.appendChild(c); + const c = document.createElement('span'); c.className = 'dupre-cnt'; e.appendChild(c); host.appendChild(e); let n; const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); }; @@ -5420,9 +5514,9 @@ const DUPRE_CSS = ` .dupre-chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} .dupre-chip.dupre-on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))} -.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} -.badge.red{background:var(--fail);color:var(--cream)} -.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} +.dupre-badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.dupre-badge.dupre-red{background:var(--fail);color:var(--cream)} +.dupre-badge.dupre-ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} /* fader */ .dupre-fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none} @@ -5489,9 +5583,9 @@ const DUPRE_CSS = ` .dupre-ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} /* tabular readout */ -.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer} -.readout small{color:var(--dim);font-size:12px;font-weight:400} -.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} +.dupre-readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer} +.dupre-readout small{color:var(--dim);font-size:12px;font-weight:400} +.dupre-unit{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} /* sparkline */ .dupre-spark{width:170px;height:44px} @@ -5515,11 +5609,11 @@ const DUPRE_CSS = ` .dupre-seg button.dupre-on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700} /* engraved section label */ -.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; +.dupre-engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; display:flex;align-items:center;gap:9px} -.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} -.engrave::before{max-width:10px} -.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} +.dupre-engrave::before,.dupre-engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.dupre-engrave::before{max-width:10px} +.dupre-engrave .dupre-cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} /* waveform strip */ .dupre-wave{width:170px;height:38px} -- cgit v1.2.3