aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/prototypes/dupre-kit-additions.js30
-rw-r--r--docs/prototypes/gallery-widget.el29
-rw-r--r--docs/prototypes/gen_tokens.py21
-rw-r--r--docs/prototypes/panel-widget-gallery.html14
-rw-r--r--docs/prototypes/widgets.js160
-rw-r--r--docs/specs/2026-07-02-desktop-settings-panel-spec.org1
-rw-r--r--docs/specs/2026-07-11-net-doctor-expansion-spec.org1
7 files changed, 190 insertions, 66 deletions
diff --git a/docs/prototypes/dupre-kit-additions.js b/docs/prototypes/dupre-kit-additions.js
index 005a577..b1846a2 100644
--- a/docs/prototypes/dupre-kit-additions.js
+++ b/docs/prototypes/dupre-kit-additions.js
@@ -5,12 +5,9 @@
* so they can merge back into the kit with gallery cards.
*
* Load AFTER widgets.js. Contents:
- * - DUPRE.detentFader (NEW) — multi-detent slide attenuator with
- * speedbump drag physics.
- * - DUPRE.drumRoller (UPGRADE) — backward-compatible redefinition:
- * 1..N channels (stock hardcodes exactly two), configurable min/max
- * range and stage height. Defaults reproduce the stock instrument.
- * - DUPRE.guardedToggle (UPGRADE) — rotateX throw (no planar spin).
+ * - detentFader, drumRoller and guardedToggle have merged into widgets.js;
+ * their retired implementation references remain local below while the
+ * remaining additions continue to load in older casting prototypes.
* - DUPRE.batToggle (UPGRADE) — same throw fix.
* - DUPRE.slideRule (UPGRADE) — width option; the printed scale
* spreads its stops proportionally. Default keeps stock 180px.
@@ -101,21 +98,6 @@
const st = document.createElement('style');
st.id = 'dupre-additions-css';
st.textContent = `
-.dupre-dfader{position:relative;display:inline-block;width:230px;height:36px;cursor:ew-resize;touch-action:none}
-.dupre-dfader .df-slot{position:absolute;left:8px;right:8px;top:15px;height:6px;border-radius:4px;
- background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 1px 2px #000}
-.dupre-dfader .df-fill{position:absolute;left:9px;top:16px;height:4px;border-radius:3px;
- background:linear-gradient(180deg,var(--amber-grad-top,#f2c76a),var(--gold,#e2a038));pointer-events:none}
-.dupre-dfader .df-tick{position:absolute;top:9px;width:2px;height:18px;border-radius:1px;
- background:var(--steel,#969385);opacity:.55;transform:translateX(-50%);pointer-events:none}
-.dupre-dfader .df-tick.df-park{background:var(--gold-hi,#ffbe54);opacity:1;
- box-shadow:0 0 5px rgba(var(--glow-hi,255,190,84),.7)}
-.dupre-dfader .df-cap{position:absolute;top:5px;width:12px;height:26px;border-radius:3px;transform:translateX(-50%);
- background:linear-gradient(90deg,#7e7a70,#e8e5db 45%,#8a867c);border:1px solid #4e4a42;
- box-shadow:0 2px 4px #000a;pointer-events:none}
-.dupre-dfader .df-cap::after{content:"";position:absolute;left:50%;top:3px;bottom:3px;width:2px;
- transform:translateX(-50%);background:rgba(0,0,0,.35);border-radius:1px}
-.dupre-dfader.df-parked .df-cap{box-shadow:0 2px 4px #000a,0 0 7px rgba(var(--glow-hi,255,190,84),.55)}
.dupre-slsel{display:block;width:220px;cursor:pointer;touch-action:none;user-select:none}
.dupre-slsel .sl-cols{display:grid}
.dupre-slsel .sl-col{display:flex;flex-direction:column;align-items:center;gap:3px;padding-bottom:5px}
@@ -177,7 +159,7 @@
(the detent value or null).
CSS lives in the additions block (.dupre-dfader); no other styles
involved. */
- DUPRE.detentFader = function (host, opts = {}) {
+ const mergedDetentFaderReference = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const detents = (opts.detents || [50]).slice().sort((a, b) => a - b);
const MAGNET = opts.magnet !== undefined ? opts.magnet : 3;
@@ -255,7 +237,7 @@
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 are additions-scoped until merge. */
- DUPRE.drumRoller = function (host, opts = {}) {
+ const mergedDrumRollerReference = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const MIN = opts.min !== undefined ? opts.min : 1;
const MAX = opts.max !== undefined ? opts.max : 10;
@@ -313,7 +295,7 @@
foreshortens through the pivot and re-extends downward, never sweeping
sideways. Contract unchanged from stock: opts onLabel/offLabel/on/
onChange(on, 'ON'|'OFF'); handle {el, get, set}. */
- DUPRE.guardedToggle = function (host, opts = {}) {
+ const mergedGuardedToggleReference = function (host, opts = {}) {
const onChange = opts.onChange || noop;
const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52;
grad('dkaDiscRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]);
diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el
index 5a80e59..543affe 100644
--- a/docs/prototypes/gallery-widget.el
+++ b/docs/prototypes/gallery-widget.el
@@ -49,6 +49,12 @@ a typo'd token must fail loudly, not render black."
"Needle angle at value 0, degrees from vertical (negative = left).")
(defconst gallery-widget--gauge-max-deg 60.0
"Needle angle at value 100.")
+(defconst gallery-widget--gauge-cx 48
+ "Horizontal center of the needle gauge.")
+(defconst gallery-widget--gauge-cy 48
+ "Vertical center and baseline of the needle gauge.")
+(defconst gallery-widget--gauge-radius 47
+ "Outer radius of the needle gauge.")
(defun gallery-widget--clamp-value (value)
"Validate VALUE is a number and clamp it to the gauge's 0-100 range.
@@ -76,27 +82,36 @@ Positive angles swing right, matching the gauge's needle travel."
"Format coordinate N with two decimals for stable, readable SVG output."
(format "%.2f" n))
+(defun gallery-widget--semicircle-path (cx cy radius &optional close)
+ "Return an upper semicircle path centered at (CX, CY) with RADIUS.
+Append a closing segment when CLOSE is non-nil."
+ (format "M %g %g A %g %g 0 0 1 %g %g%s"
+ (- cx radius) cy radius radius (+ cx radius) cy
+ (if close " Z" "")))
+
(defun gallery-widget--node (svg tag &rest attrs)
"Append a TAG element with ATTRS plist to SVG and return it."
(let ((node (dom-node tag
(cl-loop for (k v) on attrs by #'cddr
collect (cons (intern (substring (symbol-name k) 1))
v)))))
- (svg--append svg node)
+ (dom-append-child svg node)
node))
(defun gallery-widget-needle-gauge (value)
"Render the gallery's needle gauge at VALUE (0-100) as an svg.el object.
Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its
bottom center, 40px needle sweeping -60..+60 degrees, readout below."
- (let* ((w 96) (h 64) (cx 48.0) (cy 48.0)
+ (let* ((w 96) (h 64)
+ (cx gallery-widget--gauge-cx)
+ (cy gallery-widget--gauge-cy)
;; clamp once; the angle and the readout render the same value
(value (gallery-widget--clamp-value value))
(angle (gallery-widget--needle-angle value))
(tip (gallery-widget--polar cx cy 40.0 angle))
(svg (svg-create w h :viewBox (format "0 0 %d %d" w h))))
;; glow filter: the SVG equivalent of the web card's box-shadow bloom
- (svg--append
+ (dom-append-child
svg
(dom-node 'defs nil
(dom-node 'filter
@@ -107,13 +122,15 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below."
(stdDeviation . "2"))))))
;; arc: 2px wash stroke, centerline radius 47 (96px circle, 2px border)
(gallery-widget--node svg 'path
- :d "M 1 48 A 47 47 0 0 1 95 48"
+ :d (gallery-widget--semicircle-path
+ cx cy gallery-widget--gauge-radius)
:fill "none"
:stroke (gallery-widget-token 'wash)
:stroke-width "2")
;; ticks: 8px steel radials at -60 / 0 / +60
(dolist (deg '(-60.0 0.0 60.0))
- (let ((outer (gallery-widget--polar cx cy 47.0 deg))
+ (let ((outer (gallery-widget--polar
+ cx cy gallery-widget--gauge-radius deg))
(inner (gallery-widget--polar cx cy 39.0 deg)))
(gallery-widget--node svg 'line
:class "tick"
@@ -148,7 +165,7 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below."
;; lower half of its 8px circle; here the dome is drawn directly)
(gallery-widget--node svg 'path
:class "hub"
- :d "M 44 48 A 4 4 0 0 1 52 48 Z"
+ :d (gallery-widget--semicircle-path cx cy 4 t)
:fill (gallery-widget-token 'gold))
;; value readout, matching the web card's cream bold figure
(svg-text svg (format "%d%%" (round value))
diff --git a/docs/prototypes/gen_tokens.py b/docs/prototypes/gen_tokens.py
index 9f011e9..7c80985 100644
--- a/docs/prototypes/gen_tokens.py
+++ b/docs/prototypes/gen_tokens.py
@@ -70,6 +70,12 @@ def resolve_color(tokens, key):
raise KeyError(key)
+def iter_section_items(tokens, sections):
+ """Yield (name, value) pairs from SECTIONS in their declared order."""
+ for section in sections:
+ yield from tokens.get(section, {}).items()
+
+
def _el_str(v):
"""Quote a value as an elisp string literal, escaping backslashes/quotes."""
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
@@ -78,9 +84,8 @@ def _el_str(v):
def emit_web_css(tokens):
"""The inner :root block: hyphenated --vars, glows as rgb triples."""
lines = []
- for section in COLOR_SECTIONS:
- for k, v in tokens.get(section, {}).items():
- lines.append(f"--{k}:{v};")
+ for k, v in iter_section_items(tokens, COLOR_SECTIONS):
+ lines.append(f"--{k}:{v};")
for name, source in tokens.get("glow", {}).items():
lines.append(f"--{name}:{hex_to_triple(resolve_color(tokens, source))};")
for k, v in tokens.get("font", {}).items():
@@ -100,9 +105,8 @@ def emit_waybar_gtk(tokens):
" GTK CSS has no custom properties; reference these as @name, and use",
" alpha(@name, 0.5) where the web build uses rgba(var(--name),.5). */",
]
- for section in COLOR_SECTIONS:
- for k, v in tokens.get(section, {}).items():
- lines.append(f"@define-color {gtk(k)} {v};")
+ for k, v in iter_section_items(tokens, COLOR_SECTIONS):
+ lines.append(f"@define-color {gtk(k)} {v};")
for name, source in tokens.get("glow", {}).items():
lines.append(f"@define-color {gtk(name)} {resolve_color(tokens, source)};")
return "\n".join(lines)
@@ -111,9 +115,8 @@ def emit_waybar_gtk(tokens):
def emit_elisp(tokens):
"""An alist of (name . hex) plus timing, for the future svg.el renderer."""
pairs = []
- for section in COLOR_SECTIONS:
- for k, v in tokens.get(section, {}).items():
- pairs.append(f"({k} . {_el_str(v)})")
+ for k, v in iter_section_items(tokens, COLOR_SECTIONS):
+ pairs.append(f"({k} . {_el_str(v)})")
for name, source in tokens.get("glow", {}).items():
pairs.append(f"({name} . {_el_str(resolve_color(tokens, source))})")
for k, v in tokens.get("font", {}).items():
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index 9ff492d..f53b823 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -581,6 +581,13 @@ const INFO={
origin:'Hardware samplers.',difficulty:'Expert.',
prefer:'The region IS the value and the data must stay visible.',
period:'1980s sampler styling.'},
+'A1':{input:'Drag horizontally; the thumb catches at each marked detent and needs an extra push to break free. Drag-only.',
+ solves:'A continuous value with a few mechanically meaningful resting points.',
+ use:'Specialty. Shines for balances and thresholds where common values should be felt without losing the continuum.',
+ limits:'Detents need enough spacing to remain distinct; a dense set should be a stepped selector instead.',
+ origin:'Console faders with center and calibrated detents.',difficulty:'Easy.',
+ prefer:'Use when the stops should slow a sweep, not quantize it.',
+ period:'1960s onward studio and control-console hardware.'},
'R20':{input:'Drag each drum vertically to roll it. Drag-only.',
solves:'Stepped selection with a satisfying mechanical roll.',
use:'Specialty. Shines for paired coarse selections (EQ turnovers).',
@@ -1394,9 +1401,12 @@ card(C,'R18','Thumb-slide attenuator pair',
card(C,'R19','Waveform region editor',
(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,'A1','Detent fader',
+ (st,rd)=>DUPRE.detentFader(st,{value:50,detents:[25,50,75],onChange:(v,t)=>rd(t)}),
+ '<b>continuous travel with three speedbumps.</b> The cap catches at each calibrated tick and needs an extra push to leave it. Drag through the rail; the readout names a parked detent.');
card(C,'R20','Drum roller selector',
- (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.');
+ (st,rd)=>DUPRE.drumRoller(st,{min:0,max:12,channels:[{name:'HIGH',v:6},{name:'MID',v:4},{name:'LOW',v:8}],onChange:(v,t)=>rd(t)}),
+ '<b>roll to the number.</b> Three independently sized numbered drums demonstrate the generalized channel bank and range; each center value sits on an inverted chip. Drag any drum. After an equalizer tone selector.');
card(C,'R21','LED program row',
(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.');
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index 57ad33e..508ec3c 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -1910,56 +1910,138 @@ DUPRE.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.
+/* A1 multi-detent fader — a horizontal attenuator with speedbump detents.
+
+ Contract:
+ opts: value (0-100, default 68); detents (default [50]); magnet (capture
+ distance, default 3); escape (extra distance needed to leave a
+ detent, default 3); onChange(value, label).
+ handle: el, get(), set(value), parked() (detent value or null). */
+DUPRE.detentFader = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const detents = (opts.detents || [50]).slice().sort((a, b) => a - b);
+ const MAGNET = opts.magnet !== undefined ? opts.magnet : 3;
+ const ESCAPE = opts.escape !== undefined ? opts.escape : 3;
+ const el = document.createElement('span'); el.className = 'dupre-dfader';
+ el.innerHTML = '<span class="df-slot"></span><span class="df-fill"></span>';
+ const ticks = detents.map(d => {
+ const t = document.createElement('span'); t.className = 'df-tick';
+ t.style.left = `calc(8px + ${d / 100} * (100% - 16px))`;
+ el.appendChild(t); return t;
+ });
+ const cap = document.createElement('span'); cap.className = 'df-cap';
+ el.appendChild(cap); host.appendChild(el);
+
+ let v, park = null;
+ const paint = () => {
+ const left = `calc(8px + ${v / 100} * (100% - 16px))`;
+ cap.style.left = left;
+ el.querySelector('.df-fill').style.width =
+ `calc(${v / 100} * (100% - 18px))`;
+ el.classList.toggle('df-parked', park !== null);
+ ticks.forEach((t, i) =>
+ t.classList.toggle('df-park', detents[i] === park));
+ };
+ const apply = raw => {
+ raw = Math.max(0, Math.min(100, raw));
+ if (park !== null) {
+ if (Math.abs(raw - park) <= MAGNET + ESCAPE) raw = park;
+ else park = null;
+ }
+ if (park === null) {
+ const near = detents.find(d => Math.abs(raw - d) <= MAGNET);
+ if (near !== undefined) { park = near; raw = near; }
+ }
+ const nv = Math.round(raw);
+ if (nv === v && el.dataset.painted) return;
+ v = nv; el.dataset.painted = '1'; paint();
+ onChange(v, park !== null ? `${v} · detent` : String(v));
+ };
+ el.addEventListener('pointerdown', e => {
+ el.setPointerCapture(e.pointerId);
+ const pct = m => {
+ const r = el.getBoundingClientRect();
+ return ((m.clientX - r.left - 8) / (r.width - 16)) * 100;
+ };
+ apply(pct(e));
+ const move = m => apply(pct(m));
+ const up = () => {
+ el.removeEventListener('pointermove', move);
+ el.removeEventListener('pointerup', up);
+ el.removeEventListener('pointercancel', up);
+ };
+ el.addEventListener('pointermove', move);
+ el.addEventListener('pointerup', up);
+ el.addEventListener('pointercancel', up);
+ e.preventDefault();
+ });
+ v = -1; apply(opts.value !== undefined ? opts.value : 68);
+ return { el, get: () => v, set: apply, parked: () => park };
+};
+
+/* R20 drum roller selector — numbered paper drums in a window.
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.
+ { name, v } drums, any count >= 1, default HIGH/LOW pair);
+ min/max (default 1/10); height (default 140); onChange(values,
+ 'NAME v · ...') fires on every set, including initial paint.
+ handle: el, get() (values array), set(i, v); each drum 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.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;
+ const MIN = opts.min !== undefined ? opts.min : 1;
+ const MAX = opts.max !== undefined ? opts.max : 10;
+ const chans = (opts.channels ||
+ [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }])
+ .map(c => ({ name: c.name, v: c.v }));
+ const N = chans.length;
+ const vw = Math.max(130, N * 50 + 40);
+ const vh = Math.max(100, opts.height !== undefined ? opts.height : 140);
+ const railY = 24, railH = vh - 36, winY = railY + 4, winH = railH - 8;
+ const s = stageSvg(host, 'rsvg', vw, vh);
+ const cy = winY + winH / 2, step = 17;
gradDef('thRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]);
gradDef('drPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]);
gradDef('drCurve', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.4)'], ['.28', 'rgba(0,0,0,0)'], ['.72', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.4)']]);
const localDefs = svgEl(s, 'defs', {});
- svgEl(s, 'text', { x: 65, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
+ if (opts.title !== '')
+ svgEl(s, 'text', { x: vw / 2, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.title || 'EQUALIZER';
const grps = [];
+ const label = () =>
+ chans.map(c => `${c.name} ${Math.round(c.v)}`).join(' · ');
const set = (i, v) => {
- v = Math.max(1, Math.min(10, v)); chans[i].v = v;
+ v = Math.max(MIN, Math.min(MAX, v)); chans[i].v = v;
const p = grps[i];
p.nums.style.transform = `translateY(${(v - p.base) * step}px)`;
p.chip.textContent = Math.round(v);
- onChange(chans.map(c => c.v), `${chans[0].name} ${Math.round(chans[0].v)} · ${chans[1].name} ${Math.round(chans[1].v)}`);
+ onChange(chans.map(c => c.v), label());
};
+ const x0 = (vw - (N - 1) * 50) / 2;
chans.forEach((ch, i) => {
- const x = 45 + i * 50, cid = uid('drClip');
- svgEl(s, 'text', { x: x + 18, y: 34, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
- svgEl(s, 'text', { x: x + 18, y: 124, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
- svgEl(s, 'rect', { x: x - 12, y: 24, width: 24, height: 104, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
+ const x = x0 + i * 50, cid = uid('drClip');
+ svgEl(s, 'text', { x: x + 18, y: winY + 8, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '+';
+ svgEl(s, 'text', { x: x + 18, y: winY + winH - 2, 'text-anchor': 'middle', 'font-size': 8, fill: 'var(--steel)' }).textContent = '−';
+ svgEl(s, 'rect', { x: x - 12, y: railY, width: 24, height: railH, rx: 4, fill: 'url(#thRail)', stroke: '#0a0908', 'stroke-width': 1.2 });
const clip = svgEl(localDefs, 'clipPath', { id: cid });
- svgEl(clip, 'rect', { x: x - 9, y: 28, width: 18, height: 96 });
- svgEl(s, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drPaper)' });
+ svgEl(clip, 'rect', { x: x - 9, y: winY, width: 18, height: winH });
+ svgEl(s, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#drPaper)' });
const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` });
const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s';
- for (let n = 1; n <= 10; n++)
+ for (let n = MIN; n <= MAX; n++)
svgEl(nums, 'text', { x, y: cy + 3 + (ch.v - n) * step, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }).textContent = n;
- svgEl(g, 'rect', { x: x - 9, y: 28, width: 18, height: 96, fill: 'url(#drCurve)', 'pointer-events': 'none' });
+ svgEl(g, 'rect', { x: x - 9, y: winY, width: 18, height: winH, fill: 'url(#drCurve)', 'pointer-events': 'none' });
svgEl(s, 'rect', { x: x - 8, y: cy - 6.5, width: 16, height: 13, rx: 1.5, fill: '#1a1613', opacity: .92 });
const chip = svgEl(s, 'text', { x, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' });
- svgEl(s, 'text', { x, y: 136, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
- const hit = svgEl(s, 'rect', { x: x - 14, y: 24, width: 28, height: 104, fill: 'transparent' });
+ svgEl(s, 'text', { x, y: vh - 4, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = ch.name;
+ const hit = svgEl(s, 'rect', { x: x - 14, y: railY, width: 28, height: railH, fill: 'transparent' });
hit.style.cursor = 'ns-resize';
grps.push({ nums, chip, base: ch.v });
- dragDelta(hit, () => chans[i].v, v => set(i, v), { min: 1, max: 10, sens: .08 });
+ dragDelta(hit, () => chans[i].v, v => set(i, v),
+ { min: MIN, max: MAX, sens: (MAX - MIN) / (winH * 1.1) });
});
- set(0, chans[0].v); set(1, chans[1].v);
+ chans.forEach((c, i) => set(i, c.v));
return { el: s, get: () => chans.map(c => c.v), set };
};
@@ -2251,11 +2333,13 @@ DUPRE.guardedToggle = function (host, opts = {}) {
svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#chromeG)', stroke: '#4e4a42', 'stroke-width': .5 });
svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#tipG)', stroke: '#5e5a52', 'stroke-width': .6 });
for (let r = 0; r < 4; r++) svgEl(lever, 'line', { x1: cx - 3.5, y1: 18.5 + r * 2.4, x2: cx + 3.5, y2: 18.5 + r * 2.4, stroke: 'rgba(0,0,0,.25)', 'stroke-width': .8 });
- lever.style.transformOrigin = `${cx}px ${cy}px`; lever.style.transition = 'transform .12s';
+ lever.style.transformBox = 'view-box';
+ lever.style.transformOrigin = `${cx}px ${cy}px`;
+ lever.style.transition = 'transform .16s ease-in-out';
let on;
const set = v => {
on = !!v;
- lever.style.transform = on ? 'rotate(0deg)' : 'rotate(180deg)';
+ lever.style.transform = on ? 'rotateX(0deg)' : 'rotateX(180deg)';
lblOn.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--dim)');
lblOff.setAttribute('fill', on ? 'var(--dim)' : 'var(--gold-hi)');
onChange(on, on ? 'ON' : 'OFF');
@@ -6089,6 +6173,23 @@ const DUPRE_CSS = `
.dupre-patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%}
.dupre-patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round}
+/* detent fader */
+.dupre-dfader{position:relative;display:inline-block;width:230px;height:36px;cursor:ew-resize;touch-action:none}
+.dupre-dfader .df-slot{position:absolute;left:8px;right:8px;top:15px;height:6px;border-radius:4px;
+ background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 1px 2px #000}
+.dupre-dfader .df-fill{position:absolute;left:9px;top:16px;height:4px;border-radius:3px;
+ background:linear-gradient(180deg,var(--amber-grad-top,#f2c76a),var(--gold,#e2a038));pointer-events:none}
+.dupre-dfader .df-tick{position:absolute;top:9px;width:2px;height:18px;border-radius:1px;
+ background:var(--steel,#969385);opacity:.55;transform:translateX(-50%);pointer-events:none}
+.dupre-dfader .df-tick.df-park{background:var(--gold-hi,#ffbe54);opacity:1;
+ box-shadow:0 0 5px rgba(var(--glow-hi,255,190,84),.7)}
+.dupre-dfader .df-cap{position:absolute;top:5px;width:12px;height:26px;border-radius:3px;transform:translateX(-50%);
+ background:linear-gradient(90deg,#7e7a70,#e8e5db 45%,#8a867c);border:1px solid #4e4a42;
+ box-shadow:0 2px 4px #000a;pointer-events:none}
+.dupre-dfader .df-cap::after{content:"";position:absolute;left:50%;top:3px;bottom:3px;width:2px;
+ transform:translateX(-50%);background:rgba(0,0,0,.35);border-radius:1px}
+.dupre-dfader.df-parked .df-cap{box-shadow:0 2px 4px #000a,0 0 7px rgba(var(--glow-hi,255,190,84),.55)}
+
/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */
.rsvg{display:block}
.rsvg.drag{cursor:ns-resize;touch-action:none}
@@ -6136,6 +6237,15 @@ DUPRE.radarSweep.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.' };
+DUPRE.detentFader.POLICY = { kind: 'relational',
+ why: 'The gold parked detent only means anything against the quieter ordinary scale ticks.',
+ authentic: 'The detent and scale remain a contrasting pair; their material finishes and spacing may vary.' };
+DUPRE.drumRoller.POLICY = { kind: 'material',
+ why: 'Its colours describe paper, ink and the dark mechanical housing rather than application state.',
+ authentic: 'Paper and metal finishes from physical numbered drum selectors; the channel count and numeric range may vary.' };
+DUPRE.guardedToggle.POLICY = { kind: 'coded',
+ why: 'The red guard and collar mark a critical protected throw; recolouring them would erase that warning grammar.',
+ authentic: 'Red warning hardware with neutral metal lever and posts. Labels and initial position may vary.' };
Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS });
window.DUPRE = DUPRE;
diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org
index 4618e75..ad01aa0 100644
--- a/docs/specs/2026-07-02-desktop-settings-panel-spec.org
+++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org
@@ -14,6 +14,7 @@
:PROPERTIES:
:ID: fb7eec22-a214-4568-82c4-903612f4832f
:END:
+- [2026-07-22 Wed] IMPLEMENTED — post-ship correction (dotfiles ba3ec5e): the CLOCK channel now projects the garage clock itself — the clock package gained a wallpaper mode and settings-project run clock execs it; faces/clock.html (the off-spec SVG dial phase 4 shipped) is deleted. Enforces the clock-channel backing decision: one owner per clock design. Verified live on a headless output (PNG face, ticking seconds, clean stop; panel service untouched).
- [2026-07-22 Wed] IMPLEMENTED — all four phases shipped in dotfiles: 7a15237 (phase 1, backings engine), 74f723e (bar slice, landed early), 5172289 (phase 2, presenters), 33d82eb (phase 3, GTK port of prototype 37), 680b50d (phase 4, projected faces / night watch / location / tick), 9038eee (the 17-point end-to-end pass re-derived from prototype 37's suite). Full dotfiles suite green (85 suites), AT-SPI smoke 13/13, e2e 17/17 on the live compositor. The Cairo drag gestures and the locked-path night-watch swap are on todo.org's "Manual testing and validation" checklist — human-eye checks, not blockers. Build adaptations from the phase logs: five-stage idle dial (WATCH ran as its own stage), held-to-unpause DND, no-backlight drums dim out, kiosk profile wiped per start, reveal gating pid-matched.
- [2026-07-22 Wed] DOING — spec-response: the four rewritten implementation phases decomposed into build tasks in todo.org under a :SPEC_ID:-stamped parent, with the test surface and the final flip-to-IMPLEMENTED task emitted. The build proceeds in dotfiles settings/ per the location decision; archsetup drives.
- [2026-07-22 Wed] READY — spec-review passed (rubric: Ready with caveats; see Review findings — two findings fixed in-run, two non-blocking build-time picks recorded). The prototyping gate closed at prototype 37, sealed by Craig's "Build it. Anything more would be bit fiddling." The FOCUS headphones glyph stands confirmed by the same words. Normative reference: prototype 37.
diff --git a/docs/specs/2026-07-11-net-doctor-expansion-spec.org b/docs/specs/2026-07-11-net-doctor-expansion-spec.org
index 2727702..0df74b3 100644
--- a/docs/specs/2026-07-11-net-doctor-expansion-spec.org
+++ b/docs/specs/2026-07-11-net-doctor-expansion-spec.org
@@ -8,6 +8,7 @@
:PROPERTIES:
:ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3
:END:
+- [2026-07-23 Thu @ 08:40 -0500] IMPLEMENTED — Phase 4 (docs + rollout) closed by the dotfiles session, =138da7b=. Accepted against the phase's *stated acceptance criteria*, all three met: help complete, user guide (=net/README.md=), ratio rollout documented. The audit behind it found a real defect worth recording — =net repair='s action list named 9 of 19 registered actions, so ten tiers were reachable but undocumented, and =repair.py='s docstring carried the same stale copy; both now generate from the =ACTIONS= registry plus =viewmodel.STEP_NARRATION=, so they can't drift again. Two corrections to this spec from the build: the dep list is one item heavy — =gtk4-layer-shell= is no longer required, the net panel is a plain floating window now. And the Phase 4 *narrative* lists a panel help affordance that its acceptance criteria never did; no sibling panel (net, maint, settings, audio, bluetooth, panelkit) has one, so there is no convention to inherit and the shape is a genuine design call. It is tracked as its own =[#C]= in dotfiles rather than invented unilaterally, and it does not hold this phase open.
- [2026-07-12 Sun @ 09:12 -0500] IMPLEMENTED — all v1 phases shipped on dotfiles main: Phase 0 read-only control-plane probe (=21ca3ff=), Phase 1 control-plane verdicts through the shared panelkit privilege model (=d73eba6=, =c9f8604=, =be15a81=), Phase 2 sharpened auth verdict (=12e3e76=). 813 net tests + 65 suites green against the fake harness. The privileged-fix live halves (real masked-NM/rival/keyfile states, real WPA3/hidden profiles) are handed off to the VM scenario harness (archsetup =a364c1e=) and the "Manual testing and validation" checklist in =todo.org= — findings there come back as bugs, not spec work. vNext items logged to =todo.org=.
- [2026-07-11 Sat @ 02:30 -0500] DOING — decomposed into build tasks (spec-response Phase 6); parent task in =todo.org= bound by =:SPEC_ID:=. Phase 0 (read-only control-plane probe) is buildable now and =:solo:=; Phases 1-2 are gated on the shared cross-panel privilege model. The systemic connection-name redaction gap is a separate filed task.
- [2026-07-11 Sat @ 02:00 -0500] READY — third skeptical re-review returned Ready with caveats, no blocking findings; all round-2 resolutions verified against the engine. Caveat accepted: Phases 1-2 depend on the shared cross-panel run-time privilege model, which doesn't exist yet, so only Phase 0 (read-only detection) is buildable today. The redaction de-scope (parity + separate systemic task) is the one product call for Craig's eye.