diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-22 15:15:02 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-22 15:15:02 -0500 |
| commit | f1c661f50d6af2a115fc1430ae0478f2acef2dec (patch) | |
| tree | b894ea2e14327794bb75671b962f37185cff22ea | |
| parent | f594068bed75302aba50793ccf5e6ce9cf53c7f8 (diff) | |
| download | archsetup-f1c661f50d6af2a115fc1430ae0478f2acef2dec.tar.gz archsetup-f1c661f50d6af2a115fc1430ae0478f2acef2dec.zip | |
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.
46 files changed, 14900 insertions, 41 deletions
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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — levels casting: detent fader + single drum</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .page-cap { width: 100%; text-align: center; color: var(--dim); font-size: 12px; letter-spacing: 0.04em; max-width: 560px; } + .page-cap b { color: var(--gold); } + .row { display: flex; justify-content: center; align-items: center; gap: 14px; flex-wrap: wrap; } + .readout { text-align: center; color: var(--steel); font-size: 11px; letter-spacing: 0.1em; min-height: 15px; } + .readout b { color: var(--gold-hi); font-weight: 400; } +</style> +</head> +<body class="ds-stage"> + <div class="page-cap"> + <b>Levels casting.</b> 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. + </div> + + <div class="ds-panel" style="width:320px"> + <div class="ds-face"><span class="glyph">⚙</span><span class="title">Levels</span></div> + + <div class="ds-engrave">Brightness — detent fader (new)</div> + <div class="row" id="fader-well"></div> + <div class="readout" id="ro-b"></div> + + <div class="ds-engrave">Keyboard — drum roller ×10 (upgraded)</div> + <div class="row" id="drum-well"></div> + <div class="readout" id="ro-k"></div> + + <div class="ds-toast" id="toast"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +DS.bindToast(document.getElementById("toast")); +let booting = true; + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function render() { + booting = true; + + const fw = document.getElementById("fader-well"); + fw.innerHTML = ""; + const roB = document.getElementById("ro-b"); + const commitB = debounce((v) => { + const pct = Math.max(DS.SLIDERS.brightness.floor, v); + if (pct !== DS.state.brightness) DS.setSlider("brightness", pct, fw); + }, 350); + DUPRE.detentFader(fw, { + value: DS.state.brightness, + detents: [25, 50, 75], + onChange: (v, label) => { + roB.innerHTML = "brightness: <b>" + v + "%</b>" + (label.includes("detent") ? " · <b>parked</b>" : ""); + if (!booting) commitB(v); + }, + }); + + const dw = document.getElementById("drum-well"); + dw.innerHTML = ""; + const roK = document.getElementById("ro-k"); + const commitK = debounce((vals) => { + const pct = Math.round(vals[0]) * 10; + if (pct !== DS.state.kbd) DS.setSlider("kbd", pct, dw); + }, 350); + DUPRE.drumRoller(dw, { + title: "KBD BACKLIGHT ×10", + channels: [{ name: "KBD", v: Math.round(DS.state.kbd / 10) }], + min: 0, + max: 10, + onChange: (vals) => { + roK.innerHTML = "keyboard: <b>" + Math.round(vals[0]) * 10 + "%</b>"; + if (!booting) commitK(vals); + }, + }); + + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 10 (spacecraft DSKY, Dupre Kit)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .row { display: flex; justify-content: center; align-items: flex-start; gap: 10px; flex-wrap: wrap; } + .dupre-rotsel, .dupre-keylock { display: inline-block; } + .cellcap { color: var(--steel); font-size: 8.5px; letter-spacing: 0.1em; text-align: center; margin-top: 3px; text-transform: uppercase; } + .swgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } + .swcell { display: flex; flex-direction: column; align-items: center; gap: 2px; } + .caution-well { pointer-events: none; } + .verbcard { color: var(--dim); font-size: 9px; line-height: 1.5; text-align: center; } + .verbcard b { color: var(--gold); font-weight: 400; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Direction 10 — Spacecraft DSKY (Dupre Kit).</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const TOGGLES = [ + ["autodim", "DIM"], ["caffeine", "CAFF"], ["touchpad", "TPAD"], ["mouse", "MOUSE"], + ["nightlight", "NIGHT"], ["dnd", "DND"], ["weathergeo", "WXGEO"], ["airplane", "AIR"], +]; +let booting = true; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + const rows = TOGGLES.filter(([k]) => k !== "airplane" || DS.state.laptop); + + // Caution board: display-only mirror of the toggle states (amber = on). + col.appendChild(DS.engrave("Caution board")); + const annWell = h("div", "caution-well"); + DUPRE.annunciator(annWell, { + cells: rows.map(([k, label]) => [label, DS.state[k] ? 1 : 0]), + }); + const bar = annWell.querySelector(".dupre-annbar"); + if (bar) bar.style.display = "none"; + col.appendChild(annWell); + + // Toggle switches: one slide switch per system. + col.appendChild(DS.engrave("Systems")); + const grid = h("div", "swgrid"); + rows.forEach(([k, label]) => { + const cell = h("div", "swcell"); + const host = h("div"); + DUPRE.slideToggle(host, { + on: DS.state[k], + preset: k === "airplane" ? "armed" : "panel", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, cell); }, + }); + cell.appendChild(host); + cell.appendChild(h("div", "cellcap", label)); + grid.appendChild(cell); + }); + col.appendChild(grid); + + // Levels: the thumb-slide attenuator (Craig favorite #2). + col.appendChild(DS.engrave("Levels")); + const lvRow = h("div", "row"); + const tsHost = h("div"); + const commit = debounce((vals) => { + if (vals[0] !== DS.state.brightness) DS.setSlider("brightness", vals[0], tsHost); + if (vals[1] !== DS.state.kbd) DS.setSlider("kbd", vals[1], tsHost); + }, 350); + DUPRE.thumbSlide(tsHost, { + channels: [ + { name: "BRIGHT", v: DS.state.brightness }, + { name: "KBD", v: DS.state.kbd }, + ], + onChange: (vals) => { if (!booting) commit(vals.slice()); }, + }); + lvRow.appendChild(tsHost); + col.appendChild(lvRow); + + // Scenes: commanded through the DSKY verb grammar. + col.appendChild(DS.engrave("Command — scenes")); + const dHost = h("div"); + DUPRE.dsky(dHost, { + onChange: (vals, label) => { + if (booting) return; + const m = /^V(\d\d) ENTR$/.exec(label); + if (!m) return; + const code = +m[1]; + if (code === 0) sceneOff(); + else if (code >= 1 && code <= 4) DS.applyScene(SCENES[code - 1], dHost); + else DS.toast("V" + m[1] + ": no such program", false); + }, + }); + col.appendChild(dHost); + const card = h("div", "verbcard"); + card.innerHTML = + "<b>V01</b> FOCUS · <b>V02</b> PRESENTATION · <b>V03</b> BATTERY · <b>V04</b> NIGHT<br>" + + "<b>V00</b> scene off · <b>V35</b> lamp test"; + col.appendChild(card); + + // Power: keyed mode switch. + col.appendChild(DS.engrave("Power mode")); + const pRow = h("div", "row"); + const kHost = h("div"); + DUPRE.keySwitch(kHost, { + items: ["PERF", "BAL", "SAVER"], + index: DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile), + onChange: (i) => { if (!booting) DS.setProfile(DS.PROFILES[i].id, kHost); }, + }); + pRow.appendChild(kHost); + col.appendChild(pRow); + + // Actions: arm-to-fire. + col.appendChild(DS.engrave("Actions")); + const aRow = h("div", "row"); + [["LOCK", () => DS.momentary("Lock")], + ["SUSPEND", () => DS.momentary("Suspend")], + ["WALLPAPER", openWallpaper]].forEach(([label, fn]) => { + const host = h("div"); + DUPRE.armButton(host, { + label, + armLabel: label + "?", + onChange: (st) => { if (!booting && st === "fired") fn(); }, + }); + aRow.appendChild(host); + }); + col.appendChild(aRow); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 11 (mixing desk, Dupre Kit)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .row { display: flex; justify-content: center; align-items: flex-start; gap: 10px; flex-wrap: wrap; } + .dupre-rotsel, .dupre-keylock { display: inline-block; } + .cellcap { color: var(--steel); font-size: 8.5px; letter-spacing: 0.12em; text-align: center; margin-top: 2px; text-transform: uppercase; } + .fader-cell { display: flex; flex-direction: column; align-items: center; } + .chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; } + .vu-well { display: flex; justify-content: center; padding: 2px 0 6px; } + .stomp-cell { display: flex; flex-direction: column; align-items: center; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Direction 11 — Mixing desk (Dupre Kit).</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const BANK = ["OFF", "FOCUS", "PRESNT", "BATT", "NIGHT"]; +const CHIPS = [ + ["autodim", "DIM"], ["caffeine", "CAFF"], ["touchpad", "TPAD"], ["mouse", "MOUSE"], + ["nightlight", "NIGHT"], ["weathergeo", "WXGEO"], ["airplane", "AIR"], +]; +const dbOf = (pct) => -24 + (pct / 100) * 36; +const pctOf = (db) => Math.round(((db + 24) / 36) * 100); +let booting = true; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // VU bridge + channel faders. + col.appendChild(DS.engrave("Channel strips — levels")); + const vuWell = h("div", "vu-well"); + const vu = DUPRE.vuPair(vuWell, {}); + vu.set(DS.state.brightness / 100, DS.state.kbd / 100); + col.appendChild(vuWell); + + const fRow = h("div", "row"); + [["brightness", "BRIGHT", 0], ["kbd", "KBD", 1]].forEach(([key, label, ch]) => { + const cell = h("div", "fader-cell"); + const host = h("div"); + const commit = debounce((db) => { + const pct = Math.max(DS.SLIDERS[key].floor, pctOf(db)); + if (pct !== DS.state[key]) DS.setSlider(key, pct, cell); + }, 350); + DUPRE.slotFader(host, { + value: dbOf(DS.state[key]), + onChange: (db) => { + if (booting) return; + const l = ch === 0 ? Math.max(0, Math.min(1, pctOf(db) / 100)) : DS.state.brightness / 100; + const r = ch === 1 ? Math.max(0, Math.min(1, pctOf(db) / 100)) : DS.state.kbd / 100; + vu.set(l, r); + commit(db); + }, + }); + cell.appendChild(host); + cell.appendChild(h("div", "cellcap", label)); + fRow.appendChild(cell); + }); + col.appendChild(fRow); + + // Scenes: punch a preset. + col.appendChild(DS.engrave("Presets — scenes")); + const pbHost = h("div"); + DUPRE.presetBank(pbHost, { + items: BANK, + active: DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0, + onChange: (i) => { + if (booting) return; + if (i === 0) sceneOff(); + else DS.applyScene(SCENES[i - 1], pbHost); + }, + }); + col.appendChild(pbHost); + + // Toggles: lit chips; DND gets the stomp pedal. + col.appendChild(DS.engrave("Mutes — toggles")); + const chips = h("div", "chips"); + CHIPS.filter(([k]) => k !== "airplane" || DS.state.laptop).forEach(([k, label]) => { + const host = h("span"); + DUPRE.chipToggle(host, { + label, + on: DS.state[k], + accent: k === "airplane" ? "red" : "amber", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + chips.appendChild(host); + }); + col.appendChild(chips); + + const sRow = h("div", "row"); + const sCell = h("div", "stomp-cell"); + const sHost = h("div"); + DUPRE.stompSwitch(sHost, { + on: DS.state.dnd, + onChange: (on) => { if (!booting && on !== DS.state.dnd) DS.setToggle("dnd", sCell); }, + }); + sCell.appendChild(sHost); + sCell.appendChild(h("div", "cellcap", "DND — stomp to silence")); + sRow.appendChild(sCell); + + // Power beside the pedal: rotary selector. + const pCell = h("div", "stomp-cell"); + const rHost = h("div"); + DUPRE.rotarySelector(rHost, { + values: ["PERF", "BAL", "SAVER"], + index: DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile), + onChange: (v) => { + if (booting) return; + const i = ["PERF", "BAL", "SAVER"].indexOf(v); + if (i >= 0) DS.setProfile(DS.PROFILES[i].id, pCell); + }, + }); + pCell.appendChild(rHost); + pCell.appendChild(h("div", "cellcap", "POWER")); + sRow.appendChild(pCell); + col.appendChild(sRow); + + // Actions: arm-to-fire. + col.appendChild(DS.engrave("Actions")); + const aRow = h("div", "row"); + [["LOCK", () => DS.momentary("Lock")], + ["SUSPEND", () => DS.momentary("Suspend")], + ["WALLPAPER", openWallpaper]].forEach(([label, fn]) => { + const host = h("div"); + DUPRE.armButton(host, { + label, + armLabel: label + "?", + onChange: (st) => { if (!booting && st === "fired") fn(); }, + }); + aRow.appendChild(host); + }); + col.appendChild(aRow); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 12 (the casting composite)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + /* receiver plate: brushed texture behind each section (direction 6 chrome) */ + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .row { display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap; } + .row-split { display: flex; justify-content: space-around; align-items: flex-start; } + .cellcap { color: var(--steel); font-size: 8.5px; letter-spacing: 0.14em; text-align: center; margin-top: 2px; text-transform: uppercase; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .dupre-rotsel, .dupre-keylock { display: inline-block; } + /* kit spans need a blockifying host outside the gallery */ + .khost { display: flex; justify-content: center; } + + /* tuner well + presets (direction 6, over the kit slideRule) */ + .tuner-well { display: flex; justify-content: center; padding: 4px 0 6px; } + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 6px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 5px 2px; text-align: center; cursor: pointer; color: var(--silver); + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 8px; color: var(--dim); display: block; } + .preset .nm { font-size: 9.5px; letter-spacing: 0.04em; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* fader line: label left, fader flexing, readout right */ + .fline { display: flex; align-items: center; gap: 8px; } + .fline .fname { color: var(--silver); font-size: 11px; width: 58px; } + .fline .fpct { color: var(--cream); font-size: 12px; width: 38px; text-align: right; font-variant-numeric: tabular-nums; } + .fline .fhost { flex: 1; display: flex; } + .fline .fhost .dupre-dfader { width: 100%; } + + /* rocker bank */ + .rockers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 6px; justify-items: center; } + + /* wallpaper row (console key) */ + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 12 — the casting composite.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["OFF", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "Auto-dim"], ["touchpad", "Touchpad"], ["mouse", "Mouse"], + ["nightlight", "Night light"], ["dnd", "DND"], ["weathergeo", "Weather geo"], +]; +let booting = true; +let caffMinutes = 0; // survives re-renders + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- Scenes: the hero — tuner dial + preset keys ------------------------ + const scenesPlate = h("div", "plate"); + const tw = h("div", "tuner-well"); + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + // string stops: the builder's lit-mark compare is numeric-only; patch it + tw.querySelectorAll(".dupre-mk").forEach((m) => + m.classList.toggle("dupre-on", m.textContent === STOPS[sceneIdx()])); + scenesPlate.appendChild(tw); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + scenesPlate.appendChild(pr); + col.appendChild(scenesPlate); + + // ---- Brightness: full-width detent fader -------------------------------- + col.appendChild(DS.engrave("Brightness")); + const fl = h("div", "fline"); + fl.appendChild(h("span", "fname", "Screen")); + const fhost = h("span", "fhost"); + const fpct = h("span", "fpct", DS.state.brightness + "%"); + const commitB = debounce((v) => { + const pct = Math.max(DS.SLIDERS.brightness.floor, v); + if (pct !== DS.state.brightness) DS.setSlider("brightness", pct, fl); + }, 350); + DUPRE.detentFader(fhost, { + value: DS.state.brightness, + detents: [25, 50, 75], + onChange: (v) => { + fpct.textContent = v + "%"; + if (!booting) commitB(v); + }, + }); + fl.appendChild(fhost); + fl.appendChild(fpct); + col.appendChild(fl); + + // ---- Keyboard drum + power chicken-head --------------------------------- + col.appendChild(DS.engrave("Keyboard · Power")); + const duo = h("div", "row-split"); + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitK = debounce((vals) => { + const pct = Math.round(vals[0]) * 10; + if (pct !== DS.state.kbd) DS.setSlider("kbd", pct, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "BACKLIGHT ×10", + channels: [{ name: "KBD", v: Math.round(DS.state.kbd / 10) }], + min: 0, max: 10, + onChange: (vals) => { if (!booting) commitK(vals); }, + }); + dcell.appendChild(dhost); + duo.appendChild(dcell); + + const pcell = h("div", "vcell"); + const phost = h("div", "khost"); + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.chickenHead(phost, { + items: [["PERF", -60], ["BAL", 0], ["SAVER", 60]], + index: pIdx, + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, pcell); }, + }); + pcell.appendChild(phost); + pcell.appendChild(h("div", "cellcap", "power")); + duo.appendChild(pcell); + col.appendChild(duo); + + // ---- Caffeine: the wind-up timer --------------------------------------- + col.appendChild(DS.engrave("Caffeine — wind to stay awake")); + const trow = h("div", "row"); + const thost = h("div", "khost"); + DUPRE.timerDial(thost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + const on = min > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", thost); + }, + }); + trow.appendChild(thost); + col.appendChild(trow); + + // ---- Toggle bank: rockers ---------------------------------------------- + col.appendChild(DS.engrave("Switches")); + const bank = h("div", "rockers"); + ROCKERS.forEach(([k, name]) => { + const cell = h("div", "vcell"); + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, cell); }, + }); + cell.appendChild(host); + cell.appendChild(h("div", "cellcap", name)); + bank.appendChild(cell); + }); + col.appendChild(bank); + + // ---- Airplane: the guarded lever (laptop-only) -------------------------- + if (DS.state.laptop) { + const grow = h("div", "row"); + const gcell = h("div", "vcell"); + const ghost = h("div", "khost"); + DUPRE.guardedToggle(ghost, { + on: DS.state.airplane, + onLabel: "AIRPLANE", + offLabel: "RADIO OK", + onChange: (on) => { if (!booting && on !== DS.state.airplane) DS.setToggle("airplane", gcell); }, + }); + gcell.appendChild(ghost); + grow.appendChild(gcell); + col.appendChild(grow); + } + + // ---- Wallpaper: sub-view row ------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 13 (P12 + Craig's iteration)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; } + .cellcap { color: var(--steel); font-size: 8.5px; letter-spacing: 0.14em; text-align: center; margin-top: 2px; text-transform: uppercase; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + + /* top block: presets over the tuner, compact chicken-head upper right */ + .topgrid { display: flex; gap: 8px; align-items: stretch; } + .topleft { flex: 1; display: flex; flex-direction: column; gap: 8px; } + .topright { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; } + .tuner-well { display: flex; justify-content: center; } + + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 5px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 4px 1px; text-align: center; cursor: pointer; color: var(--silver); + overflow: hidden; + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 7.5px; color: var(--dim); display: block; letter-spacing: 0; } + .preset .nm { font-size: 8.5px; letter-spacing: 0; display: block; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* levels + caffeine block: drums left, big timer right */ + .midgrid { display: flex; gap: 8px; align-items: flex-start; justify-content: center; } + + /* switch wall: six cells, airplane's guarded lever among them */ + .wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 6px; justify-items: center; align-items: end; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 13 — P12 + Craig's iteration.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["OFF", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "Auto-dim"], ["touchpad", "Touchpad"], ["mouse", "Mouse"], + ["nightlight", "Night light"], ["dnd", "DND"], +]; +let booting = true; +let caffMinutes = 0; // survives re-renders +let caffHold = false; // the always-on rocker +let timerHost = null; // the timerDial is PERSISTENT: it owns a live + // countdown interval, so re-renders reparent it + // instead of rebuilding (rebuilds leak ghost + // intervals and kill an in-progress wind drag) + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +// caffeine is on when held-on OR wound; keep DS in sync with that truth. +// Debounced: the sync triggers a panel re-render, which must not happen +// mid-wind (it would yank the dial out from under the drag). +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- top: presets over the tuner; compact chicken-head upper right ----- + const top = h("div", "plate"); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + top.appendChild(pr); + const grid = h("div", "topgrid"); + grid.style.marginTop = "8px"; + const left = h("div", "topleft"); + + const tw = h("div", "tuner-well"); + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + tw.querySelectorAll(".dupre-mk").forEach((m) => + m.classList.toggle("dupre-on", m.textContent === STOPS[sceneIdx()])); + // the kit tuner's fixed width clips the NIGHT stop's label; give it room + tw.querySelector(".dupre-tuner").style.width = "198px"; + left.appendChild(tw); + grid.appendChild(left); + + const right = h("div", "topright"); + const phost = h("div", "khost"); + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.chickenHead(phost, { + items: [["PERF", -60], ["BAL", 0], ["SAVER", 60]], + index: pIdx, + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, right); }, + }); + scaleSvg(phost, 78); + right.appendChild(phost); + right.appendChild(h("div", "cellcap", "power")); + grid.appendChild(right); + top.appendChild(grid); + col.appendChild(top); + + // ---- middle: drum partners left, larger caffeine timer right ------------ + col.appendChild(DS.engrave("Levels · Caffeine")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 189, // drum top flush with the timer dial, bottom with the HOLD rocker + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 178); + } + tcell.appendChild(timerHost); + const holdHost = h("div", "khost"); + DUPRE.rocker(holdHost, { + on: caffHold, + onLabel: "HOLD", + offLabel: "TIMED", + onChange: (on) => { + if (booting) return; + caffHold = on; + syncCaffeine(holdHost); + }, + }); + tcell.appendChild(holdHost); + tcell.appendChild(h("div", "cellcap", "always on")); + mid.appendChild(tcell); + col.appendChild(mid); + + // ---- switch wall: five rockers + the guarded airplane lever ------------- + col.appendChild(DS.engrave("Switches")); + const wall = h("div", "wall"); + ROCKERS.forEach(([k, name]) => { + const cell = h("div", "vcell"); + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, cell); }, + }); + cell.appendChild(host); + cell.appendChild(h("div", "cellcap", name)); + wall.appendChild(cell); + }); + if (DS.state.laptop) { + const cell = h("div", "vcell"); + const host = h("div", "khost"); + DUPRE.guardedToggle(host, { + on: DS.state.airplane, + onLabel: "AIRPLANE", + offLabel: "RADIO OK", + onChange: (on) => { if (!booting && on !== DS.state.airplane) DS.setToggle("airplane", cell); }, + }); + scaleSvg(host, 72); + cell.appendChild(host); + wall.appendChild(cell); + } + col.appendChild(wall); + + // ---- wallpaper: where it was ------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 14 (timer bat, centered dial)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; } + .cellcap { color: var(--steel); font-size: 8.5px; letter-spacing: 0.14em; text-align: center; margin-top: 2px; text-transform: uppercase; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .tcell { position: relative; } + .bat-oly { position: absolute; top: -8px; right: -10px; } + .bat-oly svg { width: 54px; height: 69px; } + + /* top block: presets over the tuner, compact chicken-head upper right */ + .topgrid { display: flex; gap: 8px; align-items: stretch; } + .topleft { flex: 1; display: flex; flex-direction: column; gap: 8px; } + .topright { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; } + .tuner-well { display: flex; justify-content: center; } + + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 5px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 4px 1px; text-align: center; cursor: pointer; color: var(--silver); + overflow: hidden; + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 7.5px; color: var(--dim); display: block; letter-spacing: 0; } + .preset .nm { font-size: 8.5px; letter-spacing: 0; display: block; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* levels + caffeine block: drums left, big timer right */ + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + + /* switch wall: six cells, airplane's guarded lever among them */ + .wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 6px; justify-items: center; align-items: end; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 14 — P13 + timer refinements.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["OFF", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "Auto-dim"], ["touchpad", "Touchpad"], ["mouse", "Mouse"], + ["nightlight", "Night light"], ["dnd", "DND"], +]; +let booting = true; +let caffMinutes = 0; // survives re-renders +let caffHold = false; // the always-on rocker +let timerHost = null; // the timerDial is PERSISTENT: it owns a live + // countdown interval, so re-renders reparent it + // instead of rebuilding (rebuilds leak ghost + // intervals and kill an in-progress wind drag) + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +// caffeine is on when held-on OR wound; keep DS in sync with that truth. +// Debounced: the sync triggers a panel re-render, which must not happen +// mid-wind (it would yank the dial out from under the drag). +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- top: presets over the tuner; compact chicken-head upper right ----- + const top = h("div", "plate"); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + top.appendChild(pr); + const grid = h("div", "topgrid"); + grid.style.marginTop = "8px"; + const left = h("div", "topleft"); + + const tw = h("div", "tuner-well"); + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + tw.querySelectorAll(".dupre-mk").forEach((m) => + m.classList.toggle("dupre-on", m.textContent === STOPS[sceneIdx()])); + // the kit tuner's fixed width clips the NIGHT stop's label; give it room + tw.querySelector(".dupre-tuner").style.width = "198px"; + left.appendChild(tw); + grid.appendChild(left); + + const right = h("div", "topright"); + const phost = h("div", "khost"); + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.chickenHead(phost, { + items: [["PERF", -60], ["BAL", 0], ["SAVER", 60]], + index: pIdx, + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, right); }, + }); + scaleSvg(phost, 78); + right.appendChild(phost); + right.appendChild(h("div", "cellcap", "power")); + grid.appendChild(right); + top.appendChild(grid); + col.appendChild(top); + + // ---- middle: drum partners left, larger caffeine timer right ------------ + col.appendChild(DS.engrave("Levels · Caffeine")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 189, // tall drums; the timer centers against them + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 178); + // the bat toggle takes the winding arrow's corner + const svg = timerHost.querySelector("svg"); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); + } + tcell.appendChild(timerHost); + const batHost = h("div", "bat-oly"); + DUPRE.batToggle(batHost, { + on: !caffHold, // up = TIMED, down = HOLD + onLabel: "TIMED", + offLabel: "HOLD", + onChange: (on) => { + if (booting) return; + caffHold = !on; + syncCaffeine(batHost); + }, + }); + tcell.appendChild(batHost); + mid.appendChild(tcell); + col.appendChild(mid); + + // ---- switch wall: five rockers + the guarded airplane lever ------------- + col.appendChild(DS.engrave("Switches")); + const wall = h("div", "wall"); + ROCKERS.forEach(([k, name]) => { + const cell = h("div", "vcell"); + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, cell); }, + }); + cell.appendChild(host); + cell.appendChild(h("div", "cellcap", name)); + wall.appendChild(cell); + }); + if (DS.state.laptop) { + const cell = h("div", "vcell"); + const host = h("div", "khost"); + DUPRE.guardedToggle(host, { + on: DS.state.airplane, + onLabel: "AIRPLANE", + offLabel: "RADIO OK", + onChange: (on) => { if (!booting && on !== DS.state.airplane) DS.setToggle("airplane", cell); }, + }); + scaleSvg(host, 72); + cell.appendChild(host); + wall.appendChild(cell); + } + col.appendChild(wall); + + // ---- wallpaper: where it was ------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 15 (power slide, full-width tuner)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; } + .cellcap { color: var(--steel); font-size: 8.5px; letter-spacing: 0.14em; text-align: center; margin-top: 2px; text-transform: uppercase; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .tcell { position: relative; } + .bat-oly { position: absolute; top: -8px; right: -10px; } + .bat-oly svg { width: 54px; height: 69px; } + + /* top block: presets over the tuner, compact chicken-head upper right */ + .topgrid { display: flex; gap: 8px; align-items: stretch; } + .topleft { flex: 1; display: flex; flex-direction: column; gap: 8px; } + .topright { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; } + .tuner-well { display: flex; justify-content: center; } + + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 5px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 4px 1px; text-align: center; cursor: pointer; color: var(--silver); + overflow: hidden; + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 7.5px; color: var(--dim); display: block; letter-spacing: 0; } + .preset .nm { font-size: 8.5px; letter-spacing: 0; display: block; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* levels + caffeine block: drums left, big timer right */ + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + + /* switch wall: six cells, airplane's guarded lever among them */ + .wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px 6px; justify-items: center; align-items: end; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 15 — P14 + power slide.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["OFF", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "Auto-dim"], ["touchpad", "Touchpad"], ["mouse", "Mouse"], + ["nightlight", "Night light"], ["dnd", "DND"], +]; +let booting = true; +let caffMinutes = 0; // survives re-renders +let caffHold = false; // the always-on rocker +let timerHost = null; // the timerDial is PERSISTENT: it owns a live + // countdown interval, so re-renders reparent it + // instead of rebuilding (rebuilds leak ghost + // intervals and kill an in-progress wind drag) + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +// caffeine is on when held-on OR wound; keep DS in sync with that truth. +// Debounced: the sync triggers a panel re-render, which must not happen +// mid-wind (it would yank the dial out from under the drag). +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- top: presets over the tuner; compact chicken-head upper right ----- + const top = h("div", "plate"); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + top.appendChild(pr); + + const tw = h("div", "tuner-well"); + tw.style.marginTop = "8px"; + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + width: 306, // restored to the plate's full width + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + top.appendChild(tw); + + const pw = h("div", "khost"); + pw.style.marginTop = "8px"; + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.slideSelector(pw, { + items: ["PERFORMANCE", "BALANCE", "POWER SAVE"], + index: pIdx, + width: 306, // equal to the tuner's length + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, pw); }, + }); + top.appendChild(pw); + col.appendChild(top); + + // ---- middle: drum partners left, larger caffeine timer right ------------ + col.appendChild(DS.engrave("Levels · Caffeine")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 189, // tall drums; the timer centers against them + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 178); + // the bat toggle takes the winding arrow's corner + const svg = timerHost.querySelector("svg"); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); + } + tcell.appendChild(timerHost); + const batHost = h("div", "bat-oly"); + DUPRE.batToggle(batHost, { + on: !caffHold, // up = TIMED, down = HOLD + onLabel: "TIMED", + offLabel: "HOLD", + onChange: (on) => { + if (booting) return; + caffHold = !on; + syncCaffeine(batHost); + }, + }); + tcell.appendChild(batHost); + mid.appendChild(tcell); + col.appendChild(mid); + + // ---- switch wall: five rockers + the guarded airplane lever ------------- + col.appendChild(DS.engrave("Switches")); + const wall = h("div", "wall"); + ROCKERS.forEach(([k, name]) => { + const cell = h("div", "vcell"); + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, cell); }, + }); + cell.appendChild(host); + cell.appendChild(h("div", "cellcap", name)); + wall.appendChild(cell); + }); + if (DS.state.laptop) { + const cell = h("div", "vcell"); + const host = h("div", "khost"); + DUPRE.guardedToggle(host, { + on: DS.state.airplane, + onLabel: "AIRPLANE", + offLabel: "RADIO OK", + onChange: (on) => { if (!booting && on !== DS.state.airplane) DS.setToggle("airplane", cell); }, + }); + scaleSvg(host, 72); + cell.appendChild(host); + wall.appendChild(cell); + } + col.appendChild(wall); + + // ---- wallpaper: where it was ------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 16 (rebalanced: quiet chassis)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + /* one chassis grid: every section on an identical plate, identical gaps */ + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .tuner-well { display: flex; justify-content: center; margin-top: 8px; } + + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 5px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 4px 1px; text-align: center; cursor: pointer; color: var(--silver); + overflow: hidden; + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 7.5px; color: var(--dim); display: block; letter-spacing: 0; } + .preset .nm { font-size: 8.5px; letter-spacing: 0; display: block; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* levels + caffeine: slides left, tamed timer right, bat docked on stage */ + .midgrid { display: flex; gap: 6px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .bat-oly { position: absolute; top: -2px; right: 0; } + .bat-oly svg { width: 52px; height: 67px; } + + /* switch wall: name-on-switch rockers + the airplane lever, one baseline */ + .wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 6px; justify-items: center; align-items: center; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 16 — rebalanced.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["OFF", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "DIM"], ["touchpad", "TPAD"], ["mouse", "MOUSE"], + ["nightlight", "NIGHT"], ["dnd", "DND"], +]; +let booting = true; +let caffMinutes = 0; +let caffHold = false; +let timerHost = null; // persistent: the timer owns a live countdown + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// de-throne the stock timer: steel stop knob, amber index/OFF, one engraving +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p16Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p16Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "WIND · PUSH STOPS"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: head unit — presets, tuner, power slide ------------------- + const head = h("div", "plate"); + head.appendChild(DS.engrave("Scenes · Power")); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + head.appendChild(pr); + + const tw = h("div", "tuner-well"); + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + width: 306, + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + head.appendChild(tw); + + const pw = h("div", "khost"); + pw.style.marginTop = "8px"; + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.slideSelector(pw, { + items: ["PERFORMANCE", "BALANCE", "POWER SAVE"], + index: pIdx, + width: 306, + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, pw); }, + }); + head.appendChild(pw); + col.appendChild(head); + + // ---- plate 2: levels + caffeine ----------------------------------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Caffeine")); + const mid = h("div", "midgrid"); + + const scell = h("div", "vcell"); + const shost = h("div", "khost"); + const commitLv = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0])); + const k = Math.round(vals[1]); + if (b !== DS.state.brightness) DS.setSlider("brightness", b, scell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, scell); + }, 350); + DUPRE.thumbSlide(shost, { + channels: [ + { name: "SCREEN", v: DS.state.brightness }, + { name: "KBD", v: DS.state.kbd }, + ], + onChange: (vals) => { if (!booting) commitLv(vals.slice()); }, + }); + scell.appendChild(shost); + mid.appendChild(scell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 158); + tameTimer(timerHost); + // the bat docks where the winding arrow was + const svg = timerHost.querySelector("svg"); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); + } + tcell.appendChild(timerHost); + const batHost = h("div", "bat-oly"); + DUPRE.batToggle(batHost, { + on: !caffHold, + onLabel: "TIMED", + offLabel: "HOLD", + onChange: (on) => { + if (booting) return; + caffHold = !on; + syncCaffeine(batHost); + }, + }); + tcell.appendChild(batHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- plate 3: switch wall ----------------------------------------------- + const sw = h("div", "plate"); + sw.appendChild(DS.engrave("Switches")); + const wall = h("div", "wall"); + ROCKERS.forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + wall.appendChild(host); + }); + if (DS.state.laptop) { + const host = h("div", "khost"); + DUPRE.guardedToggle(host, { + on: DS.state.airplane, + onLabel: "AIR", + offLabel: "SAFE", + onChange: (on) => { if (!booting && on !== DS.state.airplane) DS.setToggle("airplane", host); }, + }); + scaleSvg(host, 64); + wall.appendChild(host); + } + sw.appendChild(wall); + col.appendChild(sw); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 17 (switches under their presets)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + /* one chassis grid: every section on an identical plate, identical gaps */ + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .tuner-well { display: flex; justify-content: center; margin-top: 8px; } + + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 5px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 4px 1px; text-align: center; cursor: pointer; color: var(--silver); + overflow: hidden; + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 7.5px; color: var(--dim); display: block; letter-spacing: 0; } + .preset .nm { font-size: 8.5px; letter-spacing: 0; display: block; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* levels + caffeine: slides left, tamed timer right, bat docked on stage */ + .midgrid { display: flex; gap: 6px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .bat-oly { position: absolute; top: -2px; right: 0; } + .bat-oly svg { width: 52px; height: 67px; } + + /* switch wall: name-on-switch rockers + the airplane lever, one baseline */ + .wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 6px; justify-items: center; align-items: center; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 17 — switches under their presets.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["OFF", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "DIM"], ["touchpad", "TPAD"], ["mouse", "MOUSE"], + ["nightlight", "NIGHT"], ["dnd", "DND"], +]; +let booting = true; +let caffMinutes = 0; +let caffHold = false; +let timerHost = null; // persistent: the timer owns a live countdown + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// de-throne the stock timer: steel stop knob, amber index/OFF, one engraving +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p16Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p16Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "WIND · PUSH STOPS"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: head unit — presets, tuner, power slide ------------------- + const head = h("div", "plate"); + head.appendChild(DS.engrave("Scenes")); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + head.appendChild(pr); + + const tw = h("div", "tuner-well"); + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + width: 306, + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + head.appendChild(tw); + col.appendChild(head); + + // ---- plate 2: the switches those presets program, power slide beneath --- + const sw = h("div", "plate"); + sw.appendChild(DS.engrave("Switches · Power")); + const wall = h("div", "wall"); + ROCKERS.forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + wall.appendChild(host); + }); + if (DS.state.laptop) { + const host = h("div", "khost"); + DUPRE.guardedToggle(host, { + on: DS.state.airplane, + onLabel: "AIR", + offLabel: "SAFE", + onChange: (on) => { if (!booting && on !== DS.state.airplane) DS.setToggle("airplane", host); }, + }); + scaleSvg(host, 64); + wall.appendChild(host); + } + sw.appendChild(wall); + + const pw = h("div", "khost"); + pw.style.marginTop = "10px"; + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.slideSelector(pw, { + items: ["PERFORMANCE", "BALANCE", "POWER SAVE"], + index: pIdx, + width: 306, + knob: "dark", + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, pw); }, + }); + sw.appendChild(pw); + col.appendChild(sw); + + // ---- plate 2: levels + caffeine ----------------------------------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Caffeine")); + const mid = h("div", "midgrid"); + + const scell = h("div", "vcell"); + const shost = h("div", "khost"); + const commitLv = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0])); + const k = Math.round(vals[1]); + if (b !== DS.state.brightness) DS.setSlider("brightness", b, scell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, scell); + }, 350); + DUPRE.thumbSlide(shost, { + channels: [ + { name: "SCREEN", v: DS.state.brightness }, + { name: "KBD", v: DS.state.kbd }, + ], + onChange: (vals) => { if (!booting) commitLv(vals.slice()); }, + }); + scell.appendChild(shost); + mid.appendChild(scell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 158); + tameTimer(timerHost); + // the bat docks where the winding arrow was + const svg = timerHost.querySelector("svg"); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); + } + tcell.appendChild(timerHost); + const batHost = h("div", "bat-oly"); + DUPRE.batToggle(batHost, { + on: !caffHold, + onLabel: "TIMED", + offLabel: "HOLD", + onChange: (on) => { + if (booting) return; + caffHold = !on; + syncCaffeine(batHost); + }, + }); + tcell.appendChild(batHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 18 (program chart, real proportions)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + /* one chassis grid: every section on an identical plate, identical gaps */ + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .tuner-well { display: flex; justify-content: center; margin-top: 8px; } + + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 5px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 4px 1px; text-align: center; cursor: pointer; color: var(--silver); + overflow: hidden; + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 7.5px; color: var(--dim); display: block; letter-spacing: 0; } + .preset .nm { font-size: 8.5px; letter-spacing: 0; display: block; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* levels + caffeine: slides left, tamed timer right, bat docked on stage */ + .midgrid { display: flex; gap: 6px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .bat-oly { position: absolute; top: -2px; right: -4px; } + .bat-oly svg { width: 46px; height: 59px; } + + /* switch wall: name-on-switch rockers + the airplane lever, one baseline */ + .wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 6px; justify-items: center; align-items: center; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 18 — the program chart.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["CUSTOM", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "DIM"], ["touchpad", "TPAD"], ["mouse", "MOUSE"], + ["nightlight", "NIGHT"], ["dnd", "DND"], +]; +let booting = true; +let caffMinutes = 0; +let caffHold = false; +let timerHost = null; // persistent: the timer owns a live countdown + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: custom", true); + render(); +} + +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// de-throne the stock timer: steel stop knob, amber index/OFF, one engraving +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p16Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p16Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "WIND · PUSH STOPS"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: head unit — presets, tuner, power slide ------------------- + const head = h("div", "plate"); + head.appendChild(DS.engrave("Scenes")); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + head.appendChild(pr); + + const tw = h("div", "tuner-well"); + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + width: 306, + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "CUSTOM") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + head.appendChild(tw); + + // the program chart: what each preset recalls, printed on the plate + const chartHost = h("div", "khost"); + chartHost.style.marginTop = "8px"; + const CHART_COLS = ["DIM", "NIGHT", "DND", "CAFF", "PWR"]; + const PWR_LETTER = { performance: "P", balanced: "B", saver: "S" }; + DUPRE.programChart(chartHost, { + cols: CHART_COLS, + width: 296, + rows: SCENES.map((n) => { + const sets = DS.SCENES[n].sets; + const dot = (k) => (k in sets ? !!sets[k] : null); + return { name: n, cells: [dot("autodim"), dot("nightlight"), dot("dnd"), dot("caffeine"), PWR_LETTER[sets.powerprofile] || null] }; + }), + active: sceneIdx() - 1, + }); + head.appendChild(chartHost); + col.appendChild(head); + + // ---- plate 2: the switches those presets program, power slide beneath --- + const sw = h("div", "plate"); + sw.appendChild(DS.engrave("Switches · Power")); + const wall = h("div", "wall"); + ROCKERS.forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + wall.appendChild(host); + }); + sw.appendChild(wall); + + const pw = h("div", "khost"); + pw.style.marginTop = "10px"; + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.slideSelector(pw, { + items: ["PERFORMANCE", "BALANCE", "POWER SAVE"], + index: pIdx, + width: 306, + knob: "dark", + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, pw); }, + }); + sw.appendChild(pw); + col.appendChild(sw); + + // ---- plate 2: levels + caffeine ----------------------------------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Caffeine")); + const mid = h("div", "midgrid"); + + const scell = h("div", "vcell"); + const shost = h("div", "khost"); + const commitLv = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0])); + const k = Math.round(vals[1]); + if (b !== DS.state.brightness) DS.setSlider("brightness", b, scell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, scell); + }, 350); + DUPRE.thumbSlide(shost, { + channels: [ + { name: "SCREEN", v: DS.state.brightness }, + { name: "KBD", v: DS.state.kbd }, + ], + onChange: (vals) => { if (!booting) commitLv(vals.slice()); }, + }); + scaleSvg(shost, 165); + scell.appendChild(shost); + mid.appendChild(scell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 137); + tameTimer(timerHost); + // the bat docks where the winding arrow was + const svg = timerHost.querySelector("svg"); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); + } + tcell.appendChild(timerHost); + const batHost = h("div", "bat-oly"); + DUPRE.batToggle(batHost, { + on: !caffHold, + onLabel: "TIMED", + offLabel: "HOLD", + onChange: (on) => { + if (booting) return; + caffHold = !on; + syncCaffeine(batHost); + }, + }); + tcell.appendChild(batHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 19 (punch-card chart, green switches)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + /* one chassis grid: every section on an identical plate, identical gaps */ + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .tuner-well { display: flex; justify-content: center; margin-top: 8px; } + + .presets { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 5px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 4px 1px; text-align: center; cursor: pointer; color: var(--silver); + overflow: hidden; + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 7.5px; color: var(--dim); display: block; letter-spacing: 0; } + .preset .nm { font-size: 8.5px; letter-spacing: 0; display: block; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* levels + caffeine: slides left, tamed timer right, bat docked on stage */ + .midgrid { display: flex; gap: 6px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .bat-oly { position: absolute; top: -2px; right: -4px; } + .bat-oly svg { width: 46px; height: 59px; } + + /* switch wall: name-on-switch rockers at real rocker size, green engage */ + .wall { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 6px; justify-items: center; align-items: center; } + .wall .dupre-rocker { width: 50px; height: 32px; } + .wall .dupre-rocker .dupre-half { font-size: 8px; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 19 — the chart becomes a punch card.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const STOPS = ["CUSTOM", "FOCUS", "PRES", "BATT", "NIGHT"]; +const ROCKERS = [ + ["autodim", "DIM"], ["touchpad", "TPAD"], ["mouse", "MOUSE"], + ["nightlight", "NIGHT"], ["dnd", "DND"], +]; +let booting = true; +let caffMinutes = 0; +let caffHold = false; +let timerHost = null; // persistent: the timer owns a live countdown + +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: custom", true); + render(); +} + +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// de-throne the stock timer: steel stop knob, amber index/OFF, one engraving +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p16Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p16Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "WIND · PUSH STOPS"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: head unit — presets, tuner, power slide ------------------- + const head = h("div", "plate"); + head.appendChild(DS.engrave("Scenes")); + const pr = h("div", "presets"); + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + head.appendChild(pr); + + const tw = h("div", "tuner-well"); + DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin: "warm", + width: 306, + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "CUSTOM") sceneOff(); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + head.appendChild(tw); + + // the program chart: what each preset recalls, printed on the plate + const chartHost = h("div", "khost"); + chartHost.style.marginTop = "8px"; + const CHART_COLS = ["DIM", "NIGHT", "DND", "CAFF", "PWR"]; + const PWR_LETTER = { performance: "P", balanced: "B", saver: "S" }; + const CHART_KEYS = ["autodim", "nightlight", "dnd", "caffeine"]; + const LETTER_PWR = { P: "performance", B: "balanced", S: "saver" }; + DUPRE.programChart(chartHost, { + cols: CHART_COLS, + width: 296, + editable: true, + rows: SCENES.map((n) => { + const sets = DS.SCENES[n].sets; + const dot = (k) => (k in sets ? !!sets[k] : null); + return { name: n, cells: [dot("autodim"), dot("nightlight"), dot("dnd"), dot("caffeine"), PWR_LETTER[sets.powerprofile] || null] }; + }), + active: sceneIdx() - 1, + onEdit: (ri, ci, v) => { + const sets = DS.SCENES[SCENES[ri]].sets; + if (ci < 4) { + if (v === null) delete sets[CHART_KEYS[ci]]; + else sets[CHART_KEYS[ci]] = v; + } else { + if (v === null) delete sets.powerprofile; + else sets.powerprofile = LETTER_PWR[v]; + } + DS.toast(SCENES[ri] + " program updated", true); + }, + }); + head.appendChild(chartHost); + col.appendChild(head); + + // ---- plate 2: the switches those presets program, power slide beneath --- + const sw = h("div", "plate"); + sw.appendChild(DS.engrave("Switches · Power")); + const wall = h("div", "wall"); + ROCKERS.forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + wall.appendChild(host); + }); + sw.appendChild(wall); + + const pw = h("div", "khost"); + pw.style.marginTop = "10px"; + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + DUPRE.slideSelector(pw, { + items: ["PERFORMANCE", "BALANCE", "POWER SAVE"], + index: pIdx, + width: 306, + knob: "dark", + onChange: (i) => { if (!booting && i !== pIdx) DS.setProfile(DS.PROFILES[i].id, pw); }, + }); + sw.appendChild(pw); + col.appendChild(sw); + + // ---- plate 2: levels + caffeine ----------------------------------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Caffeine")); + const mid = h("div", "midgrid"); + + const scell = h("div", "vcell"); + const shost = h("div", "khost"); + const commitLv = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0])); + const k = Math.round(vals[1]); + if (b !== DS.state.brightness) DS.setSlider("brightness", b, scell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, scell); + }, 350); + DUPRE.thumbSlide(shost, { + channels: [ + { name: "SCREEN", v: DS.state.brightness }, + { name: "KBD", v: DS.state.kbd }, + ], + onChange: (vals) => { if (!booting) commitLv(vals.slice()); }, + }); + scaleSvg(shost, 172); + scell.appendChild(shost); + mid.appendChild(scell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 130); + tameTimer(timerHost); + // the bat docks where the winding arrow was + const svg = timerHost.querySelector("svg"); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); + } + tcell.appendChild(timerHost); + const batHost = h("div", "bat-oly"); + DUPRE.batToggle(batHost, { + on: !caffHold, + onLabel: "TIMED", + offLabel: "HOLD", + onChange: (on) => { + if (booting) return; + caffHold = !on; + syncCaffeine(batHost); + }, + }); + tcell.appendChild(batHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 20 (the switch matrix)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + + /* the matrix: switches down, presets across */ + .mx { display: grid; grid-template-columns: 96px repeat(4, 1fr); row-gap: 6px; align-items: center; } + .mx .mx-cell { display: flex; align-items: center; justify-content: center; min-height: 22px; } + .mx .mx-live { justify-content: flex-start; } + .mx .dupre-rocker { width: 50px; height: 32px; } + .mx .dupre-rocker .dupre-half { font-size: 8px; } + .mx-preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 5px 2px; text-align: center; cursor: pointer; color: var(--silver); + font-size: 8.5px; letter-spacing: 0.02em; width: 100%; margin: 0 2px; + overflow: hidden; font-family: var(--mono); + } + .mx-preset:hover { border-color: var(--gold); color: var(--gold); } + .mx-preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .mx-name { color: var(--steel); font-size: 8px; letter-spacing: 0.1em; text-transform: uppercase; margin-left: 6px; } + .mx-lamp { width: 11px; height: 11px; border-radius: 50%; + background: radial-gradient(circle at 38% 32%, #2e2a24, #171310); border: 1px solid #060505; } + .mx-lamp.on { background: radial-gradient(circle at 38% 32%, #a9c95f, var(--pass) 60%, #3c4d18); + box-shadow: 0 0 7px rgba(116,147,47,.7); } + /* punch cells: the chart's dot vocabulary, scoped for the matrix */ + .mx .pc-dot, .mx .pc-dash { pointer-events: none; } + .mx .pc-dot { width: 6px; height: 6px; border-radius: 50%; } + .mx .pc-dot.pc-set { background: #cfd3cf; } + .mx .pc-dot.pc-clear { border: 1px solid var(--steel); opacity: .6; } + .mx .pc-dash { width: 6px; height: 1px; background: var(--wash); } + .mx .mx-punch { cursor: pointer; border-radius: 4px; height: 22px; } + .mx .mx-punch:hover { background: #ffffff10; } + .mx .mx-pwr { font-size: 7.5px; color: var(--steel); font-family: var(--mono); } + .mx-row-live .mx-pwr { color: #f2f4f2; } + + .lvrow { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; } + .lvrow .lv-name { color: var(--silver); font-size: 10px; width: 48px; letter-spacing: 0.06em; } + .timer-row { display: flex; justify-content: center; margin-top: 4px; } + .tcell { position: relative; } + .bat-oly { position: absolute; top: -2px; right: -4px; } + .bat-oly svg { width: 46px; height: 59px; } + + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 20 — the switch matrix.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); // Focus Presentation Battery Night +const MX_ROWS = [ + { key: "autodim", name: "DIM", kind: "rocker" }, + { key: "nightlight", name: "NIGHT", kind: "rocker" }, + { key: "dnd", name: "DND", kind: "rocker" }, + { key: "caffeine", name: "CAFF", kind: "lamp" }, + { key: "powerprofile", name: "PWR", kind: "power" }, +]; +const PWR_LETTER = { performance: "P", balanced: "B", saver: "S" }; +const LETTER_PWR = { P: "performance", B: "balanced", S: "saver" }; +const PWR_CYCLE = ["P", "B", "S", null]; +let booting = true; +let caffMinutes = 0; +let caffHold = false; +let timerHost = null; + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function syncCaffeine(el) { + const on = caffHold || caffMinutes > 0; + if (!booting && on !== DS.state.caffeine) DS.setToggle("caffeine", el); +} +const syncCaffeineSoon = debounce(() => syncCaffeine(timerHost), 450); + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p20Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p20Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "WIND · PUSH STOPS"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// program cell value for scene n, row r +function cellOf(n, r) { + const sets = DS.SCENES[n].sets; + if (r.kind === "power") return PWR_LETTER[sets.powerprofile] || null; + return r.key in sets ? !!sets[r.key] : null; +} +function paintPunch(c, v) { + if (v === true) c.innerHTML = '<span class="pc-dot pc-set"></span>'; + else if (v === false) c.innerHTML = '<span class="pc-dot pc-clear"></span>'; + else if (typeof v === "string") { c.innerHTML = ""; c.textContent = v; c.classList.add("mx-pwr"); return; } + else c.innerHTML = '<span class="pc-dash"></span>'; + c.classList.remove("mx-pwr"); +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the matrix ------------------------------------------------ + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programs")); + const mx = h("div", "mx"); + + // header row: empty corner + preset buttons (names only) + mx.appendChild(h("div", "mx-cell")); + SCENES.forEach((n) => { + const b = h("button", "mx-preset" + (DS.state.scene === n ? " cur" : ""), n); + b.onclick = () => DS.applyScene(n, b); + const wrap = h("div", "mx-cell"); + wrap.appendChild(b); + mx.appendChild(wrap); + }); + + // switch rows + MX_ROWS.forEach((r) => { + const live = h("div", "mx-cell mx-live"); + if (r.kind === "rocker") { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[r.key], + onLabel: r.name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[r.key]) DS.setToggle(r.key, host); }, + }); + live.appendChild(host); + } else if (r.kind === "lamp") { + const lamp = h("span", "mx-lamp" + (DS.state.caffeine ? " on" : "")); + live.appendChild(lamp); + live.appendChild(h("span", "mx-name", r.name)); + } else { + const host = h("div", "khost"); + DUPRE.thumbSlideH(host, { + width: 86, + positions: ["P", "B", "S"], + index: DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile), + onChange: (i) => { + if (booting) return; + if (DS.PROFILES[i].id !== DS.state.powerprofile) DS.setProfile(DS.PROFILES[i].id, host); + }, + }); + live.appendChild(host); + } + mx.appendChild(live); + + SCENES.forEach((n) => { + const c = h("div", "mx-cell mx-punch"); + paintPunch(c, cellOf(n, r)); + c.onclick = () => { + const sets = DS.SCENES[n].sets; + const v = cellOf(n, r); + let nv; + if (r.kind === "power") { + nv = PWR_CYCLE[(PWR_CYCLE.indexOf(v) + 1) % PWR_CYCLE.length]; + if (nv === null) delete sets.powerprofile; else sets.powerprofile = LETTER_PWR[nv]; + } else { + nv = v === true ? false : v === false ? null : true; + if (nv === null) delete sets[r.key]; else sets[r.key] = nv; + } + paintPunch(c, nv); + DS.toast(n + " program updated", true); + }; + mx.appendChild(c); + }); + }); + + plate.appendChild(mx); + col.appendChild(plate); + + // ---- plate 2: levels (horizontal) + caffeine ---------------------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Caffeine")); + [["brightness", "SCREEN"], ["kbd", "KBD"]].forEach(([key, name]) => { + const row = h("div", "lvrow"); + row.appendChild(h("span", "lv-name", name)); + const host = h("div", "khost"); + host.style.flex = "1"; + const commit = debounce((v) => { + const pct = Math.max(DS.SLIDERS[key].floor, v); + if (pct !== DS.state[key]) DS.setSlider(key, pct, row); + }, 350); + DUPRE.thumbSlideH(host, { + width: 250, + value: DS.state[key], + onChange: (v) => { if (!booting) commit(v); }, + }); + row.appendChild(host); + lv.appendChild(row); + }); + + const trow = h("div", "timer-row"); + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: caffMinutes, + onChange: (min) => { + caffMinutes = min; + syncCaffeineSoon(); + }, + }); + scaleSvg(timerHost, 130); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + const batHost = h("div", "bat-oly"); + DUPRE.batToggle(batHost, { + on: !caffHold, + onLabel: "TIMED", + offLabel: "HOLD", + onChange: (on) => { + if (booting) return; + caffHold = !on; + syncCaffeine(batHost); + }, + }); + tcell.appendChild(batHost); + trow.appendChild(tcell); + lv.appendChild(trow); + col.appendChild(lv); + + // ---- plate 3: pointing devices (never scene-touched) -------------------- + const pt = h("div", "plate"); + pt.appendChild(DS.engrave("Pointing")); + const strip = h("div", "pointing"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + strip.appendChild(host); + }); + pt.appendChild(strip); + col.appendChild(pt); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 21 (pin routing matrix)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 21 — the pin routing matrix.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["DIM", "autodim"], ["NIGHT", "nightlight"], ["DND", "dnd"], ["CAFF", "caffeine"]]; +const PWR_ROWS = [["PERF", "performance"], ["BAL", "balanced"], ["SAVER", "saver"]]; +const ROW_LABELS = [...SWITCH_ROWS.map(r => r[0]), ...PWR_ROWS.map(r => r[0])]; +const COLS = ["NOW", ...SCENES]; +let booting = true; +let sleepMinutes = 0; +let timerHost = null; // persistent: the timer owns a live countdown + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each) +SCENES.forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// the dial is a SLEEP TIMER now: wind minutes; zero = suspend (mocked) +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p21Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p21Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "SLEEP TIMER · PUSH STOPS"; + if (t.textContent === "MINUTES") t.textContent = "SLEEP IN"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const mx = DUPRE.pinMatrix(mxHost, { + rows: ROW_LABELS, + cols: COLS, + pins: currentPins(), + width: 306, + x0: 58, + dy: 17, + colHeads: "button", + activeCol: DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : -1, + colColors: ["#a9c95f", "#cfd3cf", "#cfd3cf", "#cfd3cf", "#cfd3cf"], + groups: [SWITCH_ROWS.length, PWR_ROWS.length], + onColHead: (j) => { + if (booting) return; + if (j === 0) return; // NOW isn't a program + DS.applyScene(SCENES[j - 1], mxHost); + }, + onPin: (rowLbl, colLbl, seated) => { + if (booting) return; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + const pw = PWR_ROWS.find(([l]) => l === rowLbl); + if (colLbl === "NOW") { + if (sw) { + if (seated !== DS.state[sw[1]]) DS.setToggle(sw[1], mxHost); + else render(); + } else if (pw) { + if (!seated) { mx.set(rowLbl + ">NOW", true); return; } // can't pull the live profile + DS.setProfile(pw[1], mxHost); + } + return; + } + const sets = DS.SCENES[colLbl].sets; + if (sw) { + sets[sw[1]] = seated; + DS.toast(colLbl + " program updated", true); + } else if (pw) { + if (!seated) { mx.set(rowLbl + ">" + colLbl, true); return; } // one power pin per program + sets.powerprofile = pw[1]; + PWR_ROWS.forEach(([l, id]) => { if (id !== pw[1]) mx.set(l + ">" + colLbl, false); }); + DS.toast(colLbl + " program updated", true); + } + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: levels (the drums are back) + sleep timer ----------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Sleep")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: 0, + onChange: (min) => { + const was = sleepMinutes; + sleepMinutes = min; + if (booting) return; + if (was > 0.5 && min === 0) DS.toast("Sleep timer expired — SUSPEND (mock)", true); + }, + }); + scaleSvg(timerHost, 132); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- plate 3: pointing (never program-routed) --------------------------- + const pt = h("div", "plate"); + pt.appendChild(DS.engrave("Pointing")); + const strip = h("div", "pointing"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + strip.appendChild(host); + }); + pt.appendChild(strip); + col.appendChild(pt); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 22 (the programmable matrix)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 22 — the programmable matrix.</b> 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). + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROWS = [["PERF", "performance"], ["BAL", "balanced"], ["SAVER", "saver"]]; +const ROW_LABELS = [...SWITCH_ROWS.map(r => r[0]), ...PWR_ROWS.map(r => r[0])]; +const COLS = ["NOW", ...SCENES]; +let booting = true; +let sleepMinutes = 0; +let timerHost = null; // persistent: the timer owns a live countdown + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each) +SCENES.forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// the dial is a SLEEP TIMER now: wind minutes; zero = suspend (mocked) +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p21Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p21Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "SLEEP TIMER · PUSH STOPS"; + if (t.textContent === "MINUTES") t.textContent = "SLEEP IN"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_LABEL = { Focus: "FOCUS", Presentation: "PRESENT", Battery: "BATTERY", Night: "NIGHT" }; + const LABEL_PROG = Object.fromEntries(Object.entries(PROG_LABEL).map(([k, v]) => [v, k])); + const PROGS = SCENES.map((n) => PROG_LABEL[n]); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + PROG_LABEL[n]); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + PROG_LABEL[n]); }); + }); + const mx = DUPRE.programMatrix(mxHost, { + rows: ROW_LABELS, + programs: PROGS, + pins, + radios: [PWR_ROWS.map(([l]) => l)], + groups: [SWITCH_ROWS.length, PWR_ROWS.length], + active: DS.state.scene ? SCENES.indexOf(DS.state.scene) : -1, + width: 306, + x0: 104, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(SCENES[j], mxHost); + }, + onPin: (rowLbl, progLbl, seated, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + const pw = PWR_ROWS.find(([l]) => l === rowLbl); + if (sw) { + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + DS.state.scene = scene; // stay in the program: its column is the truth + render(); + } else DS.toast(progLbl + " program updated", true); + } else if (pw && seated) { + sets.powerprofile = pw[1]; + if (isActive && DS.state.powerprofile !== pw[1]) { + DS.setProfile(pw[1], mxHost); + DS.state.scene = scene; + render(); + } else DS.toast(progLbl + " program updated", true); + } + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: levels (the drums are back) + sleep timer ----------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Sleep")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: 0, + onChange: (min) => { + const was = sleepMinutes; + sleepMinutes = min; + if (booting) return; + if (was > 0.5 && min === 0) DS.toast("Sleep timer expired — SUSPEND (mock)", true); + }, + }); + scaleSvg(timerHost, 132); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- plate 3: pointing (never program-routed) --------------------------- + const pt = h("div", "plate"); + pt.appendChild(DS.engrave("Pointing")); + const strip = h("div", "pointing"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + strip.appendChild(host); + }); + pt.appendChild(strip); + col.appendChild(pt); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 23 (pushwheel CPU row)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 23 — the pushwheel CPU row.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["P", "B", "S"]; // ordered with DS.PROFILES +const LETTER_OF = { performance: "P", balanced: "B", saver: "S" }; +const PWR_OF = { P: "performance", B: "balanced", S: "saver" }; +let booting = true; +let sleepMinutes = 0; +let timerHost = null; // persistent: the timer owns a live countdown + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each) +SCENES.forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// the dial is a SLEEP TIMER now: wind minutes; zero = suspend (mocked) +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p21Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p21Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "SLEEP TIMER · PUSH STOPS"; + if (t.textContent === "MINUTES") t.textContent = "SLEEP IN"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_LABEL = { Focus: "FOCUS", Presentation: "PRESENT", Battery: "BATTERY", Night: "NIGHT" }; + const LABEL_PROG = Object.fromEntries(Object.entries(PROG_LABEL).map(([k, v]) => [v, k])); + const PROGS = SCENES.map((n) => PROG_LABEL[n]); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + PROG_LABEL[n]); }); + }); + const letters = {}; + SCENES.forEach((n) => { letters[PWR_ROW + ">" + PROG_LABEL[n]] = LETTER_OF[DS.SCENES[n].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES, stepper: true }], + programs: PROGS, + pins, + letters, + groups: [SWITCH_ROWS.length, 1], + active: DS.state.scene ? SCENES.indexOf(DS.state.scene) : -1, + width: 306, + x0: 104, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(SCENES[j], mxHost); + }, + onPin: (rowLbl, progLbl, seated, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + onLetter: (rowLbl, progLbl, v, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + onStep: (rowLbl, dir) => { + if (booting) return; + const idx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + const n = Math.max(0, Math.min(DS.PROFILES.length - 1, idx + dir)); + if (n === idx) return; + const scene = DS.state.scene; + DS.setProfile(DS.PROFILES[n].id, mxHost); + if (scene) { + DS.SCENES[scene].sets.powerprofile = DS.PROFILES[n].id; // active-is-live + stayActive(scene); + } + }, + }); + mx.setStepper(PWR_ROW, LETTER_OF[DS.state.powerprofile]); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: levels (the drums are back) + sleep timer ----------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Sleep")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: 0, + onChange: (min) => { + const was = sleepMinutes; + sleepMinutes = min; + if (booting) return; + if (was > 0.5 && min === 0) DS.toast("Sleep timer expired — SUSPEND (mock)", true); + }, + }); + scaleSvg(timerHost, 132); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- plate 3: pointing (never program-routed) --------------------------- + const pt = h("div", "plate"); + pt.appendChild(DS.engrave("Pointing")); + const strip = h("div", "pointing"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + strip.appendChild(host); + }); + pt.appendChild(strip); + col.appendChild(pt); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 24 (tape wheel, jewel verdicts)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 24 — tape wheel + jewel verdicts.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["P", "B", "S"]; // ordered with DS.PROFILES +const LETTER_OF = { performance: "P", balanced: "B", saver: "S" }; +const PWR_OF = { P: "performance", B: "balanced", S: "saver" }; +let booting = true; +let sleepMinutes = 0; +let timerHost = null; // persistent: the timer owns a live countdown + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each) +SCENES.forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// the dial is a SLEEP TIMER now: wind minutes; zero = suspend (mocked) +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p21Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p21Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "SLEEP TIMER · PUSH STOPS"; + if (t.textContent === "MINUTES") t.textContent = "SLEEP IN"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_LABEL = { Focus: "FOCUS", Presentation: "PRESENT", Battery: "BATTERY", Night: "NIGHT" }; + const LABEL_PROG = Object.fromEntries(Object.entries(PROG_LABEL).map(([k, v]) => [v, k])); + const PROGS = SCENES.map((n) => PROG_LABEL[n]); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + PROG_LABEL[n]); }); + }); + const letters = {}; + SCENES.forEach((n) => { letters[PWR_ROW + ">" + PROG_LABEL[n]] = LETTER_OF[DS.SCENES[n].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES, stepper: true }], + programs: PROGS, + pins, + letters, + groups: [SWITCH_ROWS.length, 1], + active: DS.state.scene ? SCENES.indexOf(DS.state.scene) : -1, + width: 306, + x0: 104, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(SCENES[j], mxHost); + }, + onPin: (rowLbl, progLbl, seated, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + onLetter: (rowLbl, progLbl, v, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + onStep: (rowLbl) => { + if (booting) return; + const idx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + const n = (idx + 1) % DS.PROFILES.length; // the wheel is the button: cycle, wrapping + const scene = DS.state.scene; + DS.setProfile(DS.PROFILES[n].id, mxHost); + if (scene) { + DS.SCENES[scene].sets.powerprofile = DS.PROFILES[n].id; // active-is-live + stayActive(scene); + } + }, + }); + mx.setStepper(PWR_ROW, LETTER_OF[DS.state.powerprofile]); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: levels (the drums are back) + sleep timer ----------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Sleep")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: 0, + onChange: (min) => { + const was = sleepMinutes; + sleepMinutes = min; + if (booting) return; + if (was > 0.5 && min === 0) DS.toast("Sleep timer expired — SUSPEND (mock)", true); + }, + }); + scaleSvg(timerHost, 132); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- plate 3: pointing (never program-routed) --------------------------- + const pt = h("div", "plate"); + pt.appendChild(DS.engrave("Pointing")); + const strip = h("div", "pointing"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + strip.appendChild(host); + }); + pt.appendChild(strip); + col.appendChild(pt); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 25 (wheels in every column)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 25 — wheels in every column.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["P", "B", "S"]; // ordered with DS.PROFILES +const LETTER_OF = { performance: "P", balanced: "B", saver: "S" }; +const PWR_OF = { P: "performance", B: "balanced", S: "saver" }; +let booting = true; +let sleepMinutes = 0; +let timerHost = null; // persistent: the timer owns a live countdown + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each) +SCENES.forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// the dial is a SLEEP TIMER now: wind minutes; zero = suspend (mocked) +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p21Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p21Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "SLEEP TIMER · PUSH STOPS"; + if (t.textContent === "MINUTES") t.textContent = "SLEEP IN"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_LABEL = { Focus: "FOCUS", Presentation: "PRESENT", Battery: "BATTERY", Night: "NIGHT" }; + const LABEL_PROG = Object.fromEntries(Object.entries(PROG_LABEL).map(([k, v]) => [v, k])); + const PROGS = SCENES.map((n) => PROG_LABEL[n]); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + PROG_LABEL[n]); }); + }); + const letters = {}; + SCENES.forEach((n) => { letters[PWR_ROW + ">" + PROG_LABEL[n]] = LETTER_OF[DS.SCENES[n].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + groups: [SWITCH_ROWS.length, 1], + active: DS.state.scene ? SCENES.indexOf(DS.state.scene) : -1, + width: 306, + x0: 104, + dy: 21, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(SCENES[j], mxHost); + }, + onPin: (rowLbl, progLbl, seated, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + onLetter: (rowLbl, progLbl, v, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: levels (the drums are back) + sleep timer ----------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Sleep")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: 0, + onChange: (min) => { + const was = sleepMinutes; + sleepMinutes = min; + if (booting) return; + if (was > 0.5 && min === 0) DS.toast("Sleep timer expired — SUSPEND (mock)", true); + }, + }); + scaleSvg(timerHost, 132); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- plate 3: pointing (never program-routed) --------------------------- + const pt = h("div", "plate"); + pt.appendChild(DS.engrave("Pointing")); + const strip = h("div", "pointing"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + strip.appendChild(host); + }); + pt.appendChild(strip); + col.appendChild(pt); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 26 (physical keys, arrow wheels)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .ds-panel { width: 434px; } + .ds-caption { max-width: 430px; } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 26 — physical keys, arrow wheels.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +let sleepMinutes = 0; +let timerHost = null; // persistent: the timer owns a live countdown + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each) +SCENES.forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// the dial is a SLEEP TIMER now: wind minutes; zero = suspend (mocked) +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p21Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p21Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "SLEEP TIMER · PUSH STOPS"; + if (t.textContent === "MINUTES") t.textContent = "SLEEP IN"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_LABEL = { Focus: "FOCUS", Presentation: "PRESENT", Battery: "BATTERY", Night: "NIGHT" }; + const LABEL_PROG = Object.fromEntries(Object.entries(PROG_LABEL).map(([k, v]) => [v, k])); + const PROGS = SCENES.map((n) => PROG_LABEL[n]); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + PROG_LABEL[n]); }); + }); + const letters = {}; + SCENES.forEach((n) => { letters[PWR_ROW + ">" + PROG_LABEL[n]] = LETTER_OF[DS.SCENES[n].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + groups: [SWITCH_ROWS.length, 1], + active: DS.state.scene ? SCENES.indexOf(DS.state.scene) : -1, + width: 382, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(SCENES[j], mxHost); + }, + onPin: (rowLbl, progLbl, seated, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + onLetter: (rowLbl, progLbl, v, isActive) => { + if (booting) return; + const scene = LABEL_PROG[progLbl]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast(progLbl + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: levels (the drums are back) + sleep timer ----------------- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Sleep")); + const mid = h("div", "midgrid"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + mid.appendChild(dcell); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: 0, + onChange: (min) => { + const was = sleepMinutes; + sleepMinutes = min; + if (booting) return; + if (was > 0.5 && min === 0) DS.toast("Sleep timer expired — SUSPEND (mock)", true); + }, + }); + scaleSvg(timerHost, 132); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + mid.appendChild(tcell); + lv.appendChild(mid); + col.appendChild(lv); + + // ---- plate 3: pointing (never program-routed) --------------------------- + const pt = h("div", "plate"); + pt.appendChild(DS.engrave("Pointing")); + const strip = h("div", "pointing"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + strip.appendChild(host); + }); + pt.appendChild(strip); + col.appendChild(pt); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 27 (console keys, six slots, one bench)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .ds-panel { width: 560px; } + .ds-caption { max-width: 540px; } + .bench { display: flex; align-items: center; justify-content: center; gap: 14px; } + .groove { width: 2px; align-self: stretch; margin: 4px 0; + background: linear-gradient(180deg, transparent, #060505 15%, #060505 85%, transparent); + box-shadow: 1px 0 0 #2a2724; } + .rockstack { display: flex; flex-direction: column; gap: 10px; align-items: center; } + .rockstack .dupre-rocker { width: 56px; height: 34px; } + .rockstack .dupre-rocker .dupre-half { font-size: 8px; } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 27 — console keys, six slots, one bench.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +let sleepMinutes = 0; +let timerHost = null; // persistent: the timer owns a live countdown + +// two unassigned program slots (blank keys, all-off programs) +if (!DS.SCENES.Slot5) DS.SCENES.Slot5 = { glyph: "", sets: {} }; +if (!DS.SCENES.Slot6) DS.SCENES.Slot6 = { glyph: "", sets: {} }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + +// the dial is a SLEEP TIMER now: wind minutes; zero = suspend (mocked) +function tameTimer(host) { + const svg = host.querySelector("svg"); + const NS = "http://www.w3.org/2000/svg"; + const defs = document.createElementNS(NS, "defs"); + defs.innerHTML = `<radialGradient id="p21Steel" cx="38%" cy="32%" r="80%"> + <stop offset="0" stop-color="#c9c4b8"/><stop offset="1" stop-color="#6e685c"/> + </radialGradient>`; + svg.insertBefore(defs, svg.firstChild); + svg.querySelectorAll('[fill="url(#mtRed)"]').forEach((n) => n.setAttribute("fill", "url(#p21Steel)")); + [...svg.querySelectorAll("text")].forEach((t) => { + if (t.textContent === "TURN TO START") t.remove(); + if (t.textContent === "PUSH TO STOP") t.textContent = "SLEEP TIMER · PUSH STOPS"; + if (t.textContent === "MINUTES") t.textContent = "SLEEP IN"; + if (t.textContent === "OFF") t.setAttribute("fill", "var(--gold-hi)"); + }); + [...svg.querySelectorAll("line")].forEach((l) => { + if (l.getAttribute("stroke") === "#e0523a") l.setAttribute("stroke", "var(--gold)"); + }); + svg.querySelectorAll('[stroke="#5c150c"]').forEach((n) => n.setAttribute("stroke", "#3c382f")); + [...svg.querySelectorAll("path")].find((p) => p.getAttribute("d").startsWith("M 122 14"))?.remove(); + [...svg.querySelectorAll("polygon")].find((p) => p.getAttribute("points").startsWith("136,26"))?.remove(); +} + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", scene: "Focus" }, + { id: "PRESENT", label: "PRESENT", scene: "Presentation" }, + { id: "BATTERY", label: "BATTERY", scene: "Battery" }, + { id: "NIGHT", label: "NIGHT", scene: "Night" }, + { id: "S5", label: "", scene: "Slot5" }, + { id: "S6", label: "", scene: "Slot6" }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label }) => ({ id, label })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + groups: [SWITCH_ROWS.length, 1], + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 508, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — drums · groove · pointing · groove · dial ---- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Pointing · Sleep")); + const bench = h("div", "bench"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + bench.appendChild(dcell); + + bench.appendChild(h("div", "groove")); + + const rocks = h("div", "rockstack"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + rocks.appendChild(host); + }); + bench.appendChild(rocks); + + bench.appendChild(h("div", "groove")); + + const tcell = h("div", "vcell tcell"); + if (!timerHost) { + timerHost = h("div", "khost"); + DUPRE.timerDial(timerHost, { + minutes: 0, + onChange: (min) => { + const was = sleepMinutes; + sleepMinutes = min; + if (booting) return; + if (was > 0.5 && min === 0) DS.toast("Sleep timer expired — SUSPEND (mock)", true); + }, + }); + scaleSvg(timerHost, 132); + tameTimer(timerHost); + } + tcell.appendChild(timerHost); + bench.appendChild(tcell); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 28 (the idle tripper rail)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .ds-panel { width: 560px; } + .ds-caption { max-width: 540px; } + .bench { display: flex; align-items: center; justify-content: center; gap: 14px; } + .groove { width: 2px; align-self: stretch; margin: 4px 0; + background: linear-gradient(180deg, transparent, #060505 15%, #060505 85%, transparent); + box-shadow: 1px 0 0 #2a2724; } + .rockstack { display: flex; flex-direction: column; gap: 10px; align-items: center; } + .rockstack .dupre-rocker { width: 56px; height: 34px; } + .rockstack .dupre-rocker .dupre-half { font-size: 8px; } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 28 — the idle tripper rail.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", minutes: 5 }, + { id: "lock", label: "LOCK", minutes: 7 }, + { id: "watch", label: "WATCH", minutes: 8 }, + { id: "dpms", label: "SCREEN OFF", minutes: 10 }, + { id: "suspend", label: "SUSPEND", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +if (!DS.SCENES.Slot5) DS.SCENES.Slot5 = { glyph: "", sets: {} }; +if (!DS.SCENES.Slot6) DS.SCENES.Slot6 = { glyph: "", sets: {} }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", scene: "Focus" }, + { id: "PRESENT", label: "PRESENT", scene: "Presentation" }, + { id: "BATTERY", label: "BATTERY", scene: "Battery" }, + { id: "NIGHT", label: "NIGHT", scene: "Night" }, + { id: "S5", label: "", scene: "Slot5" }, + { id: "S6", label: "", scene: "Slot6" }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label }) => ({ id, label })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + groups: [SWITCH_ROWS.length, 1], + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 508, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — drums · groove · pointing · groove · dial ---- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Pointing")); + const bench = h("div", "bench"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + bench.appendChild(dcell); + + bench.appendChild(h("div", "groove")); + + const rocks = h("div", "rockstack"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + rocks.appendChild(host); + }); + bench.appendChild(rocks); + + lv.appendChild(bench); + col.appendChild(lv); + + // ---- plate 3: the idle program — the tripper rail ----------------------- + const ip = h("div", "plate"); + ip.appendChild(DS.engrave("Idle program")); + const railHost = h("div", "khost"); + const rail = DUPRE.tripRail(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + width: 508, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + ip.appendChild(railHost); + col.appendChild(ip); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 29 (the idle tripper dial)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .ds-panel { width: 560px; } + .ds-caption { max-width: 540px; } + .bench { display: flex; align-items: center; justify-content: center; gap: 14px; } + .groove { width: 2px; align-self: stretch; margin: 4px 0; + background: linear-gradient(180deg, transparent, #060505 15%, #060505 85%, transparent); + box-shadow: 1px 0 0 #2a2724; } + .rockstack { display: flex; flex-direction: column; gap: 10px; align-items: center; } + .rockstack .dupre-rocker { width: 56px; height: 34px; } + .rockstack .dupre-rocker .dupre-half { font-size: 8px; } + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #1d1a16, #141210); + border: 1px solid #262320; + border-radius: 10px; + padding: 10px; + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 29 — the idle tripper dial.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +if (!DS.SCENES.Slot5) DS.SCENES.Slot5 = { glyph: "", sets: {} }; +if (!DS.SCENES.Slot6) DS.SCENES.Slot6 = { glyph: "", sets: {} }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Program routing")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", scene: "Focus" }, + { id: "PRESENT", label: "PRESENT", scene: "Presentation" }, + { id: "BATTERY", label: "BATTERY", scene: "Battery" }, + { id: "NIGHT", label: "NIGHT", scene: "Night" }, + { id: "S5", label: "", scene: "Slot5" }, + { id: "S6", label: "", scene: "Slot6" }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label }) => ({ id, label })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + groups: [SWITCH_ROWS.length, 1], + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 508, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — drums · groove · pointing · groove · dial ---- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Pointing · Idle")); + const bench = h("div", "bench"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 132, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + bench.appendChild(dcell); + + bench.appendChild(h("div", "groove")); + + const rocks = h("div", "rockstack"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + rocks.appendChild(host); + }); + bench.appendChild(rocks); + + bench.appendChild(h("div", "groove")); + + const icell = h("div", "vcell"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 138, + legend: true, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + icell.appendChild(railHost); + bench.appendChild(icell); + + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 30 (family chrome)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--cream); letter-spacing: .22em; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: center; justify-content: center; gap: 8px; } + .groove { width: 2px; align-self: stretch; margin: 4px 0; + background: linear-gradient(180deg, transparent, #060505 15%, #060505 85%, transparent); + box-shadow: 1px 0 0 #2a2724; } + .rockstack { display: flex; flex-direction: column; gap: 10px; align-items: center; } + .rockstack .dupre-rocker { width: 50px; height: 32px; } + .rockstack .dupre-rocker .dupre-half { font-size: 8px; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 30 — family chrome.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +if (!DS.SCENES.Slot5) DS.SCENES.Slot5 = { glyph: "", sets: {} }; +if (!DS.SCENES.Slot6) DS.SCENES.Slot6 = { glyph: "", sets: {} }; + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", scene: "Focus" }, + { id: "PRESENT", label: "PRESENT", scene: "Presentation" }, + { id: "BATTERY", label: "BATTERY", scene: "Battery" }, + { id: "NIGHT", label: "NIGHT", scene: "Night" }, + { id: "S5", label: "1", scene: "Slot5", slim: true }, + { id: "S6", label: "2", scene: "Slot6", slim: true }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, slim }) => ({ id, label, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 486, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — drums · groove · pointing · groove · dial ---- + const lv = h("div", "plate"); + lv.appendChild(DS.engrave("Levels · Pointing · Idle")); + const bench = h("div", "bench"); + + const dcell = h("div", "vcell"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, dcell); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, dcell); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 176, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + dcell.appendChild(dhost); + bench.appendChild(dcell); + + bench.appendChild(h("div", "groove")); + + const rocks = h("div", "rockstack"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const host = h("div", "khost"); + DUPRE.rocker(host, { + on: DS.state[k], + onLabel: name, + offLabel: "", + quiet: true, + accent: "green", + onChange: (on) => { if (!booting && on !== DS.state[k]) DS.setToggle(k, host); }, + }); + rocks.appendChild(host); + }); + bench.appendChild(rocks); + + bench.appendChild(h("div", "groove")); + + const icell = h("div", "vcell"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 148, + legend: true, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + icell.appendChild(railHost); + bench.appendChild(icell); + + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 31 (encircled groups)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--cream); letter-spacing: .22em; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: stretch; justify-content: center; gap: 12px; } + .fcol { display: flex; flex-direction: column; gap: 12px; } + .fbox { + position: relative; + border: 1px solid #35302a; + border-radius: 10px; + padding: 14px 12px 10px; + display: flex; align-items: center; justify-content: center; gap: 10px; + } + .fbox .flbl { + position: absolute; top: -7px; left: 12px; + background: var(--raise); + padding: 0 7px; + color: var(--steel); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; + } + .fbox .dupre-key.ptr { width: 78px; height: 24px; font-size: 8px; letter-spacing: .08em; padding: 0; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 31 — encircled groups.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +if (!DS.SCENES.Slot5) DS.SCENES.Slot5 = { glyph: "", sets: {} }; +if (!DS.SCENES.Slot6) DS.SCENES.Slot6 = { glyph: "", sets: {} }; + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", scene: "Focus" }, + { id: "PRESENT", label: "PRESENT", scene: "Presentation" }, + { id: "BATTERY", label: "BATTERY", scene: "Battery" }, + { id: "NIGHT", label: "NIGHT", scene: "Night" }, + { id: "S5", label: "1", scene: "Slot5", slim: true }, + { id: "S6", label: "2", scene: "Slot6", slim: true }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, slim }) => ({ id, label, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 486, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — three encircled instrument groups ------------- + const lv = h("div", "plate"); + const bench = h("div", "bench"); + + const fbox = (label) => { + const b = h("div", "fbox"); + b.appendChild(h("span", "flbl", label)); + return b; + }; + + // BRIGHTNESS — the drum pair + const bright = fbox("Brightness"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, bright); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, bright); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 204, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + bright.appendChild(dhost); + bench.appendChild(bright); + + const rcol = h("div", "fcol"); + + // POINTERS — console keys, horizontal + const ptr = fbox("Pointers"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const b = h("button", "dupre-key ptr", name); + if (DS.state[k]) b.classList.add("dupre-green"); + b.onclick = () => { b.blur(); DS.setToggle(k, ptr); }; + ptr.appendChild(b); + }); + rcol.appendChild(ptr); + + // IDLE — the tripper dial (size unchanged) + const idle = fbox("Idle"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 148, + legend: true, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + idle.appendChild(railHost); + rcol.appendChild(idle); + + bench.appendChild(rcol); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 32 (the wallpaper channels)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--cream); letter-spacing: .22em; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: stretch; justify-content: center; gap: 12px; } + .fcol { display: flex; flex-direction: column; gap: 12px; } + .fbox { + position: relative; + border: 1px solid #35302a; + border-radius: 10px; + padding: 14px 12px 10px; + display: flex; align-items: center; justify-content: center; gap: 10px; + } + .fbox .flbl { + position: absolute; top: -7px; left: 12px; + background: var(--raise); + padding: 0 7px; + color: var(--steel); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; + } + .fbox .dupre-key.ptr { width: 78px; height: 24px; font-size: 8px; letter-spacing: .08em; padding: 0; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + /* ---- wallpaper channel bank ---- */ + .wp-chans { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; } + .wp-chan { + border: 1px solid #33302b; border-radius: 8px; padding: 4px; + background: linear-gradient(180deg, var(--key-top, #23211e), var(--key-bot, #191715)); + cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; + } + .wp-chan:hover { border-color: var(--gold); } + .wp-chan.on { border-color: #a9c95f; box-shadow: 0 0 8px rgba(169,201,95,.25); } + .wp-chan .face { width: 100%; aspect-ratio: 16/10; border-radius: 4px; overflow: hidden; position: relative; background: #0c0a08; } + .wp-chan .nm { font-size: 7px; letter-spacing: .08em; color: var(--dim); text-transform: uppercase; } + .wp-chan.on .nm { color: #a9c95f; } + .half { position: absolute; inset: 0; } + .half.b { clip-path: polygon(100% 0, 100% 100%, 0 100%); } + .deck { position: absolute; inset: 0; } + .deck i { position: absolute; width: 68%; height: 68%; border-radius: 3px; border: 1px solid #0009; } + .mini-clocks { position: absolute; inset: 2px; display: grid; grid-template-columns: repeat(3,1fr); gap: 1px; } + .mini-clocks i { border-radius: 50%; border: 1px solid #4a4438; position: relative; background: #14110e; } + .mini-clocks i::after { content: ""; position: absolute; left: 50%; top: 18%; width: 1px; height: 34%; background: var(--gold); transform-origin: bottom; } + .mini-clocks i:nth-child(2)::after { transform: rotate(65deg); } + .mini-clocks i:nth-child(3)::after { transform: rotate(140deg); } + .mini-clocks i:nth-child(4)::after { transform: rotate(-50deg); } + .mini-clocks i:nth-child(5)::after { transform: rotate(110deg); } + .mini-clocks i:nth-child(6)::after { transform: rotate(-120deg); } + .clock-face { position: absolute; inset: 8% 25%; border-radius: 50%; border: 1.5px solid var(--cream); background: #14110e; } + .clock-face::before { content: ""; position: absolute; left: 50%; top: 12%; width: 1.5px; height: 40%; background: var(--cream); transform: rotate(40deg); transform-origin: bottom; } + .clock-face::after { content: ""; position: absolute; left: 50%; top: 22%; width: 1.5px; height: 30%; background: var(--gold); transform: rotate(-70deg); transform-origin: bottom; } + .watch-face { position: absolute; inset: 6% 22%; border-radius: 50%; border: 1px solid #3a5c2a; background: radial-gradient(circle, #0f1a0c, #060905); } + .watch-face::after { content: ""; position: absolute; left: 50%; top: 6%; width: 1px; height: 44%; background: #a9c95f; transform: rotate(55deg); transform-origin: bottom; box-shadow: 0 0 4px #a9c95f; } + .vid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fffc; font-size: 14px; text-shadow: 0 1px 3px #000; } + + .wp-conf { margin-top: 10px; } + .wp-lib { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } + .wp-th { aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .wp-th:hover { border-color: var(--gold); } + .wp-th.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .pair-bench { display: flex; align-items: center; gap: 10px; justify-content: center; } + .pair-slot { width: 104px; aspect-ratio: 16/10; border: 1.5px dashed #4a443a; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; } + .pair-slot.filled { border-style: solid; } + .pair-slot.arm { border-color: var(--gold); } + .pair-slot .cap { position: absolute; bottom: 2px; left: 4px; font-size: 7px; letter-spacing: .12em; color: #fff9; text-shadow: 0 1px 2px #000; } + .pair-tray { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } + .pair-chip { width: 64px; aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .pair-chip.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .wp-note { color: var(--dim); font-size: 10px; margin-top: 6px; } + .wp-row2 { display: flex; align-items: center; gap: 10px; justify-content: center; margin-top: 8px; } + .int-wheel { + width: 46px; height: 30px; border-radius: 4px; background: #0a0908; border: 1px solid #3a352c; + display: flex; align-items: center; justify-content: center; cursor: pointer; + color: #2b2418; font-weight: 700; font-size: 12px; font-family: var(--mono); + background: linear-gradient(90deg,#cfc6a8,#ece4c8 50%,#c6bd9e); + box-shadow: inset 0 4px 5px rgba(0,0,0,.35), inset 0 -4px 5px rgba(0,0,0,.35); + } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 32 — the wallpaper channels.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +if (!DS.SCENES.Slot5) DS.SCENES.Slot5 = { glyph: "", sets: {} }; +if (!DS.SCENES.Slot6) DS.SCENES.Slot6 = { glyph: "", sets: {} }; + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +// ---- wallpaper: channels state (mock persistence) ------------------------- +const SWATCH = [ + "linear-gradient(135deg,#2a3d5c,#101b2e)", "linear-gradient(135deg,#5c3a2a,#2e1810)", + "linear-gradient(135deg,#3a5c2a,#16240f)", "linear-gradient(135deg,#40364f,#1b1626)", + "linear-gradient(135deg,#1a1e2e,#05070d)", "linear-gradient(135deg,#5c5030,#2e2810)", + "linear-gradient(135deg,#6b4a5c,#2e1420)", "linear-gradient(135deg,#2a5c55,#0f2420)", + "linear-gradient(135deg,#7a6248,#3a2c1a)", "linear-gradient(135deg,#23364a,#0a1018)", +]; +const VIDEOS = ["linear-gradient(160deg,#38506b,#0d1520)", "linear-gradient(160deg,#5c4a2a,#241a08)", "linear-gradient(160deg,#2c4a3e,#0a1a12)"]; +const WP = { + channel: "single", + single: 2, + pairs: [{ day: 1, night: 4 }], + pairSel: 0, + video: 0, + interval: 30, + building: { day: null, night: null, arm: null }, +}; +const INTERVALS = [5, 10, 15, 30, 60]; +const CHANNELS = [ + { id: "watch", nm: "Watch" }, + { id: "pair", nm: "Sun pair" }, + { id: "single", nm: "Single" }, + { id: "random", nm: "Random" }, + { id: "video", nm: "Video" }, + { id: "clock", nm: "Clock" }, + { id: "world", nm: "World" }, +]; + +function chanFace(id) { + const f = h("div", "face"); + if (id === "single") { const i = h("div", "half"); i.style.background = SWATCH[WP.single]; f.appendChild(i); } + else if (id === "pair") { + const p = WP.pairs[WP.pairSel] || WP.pairs[0]; + const a = h("div", "half"); a.style.background = p ? SWATCH[p.day] : "#222"; + const b = h("div", "half b"); b.style.background = p ? SWATCH[p.night] : "#111"; + f.appendChild(a); f.appendChild(b); + } else if (id === "random") { + const d = h("div", "deck"); + [[8, "18%", "4%", "-6deg"], [4, "10%", "16%", "3deg"], [0, "2%", "28%", "-2deg"]].forEach(([si, top, left, rot]) => { + const c = h("i"); c.style.cssText = `top:${top};left:${left};transform:rotate(${rot});background:${SWATCH[si]}`; + d.appendChild(c); + }); + f.appendChild(d); + } else if (id === "video") { + const i = h("div", "half"); i.style.background = VIDEOS[WP.video]; f.appendChild(i); + f.appendChild(h("div", "vid-play", "\u25B6")); + } else if (id === "clock") f.appendChild(h("div", "clock-face")); + else if (id === "world") { + const m = h("div", "mini-clocks"); + for (let i = 0; i < 6; i++) { const c = h("i"); c.style.setProperty("--r", (i * 55) + "deg"); c.querySelector; m.appendChild(c); } + f.appendChild(m); + } else if (id === "watch") f.appendChild(h("div", "watch-face")); + return f; +} + +function libGrid(sel, onPick) { + const g = h("div", "wp-lib"); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th" + (i === sel ? " sel" : "")); + t.style.background = sw; + t.onclick = () => onPick(i); + g.appendChild(t); + }); + return g; +} + +function wallpaperView() { + const sv = h("div", "ds-col"); + const head = h("div", "ds-sub-head"); + head.innerHTML = `<span class="back">\u2039 Back</span><span class="title">Wallpaper</span>`; + head.querySelector(".back").onclick = () => subview.classList.remove("open"); + sv.appendChild(head); + + const paint = () => { + body2.innerHTML = ""; + + // channel bank + const bank = h("div", "wp-chans"); + CHANNELS.forEach((c) => { + const card = h("div", "wp-chan" + (WP.channel === c.id ? " on" : "")); + card.appendChild(chanFace(c.id)); + card.appendChild(h("div", "nm", c.nm)); + card.onclick = () => { WP.channel = c.id; DS.toast("Wallpaper: " + c.nm, true); paint(); }; + bank.appendChild(card); + }); + body2.appendChild(bank); + + const conf = h("div", "wp-conf"); + if (WP.channel === "single") { + conf.appendChild(DS.engrave("Library")); + conf.appendChild(libGrid(WP.single, (i) => { WP.single = i; DS.toast("Wallpaper set", true); paint(); })); + } else if (WP.channel === "pair") { + conf.appendChild(DS.engrave("Pair press — fill both, confirm to mint")); + const bench = h("div", "pair-bench"); + ["day", "night"].forEach((k) => { + const slot = h("div", "pair-slot" + + (WP.building[k] !== null ? " filled" : "") + (WP.building.arm === k ? " arm" : "")); + if (WP.building[k] !== null) slot.style.background = SWATCH[WP.building[k]]; + slot.appendChild(h("span", "cap", k === "day" ? "\u2600 DAY" : "\u263E NIGHT")); + slot.onclick = () => { WP.building.arm = WP.building.arm === k ? null : k; paint(); }; + bench.appendChild(slot); + }); + const mint = h("button", "dupre-key", "MINT PAIR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + if (WP.building.day === null || WP.building.night === null) { DS.toast("fill both frames first", false); return; } + WP.pairs.push({ day: WP.building.day, night: WP.building.night }); + WP.pairSel = WP.pairs.length - 1; + WP.building = { day: null, night: null, arm: null }; + DS.toast("Pair minted", true); + paint(); + }; + bench.appendChild(mint); + conf.appendChild(bench); + if (WP.building.arm) { + conf.appendChild(h("div", "wp-note", "pick the " + WP.building.arm.toUpperCase() + " picture:")); + conf.appendChild(libGrid(WP.building[WP.building.arm], (i) => { + WP.building[WP.building.arm] = i; + WP.building.arm = WP.building.arm === "day" && WP.building.night === null ? "night" : null; + paint(); + })); + } + conf.appendChild(DS.engrave("Minted pairs")); + const tray = h("div", "pair-tray"); + WP.pairs.forEach((p, i) => { + const chip = h("div", "pair-chip" + (i === WP.pairSel ? " sel" : "")); + const a = h("div", "half"); a.style.background = SWATCH[p.day]; + const b = h("div", "half b"); b.style.background = SWATCH[p.night]; + chip.appendChild(a); chip.appendChild(b); + chip.onclick = () => { WP.pairSel = i; DS.toast("Pair selected", true); paint(); }; + tray.appendChild(chip); + }); + conf.appendChild(tray); + conf.appendChild(h("div", "wp-note", "Swaps at local sunrise / sundown (sun-time from lat/long).")); + } else if (WP.channel === "random") { + conf.appendChild(DS.engrave("Random — draws from the whole library")); + const row = h("div", "wp-row2"); + row.appendChild(h("span", "wp-note", "next draw every")); + const wheel = h("div", "int-wheel", WP.interval); + wheel.onclick = () => { + WP.interval = INTERVALS[(INTERVALS.indexOf(WP.interval) + 1) % INTERVALS.length]; + DS.toast("Random interval: " + WP.interval + "m", true); + paint(); + }; + row.appendChild(wheel); + row.appendChild(h("span", "wp-note", "minutes")); + conf.appendChild(row); + } else if (WP.channel === "video") { + conf.appendChild(DS.engrave("Videos")); + const g = h("div", "wp-lib"); + VIDEOS.forEach((v, i) => { + const t = h("div", "wp-th" + (i === WP.video ? " sel" : "")); + t.style.background = v; + t.appendChild(h("div", "vid-play", "\u25B6")); + t.onclick = () => { WP.video = i; DS.toast("Video set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(g); + } else { + conf.appendChild(h("div", "wp-note", + WP.channel === "watch" ? "The night watch plays as the wallpaper. No options." + : WP.channel === "clock" ? "The custom clock fills the screen. No options." + : "A matrix of world clocks fills the screen. No options.")); + } + body2.appendChild(conf); + }; + + const body2 = h("div", "ds-col"); + sv.appendChild(body2); + paint(); + return sv; +} + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(wallpaperView()); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", scene: "Focus" }, + { id: "PRESENT", label: "PRESENT", scene: "Presentation" }, + { id: "BATTERY", label: "BATTERY", scene: "Battery" }, + { id: "NIGHT", label: "NIGHT", scene: "Night" }, + { id: "S5", label: "1", scene: "Slot5", slim: true }, + { id: "S6", label: "2", scene: "Slot6", slim: true }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, slim }) => ({ id, label, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 486, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — three encircled instrument groups ------------- + const lv = h("div", "plate"); + const bench = h("div", "bench"); + + const fbox = (label) => { + const b = h("div", "fbox"); + b.appendChild(h("span", "flbl", label)); + return b; + }; + + // BRIGHTNESS — the drum pair + const bright = fbox("Brightness"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, bright); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, bright); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 204, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + bright.appendChild(dhost); + bench.appendChild(bright); + + const rcol = h("div", "fcol"); + + // POINTERS — console keys, horizontal + const ptr = fbox("Pointers"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const b = h("button", "dupre-key ptr", name); + if (DS.state[k]) b.classList.add("dupre-green"); + b.onclick = () => { b.blur(); DS.setToggle(k, ptr); }; + ptr.appendChild(b); + }); + rcol.appendChild(ptr); + + // IDLE — the tripper dial (size unchanged) + const idle = fbox("Idle"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 148, + legend: true, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + idle.appendChild(railHost); + rcol.appendChild(idle); + + bench.appendChild(rcol); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 33 (sources + pair scrap)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--cream); letter-spacing: .22em; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: stretch; justify-content: center; gap: 12px; } + .fcol { display: flex; flex-direction: column; gap: 12px; } + .fbox { + position: relative; + border: 1px solid #35302a; + border-radius: 10px; + padding: 14px 12px 10px; + display: flex; align-items: center; justify-content: center; gap: 10px; + } + .fbox .flbl { + position: absolute; top: -7px; left: 12px; + background: var(--raise); + padding: 0 7px; + color: var(--steel); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; + } + .fbox .dupre-key.ptr { width: 78px; height: 24px; font-size: 8px; letter-spacing: .08em; padding: 0; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + /* ---- wallpaper channel bank ---- */ + .wp-chans { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; } + .wp-chan { + border: 1px solid #33302b; border-radius: 8px; padding: 4px; + background: linear-gradient(180deg, var(--key-top, #23211e), var(--key-bot, #191715)); + cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; + } + .wp-chan:hover { border-color: var(--gold); } + .wp-chan.on { border-color: #a9c95f; box-shadow: 0 0 8px rgba(169,201,95,.25); } + .wp-chan .face { width: 100%; aspect-ratio: 16/10; border-radius: 4px; overflow: hidden; position: relative; background: #0c0a08; } + .wp-chan .nm { font-size: 7px; letter-spacing: .08em; color: var(--dim); text-transform: uppercase; } + .wp-chan.on .nm { color: #a9c95f; } + .half { position: absolute; inset: 0; } + .half.b { clip-path: polygon(100% 0, 100% 100%, 0 100%); } + .deck { position: absolute; inset: 0; } + .deck i { position: absolute; width: 68%; height: 68%; border-radius: 3px; border: 1px solid #0009; } + .mini-clocks { position: absolute; inset: 2px; display: grid; grid-template-columns: repeat(3,1fr); gap: 1px; } + .mini-clocks i { border-radius: 50%; border: 1px solid #4a4438; position: relative; background: #14110e; } + .mini-clocks i::after { content: ""; position: absolute; left: 50%; top: 18%; width: 1px; height: 34%; background: var(--gold); transform-origin: bottom; } + .mini-clocks i:nth-child(2)::after { transform: rotate(65deg); } + .mini-clocks i:nth-child(3)::after { transform: rotate(140deg); } + .mini-clocks i:nth-child(4)::after { transform: rotate(-50deg); } + .mini-clocks i:nth-child(5)::after { transform: rotate(110deg); } + .mini-clocks i:nth-child(6)::after { transform: rotate(-120deg); } + .clock-face { position: absolute; inset: 8% 25%; border-radius: 50%; border: 1.5px solid var(--cream); background: #14110e; } + .clock-face::before { content: ""; position: absolute; left: 50%; top: 12%; width: 1.5px; height: 40%; background: var(--cream); transform: rotate(40deg); transform-origin: bottom; } + .clock-face::after { content: ""; position: absolute; left: 50%; top: 22%; width: 1.5px; height: 30%; background: var(--gold); transform: rotate(-70deg); transform-origin: bottom; } + .watch-face { position: absolute; inset: 6% 22%; border-radius: 50%; border: 1px solid #3a5c2a; background: radial-gradient(circle, #0f1a0c, #060905); } + .watch-face::after { content: ""; position: absolute; left: 50%; top: 6%; width: 1px; height: 44%; background: #a9c95f; transform: rotate(55deg); transform-origin: bottom; box-shadow: 0 0 4px #a9c95f; } + .vid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fffc; font-size: 14px; text-shadow: 0 1px 3px #000; } + + .wp-conf { margin-top: 10px; } + .wp-lib { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } + .wp-th { aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .wp-th:hover { border-color: var(--gold); } + .wp-th.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .pair-bench { display: flex; align-items: center; gap: 10px; justify-content: center; } + .pair-slot { width: 104px; aspect-ratio: 16/10; border: 1.5px dashed #4a443a; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; } + .pair-slot.filled { border-style: solid; } + .pair-slot.arm { border-color: var(--gold); } + .pair-slot .cap { position: absolute; bottom: 2px; left: 4px; font-size: 7px; letter-spacing: .12em; color: #fff9; text-shadow: 0 1px 2px #000; } + .pair-tray { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } + .pair-chip { width: 64px; aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .pair-chip.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .wp-note { color: var(--dim); font-size: 10px; margin-top: 6px; } + .wp-row2 { display: flex; align-items: center; gap: 10px; justify-content: center; margin-top: 8px; } + .int-wheel { + width: 46px; height: 30px; border-radius: 4px; background: #0a0908; border: 1px solid #3a352c; + display: flex; align-items: center; justify-content: center; cursor: pointer; + color: #2b2418; font-weight: 700; font-size: 12px; font-family: var(--mono); + background: linear-gradient(90deg,#cfc6a8,#ece4c8 50%,#c6bd9e); + box-shadow: inset 0 4px 5px rgba(0,0,0,.35), inset 0 -4px 5px rgba(0,0,0,.35); + } + .wp-dirs { display: flex; flex-direction: column; gap: 4px; } + .wp-dir { + display: flex; align-items: center; gap: 8px; + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 6px; + padding: 5px 8px; font-size: 11px; color: var(--silver); + } + .wp-dir .path { color: var(--dim); flex: 1; } + .wp-dir .rm { color: var(--dim); cursor: pointer; font-size: 12px; padding: 0 3px; } + .wp-dir .rm:hover { color: var(--fail); } + .pair-chip .rm { + position: absolute; top: 1px; right: 2px; color: #fffa; cursor: pointer; + font-size: 10px; text-shadow: 0 1px 2px #000; display: none; padding: 0 2px; + } + .pair-chip:hover .rm { display: block; } + .pair-chip.armed { border-color: var(--fail); box-shadow: 0 0 6px rgba(203,107,77,.5); } + .pair-chip.armed .rm { display: block; color: var(--fail); } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 32 — the wallpaper channels.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "\u2013", "\u2193"]; // up / dash / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "\u2013", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "\u2013": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +if (!DS.SCENES.Slot5) DS.SCENES.Slot5 = { glyph: "", sets: {} }; +if (!DS.SCENES.Slot6) DS.SCENES.Slot6 = { glyph: "", sets: {} }; + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +// ---- wallpaper: channels state (mock persistence) ------------------------- +const SWATCH = [ + "linear-gradient(135deg,#2a3d5c,#101b2e)", "linear-gradient(135deg,#5c3a2a,#2e1810)", + "linear-gradient(135deg,#3a5c2a,#16240f)", "linear-gradient(135deg,#40364f,#1b1626)", + "linear-gradient(135deg,#1a1e2e,#05070d)", "linear-gradient(135deg,#5c5030,#2e2810)", + "linear-gradient(135deg,#6b4a5c,#2e1420)", "linear-gradient(135deg,#2a5c55,#0f2420)", + "linear-gradient(135deg,#7a6248,#3a2c1a)", "linear-gradient(135deg,#23364a,#0a1018)", +]; +const VIDEOS = ["linear-gradient(160deg,#38506b,#0d1520)", "linear-gradient(160deg,#5c4a2a,#241a08)", "linear-gradient(160deg,#2c4a3e,#0a1a12)"]; +const WP = { + channel: "single", + single: 2, + pairs: [{ day: 1, night: 4 }], + pairSel: 0, + video: 0, + interval: 30, + building: { day: null, night: null, arm: null }, + dirs: ["~/pictures/wallpaper", "~/pictures/wallpaper/day-night"], + scrapArm: null, +}; +const DIR_CANDIDATES = ["~/pictures/travel", "~/pictures/astro", "~/downloads/walls", "~/pictures/scans"]; +const INTERVALS = [5, 10, 15, 30, 60]; +const CHANNELS = [ + { id: "watch", nm: "Watch" }, + { id: "pair", nm: "Sun pair" }, + { id: "single", nm: "Single" }, + { id: "random", nm: "Random" }, + { id: "video", nm: "Video" }, + { id: "clock", nm: "Clock" }, + { id: "world", nm: "World" }, +]; + +function chanFace(id) { + const f = h("div", "face"); + if (id === "single") { const i = h("div", "half"); i.style.background = SWATCH[WP.single]; f.appendChild(i); } + else if (id === "pair") { + const p = WP.pairs[WP.pairSel] || WP.pairs[0]; + const a = h("div", "half"); a.style.background = p ? SWATCH[p.day] : "#222"; + const b = h("div", "half b"); b.style.background = p ? SWATCH[p.night] : "#111"; + f.appendChild(a); f.appendChild(b); + } else if (id === "random") { + const d = h("div", "deck"); + [[8, "18%", "4%", "-6deg"], [4, "10%", "16%", "3deg"], [0, "2%", "28%", "-2deg"]].forEach(([si, top, left, rot]) => { + const c = h("i"); c.style.cssText = `top:${top};left:${left};transform:rotate(${rot});background:${SWATCH[si]}`; + d.appendChild(c); + }); + f.appendChild(d); + } else if (id === "video") { + const i = h("div", "half"); i.style.background = VIDEOS[WP.video]; f.appendChild(i); + f.appendChild(h("div", "vid-play", "\u25B6")); + } else if (id === "clock") f.appendChild(h("div", "clock-face")); + else if (id === "world") { + const m = h("div", "mini-clocks"); + for (let i = 0; i < 6; i++) { const c = h("i"); c.style.setProperty("--r", (i * 55) + "deg"); c.querySelector; m.appendChild(c); } + f.appendChild(m); + } else if (id === "watch") f.appendChild(h("div", "watch-face")); + return f; +} + +function libGrid(sel, onPick) { + const g = h("div", "wp-lib"); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th" + (i === sel ? " sel" : "")); + t.style.background = sw; + t.onclick = () => onPick(i); + g.appendChild(t); + }); + return g; +} + +function wallpaperView() { + const sv = h("div", "ds-col"); + const head = h("div", "ds-sub-head"); + head.innerHTML = `<span class="back">\u2039 Back</span><span class="title">Wallpaper</span>`; + head.querySelector(".back").onclick = () => subview.classList.remove("open"); + sv.appendChild(head); + + const paint = () => { + body2.innerHTML = ""; + + // channel bank + const bank = h("div", "wp-chans"); + CHANNELS.forEach((c) => { + const card = h("div", "wp-chan" + (WP.channel === c.id ? " on" : "")); + card.appendChild(chanFace(c.id)); + card.appendChild(h("div", "nm", c.nm)); + card.onclick = () => { WP.channel = c.id; DS.toast("Wallpaper: " + c.nm, true); paint(); }; + bank.appendChild(card); + }); + body2.appendChild(bank); + + const conf = h("div", "wp-conf"); + if (WP.channel === "single") { + conf.appendChild(DS.engrave("Library")); + conf.appendChild(libGrid(WP.single, (i) => { WP.single = i; DS.toast("Wallpaper set", true); paint(); })); + } else if (WP.channel === "pair") { + conf.appendChild(DS.engrave("Pair press — fill both, confirm to mint")); + const bench = h("div", "pair-bench"); + ["day", "night"].forEach((k) => { + const slot = h("div", "pair-slot" + + (WP.building[k] !== null ? " filled" : "") + (WP.building.arm === k ? " arm" : "")); + if (WP.building[k] !== null) slot.style.background = SWATCH[WP.building[k]]; + slot.appendChild(h("span", "cap", k === "day" ? "\u2600 DAY" : "\u263E NIGHT")); + slot.onclick = () => { WP.building.arm = WP.building.arm === k ? null : k; paint(); }; + bench.appendChild(slot); + }); + const mint = h("button", "dupre-key", "MINT PAIR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + if (WP.building.day === null || WP.building.night === null) { DS.toast("fill both frames first", false); return; } + WP.pairs.push({ day: WP.building.day, night: WP.building.night }); + WP.pairSel = WP.pairs.length - 1; + WP.building = { day: null, night: null, arm: null }; + DS.toast("Pair minted", true); + paint(); + }; + bench.appendChild(mint); + conf.appendChild(bench); + if (WP.building.arm) { + conf.appendChild(h("div", "wp-note", "pick the " + WP.building.arm.toUpperCase() + " picture:")); + conf.appendChild(libGrid(WP.building[WP.building.arm], (i) => { + WP.building[WP.building.arm] = i; + WP.building.arm = WP.building.arm === "day" && WP.building.night === null ? "night" : null; + paint(); + })); + } + conf.appendChild(DS.engrave("Minted pairs")); + const tray = h("div", "pair-tray"); + WP.pairs.forEach((p, i) => { + const chip = h("div", "pair-chip" + (i === WP.pairSel ? " sel" : "") + (WP.scrapArm === i ? " armed" : "")); + const a = h("div", "half"); a.style.background = SWATCH[p.day]; + const b = h("div", "half b"); b.style.background = SWATCH[p.night]; + chip.appendChild(a); chip.appendChild(b); + const rm = h("span", "rm", "\u2715"); + rm.onclick = (ev) => { + ev.stopPropagation(); + if (WP.scrapArm === i) { + WP.pairs.splice(i, 1); + WP.scrapArm = null; + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair scrapped", true); + } else { + WP.scrapArm = i; + DS.toast("strike \u2715 again to scrap", false); + } + paint(); + }; + chip.appendChild(rm); + chip.onclick = () => { WP.scrapArm = null; WP.pairSel = i; DS.toast("Pair selected", true); paint(); }; + tray.appendChild(chip); + }); + conf.appendChild(tray); + conf.appendChild(h("div", "wp-note", "Swaps at local sunrise / sundown (sun-time from lat/long).")); + } else if (WP.channel === "random") { + conf.appendChild(DS.engrave("Random — draws from the whole library")); + const row = h("div", "wp-row2"); + row.appendChild(h("span", "wp-note", "next draw every")); + const wheel = h("div", "int-wheel", WP.interval); + wheel.onclick = () => { + WP.interval = INTERVALS[(INTERVALS.indexOf(WP.interval) + 1) % INTERVALS.length]; + DS.toast("Random interval: " + WP.interval + "m", true); + paint(); + }; + row.appendChild(wheel); + row.appendChild(h("span", "wp-note", "minutes")); + conf.appendChild(row); + } else if (WP.channel === "video") { + conf.appendChild(DS.engrave("Videos")); + const g = h("div", "wp-lib"); + VIDEOS.forEach((v, i) => { + const t = h("div", "wp-th" + (i === WP.video ? " sel" : "")); + t.style.background = v; + t.appendChild(h("div", "vid-play", "\u25B6")); + t.onclick = () => { WP.video = i; DS.toast("Video set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(g); + } else { + conf.appendChild(h("div", "wp-note", + WP.channel === "watch" ? "The night watch plays as the wallpaper. No options." + : WP.channel === "clock" ? "The custom clock fills the screen. No options." + : "A matrix of world clocks fills the screen. No options.")); + } + body2.appendChild(conf); + + // sources: the directories the library draws from (all channels) + const src = h("div", "wp-conf"); + src.appendChild(DS.engrave("Sources")); + const dirs = h("div", "wp-dirs"); + WP.dirs.forEach((d, i) => { + const row = h("div", "wp-dir"); + row.innerHTML = `<span>\u{f024b}</span>`; + row.appendChild(h("span", "path", d)); + const rm = h("span", "rm", "\u2715"); + rm.onclick = () => { + if (WP.dirs.length === 1) { DS.toast("keep at least one source", false); return; } + WP.dirs.splice(i, 1); + DS.toast("Source removed", true); + paint(); + }; + row.appendChild(rm); + dirs.appendChild(row); + }); + src.appendChild(dirs); + const add = h("button", "dupre-key", "ADD DIRECTORY"); + add.style.cssText = "height:24px;font-size:8px;padding:0 10px;margin-top:6px"; + add.onclick = () => { + const next = DIR_CANDIDATES.find((c) => !WP.dirs.includes(c)); + if (!next) { DS.toast("no more candidates in the mock", false); return; } + WP.dirs.push(next); + DS.toast("Source added: " + next, true); + paint(); + }; + src.appendChild(add); + body2.appendChild(src); + }; + + const body2 = h("div", "ds-col"); + sv.appendChild(body2); + paint(); + return sv; +} + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(wallpaperView()); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", scene: "Focus" }, + { id: "PRESENT", label: "PRESENT", scene: "Presentation" }, + { id: "BATTERY", label: "BATTERY", scene: "Battery" }, + { id: "NIGHT", label: "NIGHT", scene: "Night" }, + { id: "S5", label: "1", scene: "Slot5", slim: true }, + { id: "S6", label: "2", scene: "Slot6", slim: true }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, slim }) => ({ id, label, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 486, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — three encircled instrument groups ------------- + const lv = h("div", "plate"); + const bench = h("div", "bench"); + + const fbox = (label) => { + const b = h("div", "fbox"); + b.appendChild(h("span", "flbl", label)); + return b; + }; + + // BRIGHTNESS — the drum pair + const bright = fbox("Brightness"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, bright); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, bright); + }, 350); + DUPRE.drumRoller(dhost, { + title: "LEVELS ×10", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 204, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + bright.appendChild(dhost); + bench.appendChild(bright); + + const rcol = h("div", "fcol"); + + // POINTERS — console keys, horizontal + const ptr = fbox("Pointers"); + [["touchpad", "TPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const b = h("button", "dupre-key ptr", name); + if (DS.state[k]) b.classList.add("dupre-green"); + b.onclick = () => { b.blur(); DS.setToggle(k, ptr); }; + ptr.appendChild(b); + }); + rcol.appendChild(ptr); + + // IDLE — the tripper dial (size unchanged) + const idle = fbox("Idle"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 148, + legend: true, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + idle.appendChild(railHost); + rcol.appendChild(idle); + + bench.appendChild(rcol); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 34 (annotation batch one)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--gold); letter-spacing: .12em; font-size: 15px; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: stretch; justify-content: center; gap: 12px; } + .fcol { display: flex; flex-direction: column; gap: 12px; } + .fbox { + position: relative; + border: 1px solid #35302a; + border-radius: 10px; + padding: 14px 12px 10px; + display: flex; align-items: center; justify-content: center; gap: 10px; + } + .fbox .flbl { + position: absolute; top: -7px; left: 12px; + background: var(--raise); + padding: 0 7px; + color: var(--steel); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; + } + .fbox .dupre-key.ptr { width: 96px; height: 24px; font-size: 8px; letter-spacing: .08em; padding: 0; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + /* ---- wallpaper channel bank ---- */ + .wp-chans { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; } + .wp-chan { + border: 1px solid #33302b; border-radius: 8px; padding: 4px; + background: linear-gradient(180deg, var(--key-top, #23211e), var(--key-bot, #191715)); + cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; + } + .wp-chan:hover { border-color: var(--gold); } + .wp-chan.on { border-color: #a9c95f; box-shadow: 0 0 8px rgba(169,201,95,.25); } + .wp-chan .face { width: 100%; aspect-ratio: 16/10; border-radius: 4px; overflow: hidden; position: relative; background: #0c0a08; } + .wp-chan .nm { font-size: 7px; letter-spacing: .08em; color: var(--dim); text-transform: uppercase; } + .wp-chan.on .nm { color: #a9c95f; } + .half { position: absolute; inset: 0; } + .half.b { clip-path: polygon(100% 0, 100% 100%, 0 100%); } + .deck { position: absolute; inset: 0; } + .deck i { position: absolute; width: 68%; height: 68%; border-radius: 3px; border: 1px solid #0009; } + .mini-clocks { position: absolute; inset: 2px; display: grid; grid-template-columns: repeat(3,1fr); gap: 1px; } + .mini-clocks i { border-radius: 50%; border: 1px solid #4a4438; position: relative; background: #14110e; } + .mini-clocks i::after { content: ""; position: absolute; left: 50%; top: 18%; width: 1px; height: 34%; background: var(--gold); transform-origin: bottom; } + .mini-clocks i:nth-child(2)::after { transform: rotate(65deg); } + .mini-clocks i:nth-child(3)::after { transform: rotate(140deg); } + .mini-clocks i:nth-child(4)::after { transform: rotate(-50deg); } + .mini-clocks i:nth-child(5)::after { transform: rotate(110deg); } + .mini-clocks i:nth-child(6)::after { transform: rotate(-120deg); } + .clock-face { position: absolute; inset: 8% 25%; border-radius: 50%; border: 1.5px solid var(--cream); background: #14110e; } + .clock-face::before { content: ""; position: absolute; left: 50%; top: 12%; width: 1.5px; height: 40%; background: var(--cream); transform: rotate(40deg); transform-origin: bottom; } + .clock-face::after { content: ""; position: absolute; left: 50%; top: 22%; width: 1.5px; height: 30%; background: var(--gold); transform: rotate(-70deg); transform-origin: bottom; } + .watch-face { position: absolute; inset: 6% 22%; border-radius: 50%; border: 1px solid #3a5c2a; background: radial-gradient(circle, #0f1a0c, #060905); } + .watch-face::after { content: ""; position: absolute; left: 50%; top: 6%; width: 1px; height: 44%; background: #a9c95f; transform: rotate(55deg); transform-origin: bottom; box-shadow: 0 0 4px #a9c95f; } + .vid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fffc; font-size: 14px; text-shadow: 0 1px 3px #000; } + + .wp-conf { margin-top: 10px; } + .wp-lib { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } + .wp-th { aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .wp-th:hover { border-color: var(--gold); } + .wp-th.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .pair-bench { display: flex; align-items: center; gap: 10px; justify-content: center; } + .pair-slot { width: 104px; aspect-ratio: 16/10; border: 1.5px dashed #4a443a; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; } + .pair-slot.filled { border-style: solid; } + .pair-slot.arm { border-color: var(--gold); } + .pair-slot .cap { position: absolute; bottom: 2px; left: 4px; font-size: 7px; letter-spacing: .12em; color: #fff9; text-shadow: 0 1px 2px #000; } + .pair-tray { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } + .pair-chip { width: 64px; aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .pair-chip.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .wp-note { color: var(--dim); font-size: 10px; margin-top: 6px; } + .wp-row2 { display: flex; align-items: center; gap: 10px; justify-content: center; margin-top: 8px; } + .int-wheel { + width: 46px; height: 30px; border-radius: 4px; background: #0a0908; border: 1px solid #3a352c; + display: flex; align-items: center; justify-content: center; cursor: pointer; + color: #2b2418; font-weight: 700; font-size: 12px; font-family: var(--mono); + background: linear-gradient(90deg,#cfc6a8,#ece4c8 50%,#c6bd9e); + box-shadow: inset 0 4px 5px rgba(0,0,0,.35), inset 0 -4px 5px rgba(0,0,0,.35); + } + .ds-toast.empty { visibility: hidden; } + .wp-dirs { display: flex; flex-direction: column; gap: 4px; } + .wp-dir { + display: flex; align-items: center; gap: 8px; + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 6px; + padding: 5px 8px; font-size: 11px; color: var(--silver); + } + .wp-dir .path { color: var(--dim); flex: 1; } + .wp-dir .rm { color: var(--dim); cursor: pointer; font-size: 12px; padding: 0 3px; } + .wp-dir .rm:hover { color: var(--fail); } + .pair-chip .rm { + position: absolute; top: 1px; right: 2px; color: #fffa; cursor: pointer; + font-size: 10px; text-shadow: 0 1px 2px #000; display: none; padding: 0 2px; + } + .pair-chip:hover .rm { display: block; } + .pair-chip.armed { border-color: var(--fail); box-shadow: 0 0 6px rgba(203,107,77,.5); } + .pair-chip.armed .rm { display: block; color: var(--fail); } + .wp-scroll { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 5px; } + .wp-scroll .wp-th { flex: 0 0 62px; } + .wp-desc { + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 8px; + color: var(--steel); font-size: 11px; line-height: 1.5; padding: 8px 10px; margin-top: 8px; + } + .wp-prev { + width: 100%; aspect-ratio: 16/9; border-radius: 8px; border: 1px solid #2c2823; + overflow: hidden; position: relative; background: #0c0a08; + } + .wp-prev img { width: 100%; height: 100%; object-fit: cover; display: block; } + .sortkeys { display: flex; gap: 6px; margin-bottom: 6px; } + .sortkeys .dupre-key.srt { width: 30px; height: 20px; font-size: 9px; padding: 0; } + .pair-scroll .pair-cell { flex: 0 0 66px; display: flex; flex-direction: column; } + .pair-cell .pair-chip { width: 66px; } + .pair-foot { display: flex; align-items: center; justify-content: space-between; height: 14px; } + .pair-lbl { font-size: 7.5px; color: var(--steel); letter-spacing: .04em; overflow: hidden; white-space: nowrap; } + .pbtn { color: var(--dim); cursor: pointer; font-size: 10px; padding: 0 2px; } + .pbtn:hover { color: var(--gold); } + .press-side { display: flex; flex-direction: column; gap: 6px; } + .wp-lblin { + background: #0a0908; border: 1px solid #3a352c; border-radius: 5px; + color: var(--cream); font-family: var(--mono); font-size: 10px; padding: 4px 7px; width: 120px; + } + .wp-lblin::placeholder { color: var(--dim); } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 32 — the wallpaper channels.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">SETTINGS</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "=", "\u2193"]; // up / equals / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "=", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "=": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +["Slot5", "Slot6", "Slot7", "Slot8"].forEach((n) => { if (!DS.SCENES[n]) DS.SCENES[n] = { glyph: "", sets: {} }; }); + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +// ---- wallpaper: channels state (mock persistence) ------------------------- +const SWATCH = [ + "linear-gradient(135deg,#2a3d5c,#101b2e)", "linear-gradient(135deg,#5c3a2a,#2e1810)", + "linear-gradient(135deg,#3a5c2a,#16240f)", "linear-gradient(135deg,#40364f,#1b1626)", + "linear-gradient(135deg,#1a1e2e,#05070d)", "linear-gradient(135deg,#5c5030,#2e2810)", + "linear-gradient(135deg,#6b4a5c,#2e1420)", "linear-gradient(135deg,#2a5c55,#0f2420)", + "linear-gradient(135deg,#7a6248,#3a2c1a)", "linear-gradient(135deg,#23364a,#0a1018)", + "linear-gradient(135deg,#4a5a3a,#1c2410)", "linear-gradient(135deg,#5a3a4a,#240f1c)", +]; +const VIDEOS = ["linear-gradient(160deg,#38506b,#0d1520)", "linear-gradient(160deg,#5c4a2a,#241a08)", "linear-gradient(160deg,#2c4a3e,#0a1a12)"]; +const WP = { + channel: "single", + single: 2, + pairs: [{ day: 1, night: 4, label: "harbor", ts: 1 }, { day: 8, night: 9, label: "", ts: 2 }], + pairSel: 0, + video: 0, + interval: 30, + building: { day: null, night: null, label: "", arm: null }, + dirs: ["~/pictures/wallpaper", "~/pictures/wallpaper/day-night"], + sort: "time", + mintSeq: 3, +}; +const INTERVALS = [5, 10, 15, 30, 60]; +const CHANNELS = [ + { id: "watch", nm: "Watch" }, + { id: "pair", nm: "Sun pair" }, + { id: "single", nm: "Single" }, + { id: "random", nm: "Random" }, + { id: "video", nm: "Video" }, + { id: "clock", nm: "Clock" }, + { id: "world", nm: "World" }, +]; +const SOURCED = ["single", "pair", "random", "video"]; // generator modes hide sources +const DESC = { + watch: "Plays the Dupre night watch: the instrument board keeps real time while the machine idles. The same board is the lock screen's face.", + pair: "Two pictures, one day. The day picture shows from sunrise, the night picture from sundown — sun-times from your location. Pick a minted pair from the gallery, or press a new one below: fill both frames from the pictures, name it if you like, and mint.", + single: "One picture, always. Pick it from the gallery; the preview shows what ships.", + random: "Draws a fresh picture from your sources on the wheel's interval. Click the wheel to change the minutes.", + video: "Plays a video loop as the wallpaper, aerial style. Pick the loop from the gallery.", + clock: "Fills the screen with the big clock — the same clock the time module opens when clicked.", + world: "Fills the screen with a matrix of world clocks — the cities and timezones from the time module's hover.", +}; +const DIR_CANDIDATES = ["~/pictures/travel", "~/pictures/astro", "~/downloads/walls", "~/pictures/scans"]; + +function chanFace(id) { + const f = h("div", "face"); + if (id === "single") { const i = h("div", "half"); i.style.background = SWATCH[WP.single]; f.appendChild(i); } + else if (id === "pair") { + const p = WP.pairs[WP.pairSel] || WP.pairs[0]; + const a = h("div", "half"); a.style.background = p ? SWATCH[p.day] : "#222"; + const b = h("div", "half b"); b.style.background = p ? SWATCH[p.night] : "#111"; + f.appendChild(a); f.appendChild(b); + } else if (id === "random") { + const d = h("div", "deck"); + [[8, "18%", "4%", "-6deg"], [4, "10%", "16%", "3deg"], [0, "2%", "28%", "-2deg"]].forEach(([si, top, left, rot]) => { + const c = h("i"); c.style.cssText = `top:${top};left:${left};transform:rotate(${rot});background:${SWATCH[si]}`; + d.appendChild(c); + }); + f.appendChild(d); + } else if (id === "video") { + const i = h("div", "half"); i.style.background = VIDEOS[WP.video]; f.appendChild(i); + f.appendChild(h("div", "vid-play", "\u25B6")); + } else if (id === "clock") f.appendChild(h("div", "clock-face")); + else if (id === "world") { + const m = h("div", "mini-clocks"); + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + f.appendChild(m); + } else if (id === "watch") f.appendChild(h("div", "watch-face")); + return f; +} + +function scrollGallery(cls) { + return h("div", "wp-scroll" + (cls ? " " + cls : "")); +} + +function wallpaperView() { + const sv = h("div", "ds-col"); + const head = h("div", "ds-sub-head"); + head.innerHTML = `<span class="back">\u2039 Back</span><span class="title">Wallpaper</span>`; + head.querySelector(".back").onclick = () => subview.classList.remove("open"); + sv.appendChild(head); + const body2 = h("div", "ds-col"); + sv.appendChild(body2); + + const paint = () => { + body2.innerHTML = ""; + + // modes — labeled and divided like every family section + body2.appendChild(DS.engrave("Modes")); + const bank = h("div", "wp-chans"); + CHANNELS.forEach((c) => { + const card = h("div", "wp-chan" + (WP.channel === c.id ? " on" : "")); + card.appendChild(chanFace(c.id)); + card.appendChild(h("div", "nm", c.nm)); + card.onclick = () => { WP.channel = c.id; DS.toast("Wallpaper: " + c.nm, true); paint(); }; + bank.appendChild(card); + }); + body2.appendChild(bank); + + // description — helpful text for every mode, never "no options" + body2.appendChild(h("div", "wp-desc", DESC[WP.channel])); + + const conf = h("div", "wp-conf"); + const previewBox = (bg, imgSrc) => { + const pv = h("div", "wp-prev"); + if (imgSrc) { const img = h("img"); img.src = imgSrc; pv.appendChild(img); } + else if (bg) pv.style.background = bg; + return pv; + }; + + if (WP.channel === "single") { + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th" + (i === WP.single ? " sel" : "")); + t.style.background = sw; + t.onclick = () => { WP.single = i; DS.toast("Wallpaper set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(g); + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(SWATCH[WP.single])); + } else if (WP.channel === "pair") { + // minted pair gallery: side-scroll, sortable, labeled, editable + conf.appendChild(DS.engrave("Minted pairs")); + const sortRow = h("div", "sortkeys"); + [["alpha", "a"], ["time", "\u{f0150}"]].forEach(([mode, glyph]) => { + const k = h("button", "dupre-key srt" + (WP.sort === mode ? " dupre-on" : ""), glyph); + k.onclick = () => { k.blur(); WP.sort = mode; paint(); }; + sortRow.appendChild(k); + }); + conf.appendChild(sortRow); + const tray = scrollGallery("pair-scroll"); + const order = WP.pairs.map((p, i) => ({ p, i })); + if (WP.sort === "alpha") order.sort((x, y) => (x.p.label || "\uffff").localeCompare(y.p.label || "\uffff")); + else order.sort((x, y) => y.p.ts - x.p.ts); + order.forEach(({ p, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip" + (i === WP.pairSel ? " sel" : "")); + const a = h("div", "half"); a.style.background = SWATCH[p.day]; + const b = h("div", "half b"); b.style.background = SWATCH[p.night]; + chip.appendChild(a); chip.appendChild(b); + chip.onclick = () => { WP.pairSel = i; DS.toast("Pair selected", true); paint(); }; + cell.appendChild(chip); + const foot = h("div", "pair-foot"); + const ed = h("span", "pbtn", "~"); + ed.title = "edit — reopens in the press"; + ed.onclick = () => { + WP.building = { day: p.day, night: p.night, label: p.label, arm: null }; + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair opened in the press", true); + paint(); + }; + foot.appendChild(ed); + foot.appendChild(h("span", "pair-lbl", p.label || "")); + const del = h("span", "pbtn", "\u2212"); + del.title = "delete — recreate to get it back"; + del.onclick = () => { + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair deleted", true); + paint(); + }; + foot.appendChild(del); + cell.appendChild(foot); + tray.appendChild(cell); + }); + conf.appendChild(tray); + + // the pair press, with its label field + conf.appendChild(DS.engrave("Pair press")); + const bench = h("div", "pair-bench"); + ["day", "night"].forEach((k) => { + const slot = h("div", "pair-slot" + + (WP.building[k] !== null ? " filled" : "") + (WP.building.arm === k ? " arm" : "")); + if (WP.building[k] !== null) slot.style.background = SWATCH[WP.building[k]]; + slot.appendChild(h("span", "cap", k === "day" ? "\u2600 DAY" : "\u263E NIGHT")); + slot.onclick = () => { WP.building.arm = WP.building.arm === k ? null : k; paint(); }; + bench.appendChild(slot); + }); + const side = h("div", "press-side"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.building.label || ""; + lbl.oninput = () => { WP.building.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT PAIR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + if (WP.building.day === null || WP.building.night === null) { DS.toast("fill both frames first", false); return; } + WP.pairs.push({ day: WP.building.day, night: WP.building.night, label: WP.building.label || "", ts: WP.mintSeq++ }); + WP.pairSel = WP.pairs.length - 1; + WP.building = { day: null, night: null, label: "", arm: null }; + DS.toast("Pair minted", true); + paint(); + }; + side.appendChild(mint); + bench.appendChild(side); + conf.appendChild(bench); + if (WP.building.arm) conf.appendChild(h("div", "wp-note", "click a picture below to fill the " + WP.building.arm.toUpperCase() + " frame")); + + // the picture gallery that feeds the press + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th"); + t.style.background = sw; + t.onclick = () => { + if (!WP.building.arm) { DS.toast("click a DAY or NIGHT frame first", false); return; } + WP.building[WP.building.arm] = i; + WP.building.arm = WP.building.arm === "day" && WP.building.night === null ? "night" : null; + paint(); + }; + g.appendChild(t); + }); + conf.appendChild(g); + conf.appendChild(h("div", "wp-note", "Only picture types the lock screen can display appear here.")); + } else if (WP.channel === "random") { + const row = h("div", "wp-row2"); + row.appendChild(h("span", "wp-note", "next draw every")); + const wheel = h("div", "int-wheel", WP.interval); + wheel.onclick = () => { + WP.interval = INTERVALS[(INTERVALS.indexOf(WP.interval) + 1) % INTERVALS.length]; + DS.toast("Random interval: " + WP.interval + "m", true); + paint(); + }; + row.appendChild(wheel); + row.appendChild(h("span", "wp-note", "minutes")); + conf.appendChild(row); + } else if (WP.channel === "video") { + conf.appendChild(DS.engrave("Videos")); + const g = scrollGallery(); + VIDEOS.forEach((v, i) => { + const t = h("div", "wp-th" + (i === WP.video ? " sel" : "")); + t.style.background = v; + t.appendChild(h("div", "vid-play", "\u25B6")); + t.onclick = () => { WP.video = i; DS.toast("Video set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(g); + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox(VIDEOS[WP.video]); + pv.appendChild(h("div", "vid-play", "\u25B6")); + conf.appendChild(pv); + } else if (WP.channel === "watch") { + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(null, "2026-07-22-watch-preview.png")); + } else if (WP.channel === "clock") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const cf = h("div", "clock-face"); cf.style.inset = "6% 34%"; + pv.appendChild(cf); + conf.appendChild(pv); + } else if (WP.channel === "world") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const m = h("div", "mini-clocks"); m.style.inset = "8%"; + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + pv.appendChild(m); + conf.appendChild(pv); + } + body2.appendChild(conf); + + // sources: only for the modes that draw from directories + if (SOURCED.includes(WP.channel)) { + const src = h("div", "wp-conf"); + src.appendChild(DS.engrave("Sources")); + const dirs = h("div", "wp-dirs"); + WP.dirs.forEach((d, i) => { + const row = h("div", "wp-dir"); + row.innerHTML = `<span>\u{f024b}</span>`; + row.appendChild(h("span", "path", d)); + const rm = h("span", "rm", "\u2715"); + rm.onclick = () => { + if (WP.dirs.length === 1) { DS.toast("keep at least one source", false); return; } + WP.dirs.splice(i, 1); + DS.toast("Source removed", true); + paint(); + }; + row.appendChild(rm); + dirs.appendChild(row); + }); + src.appendChild(dirs); + const add = h("button", "dupre-key", "ADD DIRECTORY"); + add.style.cssText = "height:24px;font-size:8px;padding:0 10px;margin-top:6px"; + add.onclick = () => { + add.blur(); + const next = DIR_CANDIDATES.find((c) => !WP.dirs.includes(c)); + if (!next) { DS.toast("no more candidates in the mock", false); return; } + WP.dirs.push(next); + DS.toast("Source added: " + next, true); + paint(); + }; + src.appendChild(add); + body2.appendChild(src); + } + }; + paint(); + return sv; +} + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(wallpaperView()); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", glyph: "\u{f0210}", scene: "Focus" }, // crosshairs — focus + { id: "PRESENT", label: "PRESENT", glyph: "\u{f0379}", scene: "Presentation" }, // projector screen + { id: "BATTERY", label: "BATTERY", glyph: "\u{f007e}", scene: "Battery" }, // half battery + { id: "NIGHT", label: "NIGHT", glyph: "\u{f0594}", scene: "Night" }, // crescent moon + { id: "S5", label: "1", scene: "Slot5", slim: true }, + { id: "S6", label: "2", scene: "Slot6", slim: true }, + { id: "S7", label: "3", scene: "Slot7", slim: true }, + { id: "S8", label: "4", scene: "Slot8", slim: true }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, glyph, slim }) => ({ id, label, glyph, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 452, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — three encircled instrument groups ------------- + const lv = h("div", "plate"); + const bench = h("div", "bench"); + + const fbox = (label) => { + const b = h("div", "fbox"); + b.appendChild(h("span", "flbl", label)); + return b; + }; + + // BRIGHTNESS — the drum pair + const bright = fbox("Brightness"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, bright); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, bright); + }, 350); + DUPRE.drumRoller(dhost, { + title: "", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 210, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + bright.appendChild(dhost); + bench.appendChild(bright); + + const rcol = h("div", "fcol"); + + // POINTERS — console keys, horizontal + const ptr = fbox("Pointers"); + [["touchpad", "TOUCHPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const b = h("button", "dupre-key ptr", name); + if (DS.state[k]) b.classList.add("dupre-green"); + b.onclick = () => { b.blur(); DS.setToggle(k, ptr); }; + ptr.appendChild(b); + }); + rcol.appendChild(ptr); + + // IDLE — the tripper dial (size unchanged) + const idle = fbox("Idle"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 148, + legend: true, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + idle.appendChild(railHost); + rcol.appendChild(idle); + + bench.appendChild(rcol); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 35 (FINAL candidate)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--gold); letter-spacing: .12em; font-size: 15px; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: stretch; justify-content: center; gap: 12px; } + .fcol { display: flex; flex-direction: column; gap: 12px; } + .fbox { + position: relative; + border: 1px solid #35302a; + border-radius: 10px; + padding: 14px 12px 10px; + display: flex; align-items: center; justify-content: center; gap: 10px; + } + .fbox .flbl { + position: absolute; top: -7px; left: 12px; + background: var(--raise); + padding: 0 7px; + color: var(--steel); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; + } + .fbox .dupre-key.ptr { width: 96px; height: 24px; font-size: 8px; letter-spacing: .08em; padding: 0; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + /* ---- wallpaper channel bank ---- */ + .wp-chans { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; } + .wp-chan { + border: 1px solid #33302b; border-radius: 8px; padding: 4px; + background: linear-gradient(180deg, var(--key-top, #23211e), var(--key-bot, #191715)); + cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; + } + .wp-chan:hover { border-color: var(--gold); } + .wp-chan.on { border-color: #a9c95f; box-shadow: 0 0 8px rgba(169,201,95,.25); } + .wp-chan .face { width: 100%; aspect-ratio: 16/10; border-radius: 4px; overflow: hidden; position: relative; background: #0c0a08; } + .wp-chan .nm { font-size: 7px; letter-spacing: .08em; color: var(--dim); text-transform: uppercase; } + .wp-chan.on .nm { color: #a9c95f; } + .half { position: absolute; inset: 0; } + .half.b { clip-path: polygon(100% 0, 100% 100%, 0 100%); } + .deck { position: absolute; inset: 0; } + .deck i { position: absolute; width: 68%; height: 68%; border-radius: 3px; border: 1px solid #0009; } + .mini-clocks { position: absolute; inset: 2px; display: grid; grid-template-columns: repeat(3,1fr); gap: 1px; } + .mini-clocks i { border-radius: 50%; border: 1px solid #4a4438; position: relative; background: #14110e; } + .mini-clocks i::after { content: ""; position: absolute; left: 50%; top: 18%; width: 1px; height: 34%; background: var(--gold); transform-origin: bottom; } + .mini-clocks i:nth-child(2)::after { transform: rotate(65deg); } + .mini-clocks i:nth-child(3)::after { transform: rotate(140deg); } + .mini-clocks i:nth-child(4)::after { transform: rotate(-50deg); } + .mini-clocks i:nth-child(5)::after { transform: rotate(110deg); } + .mini-clocks i:nth-child(6)::after { transform: rotate(-120deg); } + .clock-face { position: absolute; inset: 8% 25%; border-radius: 50%; border: 1.5px solid var(--cream); background: #14110e; } + .clock-face::before { content: ""; position: absolute; left: 50%; top: 12%; width: 1.5px; height: 40%; background: var(--cream); transform: rotate(40deg); transform-origin: bottom; } + .clock-face::after { content: ""; position: absolute; left: 50%; top: 22%; width: 1.5px; height: 30%; background: var(--gold); transform: rotate(-70deg); transform-origin: bottom; } + .watch-face { position: absolute; inset: 6% 22%; border-radius: 50%; border: 1px solid #3a5c2a; background: radial-gradient(circle, #0f1a0c, #060905); } + .watch-face::after { content: ""; position: absolute; left: 50%; top: 6%; width: 1px; height: 44%; background: #a9c95f; transform: rotate(55deg); transform-origin: bottom; box-shadow: 0 0 4px #a9c95f; } + .vid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fffc; font-size: 14px; text-shadow: 0 1px 3px #000; } + + .wp-conf { margin-top: 10px; } + .wp-lib { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } + .wp-th { aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .wp-th:hover { border-color: var(--gold); } + .wp-th.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .pair-bench { display: flex; align-items: center; gap: 10px; justify-content: center; } + .pair-slot { width: 104px; aspect-ratio: 16/10; border: 1.5px dashed #4a443a; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; } + .pair-slot.filled { border-style: solid; } + .pair-slot.arm { border-color: var(--gold); } + .pair-slot .cap { position: absolute; bottom: 2px; left: 4px; font-size: 7px; letter-spacing: .12em; color: #fff9; text-shadow: 0 1px 2px #000; } + .pair-tray { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } + .pair-chip { width: 64px; aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .pair-chip.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .wp-note { color: var(--dim); font-size: 10px; margin-top: 6px; } + .wp-row2 { display: flex; align-items: center; gap: 10px; justify-content: center; margin-top: 8px; } + .int-wheel { + width: 46px; height: 30px; border-radius: 4px; background: #0a0908; border: 1px solid #3a352c; + display: flex; align-items: center; justify-content: center; cursor: pointer; + color: #2b2418; font-weight: 700; font-size: 12px; font-family: var(--mono); + background: linear-gradient(90deg,#cfc6a8,#ece4c8 50%,#c6bd9e); + box-shadow: inset 0 4px 5px rgba(0,0,0,.35), inset 0 -4px 5px rgba(0,0,0,.35); + } + .ds-toast.empty { visibility: hidden; } + .wp-dirs { display: flex; flex-direction: column; gap: 4px; } + .wp-dir { + display: flex; align-items: center; gap: 8px; + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 6px; + padding: 5px 8px; font-size: 11px; color: var(--silver); + } + .wp-dir .path { color: var(--dim); flex: 1; } + .wp-dir .rm { color: var(--dim); cursor: pointer; font-size: 12px; padding: 0 3px; } + .wp-dir .rm:hover { color: var(--fail); } + .pair-chip .rm { + position: absolute; top: 1px; right: 2px; color: #fffa; cursor: pointer; + font-size: 10px; text-shadow: 0 1px 2px #000; display: none; padding: 0 2px; + } + .pair-chip:hover .rm { display: block; } + .pair-chip.armed { border-color: var(--fail); box-shadow: 0 0 6px rgba(203,107,77,.5); } + .pair-chip.armed .rm { display: block; color: var(--fail); } + .wp-scroll { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 5px; } + .wp-scroll .wp-th { flex: 0 0 62px; } + .wp-desc { + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 8px; + color: var(--steel); font-size: 11px; line-height: 1.5; padding: 8px 10px; margin-top: 8px; + } + .wp-prev { + width: 100%; aspect-ratio: 16/9; border-radius: 8px; border: 1px solid #2c2823; + overflow: hidden; position: relative; background: #0c0a08; + } + .wp-prev img { width: 100%; height: 100%; object-fit: cover; display: block; } + .sortkeys { display: flex; gap: 6px; margin-bottom: 6px; } + .sortkeys .dupre-key.srt { width: 30px; height: 20px; font-size: 9px; padding: 0; } + .pair-scroll .pair-cell { flex: 0 0 66px; display: flex; flex-direction: column; } + .pair-cell .pair-chip { width: 66px; } + .pair-foot { display: flex; align-items: center; justify-content: space-between; height: 14px; } + .pair-lbl { font-size: 7.5px; color: var(--steel); letter-spacing: .04em; overflow: hidden; white-space: nowrap; } + .pbtn { color: var(--dim); cursor: pointer; font-size: 10px; padding: 0 2px; } + .pbtn:hover { color: var(--gold); } + .press-side { display: flex; flex-direction: column; gap: 6px; } + .wp-lblin { + background: #0a0908; border: 1px solid #3a352c; border-radius: 5px; + color: var(--cream); font-family: var(--mono); font-size: 10px; padding: 4px 7px; width: 120px; + } + .wp-lblin::placeholder { color: var(--dim); } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 35 — the final candidate.</b> 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. + </div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">SETTINGS</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "=", "\u2193"]; // up / equals / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "=", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "=": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +["Slot5", "Slot6", "Slot7", "Slot8"].forEach((n) => { if (!DS.SCENES[n]) DS.SCENES[n] = { glyph: "", sets: {} }; }); + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +// ---- wallpaper: channels state (mock persistence) ------------------------- +const SWATCH = [ + "linear-gradient(135deg,#2a3d5c,#101b2e)", "linear-gradient(135deg,#5c3a2a,#2e1810)", + "linear-gradient(135deg,#3a5c2a,#16240f)", "linear-gradient(135deg,#40364f,#1b1626)", + "linear-gradient(135deg,#1a1e2e,#05070d)", "linear-gradient(135deg,#5c5030,#2e2810)", + "linear-gradient(135deg,#6b4a5c,#2e1420)", "linear-gradient(135deg,#2a5c55,#0f2420)", + "linear-gradient(135deg,#7a6248,#3a2c1a)", "linear-gradient(135deg,#23364a,#0a1018)", + "linear-gradient(135deg,#4a5a3a,#1c2410)", "linear-gradient(135deg,#5a3a4a,#240f1c)", +]; +const VIDEOS = ["linear-gradient(160deg,#38506b,#0d1520)", "linear-gradient(160deg,#5c4a2a,#241a08)", "linear-gradient(160deg,#2c4a3e,#0a1a12)"]; +const WP = { + channel: "single", + single: 2, + pairs: [{ day: 1, night: 4, label: "harbor", ts: 1 }, { day: 8, night: 9, label: "", ts: 2 }], + pairSel: 0, + video: 0, + interval: 30, + building: { day: null, night: null, label: "", arm: null }, + dirs: ["~/pictures/wallpaper", "~/pictures/wallpaper/day-night"], + sort: "time", + mintSeq: 3, +}; +const INTERVALS = [5, 10, 15, 30, 60]; +const CHANNELS = [ + { id: "watch", nm: "Watch" }, + { id: "pair", nm: "Sun pair" }, + { id: "single", nm: "Single" }, + { id: "random", nm: "Random" }, + { id: "video", nm: "Video" }, + { id: "clock", nm: "Clock" }, + { id: "world", nm: "World" }, +]; +const SOURCED = ["single", "pair", "random", "video"]; // generator modes hide sources +const DESC = { + watch: "Plays the Dupre night watch: the instrument board keeps real time while the machine idles. The same board is the lock screen's face.", + pair: "Two pictures, one day. The day picture shows from sunrise, the night picture from sundown — sun-times from your location. Pick a minted pair from the gallery, or press a new one below: fill both frames from the pictures, name it if you like, and mint.", + single: "One picture, always. Pick it from the gallery; the preview shows what ships.", + random: "Draws a fresh picture from your sources on the wheel's interval. Click the wheel to change the minutes.", + video: "Plays a video loop as the wallpaper, aerial style. Pick the loop from the gallery.", + clock: "Fills the screen with the big clock — the same clock the time module opens when clicked.", + world: "Fills the screen with a matrix of world clocks — the cities and timezones from the time module's hover.", +}; +const DIR_CANDIDATES = ["~/pictures/travel", "~/pictures/astro", "~/downloads/walls", "~/pictures/scans"]; + +function chanFace(id) { + const f = h("div", "face"); + if (id === "single") { const i = h("div", "half"); i.style.background = SWATCH[WP.single]; f.appendChild(i); } + else if (id === "pair") { + const p = WP.pairs[WP.pairSel] || WP.pairs[0]; + const a = h("div", "half"); a.style.background = p ? SWATCH[p.day] : "#222"; + const b = h("div", "half b"); b.style.background = p ? SWATCH[p.night] : "#111"; + f.appendChild(a); f.appendChild(b); + } else if (id === "random") { + const d = h("div", "deck"); + [[8, "18%", "4%", "-6deg"], [4, "10%", "16%", "3deg"], [0, "2%", "28%", "-2deg"]].forEach(([si, top, left, rot]) => { + const c = h("i"); c.style.cssText = `top:${top};left:${left};transform:rotate(${rot});background:${SWATCH[si]}`; + d.appendChild(c); + }); + f.appendChild(d); + } else if (id === "video") { + const i = h("div", "half"); i.style.background = VIDEOS[WP.video]; f.appendChild(i); + f.appendChild(h("div", "vid-play", "\u25B6")); + } else if (id === "clock") f.appendChild(h("div", "clock-face")); + else if (id === "world") { + const m = h("div", "mini-clocks"); + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + f.appendChild(m); + } else if (id === "watch") f.appendChild(h("div", "watch-face")); + return f; +} + +function scrollGallery(cls) { + return h("div", "wp-scroll" + (cls ? " " + cls : "")); +} + +function wallpaperView() { + const sv = h("div", "ds-col"); + const head = h("div", "ds-sub-head"); + head.innerHTML = `<span class="back">\u2039 Back</span><span class="title">Wallpaper</span>`; + head.querySelector(".back").onclick = () => subview.classList.remove("open"); + sv.appendChild(head); + const body2 = h("div", "ds-col"); + sv.appendChild(body2); + + const paint = () => { + body2.innerHTML = ""; + + // modes — labeled and divided like every family section + body2.appendChild(DS.engrave("Modes")); + const bank = h("div", "wp-chans"); + CHANNELS.forEach((c) => { + const card = h("div", "wp-chan" + (WP.channel === c.id ? " on" : "")); + card.appendChild(chanFace(c.id)); + card.appendChild(h("div", "nm", c.nm)); + card.onclick = () => { WP.channel = c.id; DS.toast("Wallpaper: " + c.nm, true); paint(); }; + bank.appendChild(card); + }); + body2.appendChild(bank); + + // description — helpful text for every mode, never "no options" + body2.appendChild(h("div", "wp-desc", DESC[WP.channel])); + + const conf = h("div", "wp-conf"); + const previewBox = (bg, imgSrc) => { + const pv = h("div", "wp-prev"); + if (imgSrc) { const img = h("img"); img.src = imgSrc; pv.appendChild(img); } + else if (bg) pv.style.background = bg; + return pv; + }; + + if (WP.channel === "single") { + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th" + (i === WP.single ? " sel" : "")); + t.style.background = sw; + t.onclick = () => { WP.single = i; DS.toast("Wallpaper set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(g); + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(SWATCH[WP.single])); + } else if (WP.channel === "pair") { + // minted pair gallery: side-scroll, sortable, labeled, editable + conf.appendChild(DS.engrave("Minted pairs")); + const sortRow = h("div", "sortkeys"); + [["alpha", "a"], ["time", "\u{f0150}"]].forEach(([mode, glyph]) => { + const k = h("button", "dupre-key srt" + (WP.sort === mode ? " dupre-on" : ""), glyph); + k.onclick = () => { k.blur(); WP.sort = mode; paint(); }; + sortRow.appendChild(k); + }); + conf.appendChild(sortRow); + const tray = scrollGallery("pair-scroll"); + const order = WP.pairs.map((p, i) => ({ p, i })); + if (WP.sort === "alpha") order.sort((x, y) => (x.p.label || "\uffff").localeCompare(y.p.label || "\uffff")); + else order.sort((x, y) => y.p.ts - x.p.ts); + order.forEach(({ p, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip" + (i === WP.pairSel ? " sel" : "")); + const a = h("div", "half"); a.style.background = SWATCH[p.day]; + const b = h("div", "half b"); b.style.background = SWATCH[p.night]; + chip.appendChild(a); chip.appendChild(b); + chip.onclick = () => { WP.pairSel = i; DS.toast("Pair selected", true); paint(); }; + cell.appendChild(chip); + const foot = h("div", "pair-foot"); + const ed = h("span", "pbtn", "~"); + ed.title = "edit — reopens in the press"; + ed.onclick = () => { + WP.building = { day: p.day, night: p.night, label: p.label, arm: null }; + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair opened in the press", true); + paint(); + }; + foot.appendChild(ed); + foot.appendChild(h("span", "pair-lbl", p.label || "")); + const del = h("span", "pbtn", "\u2212"); + del.title = "delete — recreate to get it back"; + del.onclick = () => { + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair deleted", true); + paint(); + }; + foot.appendChild(del); + cell.appendChild(foot); + tray.appendChild(cell); + }); + conf.appendChild(tray); + + // the pair press, with its label field + conf.appendChild(DS.engrave("Pair press")); + const bench = h("div", "pair-bench"); + ["day", "night"].forEach((k) => { + const slot = h("div", "pair-slot" + + (WP.building[k] !== null ? " filled" : "") + (WP.building.arm === k ? " arm" : "")); + if (WP.building[k] !== null) slot.style.background = SWATCH[WP.building[k]]; + slot.appendChild(h("span", "cap", k === "day" ? "\u2600 DAY" : "\u263E NIGHT")); + slot.onclick = () => { WP.building.arm = WP.building.arm === k ? null : k; paint(); }; + bench.appendChild(slot); + }); + const side = h("div", "press-side"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.building.label || ""; + lbl.oninput = () => { WP.building.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT PAIR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + if (WP.building.day === null || WP.building.night === null) { DS.toast("fill both frames first", false); return; } + WP.pairs.push({ day: WP.building.day, night: WP.building.night, label: WP.building.label || "", ts: WP.mintSeq++ }); + WP.pairSel = WP.pairs.length - 1; + WP.building = { day: null, night: null, label: "", arm: null }; + DS.toast("Pair minted", true); + paint(); + }; + side.appendChild(mint); + bench.appendChild(side); + conf.appendChild(bench); + if (WP.building.arm) conf.appendChild(h("div", "wp-note", "click a picture below to fill the " + WP.building.arm.toUpperCase() + " frame")); + + // the picture gallery that feeds the press + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th"); + t.style.background = sw; + t.onclick = () => { + if (!WP.building.arm) { DS.toast("click a DAY or NIGHT frame first", false); return; } + WP.building[WP.building.arm] = i; + WP.building.arm = WP.building.arm === "day" && WP.building.night === null ? "night" : null; + paint(); + }; + g.appendChild(t); + }); + conf.appendChild(g); + conf.appendChild(h("div", "wp-note", "Only picture types the lock screen can display appear here.")); + } else if (WP.channel === "random") { + const row = h("div", "wp-row2"); + row.appendChild(h("span", "wp-note", "next draw every")); + const wheel = h("div", "int-wheel", WP.interval); + wheel.onclick = () => { + WP.interval = INTERVALS[(INTERVALS.indexOf(WP.interval) + 1) % INTERVALS.length]; + DS.toast("Random interval: " + WP.interval + "m", true); + paint(); + }; + row.appendChild(wheel); + row.appendChild(h("span", "wp-note", "minutes")); + conf.appendChild(row); + } else if (WP.channel === "video") { + conf.appendChild(DS.engrave("Videos")); + const g = scrollGallery(); + VIDEOS.forEach((v, i) => { + const t = h("div", "wp-th" + (i === WP.video ? " sel" : "")); + t.style.background = v; + t.appendChild(h("div", "vid-play", "\u25B6")); + t.onclick = () => { WP.video = i; DS.toast("Video set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(g); + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox(VIDEOS[WP.video]); + pv.appendChild(h("div", "vid-play", "\u25B6")); + conf.appendChild(pv); + } else if (WP.channel === "watch") { + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(null, "2026-07-22-watch-preview.png")); + } else if (WP.channel === "clock") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const cf = h("div", "clock-face"); cf.style.inset = "6% 34%"; + pv.appendChild(cf); + conf.appendChild(pv); + } else if (WP.channel === "world") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const m = h("div", "mini-clocks"); m.style.inset = "8%"; + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + pv.appendChild(m); + conf.appendChild(pv); + } + body2.appendChild(conf); + + // sources: only for the modes that draw from directories + if (SOURCED.includes(WP.channel)) { + const src = h("div", "wp-conf"); + src.appendChild(DS.engrave("Sources")); + const dirs = h("div", "wp-dirs"); + WP.dirs.forEach((d, i) => { + const row = h("div", "wp-dir"); + row.innerHTML = `<span>\u{f024b}</span>`; + row.appendChild(h("span", "path", d)); + const rm = h("span", "rm", "\u2715"); + rm.onclick = () => { + if (WP.dirs.length === 1) { DS.toast("keep at least one source", false); return; } + WP.dirs.splice(i, 1); + DS.toast("Source removed", true); + paint(); + }; + row.appendChild(rm); + dirs.appendChild(row); + }); + src.appendChild(dirs); + const add = h("button", "dupre-key", "ADD DIRECTORY"); + add.style.cssText = "height:24px;font-size:8px;padding:0 10px;margin-top:6px"; + add.onclick = () => { + add.blur(); + const next = DIR_CANDIDATES.find((c) => !WP.dirs.includes(c)); + if (!next) { DS.toast("no more candidates in the mock", false); return; } + WP.dirs.push(next); + DS.toast("Source added: " + next, true); + paint(); + }; + src.appendChild(add); + body2.appendChild(src); + } + }; + paint(); + return sv; +} + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(wallpaperView()); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", glyph: "\u{f0210}", scene: "Focus" }, // crosshairs — focus + { id: "PRESENT", label: "PRESENT", glyph: "\u{f0379}", scene: "Presentation" }, // projector screen + { id: "BATTERY", label: "BATTERY", glyph: "\u{f007e}", scene: "Battery" }, // half battery + { id: "NIGHT", label: "NIGHT", glyph: "\u{f0594}", scene: "Night" }, // crescent moon + { id: "S5", label: "1", scene: "Slot5", slim: true }, + { id: "S6", label: "2", scene: "Slot6", slim: true }, + { id: "S7", label: "3", scene: "Slot7", slim: true }, + { id: "S8", label: "4", scene: "Slot8", slim: true }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, glyph, slim }) => ({ id, label, glyph, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 452, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — three encircled instrument groups ------------- + const lv = h("div", "plate"); + const bench = h("div", "bench"); + + const fbox = (label) => { + const b = h("div", "fbox"); + b.appendChild(h("span", "flbl", label)); + return b; + }; + + // BRIGHTNESS — the drum pair + const bright = fbox("Brightness"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, bright); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, bright); + }, 350); + DUPRE.drumRoller(dhost, { + title: "", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 210, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + bright.appendChild(dhost); + bench.appendChild(bright); + + const rcol = h("div", "fcol"); + + // POINTERS — console keys, horizontal + const ptr = fbox("Pointers"); + [["touchpad", "TOUCHPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const b = h("button", "dupre-key ptr", name); + if (DS.state[k]) b.classList.add("dupre-green"); + b.onclick = () => { b.blur(); DS.setToggle(k, ptr); }; + ptr.appendChild(b); + }); + rcol.appendChild(ptr); + + // IDLE — the tripper dial (size unchanged) + const idle = fbox("Idle"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 148, + legend: true, + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + idle.appendChild(railHost); + rcol.appendChild(idle); + + bench.appendChild(rcol); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 36 (annotation batch two)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--gold); letter-spacing: .12em; font-size: 15px; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: stretch; justify-content: center; gap: 12px; } + .fcol { display: flex; flex-direction: column; gap: 12px; } + .fbox { + position: relative; + border: 1px solid #35302a; + border-radius: 10px; + padding: 14px 12px 10px; + display: flex; align-items: center; justify-content: center; gap: 10px; + } + .fbox .flbl { + position: absolute; top: -7px; left: 12px; + background: var(--raise); + padding: 0 7px; + color: var(--steel); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; + } + .fbox .dupre-key.ptr { width: 96px; height: 24px; font-size: 8px; letter-spacing: .08em; padding: 0; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + /* ---- wallpaper channel bank ---- */ + .wp-chans { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; } + .wp-chan { + border: 1px solid #33302b; border-radius: 8px; padding: 4px; + background: linear-gradient(180deg, var(--key-top, #23211e), var(--key-bot, #191715)); + cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; + } + .wp-chan:hover { border-color: var(--gold); } + .wp-chan.on { border-color: #a9c95f; box-shadow: 0 0 8px rgba(169,201,95,.25); } + .wp-chan .face { width: 100%; aspect-ratio: 16/10; border-radius: 4px; overflow: hidden; position: relative; background: #0c0a08; } + .wp-chan .nm { font-size: 7px; letter-spacing: .08em; color: var(--dim); text-transform: uppercase; } + .wp-chan.on .nm { color: #a9c95f; } + .half { position: absolute; inset: 0; } + .half.b { clip-path: polygon(100% 0, 100% 100%, 0 100%); } + .deck { position: absolute; inset: 0; } + .deck i { position: absolute; width: 68%; height: 68%; border-radius: 3px; border: 1px solid #0009; } + .mini-clocks { position: absolute; inset: 2px; display: grid; grid-template-columns: repeat(3,1fr); gap: 1px; } + .mini-clocks i { border-radius: 50%; border: 1px solid #4a4438; position: relative; background: #14110e; } + .mini-clocks i::after { content: ""; position: absolute; left: 50%; top: 18%; width: 1px; height: 34%; background: var(--gold); transform-origin: bottom; } + .mini-clocks i:nth-child(2)::after { transform: rotate(65deg); } + .mini-clocks i:nth-child(3)::after { transform: rotate(140deg); } + .mini-clocks i:nth-child(4)::after { transform: rotate(-50deg); } + .mini-clocks i:nth-child(5)::after { transform: rotate(110deg); } + .mini-clocks i:nth-child(6)::after { transform: rotate(-120deg); } + .clock-face { position: absolute; inset: 8% 25%; border-radius: 50%; border: 1.5px solid var(--cream); background: #14110e; } + .clock-face::before { content: ""; position: absolute; left: 50%; top: 12%; width: 1.5px; height: 40%; background: var(--cream); transform: rotate(40deg); transform-origin: bottom; } + .clock-face::after { content: ""; position: absolute; left: 50%; top: 22%; width: 1.5px; height: 30%; background: var(--gold); transform: rotate(-70deg); transform-origin: bottom; } + .watch-face { position: absolute; inset: 6% 22%; border-radius: 50%; border: 1px solid #3a5c2a; background: radial-gradient(circle, #0f1a0c, #060905); } + .watch-face::after { content: ""; position: absolute; left: 50%; top: 6%; width: 1px; height: 44%; background: #a9c95f; transform: rotate(55deg); transform-origin: bottom; box-shadow: 0 0 4px #a9c95f; } + .vid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fffc; font-size: 14px; text-shadow: 0 1px 3px #000; } + + .wp-conf { margin-top: 10px; } + .wp-lib { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } + .wp-th { aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .wp-th:hover { border-color: var(--gold); } + .wp-th.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .pair-bench { display: flex; align-items: center; gap: 10px; justify-content: center; } + .pair-slot { width: 104px; aspect-ratio: 16/10; border: 1.5px dashed #4a443a; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; } + .pair-slot.filled { border-style: solid; } + .pair-slot.arm { border-color: var(--gold); } + .pair-slot .cap { position: absolute; bottom: 2px; left: 4px; font-size: 7px; letter-spacing: .12em; color: #fff9; text-shadow: 0 1px 2px #000; } + .pair-tray { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } + .pair-chip { width: 64px; aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .pair-chip.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .wp-note { color: var(--dim); font-size: 10px; margin-top: 6px; } + .wp-row2 { display: flex; align-items: center; gap: 10px; justify-content: center; margin-top: 8px; } + .int-wheel { + width: 46px; height: 30px; border-radius: 4px; background: #0a0908; border: 1px solid #3a352c; + display: flex; align-items: center; justify-content: center; cursor: pointer; + color: #2b2418; font-weight: 700; font-size: 12px; font-family: var(--mono); + background: linear-gradient(90deg,#cfc6a8,#ece4c8 50%,#c6bd9e); + box-shadow: inset 0 4px 5px rgba(0,0,0,.35), inset 0 -4px 5px rgba(0,0,0,.35); + } + .ds-toast.empty { visibility: hidden; } + .wp-dirs { display: flex; flex-direction: column; gap: 4px; max-height: 100px; overflow-y: auto; padding-right: 4px; } + .wp-dirs::-webkit-scrollbar { width: 7px; } + .wp-dirs::-webkit-scrollbar-track { background: #0a0908; border-radius: 4px; } + .wp-dirs::-webkit-scrollbar-thumb { background: #3a352c; border-radius: 4px; } + .wp-dir { + display: flex; align-items: center; gap: 8px; + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 6px; + padding: 5px 8px; font-size: 11px; color: var(--silver); + } + .wp-dir .path { color: var(--dim); flex: 1; } + .wp-dir .rm { color: var(--dim); cursor: pointer; font-size: 12px; padding: 0 3px; } + .wp-dir .rm:hover { color: var(--fail); } + .pair-chip .rm { + position: absolute; top: 1px; right: 2px; color: #fffa; cursor: pointer; + font-size: 10px; text-shadow: 0 1px 2px #000; display: none; padding: 0 2px; + } + .pair-chip:hover .rm { display: block; } + .pair-chip.armed { border-color: var(--fail); box-shadow: 0 0 6px rgba(203,107,77,.5); } + .pair-chip.armed .rm { display: block; color: var(--fail); } + .wp-well { + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 8px; + padding: 10px; + } + .wp-scroll { display: flex; gap: 8px; overflow-x: auto; padding: 0 0 12px; } + .wp-scroll::-webkit-scrollbar { height: 7px; } + .wp-scroll::-webkit-scrollbar-track { background: #0a0908; border-radius: 4px; } + .wp-scroll::-webkit-scrollbar-thumb { background: #3a352c; border-radius: 4px; } + .wp-scroll::-webkit-scrollbar-thumb:hover { background: var(--gold); } + .wp-scroll .wp-th { flex: 0 0 62px; } + .sorted-row { display: flex; gap: 8px; align-items: stretch; } + .sorted-row .sortcol { display: flex; flex-direction: column; gap: 6px; justify-content: flex-start; padding-top: 10px; } + .sorted-row .wp-well { flex: 1; min-width: 0; } + .count-badge { + position: absolute; top: 2px; right: 3px; background: rgba(0,0,0,.65); + color: var(--cream); font-size: 8px; font-family: var(--mono); + border-radius: 4px; padding: 0 4px; + } + .color-chip { border: 1.5px solid #33302b; } + input[type="color"].wp-picker { + width: 54px; height: 32px; border: 1px solid #3a352c; border-radius: 6px; + background: #0a0908; padding: 2px; cursor: pointer; + } + .wp-desc { + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 8px; + color: var(--steel); font-size: 11px; line-height: 1.5; padding: 8px 10px; margin-top: 8px; + } + .wp-prev { + width: 100%; aspect-ratio: 16/9; border-radius: 8px; border: 1px solid #2c2823; + overflow: hidden; position: relative; background: #0c0a08; + } + .wp-prev img { width: 100%; height: 100%; object-fit: cover; display: block; } + .sortkeys { display: flex; flex-direction: column; gap: 6px; } + .sortkeys .dupre-key.srt { width: 30px; height: 20px; font-size: 9px; padding: 0; } + .pair-scroll .pair-cell { flex: 0 0 66px; display: flex; flex-direction: column; } + .pair-cell .pair-chip { width: 66px; } + .pair-foot { display: flex; align-items: center; justify-content: space-between; height: 14px; } + .pair-lbl { font-size: 7.5px; color: var(--steel); letter-spacing: .04em; overflow: hidden; white-space: nowrap; } + .pbtn { color: var(--dim); cursor: pointer; font-size: 10px; padding: 0 2px; } + .pbtn:hover { color: var(--gold); } + .press-side { display: flex; flex-direction: column; gap: 6px; } + .wp-lblin { + background: #0a0908; border: 1px solid #3a352c; border-radius: 5px; + color: var(--cream); font-family: var(--mono); font-size: 10px; padding: 4px 7px; width: 120px; + } + .wp-lblin::placeholder { color: var(--dim); } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 36 — annotation batch two.</b> 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. + </div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">SETTINGS</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "=", "\u2193"]; // up / equals / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "=", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "=": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +["Slot5", "Slot6", "Slot7", "Slot8"].forEach((n) => { if (!DS.SCENES[n]) DS.SCENES[n] = { glyph: "", sets: {} }; }); + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +// ---- wallpaper: channels state (mock persistence) ------------------------- +const SWATCH = [ + "linear-gradient(135deg,#2a3d5c,#101b2e)", "linear-gradient(135deg,#5c3a2a,#2e1810)", + "linear-gradient(135deg,#3a5c2a,#16240f)", "linear-gradient(135deg,#40364f,#1b1626)", + "linear-gradient(135deg,#1a1e2e,#05070d)", "linear-gradient(135deg,#5c5030,#2e2810)", + "linear-gradient(135deg,#6b4a5c,#2e1420)", "linear-gradient(135deg,#2a5c55,#0f2420)", + "linear-gradient(135deg,#7a6248,#3a2c1a)", "linear-gradient(135deg,#23364a,#0a1018)", + "linear-gradient(135deg,#4a5a3a,#1c2410)", "linear-gradient(135deg,#5a3a4a,#240f1c)", +]; +const VIDEOS = ["linear-gradient(160deg,#38506b,#0d1520)", "linear-gradient(160deg,#5c4a2a,#241a08)", "linear-gradient(160deg,#2c4a3e,#0a1a12)"]; +const WP = { + channel: "single", + single: 2, + pairs: [ + { day: 1, night: 4, label: "harbor", ts: 1 }, { day: 8, night: 9, label: "", ts: 2 }, + { day: 0, night: 4, label: "coast", ts: 3 }, { day: 2, night: 10, label: "forest", ts: 4 }, + { day: 5, night: 11, label: "amber", ts: 5 }, { day: 6, night: 3, label: "", ts: 6 }, + { day: 7, night: 9, label: "reef", ts: 7 }, { day: 8, night: 4, label: "dune", ts: 8 }, + { day: 3, night: 11, label: "violet", ts: 9 }, { day: 10, night: 2, label: "", ts: 10 }, + { day: 9, night: 0, label: "slate", ts: 11 }, { day: 11, night: 5, label: "plum", ts: 12 }, + ], + pairSel: 0, + sets: [{ pics: [0, 2, 5, 8], label: "earthtones", ts: 1 }], + setSel: 0, + setBuild: { pics: [], label: "" }, + colors: [{ c: "#3a5c2a", label: "moss", ts: 1 }, { c: "#54677d", label: "slate", ts: 2 }], + colorSel: 0, + colorBuild: { c: "#dab53d", label: "" }, + video: 0, + interval: 30, + building: { day: null, night: null, label: "", arm: null }, + dirs: ["~/pictures/wallpaper", "~/pictures/wallpaper/day-night"], + sort: "time", + mintSeq: 13, +}; +const INTERVALS = [5, 10, 15, 30, 60]; +const CHANNELS = [ + { id: "watch", nm: "Watch" }, + { id: "pair", nm: "Sun pair" }, + { id: "single", nm: "Single" }, + { id: "solid", nm: "Solid" }, + { id: "random", nm: "Random" }, + { id: "video", nm: "Video" }, + { id: "clock", nm: "Clock" }, + { id: "world", nm: "World" }, +]; +const SOURCED = ["single", "pair", "random", "video"]; // solid + generators hide sources +const DESC = { + solid: "A single flat color fills the screen. Mint colors from the picker below — they persist like pairs — and select one from the shelf.", + watch: "Plays the Dupre night watch: the instrument board keeps real time while the machine idles. The same board is the lock screen's face.", + pair: "Two pictures, one day. The day picture shows from sunrise, the night picture from sundown — sun-times from your location. Pick a minted pair from the gallery, or press a new one below: fill both frames from the pictures, name it if you like, and mint.", + single: "One picture, always. Pick it from the gallery; the preview shows what ships.", + random: "Draws a fresh picture from your sources on the wheel's interval. Click the wheel to change the minutes.", + video: "Plays a video loop as the wallpaper, aerial style. Pick the loop from the gallery.", + clock: "Fills the screen with the big clock — the same clock the time module opens when clicked.", + world: "Fills the screen with a matrix of world clocks — the cities and timezones from the time module's hover.", +}; +const DIR_CANDIDATES = ["~/pictures/travel", "~/pictures/astro", "~/downloads/walls", "~/pictures/scans"]; + +function chanFace(id) { + const f = h("div", "face"); + if (id === "single") { const i = h("div", "half"); i.style.background = SWATCH[WP.single]; f.appendChild(i); } + else if (id === "pair") { + const p = WP.pairs[WP.pairSel] || WP.pairs[0]; + const a = h("div", "half"); a.style.background = p ? SWATCH[p.day] : "#222"; + const b = h("div", "half b"); b.style.background = p ? SWATCH[p.night] : "#111"; + f.appendChild(a); f.appendChild(b); + } else if (id === "random") { + const set = WP.sets[WP.setSel]; + const i = h("div", "half"); i.style.background = set ? SWATCH[set.pics[0]] : "#222"; + f.appendChild(i); + if (set) f.appendChild(h("span", "count-badge", set.pics.length)); + } else if (id === "solid") { + const c = WP.colors[WP.colorSel]; + const i = h("div", "half"); i.style.background = c ? c.c : "#444"; + f.appendChild(i); + } else if (id === "video") { + const i = h("div", "half"); i.style.background = VIDEOS[WP.video]; f.appendChild(i); + f.appendChild(h("div", "vid-play", "\u25B6")); + } else if (id === "clock") f.appendChild(h("div", "clock-face")); + else if (id === "world") { + const m = h("div", "mini-clocks"); + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + f.appendChild(m); + } else if (id === "watch") f.appendChild(h("div", "watch-face")); + return f; +} + +function scrollGallery(cls) { + return h("div", "wp-scroll" + (cls ? " " + cls : "")); +} + +function wallpaperView() { + const sv = h("div", "ds-col"); + const head = h("div", "ds-sub-head"); + head.innerHTML = `<span class="back">\u2039 Back</span><span class="title">Wallpaper</span>`; + head.querySelector(".back").onclick = () => subview.classList.remove("open"); + sv.appendChild(head); + const body2 = h("div", "ds-col"); + sv.appendChild(body2); + + const paint = () => { + body2.innerHTML = ""; + + // modes — labeled and divided like every family section + body2.appendChild(DS.engrave("Modes")); + const bank = h("div", "wp-chans"); + CHANNELS.forEach((c) => { + const card = h("div", "wp-chan" + (WP.channel === c.id ? " on" : "")); + card.appendChild(chanFace(c.id)); + card.appendChild(h("div", "nm", c.nm)); + card.onclick = () => { WP.channel = c.id; DS.toast("Wallpaper: " + c.nm, true); paint(); }; + bank.appendChild(card); + }); + body2.appendChild(bank); + + // description — helpful text for every mode, never "no options" + body2.appendChild(h("div", "wp-desc", DESC[WP.channel])); + + const conf = h("div", "wp-conf"); + const previewBox = (bg, imgSrc) => { + const pv = h("div", "wp-prev"); + if (imgSrc) { const img = h("img"); img.src = imgSrc; pv.appendChild(img); } + else if (bg) pv.style.background = bg; + return pv; + }; + + const sortCol = () => { + const c = h("div", "sortcol"); + [["alpha", "a"], ["time", "\u{f0150}"]].forEach(([mode, glyph]) => { + const k = h("button", "dupre-key srt" + (WP.sort === mode ? " dupre-on" : ""), glyph); + k.onclick = () => { k.blur(); WP.sort = mode; paint(); }; + c.appendChild(k); + }); + return c; + }; + const sortedRow = (wellContent) => { + const row = h("div", "sorted-row"); + row.appendChild(sortCol()); + const well = h("div", "wp-well"); + well.appendChild(wellContent); + row.appendChild(well); + return row; + }; + const sortView = (arr) => { + const order = arr.map((o, i) => ({ o, i })); + if (WP.sort === "alpha") order.sort((x, y) => (x.o.label || "\uffff").localeCompare(y.o.label || "\uffff")); + else order.sort((x, y) => y.o.ts - x.o.ts); + return order; + }; + const chipFoot = (label, onEdit, onDel) => { + const foot = h("div", "pair-foot"); + const ed = h("span", "pbtn", "~"); ed.title = "edit — reopens below"; ed.onclick = onEdit; + foot.appendChild(ed); + foot.appendChild(h("span", "pair-lbl", label || "")); + const del = h("span", "pbtn", "\u2212"); del.title = "delete — recreate to get it back"; del.onclick = onDel; + foot.appendChild(del); + return foot; + }; + const galleryWell = (content) => { + const well = h("div", "wp-well"); + well.appendChild(content); + return well; + }; + + if (WP.channel === "single") { + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th" + (i === WP.single ? " sel" : "")); + t.style.background = sw; + t.onclick = () => { WP.single = i; DS.toast("Wallpaper set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(SWATCH[WP.single])); + } else if (WP.channel === "pair") { + conf.appendChild(DS.engrave("Minted pairs")); + const tray = scrollGallery("pair-scroll"); + sortView(WP.pairs).forEach(({ o: p, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip" + (i === WP.pairSel ? " sel" : "")); + const a = h("div", "half"); a.style.background = SWATCH[p.day]; + const b = h("div", "half b"); b.style.background = SWATCH[p.night]; + chip.appendChild(a); chip.appendChild(b); + chip.onclick = () => { WP.pairSel = i; DS.toast("Pair selected", true); paint(); }; + cell.appendChild(chip); + cell.appendChild(chipFoot(p.label, + () => { + WP.building = { day: p.day, night: p.night, label: p.label, arm: null }; + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair opened in the press", true); + paint(); + }, + () => { + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair deleted", true); + paint(); + })); + tray.appendChild(cell); + }); + conf.appendChild(sortedRow(tray)); + + conf.appendChild(DS.engrave("Pair press")); + const bench = h("div", "pair-bench"); + ["day", "night"].forEach((k) => { + const slot = h("div", "pair-slot" + + (WP.building[k] !== null ? " filled" : "") + (WP.building.arm === k ? " arm" : "")); + if (WP.building[k] !== null) slot.style.background = SWATCH[WP.building[k]]; + slot.appendChild(h("span", "cap", k === "day" ? "\u2600 DAY" : "\u263E NIGHT")); + slot.onclick = () => { WP.building.arm = WP.building.arm === k ? null : k; paint(); }; + bench.appendChild(slot); + }); + const side = h("div", "press-side"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.building.label || ""; + lbl.oninput = () => { WP.building.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT PAIR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + if (WP.building.day === null || WP.building.night === null) { DS.toast("fill both frames first", false); return; } + WP.pairs.push({ day: WP.building.day, night: WP.building.night, label: WP.building.label || "", ts: WP.mintSeq++ }); + WP.pairSel = WP.pairs.length - 1; + WP.building = { day: null, night: null, label: "", arm: null }; + DS.toast("Pair minted", true); + paint(); + }; + side.appendChild(mint); + bench.appendChild(side); + conf.appendChild(bench); + if (WP.building.arm) conf.appendChild(h("div", "wp-note", "click a picture below to fill the " + WP.building.arm.toUpperCase() + " frame")); + + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th"); + t.style.background = sw; + t.onclick = () => { + if (!WP.building.arm) { DS.toast("click a DAY or NIGHT frame first", false); return; } + WP.building[WP.building.arm] = i; + WP.building.arm = WP.building.arm === "day" && WP.building.night === null ? "night" : null; + paint(); + }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + conf.appendChild(h("div", "wp-note", "Only picture types the lock screen can display appear here.")); + } else if (WP.channel === "solid") { + conf.appendChild(DS.engrave("Minted colors")); + const tray = scrollGallery("pair-scroll"); + sortView(WP.colors).forEach(({ o: c, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip color-chip" + (i === WP.colorSel ? " sel" : "")); + chip.style.background = c.c; + chip.onclick = () => { WP.colorSel = i; DS.toast("Color selected", true); paint(); }; + cell.appendChild(chip); + cell.appendChild(chipFoot(c.label, + () => { + WP.colorBuild = { c: c.c, label: c.label }; + WP.colors.splice(i, 1); + if (WP.colorSel >= WP.colors.length) WP.colorSel = WP.colors.length - 1; + DS.toast("Color opened in the picker", true); + paint(); + }, + () => { + WP.colors.splice(i, 1); + if (WP.colorSel >= WP.colors.length) WP.colorSel = WP.colors.length - 1; + DS.toast("Color deleted", true); + paint(); + })); + tray.appendChild(cell); + }); + conf.appendChild(sortedRow(tray)); + + conf.appendChild(DS.engrave("Color picker")); + const row = h("div", "pair-bench"); + const pick = h("input", "wp-picker"); + pick.type = "color"; + pick.value = WP.colorBuild.c; + pick.oninput = () => { WP.colorBuild.c = pick.value; }; + row.appendChild(pick); + const side = h("div", "press-side"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.colorBuild.label || ""; + lbl.oninput = () => { WP.colorBuild.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT COLOR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + WP.colors.push({ c: WP.colorBuild.c, label: WP.colorBuild.label || "", ts: WP.mintSeq++ }); + WP.colorSel = WP.colors.length - 1; + WP.colorBuild = { c: WP.colorBuild.c, label: "" }; + DS.toast("Color minted", true); + paint(); + }; + side.appendChild(mint); + row.appendChild(side); + conf.appendChild(row); + } else if (WP.channel === "random") { + conf.appendChild(DS.engrave("Minted sets")); + const tray = scrollGallery("pair-scroll"); + sortView(WP.sets).forEach(({ o: st, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip" + (i === WP.setSel ? " sel" : "")); + chip.style.background = SWATCH[st.pics[0]]; + chip.appendChild(h("span", "count-badge", st.pics.length)); + chip.onclick = () => { WP.setSel = i; DS.toast("Set selected", true); paint(); }; + cell.appendChild(chip); + cell.appendChild(chipFoot(st.label, + () => { + WP.setBuild = { pics: st.pics.slice(), label: st.label }; + WP.sets.splice(i, 1); + if (WP.setSel >= WP.sets.length) WP.setSel = WP.sets.length - 1; + DS.toast("Set opened below", true); + paint(); + }, + () => { + WP.sets.splice(i, 1); + if (WP.setSel >= WP.sets.length) WP.setSel = WP.sets.length - 1; + DS.toast("Set deleted", true); + paint(); + })); + tray.appendChild(cell); + }); + conf.appendChild(sortedRow(tray)); + + conf.appendChild(DS.engrave("Set press — click pictures to add")); + const bRow = h("div", "wp-well"); + const bTray = scrollGallery(); + if (WP.setBuild.pics.length === 0) bTray.appendChild(h("div", "wp-note", "empty — click pictures below")); + WP.setBuild.pics.forEach((pi, k) => { + const t = h("div", "wp-th"); + t.style.background = SWATCH[pi]; + t.title = "click to remove from the set"; + t.onclick = () => { WP.setBuild.pics.splice(k, 1); paint(); }; + bTray.appendChild(t); + }); + bRow.appendChild(bTray); + conf.appendChild(bRow); + const side = h("div", "pair-bench"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.setBuild.label || ""; + lbl.oninput = () => { WP.setBuild.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT SET"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + if (WP.setBuild.pics.length < 2) { DS.toast("a set needs at least two pictures", false); return; } + WP.sets.push({ pics: WP.setBuild.pics.slice(), label: WP.setBuild.label || "", ts: WP.mintSeq++ }); + WP.setSel = WP.sets.length - 1; + WP.setBuild = { pics: [], label: "" }; + DS.toast("Set minted", true); + paint(); + }; + side.appendChild(mint); + const row2 = h("div", "wp-row2"); + row2.appendChild(h("span", "wp-note", "next draw every")); + const wheel = h("div", "int-wheel", WP.interval); + wheel.onclick = () => { + WP.interval = INTERVALS[(INTERVALS.indexOf(WP.interval) + 1) % INTERVALS.length]; + DS.toast("Random interval: " + WP.interval + "m", true); + paint(); + }; + row2.appendChild(wheel); + row2.appendChild(h("span", "wp-note", "minutes")); + side.appendChild(row2); + conf.appendChild(side); + + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th"); + t.style.background = sw; + t.onclick = () => { + if (WP.setBuild.pics.includes(i)) { DS.toast("already in the set", false); return; } + WP.setBuild.pics.push(i); + paint(); + }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + } else if (WP.channel === "video") { + conf.appendChild(DS.engrave("Videos")); + const g = scrollGallery(); + VIDEOS.forEach((v, i) => { + const t = h("div", "wp-th" + (i === WP.video ? " sel" : "")); + t.style.background = v; + t.appendChild(h("div", "vid-play", "\u25B6")); + t.onclick = () => { WP.video = i; DS.toast("Video set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox(VIDEOS[WP.video]); + pv.appendChild(h("div", "vid-play", "\u25B6")); + conf.appendChild(pv); + } else if (WP.channel === "watch") { + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(null, "2026-07-22-watch-preview.png")); + } else if (WP.channel === "clock") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const cf = h("div", "clock-face"); cf.style.inset = "6% 34%"; + pv.appendChild(cf); + conf.appendChild(pv); + } else if (WP.channel === "world") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const m = h("div", "mini-clocks"); m.style.inset = "8%"; + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + pv.appendChild(m); + conf.appendChild(pv); + } + body2.appendChild(conf); + + // sources: only for the modes that draw from directories + if (SOURCED.includes(WP.channel)) { + const src = h("div", "wp-conf"); + src.appendChild(DS.engrave("Sources")); + const dirs = h("div", "wp-dirs"); + WP.dirs.forEach((d, i) => { + const row = h("div", "wp-dir"); + row.innerHTML = `<span>\u{f024b}</span>`; + row.appendChild(h("span", "path", d)); + const rm = h("span", "rm", "\u2715"); + rm.onclick = () => { + if (WP.dirs.length === 1) { DS.toast("keep at least one source", false); return; } + WP.dirs.splice(i, 1); + DS.toast("Source removed", true); + paint(); + }; + row.appendChild(rm); + dirs.appendChild(row); + }); + src.appendChild(dirs); + const add = h("button", "dupre-key", "ADD DIRECTORY"); + add.style.cssText = "height:24px;font-size:8px;padding:0 10px;margin-top:6px"; + add.onclick = () => { + add.blur(); + const next = DIR_CANDIDATES.find((c) => !WP.dirs.includes(c)); + if (!next) { DS.toast("no more candidates in the mock", false); return; } + WP.dirs.push(next); + DS.toast("Source added: " + next, true); + paint(); + }; + src.appendChild(add); + body2.appendChild(src); + } + }; + paint(); + return sv; +} + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(wallpaperView()); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", glyph: "\u{f0210}", scene: "Focus" }, // crosshairs — focus + { id: "PRESENT", label: "PRESENT", glyph: "\u{f0379}", scene: "Presentation" }, // projector screen + { id: "BATTERY", label: "BATTERY", glyph: "\u{f007e}", scene: "Battery" }, // half battery + { id: "NIGHT", label: "NIGHT", glyph: "\u{f0594}", scene: "Night" }, // crescent moon + { id: "S5", label: "1", scene: "Slot5" }, + { id: "S6", label: "2", scene: "Slot6" }, + { id: "S7", label: "3", scene: "Slot7" }, + { id: "S8", label: "4", scene: "Slot8" }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, glyph, slim }) => ({ id, label, glyph, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 436, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — pointers over brightness; the idle dial alone - + const lv = h("div", "plate"); + const bench = h("div", "bench"); + + const fbox = (label) => { + const b = h("div", "fbox"); + b.appendChild(h("span", "flbl", label)); + return b; + }; + + const lcol = h("div", "fcol"); + + // POINTERS — console keys, horizontal (above brightness per batch two) + const ptr = fbox("Pointers"); + [["touchpad", "TOUCHPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const b = h("button", "dupre-key ptr", name); + if (DS.state[k]) b.classList.add("dupre-green"); + b.onclick = () => { b.blur(); DS.setToggle(k, ptr); }; + ptr.appendChild(b); + }); + lcol.appendChild(ptr); + + // BRIGHTNESS — the drum pair, centered in its frame + const bright = fbox("Brightness"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, bright); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, bright); + }, 350); + DUPRE.drumRoller(dhost, { + title: "", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 196, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + bright.appendChild(dhost); + lcol.appendChild(bright); + bench.appendChild(lcol); + + // IDLE — enlarged dial, legend beneath, alone in its frame + const idle = fbox("Idle"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 184, + legend: true, + legendPos: "bottom", + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + idle.appendChild(railHost); + bench.appendChild(idle); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 37 (annotation batch three)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#dab53d; --gold-hi:#ffd75f; + --amber-grad-top:#f0d879; --amber-grad-mid:#dab53d; --amber-grad-bot:#8f7a1f; + --amber-edge:#7d6a16; --amber-warn:#edc95d; + --glow-hi:255,215,95; --glow-lo:218,181,61; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { + background: radial-gradient(1200px 600px at 70% -10%, #1c1915 0%, transparent 60%), var(--ground); + } + .ds-panel { width: 540px; background-color: var(--panel); border-width: 1.6px; } + .ds-face { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #262320; + box-shadow: inset 0 1px 0 rgba(255,255,255,.04); + } + .ds-face .title { color: var(--gold); letter-spacing: .12em; font-size: 15px; } + .ds-caption { max-width: 520px; } + .bench { display: flex; align-items: stretch; justify-content: center; gap: 12px; } + .fcol { display: flex; flex-direction: column; gap: 12px; } + .fbox { + position: relative; + border: 1px solid #35302a; + border-radius: 10px; + padding: 14px 12px 10px; + display: flex; align-items: center; justify-content: center; gap: 10px; + } + .fbox .flbl { + position: absolute; top: -7px; left: 12px; + background: var(--raise); + padding: 0 7px; + color: var(--steel); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; + } + .fbox .dupre-key.ptr { width: 96px; height: 24px; font-size: 8px; letter-spacing: .08em; padding: 0; } + .plate { + background: linear-gradient(180deg, var(--raise), var(--panel)); + border: 1px solid #2c2823; + border-radius: 11px; + padding: 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.05), 0 2px 6px rgba(0,0,0,.35); + } + .plate .ds-engrave { margin: 0 2px 8px; } + .vcell { display: flex; flex-direction: column; align-items: center; } + .khost { display: flex; justify-content: center; } + .midgrid { display: flex; gap: 8px; align-items: center; justify-content: center; } + .tcell { position: relative; } + .pointing { display: flex; justify-content: center; gap: 24px; } + .pointing .dupre-rocker { width: 50px; height: 32px; } + .pointing .dupre-rocker .dupre-half { font-size: 8px; } + /* ---- wallpaper channel bank ---- */ + .wp-chans { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; } + .wp-chan { + border: 1px solid #33302b; border-radius: 8px; padding: 4px; + background: linear-gradient(180deg, var(--key-top, #23211e), var(--key-bot, #191715)); + cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; + } + .wp-chan:hover { border-color: var(--gold); } + .wp-chan.on { border-color: #a9c95f; box-shadow: 0 0 8px rgba(169,201,95,.25); } + .wp-chan .face { width: 100%; aspect-ratio: 16/10; border-radius: 4px; overflow: hidden; position: relative; background: #0c0a08; } + .wp-chan .nm { font-size: 7px; letter-spacing: .08em; color: var(--dim); text-transform: uppercase; } + .wp-chan.on .nm { color: #a9c95f; } + .half { position: absolute; inset: 0; } + .half.b { clip-path: polygon(100% 0, 100% 100%, 0 100%); } + .deck { position: absolute; inset: 0; } + .deck i { position: absolute; width: 68%; height: 68%; border-radius: 3px; border: 1px solid #0009; } + .mini-clocks { position: absolute; inset: 3px; display: grid; grid-template-columns: repeat(3,1fr); gap: 2px; justify-items: center; align-items: center; } + .mini-clocks i { + width: 86%; aspect-ratio: 1; border-radius: 50%; position: relative; + background: radial-gradient(circle at 40% 32%, #f2ecd8, #cfc8b6); + border: 1px solid #4a4438; + } + .mini-clocks i::before, .mini-clocks i::after { + content: ""; position: absolute; left: 50%; background: #2b2418; transform-origin: bottom; + } + .mini-clocks i::before { top: 26%; width: 1.5px; height: 24%; } /* hour */ + .mini-clocks i::after { top: 14%; width: 1px; height: 36%; } /* minute */ + .mini-clocks i:nth-child(1)::before { transform: rotate(40deg); } + .mini-clocks i:nth-child(2)::before { transform: rotate(105deg); } .mini-clocks i:nth-child(2)::after { transform: rotate(-60deg); } + .mini-clocks i:nth-child(3)::before { transform: rotate(160deg); } .mini-clocks i:nth-child(3)::after { transform: rotate(80deg); } + .mini-clocks i:nth-child(4)::before { transform: rotate(-75deg); } .mini-clocks i:nth-child(4)::after { transform: rotate(150deg); } + .mini-clocks i:nth-child(5)::before { transform: rotate(15deg); } .mini-clocks i:nth-child(5)::after { transform: rotate(-130deg); } + .mini-clocks i:nth-child(6)::before { transform: rotate(-140deg); } .mini-clocks i:nth-child(6)::after { transform: rotate(30deg); } + .face-glyph { position: absolute; font-size: 11px; } + .face-glyph.sun { left: 14%; top: 10%; color: #6b4e08; } + .face-glyph.moon { right: 14%; bottom: 8%; color: #e8e2cc; } + .wp-prev.sm { width: 55%; margin: 0 auto; } + .clock-face { position: absolute; inset: 8% 25%; border-radius: 50%; border: 1.5px solid var(--cream); background: #14110e; } + .clock-face::before { content: ""; position: absolute; left: 50%; top: 12%; width: 1.5px; height: 40%; background: var(--cream); transform: rotate(40deg); transform-origin: bottom; } + .clock-face::after { content: ""; position: absolute; left: 50%; top: 22%; width: 1.5px; height: 30%; background: var(--gold); transform: rotate(-70deg); transform-origin: bottom; } + .watch-face { position: absolute; inset: 6% 22%; border-radius: 50%; border: 1px solid #3a5c2a; background: radial-gradient(circle, #0f1a0c, #060905); } + .watch-face::after { content: ""; position: absolute; left: 50%; top: 6%; width: 1px; height: 44%; background: #a9c95f; transform: rotate(55deg); transform-origin: bottom; box-shadow: 0 0 4px #a9c95f; } + .vid-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fffc; font-size: 14px; text-shadow: 0 1px 3px #000; } + + .wp-conf { margin-top: 10px; } + .wp-lib { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; } + .wp-th { aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .wp-th:hover { border-color: var(--gold); } + .wp-th.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .pair-bench { display: flex; align-items: center; gap: 10px; justify-content: center; } + .pair-slot { width: 104px; aspect-ratio: 16/10; border: 1.5px dashed #4a443a; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; } + .pair-slot.filled { border-style: solid; } + .pair-slot.arm { border-color: var(--gold); } + .pair-slot .cap { position: absolute; bottom: 2px; left: 4px; font-size: 7px; letter-spacing: .12em; color: #fff9; text-shadow: 0 1px 2px #000; } + .pair-tray { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; } + .pair-chip { width: 64px; aspect-ratio: 16/10; border-radius: 5px; border: 1.5px solid #33302b; cursor: pointer; position: relative; overflow: hidden; } + .pair-chip.sel { border-color: var(--gold); box-shadow: 0 0 6px rgba(218,181,61,.4); } + .wp-note { color: var(--dim); font-size: 10px; margin-top: 6px; } + .wp-row2 { display: flex; align-items: center; gap: 10px; justify-content: center; margin-top: 8px; } + .int-wheel { + width: 46px; height: 30px; border-radius: 4px; background: #0a0908; border: 1px solid #3a352c; + display: flex; align-items: center; justify-content: center; cursor: pointer; + color: #2b2418; font-weight: 700; font-size: 12px; font-family: var(--mono); + background: linear-gradient(90deg,#cfc6a8,#ece4c8 50%,#c6bd9e); + box-shadow: inset 0 4px 5px rgba(0,0,0,.35), inset 0 -4px 5px rgba(0,0,0,.35); + } + .ds-toast.empty { visibility: hidden; } + .wp-dirs { display: flex; flex-direction: column; gap: 4px; max-height: 100px; overflow-y: auto; padding-right: 4px; } + .wp-dirs::-webkit-scrollbar { width: 7px; } + .wp-dirs::-webkit-scrollbar-track { background: #0a0908; border-radius: 4px; } + .wp-dirs::-webkit-scrollbar-thumb { background: #3a352c; border-radius: 4px; } + .wp-dir { + display: flex; align-items: center; gap: 8px; + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 6px; + padding: 5px 8px; font-size: 11px; color: var(--silver); + } + .wp-dir .path { color: var(--dim); flex: 1; } + .wp-dir .rm { color: var(--dim); cursor: pointer; font-size: 12px; padding: 0 3px; } + .wp-dir .rm:hover { color: var(--fail); } + .pair-chip .rm { + position: absolute; top: 1px; right: 2px; color: #fffa; cursor: pointer; + font-size: 10px; text-shadow: 0 1px 2px #000; display: none; padding: 0 2px; + } + .pair-chip:hover .rm { display: block; } + .pair-chip.armed { border-color: var(--fail); box-shadow: 0 0 6px rgba(203,107,77,.5); } + .pair-chip.armed .rm { display: block; color: var(--fail); } + .wp-well { + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 8px; + padding: 10px; + } + .wp-scroll { display: flex; gap: 8px; overflow-x: auto; padding: 0 0 12px; } + .wp-scroll::-webkit-scrollbar { height: 7px; } + .wp-scroll::-webkit-scrollbar-track { background: #0a0908; border-radius: 4px; } + .wp-scroll::-webkit-scrollbar-thumb { background: #3a352c; border-radius: 4px; } + .wp-scroll::-webkit-scrollbar-thumb:hover { background: var(--gold); } + .wp-scroll .wp-th { flex: 0 0 62px; } + .sorted-row { display: flex; gap: 8px; align-items: stretch; } + .sorted-row .sortcol { display: flex; flex-direction: column; gap: 6px; justify-content: flex-start; padding-top: 10px; } + .sorted-row .wp-well { flex: 1; min-width: 0; } + .count-badge { + position: absolute; top: 2px; right: 3px; background: rgba(0,0,0,.65); + color: var(--cream); font-size: 8px; font-family: var(--mono); + border-radius: 4px; padding: 0 4px; + } + .color-chip { border: 1.5px solid #33302b; } + input[type="color"].wp-picker { + width: 54px; height: 32px; border: 1px solid #3a352c; border-radius: 6px; + background: #0a0908; padding: 2px; cursor: pointer; + } + .wp-desc { + background: #0e0c0a; border: 1px solid #2c2823; border-radius: 8px; + color: var(--steel); font-size: 11px; line-height: 1.5; padding: 8px 10px; margin-top: 8px; + } + .wp-prev { + width: 100%; aspect-ratio: 16/9; border-radius: 8px; border: 1px solid #2c2823; + overflow: hidden; position: relative; background: #0c0a08; + } + .wp-prev img { width: 100%; height: 100%; object-fit: cover; display: block; } + .sortkeys { display: flex; flex-direction: column; gap: 6px; } + .sortkeys .dupre-key.srt { width: 30px; height: 20px; font-size: 9px; padding: 0; } + .pair-scroll .pair-cell { flex: 0 0 66px; display: flex; flex-direction: column; } + .pair-cell .pair-chip { width: 66px; } + .pair-foot { display: flex; align-items: center; justify-content: space-between; height: 14px; } + .pair-lbl { font-size: 7.5px; color: var(--steel); letter-spacing: .04em; overflow: hidden; white-space: nowrap; } + .pbtn { color: var(--dim); cursor: pointer; font-size: 10px; padding: 0 2px; } + .pbtn:hover { color: var(--gold); } + .press-side { display: flex; flex-direction: column; gap: 6px; } + .wp-lblin { + background: #0a0908; border: 1px solid #3a352c; border-radius: 5px; + color: var(--cream); font-family: var(--mono); font-size: 10px; padding: 4px 7px; width: 120px; + } + .wp-lblin::placeholder { color: var(--dim); } + .wprow { + display: flex; align-items: center; gap: 8px; + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-radius: 8px; + padding: 8px 10px; cursor: pointer; color: var(--silver); font-size: 12px; + } + .wprow:hover { border-color: var(--gold); color: var(--gold); } + .wprow .k-state { margin-left: auto; color: var(--dim); font-size: 10px; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Prototype 37 — annotation batch three.</b> 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. + </div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">SETTINGS</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); +const SWITCH_ROWS = [["AUTO-DIM", "autodim"], ["NIGHT LIGHT", "nightlight"], ["DO NOT DISTURB", "dnd"], ["CAFFEINE", "caffeine"]]; +const PWR_ROW = "CPU POWER"; +const PWR_VALUES = ["\u2191", "=", "\u2193"]; // up / equals / down, ordered with DS.PROFILES +const LETTER_OF = { performance: "\u2191", balanced: "=", saver: "\u2193" }; +const PWR_OF = { "\u2191": "performance", "=": "balanced", "\u2193": "saver" }; +let booting = true; +// the idle policy the rail programs (mock persistence across renders) +const IDLE_STAGES = [ + { id: "dim", label: "DIM", color: "#d29638", minutes: 5 }, + { id: "lock", label: "LOCK · WATCH", color: "#54677d", minutes: 7 }, + { id: "dpms", label: "SCREEN OFF", color: "#969385", minutes: 10 }, + { id: "suspend", label: "SUSPEND", color: "#cb6b4d", minutes: 30 }, +]; + +// two unassigned program slots (blank keys, all-off programs) +["1", "2", "3", "4"].forEach((n) => { if (!DS.SCENES[n]) DS.SCENES[n] = { glyph: "", sets: {} }; }); + +// factory presets (Craig, 2026-07-22) — rows: AUTO-DIM, NIGHT LIGHT, DND, CAFFEINE + CPU +DS.SCENES.Focus.sets = { autodim: true, nightlight: false, dnd: true, caffeine: true, powerprofile: "performance" }; +DS.SCENES.Presentation.sets = { autodim: false, nightlight: false, dnd: true, caffeine: true, powerprofile: "balanced" }; +DS.SCENES.Battery.sets = { autodim: true, nightlight: false, dnd: false, caffeine: false, powerprofile: "saver" }; +DS.SCENES.Night.sets = { autodim: true, nightlight: true, dnd: false, caffeine: true, powerprofile: "saver" }; + +// normalize scene programs to COMPLETE definitions: recall = the column's +// pin pattern becomes the truth (pin on, no pin off; one power pin each). +// Iterate the live key set so the just-registered blank slots normalize too. +Object.keys(DS.SCENES).forEach((n) => { + const sets = DS.SCENES[n].sets; + SWITCH_ROWS.forEach(([, key]) => { sets[key] = !!sets[key]; }); + if (!sets.powerprofile) sets.powerprofile = "balanced"; +}); + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +// ---- wallpaper: channels state (mock persistence) ------------------------- +const SWATCH = [ + "linear-gradient(135deg,#2a3d5c,#101b2e)", "linear-gradient(135deg,#5c3a2a,#2e1810)", + "linear-gradient(135deg,#3a5c2a,#16240f)", "linear-gradient(135deg,#40364f,#1b1626)", + "linear-gradient(135deg,#1a1e2e,#05070d)", "linear-gradient(135deg,#5c5030,#2e2810)", + "linear-gradient(135deg,#6b4a5c,#2e1420)", "linear-gradient(135deg,#2a5c55,#0f2420)", + "linear-gradient(135deg,#7a6248,#3a2c1a)", "linear-gradient(135deg,#23364a,#0a1018)", + "linear-gradient(135deg,#4a5a3a,#1c2410)", "linear-gradient(135deg,#5a3a4a,#240f1c)", +]; +const VIDEOS = ["linear-gradient(160deg,#38506b,#0d1520)", "linear-gradient(160deg,#5c4a2a,#241a08)", "linear-gradient(160deg,#2c4a3e,#0a1a12)"]; +const WP = { + channel: "single", + single: 2, + pairs: [ + { day: 1, night: 4, label: "harbor", ts: 1 }, { day: 8, night: 9, label: "", ts: 2 }, + { day: 0, night: 4, label: "coast", ts: 3 }, { day: 2, night: 10, label: "forest", ts: 4 }, + { day: 5, night: 11, label: "amber", ts: 5 }, { day: 6, night: 3, label: "", ts: 6 }, + { day: 7, night: 9, label: "reef", ts: 7 }, { day: 8, night: 4, label: "dune", ts: 8 }, + { day: 3, night: 11, label: "violet", ts: 9 }, { day: 10, night: 2, label: "", ts: 10 }, + { day: 9, night: 0, label: "slate", ts: 11 }, { day: 11, night: 5, label: "plum", ts: 12 }, + ], + pairSel: 0, + sets: [{ pics: [0, 2, 5, 8], label: "earthtones", ts: 1 }], + setSel: 0, + setBuild: { pics: [], label: "" }, + colors: [{ c: "#3a5c2a", label: "moss", ts: 1 }, { c: "#54677d", label: "slate", ts: 2 }], + colorSel: 0, + colorBuild: { c: "#dab53d", label: "" }, + video: 0, + interval: 30, + building: { day: null, night: null, label: "", arm: null }, + dirs: ["~/pictures/wallpaper", "~/pictures/wallpaper/day-night"], + sort: "time", + mintSeq: 13, +}; +const INTERVALS = [5, 10, 15, 30, 60]; +const CHANNELS = [ + { id: "watch", nm: "Watch" }, + { id: "pair", nm: "Sun pair" }, + { id: "single", nm: "Single" }, + { id: "solid", nm: "Solid" }, + { id: "random", nm: "Random" }, + { id: "video", nm: "Video" }, + { id: "clock", nm: "Clock" }, + { id: "world", nm: "World" }, +]; +const SOURCED = ["single", "pair", "random", "video"]; // solid + generators hide sources +const DESC = { + solid: "A single flat color fills the screen. Mint colors from the picker below — they persist like pairs — and select one from the shelf.", + watch: "Plays the Dupre night watch: the instrument board keeps real time while the machine idles. The same board is the lock screen's face.", + pair: "Two pictures, one day. The day picture shows from sunrise, the night picture from sundown — sun-times from your location. Pick a minted pair from the gallery, or press a new one below: fill both frames from the pictures, name it if you like, and mint.", + single: "One picture, always. Pick it from the gallery; the preview shows what ships.", + random: "Draws a fresh picture from your sources on the wheel's interval. Click the wheel to change the minutes.", + video: "Plays a video loop as the wallpaper, aerial style. Pick the loop from the gallery.", + clock: "Fills the screen with the big clock — the same clock the time module opens when clicked.", + world: "Fills the screen with a matrix of world clocks — the cities and timezones from the time module's hover.", +}; +const DIR_CANDIDATES = ["~/pictures/travel", "~/pictures/astro", "~/downloads/walls", "~/pictures/scans"]; + +function chanFace(id) { + const f = h("div", "face"); + if (id === "single") { const i = h("div", "half"); i.style.background = SWATCH[WP.single]; f.appendChild(i); } + else if (id === "pair") { + const a = h("div", "half"); a.style.background = "#dab53d"; + const b = h("div", "half b"); b.style.background = "#1a2438"; + f.appendChild(a); f.appendChild(b); + const sun = h("span", "face-glyph sun", "\u2600"); + const moon = h("span", "face-glyph moon", "\u263E"); + f.appendChild(sun); f.appendChild(moon); + } else if (id === "random") { + const set = WP.sets[WP.setSel]; + const i = h("div", "half"); i.style.background = set ? SWATCH[set.pics[0]] : "#222"; + f.appendChild(i); + if (set) f.appendChild(h("span", "count-badge", set.pics.length)); + } else if (id === "solid") { + const i = h("div", "half"); i.style.background = "#54677d"; /* dupre blue */ + f.appendChild(i); + } else if (id === "video") { + const i = h("div", "half"); i.style.background = VIDEOS[WP.video]; f.appendChild(i); + f.appendChild(h("div", "vid-play", "\u25B6")); + } else if (id === "clock") f.appendChild(h("div", "clock-face")); + else if (id === "world") { + const m = h("div", "mini-clocks"); + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + f.appendChild(m); + } else if (id === "watch") f.appendChild(h("div", "watch-face")); + return f; +} + +function scrollGallery(cls) { + return h("div", "wp-scroll" + (cls ? " " + cls : "")); +} + +function wallpaperView() { + const sv = h("div", "ds-col"); + const head = h("div", "ds-sub-head"); + head.innerHTML = `<span class="back">\u2039 Back</span><span class="title">WALLPAPER</span>`; + head.querySelector(".back").onclick = () => subview.classList.remove("open"); + sv.appendChild(head); + const body2 = h("div", "ds-col"); + sv.appendChild(body2); + + const paint = () => { + body2.innerHTML = ""; + + // modes — labeled and divided like every family section + body2.appendChild(DS.engrave("Modes")); + const bank = h("div", "wp-chans"); + CHANNELS.forEach((c) => { + const card = h("div", "wp-chan" + (WP.channel === c.id ? " on" : "")); + card.appendChild(chanFace(c.id)); + card.appendChild(h("div", "nm", c.nm)); + card.onclick = () => { WP.channel = c.id; DS.toast("Wallpaper: " + c.nm, true); paint(); }; + bank.appendChild(card); + }); + body2.appendChild(bank); + + // description — helpful text for every mode, never "no options" + body2.appendChild(h("div", "wp-desc", DESC[WP.channel])); + + const conf = h("div", "wp-conf"); + const previewBox = (bg, imgSrc) => { + const pv = h("div", "wp-prev"); + if (imgSrc) { const img = h("img"); img.src = imgSrc; pv.appendChild(img); } + else if (bg) pv.style.background = bg; + return pv; + }; + + const sortCol = () => { + const c = h("div", "sortcol"); + [["alpha", "a"], ["time", "\u{f0150}"]].forEach(([mode, glyph]) => { + const k = h("button", "dupre-key srt" + (WP.sort === mode ? " dupre-on" : ""), glyph); + k.onclick = () => { k.blur(); WP.sort = mode; paint(); }; + c.appendChild(k); + }); + return c; + }; + const sortedRow = (wellContent) => { + const row = h("div", "sorted-row"); + row.appendChild(sortCol()); + const well = h("div", "wp-well"); + well.appendChild(wellContent); + row.appendChild(well); + return row; + }; + const sortView = (arr) => { + const order = arr.map((o, i) => ({ o, i })); + if (WP.sort === "alpha") order.sort((x, y) => (x.o.label || "\uffff").localeCompare(y.o.label || "\uffff")); + else order.sort((x, y) => y.o.ts - x.o.ts); + return order; + }; + const chipFoot = (label, onEdit, onDel) => { + const foot = h("div", "pair-foot"); + const ed = h("span", "pbtn", "~"); ed.title = "edit — reopens below"; ed.onclick = onEdit; + foot.appendChild(ed); + foot.appendChild(h("span", "pair-lbl", label || "")); + const del = h("span", "pbtn", "\u2212"); del.title = "delete — recreate to get it back"; del.onclick = onDel; + foot.appendChild(del); + return foot; + }; + const galleryWell = (content) => { + const well = h("div", "wp-well"); + well.appendChild(content); + return well; + }; + + if (WP.channel === "single") { + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th" + (i === WP.single ? " sel" : "")); + t.style.background = sw; + t.onclick = () => { WP.single = i; DS.toast("Wallpaper set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(SWATCH[WP.single])); + } else if (WP.channel === "pair") { + conf.appendChild(DS.engrave("Minted pairs")); + const tray = scrollGallery("pair-scroll"); + sortView(WP.pairs).forEach(({ o: p, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip" + (i === WP.pairSel ? " sel" : "")); + const a = h("div", "half"); a.style.background = SWATCH[p.day]; + const b = h("div", "half b"); b.style.background = SWATCH[p.night]; + chip.appendChild(a); chip.appendChild(b); + chip.onclick = () => { WP.pairSel = i; DS.toast("Pair selected", true); paint(); }; + cell.appendChild(chip); + cell.appendChild(chipFoot(p.label, + () => { + WP.building = { day: p.day, night: p.night, label: p.label, arm: null }; + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair opened in the press", true); + paint(); + }, + () => { + WP.pairs.splice(i, 1); + if (WP.pairSel >= WP.pairs.length) WP.pairSel = WP.pairs.length - 1; + DS.toast("Pair deleted", true); + paint(); + })); + tray.appendChild(cell); + }); + conf.appendChild(sortedRow(tray)); + + conf.appendChild(DS.engrave("Pair press")); + const bench = h("div", "pair-bench"); + ["day", "night"].forEach((k) => { + const slot = h("div", "pair-slot" + + (WP.building[k] !== null ? " filled" : "") + (WP.building.arm === k ? " arm" : "")); + if (WP.building[k] !== null) slot.style.background = SWATCH[WP.building[k]]; + slot.appendChild(h("span", "cap", k === "day" ? "\u2600 DAY" : "\u263E NIGHT")); + slot.onclick = () => { WP.building.arm = WP.building.arm === k ? null : k; paint(); }; + bench.appendChild(slot); + }); + const side = h("div", "press-side"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.building.label || ""; + lbl.oninput = () => { WP.building.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT PAIR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + if (WP.building.day === null || WP.building.night === null) { DS.toast("fill both frames first", false); return; } + WP.pairs.push({ day: WP.building.day, night: WP.building.night, label: WP.building.label || "", ts: WP.mintSeq++ }); + WP.pairSel = WP.pairs.length - 1; + WP.building = { day: null, night: null, label: "", arm: null }; + DS.toast("Pair minted", true); + paint(); + }; + side.appendChild(mint); + bench.appendChild(side); + conf.appendChild(bench); + if (WP.building.arm) conf.appendChild(h("div", "wp-note", "click a picture below to fill the " + WP.building.arm.toUpperCase() + " frame")); + + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th"); + t.style.background = sw; + t.onclick = () => { + if (!WP.building.arm) { DS.toast("click a DAY or NIGHT frame first", false); return; } + WP.building[WP.building.arm] = i; + WP.building.arm = WP.building.arm === "day" && WP.building.night === null ? "night" : null; + paint(); + }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + conf.appendChild(h("div", "wp-note", "Only picture types the lock screen can display appear here.")); + } else if (WP.channel === "solid") { + conf.appendChild(DS.engrave("Minted colors")); + const tray = scrollGallery("pair-scroll"); + sortView(WP.colors).forEach(({ o: c, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip color-chip" + (i === WP.colorSel ? " sel" : "")); + chip.style.background = c.c; + chip.onclick = () => { WP.colorSel = i; DS.toast("Color selected", true); paint(); }; + cell.appendChild(chip); + cell.appendChild(chipFoot(c.label, + () => { + WP.colorBuild = { c: c.c, label: c.label }; + WP.colors.splice(i, 1); + if (WP.colorSel >= WP.colors.length) WP.colorSel = WP.colors.length - 1; + DS.toast("Color opened in the picker", true); + paint(); + }, + () => { + WP.colors.splice(i, 1); + if (WP.colorSel >= WP.colors.length) WP.colorSel = WP.colors.length - 1; + DS.toast("Color deleted", true); + paint(); + })); + tray.appendChild(cell); + }); + conf.appendChild(sortedRow(tray)); + + conf.appendChild(DS.engrave("Color picker")); + const row = h("div", "pair-bench"); + const pick = h("input", "wp-picker"); + pick.type = "color"; + pick.value = WP.colorBuild.c; + pick.oninput = () => { WP.colorBuild.c = pick.value; }; + row.appendChild(pick); + const side = h("div", "press-side"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.colorBuild.label || ""; + lbl.oninput = () => { WP.colorBuild.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT COLOR"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + WP.colors.push({ c: WP.colorBuild.c, label: WP.colorBuild.label || "", ts: WP.mintSeq++ }); + WP.colorSel = WP.colors.length - 1; + WP.colorBuild = { c: WP.colorBuild.c, label: "" }; + DS.toast("Color minted", true); + paint(); + }; + side.appendChild(mint); + row.appendChild(side); + conf.appendChild(row); + } else if (WP.channel === "random") { + conf.appendChild(DS.engrave("Minted sets")); + const tray = scrollGallery("pair-scroll"); + sortView(WP.sets).forEach(({ o: st, i }) => { + const cell = h("div", "pair-cell"); + const chip = h("div", "pair-chip" + (i === WP.setSel ? " sel" : "")); + chip.style.background = SWATCH[st.pics[0]]; + chip.appendChild(h("span", "count-badge", st.pics.length)); + chip.onclick = () => { WP.setSel = i; DS.toast("Set selected", true); paint(); }; + cell.appendChild(chip); + cell.appendChild(chipFoot(st.label, + () => { + WP.setBuild = { pics: st.pics.slice(), label: st.label }; + WP.sets.splice(i, 1); + if (WP.setSel >= WP.sets.length) WP.setSel = WP.sets.length - 1; + DS.toast("Set opened below", true); + paint(); + }, + () => { + WP.sets.splice(i, 1); + if (WP.setSel >= WP.sets.length) WP.setSel = WP.sets.length - 1; + DS.toast("Set deleted", true); + paint(); + })); + tray.appendChild(cell); + }); + conf.appendChild(sortedRow(tray)); + + conf.appendChild(DS.engrave("Set press — click pictures to add")); + const bRow = h("div", "wp-well"); + const bTray = scrollGallery(); + if (WP.setBuild.pics.length === 0) bTray.appendChild(h("div", "wp-note", "empty — click pictures below")); + WP.setBuild.pics.forEach((pi, k) => { + const t = h("div", "wp-th"); + t.style.background = SWATCH[pi]; + t.title = "click to remove from the set"; + t.onclick = () => { WP.setBuild.pics.splice(k, 1); paint(); }; + bTray.appendChild(t); + }); + bRow.appendChild(bTray); + conf.appendChild(bRow); + const side = h("div", "pair-bench"); + const lbl = h("input", "wp-lblin"); + lbl.placeholder = "label (optional)"; + lbl.value = WP.setBuild.label || ""; + lbl.oninput = () => { WP.setBuild.label = lbl.value; }; + side.appendChild(lbl); + const mint = h("button", "dupre-key", "MINT SET"); + mint.style.cssText = "height:26px;font-size:8px;padding:0 10px"; + mint.onclick = () => { + mint.blur(); + if (WP.setBuild.pics.length < 2) { DS.toast("a set needs at least two pictures", false); return; } + WP.sets.push({ pics: WP.setBuild.pics.slice(), label: WP.setBuild.label || "", ts: WP.mintSeq++ }); + WP.setSel = WP.sets.length - 1; + WP.setBuild = { pics: [], label: "" }; + DS.toast("Set minted", true); + paint(); + }; + side.appendChild(mint); + const row2 = h("div", "wp-row2"); + row2.appendChild(h("span", "wp-note", "next draw every")); + const wheel = h("div", "int-wheel", WP.interval); + wheel.onclick = () => { + WP.interval = INTERVALS[(INTERVALS.indexOf(WP.interval) + 1) % INTERVALS.length]; + DS.toast("Random interval: " + WP.interval + "m", true); + paint(); + }; + row2.appendChild(wheel); + row2.appendChild(h("span", "wp-note", "minutes")); + side.appendChild(row2); + conf.appendChild(side); + + conf.appendChild(DS.engrave("Pictures")); + const g = scrollGallery(); + SWATCH.forEach((sw, i) => { + const t = h("div", "wp-th"); + t.style.background = sw; + t.onclick = () => { + if (WP.setBuild.pics.includes(i)) { DS.toast("already in the set", false); return; } + WP.setBuild.pics.push(i); + paint(); + }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + } else if (WP.channel === "video") { + conf.appendChild(DS.engrave("Videos")); + const g = scrollGallery(); + VIDEOS.forEach((v, i) => { + const t = h("div", "wp-th" + (i === WP.video ? " sel" : "")); + t.style.background = v; + t.appendChild(h("div", "vid-play", "\u25B6")); + t.onclick = () => { WP.video = i; DS.toast("Video set", true); paint(); }; + g.appendChild(t); + }); + conf.appendChild(galleryWell(g)); + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox(VIDEOS[WP.video]); + pv.classList.add("sm"); + pv.appendChild(h("div", "vid-play", "\u25B6")); + conf.appendChild(pv); + } else if (WP.channel === "watch") { + conf.appendChild(DS.engrave("Preview")); + conf.appendChild(previewBox(null, "2026-07-22-watch-preview.png")); + } else if (WP.channel === "clock") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const cf = h("div", "clock-face"); cf.style.inset = "6% 34%"; + pv.appendChild(cf); + conf.appendChild(pv); + } else if (WP.channel === "world") { + conf.appendChild(DS.engrave("Preview")); + const pv = previewBox("#0c0a08"); + const m = h("div", "mini-clocks"); m.style.inset = "8%"; + for (let i = 0; i < 6; i++) m.appendChild(h("i")); + pv.appendChild(m); + conf.appendChild(pv); + } + body2.appendChild(conf); + + // sources: only for the modes that draw from directories + if (SOURCED.includes(WP.channel)) { + const src = h("div", "wp-conf"); + src.appendChild(DS.engrave("Sources")); + const dirs = h("div", "wp-dirs"); + WP.dirs.forEach((d, i) => { + const row = h("div", "wp-dir"); + row.innerHTML = `<span>\u{f024b}</span>`; + row.appendChild(h("span", "path", d)); + const rm = h("span", "rm", "\u2715"); + rm.onclick = () => { + if (WP.dirs.length === 1) { DS.toast("keep at least one source", false); return; } + WP.dirs.splice(i, 1); + DS.toast("Source removed", true); + paint(); + }; + row.appendChild(rm); + dirs.appendChild(row); + }); + src.appendChild(dirs); + const add = h("button", "dupre-key", "ADD DIRECTORY"); + add.style.cssText = "height:24px;font-size:8px;padding:0 10px;margin-top:6px"; + add.onclick = () => { + add.blur(); + const next = DIR_CANDIDATES.find((c) => !WP.dirs.includes(c)); + if (!next) { DS.toast("no more candidates in the mock", false); return; } + WP.dirs.push(next); + DS.toast("Source added: " + next, true); + paint(); + }; + src.appendChild(add); + body2.appendChild(src); + } + }; + paint(); + return sv; +} + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(wallpaperView()); + subview.classList.add("open"); +} + +function scaleSvg(host, w) { + const svg = host.querySelector("svg"); + const [, , vw, vh] = svg.getAttribute("viewBox").split(" ").map(Number); + svg.setAttribute("width", w); + svg.setAttribute("height", Math.round(w * vh / vw)); +} + + +// current pin set for the whole matrix +function currentPins() { + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + if (DS.state[key]) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets[key]) pins.push(lbl + ">" + n); }); + }); + PWR_ROWS.forEach(([lbl, id]) => { + if (DS.state.powerprofile === id) pins.push(lbl + ">NOW"); + SCENES.forEach((n) => { if (DS.SCENES[n].sets.powerprofile === id) pins.push(lbl + ">" + n); }); + }); + return pins; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // ---- plate 1: the routing matrix --------------------------------------- + const plate = h("div", "plate"); + plate.appendChild(DS.engrave("Programmable presets")); + const mxHost = h("div", "khost"); + const PROG_DEFS = [ + { id: "FOCUS", label: "FOCUS", glyph: "\u{f02cb}", scene: "Focus" }, // headphones — deep work (Craig to confirm) + { id: "PRESENT", label: "PRESENT", glyph: "\u{f0379}", scene: "Presentation" }, // projector screen + { id: "BATTERY", label: "BATTERY", glyph: "\u{f007e}", scene: "Battery" }, // half battery + { id: "NIGHT", label: "NIGHT", glyph: "\u{f0594}", scene: "Night" }, // crescent moon + { id: "S5", label: "1", scene: "1" }, + { id: "S6", label: "2", scene: "2" }, + { id: "S7", label: "3", scene: "3" }, + { id: "S8", label: "4", scene: "4" }, + ]; + const SCENE_OF = Object.fromEntries(PROG_DEFS.map((p) => [p.id, p.scene])); + const PROGS = PROG_DEFS.map(({ id, label, glyph, slim }) => ({ id, label, glyph, slim })); + const pins = []; + SWITCH_ROWS.forEach(([lbl, key]) => { + PROG_DEFS.forEach((p) => { if (DS.SCENES[p.scene].sets[key]) pins.push(lbl + ">" + p.id); }); + }); + const letters = {}; + PROG_DEFS.forEach((p) => { letters[PWR_ROW + ">" + p.id] = LETTER_OF[DS.SCENES[p.scene].sets.powerprofile]; }); + const stayActive = (scene) => { DS.state.scene = scene; render(); }; + const mx = DUPRE.programMatrix(mxHost, { + rows: [...SWITCH_ROWS.map(([l]) => l), { label: PWR_ROW, values: PWR_VALUES }], + programs: PROGS, + pins, + letters, + active: DS.state.scene ? PROG_DEFS.findIndex((p) => p.scene === DS.state.scene) : -1, + width: 436, + x0: 128, + dy: 23, + engraved: true, + onActivate: (j) => { + if (booting) return; + DS.applyScene(PROG_DEFS[j].scene, mxHost); + }, + onPin: (rowLbl, progId, seated, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + const sets = DS.SCENES[scene].sets; + const sw = SWITCH_ROWS.find(([l]) => l === rowLbl); + if (!sw) return; + sets[sw[1]] = seated; + if (isActive && seated !== DS.state[sw[1]]) { + DS.setToggle(sw[1], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + onLetter: (rowLbl, progId, v, isActive) => { + if (booting) return; + const scene = SCENE_OF[progId]; + DS.SCENES[scene].sets.powerprofile = PWR_OF[v]; + if (isActive && DS.state.powerprofile !== PWR_OF[v]) { + DS.setProfile(PWR_OF[v], mxHost); + stayActive(scene); + } else DS.toast((PROG_DEFS.find((p) => p.id === progId).label || "slot") + " program updated", true); + }, + }); + plate.appendChild(mxHost); + col.appendChild(plate); + + // ---- plate 2: the bench — pointers over brightness; the idle dial alone - + const lv = h("div", "plate"); + const bench = h("div", "bench"); + + const fbox = (label) => { + const b = h("div", "fbox"); + b.appendChild(h("span", "flbl", label)); + return b; + }; + + const lcol = h("div", "fcol"); + + // POINTERS — console keys, horizontal (above brightness per batch two) + const ptr = fbox("Pointers"); + [["touchpad", "TOUCHPAD"], ["mouse", "MOUSE"]].forEach(([k, name]) => { + const b = h("button", "dupre-key ptr", name); + if (DS.state[k]) b.classList.add("dupre-green"); + b.onclick = () => { b.blur(); DS.setToggle(k, ptr); }; + ptr.appendChild(b); + }); + lcol.appendChild(ptr); + + // BRIGHTNESS — the drum pair, centered in its frame + const bright = fbox("Brightness"); + const dhost = h("div", "khost"); + const commitDrums = debounce((vals) => { + const b = Math.max(DS.SLIDERS.brightness.floor, Math.round(vals[0]) * 10); + const k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, bright); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, bright); + }, 350); + DUPRE.drumRoller(dhost, { + title: "", + channels: [ + { name: "SCREEN", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.round(DS.state.kbd / 10) }, + ], + min: 0, max: 10, + height: 196, + onChange: (vals) => { if (!booting) commitDrums(vals.slice()); }, + }); + bright.appendChild(dhost); + lcol.appendChild(bright); + bench.appendChild(lcol); + + // IDLE — enlarged dial, legend beneath, alone in its frame + const idle = fbox("Idle"); + const railHost = h("div", "khost"); + const rail = DUPRE.tripDial(railHost, { + stages: IDLE_STAGES.map((st) => ({ ...st })), + max: 60, + size: 184, + legend: true, + legendPos: "bottom", + onChange: (id, minutes, snapshot) => { + snapshot.forEach((st) => { const m = IDLE_STAGES.find((o) => o.id === st.id); if (m) m.minutes = st.minutes; }); + if (booting || !id) return; + const st = IDLE_STAGES.find((o) => o.id === id); + DS.toast(st.label + (minutes == null ? " stage disabled" : " → idle " + Math.round(minutes) + "m"), true); + }, + }); + rail.setBypassed(DS.state.caffeine); + idle.appendChild(railHost); + bench.appendChild(idle); + lv.appendChild(bench); + col.appendChild(lv); + + // ---- wallpaper ---------------------------------------------------------- + const wp = h("div", "wprow"); + wp.innerHTML = `<span>\u{f021c}</span><span>Wallpaper</span><span class="k-state">manage ›</span>`; + wp.onclick = openWallpaper; + col.appendChild(wp); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 6 (hi-fi receiver)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + /* Hi-fi receiver faceplate: brushed plate, backlit tuner dial with scene + "stations", preset keys, VU meters over rotary knobs, switch bank. */ + .plate { + background: + repeating-linear-gradient(90deg, #ffffff05 0 1px, transparent 1px 3px), + linear-gradient(180deg, #201e1a, #171512); + border: 1px solid var(--plate-edge); + border-radius: 10px; + padding: 10px; + } + + /* Tuner dial: warm backlit window, station labels, sliding needle. */ + .tuner { + position: relative; + height: 64px; + border-radius: 8px; + background: linear-gradient(180deg, #2b2414, #1c1809); + border: 1px solid #3a321c; + box-shadow: inset 0 0 18px #dab53d22; + overflow: hidden; + } + .tuner .scale { + position: absolute; left: 10px; right: 10px; top: 12px; height: 8px; + background: + repeating-linear-gradient(90deg, #dab53d55 0 1px, transparent 1px 8.5%); + border-bottom: 1px solid #dab53d44; + } + .tuner .station { + position: absolute; top: 28px; + transform: translateX(-50%); + color: #9a8a55; font-size: 10px; letter-spacing: 0.1em; + cursor: pointer; padding: 2px 4px; + } + .tuner .station:hover { color: var(--gold-bright); } + .tuner .station.cur { color: var(--gold-bright); text-shadow: 0 0 8px #dab53daa; } + .tuner .freq { + position: absolute; bottom: 3px; left: 10px; + color: #6a5f3a; font-size: 9px; letter-spacing: 0.14em; + } + .tuner .needle { + position: absolute; top: 6px; bottom: 6px; width: 2px; + background: linear-gradient(180deg, var(--fail), #8a4830); + box-shadow: 0 0 6px #cb6b4d88; + transition: left .35s cubic-bezier(.3,1.2,.4,1); + } + + /* Preset keys: chunky mechanical station buttons under the dial. */ + .presets { display: grid; grid-template-columns: repeat(4,1fr); gap: 6px; margin-top: 8px; } + .preset { + background-image: linear-gradient(180deg, var(--key-top), var(--key-bot)); + border: 1px solid var(--key-edge); + border-bottom-width: 3px; + border-radius: 6px; + padding: 6px 4px; + text-align: center; + cursor: pointer; + color: var(--silver); + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 9px; color: var(--dim); display: block; } + .preset .nm { font-size: 10.5px; letter-spacing: 0.06em; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; + background-image: linear-gradient(180deg, #2a2618, #1d1a12); + transform: translateY(2px); + color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* VU meter: cream arc window with a swinging needle. */ + .duo { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + .vu { + position: relative; height: 58px; border-radius: 8px 8px 4px 4px; + background: linear-gradient(180deg, #efe3c2, #d9cba2); + border: 1px solid #3a3226; + overflow: hidden; + } + .vu .arc { + position: absolute; left: 8px; right: 8px; top: 10px; height: 5px; + border-radius: 4px; + background: linear-gradient(90deg, #6b6352 70%, #b5432e 70%); + opacity: .8; + } + .vu .vu-lbl { + position: absolute; bottom: 3px; width: 100%; text-align: center; + color: #5a5140; font-size: 8.5px; letter-spacing: 0.18em; + } + .vu .nd { + position: absolute; left: 50%; bottom: -6px; width: 2px; height: 52px; + background: #1d1a14; + transform-origin: 50% 100%; + transition: transform .25s ease-out; + } + .knob-cell { display: flex; flex-direction: column; align-items: center; gap: 5px; margin-top: 6px; } + .knob { + width: 52px; height: 52px; border-radius: 50%; + background: + radial-gradient(circle at 35% 30%, #35322c, #191715 70%), + var(--key-bot); + border: 2px solid var(--key-edge); + box-shadow: 0 3px 8px #000a, inset 0 1px 0 #ffffff14; + position: relative; + cursor: ew-resize; + touch-action: none; + } + .knob .ptr { + position: absolute; left: 50%; top: 4px; width: 2px; height: 14px; + margin-left: -1px; + background: var(--gold); + transform-origin: 50% 22px; + } + .knob-lbl { color: var(--steel); font-size: 9.5px; letter-spacing: 0.14em; } + .knob-val { color: var(--cream); font-size: 11px; font-variant-numeric: tabular-nums; } + + /* Switch bank: square backlit-legend switches, two rows of four. */ + .bank { display: grid; grid-template-columns: repeat(4,1fr); gap: 6px; } + .sw { + background-image: linear-gradient(180deg, var(--key-top), var(--key-bot)); + border: 1px solid var(--key-edge); + border-radius: 6px; + padding: 7px 3px 5px; + display: flex; flex-direction: column; align-items: center; gap: 4px; + cursor: pointer; + } + .sw:hover { border-color: var(--gold); } + .sw .g { font-size: 15px; color: var(--steel); } + .sw .lgd { font-size: 8px; letter-spacing: 0.08em; color: var(--dim); text-transform: uppercase; } + .sw.on { border-color: var(--gold); background-image: linear-gradient(180deg, #2a2618, #1d1a12); } + .sw.on .g { color: var(--gold); } + .sw.on .lgd { color: var(--gold-bright); text-shadow: 0 0 7px #dab53d99; } + .sw.verified { animation: dsVerify 0.9s ease-out; } + + /* Input selector: one rotary with three engraved detents. */ + .selector { display: flex; align-items: center; gap: 14px; padding: 2px 6px; } + .selector .labels { display: flex; flex-direction: column; gap: 3px; } + .selector .det { color: var(--dim); font-size: 10px; letter-spacing: 0.12em; cursor: pointer; } + .selector .det:hover { color: var(--silver); } + .selector .det.cur { color: var(--gold-bright); text-shadow: 0 0 7px #dab53d77; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Direction 6 — Hi-fi receiver.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENE_ORDER = Object.keys(DS.SCENES); // 4 stations +const STATION_POS = [14, 38, 62, 86]; // % across the dial + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function tuner() { + const t = h("div", "tuner"); + t.appendChild(h("div", "scale")); + SCENE_ORDER.forEach((n, i) => { + const s = h("span", "station" + (DS.state.scene === n ? " cur" : ""), n.toUpperCase()); + s.style.left = STATION_POS[i] + "%"; + s.onclick = () => DS.applyScene(n, t); + t.appendChild(s); + }); + t.appendChild(h("span", "freq", "SCENE TUNING")); + const nd = h("div", "needle"); + const idx = SCENE_ORDER.indexOf(DS.state.scene); + nd.style.left = (idx < 0 ? 3 : STATION_POS[idx]) + "%"; + t.appendChild(nd); + return t; +} + +function presetKeys() { + const wrap = h("div", "presets"); + SCENE_ORDER.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + wrap.appendChild(k); + }); + return wrap; +} + +// VU needle: value 0-100 → -42°..42° swing. +function vuMeter(key, label) { + const v = h("div", "vu"); + v.appendChild(h("div", "arc")); + const nd = h("div", "nd"); + nd.style.transform = `rotate(${(DS.state[key] / 100) * 84 - 42}deg)`; + nd.dataset.vu = key; + v.appendChild(nd); + v.appendChild(h("div", "vu-lbl", label)); + return v; +} + +// Rotary knob: drag sideways (or wheel) to set; pointer sweeps -135°..135°. +function knob(key, label) { + const meta = DS.SLIDERS[key]; + const cell = h("div", "knob-cell"); + const kn = h("div", "knob"); + const ptr = h("div", "ptr"); + const val = h("div", "knob-val"); + const angle = (v) => ((v - meta.floor) / (100 - meta.floor)) * 270 - 135; + const paint = (v) => { + ptr.style.transform = `rotate(${angle(v)}deg)`; + val.textContent = v + "%"; + const vu = document.querySelector(`[data-vu="${key}"]`); + if (vu) vu.style.transform = `rotate(${(v / 100) * 84 - 42}deg)`; + }; + kn.appendChild(ptr); + paint(DS.state[key]); + let live = DS.state[key]; + kn.onpointerdown = (e) => { + kn.setPointerCapture(e.pointerId); + const startX = e.clientX, startV = live; + kn.onpointermove = (m) => { + live = Math.max(meta.floor, Math.min(100, Math.round(startV + (m.clientX - startX) / 2))); + paint(live); + }; + kn.onpointerup = () => { kn.onpointermove = null; DS.setSlider(key, live, kn); }; + }; + kn.onwheel = (e) => { + e.preventDefault(); + live = Math.max(meta.floor, Math.min(100, live + (e.deltaY < 0 ? 5 : -5))); + paint(live); + clearTimeout(kn._w); + kn._w = setTimeout(() => DS.setSlider(key, live, kn), 350); + }; + cell.appendChild(kn); + cell.appendChild(h("div", "knob-lbl", label)); + cell.appendChild(val); + return cell; +} + +function switchBank() { + const bank = h("div", "bank"); + const keys = ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd", "weathergeo"]; + if (DS.state.laptop) keys.push("airplane"); + keys.forEach((k) => { + const meta = DS.TOGGLES[k]; + const el = h("div", "sw" + (DS.state[k] ? " on" : "")); + el.innerHTML = `<span class="g">${meta.glyph}</span><span class="lgd">${meta.name}</span>`; + el.onclick = () => DS.setToggle(k, el); + bank.appendChild(el); + }); + return bank; +} + +function inputSelector() { + const sel = h("div", "selector"); + const kn = h("div", "knob"); + kn.style.cursor = "pointer"; + const ptr = h("div", "ptr"); + const idx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + ptr.style.transform = `rotate(${idx * 45 - 45}deg)`; + kn.appendChild(ptr); + kn.onclick = () => { + const next = DS.PROFILES[(idx + 1) % DS.PROFILES.length]; + DS.setProfile(next.id, sel); + }; + sel.appendChild(kn); + const labels = h("div", "labels"); + DS.PROFILES.forEach((p) => { + const d = h("span", "det" + (p.id === DS.state.powerprofile ? " cur" : ""), p.label.toUpperCase()); + d.onclick = () => DS.setProfile(p.id, sel); + labels.appendChild(d); + }); + sel.appendChild(labels); + sel.appendChild(h("span", "knob-lbl", "POWER")); + return sel; +} + +function render() { + body.innerHTML = ""; + const col = h("div", "ds-col"); + + const dialPlate = h("div", "plate"); + dialPlate.appendChild(tuner()); + dialPlate.appendChild(presetKeys()); + col.appendChild(dialPlate); + + col.appendChild(DS.engrave("Levels")); + const lv = h("div", "plate"); + const duo = h("div", "duo"); + const c1 = h("div"); c1.appendChild(vuMeter("brightness", "BRIGHTNESS")); c1.appendChild(knob("brightness", "BRIGHTNESS")); + const c2 = h("div"); c2.appendChild(vuMeter("kbd", "KEYBOARD")); c2.appendChild(knob("kbd", "KEYBOARD")); + duo.appendChild(c1); duo.appendChild(c2); + lv.appendChild(duo); + col.appendChild(lv); + + col.appendChild(DS.engrave("Switches")); + col.appendChild(switchBank()); + + col.appendChild(DS.engrave("Power")); + col.appendChild(inputSelector()); + + col.appendChild(DS.engrave("Actions")); + const acts = h("div", "ds-grid3"); + acts.appendChild(DS.actionBtn("Lock", "\u{f033e}", false)); + acts.appendChild(DS.actionBtn("Suspend", "\u{f04b2}", false)); + acts.appendChild(DS.actionBtn("Wallpaper", "\u{f021c}", false, openWallpaper)); + col.appendChild(acts); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 7 (chronograph)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + /* Chronograph: one big watch face. Brightness is the main dial (drag the + bezel), chrono subdials carry keyboard / power / scene, toggles are the + bracelet links below. */ + .case { + display: flex; flex-direction: column; align-items: center; gap: 8px; + } + .face-wrap { position: relative; width: 300px; height: 300px; } + .bezel { + position: absolute; inset: 0; border-radius: 50%; + background: + radial-gradient(circle at 32% 28%, #3a362e, #191715 72%); + border: 2px solid var(--key-edge); + box-shadow: 0 8px 26px #000c, inset 0 1px 0 #ffffff14; + cursor: pointer; + touch-action: none; + } + .ring { + position: absolute; inset: 12px; border-radius: 50%; + } + .dial { + position: absolute; inset: 34px; border-radius: 50%; + background: radial-gradient(circle at 40% 32%, #14130f, #0b0a08 75%); + border: 1px solid #2c2820; + pointer-events: none; + } + .tick { + position: absolute; left: 50%; top: 14px; width: 1px; height: 7px; + margin-left: -0.5px; + background: #6a6250; + transform-origin: 50% 136px; + pointer-events: none; + } + .tick.major { width: 2px; height: 11px; margin-left: -1px; background: var(--gold); } + .hand { + position: absolute; left: 50%; top: 44px; width: 3px; height: 106px; + margin-left: -1.5px; + background: linear-gradient(180deg, var(--gold-bright), var(--gold) 80%, transparent); + transform-origin: 50% 106px; + border-radius: 3px; + box-shadow: 0 0 6px #dab53d55; + transition: transform .2s ease-out; + pointer-events: none; + } + .cap { + position: absolute; left: 50%; top: 50%; width: 12px; height: 12px; + margin: -6px 0 0 -6px; border-radius: 50%; + background: radial-gradient(circle at 35% 30%, var(--cream), #8a7d55); + pointer-events: none; + } + .digital { + position: absolute; left: 50%; top: 33%; transform: translateX(-50%); + text-align: center; pointer-events: none; + } + .digital .big { color: var(--cream); font-size: 21px; font-weight: 700; font-variant-numeric: tabular-nums; } + .digital .big .u { color: var(--dim); font-size: 11px; } + .digital .lbl { color: var(--steel); font-size: 8.5px; letter-spacing: 0.22em; } + + /* Subdials */ + .subdial { + position: absolute; width: 74px; height: 74px; border-radius: 50%; + background: radial-gradient(circle at 40% 32%, #1b1914, #100e0a 75%); + border: 1px solid #322d24; + cursor: pointer; + } + .subdial .s-hand { + position: absolute; left: 50%; top: 9px; width: 2px; height: 28px; + margin-left: -1px; + background: var(--fail); + transform-origin: 50% 28px; + transition: transform .25s cubic-bezier(.3,1.4,.4,1); + pointer-events: none; + } + .subdial .s-cap { + position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; + margin: -3px 0 0 -3px; border-radius: 50%; background: var(--cream); + pointer-events: none; + } + .subdial .s-lbl { + position: absolute; width: 100%; text-align: center; bottom: 13px; + color: var(--steel); font-size: 7.5px; letter-spacing: 0.14em; + pointer-events: none; + } + .subdial .s-val { + position: absolute; width: 100%; text-align: center; bottom: 4px; + color: var(--gold); font-size: 8.5px; + pointer-events: none; + } + .subdial.left { left: 42px; top: 113px; } + .subdial.right { right: 42px; top: 113px; } + .subdial.bottom{ left: 113px; bottom: 34px; } + .subdial:hover { border-color: var(--gold); } + + /* Crown + pushers: momentary actions on the right case edge. */ + .crown { + position: absolute; right: -12px; width: 16px; height: 26px; + border-radius: 4px; + background: linear-gradient(90deg, #2c2922, #191715); + border: 1px solid var(--key-edge); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + color: var(--steel); font-size: 9px; + } + .crown:hover { border-color: var(--gold); color: var(--gold); } + .crown.c1 { top: 84px; transform: rotate(20deg); } + .crown.c2 { top: 140px; } + .crown.c3 { top: 196px; transform: rotate(-20deg); } + .crown-lbl { + position: absolute; right: -66px; width: 50px; + color: var(--dim); font-size: 8.5px; letter-spacing: 0.08em; + } + + /* Bracelet: toggle links under the case. */ + .bracelet { display: grid; grid-template-columns: repeat(4,1fr); gap: 5px; width: 100%; } + .link { + background: linear-gradient(180deg, #24211c, #171512); + border: 1px solid var(--key-edge); + border-radius: 5px; + padding: 6px 2px 5px; + display: flex; flex-direction: column; align-items: center; gap: 3px; + cursor: pointer; + } + .link:hover { border-color: var(--gold); } + .link .g { font-size: 14px; color: var(--steel); } + .link .n { font-size: 7.5px; letter-spacing: 0.06em; color: var(--dim); text-transform: uppercase; } + .link.on { border-color: var(--gold); background: linear-gradient(180deg, #2a2618, #1d1a12); } + .link.on .g { color: var(--gold); } + .link.on .n { color: var(--gold-bright); } + .link.verified { animation: dsVerify 0.9s ease-out; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Direction 7 — Chronograph.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENE_ORDER = Object.keys(DS.SCENES); +const B_FLOOR = DS.SLIDERS.brightness.floor; +const SWEEP = 270, START = -135; // main dial sweep + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +const angleOf = (v, floor) => START + ((v - floor) / (100 - floor)) * SWEEP; + +// pointer position → dial value (angle from 12 o'clock, clamped to the sweep) +function valueAt(e, el, floor) { + const r = el.getBoundingClientRect(); + const dx = e.clientX - (r.left + r.width / 2); + const dy = e.clientY - (r.top + r.height / 2); + let a = Math.atan2(dx, -dy) * 180 / Math.PI; // 0 at top, cw positive + a = Math.max(START, Math.min(START + SWEEP, a)); + return Math.round(floor + ((a - START) / SWEEP) * (100 - floor)); +} + +function mainDial() { + const wrap = h("div", "face-wrap"); + const bez = h("div", "bezel"); + + const ring = h("div", "ring"); + const paintRing = (v) => { + const deg = ((v - B_FLOOR) / (100 - B_FLOOR)) * SWEEP; + ring.style.background = + `conic-gradient(from ${START}deg, var(--gold) 0 ${deg}deg, #21201b ${deg}deg ${SWEEP}deg, transparent ${SWEEP}deg 360deg)`; + ring.style.borderRadius = "50%"; + }; + paintRing(DS.state.brightness); + bez.appendChild(ring); + bez.appendChild(h("div", "dial")); + + for (let i = 0; i <= 20; i++) { + const t = h("div", "tick" + (i % 5 === 0 ? " major" : "")); + t.style.transform = `rotate(${START + (i / 20) * SWEEP}deg)`; + bez.appendChild(t); + } + + const hand = h("div", "hand"); + const dig = h("div", "digital"); + const paintHand = (v) => { + hand.style.transform = `rotate(${angleOf(v, B_FLOOR)}deg)`; + dig.innerHTML = `<div class="big">${v}<span class="u">%</span></div><div class="lbl">BRIGHTNESS</div>`; + }; + paintHand(DS.state.brightness); + bez.appendChild(hand); + bez.appendChild(dig); + bez.appendChild(h("div", "cap")); + + let live = DS.state.brightness; + bez.onpointerdown = (e) => { + if (e.target.closest(".subdial") || e.target.closest(".crown")) return; + bez.setPointerCapture(e.pointerId); + live = valueAt(e, bez, B_FLOOR); paintRing(live); paintHand(live); + bez.onpointermove = (m) => { live = valueAt(m, bez, B_FLOOR); paintRing(live); paintHand(live); }; + bez.onpointerup = () => { bez.onpointermove = null; DS.setSlider("brightness", live, bez); }; + }; + + // Subdial: keyboard backlight (drag same as main, smaller sweep display). + const kbd = h("div", "subdial left"); + const kh = h("div", "s-hand"); + const kFloor = DS.SLIDERS.kbd.floor; + const paintK = (v) => kh.style.transform = `rotate(${angleOf(v, kFloor)}deg)`; + paintK(DS.state.kbd); + kbd.appendChild(kh); kbd.appendChild(h("div", "s-cap")); + kbd.appendChild(h("div", "s-lbl", "KEYBOARD")); + const kv = h("div", "s-val", DS.state.kbd + "%"); + kbd.appendChild(kv); + let kLive = DS.state.kbd; + kbd.onpointerdown = (e) => { + e.stopPropagation(); + kbd.setPointerCapture(e.pointerId); + kLive = valueAt(e, kbd, kFloor); paintK(kLive); kv.textContent = kLive + "%"; + kbd.onpointermove = (m) => { kLive = valueAt(m, kbd, kFloor); paintK(kLive); kv.textContent = kLive + "%"; }; + kbd.onpointerup = () => { kbd.onpointermove = null; DS.setSlider("kbd", kLive, kbd); }; + }; + + // Subdial: power profile — three detents, click steps. + const pw = h("div", "subdial right"); + const pIdx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + const ph = h("div", "s-hand"); + ph.style.transform = `rotate(${pIdx * 90 - 90}deg)`; + pw.appendChild(ph); pw.appendChild(h("div", "s-cap")); + pw.appendChild(h("div", "s-lbl", "POWER")); + pw.appendChild(h("div", "s-val", DS.PROFILES[pIdx].label.toUpperCase())); + pw.onclick = (e) => { + e.stopPropagation(); + DS.setProfile(DS.PROFILES[(pIdx + 1) % DS.PROFILES.length].id, pw); + }; + + // Subdial: scene — off + four stations, click steps. + const sc = h("div", "subdial bottom"); + const sIdx = SCENE_ORDER.indexOf(DS.state.scene); // -1 = off + const sh = h("div", "s-hand"); + sh.style.transform = `rotate(${(sIdx + 1) * 72 - 144}deg)`; + sc.appendChild(sh); sc.appendChild(h("div", "s-cap")); + sc.appendChild(h("div", "s-lbl", "SCENE")); + sc.appendChild(h("div", "s-val", sIdx < 0 ? "OFF" : SCENE_ORDER[sIdx].toUpperCase())); + sc.onclick = (e) => { + e.stopPropagation(); + const next = SCENE_ORDER[(sIdx + 1) % SCENE_ORDER.length]; + DS.applyScene(next, sc); + }; + + bez.appendChild(kbd); bez.appendChild(pw); bez.appendChild(sc); + wrap.appendChild(bez); + + // Crowns: momentary actions. + const mk = (cls, glyph, label, fn) => { + const c = h("div", "crown " + cls, glyph); + c.title = label; + c.onclick = (e) => { e.stopPropagation(); fn ? fn() : DS.momentary(label); }; + return c; + }; + wrap.appendChild(mk("c1", "\u{f033e}", "Lock")); + wrap.appendChild(mk("c2", "\u{f04b2}", "Suspend")); + wrap.appendChild(mk("c3", "\u{f021c}", "Wallpaper", openWallpaper)); + + return wrap; +} + +function bracelet() { + const br = h("div", "bracelet"); + const keys = ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd", "weathergeo"]; + if (DS.state.laptop) keys.push("airplane"); + keys.forEach((k) => { + const meta = DS.TOGGLES[k]; + const el = h("div", "link" + (DS.state[k] ? " on" : "")); + el.innerHTML = `<span class="g">${meta.glyph}</span><span class="n">${meta.name}</span>`; + el.onclick = () => DS.setToggle(k, el); + br.appendChild(el); + }); + return br; +} + +function render() { + body.innerHTML = ""; + const cs = h("div", "case"); + cs.appendChild(mainDial()); + cs.appendChild(bracelet()); + body.appendChild(cs); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 8 (gauge cluster)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + /* 60s dashboard: twin needle gauges in a binnacle, a rocker-switch bank, + push-button drive modes for scenes, an ignition rotary for power. */ + .binnacle { + background: + radial-gradient(140px 90px at 25% 20%, #ffffff07, transparent), + linear-gradient(180deg, #1c1a17, #121110); + border: 1px solid var(--plate-edge); + border-radius: 14px; + padding: 12px 10px 8px; + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; + } + .gauge { + position: relative; + width: 100%; aspect-ratio: 1; + border-radius: 50%; + background: radial-gradient(circle at 38% 30%, #16140f, #0a0908 78%); + border: 3px solid #2c2820; + box-shadow: inset 0 0 16px #000c, 0 4px 12px #0009; + cursor: pointer; + touch-action: none; + } + .gauge .tk { + position: absolute; left: 50%; top: 8px; width: 1.5px; height: 7px; + margin-left: -0.75px; + background: #7a7057; + transform-origin: 50% calc(var(--gr) * 1px); + pointer-events: none; + } + .gauge .tk.mj { width: 2.5px; height: 11px; background: var(--cream); } + .gauge .num { + position: absolute; left: 50%; top: 50%; + color: var(--steel); font-size: 9px; + transform: translate(-50%, -50%); + pointer-events: none; + } + .gauge .needle { + position: absolute; left: 50%; bottom: 50%; width: 3px; height: 42%; + margin-left: -1.5px; + background: linear-gradient(180deg, var(--fail) 60%, #7e4530); + transform-origin: 50% 100%; + border-radius: 3px 3px 0 0; + box-shadow: 0 0 6px #cb6b4d66; + transition: transform .18s ease-out; + pointer-events: none; + } + .gauge .hub { + position: absolute; left: 50%; top: 50%; width: 14px; height: 14px; + margin: -7px 0 0 -7px; border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #4a453b, #211e19); + border: 1px solid #383328; + pointer-events: none; + } + .gauge .g-lbl { + position: absolute; width: 100%; text-align: center; top: 27%; + color: var(--steel); font-size: 8px; letter-spacing: 0.2em; + pointer-events: none; + } + .odo { + position: absolute; width: 100%; text-align: center; bottom: 17%; + pointer-events: none; + } + .odo span { + display: inline-block; + background: #060505; border: 1px solid #2a2620; border-radius: 3px; + color: var(--cream); font-size: 11px; font-variant-numeric: tabular-nums; + padding: 1px 6px; letter-spacing: 0.12em; + } + + /* Rocker bank: tilting switches with engraved legends. */ + .rockers { display: grid; grid-template-columns: repeat(4,1fr); gap: 6px; } + .rocker { + display: flex; flex-direction: column; align-items: center; gap: 4px; + cursor: pointer; padding: 5px 2px 4px; + border-radius: 6px; border: 1px solid transparent; + } + .rocker:hover { border-color: var(--key-edge); } + .rk { + width: 26px; height: 38px; border-radius: 5px; + background: linear-gradient(180deg, var(--key-top), var(--key-bot)); + border: 1px solid var(--key-edge); + position: relative; + perspective: 60px; + } + .rk .cap { + position: absolute; inset: 2px; border-radius: 3px; + background: linear-gradient(180deg, #2e2b25 48%, #191712 52%); + transform: rotateX(14deg); + transition: transform .12s, background .12s; + } + .rocker.on .rk { border-color: var(--gold); } + .rocker.on .rk .cap { + transform: rotateX(-14deg); + background: linear-gradient(180deg, #191712 48%, #4a3d16 52%); + } + .rocker .lgd { + font-size: 7.5px; letter-spacing: 0.06em; color: var(--dim); + text-transform: uppercase; text-align: center; + } + .rocker.on .lgd { color: var(--gold-bright); text-shadow: 0 0 6px #dab53d88; } + .rocker .g { font-size: 13px; color: var(--steel); } + .rocker.on .g { color: var(--gold); } + .rocker.verified { animation: dsVerify 0.9s ease-out; } + + /* Drive-mode push buttons (scenes): the push-button transmission column. */ + .modes { display: grid; grid-template-columns: repeat(4,1fr); gap: 6px; } + .mode { + background: linear-gradient(180deg, var(--key-top), var(--key-bot)); + border: 1px solid var(--key-edge); + border-bottom-width: 4px; + border-radius: 6px; + padding: 7px 2px; + text-align: center; cursor: pointer; + } + .mode .ltr { display: block; color: var(--cream); font-size: 15px; font-weight: 700; } + .mode .nm { display: block; color: var(--dim); font-size: 7.5px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: 2px; } + .mode:hover { border-color: var(--gold); } + .mode.cur { + border-color: var(--gold); border-bottom-width: 1px; + transform: translateY(3px); + background: linear-gradient(180deg, #2a2618, #1d1a12); + } + .mode.cur .ltr { color: var(--gold-bright); text-shadow: 0 0 8px #dab53d99; } + .mode.cur .nm { color: var(--gold); } + + /* Ignition rotary (power profile). */ + .ignition { display: flex; align-items: center; gap: 14px; padding: 2px 6px; } + .ig-knob { + width: 54px; height: 54px; border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #35322c, #191715 70%); + border: 2px solid var(--key-edge); + box-shadow: 0 3px 8px #000a; + position: relative; cursor: pointer; + } + .ig-knob .slot { + position: absolute; left: 50%; top: 50%; width: 5px; height: 26px; + margin: -13px 0 0 -2.5px; + background: #0a0908; border-radius: 3px; border: 1px solid #3a352c; + transform-origin: 50% 50%; + transition: transform .2s; + } + .ig-labels { display: flex; flex-direction: column; gap: 3px; } + .ig-labels .det { color: var(--dim); font-size: 10px; letter-spacing: 0.12em; cursor: pointer; } + .ig-labels .det:hover { color: var(--silver); } + .ig-labels .det.cur { color: var(--gold-bright); text-shadow: 0 0 7px #dab53d77; } + .ig-lbl { margin-left: auto; color: var(--steel); font-size: 9px; letter-spacing: 0.16em; } + + /* Dash pull-knob actions. */ + .pulls { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; } + .pull { + display: flex; flex-direction: column; align-items: center; gap: 4px; + cursor: pointer; padding: 5px 2px; + border-radius: 6px; border: 1px solid transparent; + } + .pull:hover { border-color: var(--key-edge); } + .pull .cap { + width: 30px; height: 30px; border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #3a362e, #1c1a15); + border: 2px solid var(--key-edge); + box-shadow: 0 3px 6px #000a; + display: flex; align-items: center; justify-content: center; + color: var(--steel); font-size: 13px; + } + .pull:hover .cap { border-color: var(--gold); color: var(--gold); } + .pull .lgd { font-size: 8px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Direction 8 — Gauge cluster.</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENE_ORDER = Object.keys(DS.SCENES); +const SWEEP = 240, START = -120; // gauge sweep + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function valueAt(e, el, floor) { + const r = el.getBoundingClientRect(); + const dx = e.clientX - (r.left + r.width / 2); + const dy = e.clientY - (r.top + r.height / 2); + let a = Math.atan2(dx, -dy) * 180 / Math.PI; + a = Math.max(START, Math.min(START + SWEEP, a)); + return Math.round(floor + ((a - START) / SWEEP) * (100 - floor)); +} + +function gauge(key, label) { + const meta = DS.SLIDERS[key]; + const g = h("div", "gauge"); + + // ticks + numerals — radius derives from the rendered size after mount + requestAnimationFrame(() => { + const R = g.clientWidth / 2; + for (let i = 0; i <= 10; i++) { + const t = h("div", "tk" + (i % 2 === 0 ? " mj" : "")); + t.style.setProperty("--gr", R - 8); + t.style.transform = `rotate(${START + (i / 10) * SWEEP}deg)`; + g.appendChild(t); + if (i % 2 === 0) { + const a = (START + (i / 10) * SWEEP - 90) * Math.PI / 180; + const nr = R - 26; + const n = h("div", "num", i * 10); + n.style.left = 50 + (Math.cos(a) * nr / R) * 50 + "%"; + n.style.top = 50 + (Math.sin(a) * nr / R) * 50 + "%"; + g.appendChild(n); + } + } + }); + + g.appendChild(h("div", "g-lbl", label)); + const nd = h("div", "needle"); + const odo = h("div", "odo"); + const odoSpan = h("span"); + odo.appendChild(odoSpan); + const paint = (v) => { + nd.style.transform = `rotate(${START + ((v - meta.floor) / (100 - meta.floor)) * SWEEP}deg)`; + odoSpan.textContent = String(v).padStart(3, "0"); + }; + paint(DS.state[key]); + g.appendChild(nd); + g.appendChild(h("div", "hub")); + g.appendChild(odo); + + let live = DS.state[key]; + g.onpointerdown = (e) => { + g.setPointerCapture(e.pointerId); + live = valueAt(e, g, meta.floor); paint(live); + g.onpointermove = (m) => { live = valueAt(m, g, meta.floor); paint(live); }; + g.onpointerup = () => { g.onpointermove = null; DS.setSlider(key, live, g); }; + }; + return g; +} + +function rockerBank() { + const bank = h("div", "rockers"); + const keys = ["autodim", "caffeine", "touchpad", "mouse", "nightlight", "dnd", "weathergeo"]; + if (DS.state.laptop) keys.push("airplane"); + keys.forEach((k) => { + const meta = DS.TOGGLES[k]; + const el = h("div", "rocker" + (DS.state[k] ? " on" : "")); + el.innerHTML = + `<span class="g">${meta.glyph}</span>` + + `<span class="rk"><span class="cap"></span></span>` + + `<span class="lgd">${meta.name}</span>`; + el.onclick = () => DS.setToggle(k, el); + bank.appendChild(el); + }); + return bank; +} + +function driveModes() { + const m = h("div", "modes"); + SCENE_ORDER.forEach((n) => { + const el = h("div", "mode" + (DS.state.scene === n ? " cur" : "")); + el.innerHTML = `<span class="ltr">${n[0]}</span><span class="nm">${n}</span>`; + el.onclick = () => DS.applyScene(n, el); + m.appendChild(el); + }); + return m; +} + +function ignition() { + const ig = h("div", "ignition"); + const kn = h("div", "ig-knob"); + const slot = h("div", "slot"); + const idx = DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile); + slot.style.transform = `rotate(${idx * 45 - 45}deg)`; + kn.appendChild(slot); + kn.onclick = () => DS.setProfile(DS.PROFILES[(idx + 1) % DS.PROFILES.length].id, ig); + ig.appendChild(kn); + const labels = h("div", "ig-labels"); + DS.PROFILES.forEach((p) => { + const d = h("span", "det" + (p.id === DS.state.powerprofile ? " cur" : ""), p.label.toUpperCase()); + d.onclick = () => DS.setProfile(p.id, ig); + labels.appendChild(d); + }); + ig.appendChild(labels); + ig.appendChild(h("span", "ig-lbl", "IGNITION")); + return ig; +} + +function pulls() { + const p = h("div", "pulls"); + const mk = (glyph, label, fn) => { + const el = h("div", "pull"); + el.innerHTML = `<span class="cap">${glyph}</span><span class="lgd">${label}</span>`; + el.onclick = fn || (() => DS.momentary(label)); + return el; + }; + p.appendChild(mk("\u{f033e}", "Lock")); + p.appendChild(mk("\u{f04b2}", "Suspend")); + p.appendChild(mk("\u{f021c}", "Wallpaper", openWallpaper)); + return p; +} + +function render() { + body.innerHTML = ""; + const col = h("div", "ds-col"); + + const bn = h("div", "binnacle"); + bn.appendChild(gauge("brightness", "BRIGHTNESS")); + bn.appendChild(gauge("kbd", "KEYBOARD")); + col.appendChild(bn); + + col.appendChild(DS.engrave("Switches")); + col.appendChild(rockerBank()); + + col.appendChild(DS.engrave("Drive modes")); + col.appendChild(driveModes()); + + col.appendChild(DS.engrave("Ignition")); + col.appendChild(ignition()); + + col.appendChild(DS.engrave("Actions")); + col.appendChild(pulls()); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — Prototype 9 (engine room, Dupre Kit)</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + /* Dupre Kit token set (from the gallery :root / tokens.json) — the kit + builders read these; they override the shared prototype gold. */ + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + .row { display: flex; justify-content: center; align-items: flex-start; gap: 8px; flex-wrap: wrap; } + /* kit spans that carry width/height need block layout outside the gallery */ + .dupre-rotsel, .dupre-keylock { display: inline-block; } + .cellcap { color: var(--steel); font-size: 9px; letter-spacing: 0.14em; text-align: center; margin-top: 2px; } + .flap-well { display: flex; justify-content: center; padding: 6px 0 2px; pointer-events: none; } +</style> +</head> +<body class="ds-stage"> + <div class="ds-caption"> + <b>Direction 9 — Engine room (Dupre Kit).</b> 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. + </div> + + <div class="ds-bar"><span>19:24</span><span class="gear">⚙</span></div> + + <div class="ds-panel" id="panel"> + <div class="ds-face"> + <span class="glyph">⚙</span> + <span class="title">Settings</span> + <span class="sub" id="scene-sub"></span> + <span class="x">✕</span> + </div> + <div id="body"></div> + <div class="ds-toast" id="toast"></div> + <div class="ds-subview" id="subview"></div> + </div> + +<script src="widgets.js"></script> +<script src="dupre-kit-additions.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +const body = document.getElementById("body"); +const subview = document.getElementById("subview"); +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); // 4 names +const SEL = ["OFF", "FOCUS", "PRESENT", "BATTERY", "NIGHT"]; // rotary detents +let booting = true; + +function openWallpaper() { + subview.innerHTML = ""; + subview.appendChild(DS.wallpaperSubview(() => subview.classList.remove("open"))); + subview.classList.add("open"); +} + +function sceneOff() { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} + +const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; + +// breakerPanel has no set(): sync its hardcoded initial states to DS by +// clicking the transparent hit rects while booting swallows the onChange. +function breakers(names, keys) { + const host = h("div"); + const bp = DUPRE.breakerPanel(host, { + names, + onChange: (states) => { + if (booting) return; + states.forEach((st, i) => { + const on = st === "on"; + if (on !== DS.state[keys[i]]) DS.setToggle(keys[i], host); + }); + }, + }); + const hits = host.querySelectorAll('rect[fill="transparent"]'); + bp.get().forEach((st, i) => { + if ((st === "on") !== DS.state[keys[i]]) + hits[i].dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + return host; +} + +function render() { + booting = true; + body.innerHTML = ""; + const col = h("div", "ds-col"); + + // Scenes: split-flap reads the tuned scene, rotary selector picks it. + col.appendChild(DS.engrave("Scene board")); + const flapWell = h("div", "flap-well"); + const flap = DUPRE.splitFlap(flapWell, { cells: 8, animate: true, flapMs: 60 }); + const BOARD = { Focus: "FOCUS", Presentation: "PRESENT", Battery: "BATTERY", Night: "NIGHT" }; + flap.setText(DS.state.scene ? BOARD[DS.state.scene] : "STANDBY"); + col.appendChild(flapWell); + const selRow = h("div", "row"); + const selHost = h("div"); + DUPRE.rotarySelector(selHost, { + values: SEL, + index: DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(); + else DS.applyScene(SCENES[SEL.indexOf(v) - 1], selHost); + }, + }); + selRow.appendChild(selHost); + selRow.appendChild(Object.assign(h("div", "cellcap", "SCENE SELECT"), { style: "align-self:center" })); + col.appendChild(selRow); + + // Levels: the drum roller (Craig favorite #1) — drums are 1-10, ×10 = percent. + col.appendChild(DS.engrave("Levels")); + const drumRow = h("div", "row"); + const drumHost = h("div"); + const commitDrums = debounce((vals) => { + const b = Math.round(vals[0]) * 10, k = Math.round(vals[1]) * 10; + if (b !== DS.state.brightness) DS.setSlider("brightness", b, drumHost); + if (k !== DS.state.kbd) DS.setSlider("kbd", k, drumHost); + }, 350); + DUPRE.drumRoller(drumHost, { + title: "LEVELS ×10", + channels: [ + { name: "BRIGHT", v: Math.max(1, Math.round(DS.state.brightness / 10)) }, + { name: "KBD", v: Math.max(1, Math.round(DS.state.kbd / 10)) }, + ], + onChange: (vals) => { if (!booting) commitDrums(vals); }, + }); + drumRow.appendChild(drumHost); + drumRow.appendChild(h("div")); + drumRow.lastChild.appendChild(h("div", "cellcap", "DRAG DRUM")); + col.appendChild(drumRow); + + // Toggles: breaker panels + a guarded lever for airplane. + col.appendChild(DS.engrave("Breakers")); + const brRow = h("div", "row"); + brRow.appendChild(breakers(["DIM", "CAFF", "TPAD", "MOUSE"], ["autodim", "caffeine", "touchpad", "mouse"])); + brRow.appendChild(breakers(["NIGHT", "DND", "WXGEO"], ["nightlight", "dnd", "weathergeo"])); + col.appendChild(brRow); + + if (DS.state.laptop) { + const gRow = h("div", "row"); + const gHost = h("div"); + DUPRE.guardedToggle(gHost, { + on: DS.state.airplane, + onLabel: "AIRPLANE", + offLabel: "RADIO OK", + onChange: (on) => { if (!booting && on !== DS.state.airplane) DS.setToggle("airplane", gHost); }, + }); + gRow.appendChild(gHost); + col.appendChild(gRow); + } + + // Power: engine-order telegraph — ring the next order. + col.appendChild(DS.engrave("Engine order — power")); + const tRow = h("div", "row"); + const tHost = h("div"); + DUPRE.telegraphIndicator(tHost, { + states: ["PERF", "BAL", "SAVER"], + index: DS.PROFILES.findIndex((p) => p.id === DS.state.powerprofile), + onChange: (i) => { if (!booting) DS.setProfile(DS.PROFILES[i].id, tHost); }, + }); + tRow.appendChild(tHost); + col.appendChild(tRow); + + // Actions: arm-to-fire. + col.appendChild(DS.engrave("Actions")); + const aRow = h("div", "row"); + [["LOCK", () => DS.momentary("Lock")], + ["SUSPEND", () => DS.momentary("Suspend")], + ["WALLPAPER", openWallpaper]].forEach(([label, fn]) => { + const host = h("div"); + DUPRE.armButton(host, { + label, + armLabel: label + "?", + onChange: (st) => { if (!booting && st === "fired") fn(); }, + }); + aRow.appendChild(host); + }); + col.appendChild(aRow); + + body.appendChild(col); + document.getElementById("scene-sub").textContent = DS.state.scene ? "▶ " + DS.state.scene : ""; + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Desktop Settings — scene control casting: tuner dial vs rotary + split-flap</title> +<link rel="stylesheet" href="desktop-settings-shared.css"> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + body.ds-stage { flex-direction: row; flex-wrap: wrap; align-items: flex-start; justify-content: center; gap: 24px; padding-top: 40px; } + .page-cap { width: 100%; text-align: center; color: var(--dim); font-size: 12px; letter-spacing: 0.04em; } + .page-cap b { color: var(--gold); } + .dupre-rotsel { display: inline-block; } + .row { display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap; } + .flap-well { display: flex; justify-content: center; padding: 4px 0 2px; pointer-events: none; } + .tuner-well { display: flex; justify-content: center; padding: 6px 0; } + .readout { text-align: center; color: var(--steel); font-size: 11px; letter-spacing: 0.1em; min-height: 15px; } + .readout b { color: var(--gold-hi); font-weight: 400; } + + /* preset keys under the dial (the direction-6 keys, Dupre Kit tokens) */ + .presets { display: grid; grid-template-columns: repeat(4,1fr); gap: 6px; } + .preset { + background-image: linear-gradient(180deg, var(--raise), #131110); + border: 1px solid #33302b; border-bottom-width: 3px; border-radius: 6px; + padding: 6px 4px; text-align: center; cursor: pointer; color: var(--silver); + } + .preset:hover { border-color: var(--gold); color: var(--gold); } + .preset .num { font-size: 9px; color: var(--dim); display: block; } + .preset .nm { font-size: 10.5px; letter-spacing: 0.06em; } + .preset.cur { + border-color: var(--gold); border-bottom-width: 1px; transform: translateY(2px); + background-image: linear-gradient(180deg, #2a2618, #1d1a12); color: var(--cream); + } + .preset.cur .num { color: var(--gold); } + + /* skin chips for the tuner */ + .skins { display: flex; gap: 8px; justify-content: center; } + .skinchip { + color: var(--dim); font-size: 9.5px; letter-spacing: 0.1em; cursor: pointer; + border-bottom: 1px dotted var(--wash); text-transform: uppercase; + } + .skinchip:hover { color: var(--silver); } + .skinchip.cur { color: var(--gold-hi); border-color: var(--gold); } +</style> +</head> +<body class="ds-stage"> + <div class="page-cap"> + <b>Scene-control casting.</b> 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. + </div> + + <div class="ds-panel" style="width:300px"> + <div class="ds-face"><span class="glyph">⚙</span><span class="title">Candidate 1</span></div> + <div class="ds-engrave">Tuner dial + presets</div> + <div class="tuner-well" id="tuner-well"></div> + <div class="skins" id="skins"></div> + <div class="presets" id="presets"></div> + <div class="readout" id="ro1"></div> + </div> + + <div class="ds-panel" style="width:300px"> + <div class="ds-face"><span class="glyph">⚙</span><span class="title">Candidate 3</span></div> + <div class="ds-engrave">Rotary + split-flap</div> + <div class="flap-well" id="flap-well"></div> + <div class="row" id="rot-well"></div> + <div class="readout" id="ro2"></div> + </div> + + <div class="ds-toast" id="toast" style="width:300px"></div> + +<script src="widgets.js"></script> +<script src="desktop-settings-shared.js"></script> +<script> +const h = DS.h; +DS.bindToast(document.getElementById("toast")); + +const SCENES = Object.keys(DS.SCENES); // Focus Presentation Battery Night +const STOPS = ["OFF", "FOCUS", "PRES", "BATT", "NIGHT"]; +const BOARD = { Focus: "FOCUS", Presentation: "PRESENT", Battery: "BATTERY", Night: "NIGHT" }; +let booting = true; + +function sceneOff(el) { + DS.state.scene = null; + DS.toast("Scene: off", true); + render(); +} +const sceneIdx = () => (DS.state.scene ? SCENES.indexOf(DS.state.scene) + 1 : 0); + +let skin = "warm"; + +function render() { + booting = true; + + // ---- Candidate 1: slideRule tuner + preset keys ------------------------- + const tw = document.getElementById("tuner-well"); + tw.innerHTML = ""; + const dial = DUPRE.slideRule(tw, { + values: STOPS, + value: STOPS[sceneIdx()], + skin, + fmt: (v) => v, + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(tw); + else DS.applyScene(SCENES[STOPS.indexOf(v) - 1], tw); + }, + }); + // string stops: the builder's lit-mark compare is numeric-only; patch it. + const glow = () => tw.querySelectorAll(".dupre-mk").forEach((m) => + m.classList.toggle("dupre-on", m.textContent === STOPS[sceneIdx()])); + glow(); + + const sk = document.getElementById("skins"); + sk.innerHTML = ""; + ["warm", "chrome", "black", "blue"].forEach((name) => { + const c = h("span", "skinchip" + (name === skin ? " cur" : ""), name); + c.onclick = () => { skin = name; dial.setStyle("skin", name); sk.querySelectorAll(".skinchip").forEach((x) => x.classList.toggle("cur", x.textContent === name)); }; + sk.appendChild(c); + }); + + const pr = document.getElementById("presets"); + pr.innerHTML = ""; + SCENES.forEach((n, i) => { + const k = h("div", "preset" + (DS.state.scene === n ? " cur" : "")); + k.innerHTML = `<span class="num">PRESET ${i + 1}</span><span class="nm">${n}</span>`; + k.onclick = () => DS.applyScene(n, k); + pr.appendChild(k); + }); + + // ---- Candidate 3: rotary selector + split-flap -------------------------- + const fw = document.getElementById("flap-well"); + fw.innerHTML = ""; + const flap = DUPRE.splitFlap(fw, { cells: 8, animate: true, flapMs: 60 }); + flap.setText(DS.state.scene ? BOARD[DS.state.scene] : "STANDBY"); + + const rw = document.getElementById("rot-well"); + rw.innerHTML = ""; + const rHost = h("div"); + DUPRE.rotarySelector(rHost, { + values: ["OFF", "FOCUS", "PRESENT", "BATTERY", "NIGHT"], + index: sceneIdx(), + onChange: (v) => { + if (booting) return; + if (v === "OFF") sceneOff(rHost); + else DS.applyScene(SCENES[["OFF", "FOCUS", "PRESENT", "BATTERY", "NIGHT"].indexOf(v) - 1], rHost); + }, + }); + rw.appendChild(rHost); + + // ---- readouts ----------------------------------------------------------- + const cur = DS.state.scene ? DS.state.scene.toUpperCase() : "no scene"; + document.getElementById("ro1").innerHTML = "tuned: <b>" + cur + "</b>"; + document.getElementById("ro2").innerHTML = "board: <b>" + cur + "</b>"; + + booting = false; +} + +DS.onChange(render); +render(); +</script> +</body> +</html> 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 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Dupre Night Watch — idle-stage screensaver prototype 1</title> +<style> + :root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --silver:#bfc4d0; --cream:#f3e7c5; --steel:#969385; --dim:#7c838a; + --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --pass:#74932f; --fail:#cb6b4d; + --phos:#7fe0a0; --phos-dim:#1c3626; --vfd:#63e6c8; + --sevgrn:#57d357; --sevred:#e2543f; --sevoff:#1a1613; + --jewel-r:#ff5b45; --jewel-a:#ffb43a; --jewel-g:#6fce33; + --gold:#e2a038; --gold-hi:#ffbe54; + --amber-grad-top:#f2c76a; --amber-grad-mid:#d29638; --amber-grad-bot:#8f671f; + --amber-edge:#7d5c16; --amber-warn:#f0b552; + --glow-hi:255,190,84; --glow-lo:226,160,56; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + --pulse-rate:1s; + } + * { box-sizing: border-box; } + body { + margin: 0; min-height: 100vh; background: #050505; + color: var(--silver); font-family: var(--mono); + display: flex; flex-direction: column; align-items: center; + gap: 10px; padding: 24px; + zoom: 1.7; + cursor: none; /* it's a screensaver */ + } + .board { + display: grid; + grid-template-columns: repeat(4, auto); + gap: 14px 18px; + align-items: center; + justify-items: center; + } + .cell { + background: linear-gradient(180deg, #16140f, #0e0d0b); + border: 1px solid #262320; + border-radius: 10px; + padding: 8px 10px 6px; + display: flex; flex-direction: column; align-items: center; gap: 4px; + } + /* kit instruments are spans with fixed CSS sizes; a flex host blockifies + them (the gallery's card grid does this implicitly) */ + .cell > div:first-child { display: flex; justify-content: center; } + .cell .lbl { + color: var(--steel); font-size: 8px; letter-spacing: 0.22em; + text-transform: uppercase; + } + .wide { grid-column: span 2; } + .masthead { + color: var(--steel); font-size: 10px; letter-spacing: 0.35em; + text-transform: uppercase; padding-bottom: 2px; + } + .masthead b { color: var(--gold); font-weight: 400; } +</style> +</head> +<body> + <div class="masthead"><b>DUPRE</b> · NIGHT WATCH</div> + <div class="board" id="board"></div> + +<script src="widgets.js"></script> +<script> +const cell = (label, span) => { + const c = document.createElement("div"); + c.className = "cell" + (span ? " wide" : ""); + const host = document.createElement("div"); + c.appendChild(host); + const l = document.createElement("div"); + l.className = "lbl"; l.textContent = label; + c.appendChild(l); + document.getElementById("board").appendChild(c); + return host; +}; + +/* ---- top row: timekeeping ---------------------------------------------- */ +const flap = DUPRE.splitFlap(cell("local time", true), { + cells: 5, chars: " 0123456789:", flapMs: 70, animate: true, +}); +const clock = DUPRE.analogClock(cell("movement")); +const seg = DUPRE.sevenSeg(cell("min : sec")); + +/* ---- second row: watchstanding ----------------------------------------- */ +DUPRE.radarSweep(cell("scan"), { contacts: [[40, .5], [150, .72], [265, .38], [330, .6]] }); +const scope = DUPRE.scope(cell("trace")); +const strip = DUPRE.stripChart(cell("drift"), { samples: 60, value: 0.5 }); +const eq = DUPRE.eqBars(cell("spectrum"), { bands: 11, cells: 9 }); + +/* ---- third row: machine room ------------------------------------------- */ +const vu = DUPRE.vuPair(cell("programme")); +const blinken = DUPRE.blinkenlights(cell("core")); +const tape = DUPRE.tapeCounter(cell("elapsed"), { value: 0, wheels: 6, redFrom: 4 }); +const lamps = DUPRE.statusLamps(cell("systems"), { states: [0, 0, 1, 0, 4] }); + +/* ---- bottom row: comms + orders ---------------------------------------- */ +DUPRE.voiceLoop(cell("loops")); +const marquee = DUPRE.vfdMarquee(cell("wire", true), { + width: 200, + msgs: [ + "DUPRE NIGHT WATCH · ALL SYSTEMS NOMINAL", + "IDLE PIPELINE ARMED · DIM 5 · LOCK 7 · DPMS 10", + "NEXT ORDER ON THE TELEGRAPH · STAND EASY", + ], +}); +const tele = DUPRE.telegraphIndicator(cell("engine order"), { + states: ["AHEAD", "HALF", "SLOW", "STOP", "STAND BY", "FULL"], index: 3, +}); + +/* ---- the page owns the clock (tick contract) ---------------------------- */ +let last = { m: -1 }; +const timeTick = () => { + const d = new Date(); + clock.set(d.getHours(), d.getMinutes(), d.getSeconds()); + seg.set(d.getMinutes() * 60 + d.getSeconds()); + if (d.getMinutes() !== last.m) { + last.m = d.getMinutes(); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + flap.setText(hh + ":" + mm); + } +}; +timeTick(); +setInterval(timeTick, 1000); + +/* signals: slow random walks + a breathing sine */ +let vl = 0.4, vr = 0.5, ph = 0, driftV = 0.5; +setInterval(() => { + vl = Math.max(0.05, Math.min(0.95, vl + (Math.random() - 0.5) * 0.16)); + vr = Math.max(0.05, Math.min(0.95, vr + (Math.random() - 0.5) * 0.16)); + vu.set(vl, vr); + eq.set(Array.from({ length: 11 }, (_, i) => + Math.max(0, Math.min(1, (vl + vr) / 2 + Math.sin(ph + i * 0.7) * 0.3 + (Math.random() - 0.5) * 0.2)))); +}, 110); + +setInterval(() => { + ph += 0.25; + const n = 64; + scope.set(Array.from({ length: n }, (_, i) => + Math.sin(ph + (i / n) * Math.PI * 4) * (0.5 + 0.3 * Math.sin(ph * 0.13))), 6.2); +}, 90); + +setInterval(() => { + driftV = Math.max(0.08, Math.min(0.92, driftV + (Math.random() - 0.5) * 0.09)); + strip.push(driftV); +}, 450); + +setInterval(() => blinken.tick(), 260); +setInterval(() => marquee.tick(), 40); + +let elapsed = 0; +setInterval(() => tape.set(++elapsed), 1000); + +/* the watch changes: a lamp flickers its state every so often, and every + couple of minutes the telegraph rings a new order */ +setInterval(() => { + const i = Math.floor(Math.random() * 5); + lamps.set(i, Math.random() < 0.7 ? 0 : Math.random() < 0.5 ? 1 : 4); +}, 4200); +setInterval(() => tele.set(Math.floor(Math.random() * 6)), 120000); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-22-watch-preview.png b/docs/prototypes/2026-07-22-watch-preview.png Binary files differnew file mode 100644 index 0000000..9b8419b --- /dev/null +++ b/docs/prototypes/2026-07-22-watch-preview.png 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 = `<span class="back">‹ Back</span><span class="title">Wallpaper</span>`; 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 = '<span class="df-slot"></span><span class="df-fill"></span>'; + const ticks = detents.map(d => { + const t = document.createElement('span'); t.className = 'df-tick'; + t.style.left = `calc(8px + ${d / 100} * (100% - 16px))`; + el.appendChild(t); return t; + }); + const cap = document.createElement('span'); cap.className = 'df-cap'; el.appendChild(cap); + host.appendChild(el); + + let v, park = null; + const paint = () => { + const left = `calc(8px + ${v / 100} * (100% - 16px))`; + cap.style.left = left; + el.querySelector('.df-fill').style.width = `calc(${v / 100} * (100% - 18px))`; + el.classList.toggle('df-parked', park !== null); + ticks.forEach((t, i) => t.classList.toggle('df-park', detents[i] === park)); + }; + /* 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 = `<span class="sl-lamp"></span><span class="sl-lbl">${lbl}</span>`; + 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 + ? `<span class="dupre-tick" style="left:${sp.x}px"></span><span class="dupre-mk" style="left:${sp.x}px">${sp.v}</span>` + : `<span class="dupre-tick dupre-mn" style="left:${sp.x}px"></span>`); + }); + 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 = `<span class="dupre-half dupre-top">${opts.onLabel !== undefined ? opts.onLabel : 'ON'}</span><span class="dupre-half dupre-bot">${opts.offLabel !== undefined ? opts.offLabel : 'OFF'}</span>`; + 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 = '<span class="pc-dot pc-set"></span>'; + else if (v === false) c.innerHTML = '<span class="pc-dot pc-clear"></span>'; + else if (typeof v === 'string') { c.classList.add('pc-pwr'); c.textContent = v; } + else c.innerHTML = '<span class="pc-dash"></span>'; + }; + 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); + }, + }; + }; +})(); diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org index 3aef647..f2ac773 100644 --- a/docs/specs/2026-07-02-desktop-settings-panel-spec.org +++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org @@ -4,17 +4,26 @@ #+TODO: TODO | DONE #+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED -* DRAFT Desktop-Settings Dropdown Panel +* DOING Desktop-Settings Dropdown Panel :PROPERTIES: :ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce :END: - 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed) -* DRAFT Status +* DOING Status :PROPERTIES: :ID: fb7eec22-a214-4568-82c4-903612f4832f :END: -- [2026-07-19 Sun] DRAFT — folded Craig's control-set decisions (home->archsetup handoff): added night-light, DND, lock/suspend, power profile, and scenes to the controls table; recorded volume and theme as out-of-scope with reasons; pulled the format pickers into a separate display-format sibling spec (stub filed). The "few other things" scoping decision and the scenes decision are now closed. One decision stays open — the wallpaper manager's shape (row-that-opens-a-sub-view vs its own sub-spec), to resolve at spec-review. UI prototyping (ui-prototyping rule: research -> 5 directions -> iterate) is not done yet and is the gate before READY. +- [2026-07-22 Wed] DOING — spec-response: the four rewritten implementation phases decomposed into build tasks in todo.org under a :SPEC_ID:-stamped parent, with the test surface and the final flip-to-IMPLEMENTED task emitted. The build proceeds in dotfiles settings/ per the location decision; archsetup drives. +- [2026-07-22 Wed] READY — spec-review passed (rubric: Ready with caveats; see Review findings — two findings fixed in-run, two non-blocking build-time picks recorded). The prototyping gate closed at prototype 37, sealed by Craig's "Build it. Anything more would be bit fiddling." The FOCUS headphones glyph stands confirmed by the same words. Normative reference: prototype 37. +- [2026-07-22 Wed] DRAFT — UI PROTOTYPING COMPLETE. Thirty-five prototypes ended at [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-35.html][Prototype 35, the final candidate]] (linked as the normative reference under Design sketch), sealed by Craig's "I think that's it" after his annotation batch applied clean. The design evolved far past the original controls table — programmable-presets matrix, instrument bench, wallpaper channels — with every turn recorded under Prototype iterations. The gate before READY is satisfied: research cited, ~5 directions grew to 35 with the winner iterated to final, every UI decision made against working prototypes. Next: spec-review (reconcile the Design sketch's aging architecture prose against what the prototypes actually settled), then spec-response into build phases. +- [2026-07-21 Tue] DRAFT — casting sitting complete: Craig cast every control role live against working candidates (see "Casting" under Prototype iterations). Scenes = tuner dial + presets; brightness = new detent fader with speedbump physics; keyboard = single-drum roller; power = chicken-head; toggles = rocker bank; airplane = guarded lever (throw fixed); caffeine = wind-up timer dial; chrome = warm-gold receiver plate. Three kit-candidate components accrued in dupre-kit-additions.js with a merge task filed. Next: compose prototype 12 and iterate to final. +- [2026-07-21 Tue] DRAFT — scope change (Craig): lock/suspend withdrawn from the panel (they live on the wlogout screen, Super+Shift+Q); a new open decision "Idle pipeline" proposes staged idle timeouts (dim/lock/screen-off/power action) and caffeine as a timed inhibit, replacing the bare on/off. DND semantics finding recorded in the controls table (pause dams and floods; dead-drop needs an explicit queue clear). +- [2026-07-21 Tue] DRAFT — three Dupre Kit directions (9 engine room, 10 spacecraft DSKY, 11 mixing desk) composed from the real widgets.js builders after Craig pointed the phase at the panel-widget-gallery catalogue; his two favorite level controls (drum roller, thumb-slide attenuator) each carry the levels in one direction for a working comparison. Eleven directions total; pick still pending. +- [2026-07-21 Tue] DRAFT — three more direction prototypes (6 hi-fi receiver, 7 chronograph, 8 gauge cluster) built to Craig's aesthetic brief: retro hi-fi stereo / chronograph / automobile dashboard. Craig loved the scenes-as-station-presets idea, so the tuner dial is direction 6's hero. Also retired the status lamps across all directions (the glowing dot pulled the eye) and fixed a slider-fill CSS hack that bled a huge gold disc over directions 1/2/3/5. Direction pick still pending. +- [2026-07-21 Tue] DRAFT — UI-prototyping phase: recorded the quick-settings prior-art survey (see Prior art under Design sketch) and brought the five 2026-07-19 direction prototypes up to the current controls table (weather-geo toggle added to the shared engine and every direction; caffeine restored to directions 2 and 5, which had dropped it). All five verified rendering error-free headless. Awaiting Craig's direction pick before the iterate-to-final pass. +- [2026-07-21 Tue] DRAFT — added a weather-geolocation toggle to the controls table (use_geo in the weather config: whereami WiFi fix vs pinned home coords). Both machines run use_geo=true for testing as of today; ratio may flip back by hand until the panel ships the switch. +- [2026-07-19 Sun] DRAFT — folded Craig's control-set decisions (home->archsetup handoff): added night-light, DND, lock/suspend, power profile, and scenes to the controls table; recorded volume and theme as out-of-scope with reasons; pulled the format pickers into a separate display-format sibling spec (stub filed). The "few other things" scoping decision and the scenes decision are now closed. The wallpaper-shape decision closed the same day (row-that-opens-a-sub-view — see Decisions), leaving no open decisions. UI prototyping (ui-prototyping rule: research -> 5 directions -> iterate) is not done yet and is the gate before READY. - [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (dim + airplane collapse into the panel, touchpad + caffeine stay on the bar; Super+Shift+G keybind; code in dotfiles settings/ beside net/; 5% brightness floor). The four collapse/keybind/location/floor decisions are closed; one open scoping question remains ("few other things" — see Decisions) before a spec-review can flip it READY. - [2026-07-03 Fri] DRAFT update — from the waybar/panel-family design discussion: adopt the instrument-console faceplate aesthetic net + bt @@ -26,11 +35,528 @@ dropdown panel" (2026-06-24 review), updated for the Blueprint/GTK4 pipeline the net panel stood up 2026-07-01. +** Prototype iterations + +Five direction prototypes (2026-07-19), all full working layouts over one +shared mock engine ([[file:../prototypes/desktop-settings-shared.js][desktop-settings-shared.js]] + +[[file:../prototypes/desktop-settings-shared.css][desktop-settings-shared.css]]) so the directions are genuinely comparable: +same state model, same verify-everything simulation (apply, mock readback, +green verify flash + toast), same wallpaper sub-view. + +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-1.html][Direction 1 — tile grid]]: the Android/GNOME quick-settings idiom; scenes + strip, 2-column toggle tiles, sliders and power below. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-2.html][Direction 2 — sectioned rack]]: full-width console keys under engraved + section labels; closest to the net/bt panel idiom. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-3.html][Direction 3 — scenes-first]]: scenes as the hero controls, everything else + beneath. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-4.html][Direction 4 — instrument cluster]]: ring gauges for the levels, a rotary + power-profile selector, lamp rows; leans hardest into the faceplate + aesthetic. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-5.html][Direction 5 — compact expandable rows]]: one slim row per control, + progressive disclosure for power/scenes/wallpaper. + +2026-07-21: all five updated to the current controls table (weather-geo +added; caffeine restored to directions 2 and 5) and verified rendering +error-free. + +Three more directions added 2026-07-21 to Craig's aesthetic brief — retro +hi-fi stereo, chronograph, automobile dashboard — treating the panel as a +physical object rather than a software surface. Same shared engine, same +control roster: + +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-6.html][Direction 6 — hi-fi receiver]]: scenes as station presets on a backlit + tuner dial (sliding needle) + preset keys; rotary knobs with VU meters + for the levels; a backlit switch bank; an input-selector knob for power. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-7.html][Direction 7 — chronograph]]: the panel is a watch. Brightness is the main + dial (drag the bezel; gold hand + digital window), chrono subdials carry + keyboard/power/scene, crowns are the momentary actions, bracelet links + are the toggles. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-8.html][Direction 8 — gauge cluster]]: a dashboard binnacle with twin needle + gauges (drag to set), a rocker-switch bank with backlit legends, + push-button drive modes for scenes, an ignition rotary for power, dash + pull-knobs for actions. + +Three further directions added 2026-07-21, composed from the real Dupre Kit +builders (widgets.js) rather than hand-rolled lookalikes — so each doubles +as a component-selection exercise for the eventual GTK port. Craig's two +favorite level controls get the same job in two different directions so +they can be compared in working form: the drum roller in direction 9, the +thumb-slide attenuator in direction 10. + +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-9.html][Direction 9 — engine room]]: drum-roller levels, split-flap scene board + read from a rotary selector, breaker-panel toggles, guarded airplane + lever, engine-order telegraph for power, arm-to-fire actions. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-10.html][Direction 10 — spacecraft DSKY]]: thumb-slide levels, slide-switch bank + under a live caution board, scenes commanded by DSKY verb entry + (V01-V04, V00 off), keyed mode switch for power. +- [[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-11.html][Direction 11 — mixing desk]]: slot faders in dB with a VU bridge, + radio-preset scene bank, chip toggles with DND on a stomp pedal, rotary + power selector. + +Kit-integration notes for the port, learned building these: the kit's +CSS-built selector spans (.dupre-rotsel, .dupre-keylock) size themselves +with width/height on inline display, so consumers outside the gallery need +display:inline-block on them; breakerPanel has no set() and hardcodes its +initial states, so reflecting external state means synthesizing clicks on +its hit rects (a set() upstream would fix it); and the shared mock engine +now carries weathergeo in its state object (it was metadata-only, and +kit toggles default-on when opts.on is undefined). + +*** Casting (2026-07-21) — instrument per role, picked live + +Rather than one direction wholesale, Craig cast each control role from the +bench in a live sitting (candidates driven in working form; the scenes and +levels casting pages below were built for the head-to-heads): + +- Scenes (panel hero, top): kit slideRule tuner dial — stops + OFF/FOCUS/PRES/BATT/NIGHT, lit stop label, sliding needle — over + PRESET 1-4 keys. Beat rotary + split-flap head-to-head + ([[file:../prototypes/2026-07-02-desktop-settings-panel-scenes-compare.html][scenes casting page]]). +- Screen brightness: detentFader (NEW kit-candidate component) — full-width + horizontal fader, detents at 25/50/75 with speedbump physics (magnet + + escape hysteresis; the thumb parks until the pointer pushes through). + Craig's revision of the plain faderH pick. +- Keyboard backlight: drumRoller, single drum 0-10 (kit builder generalized + to 1..N channels + min/max — stock was hardcoded to two drums). + ([[file:../prototypes/2026-07-02-desktop-settings-panel-levels-compare.html][levels casting page]] carries both level controls.) +- Power profile: chicken-head knob, three detents. +- Toggle bank: kit rocker bank — lit engraved legends carry state (no lamp + dots; the status-lamp retirement holds). +- Airplane: guardedToggle, laptop-only, patched to a rotateX throw (the + stock 180° planar spin swept the lever sideways mid-transition). +- Caffeine / idle inhibit: kit timerDial as a wind-up timed inhibit (real + minutes at build; demo clock is min/sec). +- Chrome: direction 6's receiver faceplate in warm Dupre gold — family-kin + with net/bt/audio. (Marantz-blue slideRule skin seen and passed on.) + +The three kit-candidate components (detentFader NEW, drumRoller and +guardedToggle UPGRADEs) live in +[[file:../prototypes/dupre-kit-additions.js][dupre-kit-additions.js]] pending the +kit merge (tracked in todo.org). + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-12.html][Prototype 12 — the casting composite]] (2026-07-21, overnight on Craig's +go): every cast instrument composed on the warm-gold receiver chassis with +the current roster — tuner + presets, detent fader, single drum, +chicken-head, wind-up caffeine timer, rocker bank, guarded airplane, +wallpaper sub-view; lock/suspend absent per the wlogout amendment. All +interactions verified headless (scene tune + preset punch, fader detents, +drum drag, chicken step, timer wind → caffeine, rockers, lever, sub-view +round-trip). Composing it surfaced and fixed a latent shared-engine bug: +wallpaperSubview returned a div carrying the ds-subview class +(display:none outside .open), so the wallpaper sub-view had been opening +empty in every direction prototype. Iteration to final happens against +this file. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-13.html][Prototype 13 — P12 + Craig's first iteration]] (2026-07-22): presets moved +above the dial full-width (Preset 2 contains "Presentation" whole); screen +brightness RECAST from the detent fader to the drum — screen + keyboard now +run as the classic two-drum pair left of an enlarged caffeine timer, which +gains a HOLD/TIMED rocker so caffeine can be held always-on; compact +chicken-head in the upper-right quadrant; switch wall of six with the +guarded airplane lever in the mix; weather-geo DROPPED from the panel +entirely (controls-table row removed — the config stays hand-edited); +wallpaper row unchanged. The detentFader remains a kit-merge candidate on +its own merits. Verified: HOLD drives caffeine, drum drag drives +brightness, preset containment and tuner stop visibility checked +programmatically. Second pass same night: the drums grew to run flush with +the timer column (drum top level with the dial top, bottom with the HOLD +rocker — verified 0px deltas); drumRoller gained a height option for it (a +taller stage opens a longer paper window showing more of the scale), which +joins the kit-merge candidate's contract. Third pass: the caffeine timer +was unwindable live — crossing zero mid-drag toggled caffeine, the +re-render destroyed the dial under the pointer, and every rebuilt +timerDial leaked its countdown interval as a ghost writing stale minutes. +Fix: self-clocked instruments persist across re-renders (reparent, never +rebuild) and state sync is debounced past the drag. This is a +build-architecture rule for the GTK port too: instruments that own a +clock own their widget lifetime. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-14.html][Prototype 14 — timer refinements]] (2026-07-22): the HOLD/TIMED rocker is +replaced by a bat toggle seated at the timer's upper-right where the +winding-direction arrow was (arrow removed) — bat up TIMED, down HOLD +(always-on caffeine); the timer dial is vertically centered against the +drum column instead of top-flush. batToggle joins the kit-merge additions +with the same rotateX throw fix as guardedToggle (stock had the identical +180° planar spin). Verified: bat drives hold-caffeine both ways, center +delta 0, arrow absent. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-15.html][Prototype 15 — power slide]] (2026-07-22): the chicken-head is replaced by +a three-position slide switch under the tuner — a status lamp per +position, labeled PERFORMANCE / BALANCE / POWER SAVE, stretching to +exactly the tuner's length; the tuner itself is restored to full plate +width. Two more kit-candidates for it: slideSelector (NEW — stretchable +n-position slide with per-position lamps, pillSlide's full-size +descendant) and a slideRule width option (the printed scale spreads its +stops proportionally; custom widths take a wider edge margin so end-stop +labels stay inside). Verified: slide clicks and drags land positions and +drive the profile, exactly one lamp lit, scenes move the slide (Focus +sets BALANCE), tuner tunes at full width with end labels contained. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-16.html][Prototype 16 — rebalanced]] (2026-07-22): built from a design critique +Craig requested ("it still feels off") whose diagnosis was inverted +salience — the least-used control (the caffeine timer, largest object, +only big red) shouted loudest while the heroes (scenes, brightness) +whispered. Changes, same instruments throughout: three uniform plates on +one grid (Scenes·Power / Levels·Caffeine / Switches); the drum pair +replaced by the thumb-slide attenuator pair at Craig's call (the cream +paper drums were the panel's bright blob and out of family with the +slide/fader chrome); the timer de-throned — steel stop knob, amber +index/OFF, single engraving, bat docked on its stage; rockers carry their +function name on the lit half with a new `quiet` option killing the +warning-red off state (a fourth kit-candidate UPGRADE — red-off stays +stock default for switches where off is the notable state); the airplane +lever sits on the rocker baseline with trimmed AIR/SAFE labels. Red now +appears exactly twice, both semantically: the tuner needle (vintage +heritage) and the airplane danger disc. Salience rule recorded for the +GTK build: visual loudness follows frequency × importance — scenes and +brightness loudest, switches calm, caffeine polite. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-17.html][Prototype 17 — switches under their presets]] (2026-07-22): plate order +rearranged for causality — the presets program the switches, so the wall +now sits directly beneath the scenes plate ("strange to have the preset +buttons on top controlling switches on the bottom" — Craig), with the +power slide just under the wall in a shared Switches · Power plate; +Levels · Caffeine follows. The power slide's knob goes dark (a knob +option — chrome default / machined-dark — added to the slideSelector +kit candidate). Verified: plate order wall → slide → levels, dark knob +class active, and the adjacency in action — the Night preset lights the +rockers directly beneath it and slides power to POWER SAVE. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-18.html][Prototype 18 — the program chart]] (2026-07-22): Craig asked what analog +component the missing "one selector recalls a configuration" pattern maps +to. Answer: two — the behavioral half is the organ combination action / +flying-fader scene recall (controls physically move on recall, which the +panel already does), and the legible half is the washing-machine faceplate +program chart, which was the missing piece. New kit-candidate +DUPRE.programChart: one row per program, one column per contact — filled +dot closes it, ring opens it, dash leaves it alone, letters carry enums — +active row lit, display-only, derived straight from the scene definitions +(which makes visible that scenes never touch TPAD/MOUSE). Also this pass: +airplane removed from the panel (network panel owns it — table row and +wall slot dropped); the dial's OFF stop relabeled CUSTOM (no scene = a +hand-set configuration, not "off"); a real-proportion pass — thumb slides +up toward hand size, timer down toward kitchen-timer size. Verified: +chart rows follow scene changes, CUSTOM releases the scene while the +switches hold state, airplane absent. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-19.html][Prototype 19 — the chart becomes a punch card]] (2026-07-22): the program +chart turns editable and white — its lights indicate FUTURE state (what +the preset will set on recall), so they sit deliberately outside the +amber live family; clicking a cell reprograms the preset (dots cycle +closes → opens → untouched, the power letter cycles P/B/S/–), with edits +written back to the scene definitions and honored on the next recall +(verified: adding DND to Battery's program, then recalling Battery, +engaged DND for real). Switches engage GREEN (a rocker accent option — +run/engaged semantics) and shrink to true rocker size; slides grow. +Three-register color semantics recorded for the build: amber = scene +family and live selection, green = engaged switches, white = programmed +future state. Craig's observation logged with it: the panel is converging +on a master-preset architecture — the presets program everything below +except the wound timer minutes; the chart is that programming surface. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-20.html][Prototype 20 — the switch matrix]] (2026-07-22): the master-preset +architecture made literal. The panel's axis IS the switches: preset row, +punch chart, and switch wall fuse into one matrix — each row a live +switch (rocker / caffeine lamp / a mini three-detent power slide), the +four preset buttons running across the top as column heads (names only, +no PRESET N), white punch lights under each program column, every cell +editable. The slide-rule tuner is retired (preset buttons + CUSTOM-by- +touching carry scene selection); TPAD and MOUSE — which no scene touches +— drop to their own Pointing strip; SCREEN, KBD, and power go horizontal +via a new kit-candidate, thumbSlideH (the thumb slide turned on its side: +continuous 0-100 or detented positions, stretchable). Build note: the +matrix itself is page-level composition for now — if it survives +iteration it should become a kit switchMatrix component; and component- +scoped CSS bit again (the chart's dot classes are scoped to .dupre-pchart, +so the matrix carries its own copy). Verified: preset buttons recall and +light the rockers, punch edits write back and hold, horizontal slides +drive both levels, the matrix power slide sets the profile, tuner absent. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-21.html][Prototype 21 — the pin routing matrix]] (2026-07-22): P20's accidental +switch-relocation told Craig what the interface actually is — a pin +routing matrix (EMS-Synthi patch idiom, already in the kit). The kit +pinMatrix got the upgrade treatment (kit-candidate #9: configurable +geometry, per-column pin colors, clickable button column-heads with an +active state, row-group dividers, set(); stock 5×6 defaults preserved) +and became the whole interface: switch names label the rows (the physical +rockers are gone — "remove all the switches, which now become labels"), +program buttons head the columns, a green live NOW column carries current +state (click to toggle; the live power pin can't be pulled, only moved), +white pins program the four scene columns, and the power rows are a radio +group per column. Program semantics simplified to complete definitions: +recall makes the column's pin pattern the truth (pin on, absent off). +CAFF is an ordinary switch row; the caffeine timer job is retired and the +dial is repurposed as a SLEEP TIMER (wind minutes until suspend — the +receiver classic; pairs with CAFF as the two directions of the idle +fight), bat toggle retired with it. The drums return for SCREEN/KBD at +Craig's call ("I love the drum"). Verified end to end: NOW pins toggle +live state, program pins write back, power radio enforces one pin per +column, column-head recall applies the full pattern (including a +just-seated CAFF pin), sleep timer winds/runs down/fires the mock +suspend, button heads contained after a geometry margin fix. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-22.html][Prototype 22 — the programmable matrix]] (2026-07-22): Craig named the +component the design was building toward, and it exists now — +DUPRE.programMatrix (kit-candidate #10): programs as caps column-head key +plates (FOCUS · PRESENT · BATTERY · NIGHT — "Presentation" shortened), +settings as spelled-out engraved rows (AUTO-DIM, NIGHT LIGHT, DO NOT +DISTURB, CAFFEINE; engraved-and-infilled relief after the pure kit +engrave idiom proved illegible at 6px), radio row groups, and the core +semantic: NO live column — pressing a head activates the program and its +column's pins turn white → green, because active-is-live: the recalled +pattern IS the machine's state. Editing the green column edits the +present (live state and stored program move together, and the column +STAYS active); editing a white column edits a future. Build finding for +the GTK port: the mock engine cleared the active scene on any member +write, which silently dropped active-is-live — the real PanelModel must +let program-membership writes survive without deactivating the program. +Verified: activation floods the column green and applies the pattern, +active-column DND pull changed live + program while staying in NIGHT, +radio PERF move followed suit, inactive-column CAFFEINE seat edited the +program without touching live state. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-23.html][Prototype 23 — the pushwheel CPU row]] (2026-07-22): CPU power collapses +from three radio rows to ONE letter row. Craig asked for the analog +referent — triangle up/down steppers beside a lettered tape-counter +window — which is the industrial push-button thumbwheel (BCD pushwheel) +switch; no kit component existed (chromeMinMax checked: it's a dome +gauge), so programMatrix grew letter rows: program cells carry one of +the row's values (P/B/S here, click cycles; ↑/~/↓ noted as an alternate +glyph set), and stepper: true appends the pushwheel at the row's right — +window showing the LIVE letter, triangles stepping it (up toward P, +clamped). Active-is-live holds: stepping while a program is active +writes the program's letter too and stays active. Row labels now +right-align to the column buttons' left edge (Craig). Verified: stepper +walks B→S and back up to P, inactive letter cycles edit programs only, +NIGHT activation floods letters green, active stepping moves live + +program together; a margin fix cleared the fourth column's letter from +the pushwheel window. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-24.html][Prototype 24 — tape wheel + jewel verdicts]] (2026-07-22): the triangle +steppers retire after one prototype — the tape-counter wheel IS the +button now (click cycles the live CPU letter, wrapping), rendered as a +proper wheel cell: cream paper, cylinder shading, 13px printed letter, +after the kit tapeCounter's look. And the active column gains verdict +lamps per Craig's read that they're jewel pilot lamps "just not so large +and flashy": every switch row in the live column shows a definite state — +a domed green jewel for seated, a smaller red jewel for a socket the +program holds off; inactive seated pins wear a frost-white jewel dome. +Verified: wheel cycles B→S→P→B, NIGHT activation shows three green +verdicts + one red (CAFFEINE held off) + the green S letter, program +edits and active-is-live unchanged. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-25.html][Prototype 25 — wheels in every column]] (2026-07-22): Craig caught the +side wheel as another NOW-column vestige — under active-is-live the +active column's value IS the live value, so the tape wheel belongs in +every program column, not off to the side. Each CPU POWER cell is now +its own bezeled tape wheel (click cycles that program's letter); the +active column's wheel wears a green bezel and is the machine's live CPU +state. Jewel pilot lamps sized up (5.6 / 4.8) with taller rows (dy 21) +to match. Verified: inactive wheels edit programs only, the active wheel +cycles live + program together and stays active, activation applies the +program's letter. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-26.html][Prototype 26 — physical keys, arrow wheels]] (2026-07-22): the program +heads become real keys — raised bevel over a static shadow at rest, +visibly DEPRESSED onto it when active, with green lighting only the +engraved legend (a backlit key, not a bordered chip); the active wheel's +green bezel is dropped (paper doesn't glow — the depressed key and the +jewel verdicts carry live indication); row labels engraved larger and +brighter-infilled for faceplate presence; the CPU wheels print ↑ – ↓ +(performance / balanced / saver) instead of letters; and the whole panel +widens to 434px (matrix 382) to give it all room. Verified: NIGHT's key +depresses with a green legend while the others sit proud, wheels carry +the arrow glyphs, the active wheel still cycles live + program together +without dropping the program. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-27.html][Prototype 27 — console keys, six slots, one bench]] (2026-07-22): the +head keys become the kit's own console-key primitive (.dupre-key, +lighting full green when active — the component now composes DOM keys +over the SVG pin field); TWO blank program slots grow the matrix +rightward (programs are {id, label} so blank labels keep unique pin +keys; the slots recall as honest all-off programs — verified: Slot5 +lights four red verdicts and a dash); the panel window widens to 560. +The lower level becomes one bench: drums · machined groove · TPAD over +MOUSE · groove · sleep dial, and the separate Pointing plate dissolves. +Maintenance note: a string-surgery patch had earlier fused pinMatrix and +programMatrix (shared anchor text matched the wrong builder — the +assignment silently vanished); both were rebuilt clean, which the kit +merge should treat as the canonical text. Keys blur after click so only +meaningful light remains. Next design conversation, per Craig: how the +sleep dial associates with the action it fires at zero. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-28.html][Prototype 28 — the idle tripper rail]] (2026-07-22): the dial-association +conversation resolved the dial out of existence. Craig's question — "in X +minutes from now, or when idle for X minutes?" — was answered B, with the +principle stated: THIS PANEL PROGRAMS FUTURES, not immediate actions. A +wound countdown lies for intent B (an idle threshold re-arms on input), +so the sleep dial retired and the idle pipeline surfaced as its honest +analog: the tripper rail (kit-candidate #12, DUPRE.tripRail — the +mechanical time-switch tripper translated from clock time to idle time). +A sqrt-spaced idle-minutes scale with clamp tabs — DIM 5 · LOCK 7 · +WATCH 8 · SCREEN OFF 10 · SUSPEND 30 — dragged to retune (stages clamp +between neighbors, keeping pipeline order), a dashed OFF siding parks a +stage disabled, flags re-alternate among active tabs so labels never +collide, and engaged CAFFEINE renders the whole rail dimmed with a +BYPASSED · CAFFEINE stamp — the pin's meaning made visible. This +supersedes the idle-pipeline decision's "delays live in hypridle.conf" +placement: the rail IS the panel-native programming surface for those +same values (the build writes hypridle.conf and reloads). Verified: +dragging SUSPEND 30→45 commits, LOCK clamps below its neighbor, WATCH +parks to off, the bypass dims and stamps. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-29.html][Prototype 29 — the idle tripper dial]] (2026-07-22): the rail was my +translation; Craig's actual mental image was the ROUND form — the +Intermatic segment-timer dial with four COLORED tripper tabs clamped on +the rim (kit-candidate #13, DUPRE.tripDial; the rail stays a candidate in +its own right). A 300° sqrt-spaced idle scale with a bottom OFF notch: +drag a tab around the rim to retune its stage, drop it through the notch +to disable; a side legend maps color → stage → minutes (DIM amber · +LOCK slate · SCREEN OFF steel · SUSPEND terracotta). Two design +resolutions rode in with it: WATCH IS THE LOCK SCREEN — the night watch +becomes the face the lock stage shows, not a pipeline stage of its own +(the idle-decision's separate eye-candy stage folds into LOCK; the +night-watch build gains "runs as/over hyprlock" as its integration +target); and the dial returns to the bench, restoring drums · groove · +pointing · groove · dial. Verified: rim drag commits (SUSPEND 30→45), +order clamping holds (LOCK stopped at DIM+0.5), the notch parks a stage, +CAFFEINE dims the face under the BYPASSED stamp. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-30.html][Prototype 30 — family chrome]] (2026-07-22): Craig flagged the panel as +noticeably off-family from maint/net/bt; diagnosis: the prototypes had +been wearing the widget gallery's amber (#e2a038) since P9, while the +family runs THEME gold (#dab53d / #ffd75f) — plus flat brushed plates +where the family uses raised gradient faceplates. Aligned: theme-gold +token family, maint-style body glow and panel background, the maint +header (raised face, cream wide-tracked title), raised-gradient plates +with inset highlights, and a darker matrix field inset into a lighter +plate (the requested contrast — empty sockets now read as visible +punch-card rings). Also this pass: slots labeled 1 · 2 as SLIM keys +(programMatrix gains weighted column widths; window compacts to 540); +factory presets programmed (FOCUS 1011↑ · PRESENT 0011– · BATTERY 1000↓ +· NIGHT 1101↓, slots all-off, all programs persist edits); the section +renamed PROGRAMMABLE PRESETS; the caffeine/CPU divider removed; drums +and dial enlarged with lighter cream dial numerals. Open question +parked: where TPAD/MOUSE live (Craig floated right-of-the-presets; +recommendation recorded in session notes to keep them off the +programming faceplate). + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-31.html][Prototype 31 — encircled groups]] (2026-07-22): the TPAD/MOUSE question +resolved — Craig accepted keeping them off the programming faceplate but +wanted them smaller: the rockers retire and the pair become console keys +(same primitive as the preset heads, lighting green when engaged), +horizontal, seated above the dial. The bench reorganizes into three +ENCIRCLED instrument groups — bordered boxes with their labels set into +the border break, the classic faceplate group idiom: BRIGHTNESS (the +drum pair, grown to 204 to match proportion), POINTERS (the two keys), +IDLE (the tripper dial, size unchanged). Grooves retired with the +rockers. Verified: keys toggle live state and light green, groups render +with break labels, no bench overflow. With this pass Craig declared the +settings panel's cosmetics done; next surface: the wallpaper sub-view. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-32.html][Prototype 32 — the wallpaper channels]] (2026-07-22): built from Craig's +mode-table reframe (see "Wallpaper sub-view — the mode table" under +Design sketch): the units of choice are MODES, not framed pictures, so +the sub-view is a CHANNEL BANK — seven mode cards, each wearing its own +face per the table: WATCH (mini radar face), SUN PAIR (diagonal slashed +thumbnail of the selected pair), SINGLE (the chosen thumb), RANDOM (a +fanned deck — the answer to the table's open representation question), +VIDEO (play-badged frame), CLOCK (drawn face), WORLD (mini-clock +matrix). The active channel rings green; the config surface below +switches per channel — library grid for single, the PAIR PRESS for +sun pair (fill the day frame, the night frame auto-arms, MINT PAIR +strikes the slashed chip into a persistent tray of selectable pair +objects, exactly the mint semantics Craig specified), the interval wheel +(tape-wheel idiom, 5/10/15/30/60) for random, a video picker, and +honest no-options plates for the generators. The wallpaper row's state +text reads the tuned channel. Verified: channel switching, single +selection, the full mint flow (day pick auto-arms night, minted pair +joins tray selected), interval cycling. Mechanisms like the carousel +remain available for the eventual real-image library picker, demoted to +the one layer where units are uniform. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-33.html][Prototype 33 — sources + pair scrap]] (2026-07-22): two missing +capabilities land. SOURCES: a directory list at the sub-view's foot (all +channels draw from it) — folder rows with remove (guarded: the last +source can't be removed) and an ADD DIRECTORY key (mock candidates; the +real build opens a directory picker). PAIR SCRAP: hovering a minted chip +reveals ✕; the first strike arms it red ("strike again to scrap"), the +second deletes — arm-to-fire friction for a destructive act, consistent +with the panel's danger idiom; selection index self-heals including the +empty-tray case. Verified: add/remove sources with the last-source +guard, scrap flow arms then deletes, empty tray renders honestly. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-34.html][Prototype 34 — annotation batch one]] (2026-07-22): Craig's new iteration +method in action — he annotated via org-capture into the roam inbox +(ten archsetup notes), I claimed and applied the batch. Settings side: +SETTINGS header amber-caps (verified against the net/bt prototype, whose +header word is gold — the P30 family pass had wrongly copied maint's +cream); preset keys become GLYPHS (crescent moon for NIGHT, half battery +F007E for BATTERY, projector screen for PRESENT, and — answering Craig's +"focus = ? help with this" — the crosshairs reticle F0210, aim-and- +attend, over eyeglasses which read as "reading"); the narrower keys buy +FOUR numbered slots; CPU balanced glyph – → =; TOUCHPAD spelled out; the +drums' redundant LEVELS ×10 title removed (per Craig's screenshot) and +the drums grown into the space; the toast's idle "waiting…" hidden. +Wallpaper side (his sun-pair architecture + the extend-everywhere +directive): every mode gets a DESCRIPTION panel (helpful operating text, +never "no options"); galleries are side-scrolling strips (7-10 thumbs); +the section order for sun pair runs modes → minted pairs → pair press → +pictures → sources; the minted-pair gallery sorts alpha/recency via two +small keys, shows labels under chips (nothing when unlabeled), and each +chip carries ~ (edit: reopens day/night/label in the press and leaves +the gallery, re-mint to return) and − (delete; recreate to recover); +the press gains its label field; the picture gallery feeds the armed +frame and notes the extension filtering; PREVIEW sections show watch +(Craig's real night-watch screenshot, trimmed to +prototypes/2026-07-22-watch-preview.png), clock, world, single, and +video; SOURCES appears only for the directory-drawing modes (single, +pair, random, video) and hides for the generators. All flows verified +headless including the edit-remint round trip and sort toggles. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-36.html][Prototype 36 — annotation batch two]] (2026-07-22): eight more roam +captures applied. Settings: all matrix keys uniform (43px — glyph keys +slimmed to the numbered keys' size, glyphs bumped to 15px for +recognizability); the bench rearranged — POINTERS above BRIGHTNESS on +the left, the IDLE dial alone on the right, enlarged to 184 with its +legend moved BENEATH the dial (a legendPos option on the tripDial +kit-candidate); drums centered in their frame. Wallpaper: a NEW SOLID +COLOR channel (eighth card) in the sun-pair format — minted labeled +colors above, color picker + label + MINT below, no sources; RANDOM +gains minted SETS — labeled, count-badged chips (first picture + count), +sortable, with ~/− edit/delete, built in a set press by clicking +pictures in and out, minimum two, interval wheel retained; sort keys now +stack vertically LEFT of every sortable gallery; all side-scroll +galleries wear custom themed scrollbars inside evenly-padded wells; +SOURCES scrolls vertically past four entries; and the pair tray is +seeded with a dozen pairs so the scroller's position shows. Verified: +uniform key row, bench order and dial legend, pair tray overflow, solid +mint, set press round trip (mint "blues" of three), card count badge. +P35 remains the linked final for the matrix/bench; the wallpaper +sub-view's normative reference advances to this file. + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-37.html][Prototype 37 — annotation batch three]] (2026-07-22): nine more captures. +Channel faces go ICONIC — the sun-pair card is a fixed yellow/navy +diagonal with a dark sun glyph on the gold and a cream crescent on the +navy (per Craig's spec), SOLID wears dupre blue, and the world card's +mini clocks are redrawn as honest clocks (cream faces, hour + minute +hands at six different times; the world preview matches). Numbered slots +register as scenes named 1-4 so activation announces "▶ 1" instead of a +slot codename. The idle dial's center reads the EXACT minutes (halves +included) live while a tab drags, reverting to a now-visible IDLE (9px +cream) on release; the legend shows exact values too. The video +channel's preview shrinks to 55% so sources fit; the sub-view title +capitalizes to WALLPAPER. FOCUS tries the headphones glyph (F02CB) as +the deep-work icon — awaiting Craig's confirmation, crosshairs retired. +All seventeen applied annotations (batches two + three) swept from the +roam inbox after application; seven foreign items remain for their +projects. + * Metadata | Field | Value | |--------+----------------------------------------------| -| Status | draft | +| Status | doing | |--------+----------------------------------------------| | Owner | Craig Jennings | |--------+----------------------------------------------| @@ -65,6 +591,68 @@ gathers them. * Design sketch +** Prior art — how the major desktops solve the quick-settings UX + +Surveyed 2026-07-21 for the prototyping phase (ui-prototyping rule: research +before picking a direction). + +- *GNOME Shell 43+ Quick Settings* — replaced the decade-old system menu + with a grid of pill-shaped toggle tiles. Each pill is dual-function: click + the body to toggle, click a small arrow to expand a submenu that draws + over the grid rather than opening a new surface. Sliders span two grid + columns; on/off state is carried by the pill's fill colour. Component + anatomy: QuickToggle, QuickMenuToggle (toggle + popup submenu), + QuickSlider, in a column-based grid. + Sources: [[https://www.debugpoint.com/gnome-43-quick-settings/][debugpoint on the GNOME 43 redesign]], + [[https://gjs.guide/extensions/topics/quick-settings.html][gjs.guide Quick Settings anatomy]]. +- *macOS Control Center* — a menu-bar hub of modules. Controls are + dual-function here too: clicking a module's icon toggles it, clicking its + name or arrow opens a deeper sub-view expanded in place (Wi-Fi, Bluetooth, + Display, Sound). Modules can be dragged out to the menu bar for permanent + glanceable placement — the same two-layer bar/panel split this spec + commits to (touchpad + caffeine stay on the bar; the rest collapses in). + Sources: [[https://support.apple.com/guide/mac-help/quickly-change-settings-mchl50f94f8f/mac][Apple Support — Use Control Center on Mac]], + [[https://macmost.com/customizing-and-using-control-center-on-your-mac.html][macmost Control Center walkthrough]]. +- *Windows 11 Quick Settings* — a taskbar flyout: customizable button grid + with sliders below it. The trend line is consolidation: 26H2 adds a + light/dark toggle to the flyout so the Settings app isn't needed, and the + hardware volume/brightness flyouts are being restyled and made + repositionable. + Sources: [[https://pureinfotech.com/windows-11-26h2-quick-settings-dark-mode-toggle/][pureinfotech on the 26H2 flyout]], + [[https://pureinfotech.com/change-quick-settings-buttons-windows-11/][pureinfotech on button customization]]. +- *KDE Plasma 6* — the counter-example: no single consolidated panel; + per-applet popups hang off individual system-tray icons (6.4 raised + control contrast and added tray-entry hiding). This is the + scattered-toggles status quo the spec exists to fix. + Source: [[https://www.omgubuntu.co.uk/2025/06/kde-plasma-6-4-review-new-features-linux-desktop][OMG! Ubuntu Plasma 6.4 review]]. +- *iOS / macOS Focus modes* — the prior art for scenes: named modes that + flip several settings in one action, surfaced as first-class controls in + Control Center. The shared prototype engine's SCENES model mirrors this. + +Convergent conventions the directions are judged against: a two-layer model +(glanceable bar + consolidated panel); dual-function controls (toggle on the +control itself, expand for depth); compact toggle grid with full-width +sliders; sub-views drawn inside the panel rather than as new windows +(matches the wallpaper-sub-view decision); scenes as first-class controls. + +** Final prototype (normative reference) + +[[file:../prototypes/2026-07-02-desktop-settings-panel-prototype-37.html][Prototype 37 — the sealed final]] (2026-07-22, Craig's "Build it"), +superseding P35 as the single normative reference after annotation +batches two and three: +the consolidated machine every build phase points at. Structure: the +PROGRAMMABLE PRESETS matrix (glyph program keys + four numbered slots, +white punch pins with jewel verdicts, arrow/equals CPU tape wheels, +active-is-live semantics); the instrument bench of three encircled +groups (BRIGHTNESS drum pair, POINTERS console keys, IDLE tripper dial +with color-tabbed stages and caffeine bypass); and the WALLPAPER +channel sub-view (seven mode cards, per-mode descriptions, side-scroll +galleries, previews, the labeled pair press with mint/edit/delete, and +per-mode sources). Verified by a 17-point end-to-end suite, all +passing. The full design history lives under Prototype iterations in +the Status section; the kit-candidate components accumulated along the +way live in prototypes/dupre-kit-additions.js pending the kit merge. + ** Architecture — clone the net panel's proven stack - GTK4 + gtk4-layer-shell, Blueprint .blp sources compiled to committed .ui @@ -90,14 +678,25 @@ gathers them. |--------------------+----------+----------------------------------------------| | Auto-dim toggle | toggle | hyprctl decoration:dim_inactive (dim-toggle) | |--------------------+----------+----------------------------------------------| -| Idle / caffeine | toggle | hypridle start/stop (caffeine-toggle) | +| Idle / caffeine | toggle | hypridle start/stop (caffeine-toggle). | +| | | Back to a plain toggle as of P21 — the timed | +| | | wind-up moved to the sleep timer below. | +|--------------------+----------+----------------------------------------------| +| Idle program rail | tripper | the hypridle pipeline as a time-switch | +| | rail | tripper rail: drag DIM / LOCK / WATCH / | +| | | SCREEN OFF / SUSPEND tabs along an | +| | | idle-minutes scale (writes hypridle.conf + | +| | | reload); a tab in the OFF siding disables | +| | | its stage; engaged caffeine renders the | +| | | whole rail bypassed. Replaced the P21 sleep | +| | | timer at P28 — Craig ratified intent B | +| | | ("when idle for X", not "in X from now"): | +| | | this panel programs futures. | |--------------------+----------+----------------------------------------------| | Touchpad toggle | toggle | toggle-touchpad + touchpad-state file | |--------------------+----------+----------------------------------------------| | Mouse toggle | toggle | same mechanism, mouse-state file | |--------------------+----------+----------------------------------------------| -| Airplane mode | toggle | airplane-mode script (laptop-only row) | -|--------------------+----------+----------------------------------------------| | Screen brightness | slider | brightnessctl (backlight class), slider + % | |--------------------+----------+----------------------------------------------| | Keyboard backlight | slider | brightnessctl (kbd_backlight class), slider | @@ -105,14 +704,21 @@ gathers them. | Night light / | toggle | color-temperature shift (candidate: | | color temperature | | hyprsunset; confirm the tool at build) | |--------------------+----------+----------------------------------------------| -| Do Not Disturb | toggle | dunstctl set-paused (pause notifications) | +| Do Not Disturb | toggle | dunstctl set-paused (pause notifications). | +| | | Verified 2026-07-21: pause queues, then | +| | | floods everything on unpause — never drops. | +| | | DECIDED (Craig, 2026-07-21): class-split. | +| | | Ordinary notifications DROP at unpause; | +| | | alarms (urgency critical + rules promoting | +| | | known alarm sources) SURVIVE DND and show. | +| | | Build detail: dunst pause levels with | +| | | override_pause_level lets alarms punch | +| | | through live vs held-to-unpause — pick at | +| | | build. History keeps everything either way. | |--------------------+----------+----------------------------------------------| | Power profile | selector | powerprofilesctl (performance / balanced / | | | | power-saver, three-way) | |--------------------+----------+----------------------------------------------| -| Lock / suspend | actions | hyprlock / systemctl suspend (momentary | -| | | buttons, not stateful toggles) | -|--------------------+----------+----------------------------------------------| | Scenes / profiles | composite| one control that flips several toggles at | | | | once: Focus, Presentation, Battery-saver, | | | | Night (see the Scenes decision below) | @@ -124,10 +730,50 @@ gathers them. Slider changes apply live (throttled) and read back the actual level after apply — verify-everything. Toggles re-read their source of truth after firing, same as the bar indicators do, and the bar modules get their refresh -signals so both surfaces agree. The lock/suspend buttons are momentary -actions with no persistent state to reflect. A scene reads back each toggle it +signals so both surfaces agree. A scene reads back each toggle it sets and reports the composite as applied only when every member verified. +** Wallpaper sub-view — the mode table (Craig, 2026-07-22) + +Craig's reframe, thinking out loud: every mechanism proposal (carousel, +light table, film strip) assumed uniform framed objects, but the real +units of choice are MODES — heterogeneous programs for what the screen +shows. His inventory: + +| Mode | Representation | Options | +|-------------------+--------------------------------+--------------------------| +| Night watch | to be designed | none | +|-------------------+--------------------------------+--------------------------| +| Sunup/sundown | slashed thumbnail — the thumb | choose + save pic PAIRS | +| | split vertically or diagonally | | +| | (top-right to bottom-left), | | +| | day pic one side, night the | | +| | other | | +|-------------------+--------------------------------+--------------------------| +| Single | thumbnail | none | +|-------------------+--------------------------------+--------------------------| +| Randomize | open (deck/fan candidate) | interval spinner (mins | +| | | before next random item) | +|-------------------+--------------------------------+--------------------------| +| Video (Apple-TV | video thumbnail | none | +| aerial style) | | | +|-------------------+--------------------------------+--------------------------| +| Custom clock | picture of the clock | none | +|-------------------+--------------------------------+--------------------------| +| World clock | matrix of mini-clocks | none | +|-------------------+--------------------------------+--------------------------| + +Clock-channel backings (Craig, 2026-07-22): neither clock is a new +design. CUSTOM CLOCK renders the clock that already appears when the +waybar time module is clicked; WORLD CLOCK renders a matrix of miniature +clocks with their timezones and cities — the same data the time module's +hover tooltip shows. One owner per clock design; the wallpaper channels +are projections of those existing surfaces onto the desktop. + +Pair semantics (Craig): pairs are SAVED OBJECTS — build one by choosing a +day pic and a night pic, alter either, confirm; the pair is minted and +remains selectable as a unit thereafter. The pair library persists. + ** Open/close behavior Gear glyph module on the bar right cluster; click toggles the panel @@ -140,6 +786,8 @@ Close button, matching the net panel. Keybind: Super+Shift+G (decision B). CLOSED: [2026-07-04 Sat] Resolved (Craig, 2026-07-04): touchpad and caffeine stay on the bar (glanceable state); auto-dim and airplane move into the panel (panel-only, freeing bar width). The airplane Super+Shift+A toggle keybind stays as the quick lane — only its bar indicator collapses in. +Amended 2026-07-22 (Craig, during the first live drive of the panel): caffeine's bar indicator collapses into the panel too. The bar keeps only the touchpad indicator; dim and caffeine are panel-owned (their Super+Shift+D / Super+I keybinds stay as quick lanes). Applied live same day — modules and their state CSS removed from the waybar config. + ** DONE Keybind for the panel? CLOSED: [2026-07-04 Sat] Resolved (Craig, 2026-07-04): Super+Shift+G (gear), for parity with the other panels' fast path. @@ -165,6 +813,18 @@ OUT of this panel, with reasons: volume / master-mute stays with the audio panel (this panel does not mirror it — one owner per control); theme light/dark goes to the separate theme-studio task, not here. +Amended 2026-07-22 (Craig): airplane mode is withdrawn from the panel too — +it's about the network, and the network panel carries it as its main +on/off. Same one-owner-per-control reasoning; keeping a copy here +overcomplicated the switch wall. (The Super+Shift+A keybind and the +network panel's master switch are the two lanes.) + +Amended 2026-07-21 (Craig): lock and suspend are withdrawn from the panel. +Session-exit actions live on the wlogout screen (Super+Shift+Q: +lock/reboot/shutdown/logout/suspend/hibernate) — same one-owner-per-control +reasoning as volume. The direction prototypes still show lock/suspend +buttons; the iterate-to-final pass drops them. + Format pickers (time/date/weather display format) are pulled out entirely into their own future sibling spec. The real problem there is a single source of truth — many programs store their own format, so the design question is where @@ -179,6 +839,71 @@ Confirmed the panel gathers the mouse/trackpad toggle, a no-sleep (idle-inhibit) - switch between two pictures at sunup / sundown (a day/night pair). That last one implies a sun-time source (a lat/long or a sunrise/sunset lookup). +** DONE Idle pipeline — staged timeouts and an idle power action +CLOSED: [2026-07-21 Tue] +Resolved (Craig, 2026-07-21), the lean package with one revision: dim +5 min/10% and lock 7 min stay; add screen-off (DPMS) at 10 min; idle +suspend-then-hibernate at 30 min — default ON for velox, and PRESENT on +ratio too (not hidden by battery detection) but default OFF with an +armed-trigger confirmation before enabling, since an idle-suspended desktop +drops tailscale sessions and hourly backups. Hyprlock grace 5 s. The panel +surfaces only the situational control (the caffeine timer dial); stage +delays live in hypridle.conf. Original analysis below. + +Amended same evening (Craig, 2026-07-21): the eye-candy stage is IN after +all — as the Dupre Instrument Night Watch, a fullscreen board of the kit's +own instruments on watch (split-flap + movement + seven-seg carrying real +time, radar sweeping, scope tracing, drift chart, spectrum, VU programme, +blinkenlights core, elapsed tape counter, systems lamps, voice loops, VFD +wire, engine-order telegraph that rings a new order every couple of +minutes). Prototype: +[[file:../prototypes/2026-07-21-night-watch-screensaver-prototype-1.html][night-watch prototype 1]]. +It runs between lock and screen-off as a hypridle stage (fullscreen +browser/webview launched on-timeout, killed on-resume); the power cost is +accepted for the stage's duration since DPMS still follows at its delay. +Kit note for the merge task: span-built instruments (scope, stripChart, +vfdMarquee, analogClock, rotsel, keylock) need a blockifying host outside +the gallery's card grid — the night-watch page carries the one-rule fix. +Opened 2026-07-21 from Craig's question: "shutdown/reboot/suspend/hibernate +when idle for X minutes?" Yes — hypridle listeners run arbitrary on-timeout +commands, so any systemctl action can be a stage. Today's pipeline is only +dim-to-10% at 5 min → lock at 7 min; there is no screen-off (DPMS) stage and +no power action, so an idle machine glows at 10% forever. + +Proposed idle group for the panel, replacing the bare caffeine on/off: +- Dim delay + dim level (currently hardcoded 300 s / 10%). +- Lock delay (currently 420 s). +- Screen-off (DPMS) stage after N minutes — new. +- Idle power action after X minutes — one of none / suspend / hibernate / + suspend-then-hibernate / shutdown. Ordering guarantee: lock always fires + before the power action so the machine wakes locked. +- Caffeine becomes a timed inhibit (wind-up timer, e.g. the kit timerDial: + 30/60/120 min or until-off) instead of killing the whole daemon. + +Added 2026-07-21 (Craig approved recording these alongside the stages): +- Hyprlock grace period (currently grace = 0): N seconds after lock where + any input wakes without a password — softens an aggressive lock delay. +- Optional eye-candy stage: an animated "screensaver" (mpvpaper loop, + terminal art, shader) launched by a hypridle stage and killed on resume. + Sits before the DPMS stage if offered at all; it holds GPU + display + awake, so it trades power for style. +- Lock-face notes for the build: path = screenshot shows a blurred image + of the work session (privacy consideration; wallpaper/solid is the + alternative), and hyprlock labels can run shell commands on an interval + (cmd[update:N]) — the lock face can carry weather/now-playing/battery. + +Build notes: Wayland's idle-inhibit protocol already keeps the pipeline at +bay during media playback (video players and browsers inhibit idle, and +hypridle honors it), so caffeine's real audience is passive non-media use — +long reads, watching a build, screen-share without input. systemd +suspend-then-hibernate is the likely default for velox +(suspends now, hibernates when the battery drains); shutdown-on-idle is the +risky option (kills the session and anything unsaved) and wants a long delay +if offered at all; AC-vs-battery differentiation is possible by making the +on-timeout a script that checks the power source. Decisions needed from +Craig: which stages to expose, the default delays, and whether the power +action appears on both machines or only the laptop. + ** DONE Scenes / profiles — one control that flips several toggles at once CLOSED: [2026-07-19 Sun] Resolved (Craig, 2026-07-19): the panel offers named scenes that set several @@ -199,12 +924,127 @@ build detail for the sub-view. * Implementation phases -1. settings.py backings (brightness get/set, kbd backlight, toggle - state readers) — pure engine, TDD with fake brightnessctl/hyprctl. -2. PanelModel presenter (rows, conditional visibility, verify-after-apply - semantics) — unit-tested, no GTK. -3. Blueprint UI + gear bar module + open/close wiring; palette css factored - to a shared asset; AT-SPI smoke. -4. Bar-module consolidation per decision A: drop the dim and airplane bar - modules (now panel-only), keep touchpad and caffeine on the bar, wire the - refresh signals so bar and panel agree, and bind Super+Shift+G. +All phases live in dotfiles =settings/= (archsetup drives the lifecycle). +The normative reference for every UI decision is prototype 37; the design +history is the Prototype iterations subsection. + +1. *Backings engine* — pure Python, TDD with fake binaries. Brightness + + keyboard backlight (brightnessctl, 5% floor, ×10 drum mapping); toggle + backings (hyprctl dim_inactive, touchpad/mouse state scripts, hypridle + caffeine inhibit); DND via dunst pause levels implementing the + class-split semantic (ordinary notifications drop, alarms survive — + punch-through vs held decided here); powerprofilesctl three-way; the + hypridle.conf writer + reload for the idle pipeline (four stages, per- + stage enable/disable, hyprlock grace); the wallpaper engine — persisted + mode store (channel, single, minted pairs/sets/colors with labels, + video, interval, sources), setter adapters (images via the chosen + setter, solid colors, video via mpvpaper, clock/world/watch as + projected surfaces), sunrise/sundown calculation from lat/long, the + random-draw timer, and extension filtering for the galleries. +2. *Presenters* — GTK-free models, unit-tested to 100% like the net + PanelModel. The program-matrix model (programs as complete + definitions; active-is-live with program-membership writes surviving + activation; pin, letter, and radio row kinds; slots 1-4 as first-class + programs); the bench model (drum mapping, pointers, idle stages with + order clamping, parking, and caffeine bypass); the wallpaper channels + model (mint/edit/delete and alpha/recency sort for pairs, sets, and + colors; per-mode sources visibility; previews). +3. *GTK4/Blueprint UI* — port the instruments prototype 37 actually uses + to GTK widgets in the Dupre family chrome: console keys, the + programmable matrix (glyph + numbered heads, jewel verdicts, tape + wheels), drum roller, tripper dial (bottom legend, exact-minutes drag + counter, bypass stamp), encircled bench groups, iconic channel cards, + side-scroll gallery wells with themed scrollbars, the pair/set/color + presses. Architecture rule from the prototypes: instruments that own a + clock own their widget lifetime (never rebuilt on state refresh). + Layer-shell chassis under the bar, wallpaper sub-view navigation, + AT-SPI smoke per the family pattern. +4. *Integration and bar* — gear module + Super+Shift+G; bar consolidation + per decision A as amended (dim and caffeine modules dropped 2026-07-22; + airplane's bar indicator is gone with the control — the network panel + owns it; only touchpad keeps a bar indicator; wire refresh signals + both ways); the + watch/clock/world renderers as projected surfaces (the night watch as + the hyprlock face per the WATCH-is-the-lock-screen resolution); live + hypridle rewrite-and-reload; dunst pause-level configuration; end-to- + end verify-everything against the real machine. + +Test surface: fake-binary suites for every backing; presenter suites +covering program recall/edit semantics (including the active-scene- +survives-member-writes invariant), idle-stage clamping/parking/bypass, +and mint/edit/delete/sort round trips for all three minted stores; one +AT-SPI smoke; a scripted end-to-end pass mirroring prototype 37's +17-point suite. + +* Review findings [7/7] + +Reviewed 2026-07-22 (in-session, both roles — the annotation-batch method +kept author and reviewer in one loop; Craig's "Build it" sealed the +design). Rubric: *Ready with caveats* — the caveats are build-time picks, +not design gaps. + +** DONE Implementation phases reflected the pre-casting roster +CLOSED: [2026-07-22 Wed] +The four phases still described the original controls table (lock/ +suspend, per-toggle rows). Rewritten this pass to the settled design — +matrix, bench, wallpaper channels, projected surfaces — with the test +surface enumerated. +** DONE Stale architecture prose vs settled design +CLOSED: [2026-07-22 Wed] +The Design sketch accumulated its corrections through the iteration +entries (controls table amended in place; wallpaper mode table and +clock-channel backings recorded; Final prototype section added). The +iterations subsection is the authoritative design log. +** DONE Wallpaper setter tool selection :build: +CLOSED: [2026-07-22 Wed] +DECIDED (phase 1, 2026-07-22): awww 0.12.1 — the swww fork already +running as the wallpaper daemon (=swww= on PATH is a symlink to it; +=set-wallpaper= already drives it). Images and slashed pairs go through +awww =img=; solid colors through awww =clear <color>=; video via +mpvpaper (not yet installed — install lands in phase 4 integration); +clock/world/watch are projected layer-shell surfaces, not setter jobs. +The engine's setter adapter takes the binary from env so the fake-binary +suite and a future setter swap both stay one seam. +** DONE Speedrun pre-flight picks (Craig, 2026-07-22) :build: +CLOSED: [2026-07-22 Wed] +Batched before the phases 2-4 autonomous run: +- Alarm sources for DND: wtimer only — its notifications get the + CRITICAL promotion in the phase-4 dunstrc rules; everything else + drops at unpause. +- Night-watch host: google-chrome-stable in kiosk mode, launched by the + hypridle watch stage, killed on resume. +- Sun-pair location: live whereami lookup at each transition (the + machine's own location is the right datum for its screen), with the + last good reading cached in state.json as the offline fallback. +- Factory scenes (Focus / Presentation / Battery-saver / Night): drafted + against the controls table during phase 2, corrected by Craig in the + panel afterward. +** DONE DND unpause variant — held, not punch-through :build: +CLOSED: [2026-07-22 Wed] +DECIDED (Craig, 2026-07-22, first live drive): the held-to-unpause +variant. While DND is on nothing shows, alarms included (pause level +100 holds everything). At release, ordinary notifications are dropped +(history keeps them) and the alarms that arrived during DND surface: +close-all → read history → unpause → history-pop each CRITICAL entry +newer than the engage stamp, oldest first. The engine records the +engage instant as CLOCK_MONOTONIC microseconds (dunst's history +timestamp base) in $XDG_RUNTIME_DIR. The controls-table note offering +punch-through-live is superseded by this finding. +** DONE Night-light tool selection :build: +CLOSED: [2026-07-22 Wed] +DECIDED (phase 1, 2026-07-22): gammastep (installed; hyprsunset is not). +On Wayland the gamma ramp only holds while a client owns the +wlr-gamma-control protocol, so gammastep stays resident while night +light is on and the backing mirrors caffeine's daemon model: engage = +spawn detached (=gammastep -O <temp>=, default 4500K), release = pkill, +pgrep as the one source of truth. Swapping to hyprsunset later is one +adapter change behind the same seam. +** DONE Persisted store location and format :build: +CLOSED: [2026-07-22 Wed] +DECIDED (phase 1, 2026-07-22): one JSON document at +=~/.config/desktop-settings/state.json= (root overridable via +=DESKTOP_SETTINGS_CONFIG_DIR= for tests) carrying programs (slots 1-4), +idle stages, and the wallpaper store (channel, pairs, sets, colors, +sources, interval). Factory presets ship as defaults in code; the store +writes atomically (temp file + rename) and tolerates a missing or +corrupt file by falling back to defaults. @@ -45,41 +45,98 @@ below): input-side-spec.org (DRAFT, four decisions open). * Archsetup Open Work +** DOING [#B] Build: desktop-settings panel :feature:hyprland:dotfiles: +:PROPERTIES: +:SPEC_ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce +:END: +The GTK build of the desktop-settings panel per the spec (docs/specs/2026-07-02-desktop-settings-panel-spec.org, DOING; normative reference: prototype 37). Work happens in dotfiles settings/ — archsetup drives the lifecycle. Two non-blocking build-time picks live in the spec's Review findings (wallpaper setter tool; store location/format) — decide in phase 1 and record there. +*** 2026-07-22 Wed @ 13:14:01 -0500 Built the backings engine (phase 1) — dotfiles 7a15237 +Landed as dotfiles settings/src/settings (10 modules) + tests/settings (118 tests against fake binaries, auto-discovered by make test — 81 suites green). Covers brightness/kbd (5% floor, x10 drum), toggles (dim, pointer cycle via toggle-touchpad, caffeine), DND class-split (dunst pause level 60, close-all before unpause, alarms punch through live), powerprofilesctl, nightlight (resident gammastep), hypridle.conf renderer + symlink-safe write + caffeine-respecting reload + hyprlock grace, suntimes (pure NOAA math), and the wallpaper engine (awww/mpvpaper/projector adapters, galleries, random draw, atomic JSON store). All three build-time picks recorded as DONE findings in the spec (setter=awww, store=state.json, nightlight=gammastep). Handoff note in ~/.dotfiles/inbox/. +*** TODO Phase 2 — presenters :solo: +GTK-free models at 100% unit coverage: program matrix (complete definitions, active-is-live surviving member writes, pin/letter/radio rows, slots 1-4), bench (drums, pointers, idle clamping/parking/bypass), wallpaper channels (mint/edit/delete/sort for pairs/sets/colors, per-mode sources, previews). +*** TODO Phase 3 — GTK4/Blueprint UI +Port prototype 37's instruments to GTK in family chrome: console keys, programmable matrix, drum roller, tripper dial (bottom legend, exact drag counter, bypass stamp), encircled groups, iconic channel cards, themed gallery wells, the three presses. Self-clocked instruments own their widget lifetime. Layer-shell chassis, sub-view navigation, AT-SPI smoke. +*** TODO Phase 4 — integration and bar +Bar consolidation LANDED EARLY (2026-07-22, live): gear module + Super+Shift+G wired; dim AND caffeine modules dropped (Craig amended decision A during the first drive — bar keeps only touchpad; dim/caffeine are panel-owned, keybinds stay). Remaining: watch/clock/world projected renderers (night watch as the hyprlock face); live hypridle rewrite; dunst pause-level config (alarm rules with override_pause_level 100); refresh-signal rewiring; end-to-end verify-everything. +Backing tools: mpvpaper (AUR) + power-profiles-daemon installed on ratio 2026-07-22 and added to the installer (ppd service enable gated on no-battery, since TLP owns power on laptops). Remaining: velox wants mpvpaper (and optionally the ppd package, service off) by hand — one-time AUR install the sync doesn't carry. +*** TODO Test surface — end-to-end pass +A scripted end-to-end pass mirroring prototype 37's 17-point suite against the real panel, plus the per-phase suites called out above. +*** TODO Flip the spec to IMPLEMENTED +When the phases land: flip docs/specs/2026-07-02-desktop-settings-panel-spec.org DOING → IMPLEMENTED with a dated history line naming the shipping commits. +** TODO [#C] Timer module hero hierarchy :feature:waybar:timer: +From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org). +** TODO [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer: +From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above). +** TODO [#C] PTT icon outline removal :bug:waybar: +From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C]. +** TODO [#B] Weather tooltip caching :feature:waybar:weather: +From the roam inbox (Craig, claimed 2026-07-22): retrieve the weather tooltip data once per hour and cache it. If the network is unavailable, display the cached tooltip with explanatory text saying so. Dotfiles-side work (archsetup owns the lifecycle); touches common/.local/bin/weather. +** TODO [#C] WiFi tooltip signal strength :feature:waybar:network: +From the roam inbox (Craig, claimed 2026-07-22): add signal strength to the WiFi tooltip. +** TODO [#C] Night-watch live telemetry :feature:maint: +Craig, 2026-07-21 ("mind. blown."): drive the Dupre Night Watch screensaver (docs/prototypes/2026-07-21-night-watch-screensaver-prototype-1.html, the idle-pipeline eye-candy stage in the desktop-settings spec) with real system data instead of synthetic signals — a passive status wall while the machine idles. Candidate mappings: scope = CPU load trace, drift chart = memory pressure history, spectrum = per-core utilization, VU pair = net throughput up/down, blinkenlights = disk I/O, tape counter = uptime, systems lamps / annunciator = maint status verdicts, engine-order telegraph = current power profile, VFD wire = maint status one-liner (temps, battery, pending updates). Browser prototype can poll a small local JSON endpoint; the production shape belongs to the idle-stage build. Depends on the spec's idle-pipeline implementation landing first. +** TODO [#B] Dupre Kit merge — casting additions :feature:tooling: +Fold docs/prototypes/dupre-kit-additions.js back into the kit proper: detentFader (NEW — multi-detent slide attenuator with speedbump drag physics: magnet + escape hysteresis, parked tick glow) and the drumRoller redefinition (UPGRADE — 1..N channels and min/max range; stock hardcodes two drums and throws on one, defaults reproduce stock exactly) and the guardedToggle redefinition (UPGRADE — lever throws with rotateX so it flips toward the viewer instead of the stock 180° planar spin that sweeps sideways mid-transition; contract unchanged). Merge means: builders into widgets.js, the additions CSS into DUPRE_CSS, additions-scoped gradients into the shared defs plate, gallery cards for both in panel-widget-gallery.html, and POLICY entries. Origin: the desktop-settings casting sitting 2026-07-21 — Craig's direction is that components get finished by being needed ("the ones needed most will have had the most attention"), so more additions may accrue here before the merge; batch them. +** TODO [#C] Wlogout screen review :bug:hyprland:dotfiles: +Craig, 2026-07-21: the wlogout window (Super+Shift+Q — lock/reboot/shutdown/logout/suspend/hibernate) "isn't great and has bugs." Review it end to end: catalogue the specific bugs, then assess the design against the Dupre instrument-console family (it predates the panel aesthetic). Config lives in dotfiles; the bind is hyprland.conf:428 (=pgrep -x wlogout || wlogout-menu=). Context: the desktop-settings panel spec withdrew lock/suspend in favor of this screen (2026-07-21 amendment), so it's now the sole owner of session-exit actions — worth being good. Grade each bug found via the severity×frequency matrix; this parent stays a [#C] review until specifics emerge. ** TODO [#B] Audit cgit-published repos for secrets and privacy :bug:security: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: Grading: security carve-out — cgit at git.cjennings.net serves every repo under scan-path=/var/git over unauthenticated https (any repo is anonymously cloneable). archsetup being public is by design (curl-install), but this means ANY secret in ANY /var/git repo is world-readable, and any repo meant to be private is not. Severity depends on what else lives there = P2 = [#B], raise if a private repo with secrets is found. Not :solo: — needs Craig's decisions. Steps: list repos under /var/git; for each, decide intended public vs private; scan each for secrets (keys, tokens, credentials) the way this WireGuard leak was found; for any meant-to-be-private repo, actually restrict access (cgit repo.hide only hides from the index — a known repo name is still cloneable; use http auth or move it off the public scan-path); for public repos, confirm no secrets and add a pre-receive/CI secret scan. Check dotfiles (git.cjennings.net/dotfiles) specifically — it is also under /var/git. archsetup's own move is decided and tracked separately below. ** TODO [#B] Move archsetup off cgit to cjennings@cjennings.net :chore:security: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: Decided (Craig, 2026-07-20): move the archsetup repo off the public cgit host (git@cjennings.net, scan-path /var/git) to Craig's private account remote cjennings@cjennings.net, so it is no longer world-cloneable. This is the archsetup-specific fix for the cgit-exposure finding above. Plan: create a bare repo under cjennings's control off the cgit scan-path (e.g. =~cjennings/git/archsetup.git=); push current main + tags there; migrate the post-receive hook that publishes the installer to =/var/www/cjennings/archsetup= so curl-install keeps working (the single published file stays public by design; only the repo goes private); update the origin remote on ratio and velox to =cjennings@cjennings.net:git/archsetup.git=; remove =/var/git/archsetup.git= so cgit no longer serves it. Verify: anonymous =git clone https://git.cjennings.net/archsetup.git= fails, the new private clone works from both machines, and the curl-install URL still returns the installer. Keep the two daily drivers' remotes in sync (daily-drivers rule). ** TODO [#B] Velox boot-failure retrospective — upgrade guard gaps :bug:zfs:maint: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/maint code review: - maint's UPDATE remedy runs a plain =yay -Syu --noconfirm= (remedies.py:297). The live-update guard (guard.py) only matches mesa/hyprland (the 2026-06-07 live-swap class) — it never checks /boot, kernel, initramfs, or mkinitcpio exit. No post-upgrade /boot assertion exists. An interrupted kernel transaction slips straight through. - Add a post-upgrade /boot assertion: after a transaction touching linux/linux-*, confirm vmlinuz-* + initramfs-*.img present and mkinitcpio exit 0; refuse to end the run (or page Craig) otherwise. Would have caught this. - Sanoid-vs-actual dataset drift: configure_zfs_snapshots configures zroot/var/log + zroot/var/lib/pacman as separate datasets; velox's actual layout has neither separate (/var/log sits inside zroot/var). Reconcile. -- Confirm the pre-pacman snapshot hook is actually installed + firing on velox (it should be — it's what makes recovery possible). +- CONFIRMED (2026-07-21): the pre-pacman snapshot hook fired on velox — the 2026-07-15 no-kernel boot was recovered via the pre-pacman ZFS snapshot rollback, and velox is back on the tailnet running linux-lts 6.18.38 with initramfs present (2026-07-19 session). Root-cause hook-ordering fix shipped separately. Still open: the post-upgrade /boot assertion in guard.py and the sanoid-vs-actual dataset drift reconcile (the two bullets above). -** TODO [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland: +** CANCELLED [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland: +CLOSED: [2026-07-21 Tue] +Dropped 2026-07-21 (Craig's call) — not tracking the upstream report. The crash evidence (both reports + tmpfs log excerpts) and the voice-passed issue draft stay preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] if it recurs and is worth reviving. Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix. A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo). Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it). Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync). ** TODO [#B] Assess a Hyprland left-drag window gesture :feature:hyprland: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: Evaluate whether a global left-click drag can move ordinary windows without breaking application selection, text interaction, or Wayland security expectations. Document the safe modifier/gesture alternatives before changing any binding. ** TODO [#B] Reconcile panel keybindings around Super+N :feature:hyprland: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: Swap the notification and networking bindings so primary panels are one Super-plus-letter chord away, audit the other exceptions, and bring the proposed family to Craig for a final mapping decision. ** TODO [#B] Add storage-capacity signals to the maintenance module :feature:maint: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: Investigate capacity and growth diagnostics for full disks, identify the appropriate remedies, and incorporate a clear storage signal into the maintenance console. ** TODO [#B] Add per-channel controls to the audio panel :feature:audio: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: Expose channel-level input and output volume controls without losing the existing device-level workflow. @@ -329,6 +386,8 @@ On dotfiles main (=d73eba6=, pushed). The clean, unambiguous verdict of the thre On dotfiles main (=c9f8604=, pushed). Craig approved proceeding with the default precedence. =classify= adds =disable-rival= (rival dhcpcd/networkd/iwd active + a failing link) and =chmod-keyfile= (bad-perms keyfile + a failing link), both =remedy_class="privileged"= through the same panelkit gate. PRECEDENCE settled: both gate on a local-link symptom (never nag next to a working link), sit behind rfkill/service (more fundamental) and ahead of the generic reset (naming the cause beats a blind reconnect); tested (rfkill beats rival, rival beats reset). Priv verbs =disable-rival= (three-unit allowlist), =chmod-keyfile=/=chown-keyfile= (paths validated under system-connections, no traversal). Repairs re-derive their target (=active_rivals= / the active connection). =fake-systemctl= gained a state dir so a disable flips the re-check. 782 net tests + 65 suites green, review-code clean. Two coverage edges deferred to the VM: the keyfile fix's root-owned pass (only reproduces under root), and whether disable-rival needs a follow-on reset to restore the link. All three Phase 1 verdicts now fake-complete; VM live-verification is the remaining piece (see the sub-task). **** TODO Net Phase 1 live verification — run the scenario harness against a VM The harness is BUILT (archsetup =a364c1e=): =scripts/testing/net-scenarios/{10-nm-masked,20-rival-manager,30-keyfile-perms}.sh= (break/fix/assert + the expected diagnose verdict) and =run-net-scenarios.sh= (rsyncs net+panelkit into a target over ssh, drives the scenarios). Scenario logic reviewed; the ssh transport plumbing is UNEXERCISED (marked first-draft) and wants a shakeout on the first real run. What remains needs Craig: a booted VM (a real kernel + network stack — NM does NOT run reliably in nspawn, so a container only shows the mask-symlink/keyfile-mode half, not "NM active") with NetworkManager + dhcpcd installed, reachable over ssh as root. Then =run-net-scenarios.sh --target root@HOST --profile <saved-profile>=. The two live questions: disable-rival was already chained to a follow-on reset (=be15a81=), so the run confirms whether the reset fires or NM auto-recovers; and the keyfile root-owned pass (only reproduces under root). The by-hand equivalent is the "Manual testing and validation" → "Net doctor privileged fixes" checklist. +***** 2026-07-21 Tue @ 08:20:00 -0500 Decided (Craig): agent runs it via the archangel test VM, deferred to a later session +No longer "needs Craig to provide a VM." The VM is the archangel test harness (=scripts/testing/create-base-vm.sh= + =vm-utils.sh=; ISO in =~/archangel-isos/=). Plan: in a later session the agent boots a fresh archangel VM (NetworkManager + dhcpcd), then runs =run-net-scenarios.sh --target root@HOST --profile <saved-profile>= against it, exercising the unexercised ssh transport on the first real run and answering the two live questions above. A 40-60 min VM operation; agent-driven, checkpointing and surfacing if it needs Craig's hands. This makes the sub-task agent-workable rather than blocked. *** 2026-07-11 Sat @ 06:31:29 -0500 Built the sharpened auth verdict On dotfiles main (=12e3e76=, pushed). =gather_context= derives an auth cause on an auth failure from the saved profile's key-mgmt + the scanned SECURITY flags for the SSID (not GENERAL.REASON, which only marks *that* auth failed): sae / hidden / enterprise / generic. =classify= turns sae and hidden into =fixable= Privileged profile-modifies (=key-mgmt sae= + PMF, or the hidden flag) routed through =panelkit.resolve()= like the Phase 1 control-plane verdicts; enterprise and generic stay =needs-user-action= with a cause-named message. Two new priv verbs (=conn-sae=, =conn-hidden=, uuid-validated single =nmcli connection modify= commands) + =repair_auth_sae=/=repair_auth_hidden=, each chained into a reset (FIX_CHAINS) so NM reconnects with the corrected profile. Hidden fires only on a non-empty scan missing the SSID, so a failed scan can't fabricate it. Tightened the =fake-nmcli= key-mgmt hook to require the =-g= prefix so the conn-sae modify isn't read back as a get_value. Pairwise (reason × profile-state) covered in =TestAuthCauseDerivation=. 813 net tests + 65 suites green, review-code Approve, voice. Inbox note sent. Live half (real WPA3/hidden profile-modify) is the VM/manual checklist. *** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the net spec to IMPLEMENTED and logged the vNext items @@ -379,6 +438,9 @@ Refiled from the archsetup task audit (2026-06-28), landed via ~/.dotfiles/inbox - Review all 50+ scripts in =~/.local/bin= and remove unused ones. - Check dotfiles for uninstalled packages and remove orphaned configs. - Verify all stowed files are actually used. +*** 2026-07-21 Tue @ 08:30:00 -0500 The evidence report's ref counts are UNRELIABLE — redo the scan before any kill pass +Spot-checking the "zero references" list against the live tree during the 2026-07-21 task audit found the report is badly wrong: 12 of a 14-script sample flagged refs=0 are actively invoked. The scan missed the primary invocation paths — =hyprland.conf= =bind=/=exec-once= lines, waybar config =exec=/=on-click= handlers, pypr scratchpad configs, and =profile.d= autostart. Confirmed live examples: =layout-resize= (bound mod+H/L window resize), =net-fix= (waybar net right-click), =start-hyprland= (session launcher, profile.d + startx alias), =wait-for-tray= (exec-once gating signal-desktop tray), =waybar-layout=/=waybar-worldclock= (live waybar modules, worldclock has a test), =stash-window=/=stash-others=/=stash-restore= (bound mod+O family), =hypr-refocus-scratchpad= (exec-once), =monitor-dashboard= (pypr scratchpad on ratio+velox). Only =toggle-scratchpad= and =waybar-disk= came back genuinely unreferenced in that sample. +ACTION before the kill pass: redo the reference scan to grep all invocation sources — =hyprland.conf= (bind/binde/bindm/exec-once/exec), =waybar/config= (exec/on-click*/on-scroll*), =pypr/config.toml=, =profile.d/*=, systemd units, and other scripts — then re-bucket. Nothing was deleted; the current report can't be trusted for deletions. The three orphan config dirs (audacious, wofi, ranger) are independent of the script scan and still stand as candidates. *** 2026-07-14 Tue @ 01:40:48 -0500 Built the audit evidence report Shipped as =docs/2026-07-14-bin-audit-evidence.org= in the dotfiles repo (260752a). 150 scripts bucketed: 69 keep (referenced or cron-driven), 74 kill candidates (zero references in the tree), 7 flagged (all dwm-tier, expected on a hyprland host). Config sweep: audacious and wofi configs are orphan candidates, ranger needs an install-vs-delete call (declared in archsetup but not installed on ratio). Shell history was too shallow (~700 lines) to prove by-hand disuse either way — the kill pass stays Craig's call in the parent task. @@ -1331,6 +1393,8 @@ NOTE (2026-07-04 audit): the "four-tab panel" framing predates the instrument-co :LAST_REVIEWED: 2026-07-09 :END: Remove personal info, credentials, and code quality issues before publishing. +*** 2026-07-21 Tue @ 08:00:00 -0500 Audit reconcile: the four assets/ "& Claude" author lines are fixed +The four =assets/= files the 2026-07-09 note flagged as "Craig's call, still open" all now read =#+AUTHOR: Craig Jennings= (verified). No tracked file outside =todo.org= still carries =Craig Jennings & Claude= (the one in todo.org is a historical dated note quoting the old line). That open item is resolved; the two open children below (scripts personal-info, git-history scrub) remain. *** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: docs/ scrubbed; scripts and history still open The =docs/= half of "remove personal information" landed (=a8f7e9b=). Four absolute =/home/cjennings= paths became the repo root, a home-relative path, or a named variable — every snippet stays runnable. Three =~/projects/home= references and three =.ai/sessions/= links, all dead in any clone but Craig's, were replaced by what they mean. No =file:= links broke. @@ -1383,6 +1447,8 @@ Recommend: fresh repo for GitHub (keep cjennings.net remote with full history). History is now 589 commits (the 2026-05-11 note's "275" is stale). Only the calendar-feed file has been filter-repo'd so far (2026-05-20). The five credential files remain in history at their pre-=b10cba5= paths: =.tidal-dl.token.json= (5 commits), =calibre/smtp.py.json= (6), =transmission/settings.json= (5), =.msmtprc= (8), =.mbsyncrc= (9). None are tracked in the current tree. The scrub-or-fresh-repo decision still stands. ***** 2026-07-04 Sat @ 11:48:24 -0500 Count refresh — history now 565 commits; re-verify the 5-file claim before scrubbing The 2026-07-04 audit found the history is now 565 commits, down from the 589 recorded above. Because the count dropped, re-verify that the five credential files are still present in history (re-run the per-file =git log --all -- <path>= check) before relying on the scrub scope — the earlier count is stale and the file set may have moved. +***** 2026-07-21 Tue @ 08:00:00 -0500 Re-verified: history now 851 commits; five files still present, per-file counts dropped +2026-07-21 audit re-verification. History is now 851 commits (=git rev-list --all --count=). The five credential files are still in history but at fewer commits each than the 2026-06-28 record: =.tidal-dl.token.json= 3 (was 5), =calibre/smtp.py.json= 4 (was 6), =transmission/settings.json= 3 (was 5), =.msmtprc= 5 (was 8), =.mbsyncrc= 6 (was 9). None are in the current tree. The scrub-or-fresh-repo decision still stands; the scope is smaller than recorded. *** 2026-06-24 Wed @ 19:41:56 -0400 Gated device-specific udev rules behind a flag The Logitech BRIO udev rule is now wrapped in =if [ "$install_device_udev_rules" = "true" ]=, fed by a new =INSTALL_DEVICE_UDEV_RULES= key (default yes, opt-out — still mainly a personal project). Added the var default, the config read, a =validate_config= check, and an =archsetup.conf.example= entry. Verified: default/yes writes the rule, no skips it, bogus is rejected; =bash -n= clean. @@ -1455,13 +1521,27 @@ Specced 2026-07-10 after discussion with Craig, and the design grew past the ori Parent spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] (IMPLEMENTED). This is a v1 gap found after the fact, not a phase of it. -** TODO [#C] WireGuard import is now config-less — decide feature fate :feature:network: -scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext). Decide: remove the import feature entirely, or keep it and document dropping plaintext configs into the dir out-of-band at install time (they stay gitignored). If kept, confirm the script no-ops gracefully when the dir has no *.conf. +** DONE [#C] WireGuard import is now config-less — decide feature fate :feature:network: +CLOSED: [2026-07-21 Tue] +Decided 2026-07-21 (Craig): KEEP the import feature. The out-of-band flow is already in place — =assets/wireguard-config/= carries a README documenting "drop plaintext =*.conf= locally at install time (gitignored); ship encrypted =*.conf.gpg= to track", and its =.gitignore= enforces it (=*.conf= blocked, =!*.conf.gpg= allowed). The script also already no-ops gracefully on an empty dir (=shopt -s nullglob= + a =found= flag), so nothing ships and nothing errors when no configs are present. Nothing to build; the fate decision was the whole task. +scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext). ** TODO [#C] Waybar modules run together — need subtle separators :bug:dotfiles:waybar: Craig misreads where one module ends and the next begins — the wind (weather) value runs straight into the date with no visual stop, so he reads the wind figure as the start of the date. Add a light, subtle separator or spacing between adjacent Waybar modules. Grading: Minor severity (legibility, nothing broken) x frequent (every glance at the bar) = P3 = [#C]. Not fully :solo: — needs Craig's eye on the result (separator style is a taste call, plus a live visual check). Prior work added a date-facing divider (dotfiles 103cccb); evidently not enough, so revisit the whole inter-module treatment rather than just the weather/date seam. From .emacs.d handoff 2026-07-20-1114 (roam capture; waybar is archsetup-owned per the dotfiles standing rule). -** TODO [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar: +** TODO [#C] Weather chip color signals unclear + unenforced :bug:dotfiles:waybar:weather: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-21 +:END: +From the roam inbox (2026-07-20): the shipped Waybar weather chip's comfort coloring reads as noise — it shows amber for no clear reason, and some items are bolded, which isn't a legible signal. Craig's intended scheme (every item except the arrow key colored by whether the weather is comfortable; NO bold or italic anywhere): +- Normal — all text white: temp in 60-85; condition sunny/clear/etc. +- Poor conditions — colored glyph: rain = blue glyph; snow = bright-blue glyph; fog = silver glyph. +- Bad conditions — red number: temp over 90 → the temp number is red; wind over a comfort level → the wind glyph displays and its number is red. +Open design question (Craig): which other conditions/combinations to signal, and how a forecasted severe event (e.g. an upcoming hurricane) should display. +Grading: Minor severity (legibility on a shipped chip, nothing broken) × frequent (every glance at the bar) = P3 = [#C]. Mostly buildable from the rules above; the severe-event display needs Craig's call. Waybar is archsetup-owned per the dotfiles standing rule. +** DONE [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar: +CLOSED: [2026-07-21 Tue] +Fixed in dotfiles 3e4e7ff (2026-07-20, "fix(theme): sync dupre waybar.css with the live weather rules") — dupre/waybar.css is byte-identical to live again, restoring the =#custom-weather= selectors/hover/gold divider, so =tests/theme-css= is green. Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit. The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run. @@ -1494,13 +1574,15 @@ Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, wait Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression. -Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns). Both were the session's first probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). +Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns); 2026-07-21 double-speedrun run (flashed one RED mid-run, passed on rerun). All were a session's first/early probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). ** TODO [#C] Maint live-refresh hairline replacement :feature:maint: :PROPERTIES: :LAST_REVIEWED: 2026-07-14 :END: From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline. +*** 2026-07-21 Tue @ 08:35:00 -0500 Decided (Craig): silent-until-stale age caption +Replace the draining 2px hairline with an age caption that shows ONLY when refresh is overdue (e.g. "3s", "8s" once past the expected window) and shows nothing while the board is healthy. This keeps the stale-vs-frozen signal — a frozen board surfaces a growing age number, a live one stays clean — while removing the constant motion the hairline created. Implementation (dotfiles, archsetup-owned): drop =gui._hair_tick= / the hairline draw, add an overdue-age caption driven off =viewmodel.refresh_fraction= (or the last-refresh timestamp) rendered only past the live window. Now unblocked; needs a live visual check on the panel after. ** TODO [#C] Net panel speedtest history :feature:dotfiles:network: :PROPERTIES: @@ -1526,9 +1608,10 @@ Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow fo The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused). From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output). -Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a decision and a paused-machine check): -- Waypaper canonical decision (Craig): live velox has =dark-lion.jpg=, repo has =that-one-up-there.jpg= (placeholder?). Decide the canonical, then =make reset= clears it. -- Ratio check: whether =~/.config/calibre/*.json= on ratio are symlinks or real files — same first-launch gap likely if calibre ever launched there before stow. On the ratio trip list. +Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a paused-machine check): +- Waypaper canonical decision (Craig): RESOLVED 2026-07-14 — dark-lion.jpg is canonical (dotfiles fea3e93), repo config.ini updated off the that-one-up-there.jpg placeholder. +- Ratio check: RESOLVED 2026-07-14 — ratio's =~/.config/calibre= is a directory symlink into the repo (stow folded the dir), so the first-launch gap never existed there. +- When velox is back online: run =make conflicts= / =make reset= there so its old conflict copy clears against the updated repo. (velox carries a separate boot-recovery task; check once it's reachable.) ** TODO [#C] Waybar collapse control: replace the triangle glyph :feature:waybar: :PROPERTIES: @@ -1549,6 +1632,8 @@ Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question Push-to-talk dictation that types transcribed speech into the focused Wayland window — usable at any text field, including the Claude Code terminal prompt and Emacs buffers. Claude Code has no built-in voice input; dictation has to happen at the OS level and inject text. Raised 2026-07-03. Tool choice is the open decision (needs Craig): =nerd-dictation= (Vosk, lighter, lower accuracy) vs a =whisper.cpp=-based daemon (heavier, higher accuracy, optional GPU). Wayland typing backend is =wtype= or =ydotool=. Scope once chosen: install + model download, a push-to-talk keybind (Hyprland), and an autostart entry; fold into archsetup so it lands on both daily drivers. Consider an Emacs-native path (=whisper.el=) as a complement for in-buffer dictation. +*** 2026-07-21 Tue @ 08:40:00 -0500 Decided (Craig): whisper.cpp + wtype, system-wide +STT engine = =whisper.cpp= (accurate offline, optional GPU on ratio's Radeon). Typing backend = =wtype= (Wayland-native virtual-keyboard injection into the focused window, no root/daemon), with =ydotool= (uinput) held as a fallback only if a specific app — some XWayland/Electron surface — won't accept wtype's synthetic input. One system-wide path that also covers Emacs buffers and the Claude Code prompt; the Emacs-native =whisper.el= route was NOT chosen. Build scope: whisper.cpp + a model (start with a mid-size English model, tune later), a Hyprland push-to-talk keybind driving a record→transcribe→wtype pipeline, and an autostart/service entry, folded into archsetup so it lands on ratio + velox. Now unblocked (agent-buildable; verification includes a live dictation check). ** TODO [#C] Fix install errors surfaced by the 2026-05-11 VM test run :PROPERTIES: @@ -1580,6 +1665,8 @@ Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; t ** TODO [#C] Osbot camera configuration :chore: Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned device setup: "configure osbot camera." Scope to define at pickup (device model, what "configure" covers — kernel module, v4l settings, default framing). +*** 2026-07-21 Tue @ 08:10:00 -0500 Scoped (Craig): tiny — it works, just needs configuring via a panel +Craig: the camera works (bought for being Linux-friendly), it just needs configuring. There IS a config panel — =cameractrls= 0.6.10 is installed (a GTK GUI for camera controls: exposure, white balance, PTZ, focus, framing) plus =v4l-utils= for the CLI path. Caveat found 2026-07-21: no =/dev/video*= device is present right now, so the camera isn't currently plugged in / its UVC node isn't enumerated. Task: with the camera connected, confirm it enumerates as a /dev/video node, then set defaults in cameractrls. Small, mostly a live-hardware step. ** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :chore:solo: :PROPERTIES: :LAST_REVIEWED: 2026-07-09 @@ -1610,6 +1697,8 @@ error-prone — changes must be made in both places. Consider: - Or symlinking the stowed config to a theme-managed location - Same situation applies to fuzzel.ini The goal is a single place to edit each config, not two. +*** 2026-07-21 Tue @ 08:00:00 -0500 Audit reconcile: single-theme now, so the switch-revert pressure is lower +The theme system went single-theme — Hudson was retired (dotfiles e03436e; documented archsetup 98142bb, 2026-07-18), leaving Dupre as the only theme. So "edits get overwritten on theme switch" now bites only rarely (switches are effectively nonexistent). The structural two-places-to-edit problem still stands and is worth fixing, but the urgency the original grading implied has dropped. ** TODO [#C] Review current tool pain points annually :PROPERTIES: @@ -1621,6 +1710,8 @@ Once-yearly systematic inventory of known deficiencies and friction points in cu Grading: no behavior change; parking lot. 10 refactors remain from the sentry audit — duplicated GPU-modalias scan, triple hand-rolled retry loop, stow x4, display_server/window_manager dispatch dup, Maia ELO range x3, per-script log helpers, GRUB/snapper/fsck sed clusters, waybar-battery positional sed. Full list with line numbers in [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (High/Medium/Low tagged). Pull individual ones out as their own tasks when tackled. The system-mutation sed clusters (snapper/fsck/GRUB/waybar) want characterization coverage before any rewrite. *** 2026-07-20 Mon @ 16:35:00 -0500 Extracted validate_yesno and the NVIDIA_MIN_DRIVER constant In 67d0b6e: the four yes/no config-validation blocks collapse into validate_yesno (TDD), and the driver-floor literal 535 becomes NVIDIA_MIN_DRIVER. The safe, purely-testable slice; the remaining 10 (structural / system-mutation) stay parked above. +*** 2026-07-21 Tue @ 08:00:00 -0500 Audit reconcile: the GRUB cmdline sed slice shipped +f9da097 (2026-07-21) rewrote the boot-critical =GRUB_CMDLINE_LINUX_DEFAULT= sed into a guarded awk+mv merge with a backup and added characterization tests (tests/installer-steps/test_grub_cmdline.py). So the "GRUB sed clusters want characterization coverage before rewrite" caveat no longer covers the cmdline line — only the 4 cosmetic GRUB sed lines (timeout/default/terminal/gfxmode, which overwrite fixed values with nothing to preserve) remain in that slice. ** TODO [#D] Test-framework + prototype refactor cluster :refactor: Grading: no behavior change; parking lot. Refactors from the S5-S7 audit, distinct from the installer refactor rollup above. scripts/testing/run-test.sh + run-test-baremetal.sh duplicate the run/poll/report skeleton and have drifted (VM uses setsid + copy helpers, baremetal uses nohup + hand-rolled sshpass scp) — extract the shared core so baremetal inherits the sturdier paths; run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 duplicate the transport-independent _scenario_var/_validate_scenario/run_scenario (a sourced lib/maint-scenario.sh); run-test.sh:251,265 uses two different mechanisms (pgrep vs ps|grep) for the same liveness check; docs/prototypes/gen_tokens.py:78 repeats the section-iteration skeleton across four emitters; gallery-widget.el:95,136 hardcodes SVG arc/hub path strings that duplicate the cx/cy/radius geometry (dial desyncs silently on a constant change); gallery-widget.el:72,84 leans on the private svg--append. See findings doc (S5, S6, S7). @@ -1631,7 +1722,7 @@ Deferred from the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][ne Deferred from the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion spec]] (IMPLEMENTED, v1 shipped): the stale-bond re-pair-offer signature (v1 keeps re-pair strictly user-initiated; the signature must be designed and validated before the doctor ever offers it); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict; the bt-audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume) — wants coordination with the audio doctor so the two panels don't claim the same A2DP/HFP diagnosis with divergent verdicts. ** TODO [#D] Widget catalogue vNext :feature:design: -Deferred from the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DRAFT): framework wrappers for the web library (React or otherwise — demand-gated, none until a real framework consumer exists); Level-2/3 widget codegen if the spec's Phase 5 decision point says go (a go spawns its own spec); ports beyond the demand matrix as new consumers appear. +Deferred from the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DOING): framework wrappers for the web library (React or otherwise — demand-gated, none until a real framework consumer exists); Level-2/3 widget codegen if the spec's Phase 5 decision point says go (a go spawns its own spec); ports beyond the demand matrix as new consumers appear. ** TODO [#D] Maintenance console vNext :feature: Deferred from the [[file:docs/specs/2026-07-07-maintenance-console-spec.org][spec]] (v1 ships determinate remedies only): AI/workflow assistance on read-only metrics (the vLater tier — failed-unit diagnosis, journal-error fix suggestions, OOM investigation); SIGKILL escalation for TERM-survivors on the KILL lever; live streaming meters on evidence rows where a count could be a level. @@ -1670,11 +1761,18 @@ Parse yay errors and provide specific, actionable fixes instead of generic error ** TODO [#D] Improve progress indicators throughout install Enhance existing indicators to show what's happening in real-time -** TODO [#D] Telega coredump recurrence tell :bug:maint: +** TODO [#C] Telega coredump recurrence tell :bug:maint: :PROPERTIES: -:LAST_REVIEWED: 2026-07-09 +:LAST_REVIEWED: 2026-07-21 :END: +*** 2026-07-21 Tue @ 08:10:00 -0500 Re-graded D → C (Craig): treat as the actionable version-skew recurrence +Craig's call: the fired tell counts as the version-skew recurrence this task predicts (zevlg =:latest= outran the installed elisp again), not just benign host noise — so it bumps [#D] → [#C]. Action: re-pin / upgrade the TDLib side (upgrade the elisp telega package on ratio+velox, or move to a host-native pinned TDLib build) so the server's plist parser and the installed elisp agree again. The durable escape remains the host-native pinned TDLib build. + +*** 2026-07-21 Tue @ 08:00:00 -0500 Audit reconcile: the tell FIRED — coredumps recurred +The named tell has tripped. =~/.telega/telega-server.log:11413= now carries a fresh =Assertion failed: false (telega-dat.c: tdat_plist_value: 500)=, and =coredumpctl= shows telega-server SIGSEGV recurring on ratio: a burst of 8 on 2026-07-15, then 07-16, 07-18, 07-19, newest 2026-07-20 23:41. The 2026-07-09 "nothing recurred" verdict below is superseded. +Open question for Craig (re-grade): is this the version-skew recurrence this task predicts (zevlg =:latest= outran the installed elisp again — actionable: re-pin/upgrade TDLib) or the separately-known benign dockerized-musl SIGSEGV class (the health-check known-issues entry, cosmetic host-coredump noise)? The answer decides whether this bumps back [#D] → [#C]. Note the health-check on 2026-07-21 classified this boot's telega coredumps as KNOWN musl-build noise, which argues against a version-skew regression — but the fresh tdat_plist_value assertion is the skew signature, so the two need reconciling. + *** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: the fix is holding; re-graded C → D Verified rather than assumed: =~/.telega/telega-server.log= carries zero =tdat_plist_value= assertions, and the newest telega-server coredump is 2026-07-08 16:57 — before .emacs.d upgraded the elisp. Nothing has recurred. |
