aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/widgets.js
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-18 17:21:24 -0500
committerCraig Jennings <c@cjennings.net>2026-07-18 17:21:24 -0500
commit2ec6fc8cdb34968f752d018e3a41285d35cf66d2 (patch)
tree922fa383325615117a011ad358b4ecdf192e4e68 /docs/prototypes/widgets.js
parent05398f5a02f2008c23c05cfe80c6dd4fcd183860 (diff)
downloadarchsetup-2ec6fc8cdb34968f752d018e3a41285d35cf66d2.tar.gz
archsetup-2ec6fc8cdb34968f752d018e3a41285d35cf66d2.zip
feat(gallery): bring the needle-through-edge ten to the extraction bar
crossNeedle, thermometer, bourdon, stripChart, corrMeter, battCells, mcVu, chromeMinMax, aviationGauge, and edgeMeter each get the contract comment. The six DOM builders take dupre- prefixes across their markup and CSS blocks. Internals go compound where gauge, fader, and nixie already hold the plain names: bourdon's needle, hub, and cap, corrMeter's face, arc, needle, and label, crossNeedle's arc, and thermometer's tube and fill. crossNeedle's nA and nB become dupre-fwd and dupre-rfl, and battCells moves its cell modifiers to the shared dupre-on and dupre-warn. The handles now enforce their documented domains: the needle meters clamp set() to 0-100, stripChart floors its history at two samples and survives a non-array set(), battCells floors its cell count at one, and mcVu clamps and paints opts.value at build instead of ignoring it for a hard-coded .35. Two probe checks cover the gates and drive the renamed DOM. Extraction audit: 84/111 contract comments.
Diffstat (limited to 'docs/prototypes/widgets.js')
-rw-r--r--docs/prototypes/widgets.js247
1 files changed, 170 insertions, 77 deletions
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 6477046..dc37bd4 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -3273,17 +3273,26 @@ DUPRE.eqBars = function (host, opts = {}) {
return { el, get: () => vals, set };
};
-/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross */
+/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 55); onChange(value, 'FWD n · RFL n') fires
+ on every set — RFL derives from the drive value (0.68×), the way a
+ crossed-needle SWR face couples the two.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag on
+ the face sets by vertical delta.
+ CSS lives in the "crossed-needle" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.crossNeedle = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 55;
- const el = document.createElement('div'); el.className = 'crossm';
- el.innerHTML = `<div class="face"><div class="arc"></div><div class="nA"></div><div class="nB"></div></div>
- <div class="lbl"><span>FWD</span><span>RFL</span></div>`;
+ const el = document.createElement('div'); el.className = 'dupre-crossm';
+ el.innerHTML = `<div class="dupre-face"><div class="dupre-crossm-arc"></div><div class="dupre-fwd"></div><div class="dupre-rfl"></div></div>
+ <div class="dupre-lbl"><span>FWD</span><span>RFL</span></div>`;
host.appendChild(el);
- const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB');
+ const face = el.querySelector('.dupre-face'), nA = el.querySelector('.dupre-fwd'), nB = el.querySelector('.dupre-rfl');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
const fwd = v, rfl = v * 0.68;
nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`;
nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`;
@@ -3294,17 +3303,25 @@ DUPRE.crossNeedle = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* N14 thermometer column — mercury rises with the value */
+/* N14 thermometer column — mercury rises with the value.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 58); onChange(value, 'N°') fires on every
+ set — the readout maps 0-100 onto the printed 30-90° scale.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag
+ anywhere on the column sets by vertical delta.
+ CSS lives in the "thermometer" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.thermometer = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 58;
- const el = document.createElement('div'); el.className = 'thermo';
- el.innerHTML = `<div class="scale"><span>90</span><span>60</span><span>30</span></div>
- <div class="wrapcol"><div class="tube"><div class="fill"></div></div><div class="bulb"></div></div>`;
+ const el = document.createElement('div'); el.className = 'dupre-thermo';
+ el.innerHTML = `<div class="dupre-scale"><span>90</span><span>60</span><span>30</span></div>
+ <div class="dupre-wrapcol"><div class="dupre-thermo-tube"><div class="dupre-thermo-fill"></div></div><div class="dupre-bulb"></div></div>`;
host.appendChild(el);
- const fill = el.querySelector('.fill');
+ const fill = el.querySelector('.dupre-thermo-fill');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
fill.style.height = v + '%';
onChange(v, Math.round(30 + v / 100 * 60) + '°');
}
@@ -3313,18 +3330,27 @@ DUPRE.thermometer = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* N15 bourdon pressure gauge — needle over a printed arc with a redline */
+/* N15 bourdon pressure gauge — needle over a printed arc with a redline.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 45); onChange(value, 'N PSI') fires on every
+ set — the readout maps 0-100 onto a 0-160 PSI scale.
+ handle: el, get() (current value), set(v) — clamped to 0-100, the
+ needle sweeps -60°..+60°; drag anywhere on the dial sets by
+ vertical delta. The printed arc and redline are the builder's
+ own inline SVG.
+ CSS lives in the "bourdon" block of DUPRE_CSS; no other styles involved. */
DUPRE.bourdon = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let v = opts.value ?? 45;
- const el = document.createElement('div'); el.className = 'bourdon';
+ const el = document.createElement('div'); el.className = 'dupre-bourdon';
el.innerHTML = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/>
<path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg>
- <div class="ndl"></div><div class="hub"></div><div class="cap">PSI</div>`;
+ <div class="dupre-bourdon-ndl"></div><div class="dupre-bourdon-hub"></div><div class="dupre-bourdon-cap">PSI</div>`;
host.appendChild(el);
- const ndl = el.querySelector('.ndl');
+ const ndl = el.querySelector('.dupre-bourdon-ndl');
function set(nv) {
- v = nv;
+ v = Math.max(0, Math.min(100, nv));
ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`;
onChange(v, Math.round(v / 100 * 160) + ' PSI');
}
@@ -3333,16 +3359,25 @@ DUPRE.bourdon = function (host, opts = {}) {
return { el, get: () => v, set };
};
-/* N16 strip-chart recorder — scrolling history, pen rides the newest value;
- push(v) appends, set(samples, current) replaces the trace (values 0..1) */
+/* N16 strip-chart recorder — scrolling history, pen rides the newest value.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: samples (history length, default 60, floor 2); value (initial
+ level 0-1, default 0.5); onChange(current, 'N') fires on every
+ paint (readout in percent). Values clamp to 0-1 at paint.
+ handle: el, get() (newest value), push(v) appends one sample and
+ scrolls, set(samples, current) replaces the whole trace (a
+ short or missing array back-fills 0.5).
+ CSS lives in the "strip-chart" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.stripChart = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const n = opts.samples || 60;
+ const n = Math.max(2, opts.samples || 60);
const hist = Array.from({ length: n }, () => opts.value ?? 0.5);
- const el = document.createElement('span'); el.className = 'strip';
- el.innerHTML = '<span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="pen" style="top:31px"></span>';
+ const el = document.createElement('span'); el.className = 'dupre-strip';
+ el.innerHTML = '<span class="dupre-rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="dupre-pen" style="top:31px"></span>';
host.appendChild(el);
- const line = el.querySelector('polyline'), pen = el.querySelector('.pen');
+ const line = el.querySelector('polyline'), pen = el.querySelector('.dupre-pen');
const clamp = x => Math.max(0, Math.min(1, x));
const yPx = v => 59 - clamp(v) * 56;
function paint(current) {
@@ -3352,23 +3387,33 @@ DUPRE.stripChart = function (host, opts = {}) {
}
function push(v) { hist.push(v); hist.shift(); paint(v); }
function set(samples, current) {
+ if (!Array.isArray(samples)) samples = [];
for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5;
paint(current ?? hist[n - 1]);
}
return { el, get: () => hist[n - 1], push, set };
};
-/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right */
+/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 58 — 50 is the 0 rest point); onChange(corr,
+ '±x.xx') fires on every set with the CORRELATION (-1..+1 mapped
+ from the 0-100 position), not the raw position.
+ handle: el, get() (current 0-100 position), set(p) — clamped to 0-100;
+ drag left/right sets by pointer position.
+ CSS lives in the "correlation" block of DUPRE_CSS; no other styles
+ involved. */
DUPRE.corrMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
let p = opts.value ?? 58;
- const el = document.createElement('div'); el.className = 'corr';
- el.innerHTML = `<div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl"></div></div>
- <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div>`;
+ const el = document.createElement('div'); el.className = 'dupre-corr';
+ el.innerHTML = `<div class="dupre-corr-face"><div class="dupre-corr-arc"></div><div class="dupre-zero"></div><div class="dupre-corr-ndl"></div></div>
+ <div class="dupre-corr-lbl"><span>−1</span><span>0</span><span>+1</span></div>`;
host.appendChild(el);
- const ndl = el.querySelector('.ndl');
+ const ndl = el.querySelector('.dupre-corr-ndl');
function set(np) {
- p = np;
+ p = Math.max(0, Math.min(100, np));
const v = (p - 50) / 50;
ndl.style.transform = `rotate(${v * 38}deg)`;
onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2));
@@ -3378,20 +3423,28 @@ DUPRE.corrMeter = function (host, opts = {}) {
return { el, get: () => p, set };
};
-/* N18 battery-cell gauge — charge as discrete cells, the low end warns */
+/* N18 battery-cell gauge — charge as discrete cells, the low end warns.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: cells (count, default 8, floor 1); warnAt (threshold, default 25 —
+ at or below it lit cells tint dupre-warn); value (0-100, default
+ 62); onChange(value, 'N%') fires on every set.
+ handle: el, get() (current value), set(v) — clamped to 0-100; drag
+ left/right sets by pointer position. Lit cells carry dupre-on.
+ CSS lives in the "battery" block of DUPRE_CSS; no other styles involved. */
DUPRE.battCells = function (host, opts = {}) {
const onChange = opts.onChange || noop;
- const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25;
+ const cells = Math.max(1, opts.cells || 8), warnAt = opts.warnAt ?? 25;
let p = opts.value ?? 62;
- const el = document.createElement('div'); el.className = 'batt';
- el.innerHTML = '<div class="cells"></div><span class="nub"></span>';
+ const el = document.createElement('div'); el.className = 'dupre-batt';
+ el.innerHTML = '<div class="dupre-cells"></div><span class="dupre-nub"></span>';
host.appendChild(el);
- const cc = el.querySelector('.cells');
- for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); }
+ const cc = el.querySelector('.dupre-cells');
+ for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'dupre-cell'; cc.appendChild(c); }
function set(np) {
- p = np;
+ p = Math.max(0, Math.min(100, np));
const lit = Math.round(p / 100 * cells);
- for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' warn' : '');
+ for (let i = 0; i < cells; i++) cc.children[i].className = 'dupre-cell' + (i < lit ? ' dupre-on' : '') + ((p <= warnAt && i < lit) ? ' dupre-warn' : '');
onChange(p, Math.round(p) + '%');
}
dragX(el, set);
@@ -3400,7 +3453,18 @@ DUPRE.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 */
+ ballistics belong to the signal owner.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-1.02, default .35 — clamped, the needle paints from it
+ at build); onChange(t, '+x.x VU') fires on every set, no initial
+ fire — the readout maps t through the shared VU law (VUDB/vuDb),
+ the needle caps at t=1.
+ handle: el, get() (current t), set(t) — clamped to 0-1.02; positions
+ instantly, no ballistics. No drag — display-only.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the glass gradient and face clip are per-instance defs
+ entries. */
DUPRE.mcVu = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg', 150, 96);
@@ -3436,13 +3500,13 @@ DUPRE.mcVu = function (host, opts = {}) {
x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700,
'font-family': 'var(--mono)', fill: '#3a3128'
}).textContent = 'VU';
+ let t = Math.max(0, Math.min(1.02, opts.value ?? .35));
const needle = svgEl(face, 'line', {
x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6,
- transform: `rotate(${sweep(.35)},${cx},${cy})`
+ transform: `rotate(${sweep(Math.min(1, t))},${cx},${cy})`
});
svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' });
svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` });
- let t = opts.value ?? .35;
function set(nt) {
t = Math.max(0, Math.min(1.02, nt));
needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`);
@@ -3513,7 +3577,18 @@ DUPRE.roundMeter = function (host, opts = {}) {
return { el: s, get: () => v, set };
};
-/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */
+/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 50); onChange(value, readout) fires on
+ every set — the readout says 'MIN' at ≤2, 'MAX' at ≥98, 'N%'
+ between.
+ handle: el, get() (current value), set(v) — clamped to 0-100, the
+ pointer sweeps -55°..+55°; drag anywhere sets by vertical
+ delta.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the ring and dome gradients register in the shared defs
+ plate. */
DUPRE.chromeMinMax = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
@@ -3556,7 +3631,16 @@ DUPRE.chromeMinMax = function (host, opts = {}) {
return { el: s, get: () => v, set };
};
-/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */
+/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (0-100, default 52); onChange(value, 'x.x ×100 rpm') fires
+ on every set — the readout maps 0-100 onto 0-8 ×100 RPM.
+ handle: el, get() (current value), set(v) — clamped to 0-100, the
+ needle sweeps -135°..+135°; drag anywhere sets by vertical
+ delta.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the needle-glow filter registers in the shared defs plate. */
DUPRE.aviationGauge = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg drag', 110, 104);
@@ -3610,7 +3694,16 @@ DUPRE.aviationGauge = function (host, opts = {}) {
return { el: s, get: () => v, set };
};
-/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */
+/* R13 edgewise strip meter — compressed log scale, the bar rides the edge.
+
+ Contract (everything a consumer needs; no page globals touched):
+ opts: value (fraction of the window, .08-.94, default .62); onChange(
+ frac, '−x.x dB') fires on every set — the readout interpolates
+ the compressed 0..−40 dB scale printed on the face.
+ handle: el, get() (current fraction), set(f) — clamped to .08-.94 (the
+ printed scale's span); drag up/down sets by pointer position.
+ SVG-built: styling is inline attributes plus the shared .rsvg stage block
+ of DUPRE_CSS; the face gradient is a per-instance defs entry. */
DUPRE.edgeMeter = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]];
@@ -5611,60 +5704,60 @@ const DUPRE_CSS = `
.dupre-eq .dupre-band i.dupre-clip{opacity:1;background:var(--fail)}
/* crossed-needle */
-.crossm{width:120px}
-.crossm .face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none}
-.crossm .arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%}
-.crossm .nA{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%;
+.dupre-crossm{width:120px}
+.dupre-crossm .dupre-face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none}
+.dupre-crossm .dupre-crossm-arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%}
+.dupre-crossm .dupre-fwd{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%;
border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
-.crossm .nB{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%;
+.dupre-crossm .dupre-rfl{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%;
border-radius:2px;box-shadow:0 0 5px rgba(203,107,77,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
-.crossm .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+.dupre-crossm .dupre-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
/* thermometer */
-.thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none}
-.thermo .tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
-.thermo .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))}
-.thermo .bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px;
+.dupre-thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none}
+.dupre-thermo .dupre-thermo-tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.dupre-thermo .dupre-thermo-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.dupre-thermo .dupre-bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px;
box-shadow:0 0 6px rgba(203,107,77,.5)}
-.thermo .scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)}
-.thermo .wrapcol{position:relative;padding-bottom:9px}
+.dupre-thermo .dupre-scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)}
+.dupre-thermo .dupre-wrapcol{position:relative;padding-bottom:9px}
/* bourdon */
-.bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
+.dupre-bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none;
background:radial-gradient(circle at 48% 42%,#1d1a16,#0c0b09);border:2px solid #2c261d;
box-shadow:inset 0 2px 5px rgba(0,0,0,.6)}
-.bourdon svg{position:absolute;inset:0}
-.bourdon .ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi);
+.dupre-bourdon svg{position:absolute;inset:0}
+.dupre-bourdon-ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi);
transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)}
-.bourdon .hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)}
-.bourdon .cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)}
+.dupre-bourdon-hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)}
+.dupre-bourdon-cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)}
/* strip-chart */
-.strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden;
+.dupre-strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden;
background:#0c0e0f;border:1px solid #231f18}
-.strip .rule{position:absolute;inset:0;opacity:.4;
+.dupre-strip .dupre-rule{position:absolute;inset:0;opacity:.4;
background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%}
-.strip svg{position:absolute;inset:0;width:100%;height:100%}
-.strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4}
-.strip .pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi);
+.dupre-strip svg{position:absolute;inset:0;width:100%;height:100%}
+.dupre-strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4}
+.dupre-strip .dupre-pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi);
box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear}
/* correlation */
-.corr{width:150px;cursor:pointer;touch-action:none}
-.corr .face{position:relative;height:44px;overflow:hidden}
-.corr .arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%}
-.corr .zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px}
-.corr .ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%;
+.dupre-corr{width:150px;cursor:pointer;touch-action:none}
+.dupre-corr-face{position:relative;height:44px;overflow:hidden}
+.dupre-corr-arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%}
+.dupre-corr .dupre-zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px}
+.dupre-corr-ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%;
border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .1s ease-out}
-.corr .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
+.dupre-corr-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px}
/* battery */
-.batt{display:flex;align-items:center;cursor:pointer;touch-action:none}
-.batt .cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10}
-.batt .cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35}
-.batt .cell.on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)}
-.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}
+.dupre-batt{display:flex;align-items:center;cursor:pointer;touch-action:none}
+.dupre-batt .dupre-cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10}
+.dupre-batt .dupre-cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35}
+.dupre-batt .dupre-cell.dupre-on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)}
+.dupre-batt .dupre-cell.dupre-warn.dupre-on{background:linear-gradient(180deg,var(--fail),#a04a34)}
+.dupre-batt .dupre-nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0}
/* split-flap — four clipped half-panels per cell; the fold happens on
.ftc (falls) and .fbn (lands), driven by the builder's WAAPI flips */