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 /docs/prototypes | |
| 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.
Diffstat (limited to 'docs/prototypes')
44 files changed, 13928 insertions, 7 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); + }, + }; + }; +})(); |
