From f1c661f50d6af2a115fc1430ae0478f2acef2dec Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 22 Jul 2026 15:15:02 -0500 Subject: docs(settings-panel): seal the prototype arc and open the build Prototypes P12-P37 land, along with the night-watch and levels-compare pages plus the shared-engine updates and the kit-candidate file. The spec flips from DRAFT through READY to DOING with rewritten implementation phases and seven recorded build findings. The build tasks are filed in todo.org, and phase 1 is closed against the dotfiles commits. --- ...7-02-desktop-settings-panel-levels-compare.html | 107 ++ ...6-07-02-desktop-settings-panel-prototype-1.html | 2 +- ...-07-02-desktop-settings-panel-prototype-10.html | 194 +++ ...-07-02-desktop-settings-panel-prototype-11.html | 205 ++++ ...-07-02-desktop-settings-panel-prototype-12.html | 283 +++++ ...-07-02-desktop-settings-panel-prototype-13.html | 304 +++++ ...-07-02-desktop-settings-panel-prototype-14.html | 308 +++++ ...-07-02-desktop-settings-panel-prototype-15.html | 299 +++++ ...-07-02-desktop-settings-panel-prototype-16.html | 312 +++++ ...-07-02-desktop-settings-panel-prototype-17.html | 311 +++++ ...-07-02-desktop-settings-panel-prototype-18.html | 321 +++++ ...-07-02-desktop-settings-panel-prototype-19.html | 336 ++++++ ...6-07-02-desktop-settings-panel-prototype-2.html | 3 + ...-07-02-desktop-settings-panel-prototype-20.html | 360 ++++++ ...-07-02-desktop-settings-panel-prototype-21.html | 287 +++++ ...-07-02-desktop-settings-panel-prototype-22.html | 291 +++++ ...-07-02-desktop-settings-panel-prototype-23.html | 305 +++++ ...-07-02-desktop-settings-panel-prototype-24.html | 304 +++++ ...-07-02-desktop-settings-panel-prototype-25.html | 292 +++++ ...-07-02-desktop-settings-panel-prototype-26.html | 295 +++++ ...-07-02-desktop-settings-panel-prototype-27.html | 313 +++++ ...-07-02-desktop-settings-panel-prototype-28.html | 296 +++++ ...-07-02-desktop-settings-panel-prototype-29.html | 297 +++++ ...6-07-02-desktop-settings-panel-prototype-3.html | 2 +- ...-07-02-desktop-settings-panel-prototype-30.html | 310 +++++ ...-07-02-desktop-settings-panel-prototype-31.html | 319 +++++ ...-07-02-desktop-settings-panel-prototype-32.html | 548 +++++++++ ...-07-02-desktop-settings-panel-prototype-33.html | 613 ++++++++++ ...-07-02-desktop-settings-panel-prototype-34.html | 720 +++++++++++ ...-07-02-desktop-settings-panel-prototype-35.html | 717 +++++++++++ ...-07-02-desktop-settings-panel-prototype-36.html | 901 ++++++++++++++ ...-07-02-desktop-settings-panel-prototype-37.html | 915 ++++++++++++++ ...6-07-02-desktop-settings-panel-prototype-4.html | 1 + ...6-07-02-desktop-settings-panel-prototype-5.html | 2 + ...6-07-02-desktop-settings-panel-prototype-6.html | 343 ++++++ ...6-07-02-desktop-settings-panel-prototype-7.html | 322 +++++ ...6-07-02-desktop-settings-panel-prototype-8.html | 360 ++++++ ...6-07-02-desktop-settings-panel-prototype-9.html | 211 ++++ ...7-02-desktop-settings-panel-scenes-compare.html | 177 +++ ...-07-21-night-watch-screensaver-prototype-1.html | 165 +++ docs/prototypes/2026-07-22-watch-preview.png | Bin 0 -> 88870 bytes docs/prototypes/desktop-settings-shared.css | 12 +- docs/prototypes/desktop-settings-shared.js | 16 +- docs/prototypes/dupre-kit-additions.js | 1256 ++++++++++++++++++++ 44 files changed, 13928 insertions(+), 7 deletions(-) create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-levels-compare.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-10.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-11.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-12.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-13.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-14.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-15.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-16.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-17.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-18.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-19.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-20.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-21.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-22.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-23.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-24.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-25.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-26.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-27.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-28.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-29.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-30.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-31.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-32.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-33.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-34.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-35.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-36.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-37.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-6.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-7.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-8.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-prototype-9.html create mode 100644 docs/prototypes/2026-07-02-desktop-settings-panel-scenes-compare.html create mode 100644 docs/prototypes/2026-07-21-night-watch-screensaver-prototype-1.html create mode 100644 docs/prototypes/2026-07-22-watch-preview.png create mode 100644 docs/prototypes/dupre-kit-additions.js (limited to 'docs/prototypes') diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-levels-compare.html b/docs/prototypes/2026-07-02-desktop-settings-panel-levels-compare.html new file mode 100644 index 0000000..6f2427d --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-levels-compare.html @@ -0,0 +1,107 @@ + + + + + +Desktop Settings — levels casting: detent fader + single drum + + + + +
+ Levels casting. Brightness: the new multi-detent slide attenuator — + drag slowly across 25 / 50 / 75 and feel the thumb park until you push + through (the tick lights while parked). Keyboard: the generalized drum + roller running a single drum, 0-10. +
+ +
+
Levels
+ +
Brightness — detent fader (new)
+
+
+ +
Keyboard — drum roller ×10 (upgraded)
+
+
+ +
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-1.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-1.html index e57d13e..1671eda 100644 --- a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-1.html +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-1.html @@ -52,7 +52,7 @@ function render() { // Toggle tiles col.appendChild(DS.engrave("Toggles")); const grid = DS.h("div", "ds-grid2"); - ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd"].forEach((k) => + ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd", "weathergeo"].forEach((k) => grid.appendChild(DS.toggleTile(k))); if (DS.state.laptop) grid.appendChild(DS.toggleTile("airplane")); col.appendChild(grid); diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-10.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-10.html new file mode 100644 index 0000000..ea26651 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-10.html @@ -0,0 +1,194 @@ + + + + + +Desktop Settings — Prototype 10 (spacecraft DSKY, Dupre Kit) + + + + +
+ Direction 10 — Spacecraft DSKY (Dupre Kit). The THUMB SLIDE + attenuator (drag the tabs) sets both levels. Toggles are slide switches + under a live caution board (amber cell = engaged). Scenes are commanded on + the DSKY: key VERB, two digits, ENTR. Power is the keyed mode switch. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-11.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-11.html new file mode 100644 index 0000000..d58fbfc --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-11.html @@ -0,0 +1,205 @@ + + + + + +Desktop Settings — Prototype 11 (mixing desk, Dupre Kit) + + + + +
+ Direction 11 — Mixing desk (Dupre Kit). Channel strips: slot faders + ride the two levels in dB (the VU bridge follows), scenes punch in on the + radio preset bank, toggles are lit chips with DND on a stomp pedal, power + is a rotary selector. Lock/suspend/wallpaper are arm-to-fire. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-12.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-12.html new file mode 100644 index 0000000..866fa80 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-12.html @@ -0,0 +1,283 @@ + + + + + +Desktop Settings — Prototype 12 (the casting composite) + + + + +
+ Prototype 12 — the casting composite. Every control as cast + 2026-07-21: tuner-dial scenes + presets, detent fader, single drum, + chicken-head power, rocker bank, guarded airplane, wind-up caffeine + timer, wallpaper sub-view. Warm-gold receiver chrome. Lock/suspend + live on wlogout, not here. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-13.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-13.html new file mode 100644 index 0000000..401644f --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-13.html @@ -0,0 +1,304 @@ + + + + + +Desktop Settings — Prototype 13 (P12 + Craig's iteration) + + + + +
+ Prototype 13 — P12 + Craig's iteration. Presets moved above the + dial (Preset 2 contains its text); screen + keyboard are drum partners + left of a larger caffeine timer with an always-on rocker; compact + chicken-head upper right; switch wall of six with airplane's guarded + lever in the mix; weather-geo dropped from the panel; wallpaper where + it was. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-14.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-14.html new file mode 100644 index 0000000..af02eab --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-14.html @@ -0,0 +1,308 @@ + + + + + +Desktop Settings — Prototype 14 (timer bat, centered dial) + + + + +
+ Prototype 14 — P13 + timer refinements. The HOLD rocker is gone: + a bat toggle sits at the timer's upper right where the winding arrow + was — up TIMED, down HOLD (always on). The dial is vertically centered + against the drums. Everything else as P13. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-15.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-15.html new file mode 100644 index 0000000..7bdc336 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-15.html @@ -0,0 +1,299 @@ + + + + + +Desktop Settings — Prototype 15 (power slide, full-width tuner) + + + + +
+ Prototype 15 — P14 + power slide. The chicken-head is out: the + tuner dial is restored to full width, and beneath it a three-position + slide switch of equal length carries power — a status lamp per position, + labeled PERFORMANCE / BALANCE / POWER SAVE. Everything else as P14. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-16.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-16.html new file mode 100644 index 0000000..a2d76e0 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-16.html @@ -0,0 +1,312 @@ + + + + + +Desktop Settings — Prototype 16 (rebalanced: quiet chassis) + + + + +
+ Prototype 16 — rebalanced. Same instruments, honest loudness: + three uniform plates on one grid; thumb-slide pair replaces the drums; + the timer is de-throned (steel knob, one engraving, amber index) with + the bat docked on its stage; rockers carry their function name on the + lit half (quiet dark when off); red survives only on the tuner needle + and the airplane disc. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-17.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-17.html new file mode 100644 index 0000000..3372694 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-17.html @@ -0,0 +1,311 @@ + + + + + +Desktop Settings — Prototype 17 (switches under their presets) + + + + +
+ Prototype 17 — switches under their presets. The switch wall + moves up beside its cause: scenes on top, the switches they program + directly beneath, the power slide just below the wall — with a dark + knob now. Levels · caffeine follow. Everything else as P16. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-18.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-18.html new file mode 100644 index 0000000..c0ff06c --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-18.html @@ -0,0 +1,321 @@ + + + + + +Desktop Settings — Prototype 18 (program chart, real proportions) + + + + +
+ Prototype 18 — the program chart. The missing analog component: + a faceplate program chart (washing-machine style) printed between the + scenes and their switches — filled dot = the program closes it, ring = + opens it, dash = leaves it alone, letter = power; the active program's + row lights. Airplane is gone (the network panel owns its master + switch). OFF on the dial now reads CUSTOM. Slides and timer re-scaled + toward their real-life sizes. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-19.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-19.html new file mode 100644 index 0000000..442e39e --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-19.html @@ -0,0 +1,336 @@ + + + + + +Desktop Settings — Prototype 19 (punch-card chart, green switches) + + + + +
+ Prototype 19 — the chart becomes a punch card. Program-chart + lights are white (future state, not live amber) and clickable: a click + reprograms the preset — dots cycle closes/opens/untouched, the power + letter cycles P/B/S/–. Switches engage GREEN and shrink to real rocker + size; the slides grow. Less amber everywhere. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html index af2160c..c215f85 100644 --- a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-2.html @@ -72,6 +72,7 @@ function render() { ])); const powerNodes = []; + powerNodes.push(DS.toggleKey("caffeine")); if (DS.state.laptop) powerNodes.push(DS.toggleKey("airplane")); powerNodes.push(DS.profileSeg()); const acts = DS.h("div", "ds-grid2"); @@ -82,6 +83,8 @@ function render() { col.appendChild(section("Notifications", [DS.toggleKey("dnd")])); + col.appendChild(section("Weather", [DS.toggleKey("weathergeo")])); + const scenes = DS.h("div", "ds-grid4"); Object.keys(DS.SCENES).forEach((n) => scenes.appendChild(DS.sceneKey(n))); col.appendChild(section("Scenes", [scenes])); diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-20.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-20.html new file mode 100644 index 0000000..9f01322 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-20.html @@ -0,0 +1,360 @@ + + + + + +Desktop Settings — Prototype 20 (the switch matrix) + + + + +
+ Prototype 20 — the switch matrix. The panel's axis is the + switches: each row is a live switch, the four preset buttons run across + the top (names only), and the white punch lights under each preset + program what it recalls. TPAD and MOUSE (never scene-touched) drop to + their own strip below the levels. Screen, keyboard, and power are + horizontal thumb slides. The slide-rule dial is retired. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-21.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-21.html new file mode 100644 index 0000000..274ae78 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-21.html @@ -0,0 +1,287 @@ + + + + + +Desktop Settings — Prototype 21 (pin routing matrix) + + + + +
+ Prototype 21 — the pin routing matrix. The interface is the + matrix: switch names label the rows, the program buttons head the + columns, pins route settings into programs. The NOW column is live — + green pins are current state, click to toggle. Power is a radio row + group (one pin per column). CAFF is an ordinary switch; the dial is + now a SLEEP TIMER (wind minutes until suspend). The drums are back. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-22.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-22.html new file mode 100644 index 0000000..f67e990 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-22.html @@ -0,0 +1,291 @@ + + + + + +Desktop Settings — Prototype 22 (the programmable matrix) + + + + +
+ Prototype 22 — the programmable matrix. No NOW column: press a + program head (FOCUS · PRESENT · BATTERY · NIGHT) and its column's pins + go white → green, because the active program IS the live state. + Editing the green column edits the present; editing a white column + edits a future. Power rows are radio (a pin moves, never pulls). +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-23.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-23.html new file mode 100644 index 0000000..d0ddf85 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-23.html @@ -0,0 +1,305 @@ + + + + + +Desktop Settings — Prototype 23 (pushwheel CPU row) + + + + +
+ Prototype 23 — the pushwheel CPU row. CPU power collapses to a + single letter row: each program carries P / B / S (click cycles), and + the row ends in a push-button thumbwheel — tape-counter window showing + the LIVE letter, triangle steppers to walk it (up toward P). Row + labels right-align to the buttons' left edge. Active column still + floods green, letters included. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-24.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-24.html new file mode 100644 index 0000000..c473ce0 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-24.html @@ -0,0 +1,304 @@ + + + + + +Desktop Settings — Prototype 24 (tape wheel, jewel verdicts) + + + + +
+ Prototype 24 — tape wheel + jewel verdicts. The triangles are + gone: click the tape-counter wheel itself to cycle the live CPU letter + (P → B → S, wrapping). In the active column every switch row shows a + verdict as a small jewel pilot lamp — green seated, red for a socket + the program holds off. The wheel cell is bigger, cream paper and + cylinder shade like the kit tape counter. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-25.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-25.html new file mode 100644 index 0000000..e0bbb1d --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-25.html @@ -0,0 +1,292 @@ + + + + + +Desktop Settings — Prototype 25 (wheels in every column) + + + + +
+ Prototype 25 — wheels in every column. The side wheel was a + NOW-column vestige: every program column now carries its own tape + wheel in the CPU row — click any wheel to cycle that program's letter; + the active column's wheel wears the green bezel and IS the live value. + Jewel pilot lamps sized up to match the taller rows. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-26.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-26.html new file mode 100644 index 0000000..c0d0cb4 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-26.html @@ -0,0 +1,295 @@ + + + + + +Desktop Settings — Prototype 26 (physical keys, arrow wheels) + + + + +
+ Prototype 26 — physical keys, arrow wheels. Program heads are + real keys now: raised bevel at rest, depressed when active, green + lighting only the legend. Row labels engraved bigger on the faceplate. + The CPU wheels print ↑ – ↓ instead of letters, no live highlight on + the wheel — the depressed key and the jewels say what's live. The + whole panel is wider to give it room. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-27.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-27.html new file mode 100644 index 0000000..617ee5a --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-27.html @@ -0,0 +1,313 @@ + + + + + +Desktop Settings — Prototype 27 (console keys, six slots, one bench) + + + + +
+ Prototype 27 — console keys, six slots, one bench. Heads are + kit console keys lighting green; two blank program slots grow the + matrix rightward. The lower level is one bench: drums · groove · TPAD + over MOUSE · groove · sleep dial. Next conversation: what the dial + fires when it runs out. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-28.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-28.html new file mode 100644 index 0000000..769066e --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-28.html @@ -0,0 +1,296 @@ + + + + + +Desktop Settings — Prototype 28 (the idle tripper rail) + + + + +
+ Prototype 28 — the idle tripper rail. The dial is retired: the + panel programs futures, so idleness gets a time-switch tripper rail — + drag the DIM / LOCK / WATCH / SCREEN OFF / SUSPEND tabs along the + idle-minutes scale; slide one into the OFF siding to disable it. + Engage CAFFEINE and the whole policy dims — bypassed. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-29.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-29.html new file mode 100644 index 0000000..9a05b81 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-29.html @@ -0,0 +1,297 @@ + + + + + +Desktop Settings — Prototype 29 (the idle tripper dial) + + + + +
+ Prototype 29 — the idle tripper dial. Craig's actual mental + image: the round segment-timer with four COLORED tripper tabs on the + rim — DIM · LOCK · SCREEN OFF · SUSPEND (the night watch is the lock + screen's face, not its own stage). Drag a tab around the idle scale; + drop it through the bottom OFF notch to disable. Side legend maps + color → stage → minutes. CAFFEINE still bypasses the lot. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html index 8c383f1..49661e4 100644 --- a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-3.html @@ -93,7 +93,7 @@ function render() { col.appendChild(mh); const grid2 = DS.h("div", "ds-grid2"); - ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd"].forEach((key) => + ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd", "weathergeo"].forEach((key) => grid2.appendChild(DS.toggleTile(key))); if (DS.state.laptop) grid2.appendChild(DS.toggleTile("airplane")); col.appendChild(grid2); diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-30.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-30.html new file mode 100644 index 0000000..5081165 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-30.html @@ -0,0 +1,310 @@ + + + + + +Desktop Settings — Prototype 30 (family chrome) + + + + +
+ Prototype 30 — family chrome. Aligned with the maintenance / + net / bt family: theme gold (#dab53d), raised gradient faceplates with + inset highlights, maint-style header. Slots labeled 1 · 2 (slim keys); + factory presets programmed; PROGRAMMABLE PRESETS on its own + high-contrast plate, no divider; dial + drums enlarged, dial numerals + lighter. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-31.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-31.html new file mode 100644 index 0000000..9d9a0c1 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-31.html @@ -0,0 +1,319 @@ + + + + + +Desktop Settings — Prototype 31 (encircled groups) + + + + +
+ Prototype 31 — encircled groups. TPAD and MOUSE become console + keys (lighting green), horizontal above the dial; the bench reorganizes + into three bordered instrument groups with their labels set into the + border: BRIGHTNESS · POINTERS · IDLE. Dial unchanged, drums grown to + proportion. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-32.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-32.html new file mode 100644 index 0000000..bc241de --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-32.html @@ -0,0 +1,548 @@ + + + + + +Desktop Settings — Prototype 32 (the wallpaper channels) + + + + +
+ Prototype 32 — the wallpaper channels. The sub-view is a channel + bank: seven mode cards (night watch · sun pair · single · random · + video · clock · world), each wearing its own face — slashed thumbs, + deck fans, mini clock matrices. The active channel lights green; the + config surface below switches per channel. Pairs are MINTED: fill the + day and night frames, confirm, and the slashed chip joins the tray + forever. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-33.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-33.html new file mode 100644 index 0000000..7dc67c8 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-33.html @@ -0,0 +1,613 @@ + + + + + +Desktop Settings — Prototype 33 (sources + pair scrap) + + + + +
+ Prototype 32 — the wallpaper channels. The sub-view is a channel + bank: seven mode cards (night watch · sun pair · single · random · + video · clock · world), each wearing its own face — slashed thumbs, + deck fans, mini clock matrices. The active channel lights green; the + config surface below switches per channel. Pairs are MINTED: fill the + day and night frames, confirm, and the slashed chip joins the tray + forever. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-34.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-34.html new file mode 100644 index 0000000..3692ada --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-34.html @@ -0,0 +1,720 @@ + + + + + +Desktop Settings — Prototype 34 (annotation batch one) + + + + +
+ Prototype 32 — the wallpaper channels. The sub-view is a channel + bank: seven mode cards (night watch · sun pair · single · random · + video · clock · world), each wearing its own face — slashed thumbs, + deck fans, mini clock matrices. The active channel lights green; the + config surface below switches per channel. Pairs are MINTED: fill the + day and night frames, confirm, and the slashed chip joins the tray + forever. +
+ +
19:24
+ +
+
+ + SETTINGS + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-35.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-35.html new file mode 100644 index 0000000..9296af2 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-35.html @@ -0,0 +1,717 @@ + + + + + +Desktop Settings — Prototype 35 (FINAL candidate) + + + + +
+ Prototype 35 — the final candidate. The consolidated machine: + the programmable presets matrix (glyph keys, four numbered slots, + punch pins, arrow-and-equals CPU wheels), the instrument bench + (drums · pointer keys · idle tripper dial), and the wallpaper + channels with descriptions, galleries, previews, the pair press, and + sources. Normative reference for the spec. +
+ +
+
+ + SETTINGS + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-36.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-36.html new file mode 100644 index 0000000..ac39b16 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-36.html @@ -0,0 +1,901 @@ + + + + + +Desktop Settings — Prototype 36 (annotation batch two) + + + + +
+ Prototype 36 — annotation batch two. Uniform slim preset keys; + pointers above brightness with the dial enlarged, legend below; + SOLID COLOR joins the channels; RANDOM gains labeled, count-badged + SETS; sort keys stack left of sortable galleries; custom scrollbars + with even framing; scrollable sources; a dozen seeded pairs so the + scroller shows itself. +
+ +
+
+ + SETTINGS + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-37.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-37.html new file mode 100644 index 0000000..952b9d1 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-37.html @@ -0,0 +1,915 @@ + + + + + +Desktop Settings — Prototype 37 (annotation batch three) + + + + +
+ Prototype 37 — annotation batch three. Iconic channel faces + (sun/crescent pair card, dupre-blue solid, honest mini clocks); slots + announce as 1-4; the idle dial reads exact minutes while dragging and + IDLE stands visible; smaller video preview; WALLPAPER capitalized. + FOCUS tries the headphones glyph — confirm or veto. +
+ +
+
+ + SETTINGS + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html index 23d72fb..67b9f56 100644 --- a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-4.html @@ -141,6 +141,7 @@ function render() { ["autodim", "nightlight", "touchpad", "mouse", "dnd"].forEach((k) => well.appendChild(lampRow(k))); if (DS.state.laptop) well.appendChild(lampRow("airplane")); well.appendChild(lampRow("caffeine")); + well.appendChild(lampRow("weathergeo")); col.appendChild(well); col.appendChild(DS.engrave("Scenes")); diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html index 662e9f6..8cbaa45 100644 --- a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-5.html @@ -116,8 +116,10 @@ function render() { col.appendChild(h("div", "sep")); ["touchpad", "mouse"].forEach((k) => col.appendChild(toggleRow(k))); + col.appendChild(toggleRow("caffeine")); if (DS.state.laptop) col.appendChild(toggleRow("airplane")); col.appendChild(toggleRow("dnd")); + col.appendChild(toggleRow("weathergeo")); col.appendChild(h("div", "sep")); col.appendChild(expandRow("\u{f0241}", "Power profile", DS.state.powerprofile, () => DS.profileSeg())); diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-6.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-6.html new file mode 100644 index 0000000..952e4d9 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-6.html @@ -0,0 +1,343 @@ + + + + + +Desktop Settings — Prototype 6 (hi-fi receiver) + + + + +
+ Direction 6 — Hi-fi receiver. Scenes are station presets on a backlit + tuner dial; the needle slides to the tuned scene. Rotary knobs + VU meters + for the levels, a backlit switch bank for toggles, an input selector for + power. Drag knobs sideways; click stations or preset keys. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-7.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-7.html new file mode 100644 index 0000000..dc7ac0a --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-7.html @@ -0,0 +1,322 @@ + + + + + +Desktop Settings — Prototype 7 (chronograph) + + + + +
+ Direction 7 — Chronograph. The panel is a watch. Drag around the + bezel to set brightness (the gold hand + digital window follow). Subdials: + keyboard backlight (drag or click), power profile and scene (click to step). + Crowns on the case are lock / suspend / wallpaper; the bracelet links are + the toggles. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-8.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-8.html new file mode 100644 index 0000000..13fed0b --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-8.html @@ -0,0 +1,360 @@ + + + + + +Desktop Settings — Prototype 8 (gauge cluster) + + + + +
+ Direction 8 — Gauge cluster. A dashboard binnacle: brightness and + keyboard are needle gauges (drag across the dial), toggles are a rocker + bank with backlit legends, scenes are push-button drive modes, power is an + ignition rotary, actions are dash pull-knobs. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-9.html b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-9.html new file mode 100644 index 0000000..bc15e94 --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-prototype-9.html @@ -0,0 +1,211 @@ + + + + + +Desktop Settings — Prototype 9 (engine room, Dupre Kit) + + + + +
+ Direction 9 — Engine room (Dupre Kit). Real kit instruments: the + DRUM ROLLER sets both levels (drag the drums), a split-flap board reads the + scene tuned on the rotary selector, breaker panels carry the toggles, + airplane is a guarded lever, power is an engine-order telegraph (click to + ring the next order). Lock/suspend/wallpaper are arm-to-fire. +
+ +
19:24
+ +
+
+ + Settings + + +
+
+
+
+
+ + + + + + + diff --git a/docs/prototypes/2026-07-02-desktop-settings-panel-scenes-compare.html b/docs/prototypes/2026-07-02-desktop-settings-panel-scenes-compare.html new file mode 100644 index 0000000..9efc1dc --- /dev/null +++ b/docs/prototypes/2026-07-02-desktop-settings-panel-scenes-compare.html @@ -0,0 +1,177 @@ + + + + + +Desktop Settings — scene control casting: tuner dial vs rotary + split-flap + + + + +
+ Scene-control casting. Both candidates drive the same scene state — + tune one, the other follows. Left: tuner dial (kit slideRule) + preset + keys, with skin chips. Right: rotary selector + split-flap board. +
+ +
+
Candidate 1
+
Tuner dial + presets
+
+
+
+
+
+ +
+
Candidate 3
+
Rotary + split-flap
+
+
+
+
+ +
+ + + + + + diff --git a/docs/prototypes/2026-07-21-night-watch-screensaver-prototype-1.html b/docs/prototypes/2026-07-21-night-watch-screensaver-prototype-1.html new file mode 100644 index 0000000..7ccc071 --- /dev/null +++ b/docs/prototypes/2026-07-21-night-watch-screensaver-prototype-1.html @@ -0,0 +1,165 @@ + + + + + +Dupre Night Watch — idle-stage screensaver prototype 1 + + + +
DUPRE · NIGHT WATCH
+
+ + + + + diff --git a/docs/prototypes/2026-07-22-watch-preview.png b/docs/prototypes/2026-07-22-watch-preview.png new file mode 100644 index 0000000..9b8419b Binary files /dev/null and b/docs/prototypes/2026-07-22-watch-preview.png differ diff --git a/docs/prototypes/desktop-settings-shared.css b/docs/prototypes/desktop-settings-shared.css index 95aec22..ec677c7 100644 --- a/docs/prototypes/desktop-settings-shared.css +++ b/docs/prototypes/desktop-settings-shared.css @@ -34,6 +34,7 @@ body.ds-stage { margin: 0; min-height: 100vh; + zoom: 1.6; /* the 360px capsule reads small on a large monitor (Craig, 2026-07-21) */ background: radial-gradient(1200px 600px at 80% -10%, #17151322, transparent), #050505; @@ -126,8 +127,11 @@ body.ds-stage { background: linear-gradient(90deg, var(--line), transparent); } -/* Status lamp: a colored dot with a soft glow; off is a dim wash. */ -.ds-lamp { +/* Status lamp: retired 2026-07-21 (Craig: the glowing dot pulls the eye). + State reads from the control's own gold glyph/text instead. The spans + still render; this hides them everywhere at once. */ +.ds-lamp { display: none; } +.ds-lamp-unused { width: 9px; height: 9px; border-radius: 50%; background-color: var(--pass); box-shadow: 0 0 6px 1px rgba(116,147,47,.55); @@ -228,7 +232,9 @@ body.ds-stage { background: var(--cream); border: 1px solid var(--gold); cursor: pointer; - box-shadow: -200px 0 0 194px var(--gold); /* gold fill to the left of the knob */ + /* The old box-shadow fill hack bled a huge gold disc outside the 6px + track (the thumb is taller than the input, so overflow can't clip it). + The gold fill is now painted by slider() as an inline gradient. */ } .ds-slider input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; diff --git a/docs/prototypes/desktop-settings-shared.js b/docs/prototypes/desktop-settings-shared.js index 39bc76a..f96a03a 100644 --- a/docs/prototypes/desktop-settings-shared.js +++ b/docs/prototypes/desktop-settings-shared.js @@ -16,6 +16,7 @@ const DS = (() => { airplane: false, nightlight: false, dnd: false, + weathergeo: false, brightness: 70, kbd: 40, powerprofile: "balanced", // performance | balanced | saver @@ -38,6 +39,7 @@ const DS = (() => { airplane: { glyph: "\u{f001d}", name: "Airplane", on: "on", off: "off" }, nightlight:{ glyph: "\u{f0594}", name: "Night light", on: "warm", off: "off" }, dnd: { glyph: "\u{f009b}", name: "Do Not Disturb",on:"silenced",off: "notifying" }, + weathergeo:{ glyph: "\u{f034e}", name: "Weather geo", on: "whereami",off: "pinned" }, }; const SLIDERS = { brightness: { glyph: "\u{f00df}", name: "Brightness", floor: 5 }, @@ -161,7 +163,13 @@ const DS = (() => { const inp = h("input"); inp.type = "range"; inp.min = meta.floor; inp.max = 100; inp.value = state[key]; const pct = h("span", "s-pct", state[key] + "%"); - inp.oninput = () => { pct.textContent = inp.value + "%"; }; + // Gold fill left of the knob, painted as a gradient (no shadow hack). + const paint = () => { + const p = ((inp.value - meta.floor) / (100 - meta.floor)) * 100; + inp.style.background = `linear-gradient(90deg, var(--gold) ${p}%, var(--well) ${p}%)`; + }; + paint(); + inp.oninput = () => { pct.textContent = inp.value + "%"; paint(); }; inp.onchange = () => setSlider(key, +inp.value, row); row.appendChild(inp); row.appendChild(pct); return row; @@ -202,7 +210,11 @@ const DS = (() => { "linear-gradient(135deg,#5c5030,#2e2810)", ]; function wallpaperSubview(closeCb) { - const sv = h("div", "ds-subview"); + // plain flow container: the CALLER's #subview owns the ds-subview + // overlay styling — carrying the class here left the content + // display:none inside an open overlay (empty wallpaper view, found + // testing prototype 12) + const sv = h("div", "ds-col"); const head = h("div", "ds-sub-head"); head.innerHTML = `‹ BackWallpaper`; head.querySelector(".back").onclick = closeCb; diff --git a/docs/prototypes/dupre-kit-additions.js b/docs/prototypes/dupre-kit-additions.js new file mode 100644 index 0000000..005a577 --- /dev/null +++ b/docs/prototypes/dupre-kit-additions.js @@ -0,0 +1,1256 @@ +/* dupre-kit-additions.js — kit-candidate builders developed in the + * desktop-settings casting work, written to the widgets.js conventions + * (DUPRE.name(host, opts) → {el, get, set, ...}; onChange(value, text) on + * every state change including the initial paint; no page globals touched) + * 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). + * - DUPRE.batToggle (UPGRADE) — same throw fix. + * - DUPRE.slideRule (UPGRADE) — width option; the printed scale + * spreads its stops proportionally. Default keeps stock 180px. + * - DUPRE.slideSelector (NEW) — stretchable n-position slide switch + * with a status lamp per position (pillSlide's full-size descendant). + * - DUPRE.programChart (NEW) — faceplate program matrix / punch card. + * - DUPRE.thumbSlideH (NEW) — horizontal thumb-slide attenuator; + * continuous 0-100 or detented positions (thumbSlide turned on its + * side, one channel, stretchable). + * - DUPRE.pinMatrix (UPGRADE) — configurable geometry, per-column + * pin colors, clickable button column-heads, group dividers, set(). + * Defaults reproduce the stock 5×6 synth patch exactly. + * - DUPRE.programMatrix (NEW) — the programmable matrix: programs as + * column heads, settings as rows, white pins program, the ACTIVE + * column's pins run green because the recalled program IS the live + * state. No separate live column needed. + * - DUPRE.tripRail (NEW) — idle tripper rail: a time-switch tripper + * scale for "when idle reaches here, throw this" — draggable stage + * tabs on a sqrt-spaced minutes rail, with a parking siding to + * disable a stage and a bypass state (caffeine dims the policy). + * - DUPRE.tripDial (NEW) — the round form Craig actually imagined: + * the Intermatic segment-timer dial with colored tripper tabs on the + * rim, an OFF notch at the bottom, and a side legend. Same policy + * semantics as the rail. + */ +(() => { + const noop = () => {}; + const SVGNS = 'http://www.w3.org/2000/svg'; + + /* local mini-helpers (widgets.js keeps its own private; additions carry + theirs so the file is self-contained until merged) */ + const svgEl = (parent, tag, attrs) => { + const e = document.createElementNS(SVGNS, tag); + for (const k in (attrs || {})) e.setAttribute(k, attrs[k]); + parent.appendChild(e); return e; + }; + const stageSvg = (host, cls, vw, vh) => { + const s = document.createElementNS(SVGNS, 'svg'); + s.setAttribute('class', cls); s.setAttribute('viewBox', `0 0 ${vw} ${vh}`); + s.setAttribute('width', vw); s.setAttribute('height', vh); + host.appendChild(s); return s; + }; + let uidN = 0; + const uid = p => p + '-a' + (++uidN); + + /* additions-scoped gradients (ids prefixed so they never collide with the + kit's shared defs plate; on merge these fold into gradDef) */ + let defsRoot = null; + const grad = (id, kind, geom, stops) => { + if (!defsRoot) { + const svg = document.createElementNS(SVGNS, 'svg'); + svg.setAttribute('width', 0); svg.setAttribute('height', 0); + svg.setAttribute('aria-hidden', 'true'); + svg.style.position = 'absolute'; + defsRoot = document.createElementNS(SVGNS, 'defs'); + svg.appendChild(defsRoot); + document.body.appendChild(svg); + } + if (defsRoot.querySelector('#' + id)) return; + const g = document.createElementNS(SVGNS, kind); + g.setAttribute('id', id); + for (const k in geom) g.setAttribute(k, geom[k]); + stops.forEach(([o, c]) => { + const st = document.createElementNS(SVGNS, 'stop'); + st.setAttribute('offset', o); st.setAttribute('stop-color', c); + g.appendChild(st); + }); + defsRoot.appendChild(g); + }; + + /* vertical drag with delta accumulation (the kit's dragDelta shape) */ + const dragDelta = (el, getV, setV, o) => { + el.style.touchAction = 'none'; + el.addEventListener('pointerdown', e => { + el.setPointerCapture(e.pointerId); + const y0 = e.clientY, v0 = getV(); + const move = m => setV(Math.max(o.min, Math.min(o.max, v0 + (y0 - m.clientY) * o.sens))); + const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); }; + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + e.preventDefault(); + }); + }; + + /* additions CSS, injected once (the kit pattern: DUPRE_CSS ships inside + widgets.js; these rules move into it on merge) */ + if (!document.getElementById('dupre-additions-css')) { + 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} +.dupre-slsel .sl-lamp{width:11px;height:11px;border-radius:50%; + background:radial-gradient(circle at 38% 32%,#2e2a24,#171310);border:1px solid #060505} +.dupre-slsel .sl-col.sl-on .sl-lamp{background:radial-gradient(circle at 38% 32%,var(--gold-hi,#ffbe54),var(--gold,#e2a038) 60%,#8f671f); + box-shadow:0 0 7px rgba(var(--glow-hi,255,190,84),.75)} +.dupre-slsel .sl-lbl{font-size:8px;letter-spacing:.1em;color:var(--dim,#7c838a);text-transform:uppercase;font-family:var(--mono)} +.dupre-slsel .sl-col.sl-on .sl-lbl{color:var(--gold-hi,#ffbe54)} +.dupre-slsel .sl-track{position:relative;height:20px;border-radius:10px; + background:var(--well,#0a0c0d);border:1px solid #0a0908;box-shadow:inset 0 2px 4px #000} +.dupre-slsel .sl-knob{position:absolute;top:2px;bottom:2px;border-radius:8px;transform:translateX(-50%); + background:linear-gradient(180deg,#f0efec,#c4c1b9 45%,#75726a);border:1px solid #4e4a42; + box-shadow:0 2px 4px #000a;transition:left .16s ease} +.dupre-slsel .sl-knob::after{content:"";position:absolute;left:50%;top:4px;bottom:4px;width:2px; + transform:translateX(-50%);background:rgba(0,0,0,.3);border-radius:1px} +.dupre-rocker.dupre-quiet:not(.dupre-on) .dupre-bot{background:linear-gradient(180deg,#141210,#100e0c); + color:var(--dim,#7c838a);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)} +.dupre-rocker.dupre-green.dupre-on .dupre-top{background:linear-gradient(180deg,#a9c95f,var(--pass,#74932f)); + color:var(--panel,#100f0f);box-shadow:0 0 10px rgba(116,147,47,.35)} +.dupre-slsel.sl-dark .sl-knob{background:linear-gradient(180deg,#33302a,#1c1a16);border-color:#3f3b33} +.dupre-slsel.sl-dark .sl-knob::after{background:rgba(255,255,255,.16)} +.dupre-pchart{display:block;font-family:var(--mono);pointer-events:none} +.dupre-pchart .pc-grid{display:grid;row-gap:2px} +.dupre-pchart .pc-cell{display:flex;align-items:center;justify-content:center;height:14px} +.dupre-pchart .pc-h{font-size:6.5px;letter-spacing:.08em;color:var(--steel,#969385);text-transform:uppercase} +.dupre-pchart .pc-name{font-size:7.5px;letter-spacing:.06em;color:var(--dim,#7c838a); + text-transform:uppercase;justify-content:flex-end;padding-right:6px} +.dupre-pchart .pc-dot{width:6px;height:6px;border-radius:50%} +.dupre-pchart .pc-dot.pc-set{background:#cfd3cf} +.dupre-pchart .pc-dot.pc-clear{border:1px solid var(--steel,#969385);opacity:.6} +.dupre-pchart .pc-dash{width:6px;height:1px;background:var(--wash,#2c2f32)} +.dupre-pchart .pc-pwr{font-size:7.5px;color:var(--steel,#969385)} +.dupre-pchart .pc-row-on .pc-name{color:#f2f4f2} +.dupre-pchart .pc-row-on .pc-dot.pc-set{background:#f2f4f2; + box-shadow:0 0 5px rgba(242,244,242,.6)} +.dupre-pchart .pc-row-on .pc-pwr{color:#f2f4f2} +.dupre-pchart.pc-edit{pointer-events:auto} +.dupre-pchart.pc-edit .pc-cell:not(.pc-h):not(.pc-name){cursor:pointer;border-radius:4px} +.dupre-pchart.pc-edit .pc-cell:not(.pc-h):not(.pc-name):hover{background:#ffffff10} +`; + document.head.appendChild(st); + } + + /* NEW — multi-detent slide attenuator: a horizontal fader whose thumb + parks at each detent; the pointer must push an escape margin past the + detent to break free (a speedbump, not a snap). Crossfader's center + detent generalized to a set. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 68); detents (values 0-100, default [50] — + the crossfader case); magnet (± units that capture the thumb, + default 3); escape (extra units past the magnet edge needed to + break out, default 3); onChange(v, label) on every change, + label 'N' or 'N · detent' while parked, including the initial + paint. + handle: el, get(), set(v) (clamped 0-100; set() obeys detent capture + so a programmatic sweep feels the same bumps), parked() + (the detent value or null). + CSS lives in the additions block (.dupre-dfader); no other styles + involved. */ + 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 = ''; + 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)); + }; + /* speedbump core: raw is where the pointer says the thumb should be; + the thumb only goes there if it isn't captured, and capture only + breaks when raw clears the magnet+escape band. */ + const apply = raw => { + raw = Math.max(0, Math.min(100, raw)); + if (park !== null) { + if (Math.abs(raw - park) <= MAGNET + ESCAPE) raw = park; /* still parked */ + else park = null; /* broke through */ + } + 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 rect = () => el.getBoundingClientRect(); + const pct = m => ((m.clientX - rect().left - 8) / (rect().width - 16)) * 100; + apply(pct(e)); + const move = m => apply(pct(m)); + const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); }; + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + e.preventDefault(); + }); + + v = -1; park = null; + apply(opts.value !== undefined ? opts.value : 68); + return { el, get: () => v, set: apply, parked: () => park }; + }; + + /* UPGRADE — drum roller selector, generalized. Stock hardcodes exactly two + drums (the onChange label indexes chans[1] unconditionally, so one + channel throws) and a fixed 1-10 range. This redefinition accepts 1..N + channels and a min/max range; every default reproduces the stock + instrument, so existing callers are unaffected. + + Contract (everything a consumer needs; no page globals touched): + opts: title (engraved header, default 'EQUALIZER'); channels (array of + { name, v } drums, any count ≥ 1, default HIGH/LOW pair); + min / max (drum range, default 1 / 10); height (stage height, + default 140 — a taller stage opens a longer paper window and + shows more of the scale); 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 to min..max; + 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 onChange = opts.onChange || noop; + 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), cy = winY + winH / 2, step = 17; + grad('dkaRail', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]); + grad('dkaPaper', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]); + grad('dkaCurve', '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', {}); + 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(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), label()); + }; + const x0 = (vw - (N - 1) * 50) / 2; /* drums centered whatever the count */ + chans.forEach((ch, i) => { + const x = x0 + i * 50, cid = uid('dkaClip'); + 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(#dkaRail)', stroke: '#0a0908', 'stroke-width': 1.2 }); + const clip = svgEl(localDefs, 'clipPath', { id: cid }); + 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(#dkaPaper)' }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${cid})` }); + const nums = svgEl(g, 'g', {}); nums.style.transition = 'transform .12s'; + 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: winY, width: 18, height: winH, fill: 'url(#dkaCurve)', '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: 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: MIN, max: MAX, sens: (MAX - MIN) / (winH * 1.1) }); + }); + chans.forEach((c, i) => set(i, c.v)); + return { el: s, get: () => chans.map(c => c.v), set }; + }; + + /* UPGRADE — guarded toggle, realistic throw. Stock animates the lever with + a 180° planar rotate, so mid-transition the handle points sideways (the + Linda Blair problem — Craig, 2026-07-21). A real switch bat flips toward + the viewer: this redefinition throws with rotateX, so the lever + 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 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']]); + grad('dkaNut', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); + grad('dkaChrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]); + grad('dkaTip', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]); + const lblOn = svgEl(s, 'text', { x: cx, y: 12, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + lblOn.textContent = opts.onLabel || 'ON'; + const lblOff = svgEl(s, 'text', { x: cx, y: 96, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + lblOff.textContent = opts.offLabel || 'OFF'; + svgEl(s, 'circle', { cx, cy, r: 13, fill: 'url(#dkaDiscRed)', stroke: '#4e150a', 'stroke-width': 1 }); + const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 11 * Math.cos(a)) + ',' + (cy + 11 * Math.sin(a))); } + svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#dkaNut)', stroke: '#2b2822' }); + svgEl(s, 'circle', { cx, cy, r: 6, fill: '#14110e', stroke: '#000' }); + for (const gx of [16, 74]) { + svgEl(s, 'rect', { x: gx - 6, y: cy - 24, width: 12, height: 48, rx: 6, fill: 'url(#dkaNut)', stroke: '#2b2822', 'stroke-width': 1 }); + svgEl(s, 'rect', { x: gx - 4, y: cy - 21, width: 3.5, height: 42, rx: 2, fill: 'rgba(255,255,255,.14)' }); + } + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx - 3} ${cy} L ${cx - 2} 26 L ${cx + 2} 26 L ${cx + 3} ${cy} Z`, fill: 'url(#dkaChrome)', stroke: '#4e4a42', 'stroke-width': .5 }); + svgEl(lever, 'rect', { x: cx - 4.5, y: 16, width: 9, height: 12, rx: 2, fill: 'url(#dkaTip)', 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.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; + /* rotateX: the bat flips through the pivot plane (foreshortens, then + re-extends downward) — the head-on view of a real throw. */ + 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'); + }; + s.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: s, get: () => on, set }; + }; + + /* UPGRADE — bat toggle, realistic throw. Same disease and same cure as + guardedToggle: stock spins the bat 180° in the plane (sideways at the + midpoint); this flips it with rotateX like a real switch bat. Contract + unchanged: opts onLabel/offLabel/on/onChange(on, 'ON'|'OFF'); handle + {el, get, set}. */ + DUPRE.batToggle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44; + grad('dkaNut', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); + grad('dkaChrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#7e7a70'], ['.45', '#e8e5db'], ['1', '#8a867c']]); + grad('dkaTip', 'radialGradient', { cx: '40%', cy: '32%', r: '75%' }, [['0', '#f2efe8'], ['1', '#7e7a70']]); + const lblOn = svgEl(s, 'text', { x: cx, y: 9, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--gold-hi)' }); + lblOn.textContent = opts.onLabel || 'ON'; + const lblOff = svgEl(s, 'text', { x: cx, y: 87, 'text-anchor': 'middle', 'font-size': 7, 'letter-spacing': '.1em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + lblOff.textContent = opts.offLabel || 'OFF'; + const pts = []; for (let k = 0; k < 6; k++) { const a = (k * 60 + 30) * Math.PI / 180; pts.push((cx + 17 * Math.cos(a)) + ',' + (cy + 17 * Math.sin(a))); } + svgEl(s, 'polygon', { points: pts.join(' '), fill: 'url(#dkaNut)', stroke: '#2b2822' }); + svgEl(s, 'circle', { cx, cy, r: 9, fill: '#14110e', stroke: '#000' }); + const lever = svgEl(s, 'g', {}); + svgEl(lever, 'path', { d: `M ${cx - 3.2} ${cy} L ${cx - 1.8} 21 L ${cx + 1.8} 21 L ${cx + 3.2} ${cy} Z`, fill: 'url(#dkaChrome)', stroke: '#4e4a42', 'stroke-width': .5 }); + svgEl(lever, 'ellipse', { cx, cy: 17, rx: 6.5, ry: 8, fill: 'url(#dkaTip)', stroke: '#5e5a52', 'stroke-width': .6 }); + 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 ? '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'); + }; + s.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: s, get: () => on, set }; + }; + + /* NEW — n-position slide switch with a status lamp per position. + pillSlide's full-size descendant: the pill slide is a fixed 110px SVG + with mix LEDs; this one is DOM-built so it stretches to any width, and + each position carries its own lamp + label (only the active one lit). + + Contract (everything a consumer needs; no page globals touched): + opts: items (position labels, default ['A','B','C']); index (initial + position, default 0); width (px, default 220 — set it to match + a neighboring instrument); knob ('chrome' default, or 'dark' + for a machined-black cap); onChange(index, label) fires on + every set, including the initial paint. + handle: el, get() (the index), set(i) (clamped); click or drag lands + on the nearest position. + CSS lives in the additions block (.dupre-slsel); the lamp is + self-styled (the shared dupre-lamp is display-managed by consumers). */ + DUPRE.slideSelector = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const items = opts.items || ['A', 'B', 'C']; + const N = items.length; + const el = document.createElement('div'); el.className = 'dupre-slsel' + (opts.knob === 'dark' ? ' sl-dark' : ''); + if (opts.width) el.style.width = opts.width + 'px'; + const cols = document.createElement('div'); cols.className = 'sl-cols'; + cols.style.gridTemplateColumns = `repeat(${N},1fr)`; + const colEls = items.map(lbl => { + const c = document.createElement('div'); c.className = 'sl-col'; + c.innerHTML = `${lbl}`; + cols.appendChild(c); return c; + }); + el.appendChild(cols); + const track = document.createElement('div'); track.className = 'sl-track'; + const knob = document.createElement('div'); knob.className = 'sl-knob'; + track.appendChild(knob); el.appendChild(track); + host.appendChild(el); + knob.style.width = `calc(${100 / N}% - 8px)`; + let idx; + const set = i => { + idx = Math.max(0, Math.min(N - 1, i | 0)); + knob.style.left = ((idx + 0.5) / N * 100) + '%'; + colEls.forEach((c, k) => c.classList.toggle('sl-on', k === idx)); + onChange(idx, items[idx]); + }; + const pick = e => { + const r = track.getBoundingClientRect(); + set(Math.floor(((e.clientX - r.left) / r.width) * N)); + }; + el.addEventListener('pointerdown', e => { + el.setPointerCapture(e.pointerId); + pick(e); + const move = m => pick(m); + const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); }; + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + e.preventDefault(); + }); + set(opts.index !== undefined ? opts.index : 0); + return { el, get: () => idx, set }; + }; + + /* UPGRADE — slide-rule dial with a width option. Stock prints its stops + at hardcoded x positions inside a fixed 180px window; this redefinition + spreads majors evenly across opts.width (default keeps stock geometry). + Contract otherwise unchanged: values/value/index/fmt/skin, + onChange(v, label); handle {el, get, set, setStyle}; unit stops still + interpolate between adjacent numeric majors; ←/→ step. */ + (() => { + const OLD_STYLES = DUPRE.slideRule.STYLES; + DUPRE.slideRule = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const majors = opts.values || [4, 6, 8, 10, 12]; + const fmt = opts.fmt || (v => 'pos ' + v); + const W = opts.width || 180; + /* custom widths get a wider edge margin so end-stop labels stay inside */ + const M = opts.width ? 20 : 12; + const X = majors.map((_, i) => M + i * (W - 2 * M) / (majors.length - 1)); + const stops = []; + majors.forEach((v, i) => { + stops.push({ v, x: X[i], major: true }); + const b = majors[i + 1]; + if (typeof v === 'number' && typeof b === 'number') + for (let u = v + 1; u < b; u++) + stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false }); + }); + const t = document.createElement('span'); t.className = 'dupre-tuner'; + if (opts.width) t.style.width = W + 'px'; + t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); + const setStyle = (axis, name) => { + const o = (DUPRE.slideRule.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v); + }; + setStyle('skin', opts.skin || 'warm'); + stops.forEach(sp => { + t.insertAdjacentHTML('beforeend', sp.major + ? `${sp.v}` + : ``); + }); + const ndl = document.createElement('span'); ndl.className = 'dupre-ndl'; t.appendChild(ndl); + host.appendChild(t); + let idx; + const set = i => { + idx = Math.max(0, Math.min(stops.length - 1, i)); + ndl.style.left = stops[idx].x + 'px'; + t.querySelectorAll('.dupre-mk').forEach(m => + m.classList.toggle('dupre-on', m.textContent === String(stops[idx].v))); + onChange(stops[idx].v, fmt(stops[idx].v)); + }; + t.addEventListener('click', e => { + const r = t.getBoundingClientRect(); + const x = (e.clientX - r.left) * (t.offsetWidth / r.width); + let best = 0, bd = 1e9; stops.forEach((sp, j) => { const d = Math.abs(sp.x - x); if (d < bd) { bd = d; best = j; } }); + set(best); t.focus(); + }); + t.addEventListener('keydown', e => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); } + else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); } + }); + const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2]; + const init = stops.findIndex(sp => sp.v === initV); + set(init < 0 ? 0 : init); + return { el: t, get: () => stops[idx].v, set, setStyle }; + }; + DUPRE.slideRule.STYLES = OLD_STYLES; + })(); + + /* UPGRADE — rocker with a `quiet` option. Stock paints the off state's + bottom half warning-red, which reads as a fault for switches whose off + state is unremarkable (auto-dim off is not an alarm). quiet: true keeps + the off half plain dark. Red-off stays the default for switches where + off IS the notable state. Contract otherwise unchanged. + Intended pairing: onLabel carries the switch's FUNCTION name (lit when + engaged), offLabel is empty — state reads from light and position, not + from ON/OFF prose. opts.accent: 'amber' (default) or 'green' — green + for run/engaged semantics per the kit accent family. */ + DUPRE.rocker = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const r = document.createElement('span'); r.className = 'dupre-rocker' + + (opts.quiet ? ' dupre-quiet' : '') + (opts.accent === 'green' ? ' dupre-green' : ''); + r.innerHTML = `${opts.onLabel !== undefined ? opts.onLabel : 'ON'}${opts.offLabel !== undefined ? opts.offLabel : 'OFF'}`; + host.appendChild(r); + let on; + const set = v => { on = !!v; r.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); }; + r.addEventListener('click', () => set(!on)); + set(opts.on !== undefined ? opts.on : true); + return { el: r, get: () => on, set }; + }; + + /* NEW — program chart: the washing-machine faceplate matrix, now a punch + card. One row per program, one column per contact; a filled WHITE dot + means the program will close it on recall, a ring means it will open + it, a dash means it leaves it alone; letter cells carry enums. The dots + are future state, so they light white — deliberately outside the amber + live-state family. With editable: true, clicking a cell reprograms the + preset: dot cells cycle set → clear → untouched, letter cells cycle + the `letters` list; onEdit reports the change and the consumer owns + the stored program. + + Contract (everything a consumer needs; no page globals touched): + opts: cols (column labels); rows ({ name, cells } aligned with cols: + true dot / false ring / null dash / string letter); active + (lit row, -1 none); width (px, default 260); editable (false); + letters (cycle for letter columns, default ['P','B','S',null]); + onEdit(rowIdx, colIdx, newValue) on every cell edit; + onChange(index, row name | 'custom') on every set, including + the initial paint. + handle: el, get() (active index), set(i) (-1..rows-1). + CSS lives in the additions block (.dupre-pchart); no other styles + involved. */ + DUPRE.programChart = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const onEdit = opts.onEdit || noop; + const cols = opts.cols || ['A', 'B', 'C']; + const rows = (opts.rows || []).map(r => ({ name: r.name, cells: r.cells.slice() })); + const LETTERS = opts.letters || ['P', 'B', 'S', null]; + /* a column is a letter column if any row carries a string there */ + const letterCol = ci => rows.some(r => typeof r.cells[ci] === 'string'); + const el = document.createElement('div'); + el.className = 'dupre-pchart' + (opts.editable ? ' pc-edit' : ''); + el.style.width = (opts.width || 260) + 'px'; + const grid = document.createElement('div'); grid.className = 'pc-grid'; + grid.style.gridTemplateColumns = `minmax(52px,auto) repeat(${cols.length},1fr)`; + const cell = cls => { const c = document.createElement('div'); c.className = 'pc-cell' + (cls ? ' ' + cls : ''); grid.appendChild(c); return c; }; + cell('pc-h'); + cols.forEach(c => { cell('pc-h').textContent = c; }); + const paintCell = (c, v) => { + c.classList.remove('pc-pwr'); + if (v === true) c.innerHTML = ''; + else if (v === false) c.innerHTML = ''; + else if (typeof v === 'string') { c.classList.add('pc-pwr'); c.textContent = v; } + else c.innerHTML = ''; + }; + const rowEls = rows.map((r, ri) => { + const els = [cell('pc-name')]; + els[0].textContent = r.name; + cols.forEach((_, ci) => { + const c = cell(); + paintCell(c, r.cells[ci]); + if (opts.editable) c.addEventListener('click', () => { + const v = rows[ri].cells[ci]; + let nv; + if (letterCol(ci)) { + const cur = LETTERS.indexOf(v === undefined ? null : v); + nv = LETTERS[(cur + 1) % LETTERS.length]; + } else if (v === true) nv = false; + else if (v === false) nv = null; + else nv = true; + rows[ri].cells[ci] = nv; + paintCell(c, nv); + onEdit(ri, ci, nv); + }); + els.push(c); + }); + return els; + }); + el.appendChild(grid); host.appendChild(el); + let idx; + const set = i => { + idx = Math.max(-1, Math.min(rows.length - 1, i)); + rowEls.forEach((els, ri) => els.forEach(c => c.classList.toggle('pc-row-on', ri === idx))); + onChange(idx, idx < 0 ? 'custom' : rows[idx].name); + }; + set(opts.active !== undefined ? opts.active : -1); + return { el, get: () => idx, set }; + }; + + /* NEW — horizontal thumb-slide attenuator. The vertical thumbSlide turned + on its side: one channel, a dark rail with a chrome thumb tab, printed + scale beneath. Continuous by default; pass positions for a detented + selector (the three-position switch case) — the tab snaps and the + active position label lights. + + Contract (everything a consumer needs; no page globals touched): + opts: width (px, default 220); value (0-100, default 50 — continuous + mode); positions (label array — switches to detent mode); + index (initial detent, default 0); onChange — continuous: + (value, 'N'); detented: (index, label). Fires on every set, + including the initial paint. + handle: el, get() (value or index), set(v|i). Drag or click anywhere + on the rail. + SVG-built: styling is inline attributes plus the shared .rsvg stage + block of DUPRE_CSS; gradients are additions-scoped until merge. */ + DUPRE.thumbSlideH = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const W = opts.width || 220; + const POS = opts.positions || null; + const H = 36, x0 = 14, x1 = W - 14, railY = 14; + grad('dkaThRail', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#3a3733'], ['.5', '#211f1c'], ['1', '#161412']]); + grad('dkaThTab', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2efe6'], ['1', '#c6c1b3']]); + const s = stageSvg(host, 'rsvg', W, H); + svgEl(s, 'rect', { x: x0 - 8, y: railY - 7, width: x1 - x0 + 16, height: 14, rx: 3, fill: 'url(#dkaThRail)', stroke: '#0a0908', 'stroke-width': 1 }); + svgEl(s, 'rect', { x: x0 - 3, y: railY - 3, width: x1 - x0 + 6, height: 6, fill: '#0d0a08', stroke: '#050404' }); + const labels = []; + const xAt = f => x0 + f * (x1 - x0); + if (POS) { + POS.forEach((lbl, i) => { + const x = xAt(POS.length === 1 ? 0.5 : i / (POS.length - 1)); + svgEl(s, 'line', { x1: x, y1: railY + 5, x2: x, y2: railY + 8, stroke: 'var(--steel)', 'stroke-width': 1 }); + const t = svgEl(s, 'text', { x, y: H - 3, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }); + t.textContent = lbl; labels.push(t); + }); + } else { + for (let n = 0; n <= 10; n++) { + const x = xAt(n / 10); + svgEl(s, 'line', { x1: x, y1: railY + 5, x2: x, y2: railY + (n % 2 ? 6.5 : 8), stroke: 'var(--steel)', 'stroke-width': n % 2 ? .5 : .9, opacity: n % 2 ? .5 : .8 }); + if (n % 2 === 0) { + const t = svgEl(s, 'text', { x, y: H - 3, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: .55 }); + t.textContent = n * 10; labels.push(t); + } + } + } + const tab = svgEl(s, 'g', {}); + svgEl(tab, 'rect', { x: -6, y: railY - 9, width: 12, height: 18, rx: 2.5, fill: 'url(#dkaThTab)', stroke: '#7a766a', 'stroke-width': .6 }); + svgEl(tab, 'rect', { x: -1, y: railY - 6.5, width: 2, height: 13, fill: 'rgba(0,0,0,.25)' }); + let val; + const paint = () => { + const f = POS ? (POS.length === 1 ? 0.5 : val / (POS.length - 1)) : val / 100; + tab.setAttribute('transform', `translate(${xAt(f)},0)`); + if (POS) labels.forEach((t, i) => { + t.setAttribute('fill', i === val ? 'var(--gold-hi)' : 'var(--dim)'); + }); + else labels.forEach((t, i) => { + const on = Math.abs(i * 20 - val) <= 10; + t.setAttribute('fill', on ? 'var(--gold-hi)' : 'var(--gold)'); t.setAttribute('opacity', on ? '1' : '.55'); + }); + }; + const set = v => { + val = POS ? Math.max(0, Math.min(POS.length - 1, Math.round(v))) + : Math.max(0, Math.min(100, Math.round(v))); + paint(); + if (POS) onChange(val, POS[val]); + else onChange(val, String(val)); + }; + const fromPointer = e => { + const r = s.getBoundingClientRect(); + const f = Math.max(0, Math.min(1, ((e.clientX - r.left) * (W / r.width) - x0) / (x1 - x0))); + set(POS ? f * (POS.length - 1) : f * 100); + }; + s.style.cursor = 'ew-resize'; s.style.touchAction = 'none'; + s.addEventListener('pointerdown', e => { + s.setPointerCapture(e.pointerId); + fromPointer(e); + const move = m => fromPointer(m); + const up = () => { s.removeEventListener('pointermove', move); s.removeEventListener('pointerup', up); }; + s.addEventListener('pointermove', move); + s.addEventListener('pointerup', up); + e.preventDefault(); + }); + set(POS ? (opts.index !== undefined ? opts.index : 0) : (opts.value !== undefined ? opts.value : 50)); + return { el: s, get: () => val, set }; + }; + + /* UPGRADE — pin routing matrix, generalized. Stock is a fixed 160×110 + five-by-six with gold pins, text heads, and no set(). This + redefinition keeps those defaults exactly and adds: width/dy geometry, + colColors (pin fill per column), colHeads: 'button' (column labels + become clickable key plates; setActiveCol lights one), groups (row + counts drawing hairline dividers between row groups), set(key, on), + and an onPin convenience callback. + + Contract (everything a consumer needs; no page globals touched): + opts: rows / cols (labels); pins ('ROW>COL' keys); width (px — + custom geometry mode; omit for stock); dy (row pitch, default + 16 in custom mode); x0 (label gutter, default 56 in custom + mode); colColors (css fill per column index, default gold); + colHeads ('text' default | 'button'); activeCol (initial lit + head, -1); groups (e.g. [4,3] → divider after row 4); + onChange(pins array, label) on every seat/pull including the + initial paint; onPin(rowLabel, colLabel, seated) per click; + onColHead(colIndex) when a button head is clicked. + handle: el, get() (pin keys), set(key, on), setActiveCol(i). + SVG-built: styling is inline attributes plus the shared .rsvg stage + block of DUPRE_CSS. */ + DUPRE.pinMatrix = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const onPin = opts.onPin || noop; + const onColHead = opts.onColHead || noop; + const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV']; + const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD']; + const custom = !!opts.width; + const W = opts.width || 160; + const X0 = custom ? (opts.x0 || 56) : 42; + const DY = custom ? (opts.dy || 16) : 15.5; + const Y0 = custom ? (opts.colHeads === 'button' ? 40 : 28) : 30; + /* button heads are wider than a pin column; leave them room at the edge */ + const DX = custom ? (W - X0 - (opts.colHeads === 'button' ? 36 : 16)) / (COLS.length - 1) : 19; + const H = custom ? Y0 + (ROWS.length - 1) * DY + 14 : 110; + const COLC = opts.colColors || []; + const groups = opts.groups || null; + const pins = new Set((opts.pins || (custom ? [] : ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA'])) + .filter(k => { const [r, c] = k.split('>'); return ROWS.includes(r) && COLS.includes(c) && k === r + '>' + c; })); + const s = stageSvg(host, 'rsvg press', W, H); + svgEl(s, 'rect', { x: 2, y: 2, width: W - 4, height: H - 4, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + let activeCol = opts.activeCol !== undefined ? opts.activeCol : -1; + const headEls = []; + COLS.forEach((c, j) => { + const cx = X0 + j * DX; + if (opts.colHeads === 'button') { + const bw = Math.min(DX - 4, 58); + const g = svgEl(s, 'g', {}); + const rect = svgEl(g, 'rect', { x: cx - bw / 2, y: 8, width: bw, height: 20, rx: 5, + fill: '#131110', stroke: '#33302b', 'stroke-width': 1 }); + const txt = svgEl(g, 'text', { x: cx, y: 21, 'text-anchor': 'middle', 'font-size': 6, + 'letter-spacing': '.04em', 'font-family': 'var(--mono)', fill: 'var(--silver)' }); + txt.textContent = c; + g.style.cursor = 'pointer'; + g.addEventListener('click', () => onColHead(j)); + headEls.push({ rect, txt }); + } else { + svgEl(s, 'text', { x: cx, y: custom ? 18 : 16, 'text-anchor': 'middle', 'font-size': custom ? 6 : 5.4, + 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = c; + headEls.push(null); + } + }); + const paintHeads = () => headEls.forEach((he, j) => { + if (!he) return; + he.rect.setAttribute('stroke', j === activeCol ? 'var(--gold)' : '#33302b'); + he.rect.setAttribute('fill', j === activeCol ? '#2a2618' : '#131110'); + he.txt.setAttribute('fill', j === activeCol ? 'var(--cream)' : 'var(--silver)'); + }); + paintHeads(); + ROWS.forEach((r, i) => svgEl(s, 'text', { x: X0 - 12, y: Y0 + i * DY + 2, 'text-anchor': 'end', + 'font-size': custom ? 6 : 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r); + if (groups) { + let acc = 0; + groups.slice(0, -1).forEach(n => { + acc += n; + const y = Y0 + (acc - 0.5) * DY; + svgEl(s, 'line', { x1: 10, y1: y, x2: W - 10, y2: y, stroke: '#2c2820', 'stroke-width': 1 }); + }); + } + const caps = {}; + ROWS.forEach((r, i) => COLS.forEach((c, j) => { + const cx = X0 + j * DX, cy = Y0 + i * DY, key = r + '>' + c; + svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 }); + const fill = COLC[j] || 'var(--gold)'; + const cap = svgEl(s, 'circle', { cx, cy, r: 4.4, fill, stroke: 'rgba(0,0,0,.45)', 'stroke-width': 1, + opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' }); + caps[key] = cap; + const hit = svgEl(s, 'circle', { cx, cy, r: Math.min(8, DX / 2 - 1), fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + const on = !pins.has(key); + if (on) pins.add(key); else pins.delete(key); + cap.setAttribute('opacity', on ? 1 : 0); + onPin(r, c, on); + onChange([...pins], (on ? r + ' → ' + c : 'pulled ' + r + ' → ' + c) + ' · ' + pins.size + ' routes'); + }); + })); + onChange([...pins], pins.size + ' routes'); + return { + el: s, + get: () => [...pins], + set: (key, on) => { + if (!caps[key]) return; + if (on) pins.add(key); else pins.delete(key); + caps[key].setAttribute('opacity', on ? 1 : 0); + }, + setActiveCol: i => { activeCol = i; paintHeads(); }, + }; + }; + + /* NEW — programmable matrix. The pin routing matrix specialized into a + program store: each column is a stored program, each row a setting; a + WHITE jewel pin means the program includes that setting. Pressing a + head key activates the program and the active column renders its + verdicts — active-is-live: a seated pin becomes a green jewel, an + empty socket lights a smaller red one (the program holds it off). + Editing the active column edits the present; a white column, a + future. + + Head keys are the kit console-key primitive (.dupre-key), lighting + GREEN when their program is active. Programs may be strings or + { id, label, glyph, slim } — a glyph renders large on the key in + place of the label (label still names the program in callbacks); + blank labels make unassigned slots; ids keep pin keys unique. Rows are strings (pin rows) or { label, values } + — a LETTER ROW: every program cell is a tape-counter wheel (cream + paper, bezeled window, printed glyph from `values`); clicking a wheel + cycles that program's value; the active column's wheel IS the live + value and carries no highlight (paper doesn't glow). + + Contract (everything a consumer needs; no page globals touched): + opts: rows (strings | {label, values}); programs (strings | + {id, label}); pins ('ROW>PROGID' keys); letters + ({'ROW>PROGID': glyph}); radios (row-label arrays: one pin + per program in the group; a radio pin moves, never pulls); + groups (row counts → hairline dividers); active (program + index, -1); width / x0 / dy (508/128/23-ish); engraved (row + labels as infilled relief); onActivate(index, programId) on + head press; onPin(rowLabel, programId, seated, isActive); + onLetter(rowLabel, programId, glyph, isActive) on wheel click. + handle: el (the wrap), get() (pins), set(row, programId, on), + setLetter(row, programId, glyph), setActive(i), getActive(). + Composite-built: DOM console keys over an SVG pin field. */ + DUPRE.programMatrix = function (host, opts = {}) { + const onActivate = opts.onActivate || noop; + const onPin = opts.onPin || noop; + const onLetter = opts.onLetter || noop; + const ROWS = (opts.rows || []).map(r => typeof r === 'string' ? { label: r } : r); + const PROGS = (opts.programs || ['P1', 'P2']).map(p => typeof p === 'string' ? { id: p, label: p } : p); + /* slim columns (e.g. unassigned slots) take a narrower band */ + const weights = PROGS.map(p => p.slim ? 0.55 : 1); + const wSum = weights.reduce((a, b) => a + b, 0); + const radios = (opts.radios || []).map(g => g.slice()); + const W = opts.width || 306; + const X0 = opts.x0 || 104; + const DY = opts.dy || 21; + const Y0 = 52; + const span = W - X0 - 30; + const colX = []; /* column centers by cumulative weight bands */ + { let acc = 0; weights.forEach(w => { colX.push(X0 - 14 + (acc + w / 2) / wSum * span); acc += w; }); } + const colW = weights.map(w => w / wSum * span); + const DX = colW.length > 1 ? Math.min(...colW) : span; /* hit-radius basis */ + const H = Y0 + (ROWS.length - 1) * DY + 18; + const WHITE = '#cfd3cf', GREEN = '#a9c95f'; + grad('dkaJewelG', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#dcebae'], ['.5', '#a9c95f'], ['1', '#55702a']]); + grad('dkaJewelR', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#e0937e'], ['.5', '#c0503a'], ['1', '#6e2517']]); + grad('dkaJewelW', 'radialGradient', { cx: '38%', cy: '30%', r: '80%' }, [['0', '#f2f4f2'], ['.55', '#cfd3cf'], ['1', '#8e948e']]); + grad('dkaPaper2', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 0 }, [['0', '#cfc6a8'], ['.5', '#ece4c8'], ['1', '#c6bd9e']]); + grad('dkaCurve2', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', 'rgba(0,0,0,.45)'], ['.3', 'rgba(0,0,0,0)'], ['.7', 'rgba(0,0,0,0)'], ['1', 'rgba(0,0,0,.45)']]); + const pins = new Set(opts.pins || []); + const letters = Object.assign({}, opts.letters || {}); + let active = opts.active !== undefined ? opts.active : -1; + /* container: a DOM head row of kit console keys over the SVG pin field */ + const wrap = document.createElement('div'); + wrap.style.cssText = 'position:relative;width:' + W + 'px'; + host.appendChild(wrap); + const s = stageSvg(wrap, 'rsvg press', W, H); + svgEl(s, 'rect', { x: 2, y: 2, width: W - 4, height: H - 4, rx: 8, fill: '#0e0c0a', stroke: '#060505', 'stroke-width': 1.5 }); + const heads = PROGS.map((pgm, j) => { + const cx = colX[j]; + const headBw = Math.min(colW[j] - 8, 82); + const b = document.createElement('button'); + b.className = 'dupre-key'; + b.textContent = pgm.glyph || pgm.label; + if (pgm.glyph) b.style.fontSize = '15px'; + b.style.cssText = 'position:absolute;top:8px;left:' + (cx - headBw / 2) + 'px;width:' + headBw + + 'px;height:24px;padding:0;text-align:center;font-size:7.5px;letter-spacing:.08em;box-sizing:border-box'; + b.addEventListener('click', () => { b.blur(); onActivate(j, pgm.id); }); + wrap.appendChild(b); + return b; + }); + /* row labels right-align to the first head's LEFT edge; engraved-and- + infilled at faceplate size */ + const labelX = colX[0] - Math.min(colW[0] - 8, 82) / 2 - 4; + ROWS.forEach((r, i) => { + const y = Y0 + i * DY + 2; + if (opts.engraved) { + svgEl(s, 'text', { x: labelX, y: y - 1, 'text-anchor': 'end', 'font-size': 7.6, + 'letter-spacing': '.1em', 'font-weight': 600, 'font-family': 'var(--mono)', fill: 'rgba(0,0,0,.8)' }).textContent = r.label; + svgEl(s, 'text', { x: labelX, y, 'text-anchor': 'end', 'font-size': 7.6, + 'letter-spacing': '.1em', 'font-weight': 600, 'font-family': 'var(--mono)', fill: '#a59d8c' }).textContent = r.label; + } else { + svgEl(s, 'text', { x: labelX, y, 'text-anchor': 'end', 'font-size': 6, + 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = r.label; + } + }); + if (opts.groups) { + let acc = 0; + opts.groups.slice(0, -1).forEach(n => { + acc += n; + const y = Y0 + (acc - 0.5) * DY; + svgEl(s, 'line', { x1: 10, y1: y, x2: W - 10, y2: y, stroke: '#2c2820', 'stroke-width': 1 }); + }); + } + const caps = {}, letterEls = {}; + const paint = () => { + heads.forEach((b, j) => { + b.classList.remove('dupre-on', 'dupre-green', 'dupre-red'); + if (j === active) b.classList.add('dupre-green'); + }); + ROWS.forEach(r => PROGS.forEach((pgm, j) => { + const key = r.label + '>' + pgm.id; + if (r.values) { /* wheels carry no live highlight — paper doesn't glow */ } + else if (caps[key]) { + const seated = pins.has(key); + if (j === active) { + caps[key].setAttribute('fill', seated ? 'url(#dkaJewelG)' : 'url(#dkaJewelR)'); + caps[key].setAttribute('opacity', 1); + caps[key].setAttribute('r', seated ? 5.6 : 4.8); + } else { + caps[key].setAttribute('fill', 'url(#dkaJewelW)'); + caps[key].setAttribute('opacity', seated ? 1 : 0); + caps[key].setAttribute('r', 5.6); + } + } + })); + }; + ROWS.forEach((r, i) => { + const cy = Y0 + i * DY; + PROGS.forEach((pgm, j) => { + const cx = colX[j], key = r.label + '>' + pgm.id; + if (r.values) { + svgEl(s, 'rect', { x: cx - 13, y: cy - 10.5, width: 26, height: 21, rx: 3, + fill: '#0a0908', stroke: '#3a352c', 'stroke-width': 1.2 }); + svgEl(s, 'rect', { x: cx - 10, y: cy - 7.5, width: 20, height: 15, fill: 'url(#dkaPaper2)' }); + const t = svgEl(s, 'text', { x: cx, y: cy + 3.8, 'text-anchor': 'middle', 'font-size': 11, + 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#2b2418' }); + t.textContent = letters[key] !== undefined ? letters[key] : r.values[0]; + svgEl(s, 'rect', { x: cx - 10, y: cy - 7.5, width: 20, height: 15, fill: 'url(#dkaCurve2)', 'pointer-events': 'none' }); + letterEls[key] = { t }; + const hit = svgEl(s, 'rect', { x: cx - 12, y: cy - 10, width: 24, height: 20, fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + const cur = r.values.indexOf(t.textContent); + const nv = r.values[(cur + 1) % r.values.length]; + t.textContent = nv; letters[key] = nv; + onLetter(r.label, pgm.id, nv, j === active); + }); + } else { + svgEl(s, 'circle', { cx, cy, r: 3.4, fill: '#0a0908', stroke: '#2c2820', 'stroke-width': 1 }); + const cap = svgEl(s, 'circle', { cx, cy, r: 5.6, fill: 'url(#dkaJewelW)', stroke: 'rgba(0,0,0,.45)', 'stroke-width': 1, + opacity: pins.has(key) ? 1 : 0, filter: 'drop-shadow(0 1px 2px rgba(0,0,0,.6))' }); + caps[key] = cap; + const hit = svgEl(s, 'circle', { cx, cy, r: Math.min(9, DX / 2 - 1), fill: 'transparent' }); + hit.style.cursor = 'pointer'; + hit.addEventListener('click', () => { + const group = radios.find(g => g.includes(r.label)); + const seated = !pins.has(key); + if (group) { + if (!seated) return; /* a radio pin moves, never pulls */ + group.forEach(gr => pins.delete(gr + '>' + pgm.id)); + pins.add(key); + } else { + if (seated) pins.add(key); else pins.delete(key); + } + paint(); + onPin(r.label, pgm.id, seated, j === active); + }); + } + }); + }); + paint(); + return { + el: wrap, + get: () => [...pins], + set: (r, c, on) => { const k = r + '>' + c; if (!caps[k]) return; if (on) pins.add(k); else pins.delete(k); paint(); }, + setLetter: (r, c, v) => { const k = r + '>' + c; if (letterEls[k]) { letterEls[k].t.textContent = v; letters[k] = v; } }, + setActive: i => { active = Math.max(-1, Math.min(PROGS.length - 1, i)); paint(); }, + getActive: () => active, + }; + }; + + /* NEW — idle tripper rail. The mechanical time-switch tripper translated + from clock time to idle time: a horizontal engraved scale of idle + minutes with clamp tabs along it, each engraved with the action it + throws when idleness reaches its mark. Nothing moves by itself — the + rail is a standing policy, readable left to right. Tabs keep their + stage order (they clamp between neighbors), and the siding past the + scale end parks a tab OFF (stage disabled). setBypassed(true) dims the + whole policy — the caffeine-engaged look. + + Contract (everything a consumer needs; no page globals touched): + opts: stages ([{ id, label, minutes }] in pipeline order; minutes + null = parked/disabled); max (scale end, default 60); + width (px, default 460); onChange(id, minutes|null, stages + snapshot) on every drag commit, including the initial paint + (id null on the initial paint). + handle: el, get() (stages snapshot), set(id, minutes|null), + setBypassed(bool). + SVG-built: styling is inline attributes plus the shared .rsvg stage + block of DUPRE_CSS; sqrt scale spreads the crowded early minutes. */ + DUPRE.tripRail = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const MAX = opts.max || 60; + const W = opts.width || 460; + const H = 86; + const stages = (opts.stages || []).map(st => ({ id: st.id, label: st.label, minutes: st.minutes })); + const x0 = 16, x1 = W - 64, railY = 44; + const sideX = x1 + 14; + const xOf = m => x0 + Math.sqrt(Math.max(0, m) / MAX) * (x1 - x0); + const mOf = x => Math.pow(Math.max(0, x - x0) / (x1 - x0), 2) * MAX; + grad('dkaTabM', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + const s = stageSvg(host, 'rsvg', W, H); + const all = svgEl(s, 'g', {}); + /* rail groove + sqrt tick scale */ + svgEl(all, 'rect', { x: x0 - 8, y: railY - 4, width: x1 - x0 + 16, height: 8, rx: 3, fill: '#0d0a08', stroke: '#050404' }); + [0, 1, 2, 5, 10, 15, 20, 30, 45, 60].filter(m => m <= MAX).forEach(m => { + const x = xOf(m); + svgEl(all, 'line', { x1: x, y1: railY + 6, x2: x, y2: railY + 10, stroke: 'var(--steel)', 'stroke-width': .9, opacity: .8 }); + svgEl(all, 'text', { x, y: railY + 18, 'text-anchor': 'middle', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--steel)', opacity: .8 }).textContent = m; + }); + svgEl(all, 'text', { x: (x0 + x1) / 2, y: railY + 28, 'text-anchor': 'middle', 'font-size': 5.5, + 'letter-spacing': '.18em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'MINUTES IDLE'; + /* parking siding */ + svgEl(all, 'rect', { x: sideX - 4, y: railY - 7, width: 46, height: 14, rx: 4, fill: 'none', stroke: '#2c2820', 'stroke-width': 1, 'stroke-dasharray': '3 2' }); + svgEl(all, 'text', { x: sideX + 19, y: railY + 20, 'text-anchor': 'middle', 'font-size': 5.5, + 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'OFF'; + const tabs = stages.map((st) => { + const g = svgEl(s, 'g', {}); + const line = svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: 0, stroke: '#3a352c', 'stroke-width': 1 }); + const lbl = svgEl(g, 'text', { x: 0, y: 0, 'text-anchor': 'middle', 'font-size': 6, + 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: '#a59d8c' }); + lbl.textContent = st.label; + const min = svgEl(g, 'text', { x: 0, y: 0, 'text-anchor': 'middle', 'font-size': 5.5, + 'font-family': 'var(--mono)', fill: 'var(--gold)' }); + svgEl(g, 'rect', { x: -5, y: railY - 8, width: 10, height: 16, rx: 2, fill: 'url(#dkaTabM)', stroke: '#5e5a52', 'stroke-width': .7 }); + svgEl(g, 'rect', { x: -1, y: railY - 5, width: 2, height: 10, fill: 'rgba(0,0,0,.3)' }); + g.style.cursor = 'ew-resize'; + return { g, line, lbl, min, st }; + }); + const paint = () => { + /* flag sides alternate among ACTIVE tabs so neighbors never share a + line even when parking breaks the built order */ + let rank = 0; + tabs.forEach(t => { + const parked = t.st.minutes === null || t.st.minutes === undefined; + const up = parked ? true : (rank++ % 2 === 0); + const flagY = up ? 14 : railY + 34; + t.line.setAttribute('y1', up ? flagY + 3 : railY + 6); + t.line.setAttribute('y2', up ? railY - 6 : flagY - 8); + t.lbl.setAttribute('y', flagY); + t.min.setAttribute('y', up ? flagY + 8 : flagY - 12); + const x = parked ? sideX + 6 + tabs.filter(o => (o.st.minutes == null) && stages.indexOf(o.st) < stages.indexOf(t.st)).length * 12 : xOf(t.st.minutes); + t.g.setAttribute('transform', 'translate(' + x + ',0)'); + t.min.textContent = parked ? 'off' : Math.round(t.st.minutes) + 'm'; + t.g.setAttribute('opacity', parked ? .55 : 1); + }); + }; + /* drag: clamp between neighboring active stages; past the scale end parks */ + tabs.forEach((t, i) => { + t.g.addEventListener('pointerdown', e => { + t.g.setPointerCapture(e.pointerId); + const move = m => { + const r = s.getBoundingClientRect(); + const px = (m.clientX - r.left) * (W / r.width); + if (px > x1 + 8) { t.st.minutes = null; paint(); return; } + let mm = Math.round(mOf(Math.min(px, x1)) * 2) / 2; + const prev = tabs.slice(0, i).reverse().find(o => o.st.minutes != null); + const next = tabs.slice(i + 1).find(o => o.st.minutes != null); + if (prev) mm = Math.max(mm, prev.st.minutes + 0.5); + if (next) mm = Math.min(mm, next.st.minutes - 0.5); + t.st.minutes = Math.max(0.5, Math.min(MAX, mm)); + /* exact-number counter: the dial's center reads the dragged value */ + centerTxt.textContent = (t.st.minutes % 1 ? t.st.minutes.toFixed(1) : Math.round(t.st.minutes)) + 'm'; + paint(); + }; + const up = () => { + t.g.removeEventListener('pointermove', move); + t.g.removeEventListener('pointerup', up); + centerTxt.textContent = 'IDLE'; + onChange(t.st.id, t.st.minutes, stages.map(o => ({ ...o }))); + }; + t.g.addEventListener('pointermove', move); + t.g.addEventListener('pointerup', up); + e.preventDefault(); + }); + }); + const stamp = svgEl(s, 'text', { x: (x0 + x1) / 2, y: 12, 'text-anchor': 'middle', 'font-size': 6.5, + 'letter-spacing': '.22em', 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: 0 }); + stamp.textContent = 'BYPASSED · CAFFEINE'; + let bypassed = false; + paint(); + onChange(null, null, stages.map(o => ({ ...o }))); + return { + el: s, + get: () => stages.map(o => ({ ...o })), + set: (id, m) => { const t = stages.find(o => o.id === id); if (t) { t.minutes = m; paint(); } }, + setBypassed: b => { + bypassed = !!b; + all.setAttribute('opacity', bypassed ? .3 : 1); + tabs.forEach(t => t.g.setAttribute('opacity', bypassed ? .3 : (t.st.minutes == null ? .55 : 1))); + stamp.setAttribute('opacity', bypassed ? 1 : 0); + }, + }; + }; + + /* NEW — idle tripper dial. The mechanical segment-timer dial (Intermatic + idiom) translated from clock time to idle time: a circular sqrt-spaced + minutes scale sweeping 300° with COLORED tripper tabs clamped on the + rim — drag a tab around the rim to retune when its stage throws; drag + it into the bottom OFF notch to disable the stage. A side legend maps + color → stage → minutes. Nothing self-moves: the dial is a standing + policy. setBypassed(true) dims the face under a BYPASSED · CAFFEINE + stamp. + + Contract (everything a consumer needs; no page globals touched): + opts: stages ([{ id, label, color, minutes }] in pipeline order; + minutes null = parked in the OFF notch); max (scale end, + default 60); size (dial diameter px, default 132); legend + (true draws the color/label/minutes legend — legendPos + 'right' default, or 'bottom' beneath the dial); + onChange(id, minutes|null, stages snapshot) on every drag + commit, plus once at build (id null). + handle: el, get() (snapshot), set(id, minutes|null), + setBypassed(bool). + SVG-built: styling is inline attributes plus the shared .rsvg stage + block of DUPRE_CSS. */ + DUPRE.tripDial = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const MAX = opts.max || 60; + const SIZE = opts.size || 132; + const stages = (opts.stages || []).map(st => ({ ...st })); + const LEG_BOTTOM = opts.legend && opts.legendPos === 'bottom'; + const LEG_W = opts.legend && !LEG_BOTTOM ? 96 : 0; + const W = SIZE + LEG_W + 8; + const H = SIZE + 8 + (LEG_BOTTOM ? stages.length * 13 + 8 : 0); + const cx = SIZE / 2 + 4, cy = SIZE / 2 + 4; + const R = SIZE / 2 - 4; /* bezel radius */ + const TR = R - 7; /* tab track radius */ + const START = -150, SWEEP = 300; /* bottom 60° = the OFF notch */ + const aOf = m => START + Math.sqrt(Math.max(0, m) / MAX) * SWEEP; + const mOf = a => Math.pow((a - START) / SWEEP, 2) * MAX; + const rad = a => (a - 90) * Math.PI / 180; + const at = (r, a) => [cx + r * Math.cos(rad(a)), cy + r * Math.sin(rad(a))]; + const s = stageSvg(host, 'rsvg', W, H); + const all = svgEl(s, 'g', {}); + svgEl(all, 'circle', { cx, cy, r: R, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(all, 'circle', { cx, cy, r: R - 14, fill: '#100e0a', stroke: '#2a231c', 'stroke-width': 1 }); + /* sqrt scale ticks + numerals */ + [0, 1, 2, 5, 10, 15, 20, 30, 45, 60].filter(m => m <= MAX).forEach(m => { + const a = aOf(m); + const [x1, y1] = at(R - 2, a), [x2, y2] = at(R - 8, a); + svgEl(all, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .9, opacity: .75 }); + const [tx, ty] = at(R - 20, a); + svgEl(all, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 6.2, + 'font-family': 'var(--mono)', fill: 'var(--cream)', opacity: .85 }).textContent = m; + }); + /* OFF notch at the bottom gap */ + const [nx, ny] = at(R - 20, 180); + svgEl(all, 'text', { x: nx, y: ny + 2, 'text-anchor': 'middle', 'font-size': 5.5, + 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'OFF'; + const centerTxt = svgEl(all, 'text', { x: cx, y: cy + 3, 'text-anchor': 'middle', 'font-size': 9, + 'letter-spacing': '.2em', 'font-family': 'var(--mono)', fill: 'var(--cream)', opacity: .85 }); + centerTxt.textContent = 'IDLE'; + /* legend */ + const legendEls = {}; + if (opts.legend) { + stages.forEach((st, i) => { + const lx = LEG_BOTTOM ? 14 : SIZE + 12; + const ly = LEG_BOTTOM ? SIZE + 12 + i * 13 : 18 + i * 18; + const lw = LEG_BOTTOM ? W - 14 : W; + svgEl(s, 'rect', { x: lx, y: ly - 6, width: 8, height: 8, rx: 2, fill: st.color, stroke: 'rgba(0,0,0,.5)' }); + svgEl(s, 'text', { x: lx + 13, y: ly + 1, 'font-size': 6, 'letter-spacing': '.06em', + 'font-family': 'var(--mono)', fill: '#a59d8c' }).textContent = st.label; + legendEls[st.id] = svgEl(s, 'text', { x: lw - 4, y: ly + 1, 'text-anchor': 'end', 'font-size': 6, + 'font-family': 'var(--mono)', fill: 'var(--gold)' }); + }); + } + /* tabs: colored clamps riding the rim */ + const tabs = stages.map(st => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'rect', { x: -4.5, y: -TR - 8, width: 9, height: 15, rx: 2, fill: st.color, stroke: 'rgba(0,0,0,.55)', 'stroke-width': 1 }); + svgEl(g, 'rect', { x: -1, y: -TR - 5, width: 2, height: 9, fill: 'rgba(0,0,0,.3)' }); + g.style.cursor = 'grab'; + return { g, st }; + }); + const paint = () => { + const parkedIds = stages.filter(o => o.minutes == null).map(o => o.id); + tabs.forEach(t => { + const parked = t.st.minutes == null; + const a = parked ? 172 + parkedIds.indexOf(t.st.id) * 9 : aOf(t.st.minutes); + t.g.setAttribute('transform', 'translate(' + cx + ',' + cy + ') rotate(' + a + ')'); + t.g.setAttribute('opacity', parked ? .55 : 1); + if (legendEls[t.st.id]) legendEls[t.st.id].textContent = parked ? 'off' + : (t.st.minutes % 1 ? t.st.minutes.toFixed(1) : Math.round(t.st.minutes)) + 'm'; + }); + }; + tabs.forEach((t, i) => { + t.g.addEventListener('pointerdown', e => { + t.g.setPointerCapture(e.pointerId); + const move = m => { + const r = s.getBoundingClientRect(); + const px = (m.clientX - r.left) * (W / r.width) - cx; + const py = (m.clientY - r.top) * (H / r.height) - cy; + let a = Math.atan2(px, -py) * 180 / Math.PI; /* 0 up, cw */ + if (a > 150 || a < -150) { t.st.minutes = null; paint(); return; } + let mm = Math.round(mOf(Math.max(START, Math.min(START + SWEEP, a))) * 2) / 2; + const prev = tabs.slice(0, i).reverse().find(o => o.st.minutes != null); + const next = tabs.slice(i + 1).find(o => o.st.minutes != null); + if (prev) mm = Math.max(mm, prev.st.minutes + 0.5); + if (next) mm = Math.min(mm, next.st.minutes - 0.5); + t.st.minutes = Math.max(0.5, Math.min(MAX, mm)); + /* exact-number counter: the dial's center reads the dragged value */ + centerTxt.textContent = (t.st.minutes % 1 ? t.st.minutes.toFixed(1) : Math.round(t.st.minutes)) + 'm'; + paint(); + }; + const up = () => { + t.g.removeEventListener('pointermove', move); + t.g.removeEventListener('pointerup', up); + centerTxt.textContent = 'IDLE'; + onChange(t.st.id, t.st.minutes, stages.map(o => ({ ...o }))); + }; + t.g.addEventListener('pointermove', move); + t.g.addEventListener('pointerup', up); + e.preventDefault(); + }); + }); + const stamp = svgEl(s, 'text', { x: cx, y: cy - 14, 'text-anchor': 'middle', 'font-size': 5.5, + 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--gold)', opacity: 0 }); + stamp.textContent = 'BYPASSED'; + paint(); + onChange(null, null, stages.map(o => ({ ...o }))); + return { + el: s, + get: () => stages.map(o => ({ ...o })), + set: (id, m) => { const t = stages.find(o => o.id === id); if (t) { t.minutes = m; paint(); } }, + setBypassed: b => { + all.setAttribute('opacity', b ? .3 : 1); + tabs.forEach(t => t.g.setAttribute('opacity', b ? .3 : (t.st.minutes == null ? .55 : 1))); + stamp.setAttribute('opacity', b ? 1 : 0); + }, + }; + }; +})(); -- cgit v1.2.3