diff options
| -rw-r--r-- | docs/prototypes/widgets.js | 164 | ||||
| -rw-r--r-- | tests/gallery-probes/probe.mjs | 84 |
2 files changed, 213 insertions, 35 deletions
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 = '<div class="readout"></div><span class="u"></span>'; - const out = wrap.querySelector('.readout'); - wrap.querySelector('.u').textContent = opts.unit || 'timer'; + wrap.innerHTML = '<div class="dupre-readout"></div><span class="dupre-unit"></span>'; + 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} diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index 0539da0..81ca016 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -1402,6 +1402,90 @@ try { fwdT: 'rotate(-42deg)', rflT: 'rotate(42deg)', thH: '75%', bdT: 'rotate(0deg)', cmT: 'rotate(0deg)', penT: '3px' }), b8dom); + // batch-9 domain gates on detached hosts: the flight instruments clamp (or + // normalize) both set() and their build opts, badges coerces junk variants, + // the readout wraps its clock. + const clamps9 = await evl(`(() => { + const ai = DUPRE.attitudeIndicator(document.createElement('div'), { bank: -999, pitch: 5 }); + const aiB = ai.get().bank; + ai.set(999, 999); const aiHi = ai.get(); + const hb = DUPRE.headingBug(document.createElement('div'), { value: 450 }); + const hbB = hb.get().cmd; + const hbNdl = hb.el.querySelector('g polygon').parentNode.getAttribute('transform'); + hb.set(-30); const hbSet = hb.get().cmd; + const vt = DUPRE.verticalTape(document.createElement('div'), { value: 99 }); + const vtB = vt.get(); vt.set(0); const vtLo = vt.get(); + const vtT = vt.el.querySelector('g g').getAttribute('transform'); + const tn = DUPRE.twinNeedle(document.createElement('div'), { fuel: 99, oil: -5 }); + const tnB = tn.get(); + let cmTxt; const cm = DUPRE.comfortMeter(document.createElement('div'), + { temp: 999, humidity: -40, onChange: (v, t) => cmTxt = t }); + const cmB = cm.get(); + const bg = DUPRE.badges(document.createElement('div'), { items: [['X', 7], ['Y', 'junk']] }); + const bgVars = bg.get(); + const tr = DUPRE.tabularReadout(document.createElement('div'), { secs: -10 }); + const trB = tr.get(); + return JSON.stringify({ aiB, aiHi, hbB, hbNdl, hbSet, vtB, vtLo, vtT, tnB, cmB, cmTxt, bgVars, trB }); + })()`); + ok('batch-9 domain gates: flight-instrument clamps at build and set, badges variant coercion, readout wrap', + clamps9 === JSON.stringify({ aiB: -60, aiHi: { bank: 60, pitch: 20 }, hbB: 90, + hbNdl: 'rotate(90.0,65,65)', hbSet: 330, vtB: 35, vtLo: 5, vtT: 'translate(0,92.5)', + tnB: [4, 0], cmB: [100, 0], cmTxt: '100F · 0% RH · TOO WARM', bgVars: [1, 0], trB: 3590 }), clamps9); + + // batch-9 state through the renamed DOM (catches rename stragglers): badges + // swap variant classes, the readout counts down through dupre-readout and + // carries a styled dupre-unit (the old .readout .u rule was dead — .u is a + // sibling, so the caption rendered unstyled), the engraved label counts + // through dupre-cnt, and the SVG flight instruments track set(). + const b9dom = await evl(`(() => { + const bg = DUPRE.badges(document.createElement('div')); + const nBadge = bg.el.querySelectorAll('.dupre-badge').length; + const red0 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const ghost0 = bg.el.querySelectorAll('.dupre-badge.dupre-ghost').length; + bg.set(0, 1); + const red1 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const trHost = document.createElement('div'); document.body.appendChild(trHost); + const tr = DUPRE.tabularReadout(trHost); + const trTxt = tr.el.querySelector('.dupre-readout').textContent; + const unit = tr.el.querySelector('.dupre-unit'); + const unitTxt = unit.textContent; + const unitDisp = getComputedStyle(unit).display; + tr.tick(); const trTick = tr.el.querySelector('.dupre-readout').textContent; + tr.el.querySelector('.dupre-readout').click(); tr.tick(); + const trPaused = tr.el.querySelector('.dupre-readout').textContent; + trHost.remove(); + const el = DUPRE.engravedLabel(document.createElement('div')); + const cnt0 = el.el.querySelector('.dupre-cnt').textContent; + el.set(7); const cnt7 = el.el.querySelector('.dupre-cnt').textContent; + el.el.click(); const cnt8 = el.el.querySelector('.dupre-cnt').textContent; + const tn = DUPRE.twinNeedle(document.createElement('div')); + tn.set(0, 4); + const tnF = tn.el.querySelector('polygon[fill="#e0523a"]').parentNode.getAttribute('transform'); + const tnO = tn.el.querySelector('polygon[fill="#bfc4d0"]').parentNode.getAttribute('transform'); + const cm = DUPRE.comfortMeter(document.createElement('div')); + cm.set(70, 80); + const humid = [...cm.el.querySelectorAll('text')].find(t => t.textContent === 'TOO HUMID').getAttribute('fill'); + const ai = DUPRE.attitudeIndicator(document.createElement('div')); + ai.set(30, -10); + const aiT = ai.el.querySelector('g g').getAttribute('transform'); + const rc = DUPRE.roundCrt(document.createElement('div')); + const p1 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + rc.tick(); + const p2 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + const cr = DUPRE.chartRecorder(document.createElement('div')); + cr.reset(); + const crH = cr.get(); + const crD = cr.el.querySelector('path[stroke="#8f2416"]').getAttribute('d'); + return JSON.stringify({ nBadge, red0, ghost0, red1, trTxt, unitTxt, unitDisp, trTick, trPaused, + cnt0, cnt7, cnt8, tnF, tnO, humid, aiT, crtMoves: p1 !== p2 && !p2.includes('NaN'), crH, crD }); + })()`); + ok('batch-9 renamed DOM tracks state: badge variants, readout clock + styled unit, engraved count, instruments follow set()', + b9dom === JSON.stringify({ nBadge: 3, red0: 1, ghost0: 1, red1: 2, trTxt: '24:10', + unitTxt: 'timer', unitDisp: 'block', trTick: '24:09', trPaused: '24:09', + cnt0: '· 3', cnt7: '· 7', cnt8: '· 8', tnF: 'rotate(-170.0,60,62)', tnO: 'rotate(10.0,60,62)', + humid: '#c23a28', aiT: 'rotate(-30.0,65,65) translate(0,-16.0)', + crtMoves: true, crH: 0, crD: '' }), b9dom); + // late exceptions from interactions const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions after interaction', errs2.length === 0); |
