aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/design/2026-07-02-timer-panel-spec.org113
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-1.html693
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-2.html553
-rw-r--r--docs/prototypes/2026-07-02-timer-panel-prototype-3.html556
-rw-r--r--docs/prototypes/2026-07-03-instrument-console-panels-prototype.html1359
-rw-r--r--docs/prototypes/2026-07-03-net-panel-rescan-prototype.html251
-rw-r--r--docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html338
-rw-r--r--docs/prototypes/2026-07-03-sound-panel-prototype.html417
-rw-r--r--docs/prototypes/2026-07-03-waybar-redesign-prototype.html321
-rw-r--r--docs/prototypes/README.org21
-rw-r--r--docs/specs/2026-07-02-bluetooth-panel-spec.org (renamed from docs/design/2026-07-02-bluetooth-panel-spec.org)6
-rw-r--r--docs/specs/2026-07-02-desktop-settings-panel-spec.org (renamed from docs/design/2026-07-02-desktop-settings-panel-spec.org)81
-rw-r--r--docs/specs/2026-07-02-file-manager-swallow-spec.org (renamed from docs/design/2026-07-02-file-manager-swallow-spec.org)6
-rw-r--r--docs/specs/2026-07-02-net-panel-other-interfaces-spec.org (renamed from docs/design/2026-07-02-net-panel-other-interfaces-spec.org)6
-rw-r--r--docs/specs/2026-07-02-timer-panel-spec.org221
-rw-r--r--docs/specs/2026-07-03-audio-panel-spec.org166
-rw-r--r--docs/specs/2026-07-03-instrument-console-panels-spec.org (renamed from docs/design/2026-07-03-instrument-console-panels-spec.org)18
17 files changed, 4988 insertions, 138 deletions
diff --git a/docs/design/2026-07-02-timer-panel-spec.org b/docs/design/2026-07-02-timer-panel-spec.org
deleted file mode 100644
index 2c9f7d4..0000000
--- a/docs/design/2026-07-02-timer-panel-spec.org
+++ /dev/null
@@ -1,113 +0,0 @@
-#+TITLE: Timer GTK Panel
-#+AUTHOR: Craig Jennings
-#+DATE: 2026-07-02
-#+TODO: TODO | DONE
-#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
-
-* DRAFT Status
-:PROPERTIES:
-:ID: 1770af2e-b093-4024-a512-ae4324a2869f
-:END:
-- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture "give the
- timer a gtk UI/UX like the network panel. spec this out."
-
-* Metadata
-
-| Field | Value |
-|--------+---------------------------------------------------|
-| Status | draft |
-|--------+---------------------------------------------------|
-| Owner | Craig Jennings |
-|--------+---------------------------------------------------|
-| Repo | dotfiles |
-|--------+---------------------------------------------------|
-| Kin | net panel (architecture donor), wtimer (backing), |
-| | desktop-settings panel spec (sibling) |
-|--------+---------------------------------------------------|
-
-* Problem
-
-The timer's whole UI is a chain of three fuzzel prompts (type, value, label)
-plus a fourth for cancel. That flow can't show what's already running while
-you create, can't offer one-tap presets, gives no feedback on a typo until
-the add silently fails, and pomodoro state (phase, cycle) is only visible in
-a tooltip. The 2026-07-02 styling pass made the dialogs presentable, but the
-shape is still four blind modals for what is really one small control
-surface.
-
-* Goals
-
-1. One panel, opened from the bar's timer module, that shows everything
- running (live countdowns, pomodoro phase/cycle, paused state) and creates
- new items without leaving it.
-2. One-tap presets for the common cases (tea, pomodoro, quick alarm) next to
- freeform entry, with inline validation before the add.
-3. Per-item controls: pause/resume, cancel, promote to primary (the bar
- glyph slot).
-4. wtimer stays the single owner of timer state and the notification path;
- the panel is a view over it, never a second engine.
-
-* Design sketch
-
-** Architecture — clone the net panel's proven stack
-
-- GTK4 + gtk4-layer-shell dropdown anchored under the timer module, Blueprint
- .blp compiled to committed .ui (=make ui=; compiler is dev-only).
-- Humble-object split: GTK-free PanelModel presenter, unit-tested to 100%,
- with thin widget bindings; one gated AT-SPI smoke via the
- run-panel-smoke.sh pattern.
-- Backing: shell out to the existing wtimer CLI (=add=, =toggle=, =cancel=,
- =cycle=, =render=). =render= already emits a JSON payload; the panel polls
- it (or subscribes to the same RTMIN+14 refresh signal) for live state.
- wtimer's 89-case suite keeps owning the logic; panel tests fake the CLI
- like every dotfiles suite fakes binaries.
-- Dupre WIP palette CSS shared with the net panel (same factoring the
- desktop-settings spec calls for — one palette asset, three panels).
-
-** Layout sketch
-
-- Header row: running-item count + a Clear All button (maps to cancel-all).
-- Item list: one row per item — type glyph, label, live countdown / clock
- time / phase+cycle for pomodoro, pause and cancel buttons, click-to-promote.
-- Create strip: four type buttons (the wtimer glyphs), preset chips per type
- (e.g. 5m / 15m / 25m / 60m for timers), a freeform entry validated with
- wtimer's own parsers, an optional label field.
-- Empty state: the create strip alone, centered.
-
-** What happens to the fuzzel flow
-
-The keybind/fuzzel path stays as the keyboard-fast lane (it's now styled and
-tested); the panel replaces the click-driven path on the bar module. Whether
-the fuzzel chain eventually retires is a decision below.
-
-* Decisions (Craig)
-
-** TODO Panel scope: standalone timer panel, or a page in the desktop-settings panel?
-The desktop-settings spec (sibling DRAFT) could host timers as a page.
-Standalone matches the net panel's one-domain-one-panel shape and keeps the
-timer dropdown small; folding in means one panel binary fewer. Recommend
-standalone, sharing the palette/css asset.
-
-** TODO Fuzzel flow: keep as keyboard fast lane, or retire once the panel lands?
-Keeping both costs two creation paths to maintain (though the fuzzel chain is
-small and freshly tested). Recommend keep until the panel proves itself, then
-revisit.
-
-** TODO Presets: which chips per type?
-Strawman: timer 5m/15m/25m/60m; alarm +30m/top-of-hour/07:00; pomodoro
-default cycle only; stopwatch needs none. Adjust to taste.
-
-** TODO Live updates: poll render (1s, like the bar) or a wtimer "watch" mode?
-Polling reuses what exists and matches the bar's cadence; a watch/subscribe
-mode is cleaner but grows wtimer. Recommend polling first.
-
-* Implementation phases
-
-1. PanelModel presenter + CLI-backing seam (TDD, GTK-free, 100% like the net
- PanelModel).
-2. Blueprint UI: item list + create strip, wired to the presenter; palette
- css factored to the shared asset.
-3. Bar integration: timer module left-click opens the panel (replacing the
- fuzzel menu binding there), RTMIN+14 refresh keeps bar and panel in step.
-4. AT-SPI smoke + manual-testing checklist; decide the fuzzel flow's future
- after a week of real use.
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-1.html b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html
new file mode 100644
index 0000000..6b199f9
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html
@@ -0,0 +1,693 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — three redesigns · dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 6rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1320px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch}
+.masthead p b{color:var(--silver)}
+h2{color:var(--steel);font-size:.8rem;letter-spacing:.22em;text-transform:uppercase;
+ margin:2.6rem 0 .2rem;display:flex;align-items:center;gap:12px}
+h2 .tag{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.62rem;padding:1px 7px;letter-spacing:.12em}
+h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.blurb{color:var(--dim);font-size:.82rem;max-width:90ch;margin:.5rem 0 1.1rem}
+.blurb b{color:var(--steel);font-weight:400}
+.desk{display:flex;justify-content:center;padding:1.2rem 0 .4rem}
+
+/* ---------- faceplate ---------- */
+.panel{width:396px;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px 15px 16px;position:relative;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.panel.wide{width:660px}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+
+/* ---------- shared primitives (from the widget gallery) ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+.key.wide{width:100%;text-align:center;padding:9px}
+
+.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;
+ border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em}
+.chip:hover{color:var(--silver);border-color:var(--slate)}
+.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.chip .x{color:inherit;opacity:.5;margin-left:5px}
+.chip .x:hover{opacity:1;color:var(--fail)}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+.badge.dim{background:var(--wash);color:var(--steel)}
+
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;
+ padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.seg.vert{flex-direction:column}
+.seg.vert button{border-right:0;border-bottom:1px solid #33302b;padding:8px 10px}
+.seg.vert button:last-child{border-bottom:0}
+
+.engrave{color:var(--steel);font-size:.6rem;letter-spacing:.28em;text-transform:uppercase;
+ display:flex;align-items:center;gap:9px;margin:2px 0}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:10px}
+.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+
+.readout{color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em}
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;
+ padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}
+.tin::placeholder{color:var(--dim)}
+.tin.bad{border-color:var(--fail);color:var(--fail)}
+
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+
+/* radial ring */
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+
+/* linear bar */
+.bar{height:8px;background:#0d0f10;border:1px solid #231f18;border-radius:5px;overflow:hidden;position:relative}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));transition:width .25s linear}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+
+/* create strip common */
+.create{margin-top:13px;background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:8px;flex-wrap:wrap}
+.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
+
+/* toast */
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;
+ box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}
+.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+/* empty state */
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 6px 12px}
+
+/* =============== A · RACK UNIT =============== */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)}
+.qrow.fire{animation:firef .6s ease-in-out 3}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:1px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.16em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:19px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .pomo{font-size:.56rem;color:var(--steel);letter-spacing:.1em;text-transform:uppercase}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+
+/* =============== B · TRANSPORT DECK =============== */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center}
+.hero.fire{animation:firef .6s ease-in-out 3}
+.hero .lhs{flex:none}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px}
+.hero .htype{display:flex;align-items:center;gap:8px}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:40px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.02em}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.06em}
+.transport{display:flex;gap:7px;margin-top:2px}
+.tracks{margin-top:11px;display:flex;flex-direction:column;gap:5px}
+.track{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;border:1px solid #1c1a16;cursor:pointer;font-size:12px}
+.track:hover{background:var(--wash)}
+.track.prim{outline:1px solid var(--gold);outline-offset:-1px}
+.track .g{color:var(--gold);font-size:14px;width:16px;text-align:center}
+.track b{color:var(--cream);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.track .trd{margin-left:auto;color:var(--silver);font-variant-numeric:tabular-nums;font-weight:700}
+.track.paused .trd{color:var(--dim)}
+.track .tx{color:var(--dim);font-size:14px;padding:0 2px}
+.track .tx:hover{color:var(--fail)}
+
+/* =============== C · CHANNEL STRIP BOARD =============== */
+.board{display:flex;gap:9px;overflow-x:auto;padding:4px 2px 10px}
+.strip{flex:none;width:96px;background:#141210;border:1px solid #201d17;border-radius:10px;padding:9px 8px;
+ display:flex;flex-direction:column;align-items:center;gap:8px}
+.strip.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)}
+.strip.fire{animation:firef .6s ease-in-out 3}
+.strip .stitle{width:100%;display:flex;align-items:center;gap:5px;cursor:pointer}
+.strip .stitle .g{color:var(--gold);font-size:13px}
+.strip .stitle b{color:var(--cream);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.strip .styp{color:var(--dim);font-size:.5rem;letter-spacing:.14em;text-transform:uppercase;width:100%;text-align:left}
+.column{width:26px;height:120px;position:relative;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden}
+.column .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold));transition:height .25s linear}
+.column.warn .fill{background:linear-gradient(0deg,#a35a3f,var(--fail))}
+.column .cap{position:absolute;left:-2px;right:-2px;height:3px;background:var(--gold-hi);box-shadow:0 0 5px rgba(255,215,95,.6);transition:bottom .25s linear}
+.column.sw .fill{background:linear-gradient(0deg,#3a4a5e,var(--slate-hi));animation:swpulse 1.6s ease-in-out infinite}
+@keyframes swpulse{50%{opacity:.6}}
+.strip .srd{color:var(--cream);font-size:14px;font-weight:700;font-variant-numeric:tabular-nums}
+.strip.paused .srd{color:var(--steel)}
+.strip .skeys{display:flex;gap:4px}
+.strip.addstrip{justify-content:flex-start;width:150px;background:var(--well)}
+
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · timer</div>
+ <h1>Timer panel — three redesigns</h1>
+ <p>Three ways to shape the timer dropdown, all in the shipped instrument-console faceplate language
+ (same tokens, lamps, console keys, engraved labels, and tabular readouts as the net / bt / sound panels).
+ Each is a <b>working prototype over one shared engine</b> that mirrors wtimer + the PanelModel: add / cancel / pause /
+ resume, promote to the bar slot, per-type presets (add and delete chips), freeform entry with the same validation,
+ stopwatch lap + stop-and-save, the soonest-fire queue sort, the 10-item cap, and a real completion + notify on fire.
+ Try each: add a timer, watch one count down and fire, promote a row, pause a stopwatch, delete a preset chip.</p>
+</header>
+
+<h2><span class="tag">A</span> Rack unit — the faithful list</h2>
+<p class="blurb">The closest sibling to the net / audio panels: a vertical stack you scan top-down. Header with the live
+ count and <b>CLEAR ALL</b>; one output-well row per item, soonest-firing on top; each row carries a lamp, glyph, label,
+ the big countdown, and inline pause / promote / cancel keys. Create strip lives at the bottom — pick a type, tap a preset
+ or type a duration, name it, ADD. Safest port of what already shipped.</p>
+<div class="desk"><div class="panel" id="panelA"></div></div>
+
+<h2><span class="tag">B</span> Transport deck — one hero, a track list</h2>
+<p class="blurb">A cassette-transport shape. The <b>primary</b> item (the one in the bar glyph slot) gets a hero readout with a
+ progress ring and chunky transport keys; everything else is a compact track list underneath. Click a track to promote it into
+ the hero seat; the ‹ › keys cycle the primary. Puts the timer you care about front-and-centre, the rest one glance away.</p>
+<div class="desk"><div class="panel" id="panelB"></div></div>
+
+<h2><span class="tag">C</span> Channel-strip board — a mixing desk of timers</h2>
+<p class="blurb">The mixing-console metaphor: every item is a vertical channel strip on a board, its fader draining from the top
+ as time runs out (a stopwatch fills instead, tinted slate). Read all your timers at once like meters on a desk. Click a strip
+ header to promote it; the trailing <b>+ NEW</b> strip is the create surface. The most spatial, most stereo of the three.</p>
+<div class="desk"><div class="panel wide" id="panelC"></div></div>
+
+</div>
+
+<script>
+"use strict";
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el = (tag, cls, html) => { const n=document.createElement(tag); if(cls)n.className=cls; if(html!=null)n.innerHTML=html; return n; };
+
+/* nerd-font glyphs (mirrors timer/viewmodel.py GLYPH) */
+const GL = {
+ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}'
+};
+
+/* ---------- parsers (mirror parse.py behaviour) ---------- */
+function parseDuration(v){
+ if(v==null) return null;
+ v=String(v).trim().toLowerCase();
+ if(v==='') return null;
+ if(/^\d+$/.test(v)) return parseInt(v,10)*60; // bare number = minutes
+ if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; // only h/m/s tokens
+ let m, tot=0; const re=/(\d+)\s*([hms])/g;
+ while((m=re.exec(v))) tot += m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1];
+ return tot>0?tot:null;
+}
+function resolveAlarm(v, now){
+ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){ const s=parseDuration(v.slice(1)); return s==null?null:now+s; }
+ if(v==='@hour'||v==='top of hour'){ const d=new Date(now*1000); d.setMinutes(0,0,0); d.setHours(d.getHours()+1); return d.getTime()/1000; }
+ const t=v.match(/^(\d{1,2}):(\d{2})$/);
+ if(t){ const hh=+t[1], mm=+t[2]; if(hh>23||mm>59) return null;
+ const d=new Date(now*1000); d.setHours(hh,mm,0,0); let e=d.getTime()/1000; if(e<=now) e+=86400; return e; }
+ return null;
+}
+function fmtTime(secs){
+ secs=Math.max(0,Math.floor(secs));
+ const h=Math.floor(secs/3600), m=Math.floor((secs%3600)/60), s=secs%60;
+ return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`;
+}
+function fmtClock(epoch){ const d=new Date(epoch*1000); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; }
+
+/* ---------- default presets (mirror panel._default_presets) ---------- */
+const DEFAULT_PRESETS = () => ({
+ timer:[{label:'5m',value:'5m'},{label:'25m',value:'25m'},{label:'10m',value:'10m'},
+ {label:'15m',value:'15m'},{label:'30m',value:'30m'},{label:'60m',value:'60m'},{label:'2h',value:'2h'}],
+ alarm:[{label:'+30m',value:'+30m'},{label:'top of hour',value:'@hour'},{label:'07:00',value:'07:00'}],
+ pomodoro:[{label:'default cycle',value:''}],
+ stopwatch:[]
+});
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+const POMO={work:25*60, short:5*60, long:15*60, interval:4};
+
+/* ---------- the engine (mirrors PanelModel + wtimer state) ---------- */
+class Engine{
+ constructor(){ this.items=[]; this.seq=0; this.primary=null; this.presets=DEFAULT_PRESETS(); this.onEvent=()=>{}; }
+ now(){ return Date.now()/1000; }
+ count(){ return this.items.length; }
+ full(){ return this.items.length>=MAX; }
+
+ add(type,value,label){
+ if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now(); this.seq++; const id='t'+this.seq;
+ const it={id,type,label:label||''};
+ if(type==='timer'){ const s=parseDuration(value); if(s==null) return {ok:false,reason:`bad duration: “${value}”`}; it.target=now+s; it.total=s; }
+ else if(type==='alarm'){ const e=resolveAlarm(value,now); if(e==null) return {ok:false,reason:`bad time: “${value}”`}; it.target=e; it.total=Math.max(1,e-now); }
+ else if(type==='pomodoro'){ it.phase='work'; it.cycle=1; it.interval=POMO.interval; it.target=now+POMO.work; it.total=POMO.work; }
+ else if(type==='stopwatch'){ it.start=now; it.laps=[]; }
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it); if(!this.primary) this.primary=id;
+ return {ok:true, id};
+ }
+ find(id){ return this.items.find(i=>i.id===id); }
+ isPaused(it){ return it.type==='stopwatch' ? it.paused_elapsed!=null : it.paused_left!=null; }
+ remaining(it,ref){
+ ref=ref==null?this.now():ref;
+ if(it.type==='stopwatch') return this.isPaused(it)? it.paused_elapsed : ref-it.start;
+ return this.isPaused(it)? it.paused_left : it.target-ref;
+ }
+ toggle(id){ const it=this.find(id); if(!it) return; const now=this.now();
+ if(it.type==='stopwatch'){
+ if(this.isPaused(it)){ it.start=now-it.paused_elapsed; it.paused_elapsed=null; }
+ else it.paused_elapsed=now-it.start;
+ } else {
+ if(this.isPaused(it)){ it.target=now+it.paused_left; it.paused_left=null; }
+ else it.paused_left=it.target-now;
+ }
+ }
+ cancel(id){ const i=this.items.findIndex(x=>x.id===id); if(i<0) return; this.items.splice(i,1);
+ if(this.primary===id) this.primary=null; }
+ cancelAll(){ this.items=[]; this.primary=null; }
+ promote(id){ if(this.find(id)) this.primary=id; }
+ cycle(dir){ const ids=this.items.map(i=>i.id); if(!ids.length) return;
+ let cur=ids.indexOf(this.effectivePrimary()); cur=cur<0?0:cur;
+ const n=dir==='prev' ? (cur-1+ids.length)%ids.length : (cur+1)%ids.length;
+ this.primary=ids[n]; }
+ lap(id,name){ const it=this.find(id); if(!it||it.type!=='stopwatch') return;
+ it.laps.push({t:this.remaining(it), name:name||''}); }
+ stopSave(id){ const it=this.find(id); if(!it||it.type!=='stopwatch') return null;
+ const run={label:it.label||'run', total:this.remaining(it), laps:it.laps.slice()}; this.cancel(id); return run; }
+
+ effectivePrimary(){
+ const items=this.items; if(!items.length) return null;
+ const ids=items.map(i=>i.id);
+ if(ids.includes(this.primary)) return this.primary;
+ const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length) return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));
+ if(asw.length) return asw[0].id;
+ return ids[0];
+ }
+ /* 4-bucket sort: active countdown < paused countdown < active sw < paused sw */
+ sortKey(it){ const now=this.now(), paused=this.isPaused(it), sw=it.type==='stopwatch', rem=this.remaining(it,now);
+ return sw ? [paused?3:2, -rem, +it.id.slice(1)] : [paused?1:0, rem, +it.id.slice(1)]; }
+ rows(){ const prim=this.effectivePrimary(); const now=this.now();
+ return this.items.slice().sort((a,b)=>{ const ka=this.sortKey(a),kb=this.sortKey(b);
+ for(let i=0;i<ka.length;i++){ if(ka[i]<kb[i])return -1; if(ka[i]>kb[i])return 1; } return 0; })
+ .map(it=>this.row(it,prim,now)); }
+ row(it,prim,now){
+ const rem=this.remaining(it,now), paused=this.isPaused(it);
+ let disp, sub='', warn=false, prog=null, glyph;
+ if(it.type==='alarm'){ disp=fmtClock(it.target); sub='at '+fmtClock(it.target); prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.alarm; }
+ else if(it.type==='pomodoro'){ disp=fmtTime(rem); sub=`${it.phase} · cycle ${it.cycle}/${it.interval}`; prog=Math.max(0,Math.min(1,rem/it.total));
+ glyph=(it.phase==='work')?GL.pomo_work:GL.pomo_break; }
+ else if(it.type==='stopwatch'){ disp=fmtTime(rem); sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running'; glyph=GL.stopwatch; }
+ else { disp=fmtTime(rem); sub='timer'; prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.timer; }
+ if(prog!=null && rem<=Math.min(30, it.total*0.15)) warn=true;
+ if(paused) glyph=GL.paused;
+ return {id:it.id, type:it.type, glyph, label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type, disp, sub, paused, primary:it.id===prim, prog, warn, laps:it.laps?it.laps.length:0};
+ }
+
+ /* advance fired items; returns list of fire events for the view to flash/notify */
+ tick(){
+ const now=this.now(); const fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type) || this.isPaused(it)) continue;
+ if(it.target-now>0) continue;
+ if(it.type==='pomodoro'){
+ if(it.phase==='work'){
+ const isLong = it.cycle % it.interval === 0;
+ fired.push({id:it.id, kind:'pomo', title:`Pomodoro · ${isLong?'long':'short'} break`, body:it.label||`cycle ${it.cycle}`});
+ it.phase = isLong?'long':'short'; const len = isLong?POMO.long:POMO.short; it.target=now+len; it.total=len;
+ } else if(it.phase==='long'){
+ fired.push({id:it.id, kind:'done', title:'Pomodoro complete', body:it.label||`${it.interval} cycles done`});
+ this.cancel(it.id);
+ } else { // short break over → next work
+ it.cycle+=1; fired.push({id:it.id, kind:'pomo', title:'Pomodoro · back to work', body:it.label||`cycle ${it.cycle}`});
+ it.phase='work'; it.target=now+POMO.work; it.total=POMO.work;
+ }
+ } else {
+ fired.push({id:it.id, kind:'done', title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label|| (it.type==='alarm'?fmtClock(it.target):'done')), body:'time’s up'});
+ this.cancel(it.id);
+ }
+ }
+ return fired;
+ }
+ /* presets */
+ presetsFor(t){ return (this.presets[t]||[]).map(p=>({...p})); }
+ addPreset(t,label,value){ if(!TYPES.includes(t)) return {ok:false,reason:'bad type'};
+ if(t==='timer' && parseDuration(value)==null) return {ok:false,reason:'bad duration'};
+ (this.presets[t]||(this.presets[t]=[])).push({label,value}); return {ok:true}; }
+ deletePreset(t,label){ const a=this.presets[t]||[]; const i=a.findIndex(p=>p.label===label); if(i<0) return {ok:false}; a.splice(i,1); return {ok:true}; }
+}
+
+/* ---------- browser notification (best-effort, mirrors the notify path) ---------- */
+let notifPerm = (typeof Notification!=='undefined') ? Notification.permission : 'denied';
+function tryNotify(title, body){
+ if(typeof Notification==='undefined') return;
+ if(notifPerm==='granted'){ try{ new Notification(title,{body}); }catch(e){} }
+ else if(notifPerm==='default'){ Notification.requestPermission().then(p=>notifPerm=p); }
+}
+
+/* ---------- toast helper ---------- */
+function toaster(host){
+ const wrap=el('div','toasts'); host.appendChild(wrap);
+ return (msg,kind)=>{ const t=el('div','toast'+(kind?' '+kind:''),msg); wrap.appendChild(t);
+ setTimeout(()=>{ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(()=>t.remove(),300); }, 2600); };
+}
+
+/* ---------- create-strip controller (shared by all three views) ---------- */
+function makeCreate(engine, toast, rerender, opts){
+ opts=opts||{};
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{ const b=el('button',t==='timer'?'on':'', t[0].toUpperCase()+t.slice(1)); b.dataset.t=t; seg.appendChild(b); });
+ const chips=el('div','chips');
+ const row=el('div','row');
+ const val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2';
+ const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2';
+ const addk=el('button','key on', GL.add+' ADD'); addk.style.flex='1';
+ row.append(val,lab,addk);
+ box.append(seg,chips,row);
+
+ let selType='timer';
+ function paintChips(){
+ chips.innerHTML='';
+ engine.presetsFor(selType).forEach(p=>{
+ const c=el('span','chip', p.label + (opts.editablePresets?` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`:''));
+ c.dataset.val=p.value; chips.appendChild(c);
+ });
+ if(opts.editablePresets){ const c=el('span','chip','+ chip'); c.dataset.newchip='1'; c.style.opacity='.7'; chips.appendChild(c); }
+ // type-specific value affordance
+ val.disabled = (selType==='stopwatch'||selType==='pomodoro');
+ val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour'
+ : selType==='stopwatch' ? 'no value — just ADD'
+ : selType==='pomodoro' ? 'default 25/5 cycle' : '5m · 1h30m · 90s';
+ if(val.disabled) val.value='';
+ }
+ seg.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return;
+ selType=b.dataset.t; [...seg.children].forEach(x=>x.classList.toggle('on',x===b)); val.classList.remove('bad'); paintChips(); });
+ chips.addEventListener('click', e=>{
+ const del=e.target.closest('[data-del]');
+ if(del){ engine.deletePreset(selType, decodeURIComponent(del.dataset.del)); paintChips(); toast('preset removed','gold'); return; }
+ if(e.target.closest('[data-newchip]')){
+ const lb=prompt('Chip label (e.g. 45m):'); if(!lb) return;
+ let vv=lb; if(selType==='timer'||selType==='alarm'){ vv=prompt('Value for “'+lb+'” (e.g. 45m):', lb)||lb; }
+ const r=engine.addPreset(selType, lb, vv); paintChips(); toast(r.ok?'preset added':('preset: '+r.reason), r.ok?'gold':'red'); return;
+ }
+ const c=e.target.closest('.chip'); if(!c||c.dataset.val==null) return;
+ val.classList.remove('bad'); val.value=c.dataset.val;
+ doAdd();
+ });
+ function doAdd(){
+ const r=engine.add(selType, val.value, lab.value.trim());
+ if(!r.ok){ val.classList.add('bad'); toast(r.reason,'red'); return; }
+ val.classList.remove('bad'); if(!val.disabled) val.value=''; lab.value='';
+ toast('added '+selType, 'gold'); rerender();
+ }
+ addk.addEventListener('click', doAdd);
+ val.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); });
+ lab.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); });
+ paintChips();
+ return box;
+}
+
+/* =================================================================== */
+/* VIEW A — RACK UNIT */
+/* =================================================================== */
+function mountRack(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm', GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ head.appendChild(clear);
+ clear.addEventListener('click', ()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ const list=el('div','qlist');
+ host.append(head,list);
+ const create=makeCreate(engine, toast, ()=>render(), {editablePresets:true});
+ host.appendChild(create);
+
+ const flashing=new Set();
+ list.addEventListener('click', e=>{
+ const b=e.target.closest('[data-act]'); if(!b) return;
+ const id=b.dataset.id, act=b.dataset.act;
+ if(act==='toggle') engine.toggle(id);
+ else if(act==='promote') engine.promote(id);
+ else if(act==='cancel'){
+ if(b.dataset.armed){ engine.cancel(id); toast('cancelled'); }
+ else { b.dataset.armed='1'; b.classList.add('armed'); b.textContent='sure?'; setTimeout(()=>{ if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;} },2000); return; }
+ }
+ else if(act==='lap'){ engine.lap(id); toast('lap recorded'); }
+ else if(act==='stop'){ const run=engine.stopSave(id); if(run) toast(`saved “${run.label}” · ${run.laps.length} laps → org`,'gold'); }
+ render();
+ });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows();
+ list.innerHTML='';
+ if(!rows.length){ list.appendChild(el('div','empty','No timers running — pick a type below and ADD.')); return; }
+ rows.forEach(r=>{
+ const row=el('div','qrow'+(r.primary?' prim':'')+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':''));
+ const ctrls = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`;
+ row.innerHTML=
+ `<span class="lamp ${r.paused?'off':(r.primary?'gold':(r.warn?'red':''))}"></span>
+ <span class="g">${r.glyph}</span>
+ <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span>
+ <span class="rd">${r.disp}</span>
+ <span class="ctrls">
+ ${ctrls}
+ <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot" ${r.primary?'disabled style=opacity:.4':''}>${GL.promote}</button>
+ <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>
+ </span>`;
+ list.appendChild(row);
+ });
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>{flashing.delete(id);},1800); };
+ engine._toast=toast;
+ render();
+}
+
+/* =================================================================== */
+/* VIEW B — TRANSPORT DECK */
+/* =================================================================== */
+function mountTransport(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer · Transport</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ head.appendChild(clear);
+ const hero=el('div','hero');
+ const tracks=el('div','tracks');
+ host.append(head,hero,tracks);
+ const create=makeCreate(engine,toast,()=>render(),{editablePresets:true});
+ host.appendChild(create);
+
+ const flashing=new Set();
+ function act(fn){ return e=>{ fn(); render(); }; }
+ hero.addEventListener('click', e=>{ const b=e.target.closest('[data-act]'); if(!b) return; const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');}
+ else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}” · ${r.laps.length} laps`,'gold');}
+ render(); });
+ tracks.addEventListener('click', e=>{
+ const x=e.target.closest('[data-cancel]'); if(x){ engine.cancel(x.dataset.cancel); toast('cancelled'); render(); return; }
+ const t=e.target.closest('[data-id]'); if(t){ engine.promote(t.dataset.id); render(); } });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(); const primId=engine.effectivePrimary();
+ // hero = the primary row
+ const h = rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers — add one below to load the deck.</div>'; }
+ else {
+ const ringP = h.prog!=null ? Math.round(h.prog*100) : (h.type==='stopwatch'? 100 : 0);
+ const ringInner = h.type==='stopwatch'
+ ? `<b style="color:var(--slate-hi);font-size:11px">SW</b>`
+ : `<b style="color:var(--cream);font-size:15px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`;
+ const transport = h.type==='stopwatch'
+ ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key" data-act="lap" data-id="${h.id}">LAP</button>
+ <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>`
+ : `<button class="key icon" data-act="cycle" data-dir="prev">${'‹'}</button>
+ <button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key red icon" data-act="cancel" data-id="${h.id}">${GL.cancel}</button>
+ <button class="key icon" data-act="cycle" data-dir="next">${'›'}</button>`;
+ hero.innerHTML=
+ `<div class="lhs"><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:88px;height:88px">${ringInner}</span></div>
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>
+ ${h.primary?'<span class="badge">BAR SLOT</span>':''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}</div>
+ <div class="hsub">${h.sub}</div>
+ <div class="transport">${transport}</div>
+ </div>`;
+ }
+ // track list = everything except the hero
+ tracks.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ if(rest.length){ tracks.appendChild(el('div','engrave','up next <span class="cnt">· '+rest.length+'</span>')); }
+ rest.forEach(r=>{
+ const t=el('div','track'+(r.paused?' paused':''),
+ `<span class="g">${r.glyph}</span><b>${r.label}</b>
+ <span class="trd">${r.disp}</span>
+ <span class="tx" data-cancel="${r.id}" title="cancel">${GL.cancel}</span>`);
+ t.dataset.id=r.id; tracks.appendChild(t);
+ });
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); };
+ engine._toast=toast;
+ render();
+}
+
+/* =================================================================== */
+/* VIEW C — CHANNEL STRIP BOARD */
+/* =================================================================== */
+function mountBoard(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',
+ `<span class="brand">Timer · Board</span><span class="pcount">channels <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); });
+ head.appendChild(clear);
+ const board=el('div','board');
+ host.append(head,board);
+
+ // create controls live in the trailing add-strip; build once, reuse the shared controller inside it
+ const addStrip=el('div','strip addstrip');
+ const create=makeCreate(engine,toast,()=>render(),{editablePresets:true});
+ create.style.margin='0'; create.style.background='transparent'; create.style.border='0'; create.style.padding='0'; create.style.width='100%';
+ addStrip.append(el('div','styp','+ new channel'), create);
+
+ board.addEventListener('click', e=>{
+ const b=e.target.closest('[data-act]');
+ if(b){ const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='lap'){engine.lap(id);toast('lap');} else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}”`,'gold');}
+ render(); return; }
+ const h=e.target.closest('[data-promote]'); if(h){ engine.promote(h.dataset.promote); render(); }
+ });
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows();
+ board.innerHTML='';
+ rows.forEach(r=>{
+ const strip=el('div','strip'+(r.primary?' prim':'')+(r.paused?' paused':''));
+ const pct = r.prog!=null ? Math.round(r.prog*100) : 100;
+ const colCls = 'column'+(r.type==='stopwatch'?' sw':'')+(r.warn?' warn':'');
+ const fillH = r.type==='stopwatch' ? 100 : pct;
+ const keys = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">${GL.cancel}</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}">${r.paused?GL.play:GL.paused}</button>
+ <button class="key icon red" data-act="cancel" data-id="${r.id}">${GL.cancel}</button>`;
+ strip.innerHTML=
+ `<div class="stitle" data-promote="${r.id}" title="promote to bar slot">
+ <span class="g">${r.glyph}</span><b>${r.label}</b></div>
+ <div class="styp">${r.typeLabel}${r.primary?' · bar':''}</div>
+ <div class="${colCls}"><div class="fill" style="height:${fillH}%"></div>
+ ${r.type!=='stopwatch'?`<div class="cap" style="bottom:${fillH}%"></div>`:''}</div>
+ <div class="srd">${r.disp}</div>
+ <div class="skeys">${keys}</div>`;
+ board.appendChild(strip);
+ });
+ board.appendChild(addStrip);
+ }
+ engine._render=render;
+ engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); };
+ const flashing=new Set();
+ engine._flashSet=flashing;
+ engine._toast=toast;
+ render();
+}
+
+/* ---------- seed + wire the three panels ---------- */
+function seed(engine){
+ engine.add('pomodoro','', 'Deep work');
+ engine.add('timer','5m','Tea');
+ engine.add('timer','45s','Egg'); // fires ~45s in, demonstrates completion + notify
+ const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id);
+ engine.add('alarm','@hour','Standup');
+}
+
+const engines=[];
+function boot(){
+ const A=new Engine(), B=new Engine(), C=new Engine();
+ seed(A); seed(B); seed(C);
+ mountRack(document.getElementById('panelA'), A);
+ mountTransport(document.getElementById('panelB'), B);
+ mountBoard(document.getElementById('panelC'), C);
+ engines.push(A,B,C);
+}
+boot();
+
+/* ---------- global tick: fire timers, flash + notify, re-render ---------- */
+function loop(){
+ for(const e of engines){
+ const fired=e.tick();
+ for(const f of fired){
+ e._flash && e._flash(f.id);
+ if(e._flashSet) e._flashSet.add(f.id), setTimeout(()=>e._flashSet.delete(f.id),1800);
+ e._toast && e._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold');
+ tryNotify(f.title, f.body);
+ }
+ e._render && e._render();
+ }
+}
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-2.html b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html
new file mode 100644
index 0000000..ffd4521
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html
@@ -0,0 +1,553 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — hero + rack (iteration 2) · dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1100px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch}
+.masthead p b{color:var(--silver)}
+.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.6rem;flex-wrap:wrap}
+.side{flex:1;min-width:300px}
+.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;
+ display:flex;align-items:center;gap:10px}
+.side h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px}
+.side li{display:flex;gap:9px}
+.side li::before{content:"›";color:var(--gold);flex:none}
+.side li b{color:var(--silver);font-weight:400}
+
+/* ---------- faceplate ---------- */
+.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px;position:relative;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+
+/* ---------- primitives ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key:disabled{opacity:.4;cursor:default}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+
+.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;
+ border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center}
+.chip:hover{color:var(--silver);border-color:var(--slate)}
+.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.chip.lock{padding-right:10px}
+.chip .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}
+.chip .x:hover{opacity:1;color:var(--fail)}
+
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.dim{background:var(--wash);color:var(--steel)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;
+ padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+
+.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:20px;background:var(--gold)}
+
+.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;
+ display:flex;align-items:center;gap:9px;margin:2px 0}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:10px}
+.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;
+ padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}
+.tin::placeholder{color:var(--dim)}
+.tin.bad{border-color:var(--fail);color:var(--fail)}
+.tin:disabled{opacity:.45}
+.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;
+ padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums}
+.numin:focus{border-color:var(--gold)}
+
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+
+.dots{display:flex;gap:4px;align-items:center}
+.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none}
+.dots i.on{background:var(--steel)}
+.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.dots i.long{outline:1px solid var(--gold);outline-offset:1px}
+
+/* ---------- HERO (top) ---------- */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center;margin-bottom:12px}
+.hero.fire{animation:firef .6s ease-in-out 3}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px}
+.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em}
+.transport{display:flex;gap:7px;margin-top:3px;flex-wrap:wrap}
+
+/* ---------- CREATE (middle) ---------- */
+.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap}
+.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px}
+.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px}
+.cfg .crow{display:flex;align-items:center;gap:8px}
+.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none}
+.cfg .crow .u{color:var(--dim);font-size:10px}
+.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px}
+
+/* ---------- LIST (bottom) ---------- */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.fire{animation:firef .6s ease-in-out 3}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px}
+
+/* toast */
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}
+.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · timer · iteration 2</div>
+ <h1>Timer panel — hero + rack</h1>
+ <p>The rack unit reshaped: the <b>hero</b> from the transport deck rides on top (the primary / bar-slot item, big),
+ the <b>create strip</b> sits under it, and the <b>queue list</b> runs below. Pomodoro is now a real configurable cycle —
+ work and rest each with a short and a long duration, a long break every N cycles, auto-advance, and progress dots —
+ with its default preset locked so it can't be deleted. Everything is live: add, count down, fire + notify, pause, promote,
+ lap / stop-save. Ideas pulled from Pomofocus, Todoist, and the classic technique (see the notes column).</p>
+</header>
+
+<div class="cols">
+ <div class="panel" id="panel"></div>
+ <div class="side">
+ <h2>What changed this pass</h2>
+ <ul>
+ <li><b>Layout flipped:</b> hero on top → create strip → list (was list → create).</li>
+ <li><b>Pomodoro is configurable:</b> Work short/long, Rest short/long, long break every N, auto-advance toggle.</li>
+ <li><b>Deep cycle:</b> every Nth pomodoro uses the long work + long rest; the rest fill mark the long dots.</li>
+ <li><b>Default cycle is locked</b> — shipped presets have no ×; only chips you add are deletable.</li>
+ <li><b>Cycle dots</b> in the hero + row show where you are in the set.</li>
+ </ul>
+ <h2 style="margin-top:1.6rem">Borrowed from good pomodoro apps</h2>
+ <ul>
+ <li><b>Pomofocus:</b> separate work / short-break / long-break lengths + long-break interval.</li>
+ <li><b>Auto-start next</b> (Pomofocus, Pomodo): auto-advance rolls into the next phase; off = wait and press start.</li>
+ <li><b>Todoist / the technique:</b> long break of 15–30m after 4 pomodoros; all durations adjustable.</li>
+ <li><b>Preset cycles:</b> Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10 — one tap loads the fields.</li>
+ <li><b>Task label</b> on every item; cycle progress shown as dots.</li>
+ </ul>
+ </div>
+</div>
+
+</div>
+
+<script>
+"use strict";
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;};
+
+const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' };
+
+/* ---- parsers ---- */
+function parseDuration(v){
+ if(v==null) return null; v=String(v).trim().toLowerCase(); if(v==='') return null;
+ if(/^\d+$/.test(v)) return parseInt(v,10)*60;
+ if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null;
+ let m,tot=0; const re=/(\d+)\s*([hms])/g;
+ while((m=re.exec(v))) tot+= m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1];
+ return tot>0?tot:null;
+}
+function resolveAlarm(v,now){
+ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;}
+ if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;}
+ const t=v.match(/^(\d{1,2}):(\d{2})$/);
+ if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;}
+ return null;
+}
+const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;
+ return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;};
+const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;};
+
+/* ---- presets: shipped defaults are locked (no delete) ---- */
+const DEFAULT_PRESETS=()=>({
+ timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true},
+ {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}],
+ alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'07:00',value:'07:00',locked:true}],
+ stopwatch:[]
+});
+/* pomodoro preset = a full config (minutes). "Classic" is the default cycle — locked. */
+const POMO_PRESETS=[
+ {label:'Classic', ws:25, wl:50, rs:5, rl:15, iv:4, locked:true},
+ {label:'Deep', ws:50, wl:50, rs:10, rl:30, iv:3, locked:true},
+ {label:'Sprint', ws:15, wl:25, rs:3, rl:10, iv:4, locked:true},
+];
+const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true};
+
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+
+class Engine{
+ constructor(){ this.items=[]; this.seq=0; this.primary=null;
+ this.presets=DEFAULT_PRESETS(); this.pomoPresets=POMO_PRESETS.map(p=>({...p})); }
+ now(){return Date.now()/1000;}
+ count(){return this.items.length;}
+ full(){return this.items.length>=MAX;}
+ add(type,value,label,cfg){
+ if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now(); this.seq++; const id='t'+this.seq; const it={id,type,label:label||''};
+ if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;}
+ else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};it.target=e;it.total=Math.max(1,e-now);}
+ else if(type==='pomodoro'){
+ const c=cfg||POMO_DEFAULT;
+ it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false};
+ it.phase='work'; it.cycle=1; const deep=(1%it.cfg.iv===0);
+ const len=deep?it.cfg.wl:it.cfg.ws; it.target=now+len; it.total=len;
+ }
+ else if(type==='stopwatch'){it.start=now;it.laps=[];}
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it); if(!this.primary) this.primary=id; return {ok:true,id};
+ }
+ find(id){return this.items.find(i=>i.id===id);}
+ isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;}
+ remaining(it,ref){ref=ref==null?this.now():ref;
+ if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;
+ return this.isPaused(it)?it.paused_left:it.target-ref;}
+ toggle(id){const it=this.find(id);if(!it)return;const now=this.now();
+ if(it.type==='stopwatch'){ if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;} else it.paused_elapsed=now-it.start; }
+ else { if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;} else it.paused_left=it.target-now; } }
+ cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;}
+ cancelAll(){this.items=[];this.primary=null;}
+ promote(id){if(this.find(id))this.primary=id;}
+ cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;
+ this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];}
+ lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});}
+ stopSave(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return null;const run={label:it.label||'run',total:this.remaining(it),laps:it.laps.slice()};this.cancel(id);return run;}
+ effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id);
+ if(ids.includes(this.primary))return this.primary;const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];}
+ sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now);
+ return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];}
+ rows(){const prim=this.effectivePrimary(),now=this.now();
+ return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);
+ for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));}
+ row(it,prim,now){
+ const rem=this.remaining(it,now),paused=this.isPaused(it);
+ let disp,sub='',warn=false,prog=null,glyph,pomo=null;
+ if(it.type==='alarm'){disp=fmtClock(it.target);sub='fires '+fmtClock(it.target);prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;}
+ else if(it.type==='pomodoro'){
+ disp=fmtTime(rem); prog=Math.max(0,Math.min(1,rem/it.total));
+ const deep=(it.cycle%it.cfg.iv===0);
+ const phLabel = it.phase==='work' ? (deep?'long work':'work')
+ : it.phase==='rest' ? ((it.cycle%it.cfg.iv===0)?'long break':'short break') : it.phase;
+ sub = it.awaiting ? `ready · start ${it.phase==='work'?'work':'break'}` : `${phLabel} · cycle ${it.cycle}/${it.cfg.iv}`;
+ glyph = it.phase==='work' ? GL.pomo_work : GL.pomo_break;
+ pomo={cycle:it.cycle, iv:it.cfg.iv, phase:it.phase, awaiting:!!it.awaiting, deep};
+ }
+ else if(it.type==='stopwatch'){disp=fmtTime(rem);sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;}
+ else {disp=fmtTime(rem);sub='timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;}
+ if(prog!=null && rem<=Math.min(30,it.total*0.15)) warn=true;
+ if(paused) glyph=GL.paused;
+ return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type,disp,sub,paused,primary:it.id===prim,prog,warn,pomo,laps:it.laps?it.laps.length:0};
+ }
+ tick(){
+ const now=this.now(),fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type)||this.isPaused(it))continue;
+ if(it.target-now>0)continue;
+ if(it.type==='pomodoro'){
+ const c=it.cfg;
+ if(it.phase==='work'){
+ const deep=(it.cycle%c.iv===0);
+ fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`});
+ it.phase='rest'; const len=deep?c.rl:c.rs; it.total=len;
+ if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; }
+ } else { // rest over → next work
+ it.cycle+=1; const deep=(it.cycle%c.iv===0);
+ fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`});
+ it.phase='work'; const len=deep?c.wl:c.ws; it.total=len;
+ if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; }
+ }
+ } else {
+ fired.push({id:it.id,kind:'done',title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label||(it.type==='alarm'?fmtClock(it.target):'done')),body:'time’s up'});
+ this.cancel(it.id);
+ }
+ }
+ return fired;
+ }
+ presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));}
+ addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom chip here'};
+ if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};
+ (this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};}
+ deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);
+ if(i<0)return {ok:false,reason:'not found'}; if(a[i].locked)return {ok:false,reason:'default — locked'};
+ a.splice(i,1);return {ok:true};}
+}
+
+/* ---- notifications ---- */
+let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied';
+function tryNotify(title,body){ if(typeof Notification==='undefined')return;
+ if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}
+ else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);} }
+function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);
+ return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);
+ setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};}
+
+function dotsHTML(p){ if(!p)return ''; let h='<span class="dots">'; const pos=(p.cycle-1)%p.iv;
+ for(let i=0;i<p.iv;i++){ const isLong=(i===p.iv-1); let cls=''; if(i<pos)cls='on'; if(i===pos)cls='now'; if(isLong)cls+=' long';
+ h+=`<i class="${cls.trim()}"></i>`; } return h+'</span>'; }
+
+/* =================================================================== */
+/* THE PANEL: hero (top) · create (middle) · list (bottom) */
+/* =================================================================== */
+function mount(host, engine){
+ const toast=toaster(host);
+ const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();});
+ head.appendChild(clear);
+ const hero=el('div','hero');
+ const create=buildCreate();
+ const list=el('div','qlist');
+ host.append(head,hero,create.box,list);
+
+ const flashing=new Set();
+
+ /* ---- hero + list interactions ---- */
+ hero.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');}
+ else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');}
+ render();});
+ list.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id); else if(a==='promote'){engine.promote(id);toast('to bar slot');}
+ else if(a==='lap'){engine.lap(id);toast('lap recorded');} else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');}
+ else if(a==='cancel'){ if(b.dataset.armed){engine.cancel(id);toast('cancelled');}
+ else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;} }
+ render();});
+
+ /* ---- create strip (swaps body by type) ---- */
+ function buildCreate(){
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);});
+ const body=el('div');
+ box.append(seg,body);
+ let selType='timer';
+ // shared value/label/add controls (rebuilt per type)
+ seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
+ selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();});
+ function labelAdd(hasVal){
+ const row=el('div','row');
+ let val=null;
+ if(hasVal){ val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; }
+ const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD'); addk.style.flex='1';
+ if(val) row.append(val); row.append(lab,addk); return {row,val,lab,addk};
+ }
+ function paintBody(){
+ body.innerHTML='';
+ if(selType==='timer'||selType==='alarm'){
+ const chips=el('div','chips');
+ engine.presetsFor(selType).forEach(p=>{
+ const c=el('span','chip'+(p.locked?' lock':''), p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));
+ c.dataset.val=p.value; chips.appendChild(c);
+ });
+ const addch=el('span','chip','+ chip'); addch.dataset.newchip='1'; addch.style.opacity='.7'; chips.appendChild(addch);
+ const {row,val,lab,addk}=labelAdd(true);
+ val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' : '5m · 1h30m · 90s';
+ body.append(chips,row);
+ chips.addEventListener('click',e=>{
+ const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset(selType,decodeURIComponent(del.dataset.del));toast(r.ok?'chip removed':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ if(e.target.closest('[data-newchip]')){const lb=prompt('Chip label:');if(!lb)return;
+ const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset(selType,lb,vv);toast(r.ok?'chip added':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.chip');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;doAdd(selType,val,lab);
+ });
+ addk.addEventListener('click',()=>doAdd(selType,val,lab));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd(selType,val,lab);}));
+ }
+ else if(selType==='stopwatch'){
+ const {row,lab,addk}=labelAdd(false); // no time entry — stopwatches count up from zero
+ body.append(row);
+ addk.addEventListener('click',()=>doAdd('stopwatch',null,lab));
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);});
+ }
+ else { // pomodoro config
+ const chips=el('div','chips');
+ engine.pomoPresets.forEach(p=>{const c=el('span','chip lock',p.label);c.dataset.pp=p.label;chips.appendChild(c);});
+ const cfg=el('div','cfg');
+ const mk=(v)=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;};
+ const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv);
+ const auto=el('span','switch on'); auto.dataset.on='1';
+ const rW=el('div','crow'); rW.append(el('span','lbl','Work'), el('span','sl','S'), ws, el('span','sl','L'), wl, el('span','u','min'));
+ const rR=el('div','crow'); rR.append(el('span','lbl','Rest'), el('span','sl','S'), rs, el('span','sl','L'), rl, el('span','u','min'));
+ const rI=el('div','crow'); rI.append(el('span','lbl','Long ev.'), iv, el('span','u','cycles → long work + long break'));
+ const rA=el('div','crow'); rA.append(el('span','lbl','Auto'), auto, el('span','u','advance into the next phase'));
+ cfg.append(rW,rR,rI,rA);
+ const {row,lab,addk}=labelAdd(false); lab.placeholder='label (optional)'; // pomodoro has no single value entry — config fields above
+ addk.innerHTML=GL.add+' ADD CYCLE';
+ body.append(chips,cfg,row);
+ auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';});
+ chips.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;
+ const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return;
+ ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;
+ [...chips.children].forEach(x=>x.classList.toggle('on',x===c));toast('loaded “'+p.label+'”','gold');});
+ function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;}
+ addk.addEventListener('click',()=>{
+ const cfgv={ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on};
+ const r=engine.add('pomodoro','',lab.value.trim(),cfgv);
+ if(!r.ok){toast(r.reason,'red');return;} lab.value=''; toast('pomodoro added','gold'); render();
+ });
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();});
+ }
+ }
+ function doAdd(type,val,lab){
+ const r=engine.add(type,val?val.value:'',lab.value.trim());
+ if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;}
+ if(val){val.classList.remove('bad'); if(!val.disabled)val.value='';} lab.value='';
+ toast('added '+type,'gold'); render();
+ }
+ paintBody();
+ return {box};
+ }
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(), primId=engine.effectivePrimary();
+ // HERO = primary
+ const h=rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>'; }
+ else {
+ const ringP=h.prog!=null?Math.round(h.prog*100):(h.type==='stopwatch'?100:0);
+ const inner=h.type==='stopwatch'?`<b style="color:var(--slate-hi);font-size:11px">SW</b>`
+ :`<b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`;
+ const startLabel = h.pomo&&h.pomo.awaiting ? (GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')) : (h.paused?GL.play+' RESUME':GL.paused+' PAUSE');
+ const transport = h.type==='stopwatch'
+ ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button>
+ <button class="key" data-act="lap" data-id="${h.id}">LAP</button>
+ <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>`
+ : `<button class="key icon" data-act="cycle" data-dir="prev" title="prev primary">‹</button>
+ <button class="key" data-act="toggle" data-id="${h.id}">${startLabel}</button>
+ <button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button>
+ <button class="key icon" data-act="cycle" data-dir="next" title="next primary">›</button>`;
+ hero.innerHTML=
+ `<div><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px">${inner}</span></div>
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>
+ <span class="badge">BAR SLOT</span>${h.pomo?dotsHTML(h.pomo):''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}</div>
+ <div class="hsub">${h.sub}</div>
+ <div class="transport">${transport}</div>
+ </div>`;
+ }
+ // LIST = the rest
+ list.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ if(!rest.length){ list.appendChild(el('div','empty', h?'Only the hero is queued — add more below.':'')); }
+ list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>'));
+ rest.forEach(r=>{
+ const row=el('div','qrow'+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':''));
+ const ctrls = r.type==='stopwatch'
+ ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button>
+ <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>`
+ : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`;
+ row.innerHTML=
+ `<span class="lamp ${r.paused?'off':(r.warn?'red':'')}"></span>
+ <span class="g">${r.glyph}</span>
+ <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}${r.pomo?' ':''}</span></span>
+ <span class="rd">${r.disp}</span>
+ <span class="ctrls">${ctrls}
+ <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>
+ <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button></span>`;
+ list.appendChild(row);
+ });
+ }
+ engine._render=render; engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);}; engine._toast=toast;
+ render();
+}
+
+/* ---- seed ---- */
+const engine=new Engine();
+const pomo=engine.add('pomodoro','', 'Deep work', {ws:25,wl:50,rs:5,rl:15,iv:4,auto:true});
+engine.add('timer','45s','Egg');
+engine.add('timer','5m','Tea');
+const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id);
+engine.add('alarm','@hour','Standup');
+engine.promote(pomo.id); // show the pomodoro in the hero
+mount(document.getElementById('panel'), engine);
+
+/* ---- global tick ---- */
+function loop(){
+ const fired=engine.tick();
+ for(const f of fired){ engine._flash(f.id);
+ engine._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); tryNotify(f.title,f.body); }
+ engine._render();
+}
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-3.html b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html
new file mode 100644
index 0000000..98778fa
--- /dev/null
+++ b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html
@@ -0,0 +1,556 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Timer panel — iteration 3 (waybar + hero-right) · dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; --sage:#8a9a5b;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1120px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:94ch}
+.masthead p b{color:var(--silver)}
+.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.4rem;flex-wrap:wrap}
+.side{flex:1;min-width:300px}
+.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;display:flex;align-items:center;gap:10px}
+.side h2::after{content:"";height:1px;background:var(--wash);flex:1}
+.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px}
+.side li{display:flex;gap:9px}.side li::before{content:"›";color:var(--gold);flex:none}
+.side li b{color:var(--silver);font-weight:400}
+
+/* ---------- waybar preview ---------- */
+.barcap{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;margin-bottom:7px;display:flex;align-items:center;gap:9px}
+.barcap::after{content:"";height:1px;background:var(--wash);flex:1}
+.wbar{width:420px;background:linear-gradient(180deg,#141312,#0e0d0c);border:1px solid #262320;border-radius:16px;
+ padding:6px 10px;display:flex;align-items:center;gap:8px;box-shadow:0 8px 20px rgba(0,0,0,.5);margin-bottom:6px}
+.wbar .fillspace{flex:1;color:var(--dim);font-size:10px;letter-spacing:.1em;padding-left:4px}
+.wmod{display:inline-flex;align-items:center;gap:7px;color:var(--silver);background:transparent;border:1.5px solid var(--gold);
+ border-radius:14px;padding:4px 12px;cursor:pointer;font-size:12.5px;white-space:nowrap;min-width:78px;justify-content:center}
+.wmod:hover{background:var(--wash)}
+.wmod .wg{font-size:16px;line-height:1}
+.wmod .wt{font-variant-numeric:tabular-nums;font-weight:700}
+.wmod .wp{color:var(--dim);font-size:11px}
+.wmod.urgent{color:var(--fail)} .wmod.paused{color:var(--dim)}
+.wmod.pomodoro-work{color:var(--gold)} .wmod.pomodoro-break{color:var(--sage)}
+.wmod.idle{color:var(--silver);min-width:0;border-color:#4a463c}
+.wtip{width:420px;background:var(--well);border:1px solid #201d17;border-radius:8px;padding:7px 10px;font-size:11px;color:var(--dim);margin-bottom:16px}
+.wtip .th{color:var(--steel);letter-spacing:.14em;text-transform:uppercase;font-size:.56rem;margin-bottom:3px}
+.wtip .tl{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* ---------- faceplate ---------- */
+.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:14px;padding:15px;position:relative;box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)}
+.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px}
+.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase}
+.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em}
+.phead .pcount b{color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+.panel.closed{display:none}
+.wbar.reopen{outline:1px dashed var(--slate);outline-offset:2px}
+
+/* ---------- primitives ---------- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}.key:active{transform:translateY(1px)}.key:disabled{opacity:.4;cursor:default}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px}
+.key.icon{padding:6px 9px;font-size:14px;line-height:1}
+.preset{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center}
+.preset:hover{color:var(--silver);border-color:var(--slate)}
+.preset.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.preset .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}.preset .x:hover{opacity:1;color:var(--fail)}
+.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase}
+.badge.red{background:var(--fail);color:var(--cream)}.badge.dim{background:var(--wash);color:var(--steel)}.badge.sage{background:var(--sage);color:var(--panel)}
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}.switch.on::after{left:20px;background:var(--gold)}
+.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;display:flex;align-items:center;gap:9px;margin:2px 0}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}.engrave::before{max-width:10px}.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;padding:7px 9px;width:100%;outline:none}
+.tin:focus{border-color:var(--gold)}.tin::placeholder{color:var(--dim)}.tin.bad{border-color:var(--fail);color:var(--fail)}
+.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums}
+.numin:focus{border-color:var(--gold)}
+@keyframes fieldflash{0%{border-color:var(--gold-hi);background:rgba(218,181,61,.22)}100%{border-color:#231f18;background:#0d0f10}}
+.tin.flash,.numin.flash{animation:fieldflash .7s ease}
+.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;border-radius:7px;padding:6px 9px}
+.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)}
+.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative}
+.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)}
+.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;z-index:1;text-align:center}
+.dots{display:flex;gap:4px;align-items:center}
+.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none}
+.dots i.on{background:var(--steel)}.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}.dots i.long{outline:1px solid var(--gold);outline-offset:1px}
+.days7{display:flex;gap:4px}
+.days7 button{font:inherit;font-size:10px;width:22px;height:22px;border-radius:50%;border:1px solid #33302b;background:#141210;color:var(--dim);cursor:pointer;padding:0}
+.days7 button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);border-color:var(--gold-hi);font-weight:700}
+
+/* ---------- HERO (info left, donut right) ---------- */
+.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;flex-direction:column;gap:13px;margin-bottom:12px}
+.hero .htop{display:flex;gap:15px;align-items:center}
+.hero.fire{animation:firef .6s ease-in-out 3}
+.hero.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)}
+@keyframes firef{50%{background:rgba(203,107,77,.22)}}
+@keyframes ringf{50%{background:rgba(203,107,77,.16)}}
+.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px}
+.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.hero .htype .g{color:var(--gold);font-size:17px}
+.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;display:flex;align-items:center;gap:11px}
+.hero.paused .hbig{color:var(--steel)}
+.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em}
+.hero .lapbadge{font-size:11px;letter-spacing:.12em;color:var(--silver);border:1px solid var(--slate);border-radius:6px;
+ padding:2px 8px;font-weight:400;font-variant-numeric:tabular-nums;background:transparent;line-height:1;align-self:center}
+/* stopwatch sweep dial — analog second-hand, one revolution per minute */
+.swdial{width:86px;height:86px;border-radius:50%;background:var(--well);border:1px solid #201d17;position:relative;display:block}
+.swdial::before{content:"";position:absolute;inset:7px;border-radius:50%;border:2px solid var(--wash)}
+.swtick{position:absolute;top:5px;left:50%;width:2px;height:8px;margin-left:-1px;background:var(--steel);border-radius:1px;transform-origin:50% 38px}
+.swtick.q{transform:rotate(90deg)}.swtick.h{transform:rotate(180deg)}.swtick.t{transform:rotate(270deg)}
+.swhand{position:absolute;left:calc(50% - 1px);bottom:50%;width:2px;height:31px;background:var(--gold-hi);
+ transform-origin:50% 100%;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.5)}
+.swhub{position:absolute;left:50%;top:50%;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;border-radius:50%;
+ background:var(--gold);box-shadow:0 0 0 2px var(--well),0 0 6px rgba(218,181,61,.5)}
+.hero .donut{flex:none}
+.transport{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-start}
+
+/* ---------- CREATE (middle) ---------- */
+.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px}
+.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap}
+.presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px}
+.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px}
+.cfg .crow{display:flex;align-items:center;gap:8px}
+.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none}
+.cfg .crow .u{color:var(--dim);font-size:10px}
+.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px}
+
+/* ---------- LIST (bottom) ---------- */
+.qlist{display:flex;flex-direction:column;gap:8px}
+.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px}
+.qrow.fire{animation:firef .6s ease-in-out 3}.qrow.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)}
+.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none}
+.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px}
+.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:104px}
+.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.1em;text-transform:uppercase}
+.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
+.qrow.paused .rd{color:var(--steel)}
+.qrow .ctrls{display:flex;gap:5px;flex:none}
+.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px}
+.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5}
+.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease}
+.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)}
+@keyframes tin{from{opacity:0;transform:translateY(6px)}}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · timer · iteration 3</div>
+ <h1>Timer panel — waybar + hero-right</h1>
+ <p>Third pass. The <b>hero donut moved to the right</b> of the readout; the redundant "bar slot" badge is gone
+ (the hero <i>is</i> the bar slot). Above the panel sits a <b>live, accurate preview of the actual waybar module</b> —
+ the same glyph + countdown + "+N" and state colours <code>wtimer render</code> emits, with its hover tooltip.
+ Presets (renamed from "chips") <b>flash the fields</b> on load instead of toasting, alarms gain a <b>half-past</b> preset,
+ and each type picked up create-strip ideas from its category's best apps: timers <b>repeat</b>, alarms carry
+ <b>recurring days + snooze</b> (with a ringing state), pomodoro keeps its configurable cycle.</p>
+</header>
+
+<div class="cols">
+ <div>
+ <div class="barcap">the waybar module · live</div>
+ <div class="wbar" id="wbar"></div>
+ <div class="wtip" id="wtip"></div>
+ <div class="panel" id="panel"></div>
+ </div>
+ <div class="side">
+ <h2>This pass</h2>
+ <ul>
+ <li><b>Donut on the right</b> of the hero info; <b>no "bar slot" label</b>.</li>
+ <li><b>Live waybar preview</b> — mirrors <code>wtimer render</code>: glyph + countdown + "+N", state colour, tooltip.</li>
+ <li><b>"Preset"</b> replaces "chip"; loading one <b>flashes the field(s)</b>, no toast.</li>
+ <li><b>Half-past</b> alarm preset — next X:30, the sibling of top-of-hour's X:00.</li>
+ </ul>
+ <h2 style="margin-top:1.5rem">Borrowed per category</h2>
+ <ul>
+ <li><b>Timer</b> (MultiTimer, Multi Timer): auto-<b>repeat</b> — restart on finish. Toggle in the create row.</li>
+ <li><b>Alarm</b> (Alarm Clock Xtreme, Alarmy): <b>recurring weekdays</b> + <b>snooze</b>; fires into a ringing state with SNOOZE / DISMISS.</li>
+ <li><b>Stopwatch</b> (Stopwatch Timer): sweep dial + infinite <b>laps</b> with the last lap beside the count; run-save deferred to a vNext.</li>
+ <li><b>Pomodoro</b> (Pomofocus): configurable work/rest short+long, long-break interval, auto-advance, cycle dots.</li>
+ </ul>
+ </div>
+</div>
+</div>
+
+<script>
+"use strict";
+const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
+const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;};
+
+const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', idle:'\u{F051B}',
+ pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}',
+ play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}', repeat:'\u{F0456}', bell:'\u{F0020}' };
+
+function parseDuration(v){ if(v==null)return null;v=String(v).trim().toLowerCase();if(v==='')return null;
+ if(/^\d+$/.test(v))return parseInt(v,10)*60; if(!/^(\s*\d+\s*[hms])+$/.test(v))return null;
+ let m,tot=0;const re=/(\d+)\s*([hms])/g; while((m=re.exec(v)))tot+=m[2]==='h'?+m[1]*3600:m[2]==='m'?+m[1]*60:+m[1]; return tot>0?tot:null; }
+function resolveAlarm(v,now){ v=String(v||'').trim().toLowerCase();
+ if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;}
+ if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;}
+ if(v==='@half'||v==='half past'||v==='half-past'){const d=new Date(now*1000);d.setSeconds(0,0);d.setMinutes(30);let e=d.getTime()/1000;if(e<=now){d.setHours(d.getHours()+1);e=d.getTime()/1000;}return e;}
+ const t=v.match(/^(\d{1,2}):(\d{2})$/); if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} return null; }
+function nextAlarm(hh,mm,days,now){ const base=new Date(now*1000);
+ for(let d=0;d<=7;d++){const c=new Date(base);c.setDate(base.getDate()+d);c.setHours(hh,mm,0,0);const e=c.getTime()/1000;
+ if(e<=now)continue; if(!days.length||days.includes(c.getDay()))return e;} return now+86400; }
+const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60;
+ return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;};
+const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;};
+const DAYNAMES=['S','M','T','W','T','F','S'];
+
+const DEFAULT_PRESETS=()=>({
+ timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true},
+ {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}],
+ alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'half-past',value:'@half',locked:true},{label:'07:00',value:'07:00',locked:true}],
+ stopwatch:[]
+});
+const POMO_PRESETS=[{label:'Classic',ws:25,wl:50,rs:5,rl:15,iv:4,locked:true},{label:'Deep',ws:50,wl:50,rs:10,rl:30,iv:3,locked:true},{label:'Sprint',ws:15,wl:25,rs:3,rl:10,iv:4,locked:true}];
+const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true};
+const TYPES=['timer','alarm','stopwatch','pomodoro'];
+const COUNTDOWN=['timer','alarm','pomodoro'];
+const MAX=10;
+
+class Engine{
+ constructor(){this.items=[];this.seq=0;this.primary=null;this.presets=DEFAULT_PRESETS();this.pomoPresets=POMO_PRESETS.map(p=>({...p}));}
+ now(){return Date.now()/1000;}
+ count(){return this.items.length;}
+ full(){return this.items.length>=MAX;}
+ add(type,value,label,opts){
+ opts=opts||{};
+ if(this.full())return {ok:false,reason:`queue full (${MAX}/${MAX})`};
+ const now=this.now();this.seq++;const id='t'+this.seq;const it={id,type,label:label||''};
+ if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;it.repeat=!!opts.repeat;}
+ else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};
+ const d=new Date(e*1000);it.hh=d.getHours();it.mm=d.getMinutes();it.days=(opts.days||[]).slice();it.snooze=opts.snooze||9;
+ it.target=it.days.length?nextAlarm(it.hh,it.mm,it.days,now):e;it.total=Math.max(1,it.target-now);}
+ else if(type==='pomodoro'){const c=opts.ws?opts:POMO_DEFAULT;it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false};
+ it.phase='work';it.cycle=1;const deep=(1%it.cfg.iv===0);const len=deep?it.cfg.wl:it.cfg.ws;it.target=now+len;it.total=len;}
+ else if(type==='stopwatch'){it.start=now;it.laps=[];}
+ else return {ok:false,reason:`unknown type: ${type}`};
+ this.items.push(it);if(!this.primary)this.primary=id;return {ok:true,id};
+ }
+ find(id){return this.items.find(i=>i.id===id);}
+ isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;}
+ remaining(it,ref){ref=ref==null?this.now():ref;if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;return this.isPaused(it)?it.paused_left:it.target-ref;}
+ toggle(id){const it=this.find(id);if(!it)return;const now=this.now();
+ if(it.type==='stopwatch'){if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;}else it.paused_elapsed=now-it.start;}
+ else{if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;}else it.paused_left=it.target-now;}}
+ cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;}
+ cancelAll(){this.items=[];this.primary=null;}
+ promote(id){if(this.find(id))this.primary=id;}
+ cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];}
+ lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});}
+ snooze(id){const it=this.find(id);if(!it)return;it.ringing=false;const m=it.snooze>0?it.snooze:9;it.target=this.now()+m*60;it.total=m*60;}
+ dismiss(id){const it=this.find(id);if(!it)return;if(it.days&&it.days.length){it.ringing=false;it.target=nextAlarm(it.hh,it.mm,it.days,this.now());it.total=Math.max(1,it.target-this.now());}else this.cancel(id);}
+ effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id);
+ const ring=items.find(i=>i.ringing);if(ring)return ring.id;
+ if(ids.includes(this.primary))return this.primary;const now=this.now();
+ const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i));
+ if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id;
+ const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];}
+ sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now);
+ if(it.ringing)return [-1,0,+it.id.slice(1)]; return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];}
+ rows(){const prim=this.effectivePrimary(),now=this.now();
+ return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));}
+ daysLabel(it){ if(!it.days||!it.days.length)return 'once'; if(it.days.length===7)return 'daily';
+ const wk=[1,2,3,4,5],we=[0,6];
+ if(wk.every(d=>it.days.includes(d))&&it.days.length===5)return 'weekdays';
+ if(we.every(d=>it.days.includes(d))&&it.days.length===2)return 'weekends';
+ return it.days.slice().sort().map(d=>DAYNAMES[d]).join(''); }
+ row(it,prim,now){
+ const rem=this.remaining(it,now),paused=this.isPaused(it);
+ let disp,sub='',warn=false,prog=null,glyph,pomo=null,ringing=!!it.ringing,badges=[],lastLap=null,sweep=null;
+ if(it.type==='alarm'){
+ if(ringing){disp='RING';sub='alarm ringing';warn=true;prog=1;glyph=GL.alarm;}
+ else{disp=fmtClock(it.target);sub=`${this.daysLabel(it)} · fires ${fmtClock(it.target)}`;prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;}
+ if(it.days&&it.days.length)badges.push({t:this.daysLabel(it),c:'sage'});
+ }
+ else if(it.type==='pomodoro'){disp=fmtTime(rem);prog=Math.max(0,Math.min(1,rem/it.total));const deep=(it.cycle%it.cfg.iv===0);
+ const ph=it.phase==='work'?(deep?'long work':'work'):(deep?'long break':'short break');
+ sub=it.awaiting?`ready · start ${it.phase==='work'?'work':'break'}`:`${ph} · cycle ${it.cycle}/${it.cfg.iv}`;
+ glyph=it.phase==='work'?GL.pomo_work:GL.pomo_break;pomo={cycle:it.cycle,iv:it.cfg.iv,phase:it.phase,awaiting:!!it.awaiting};}
+ else if(it.type==='stopwatch'){disp=fmtTime(rem);lastLap=it.laps.length?it.laps[it.laps.length-1].t:null;
+ sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;sweep=(Math.max(0,rem)%60)/60;}
+ else {disp=fmtTime(rem);sub=it.repeat?'timer · repeats':'timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;if(it.repeat)badges.push({t:'repeat',c:''});}
+ if(prog!=null&&!ringing&&rem<=Math.min(30,it.total*0.15))warn=true;
+ if(paused)glyph=GL.paused;
+ return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type],
+ typeLabel:it.type,disp,sub,paused,ringing,primary:it.id===prim,prog,warn,pomo,badges,lastLap,sweep,laps:it.laps?it.laps.length:0};
+ }
+ /* mirror wtimer render_payload for the bar */
+ barPayload(){ const now=this.now(); const items=this.items;
+ if(!items.length)return {glyph:GL.idle,text:'',plus:0,cls:'idle',tip:['No timers']};
+ const pid=this.effectivePrimary(); const p=this.find(pid);
+ let cls; if(p.ringing)cls='urgent'; else if(this.isPaused(p))cls='paused';
+ else if(p.type==='pomodoro')cls=(p.phase==='work'?'pomodoro-work':'pomodoro-break');
+ else if((p.type==='timer'||p.type==='alarm')&&this.remaining(p,now)<60)cls='urgent'; else cls=p.type;
+ const glyph=this.isPaused(p)?GL.paused:(p.type==='pomodoro'?(p.phase==='work'?GL.pomo_work:GL.pomo_break):GL[p.type]);
+ const text=p.ringing?'RING':fmtTime(this.remaining(p,now));
+ const tip=[items.length!==1?`${items.length} active`:'1 timer'];
+ for(const i of items){const g=this.isPaused(i)?GL.paused:(i.type==='pomodoro'?(i.phase==='work'?GL.pomo_work:GL.pomo_break):GL[i.type]);
+ const lb=i.label||i.type;const st=this.isPaused(i)?' (paused)':(i.ringing?' (ringing)':'');
+ const val=i.type==='alarm'?(i.ringing?'RING':fmtClock(i.target)):(i.type==='pomodoro'?`${i.cycle}/${i.cfg.iv} ${fmtTime(this.remaining(i,now))}`:fmtTime(this.remaining(i,now)));
+ tip.push(`${g} ${lb} ${val}${st}`);}
+ return {glyph,text,plus:items.length-1,cls,tip};
+ }
+ presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));}
+ addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom preset here'};
+ if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};(this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};}
+ deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);if(i<0)return {ok:false,reason:'not found'};if(a[i].locked)return {ok:false,reason:'default — locked'};a.splice(i,1);return {ok:true};}
+}
+
+let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied';
+function tryNotify(title,body){if(typeof Notification==='undefined')return;if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);}}
+function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};}
+function flash(...inputs){for(const i of inputs){if(!i)continue;i.classList.remove('flash');void i.offsetWidth;i.classList.add('flash');}}
+function dotsHTML(p){if(!p)return '';let h='<span class="dots">';const pos=(p.cycle-1)%p.iv;for(let i=0;i<p.iv;i++){const isLong=(i===p.iv-1);let cls='';if(i<pos)cls='on';if(i===pos)cls='now';if(isLong)cls+=' long';h+=`<i class="${cls.trim()}"></i>`;}return h+'</span>';}
+function badgesHTML(bs){return (bs||[]).map(b=>`<span class="badge ${b.c}">${b.t}</span>`).join(' ');}
+
+function mount(host, engine, bar, tip){
+ const toast=toaster(host);
+ const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`);
+ const clear=el('button','key sm',GL.clear+' CLEAR ALL');clear.style.marginLeft='8px';
+ clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();});
+ head.appendChild(clear);
+ // close button — flat circular ✕ like the net/bt/audio panels (Close/Esc); the
+ // waybar module reopens it, mirroring the real on-click: timer-panel toggle.
+ const closeBtn=el('button','x-btn','✕');closeBtn.title='Close (Esc)';
+ const barcap=document.querySelector('.barcap');
+ function setClosed(c){host.classList.toggle('closed',c);if(barcap)barcap.textContent=c?'the waybar module · click it to reopen the panel':'the waybar module · live';}
+ closeBtn.addEventListener('click',()=>setClosed(true));
+ head.appendChild(closeBtn);
+ bar.addEventListener('click',()=>setClosed(!host.classList.contains('closed')));
+ document.addEventListener('keydown',e=>{if(e.key==='Escape')setClosed(true);});
+ const hero=el('div','hero');
+ const create=buildCreate();
+ const list=el('div','qlist');
+ host.append(head,hero,create,list);
+ const flashing=new Set();
+
+ function itemClick(e){const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act;
+ if(a==='toggle')engine.toggle(id);
+ else if(a==='promote'){engine.promote(id);toast('to bar slot');}
+ else if(a==='cycle')engine.cycle(b.dataset.dir);
+ else if(a==='lap'){engine.lap(id);toast('lap recorded');}
+ else if(a==='stop'){engine.cancel(id);toast('stopped');}
+ else if(a==='snooze'){engine.snooze(id);toast('snoozed','gold');}
+ else if(a==='dismiss'){engine.dismiss(id);toast('dismissed');}
+ else if(a==='cancel'){if(b.dataset.armed){engine.cancel(id);toast('cancelled');}
+ else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;}}
+ render();}
+ hero.addEventListener('click',itemClick);
+ list.addEventListener('click',itemClick);
+
+ function buildCreate(){
+ const box=el('div','create');
+ const seg=el('div','seg');
+ TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);});
+ const body=el('div');box.append(seg,body);
+ let selType='timer';
+ seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();});
+ function doAdd(type,val,lab,opts){const r=engine.add(type,val?val.value:'',lab.value.trim(),opts);
+ if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;}
+ if(val){val.classList.remove('bad');if(!val.disabled)val.value='';}lab.value='';toast('added '+type,'gold');render();}
+ function paintBody(){
+ body.innerHTML='';
+ if(selType==='timer'){
+ const ps=el('div','presets');
+ engine.presetsFor('timer').forEach(p=>{const c=el('span','preset'+(p.locked?'':''),p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);});
+ ps.appendChild(Object.assign(el('span','preset','+ preset'),{}) );ps.lastChild.dataset.newp='1';ps.lastChild.style.opacity='.7';
+ const row=el('div','row');
+ const val=el('input','tin');val.placeholder='5m · 1h30m · 90s';val.style.flex='2';
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const rep=el('span','switch');const reptag=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">repeat</span>');
+ const addk=el('button','key on',GL.add+' ADD');
+ const repwrap=el('span',null,'');repwrap.style.display='inline-flex';repwrap.style.alignItems='center';repwrap.style.gap='6px';repwrap.append(rep,reptag);
+ row.append(val,lab,repwrap,addk);
+ body.append(ps,row);
+ rep.addEventListener('click',()=>rep.classList.toggle('on'));
+ ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset('timer',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ if(e.target.closest('[data-newp]')){const lb=prompt('Preset label:');if(!lb)return;const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset('timer',lb,vv);toast(r.ok?'preset added':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);});
+ addk.addEventListener('click',()=>doAdd('timer',val,lab,{repeat:rep.classList.contains('on')}));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('timer',val,lab,{repeat:rep.classList.contains('on')});}));
+ }
+ else if(selType==='alarm'){
+ const ps=el('div','presets');
+ engine.presetsFor('alarm').forEach(p=>{const c=el('span','preset',p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);});
+ const row=el('div','row');
+ const val=el('input','tin');val.placeholder='HH:MM · +30m · @hour · @half';val.style.flex='2';
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD');
+ row.append(val,lab,addk);
+ const drow=el('div','row');const days=el('div','days7');const sel=new Set();
+ DAYNAMES.forEach((d,i)=>{const b=el('button',null,d);b.dataset.d=i;b.title=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][i];days.appendChild(b);});
+ const quick=el('span',null,'');const wkd=el('button','key sm','weekdays');const evd=el('button','key sm','daily');
+ quick.style.display='inline-flex';quick.style.gap='5px';quick.append(wkd,evd);
+ const srow=el('div','row');const slbl=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">snooze</span>');
+ const sn=el('input','numin');sn.value='9';sn.inputMode='numeric';const smin=el('span',null,'<span style="color:var(--dim);font-size:10px">min</span>');
+ srow.append(slbl,sn,smin);
+ drow.append(days,quick);
+ body.append(ps,row,drow,srow);
+ function paintDays(){[...days.children].forEach(b=>b.classList.toggle('on',sel.has(+b.dataset.d)));}
+ days.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;const d=+b.dataset.d;sel.has(d)?sel.delete(d):sel.add(d);paintDays();});
+ wkd.addEventListener('click',()=>{sel.clear();[1,2,3,4,5].forEach(d=>sel.add(d));paintDays();});
+ evd.addEventListener('click',()=>{sel.clear();[0,1,2,3,4,5,6].forEach(d=>sel.add(d));paintDays();});
+ function opts(){return {days:[...sel],snooze:parseInt(sn.value,10)||9};}
+ ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]');
+ if(del){const r=engine.deletePreset('alarm',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;}
+ const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);});
+ addk.addEventListener('click',()=>doAdd('alarm',val,lab,opts()));
+ [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('alarm',val,lab,opts());}));
+ }
+ else if(selType==='stopwatch'){
+ const row=el('div','row');
+ const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD');
+ row.append(lab,addk);
+ const note=el('div','engrave','counts up from zero · lap while running');
+ body.append(row,note);
+ addk.addEventListener('click',()=>doAdd('stopwatch',null,lab));
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);});
+ }
+ else {
+ const ps=el('div','presets');
+ engine.pomoPresets.forEach(p=>{const c=el('span','preset',p.label);c.dataset.pp=p.label;ps.appendChild(c);});
+ const cfg=el('div','cfg');const mk=v=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;};
+ const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv);
+ const auto=el('span','switch on');auto.dataset.on='1';
+ const rW=el('div','crow');rW.append(el('span','lbl','Work'),el('span','sl','S'),ws,el('span','sl','L'),wl,el('span','u','min'));
+ const rR=el('div','crow');rR.append(el('span','lbl','Rest'),el('span','sl','S'),rs,el('span','sl','L'),rl,el('span','u','min'));
+ const rI=el('div','crow');rI.append(el('span','lbl','Long ev.'),iv,el('span','u','cycles → long work + long break'));
+ const rA=el('div','crow');rA.append(el('span','lbl','Auto'),auto,el('span','u','advance into the next phase'));
+ cfg.append(rW,rR,rI,rA);
+ const row=el('div','row');const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2';
+ const addk=el('button','key on',GL.add+' ADD CYCLE');row.append(lab,addk);
+ body.append(ps,cfg,row);
+ auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';});
+ ps.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return;
+ ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;[...ps.children].forEach(x=>x.classList.toggle('on',x===c));flash(ws,wl,rs,rl,iv);});
+ function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;}
+ addk.addEventListener('click',()=>{const r=engine.add('pomodoro','',lab.value.trim(),{ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on});
+ if(!r.ok){toast(r.reason,'red');return;}lab.value='';toast('pomodoro added','gold');render();});
+ lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();});
+ }
+ }
+ paintBody();
+ return box;
+ }
+
+ function renderBar(){
+ const p=engine.barPayload();
+ bar.className='wmod '+p.cls;bar.title=p.tip.join('\n');
+ bar.innerHTML=`<span class="wg">${p.glyph}</span>`+(p.text?`<span class="wt">${p.text}</span>`:'')+(p.plus>0?`<span class="wp">+${p.plus}</span>`:'');
+ tip.innerHTML=`<div class="th">hover tooltip</div>`+p.tip.map((l,i)=>`<div class="tl" style="${i===0?'color:var(--steel)':''}">${l}</div>`).join('');
+ }
+
+ function render(){
+ head.querySelector('.cnt').textContent=engine.count();
+ const rows=engine.rows(),primId=engine.effectivePrimary();
+ const h=rows.find(r=>r.id===primId);
+ hero.className='hero'+(h&&h.paused?' paused':'')+(h&&h.ringing?' ringing':'')+(h&&flashing.has(h.id)?' fire':'');
+ if(!h){hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>';}
+ else{
+ const ringP=h.prog!=null?Math.round(h.prog*100):0;
+ const donutHTML = h.type==='stopwatch'
+ ? `<span class="swdial" title="seconds sweep"><span class="swtick"></span><span class="swtick q"></span><span class="swtick h"></span><span class="swtick t"></span><span class="swhand" style="transform:rotate(${Math.round((h.sweep||0)*360)}deg)"></span><span class="swhub"></span></span>`
+ : `<span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px"><b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b></span>`;
+ let transport;
+ if(h.ringing) transport=`<button class="key" data-act="snooze" data-id="${h.id}">${GL.play} SNOOZE ${engine.find(h.id).snooze}m</button><button class="key red" data-act="dismiss" data-id="${h.id}">DISMISS</button>`;
+ else if(h.type==='stopwatch') transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button><button class="key" data-act="lap" data-id="${h.id}">LAP</button><button class="key red" data-act="stop" data-id="${h.id}">STOP</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;
+ else {const start=h.pomo&&h.pomo.awaiting?(GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')):(h.paused?GL.play+' RESUME':GL.paused+' PAUSE');
+ transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${start}</button><button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;}
+ hero.innerHTML=
+ `<div class="htop">
+ <div class="rhs">
+ <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>${badgesHTML(h.badges)}${h.pomo?dotsHTML(h.pomo):''}</div>
+ <div class="hlabel">${h.label}</div>
+ <div class="hbig">${h.disp}${h.type==='stopwatch'&&h.lastLap!=null?`<span class="lapbadge">LAP ${fmtTime(h.lastLap)}</span>`:''}</div>
+ <div class="hsub">${h.sub}</div>
+ </div>
+ <div class="donut">${donutHTML}</div>
+ </div>
+ <div class="transport">${transport}</div>`;
+ }
+ list.innerHTML='';
+ const rest=rows.filter(r=>r.id!==primId);
+ list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>'));
+ if(!rest.length)list.appendChild(el('div','empty',h?'Only one item is queued. Add more above.':''));
+ rest.forEach(r=>{
+ const row=el('div','qrow'+(r.paused?' paused':'')+(r.ringing?' ringing':'')+(flashing.has(r.id)?' fire':''));
+ let ctrls;
+ if(r.ringing)ctrls=`<button class="key sm" data-act="snooze" data-id="${r.id}">SNOOZE</button><button class="key sm red" data-act="dismiss" data-id="${r.id}">OFF</button>`;
+ else if(r.type==='stopwatch')ctrls=`<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button><button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>`;
+ else ctrls=`<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button><button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>`;
+ row.innerHTML=`<span class="lamp ${r.ringing?'red':(r.paused?'off':(r.warn?'red':''))}"></span><span class="g">${r.glyph}</span><span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span><span class="rd">${r.disp}</span><span class="ctrls">${ctrls}</span>`;
+ list.appendChild(row);
+ });
+ renderBar();
+ }
+ engine._render=render;engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);};engine._toast=toast;
+ render();
+}
+
+const engine=new Engine();
+const pomo=engine.add('pomodoro','','Deep work',{ws:25,wl:50,rs:5,rl:15,iv:4,auto:true});
+engine.add('timer','45s','Egg',{repeat:false});
+engine.add('timer','5m','Tea',{repeat:true});
+const sw=engine.add('stopwatch','','Debug run');
+{const s=engine.find(sw.id);s.start=engine.now()-215;s.laps=[{t:72},{t:158}];} // ~3:35 elapsed, last lap 2:38
+engine.add('alarm','07:00','Wake',{days:[1,2,3,4,5],snooze:9});
+engine.promote(sw.id); // show the new stopwatch sweep-dial + lap badge in the hero
+mount(document.getElementById('panel'), engine, document.getElementById('wbar'), document.getElementById('wtip'));
+
+function loop(){const fired=engine.tick?engine.tick():tickEngine(engine);
+ for(const f of fired){engine._flash(f.id);engine._toast((f.kind==='done'?GL.bell+' ':'')+f.title,f.kind==='done'?'red':'gold');tryNotify(f.title,f.body);}
+ engine._render();}
+/* engine.tick lives on the class below via prototype patch to keep add()/tick together readable */
+Engine.prototype.tick=function(){const now=this.now(),fired=[];
+ for(const it of this.items.slice()){
+ if(!COUNTDOWN.includes(it.type)||this.isPaused(it)||it.ringing)continue;
+ if(it.target-now>0)continue;
+ if(it.type==='pomodoro'){const c=it.cfg;
+ if(it.phase==='work'){const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`});
+ it.phase='rest';const len=deep?c.rl:c.rs;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}
+ else{it.cycle+=1;const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`});
+ it.phase='work';const len=deep?c.wl:c.ws;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}}
+ else if(it.type==='alarm'){fired.push({id:it.id,kind:'done',title:'Alarm · '+(it.label||fmtClock(it.target)),body:'alarm ringing'});it.ringing=true;}
+ else{if(it.repeat){fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done')+' · repeating',body:'restarted'});it.target=now+it.total;}
+ else{fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done'),body:'time’s up'});this.cancel(it.id);}}
+ }
+ return fired;};
+setInterval(loop, reduced?1000:250);
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html
new file mode 100644
index 0000000..0258f20
--- /dev/null
+++ b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html
@@ -0,0 +1,1359 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Instrument consoles — network + bluetooth</title>
+<style>
+:root {
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1280px;margin:0 auto 1.8rem}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:80ch}
+.masthead b{color:var(--silver);font-weight:700}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+.aside{flex:1 1 300px;min-width:280px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.24em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.22rem 0 .22rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li em{color:var(--dim);font-style:normal}
+.aside li b{color:var(--cream);font-weight:700}
+.demo-box{border:1px dashed var(--wash);border-radius:10px;padding:.8rem 1rem;margin-top:1rem}
+.demo-box label{display:flex;gap:.6rem;align-items:center;font-size:.82rem;cursor:pointer;color:var(--silver);margin-top:.45rem}
+.demo-box label:first-child{margin-top:0}
+.demo-box input{accent-color:#dab53d}
+.demo-box .hint{color:var(--dim);font-size:.73rem;margin:.15rem 0 0 1.5rem}
+.reset{font:inherit;font-size:.78rem;color:var(--silver);background:transparent;border:1px solid var(--wash);
+ border-radius:8px;padding:.4rem .9rem;cursor:pointer;margin-top:.8rem}
+.reset:hover{background:var(--wash)}
+
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;
+ transition:opacity .25s,transform .25s}
+.panel.closed{opacity:0;transform:translateY(-8px);pointer-events:none}
+.reopen{display:none;font:inherit;font-size:.75rem;color:var(--gold);background:transparent;
+ border:1px dashed var(--gold);border-radius:8px;padding:.5rem 1rem;cursor:pointer;margin-top:.6rem}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);
+ border-radius:4px;padding:1px 6px;display:none}
+.badge.show{display:inline-block}
+.badge.red{background:var(--fail);color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;
+ cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+.switch{width:38px;height:20px;border-radius:10px;background:var(--wash);
+ border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;
+ border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:19px;background:var(--gold)}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;margin:12px 0 6px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:12px}
+.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer}
+.engrave .act:hover{color:var(--gold)}
+
+.chan .line1{display:flex;align-items:baseline;gap:9px}
+.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px}
+.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px}
+.chan .chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash)}
+.chan .chip:hover{color:var(--gold)}
+.chan .chip.on{color:var(--gold)}
+.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px}
+.ladder i{width:4px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px}
+.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px}
+.ladder.l1 i:nth-child(-n+1){background:var(--gold)}
+.ladder.l2 i:nth-child(-n+2){background:var(--gold)}
+.ladder.l3 i:nth-child(-n+3){background:var(--gold)}
+.ladder.l4 i{background:var(--gold)}
+
+/* section row budgets: lists never grow the panel — they scroll inside it,
+ cut at a half row so the peek says "there's more" */
+.sec-scroll{overflow-y:auto;overscroll-behavior:contain}
+#networks.sec-scroll{max-height:160px}
+#tunnels.sec-scroll{max-height:131px}
+#b-paired.sec-scroll{max-height:160px}
+#b-nearby.sec-scroll{max-height:131px}
+.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px}
+.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer;position:relative}
+.lamp-row:hover{background:var(--wash)}
+.lamp-row .who{color:var(--silver);white-space:nowrap}
+.lamp-row .who b{color:var(--cream)}
+.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lamp-row.busy{pointer-events:none}
+.lamp-row .zap,.lamp-row .pen{display:none;color:var(--dim);border:0;background:transparent;font:inherit;font-size:.85rem;
+ cursor:pointer;border-radius:5px;padding:0 5px;flex:0 0 auto}
+.lamp-row:hover .zap,.lamp-row:hover .pen{display:inline-block}
+.lamp-row .zap:hover{color:var(--fail)}
+.lamp-row .pen:hover{color:var(--gold)}
+.lamp-row.armed-soft{background:rgba(218,181,61,.10)}
+.lamp-row.armed-soft .what{color:var(--gold)}
+.lamp-row.armed{background:rgba(203,107,77,.12)}
+.lamp-row.armed .what{color:var(--fail)}
+.lamp-row.armed .zap{display:inline-block;color:var(--fail)}
+
+.console-btns{display:flex;gap:8px;margin-top:2px}
+.c-btn{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11.5px;
+ background:linear-gradient(180deg,#23211e,#191715);color:var(--silver);
+ border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.c-btn:hover{color:var(--gold);border-color:var(--gold)}
+.c-btn:active{transform:translateY(1px)}
+.c-btn:disabled{opacity:.4;pointer-events:none}
+
+.meters{display:flex;gap:12px;margin-top:10px}
+.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;cursor:default;position:relative}
+.meter.testing{animation:flash 1s ease-in-out infinite;border-color:var(--gold)}
+@keyframes flash{50%{box-shadow:0 0 10px 1px rgba(218,181,61,.35)}}
+.meter.held{border-color:var(--gold);cursor:pointer}
+.meter .hold-tag{display:none;position:absolute;top:6px;right:8px;font-size:.56rem;letter-spacing:.2em;
+ color:var(--panel);background:var(--gold);border-radius:3px;padding:0 4px}
+.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)}
+.meter .mode-tag.test{color:var(--gold)}
+.meter .mode-tag.off{color:var(--dim)}
+.meter.held .hold-tag{display:block}
+.meter .dial{position:relative;height:52px;overflow:hidden;margin-top:13px}
+.meter .arc{position:absolute;inset:0 0 -52px 0;border:2px solid var(--wash);border-radius:50%}
+.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:10px;background:var(--steel);transform-origin:50% 52px}
+.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:44px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(-60deg);border-radius:2px;
+ box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .45s cubic-bezier(.3,1.3,.5,1)}
+.meter .needle.dead{background:var(--wash);box-shadow:none}
+.meter .needle.low{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)}
+.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)}
+.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
+.meter .m-value small{color:var(--dim);font-weight:400}
+.meter .m-value.low{color:var(--fail)}
+.meter .m-label{color:var(--steel);font-size:.62rem;letter-spacing:.26em;text-align:center;margin-top:2px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.meter-note{color:var(--dim);font-size:10px;text-align:center;margin-top:5px;min-height:1.2em}
+
+.well{border:1px solid var(--wash);border-radius:10px;background:var(--well)}
+.outwrap{position:relative;margin-top:10px}
+.outwrap .output{margin-top:0}
+.o-clear{display:none;position:absolute;top:3px;right:5px;z-index:2;color:var(--dim);
+ border:0;background:var(--well);font:inherit;font-size:.8rem;cursor:pointer;
+ border-radius:50%;width:20px;height:20px;line-height:1}
+.o-clear:hover{color:var(--silver);background:var(--wash)}
+.outwrap.has .o-clear{display:block}
+.output{margin-top:10px;padding:8px 10px;max-height:170px;overflow-y:auto;font-size:11.5px}
+.output:empty{padding:4px 10px;min-height:10px}
+.o-step{display:flex;gap:8px;align-items:flex-start;padding:2.5px 0}
+.o-step .lamp{margin-top:4px;width:7px;height:7px}
+.o-step .t b{color:var(--cream);font-weight:700}
+.o-step .t .why{color:var(--dim);display:block;font-size:10.5px}
+.o-step .t .ev{color:var(--steel);display:block;font-size:11px}
+.o-step.repair .t b{color:var(--gold)}
+.o-line{padding:2px 0;color:var(--silver)}
+.o-line b{color:var(--steel);font-weight:400}
+.o-verdict{margin-top:5px;padding-top:5px;border-top:1px solid var(--wash);color:var(--gold);font-weight:700}
+.o-verdict.ok{color:var(--pass)}
+.o-tip{color:var(--dim);font-size:10.5px;margin-top:4px}
+
+.toast{margin-top:9px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)}
+
+.overlay{position:absolute;inset:0;background:rgba(10,12,13,.82);border-radius:14px;display:none;
+ align-items:center;justify-content:center;z-index:5}
+.overlay.show{display:flex}
+.dlg{background:var(--panel);border:1px solid var(--gold);border-radius:12px;padding:16px 18px;width:300px}
+.dlg h4{color:var(--cream);font-size:13px;margin-bottom:4px}
+.dlg .sub{color:var(--dim);font-size:11px;margin-bottom:10px}
+.dlg .passkey{color:var(--gold-hi);font-size:22px;font-weight:700;letter-spacing:.18em;
+ text-align:center;margin:6px 0 12px;font-variant-numeric:tabular-nums}
+.dlg input{width:100%;font:inherit;font-size:12.5px;color:var(--silver);background:var(--well);
+ border:1px solid var(--wash);border-radius:7px;padding:7px 9px;margin-bottom:8px;caret-color:var(--gold)}
+.dlg input:focus{outline:none;border-color:var(--gold)}
+.dlg .dlg-btns{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}
+.btn{font:inherit;font-size:12px;cursor:pointer;background:var(--slate);color:var(--cream);
+ border:1px solid var(--gold);border-radius:8px;padding:5px 12px}
+.btn:hover{background:var(--slate-hi)}
+.btn.quiet{background:transparent;border-color:var(--wash);color:var(--silver)}
+.btn.quiet:hover{background:var(--wash)}
+
+*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent}
+::-webkit-scrollbar{width:6px;height:6px}
+::-webkit-scrollbar-track{background:transparent}
+::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px}
+::-webkit-scrollbar-thumb:hover{background:var(--slate-hi)}
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div>
+ <h1>Network + Bluetooth — the pair</h1>
+ <p>Same faceplate, same idioms: lamp rows act on click, hover reveals ✎ rename and the
+ arm-to-fire ✕, gauges under the console keys (throughput needles on NET, battery fuel
+ gauges on BT), doctor streams and repairs in the output well. <b>Try the power switch
+ on BT·01</b> — everything follows it.</p>
+</header>
+
+<div class="stage">
+
+ <!-- ============================ NET·01 ============================ -->
+ <div class="slot">
+ <div class="slot-label">net·01 — as iterated</div>
+ <div class="panel" id="p">
+ <div class="overlay" id="ov">
+ <div class="dlg">
+ <h4 id="dlg-title">Join network</h4>
+ <div class="sub" id="dlg-sub">WPA2 — password required</div>
+ <input id="dlg-ssid" placeholder="SSID" style="display:none">
+ <input id="dlg-pass" type="password" placeholder="password">
+ <div class="dlg-btns">
+ <button class="btn quiet" onclick="dlgClose()">Cancel</button>
+ <button class="btn" id="dlg-go" onclick="dlgGo()">Join</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="lamp"></span>
+ <span class="state-word" id="state">ONLINE</span>
+ <span class="badge" id="badge">TUNNEL</span>
+ <span class="badge" id="air-badge">AIRPLANE</span>
+ <span class="unit">NET·01</span>
+ <span class="switch on" id="n-power" onclick="wifiPower()" title="WiFi radio"></span>
+ <button class="x-btn" onclick="closePanel()" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">channel</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid" id="ch-ssid">@Hyatt_WiFi</span>
+ <span class="ladder l3" id="ch-ladder"><i></i><i></i><i></i><i></i></span>
+ <span class="dim" style="font-size:11px;white-space:nowrap" id="ch-dbm">-59 dBm · 44 ms</span></div>
+ <div class="line2" id="ch-route">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div>
+ </div>
+
+ <div class="engrave">networks<span class="cnt" id="net-count"></span><span class="act" onclick="dlgHidden()" title="Join a hidden SSID">+ hidden</span></div>
+ <div id="networks" class="sec-scroll"></div>
+
+ <div class="engrave">tunnels<span class="cnt" id="tun-count"></span></div>
+ <div id="tunnels" class="sec-scroll"></div>
+
+ <div class="engrave">console</div>
+ <div class="console-btns">
+ <button class="c-btn" id="b-doctor" onclick="runDoctor()">DOCTOR</button>
+ <button class="c-btn" id="b-speed" onclick="runSpeed()">SPEED TEST</button>
+ </div>
+
+ <div class="meters">
+ <div class="meter" id="m-rx" onclick="release('rx')">
+ <span class="mode-tag" id="mt-rx">LIVE</span>
+ <span class="hold-tag">HOLD</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-rx"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-rx">0.1</span> <small>Mbps</small></div>
+ <div class="m-label">RX · DOWN</div>
+ </div>
+ <div class="meter" id="m-tx" onclick="release('tx')">
+ <span class="mode-tag" id="mt-tx">LIVE</span>
+ <span class="hold-tag">HOLD</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-tx"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-tx">0.1</span> <small>Mbps</small></div>
+ <div class="m-label">TX · UP</div>
+ </div>
+ </div>
+ <div class="meter-note" id="m-note"></div>
+
+ <div class="outwrap" id="outwrap">
+ <button class="o-clear" onclick="clearOut('out')" title="Dismiss results">✕</button>
+ <div class="well output" id="out"></div>
+ </div>
+ <div class="toast" id="toastEl"></div>
+ </div>
+ <button class="reopen" id="reopen" onclick="openPanel()">reopen NET·01 (bar click)</button>
+ </div>
+
+ <!-- ============================ BT·01 ============================ -->
+ <div class="slot">
+ <div class="slot-label">bt·01 — new, same idioms</div>
+ <div class="panel" id="bp">
+ <div class="overlay" id="bov">
+ <div class="dlg">
+ <h4 id="bdlg-title">Pair device</h4>
+ <div class="sub" id="bdlg-sub">confirm the passkey matches the device</div>
+ <div class="passkey" id="bdlg-key" style="display:none">847 291</div>
+ <input id="bdlg-name" placeholder="device name" style="display:none">
+ <div class="dlg-btns">
+ <button class="btn quiet" onclick="bdlgClose()">Cancel</button>
+ <button class="btn" id="bdlg-go" onclick="bdlgGo()">Confirm</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="b-lamp"></span>
+ <span class="state-word" id="b-state">POWERED</span>
+ <span class="badge red" id="b-badge">LOW BATT</span>
+ <span class="badge" id="b-air-badge">AIRPLANE</span>
+ <span class="unit">BT·01</span>
+ <span class="switch on" id="b-power" onclick="btPower()" title="Adapter power"></span>
+ <button class="x-btn" onclick="closeBt()" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">adapter</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid">intel ax211</span>
+ <span class="dim" style="font-size:11px;margin-left:auto">hci0</span></div>
+ <div class="line2" id="b-adapter-line">
+ <span class="chip" id="b-disco" onclick="btDisco()">discoverable off</span>
+ <span> · </span><span id="b-conn-count">1 device connected</span></div>
+ </div>
+
+ <div class="engrave">paired<span class="cnt" id="b-paired-count"></span></div>
+ <div id="b-paired" class="sec-scroll"></div>
+
+ <div class="engrave">nearby<span class="cnt" id="b-nearby-count"></span><span class="act" id="b-scan-note"></span></div>
+ <div id="b-nearby" class="sec-scroll"></div>
+
+ <div class="engrave">console</div>
+ <div class="console-btns">
+ <button class="c-btn" id="bb-doctor" onclick="btDoctor()">DOCTOR</button>
+ <button class="c-btn" id="bb-scan" onclick="btScan()">SCAN</button>
+ </div>
+
+ <div class="meters">
+ <div class="meter" id="bm-0">
+ <span class="mode-tag" id="bmt-0">LIVE</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="bn-0"></div><div class="hub"></div></div>
+ <div class="m-value" id="bvw-0"><span id="bv-0">72</span> <small>%</small></div>
+ <div class="m-label" id="bl-0">LOGI M650</div>
+ </div>
+ <div class="meter" id="bm-1">
+ <span class="mode-tag off" id="bmt-1">—</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle dead" id="bn-1"></div><div class="hub"></div></div>
+ <div class="m-value" id="bvw-1"><span id="bv-1">—</span></div>
+ <div class="m-label" id="bl-1">NO DEVICE</div>
+ </div>
+ </div>
+ <div class="meter-note">battery · connected devices</div>
+
+ <div class="outwrap" id="b-outwrap">
+ <button class="o-clear" onclick="clearOut('b-out')" title="Dismiss results">✕</button>
+ <div class="well output" id="b-out"></div>
+ </div>
+ <div class="toast" id="b-toastEl"></div>
+ </div>
+ <button class="reopen" id="b-reopen" onclick="openBt()">reopen BT·01 (bar click)</button>
+ </div>
+
+ <!-- ============================ NOTES ============================ -->
+ <div class="aside">
+ <h3>The bt mapping</h3>
+ <ul>
+ <li><b>Power switch on the faceplate</b> — flip it: devices drop, gauges die, keys disable, state goes OFF. Flip back: the mouse auto-reconnects. <em>(the switch-placement ask, in console form)</em></li>
+ <li><b>Battery fuel gauges</b> are BT's meters — one per connected device, needle at charge, red under 15% with a LOW BATT badge on the faceplate.</li>
+ <li><b>Paired rows toggle on click</b> (connect/disconnect), exactly like tunnels. Hover: ✎ rename <em>(the rename ask)</em>, ✕ arm-to-forget.</li>
+ <li><b>Nearby rows pair on click</b> — passkey confirm dialog, then the device moves up to PAIRED and connects. SCAN refreshes the neighborhood.</li>
+ <li><b>discoverable off</b> in the adapter line is a click-toggle (gold when on).</li>
+ <li><b>Disconnect is arm-first on the active row</b> — first click arms in gold ("disconnect? click again"), second fires. Gold, not terracotta: disruptive, not destructive.</li>
+ <li><b>NET·01 grew the wifi radio switch</b> (faceplate, same spot as BT's). Airplane mode is system-level: both switches drop, AIRPLANE badges light, and a switch flipped under airplane refuses with the way out. A plugged ethernet cable keeps NET·01 online through it.</li>
+ <li><b>DOCTOR does it all here too</b>: adapter → radio → service → powered → devices → audio profile. Tick the degraded-audio switch and run it: it finds HSP, flips to A2DP, verifies the sink followed.</li>
+ </ul>
+ <div class="demo-box">
+ <label><input type="checkbox" id="cafe" onchange="setScenario(this.checked)"> net: walk into a new café</label>
+ <label><input type="checkbox" id="breakdns"> net: broken hotel DNS (then DOCTOR)</label>
+ <label><input type="checkbox" id="ethercb" onchange="setEther(this.checked)"> net: plug in an ethernet cable</label>
+ <label><input type="checkbox" id="aircb" onchange="setAirplane(this.checked)"> both: airplane mode (Super+Shift+A)</label>
+ <label><input type="checkbox" id="airportcb" onchange="setAirport(this.checked)"> both: airport terminal (crowded airspace)</label>
+ <label><input type="checkbox" id="lowbatt" onchange="btLowBatt(this.checked)"> bt: mouse battery low</label>
+ <label><input type="checkbox" id="badaudio"> bt: degraded audio profile (then DOCTOR)</label>
+ <div class="hint">the audio one needs the headphones connected — click WH-1000XM4 first.</div>
+ <button class="reset" onclick="location.search=''">reset prototypes</button>
+ </div>
+ </div>
+</div>
+
+<script>
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+const $ = id => document.getElementById(id);
+const T = f => reduced ? Math.max(10, f*0.02) : f;
+
+/* =========================================================== NET·01 */
+let busy = false;
+const HOTEL_NETS = () => [
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:true, sig:3, active:true},
+ {id:'meeting', ssid:'Hyatt_Meeting', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'roku', ssid:'DIRECT-roku-882',sec:'WPA2',stored:false, range:true, sig:2, active:false},
+ {id:'xfinity', ssid:'xfinitywifi', sec:null, stored:false, range:true, sig:1, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const CAFE_NETS = () => [
+ {id:'cafe5g', ssid:'CafeAmore_5G', sec:'WPA2', stored:false, range:true, sig:4, active:false, ip:'10.11.4.27/22 · gw 10.11.4.1'},
+ {id:'cafeg', ssid:'CafeAmore_Guest',sec:null, stored:false, range:true, sig:3, active:false, ip:'10.11.8.102/22 · gw 10.11.8.1'},
+ {id:'iot', ssid:'Neighbor_IoT', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const AIRPORT_NETS = () => [
+ {id:'ord', ssid:'ORD Free Wi-Fi', sec:null, stored:false, range:true, sig:4, active:false, ip:'10.40.2.19/18 · gw 10.40.0.1'},
+ {id:'boingo',ssid:'Boingo Hotspot', sec:null, stored:false, range:true, sig:3, active:false},
+ {id:'united',ssid:'United_Club', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'sky', ssid:'SkyClub_5G', sec:'WPA2', stored:false, range:true, sig:3, active:false},
+ {id:'aa', ssid:'AmericanAir-Lounge',sec:'WPA2', stored:false, range:true, sig:2, active:false},
+ {id:'sbux', ssid:'Starbucks WiFi', sec:null, stored:false, range:true, sig:2, active:false},
+ {id:'tom', ssid:"Tom's iPhone", sec:'WPA2', stored:false, range:true, sig:2, active:false},
+ {id:'hp', ssid:'HP-Print-88-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'gate', ssid:'Gate B12 Display', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'clear', ssid:'CLEAR-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'tsa', ssid:'TSA-Ops', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'dfw', ssid:'ORD-Employee', sec:'WPA2', stored:false, range:true, sig:1, active:false},
+ {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+ {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false},
+];
+const AIRPORT_NEARBY = () => [
+ {id:'ap1', name:'AirPods Pro', kind:'audio', passkey:'118 402'},
+ {id:'ap2', name:'AirPods (3rd gen)', kind:'audio', passkey:'220 981'},
+ {id:'gb2', name:'Galaxy Buds2', kind:'audio', passkey:'914 555'},
+ {id:'pbp', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'},
+ {id:'xm5', name:'Sony WF-1000XM5', kind:'audio', passkey:'774 210'},
+ {id:'jab', name:'Jabra Elite 7', kind:'audio', passkey:'333 190'},
+ {id:'aw', name:'Apple Watch', kind:'wearable',passkey:'602 118'},
+ {id:'gar', name:'Garmin Fenix 8', kind:'wearable',passkey:'488 007'},
+ {id:'tile', name:'Tile Tracker', kind:'tracker', passkey:'150 129'},
+ {id:'jbl2', name:'JBL Charge 5', kind:'audio', passkey:'847 291'},
+ {id:'bose', name:'Bose QC Ultra', kind:'audio', passkey:'962 340'},
+ {id:'gate2',name:'[TV] Gate B12', kind:'display', passkey:'302 118'},
+];
+let NETS = HOTEL_NETS();
+const WG = (id, who) => ({id, who, upWhat:'10.2.0.2/32 · route owner',
+ downWhat:'wireguard (NM) · down', up:false, ownsRoute:true, dev:'wgpvpn'});
+const TUNNELS = [
+ {id:'ts', who:'tailscale · velox', upWhat:'100.127.238.103 · 4/6 peers', downWhat:'down',
+ up:true, ownsRoute:false, dev:'tailscale0'},
+ WG('usny','USNY'), WG('usdc','USDC'), WG('uscala','USCALA'), WG('uscasf','USCASF'),
+ WG('usgaat','USGAAT'), WG('szur1','switzerlan-zurich1'), WG('szur2','switzerlan-zurich2'),
+ {id:'proton', who:'Proton VPN CLI', upWhat:'', downWhat:'down', up:false, needsLogin:true},
+];
+const tinit = () => ({ts:true, usny:false, usdc:false, uscala:false, uscasf:false,
+ usgaat:false, szur1:false, szur2:false, proton:false});
+const tstate = tinit();
+let connected = true;
+let routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+let ether = { present:false, routed:false,
+ ip:'172.20.7.44/20 · gw 172.20.0.1', dev:'enp3s0' };
+let armed = null, armTimer = null;
+let wifiOn = true, airplane = false;
+let armedDisc = null, armDiscTimer = null;
+let lastSsid = '@Hyatt_WiFi';
+
+function renderNets(){
+ const host = $('networks'); host.innerHTML = '';
+ if (ether.present){
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${ether.routed?'lamp':'lamp gold'}" id="eth-lamp"></span>`+
+ `<span class="who">${ether.routed?'<b>'+ether.dev+'</b>':ether.dev}</span>`+
+ `<span class="what" id="eth-what">${ether.routed?'active · wired · 1.0 Gbps':'wired · standby'}</span>`;
+ row.onclick = () => toggleEther();
+ host.appendChild(row);
+ }
+ if (!wifiOn){
+ const note = document.createElement('div');
+ note.className = 'lamp-row';
+ note.style.cursor = 'default';
+ note.innerHTML = `<span class="lamp off"></span><span class="who dim">wifi radio off</span>`+
+ `<span class="what">${airplane ? 'airplane mode' : 'flip the switch to scan'}</span>`;
+ host.appendChild(note);
+ tips('networks');
+ return;
+ }
+ const inRange = NETS.filter(n => n.range).sort((a,b) => (b.active-a.active) || (b.sig-a.sig));
+ const out = NETS.filter(n => !n.range);
+ $('net-count').textContent = '· ' + inRange.length + ' in range';
+ for (const n of [...inRange, ...out]){
+ const row = document.createElement('div');
+ row.className = 'lamp-row' + (armed===n.id ? ' armed' : '')
+ + (armedDisc===n.id ? ' armed-soft' : '');
+ const lamp = n.active ? 'lamp' : (n.range ? 'lamp gold' : 'lamp off');
+ const what = armed===n.id ? 'forget? click ✕ again'
+ : armedDisc===n.id ? 'disconnect? click again'
+ : n.active ? (ether.present && ether.routed ? 'connected · standby · ' : 'active · ') + (n.sec || 'open')
+ : !n.range ? 'stored · out of range'
+ : (n.stored ? 'stored · ' : '') + (n.sec || 'open') + ' · ' + [null,'22%','44%','61%','78%'][n.sig];
+ row.innerHTML = `<span class="${lamp}"></span>`+
+ `<span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+
+ (n.range && !n.active ? `<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>` : '')+
+ `<span class="what" id="nw-${n.id}">${what}</span>`+
+ (n.stored ? `<button class="zap" title="Forget ${n.ssid}">✕</button>` : '');
+ if (n.stored) row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); armForget(n.id); };
+ row.onclick = n.active ? () => armDisconnect(n.id) : () => joinNet(n.id);
+ host.appendChild(row);
+ }
+ tips('networks');
+}
+function armDisconnect(id){
+ if (busy) return;
+ const n = NETS.find(x=>x.id===id);
+ if (armedDisc === id){ // second click: disconnect
+ clearTimeout(armDiscTimer); armedDisc = null;
+ busy = true;
+ const what = $('nw-'+id);
+ if (what) what.textContent = 'disconnecting…';
+ setTimeout(() => {
+ busy = false;
+ n.active = false; connected = false;
+ if (!(ether.present && ether.routed)) tstate.ts = false;
+ netFace();
+ renderTunnels(); renderNets();
+ toast('disconnected from ' + n.ssid);
+ }, T(1100));
+ return;
+ }
+ armedDisc = id; renderNets(); // first click: arm (gold — disruptive, not destructive)
+ clearTimeout(armDiscTimer);
+ armDiscTimer = setTimeout(() => { armedDisc = null; renderNets(); }, 3000);
+}
+function armForget(id){
+ const n = NETS.find(x=>x.id===id);
+ if (armed === id){
+ clearTimeout(armTimer); armed = null;
+ NETS.splice(NETS.indexOf(n), 1); renderNets();
+ toast(`${n.ssid} forgotten`);
+ return;
+ }
+ armed = id; renderNets();
+ clearTimeout(armTimer);
+ armTimer = setTimeout(() => { armed = null; renderNets(); }, 3000);
+}
+let joining = null;
+function joinNet(id){
+ if (busy) return;
+ if (!wifiOn){ toast('wifi radio is off', true); return; }
+ const n = NETS.find(x=>x.id===id);
+ if (!n.range){ toast(n.ssid + ' is out of range', true); return; }
+ if (n.sec && !n.stored){ joining = n; dlgJoin(n); return; }
+ doJoin(n);
+}
+function doJoin(n){
+ busy = true;
+ const what = $('nw-'+n.id);
+ if (what) what.textContent = 'joining…';
+ lampState('JOINING','gold');
+ setTimeout(() => {
+ NETS.forEach(x => x.active = false);
+ n.active = true; n.stored = true;
+ busy = false;
+ $('ch-ssid').textContent = n.ssid;
+ $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][n.sig] + ' · 41 ms';
+ $('ch-ladder').className = 'ladder l'+n.sig;
+ connected = true;
+ lastSsid = n.ssid;
+ routeBase = n.ip || '172.20.2.108/20 · gw 172.20.0.1';
+ netFace();
+ if (!tstate.ts) tstate.ts = true;
+ renderTunnels(); renderNets();
+ toast('joined ' + n.ssid + ' — saved for next time');
+ }, T(1600));
+}
+function dlgJoin(n){
+ $('dlg-title').textContent = 'Join ' + n.ssid;
+ $('dlg-sub').textContent = n.sec + ' — password required';
+ $('dlg-ssid').style.display = 'none';
+ $('dlg-pass').value = '';
+ $('ov').classList.add('show');
+ $('dlg-pass').focus();
+}
+function dlgHidden(){
+ joining = 'hidden';
+ $('dlg-title').textContent = 'Join hidden network';
+ $('dlg-sub').textContent = 'SSID is not broadcast — enter it exactly';
+ $('dlg-ssid').style.display = 'block'; $('dlg-ssid').value = '';
+ $('dlg-pass').value = '';
+ $('ov').classList.add('show');
+ $('dlg-ssid').focus();
+}
+function dlgClose(){ $('ov').classList.remove('show'); joining = null; }
+function dlgGo(){
+ if (joining === 'hidden'){
+ const ssid = $('dlg-ssid').value.trim() || 'hidden-net';
+ const n = {id:'h'+Date.now(), ssid, sec:'WPA2', stored:true, range:true, sig:2, active:false};
+ NETS.splice(0,0,n); renderNets(); dlgClose(); doJoin(n);
+ return;
+ }
+ const n = joining; dlgClose(); if (n) doJoin(n);
+}
+
+function renderTunnels(){
+ const host = $('tunnels'); host.innerHTML = '';
+ for (const t of TUNNELS){
+ const up = tstate[t.id];
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${up?'lamp':'lamp off'}" id="tl-${t.id}"></span>`+
+ `<span class="who">${t.who}</span><span class="what" id="tw-${t.id}">${up?(t.upWhat||'up'):t.downWhat}</span>`;
+ row.onclick = () => toggleTunnel(t.id);
+ host.appendChild(row);
+ }
+ $('tun-count').textContent = '· ' + TUNNELS.filter(t=>tstate[t.id]).length + ' up of ' + TUNNELS.length;
+ tips('tunnels');
+ updateRoute();
+}
+function updateRoute(){
+ if (!connected && !(ether.present && ether.routed)){
+ $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off';
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = '';
+ $('ch-route').textContent = !wifiOn ? (airplane ? 'airplane mode' : 'flip the radio switch to scan')
+ : 'join a network below';
+ $('badge').classList.remove('show');
+ return;
+ }
+ const owner = TUNNELS.find(t => t.ownsRoute && tstate[t.id]);
+ const wired = ether.present && ether.routed;
+ const base = wired ? ether.ip : routeBase;
+ const dev = wired ? ether.dev : 'wlp170s0';
+ $('ch-route').textContent = base + ' · route ' + (owner ? owner.dev + ' (tunnel)' : dev);
+ $('badge').classList.toggle('show', !!owner);
+ /* channel headline follows the routed link */
+ if (wired){
+ $('ch-ssid').textContent = ether.dev;
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = 'wired · 1.0 Gbps full-duplex';
+ } else if (connected){
+ const act = NETS.find(n => n.active);
+ if (act){
+ $('ch-ssid').textContent = act.ssid;
+ $('ch-ladder').style.display = '';
+ $('ch-ladder').className = 'ladder l' + act.sig;
+ $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][act.sig] + ' · 44 ms';
+ }
+ } else {
+ $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off';
+ $('ch-ladder').style.display = 'none';
+ $('ch-dbm').textContent = '';
+ }
+}
+/* the faceplate state word, derived from one place */
+function netFace(){
+ const wired = ether.present && ether.routed;
+ $('air-badge').classList.toggle('show', airplane);
+ if (wired){ lampState('ONLINE'); return; }
+ if (connected){ lampState('ONLINE'); return; }
+ if (airplane){ lampState('AIRPLANE','gold'); return; }
+ if (!wifiOn){ lampState('OFF','off'); $('lamp').className='lamp off'; return; }
+ lampState('OFFLINE','red');
+}
+function toggleTunnel(id){
+ if (busy) return;
+ const t = TUNNELS.find(x=>x.id===id), up = tstate[id];
+ const lamp = $('tl-'+id), what = $('tw-'+id);
+ busy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = t.needsLogin && !up ? 'connecting…' : up ? 'bringing down…' : 'bringing up…';
+ setTimeout(() => {
+ busy = false;
+ if (t.needsLogin && !up){
+ lamp.className = 'lamp red';
+ what.textContent = 'sign in first: protonvpn login';
+ toast('Proton: no account signed in — run: protonvpn login', true);
+ setTimeout(() => { lamp.className = 'lamp off'; what.textContent = t.downWhat; }, 2600);
+ return;
+ }
+ tstate[id] = !up;
+ renderTunnels();
+ toast(tstate[id] ? `${t.who} up` + (t.ownsRoute ? ' — default route moved to '+t.dev : '')
+ : `${t.who} down` + (t.ownsRoute ? ' — route back on wlp170s0' : ''));
+ }, T(1500));
+}
+
+let toastTimer;
+function toast(msg, err){
+ const el = $('toastEl');
+ el.textContent = msg; el.className = 'toast show' + (err ? ' err' : '');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600);
+}
+function lampState(word, cls){
+ $('state').textContent = word;
+ $('lamp').className = 'lamp' + (cls && cls !== 'off' ? ' '+cls : cls === 'off' ? ' off' : '');
+}
+
+const held = {rx:false, tx:false};
+let testing = false, amb = 0;
+function meter(side, val){
+ const deg = -60 + Math.min(1, val/100) * 120;
+ $('n-'+side).style.transform = `rotate(${deg}deg)`;
+ $('v-'+side).textContent = val.toFixed(1);
+}
+function setMeterMode(side, mode){
+ const m = $('m-'+side), t = $('mt-'+side);
+ m.classList.toggle('testing', mode==='test');
+ m.classList.toggle('held', mode==='hold');
+ held[side] = mode==='hold';
+ t.textContent = mode==='live' ? 'LIVE' : 'TEST';
+ t.className = 'mode-tag' + (mode==='live' ? '' : ' test');
+}
+function release(side){
+ if (!held[side]) return;
+ setMeterMode(side, 'live');
+ if (!held.rx && !held.tx) $('m-note').textContent = '';
+}
+
+function runSpeed(){
+ if (busy) return;
+ busy = true; testing = true;
+ $('b-doctor').disabled = $('b-speed').disabled = true;
+ const out = $('out'); out.innerHTML = '';
+ setMeterMode('rx','test'); setMeterMode('tx','test');
+ $('m-note').textContent = 'measuring — needles follow the live rate';
+ const line = (k, v) => {
+ const el = document.createElement('div');
+ el.className = 'o-line'; el.innerHTML = `<b>${k}</b> ${v}`;
+ out.appendChild(el); out.scrollTop = out.scrollHeight;
+ };
+ setTimeout(() => line('location', 'Tulsa, OK (US) by Encore Communications'), T(900));
+ setTimeout(() => line('ping', '44.5 ms (jitter 2.1 ms)'), T(1800));
+ let dv = 0; const dT = 25.3;
+ setTimeout(() => {
+ const dTick = setInterval(() => {
+ dv = Math.min(dT, dv + 1.3 + Math.random()*2.1);
+ meter('rx', dv);
+ if (dv >= dT){
+ clearInterval(dTick);
+ setMeterMode('rx','hold'); meter('rx', dT);
+ let uv = 0; const uT = 90.8;
+ const uTick = setInterval(() => {
+ uv = Math.min(uT, uv + 4.5 + Math.random()*7);
+ meter('tx', uv);
+ if (uv >= uT){
+ clearInterval(uTick);
+ setMeterMode('tx','hold'); meter('tx', uT);
+ line('final', '25.3↓ 90.8↑ Mbps · 44.5 ms · loss 0.0%');
+ const tip = document.createElement('div');
+ tip.className = 'o-tip';
+ tip.textContent = 'tip: download well below upload — typical of a congested or shaped venue network. Try 5 GHz, move closer, or retest off-peak.';
+ out.appendChild(tip); out.scrollTop = out.scrollHeight;
+ $('m-note').textContent = 'result held — click a meter to go back to live';
+ testing = false; busy = false;
+ $('b-doctor').disabled = $('b-speed').disabled = false;
+ }
+ }, T(200));
+ }
+ }, T(185));
+ }, T(2100));
+}
+
+const CHECKS = [
+ {t:'Link', why:'is the adapter connected to a network (every later check rides the link)',
+ ev:'wlp170s0 connected (@Hyatt_WiFi)'},
+ {t:'DHCP / IPv4', why:'did the network lease us an IP address (nothing routes without one)',
+ ev:'172.20.2.108/20'},
+ {t:'Gateway', why:'does the router (first hop) answer a ping', ev:'172.20.0.1 [5 ms]'},
+ {t:'DNS config', why:'is a DNS resolver configured on the link', ev:'172.20.0.1'},
+ {t:'DNS resolution', why:'does a known hostname resolve (catches dead DNS and portal hijacks)',
+ ev:'names resolve (captive.apple.com) [48 ms]',
+ evBroken:'no resolution (portal may be stalling DNS)', canBreak:true},
+ {t:'Internet', why:'does an HTTP probe reach the open internet (the online/captive verdict)',
+ ev:'open internet (HTTP 204) [112 ms]',
+ evBroken:'link up but no clean internet (DNS or egress issue)', canBreak:true},
+];
+const REPAIR = {t:'repair: dns-test', why:'points DNS at 1.1.1.1, tests resolution, then reverts (tells a broken venue resolver from blocked egress)',
+ ev:'1.1.1.1 resolved captive.apple.com — the hotel resolver is broken, not the link'};
+const REPAIR2 = {t:'repair: dns-override', why:'sets 1.1.1.1 on the link until reconnect (works around the broken venue resolver)',
+ ev:'DNS set to 1.1.1.1 on wlp170s0'};
+
+function addStep(host, step, repair){
+ const el = document.createElement('div');
+ el.className = 'o-step' + (repair ? ' repair' : '');
+ el.innerHTML = `<span class="lamp busy"></span><span class="t"><b>${step.t}</b>`+
+ `<span class="why">${step.why}</span><span class="ev">…</span></span>`;
+ host.appendChild(el); host.scrollTop = host.scrollHeight;
+ return el;
+}
+function landStep(el, ev, cls){
+ el.querySelector('.lamp').className = 'lamp' + (cls ? ' '+cls : '');
+ el.querySelector('.ev').textContent = ev;
+ el.closest('.output').scrollTop = 1e6;
+}
+function verdict(host, text, ok){
+ const v = document.createElement('div');
+ v.className = 'o-verdict' + (ok ? ' ok' : '');
+ v.textContent = text;
+ host.appendChild(v); host.scrollTop = host.scrollHeight;
+}
+
+function runDoctor(){
+ if (busy) return;
+ busy = true;
+ $('b-doctor').disabled = $('b-speed').disabled = true;
+ const brokenDNS = $('breakdns').checked;
+ const out = $('out'); out.innerHTML = '';
+ lampState('CHECKING','gold');
+ const gap = T(620);
+ let i = 0;
+ const next = () => {
+ if (i >= CHECKS.length){ return brokenDNS ? repairPhase() : finish('Overall — everything checks out'); }
+ const c = CHECKS[i];
+ const el = addStep(out, c);
+ setTimeout(() => {
+ const broken = brokenDNS && c.canBreak;
+ landStep(el, broken ? c.evBroken : c.ev, broken ? 'red' : '');
+ i++; setTimeout(next, gap*0.35);
+ }, gap);
+ };
+ const repairPhase = () => {
+ lampState('FIXING','gold');
+ verdict(out, 'DNS not resolving — trying the lightest repair');
+ const r1 = addStep(out, REPAIR, true);
+ setTimeout(() => {
+ landStep(r1, REPAIR.ev);
+ const r2 = addStep(out, REPAIR2, true);
+ setTimeout(() => {
+ landStep(r2, REPAIR2.ev);
+ const re = addStep(out, {t:'re-check: Internet', why:'probe again through the repaired resolver', ev:''});
+ setTimeout(() => {
+ landStep(re, 'open internet (HTTP 204) [96 ms]');
+ $('breakdns').checked = false;
+ finish('fixed — back online after dns-override');
+ }, gap);
+ }, gap*1.2);
+ }, gap*1.4);
+ };
+ const finish = (text) => {
+ verdict(out, text, true);
+ lampState('ONLINE');
+ busy = false;
+ $('b-doctor').disabled = $('b-speed').disabled = false;
+ };
+ next();
+}
+
+function setEther(present){
+ if (busy) return;
+ ether.present = present;
+ if (present){
+ ether.routed = true; // cable wins the route by metric
+ lampState('ONLINE');
+ toast('link detected on ' + ether.dev + ' — route moved to wired');
+ } else {
+ ether.routed = false;
+ toast(connected ? 'cable unplugged — route back on wlp170s0'
+ : 'cable unplugged', !connected);
+ if (!connected) lampState('OFFLINE','red');
+ }
+ renderTunnels(); renderNets();
+}
+function toggleEther(){
+ if (busy) return;
+ const lamp = $('eth-lamp'), what = $('eth-what');
+ busy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = ether.routed ? 'standing by…' : 'taking the route…';
+ setTimeout(() => {
+ busy = false;
+ ether.routed = !ether.routed;
+ renderTunnels(); renderNets();
+ toast(ether.routed ? 'route moved to ' + ether.dev + ' (wired)'
+ : 'route back on wlp170s0 — ' + ether.dev + ' standing by');
+ }, T(1200));
+}
+
+function wifiPower(){
+ if (busy) return;
+ if (airplane){ toast('airplane mode is on — Super+Shift+A to leave it', true); return; }
+ setWifi(!wifiOn);
+}
+function setWifi(on, quiet){
+ wifiOn = on;
+ $('n-power').classList.toggle('on', on);
+ if (!on){
+ const act = NETS.find(n => n.active);
+ if (act){ lastSsid = act.ssid; act.active = false; }
+ connected = false;
+ if (!(ether.present && ether.routed)) tstate.ts = false;
+ netFace(); renderTunnels(); renderNets();
+ if (!quiet) toast('wifi radio off');
+ } else {
+ netFace(); renderTunnels(); renderNets();
+ if (!quiet) toast('wifi radio on — rejoining ' + lastSsid);
+ const n = NETS.find(x => x.ssid === lastSsid && x.range);
+ if (n) setTimeout(() => doJoin(n), T(700));
+ }
+}
+function setAirplane(on){
+ airplane = on;
+ if (on){
+ if (wifiOn) setWifi(false, true);
+ if (bpower) btPowerSet(false, true);
+ netFace();
+ $('b-air-badge').classList.add('show');
+ toast('airplane mode — all radios off');
+ } else {
+ $('b-air-badge').classList.remove('show');
+ setWifi(true, true);
+ btPowerSet(true, true);
+ netFace();
+ toast('airplane mode off — radios back up');
+ }
+}
+
+function setAirport(on){
+ if (busy || bbusy) return;
+ if (on && $('cafe').checked){ $('cafe').checked = false; }
+ NETS = on ? AIRPORT_NETS() : HOTEL_NETS();
+ NEARBY.length = 0;
+ NEARBY.push(...(on ? AIRPORT_NEARBY()
+ : [{id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'},
+ {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}]));
+ if (on){
+ tstate.ts = false; connected = false;
+ lampState('OFFLINE','red');
+ toast('ORD concourse B — pick a network');
+ } else {
+ tstate.ts = true; connected = true;
+ routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+ NETS.find(n=>n.id==='hyatt').active = true;
+ lampState('ONLINE');
+ }
+ renderTunnels(); renderNets(); renderBt();
+}
+
+function setScenario(cafe){
+ if (busy) return;
+ if (cafe && $('airportcb') && $('airportcb').checked){ $('airportcb').checked = false; }
+ NETS = cafe ? CAFE_NETS() : HOTEL_NETS();
+ if (cafe){
+ tstate.ts = false; connected = false;
+ $('ch-ssid').textContent = '— not connected';
+ $('ch-dbm').textContent = '';
+ $('ch-ladder').className = 'ladder';
+ lampState('OFFLINE','red');
+ } else {
+ tstate.ts = true; connected = true;
+ routeBase = '172.20.2.108/20 · gw 172.20.0.1';
+ $('ch-ssid').textContent = '@Hyatt_WiFi';
+ $('ch-dbm').textContent = '-59 dBm · 44 ms';
+ $('ch-ladder').className = 'ladder l3';
+ lampState('ONLINE');
+ }
+ renderTunnels(); renderNets();
+}
+
+function closePanel(){ $('p').classList.add('closed'); $('reopen').style.display='inline-block'; }
+function openPanel(){ $('p').classList.remove('closed'); $('reopen').style.display='none'; }
+
+/* =========================================================== BT·01 */
+let bbusy = false, bpower = true, bdisco = false, blow = false;
+const DEVS = [
+ {id:'m650', name:'Logi M650', kind:'mouse', paired:true, conn:true, batt:72, audio:false},
+ {id:'xm4', name:'WH-1000XM4', kind:'audio', paired:true, conn:false, batt:58, audio:true},
+ {id:'k3', name:'Keychron K3', kind:'keyboard', paired:true, conn:false, batt:34, audio:false},
+];
+const NEARBY = [
+ {id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'},
+ {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'},
+];
+let barmed = null, barmTimer = null;
+
+function btConnected(){ return DEVS.filter(d => d.conn); }
+
+function renderBt(){
+ /* paired rows */
+ const host = $('b-paired'); host.innerHTML = '';
+ for (const d of DEVS){
+ const row = document.createElement('div');
+ row.className = 'lamp-row' + (barmed===d.id ? ' armed' : '');
+ const lamp = !bpower ? 'lamp off' : d.conn ? 'lamp' : 'lamp off';
+ const battTxt = d.batt !== null && d.conn ? ` · battery ${d.batt}%` : '';
+ const what = barmed===d.id ? 'forget? click ✕ again'
+ : !bpower ? 'adapter off'
+ : d.conn ? d.kind + battTxt : d.kind + ' · not connected';
+ row.innerHTML = `<span class="${lamp}" id="bl-${d.id}"></span>`+
+ `<span class="who">${d.conn && bpower ? '<b>'+d.name+'</b>' : d.name}</span>`+
+ `<span class="what" id="bw-${d.id}">${what}</span>`+
+ `<button class="pen" title="Rename ${d.name}">✎</button>`+
+ `<button class="zap" title="Forget ${d.name}">✕</button>`;
+ row.querySelector('.pen').onclick = (e) => { e.stopPropagation(); bdlgRename(d.id); };
+ row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); btArmForget(d.id); };
+ if (bpower) row.onclick = () => btToggleDev(d.id);
+ host.appendChild(row);
+ }
+ /* nearby rows */
+ const nb = $('b-nearby'); nb.innerHTML = '';
+ for (const n of NEARBY){
+ const row = document.createElement('div');
+ row.className = 'lamp-row';
+ row.innerHTML = `<span class="${bpower?'lamp gold':'lamp off'}" id="bnl-${n.id}"></span>`+
+ `<span class="who">${n.name}</span><span class="what" id="bnw-${n.id}">${bpower ? n.kind+' · pairable' : 'adapter off'}</span>`;
+ if (bpower) row.onclick = () => btPair(n.id);
+ nb.appendChild(row);
+ }
+ /* adapter line + faceplate */
+ const c = btConnected().length;
+ $('b-conn-count').textContent = bpower ? (c === 1 ? '1 device connected' : c + ' devices connected') : 'adapter off';
+ $('b-paired-count').textContent = '· ' + DEVS.length;
+ $('b-nearby-count').textContent = '· ' + NEARBY.length;
+ $('b-disco').textContent = 'discoverable ' + (bdisco && bpower ? 'on' : 'off');
+ $('b-disco').className = 'chip' + (bdisco && bpower ? ' on' : '');
+ renderGauges();
+ updateBtBadge();
+ tips('b-paired'); tips('b-nearby');
+ for (const el of document.querySelectorAll('#bp .m-label')) el.title = el.textContent;
+}
+
+function renderGauges(){
+ const conns = btConnected();
+ for (let i = 0; i < 2; i++){
+ const d = bpower ? conns[i] : null;
+ const needle = $('bn-'+i), val = $('bv-'+i), label = $('bl-'+i),
+ tag = $('bmt-'+i), wrap = $('bvw-'+i);
+ if (!d){
+ needle.className = 'needle dead';
+ needle.style.transform = 'rotate(-60deg)';
+ val.textContent = '—'; wrap.className = 'm-value';
+ label.textContent = bpower ? 'NO DEVICE' : 'ADAPTER OFF';
+ tag.textContent = '—'; tag.className = 'mode-tag off';
+ continue;
+ }
+ const low = d.batt < 15;
+ needle.className = 'needle' + (low ? ' low' : '');
+ needle.style.transform = `rotate(${-60 + (d.batt/100)*120}deg)`;
+ val.innerHTML = d.batt; wrap.className = 'm-value' + (low ? ' low' : '');
+ wrap.innerHTML = `<span id="bv-${i}">${d.batt}</span> <small>%</small>`;
+ label.textContent = d.name.toUpperCase();
+ tag.textContent = 'LIVE'; tag.className = 'mode-tag';
+ }
+}
+
+function updateBtBadge(){
+ const low = bpower && btConnected().some(d => d.batt < 15);
+ $('b-badge').classList.toggle('show', low);
+}
+
+function btToggleDev(id){
+ if (bbusy || !bpower) return;
+ const d = DEVS.find(x=>x.id===id);
+ const lamp = $('bl-'+id), what = $('bw-'+id);
+ bbusy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = d.conn ? 'disconnecting…' : 'connecting…';
+ setTimeout(() => {
+ bbusy = false;
+ d.conn = !d.conn;
+ renderBt();
+ btToast(d.conn ? `${d.name} connected` : `${d.name} disconnected`);
+ }, T(1300));
+}
+
+function btArmForget(id){
+ const d = DEVS.find(x=>x.id===id);
+ if (barmed === id){
+ clearTimeout(barmTimer); barmed = null;
+ DEVS.splice(DEVS.indexOf(d), 1); renderBt();
+ btToast(`${d.name} forgotten`);
+ return;
+ }
+ barmed = id; renderBt();
+ clearTimeout(barmTimer);
+ barmTimer = setTimeout(() => { barmed = null; renderBt(); }, 3000);
+}
+
+/* pairing + rename dialogs */
+let bdlgMode = null, bdlgTarget = null;
+function btPair(id){
+ if (bbusy) return;
+ const n = NEARBY.find(x=>x.id===id);
+ const lamp = $('bnl-'+id), what = $('bnw-'+id);
+ bbusy = true;
+ lamp.className = 'lamp busy';
+ what.textContent = 'pairing…';
+ setTimeout(() => {
+ bdlgMode = 'pair'; bdlgTarget = n;
+ $('bdlg-title').textContent = 'Pair ' + n.name;
+ $('bdlg-sub').textContent = 'confirm this passkey shows on the device';
+ $('bdlg-key').style.display = 'block';
+ $('bdlg-key').textContent = n.passkey;
+ $('bdlg-name').style.display = 'none';
+ $('bdlg-go').textContent = 'Confirm';
+ $('bov').classList.add('show');
+ }, T(1200));
+}
+function bdlgRename(id){
+ const d = DEVS.find(x=>x.id===id);
+ bdlgMode = 'rename'; bdlgTarget = d;
+ $('bdlg-title').textContent = 'Rename device';
+ $('bdlg-sub').textContent = 'the alias lives on this machine (bluez set-alias)';
+ $('bdlg-key').style.display = 'none';
+ $('bdlg-name').style.display = 'block';
+ $('bdlg-name').value = d.name;
+ $('bdlg-go').textContent = 'Rename';
+ $('bov').classList.add('show');
+ $('bdlg-name').focus();
+}
+function bdlgClose(){
+ $('bov').classList.remove('show');
+ if (bdlgMode === 'pair'){ bbusy = false; renderBt(); btToast('pairing cancelled'); }
+ bdlgMode = null; bdlgTarget = null;
+}
+function bdlgGo(){
+ if (bdlgMode === 'pair'){
+ const n = bdlgTarget;
+ $('bov').classList.remove('show');
+ const what = $('bnw-'+n.id);
+ if (what) what.textContent = 'connecting…';
+ setTimeout(() => {
+ bbusy = false;
+ NEARBY.splice(NEARBY.indexOf(n), 1);
+ DEVS.push({id:n.id, name:n.name, kind:n.kind, paired:true, conn:true,
+ batt:n.kind==='audio' ? 91 : null, audio:n.kind==='audio'});
+ renderBt();
+ btToast(`${n.name} paired and connected`);
+ }, T(1100));
+ } else if (bdlgMode === 'rename'){
+ const d = bdlgTarget;
+ const name = $('bdlg-name').value.trim() || d.name;
+ d.name = name;
+ $('bov').classList.remove('show');
+ renderBt();
+ btToast(`renamed to ${name}`);
+ }
+ bdlgMode = null; bdlgTarget = null;
+}
+
+/* scan */
+const MORE_NEARBY = [
+ {id:'buds', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'},
+];
+function btScan(){
+ if (bbusy || !bpower) return;
+ $('b-scan-note').textContent = 'scanning…';
+ $('bb-scan').disabled = true;
+ setTimeout(() => {
+ if (MORE_NEARBY.length){
+ NEARBY.push(MORE_NEARBY.shift());
+ renderBt();
+ }
+ $('b-scan-note').textContent = '';
+ $('bb-scan').disabled = false;
+ btToast('scan complete — ' + NEARBY.length + ' nearby');
+ }, T(2200));
+}
+
+/* discoverable + power */
+function btDisco(){
+ if (!bpower) return;
+ bdisco = !bdisco;
+ renderBt();
+ btToast(bdisco ? 'discoverable for 2 minutes' : 'discoverable off');
+}
+function btPower(){
+ if (bbusy) return;
+ if (airplane){ btToast('airplane mode is on — Super+Shift+A to leave it', true); return; }
+ btPowerSet(!bpower);
+}
+function btPowerSet(on, quiet){
+ if (bbusy) return;
+ bpower = on;
+ $('b-power').classList.toggle('on', bpower);
+ if (!bpower){
+ bdisco = false;
+ DEVS.forEach(d => d._wasConn = d.conn);
+ DEVS.forEach(d => d.conn = false);
+ $('b-state').textContent = airplane ? 'AIRPLANE' : 'OFF';
+ $('b-lamp').className = airplane ? 'lamp gold' : 'lamp off';
+ $('bb-doctor').disabled = $('bb-scan').disabled = true;
+ renderBt();
+ if (!quiet) btToast('adapter powered off');
+ } else {
+ $('b-state').textContent = 'POWERED';
+ $('b-lamp').className = 'lamp';
+ $('bb-doctor').disabled = $('bb-scan').disabled = false;
+ renderBt();
+ if (quiet === true) { /* airplane restore: quiet */ }
+ /* the mouse auto-reconnects, like real life */
+ const mouse = DEVS.find(d => d._wasConn);
+ if (mouse){
+ setTimeout(() => {
+ const lamp = $('bl-'+mouse.id), what = $('bw-'+mouse.id);
+ if (lamp){ lamp.className = 'lamp busy'; what.textContent = 'reconnecting…'; }
+ setTimeout(() => { mouse.conn = true; renderBt(); btToast(mouse.name + ' reconnected'); }, T(1200));
+ }, T(600));
+ }
+ }
+}
+
+let btoastTimer;
+function btToast(msg, err){
+ const el = $('b-toastEl');
+ el.textContent = msg; el.className = 'toast show' + (err ? ' err' : '');
+ clearTimeout(btoastTimer);
+ btoastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600);
+}
+
+/* bt doctor — the real chain: adapter → radio → service → powered → devices → audio */
+function btChecks(){
+ const badAudio = $('badaudio').checked && DEVS.some(d => d.audio && d.conn);
+ return [
+ {t:'Adapter', why:'is a bluetooth adapter visible to the stack', ev:'intel ax211 (hci0)'},
+ {t:'Radio', why:'rfkill can block the radio in software or hardware', ev:'unblocked'},
+ {t:'Service', why:'is the bluez daemon running', ev:'bluetooth.service active'},
+ {t:'Powered', why:'radio on and ready to connect', ev:'powered on'},
+ {t:'Devices', why:'are paired devices reachable',
+ ev: btConnected().length ? btConnected().map(d=>d.name).join(', ') + ' connected' : 'none connected'},
+ {t:'Audio profile', why:'is a connected audio device on the high-quality profile (A2DP)',
+ ev: DEVS.some(d=>d.audio && d.conn) ? 'a2dp-sink' : 'no audio device connected',
+ evBroken:'stuck on headset-head-unit (HSP) — phone-call-grade audio', canBreak:badAudio},
+ ];
+}
+const BT_REPAIR = {t:'repair: a2dp-switch', why:'flips the card profile to A2DP and verifies the sink followed',
+ ev:'card profile set to a2dp-sink — sink followed'};
+
+function btDoctor(){
+ if (bbusy || !bpower) return;
+ bbusy = true;
+ $('bb-doctor').disabled = $('bb-scan').disabled = true;
+ const checks = btChecks();
+ const willRepair = checks.some(c => c.canBreak);
+ const out = $('b-out'); out.innerHTML = '';
+ $('b-state').textContent = 'CHECKING'; $('b-lamp').className = 'lamp gold';
+ const gap = T(620);
+ let i = 0;
+ const next = () => {
+ if (i >= checks.length){ return willRepair ? repairPhase() : finish('Overall — everything checks out'); }
+ const c = checks[i];
+ const el = addStep(out, c);
+ setTimeout(() => {
+ landStep(el, c.canBreak ? c.evBroken : c.ev, c.canBreak ? 'red' : '');
+ i++; setTimeout(next, gap*0.35);
+ }, gap);
+ };
+ const repairPhase = () => {
+ $('b-state').textContent = 'FIXING';
+ verdict(out, 'audio degraded — trying the lightest repair');
+ const r = addStep(out, BT_REPAIR, true);
+ setTimeout(() => {
+ landStep(r, BT_REPAIR.ev);
+ const re = addStep(out, {t:'re-check: Audio profile', why:'read the sink profile again after the switch', ev:''});
+ setTimeout(() => {
+ landStep(re, 'a2dp-sink [verified]');
+ $('badaudio').checked = false;
+ finish('fixed — high-quality audio restored');
+ }, gap);
+ }, gap*1.4);
+ };
+ const finish = (text) => {
+ verdict(out, text, true);
+ $('b-state').textContent = 'POWERED'; $('b-lamp').className = 'lamp';
+ bbusy = false;
+ $('bb-doctor').disabled = $('bb-scan').disabled = false;
+ };
+ next();
+}
+
+function btLowBatt(low){
+ blow = low;
+ const m = DEVS.find(d => d.id === 'm650');
+ if (m) m.batt = low ? 9 : 72;
+ renderBt();
+ if (low) btToast('Logi M650 battery low (9%)', true);
+}
+
+function closeBt(){ $('bp').classList.add('closed'); $('b-reopen').style.display='inline-block'; }
+function openBt(){ $('bp').classList.remove('closed'); $('b-reopen').style.display='none'; }
+
+/* shared ambience + keys */
+if (!reduced) setInterval(() => {
+ amb++;
+ if (!testing){
+ if (!held.rx) meter('rx', 0.1 + Math.abs(Math.sin(amb/3))*0.35);
+ if (!held.tx) meter('tx', 0.08 + Math.abs(Math.cos(amb/4))*0.25);
+ }
+}, 900);
+document.addEventListener('keydown', e => {
+ if (e.key === 'Escape'){
+ if ($('ov').classList.contains('show')) return dlgClose();
+ if ($('bov').classList.contains('show')) return bdlgClose();
+ closePanel(); closeBt();
+ }
+});
+
+function clearOut(id){ $(id).innerHTML = ''; }
+function tips(hostId){
+ for (const el of $(hostId).querySelectorAll('.what,.who,.m-label'))
+ el.title = el.textContent;
+}
+[['out','outwrap'],['b-out','b-outwrap']].forEach(([oid, wid]) => {
+ new MutationObserver(() => {
+ $(wid).classList.toggle('has', $(oid).childElementCount > 0);
+ }).observe($(oid), {childList: true});
+});
+
+renderNets(); renderTunnels(); renderBt();
+
+/* headless hooks */
+const auto = new URLSearchParams(location.search).get('auto');
+if (auto === 'btdoctor') btDoctor();
+if (auto === 'btdoctorfix'){
+ const xm4 = DEVS.find(d=>d.id==='xm4'); xm4.conn = true; renderBt();
+ $('badaudio').checked = true; btDoctor();
+}
+if (auto === 'btpair'){ btPair('jbl'); setTimeout(() => bdlgGo(), T(1800)); }
+if (auto === 'btpower'){ btPower(); }
+if (auto === 'btpoweron'){ btPower(); setTimeout(() => btPower(), T(600)); }
+if (auto === 'btlow'){ $('lowbatt').checked = true; btLowBatt(true); }
+if (auto === 'speed') runSpeed();
+if (auto === 'ether'){ $('ethercb').checked = true; setEther(true); }
+if (auto === 'air'){ $('aircb').checked = true; setAirplane(true); }
+if (auto === 'airether'){ $('ethercb').checked = true; setEther(true); $('aircb').checked = true; setAirplane(true); }
+if (auto === 'disc'){ armDisconnect('hyatt'); setTimeout(() => armDisconnect('hyatt'), T(600)); }
+if (auto === 'wifioff'){ setWifi(false); }
+if (auto === 'airport'){ $('airportcb').checked = true; setAirport(true); }
+if (auto === 'doctorfix'){ $('breakdns').checked = true; runDoctor(); }
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html
new file mode 100644
index 0000000..3329cdb
--- /dev/null
+++ b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html
@@ -0,0 +1,251 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Net panel — rescan affordance</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1200px;margin:0 auto 1.8rem}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch}
+.masthead p b{color:var(--silver)}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1200px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+
+.chan{margin-top:12px}
+.chan .line1{display:flex;align-items:baseline;gap:9px}
+.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px}
+.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px}
+.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px}
+.ladder i{width:4px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px}
+.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px}
+.ladder.l1 i:nth-child(-n+1){background:var(--gold)}.ladder.l2 i:nth-child(-n+2){background:var(--gold)}
+.ladder.l3 i:nth-child(-n+3){background:var(--gold)}.ladder.l4 i{background:var(--gold)}
+
+/* engrave header with the rescan action */
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.24em;text-transform:uppercase;
+ display:flex;align-items:center;gap:8px;margin:14px 0 6px}
+.engrave::before{content:"";height:1px;background:var(--wash);width:10px;flex:0 0 auto}
+.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;cursor:pointer;
+ border-bottom:1px dotted transparent}
+.engrave .cnt:hover{color:var(--gold);border-bottom-color:var(--wash)}
+.engrave .cnt.scanning{color:var(--gold);animation:breathe 1.1s ease-in-out infinite;cursor:default;border-bottom-color:transparent}
+.engrave .spacer{flex:1;height:1px;background:var(--wash)}
+/* compact rescan glyph — sits right after the count, spins while scanning */
+.engrave .ricon{color:var(--dim);cursor:pointer;font-size:.82rem;display:inline-flex;line-height:1}
+.engrave .ricon:hover{color:var(--gold)}
+.engrave .ricon.spin{color:var(--gold);cursor:default;animation:spin .9s linear infinite}
+.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer}
+.engrave .act:hover{color:var(--gold)}
+@keyframes spin{to{transform:rotate(360deg)}}
+@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}}
+
+/* the list; section-breathe busy style pulses the whole well */
+#networks{border-radius:8px;transition:background .3s}
+#networks.breathe{animation:sectionbreathe 1.4s ease-in-out infinite}
+@keyframes sectionbreathe{0%,100%{background:transparent}50%{background:rgba(218,181,61,.06)}}
+
+.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer}
+.lamp-row:hover{background:var(--wash)}
+.lamp-row .who{color:var(--silver);white-space:nowrap}
+.lamp-row .who b{color:var(--cream)}
+.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lamp-row.fresh{animation:fadein .5s ease}
+@keyframes fadein{from{opacity:0;transform:translateY(-3px);background:rgba(218,181,61,.12)}to{opacity:1}}
+
+.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+
+.aside{flex:1 1 320px;min-width:290px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li b{color:var(--cream);font-weight:700}
+.aside li em{color:var(--dim);font-style:normal}
+.demo{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:1rem}
+.demo .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.2em;text-transform:uppercase;margin-bottom:.5rem}
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;margin-bottom:.7rem}
+.seg button{flex:1;font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 6px;cursor:pointer}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+.demo .go{font:inherit;font-size:.8rem;color:var(--silver);background:transparent;border:1px solid var(--gold);
+ border-radius:8px;padding:.45rem 1rem;cursor:pointer}
+.demo .go:hover{background:rgba(218,181,61,.12)}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem}
+.rec b{color:var(--gold)}
+@media (prefers-reduced-motion:reduce){*{animation:none!important}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · net·01</div>
+ <h1>Networks — the rescan affordance</h1>
+ <p>Where does a WiFi rescan live, and how does it show it's working? The count "· N in range" is
+ really a live status field — the natural home for "scanning…". My lean: an explicit <b>⟳ rescan</b>
+ action in the engrave line (discoverable, same slot as "+ hidden"), with a <b>flash-and-fade</b>
+ busy state — the glyph spins, the count breathes, new rows fade in as found. The count is also
+ click-to-rescan as a shortcut. Use the selector on the right to feel each busy treatment.</p>
+</header>
+
+<div class="stage">
+ <div class="slot">
+ <div class="slot-label">net·01 — networks section</div>
+ <div class="panel">
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp"></span>
+ <span class="state-word">ONLINE</span>
+ <span class="unit">NET·01</span>
+ </div>
+ </div>
+
+ <div class="engrave">channel</div>
+ <div class="chan">
+ <div class="line1"><span class="ssid">@Hyatt_WiFi</span>
+ <span class="ladder l3"><i></i><i></i><i></i><i></i></span>
+ <span class="dim" style="font-size:11px">-59 dBm · 44 ms</span></div>
+ <div class="line2">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div>
+ </div>
+
+ <div class="engrave">networks<span class="cnt" id="cnt" onclick="rescan()">· 5 in range</span>
+ <span class="ricon" id="rescan" onclick="rescan()" title="Rescan for networks">⟳</span>
+ <span class="spacer"></span>
+ <span class="act" onclick="toast('would open the hidden-SSID dialog')">+ hidden</span>
+ </div>
+ <div id="networks"></div>
+
+ <div class="toast" id="toast"></div>
+ </div>
+ </div>
+
+ <div class="aside">
+ <div class="demo">
+ <div class="lbl">busy feedback style</div>
+ <div class="seg" id="styleSeg">
+ <button class="on" data-s="all">all (rec)</button>
+ <button data-s="spin">spinner</button>
+ <button data-s="count">count</button>
+ <button data-s="section">section</button>
+ </div>
+ <button class="go" onclick="rescan()">▶ run a rescan</button>
+ </div>
+
+ <h3>The three busy signals</h3>
+ <ul>
+ <li><b>Spinner</b> — the ⟳ glyph rotates while scanning. The clearest "working" cue; universal.</li>
+ <li><b>Count breathe</b> — "· 5 in range" becomes "scanning…" and slow-pulses. Your idea: the status field animates in place, no extra chrome.</li>
+ <li><b>Section breathe</b> — the whole list gives a faint gold breath while the scan runs; found rows fade in gold. Ambient, ties the animation to what's changing.</li>
+ <li><b>All (recommended)</b> — spinner + count + fade-in together. The section breathe is optional; it can read as busy on a small panel, so it's off in "all" by default and its own option to try.</li>
+ </ul>
+
+ <h3>Why an explicit action, not only the count</h3>
+ <div class="rec">
+ Overloading the count as the sole trigger is elegant but a first look doesn't know it's clickable.
+ An explicit <b>⟳ rescan</b> in the engrave line is discoverable, sits in the same slot as "+ hidden"
+ (consistent), and doesn't cost a heavy console key. Keeping the count clickable too gives power users
+ the shortcut without hiding the affordance. A dedicated <b>RESCAN console key</b> (next to DOCTOR /
+ SPEED TEST) is the third option — heavier, and rescan is a list action, not a diagnostic, so it fits
+ the engrave line better.
+ </div>
+ </div>
+</div>
+
+<script>
+const $=id=>document.getElementById(id);
+let busy=false, style='all';
+let NETS=[
+ {ssid:'@Hyatt_WiFi', sig:3, sec:'WPA2', stored:true, active:true},
+ {ssid:'Hyatt_Meeting', sig:3, sec:'WPA2', stored:false, active:false},
+ {ssid:'DIRECT-roku-882',sig:2, sec:'WPA2', stored:false, active:false},
+ {ssid:'xfinitywifi', sig:1, sec:null, stored:false, active:false},
+ {ssid:'HomeNet', sig:0, sec:'WPA2', stored:true, active:false, oor:true},
+];
+const NEWFOUND=[
+ {ssid:'Hyatt_Guest', sig:2, sec:null, stored:false, active:false},
+ {ssid:'Marriott_CONF', sig:1, sec:'WPA2', stored:false, active:false},
+];
+const pctFor=[null,'22%','44%','61%','78%'];
+
+$('styleSeg').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;
+ style=b.dataset.s;[...$('styleSeg').children].forEach(x=>x.classList.toggle('on',x===b));});
+
+function rowEl(n,fresh){
+ const r=document.createElement('div'); r.className='lamp-row'+(fresh?' fresh':'');
+ const lamp=n.active?'lamp':(n.oor?'lamp off':'lamp gold');
+ const what=n.active?'active · '+(n.sec||'open')
+ :n.oor?'stored · out of range'
+ :(n.stored?'stored · ':'')+(n.sec||'open')+' · '+pctFor[n.sig];
+ r.innerHTML=`<span class="${lamp}"></span><span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+
+ (!n.active&&!n.oor?`<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>`:'')+
+ `<span class="what">${what}</span>`;
+ r.onclick=()=>{ if(busy)return; toast(n.active?'already on '+n.ssid:'would join '+n.ssid); };
+ return r;
+}
+function render(freshSet){
+ const host=$('networks'); host.innerHTML='';
+ const inRange=NETS.filter(n=>!n.oor).sort((a,b)=>(b.active-a.active)||(b.sig-a.sig));
+ const oor=NETS.filter(n=>n.oor);
+ [...inRange,...oor].forEach(n=>host.appendChild(rowEl(n,freshSet&&freshSet.has(n.ssid))));
+ if(!busy) $('cnt').textContent='· '+inRange.length+' in range';
+}
+function rescan(){
+ if(busy) return; busy=true;
+ const useSpin = style==='all'||style==='spin';
+ const useCount= style==='all'||style==='count';
+ const useSec = style==='section';
+ if(useSpin) $('rescan').classList.add('spin');
+ if(useCount){ $('cnt').classList.add('scanning'); $('cnt').textContent='scanning…'; }
+ if(useSec) $('networks').classList.add('breathe');
+ toast('scanning for networks…');
+ // networks trickle in as "found"
+ const fresh=new Set();
+ setTimeout(()=>{ NETS.splice(1,0,NEWFOUND[0]); fresh.add(NEWFOUND[0].ssid); render(fresh); },900);
+ setTimeout(()=>{ NETS.splice(3,0,NEWFOUND[1]); fresh.add(NEWFOUND[1].ssid); render(fresh); },1700);
+ setTimeout(()=>{
+ busy=false;
+ $('rescan').classList.remove('spin');
+ $('cnt').classList.remove('scanning');
+ $('networks').classList.remove('breathe');
+ const n=NETS.filter(x=>!x.oor).length;
+ $('cnt').textContent='· '+n+' in range';
+ toast('scan complete — '+n+' networks in range');
+ // reset for the next run so the demo is repeatable
+ NETS=NETS.filter(x=>x.ssid!=='Hyatt_Guest'&&x.ssid!=='Marriott_CONF');
+ },2600);
+}
+render();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html b/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html
new file mode 100644
index 0000000..8e642f4
--- /dev/null
+++ b/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html
@@ -0,0 +1,338 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Panel widget gallery — dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1320px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:86ch}
+.masthead p b{color:var(--silver)}
+h2{color:var(--steel);font-size:.74rem;letter-spacing:.24em;text-transform:uppercase;
+ margin:2.2rem 0 .2rem;display:flex;align-items:center;gap:12px}
+h2::after{content:"";height:1px;background:var(--wash);flex:1}
+
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:14px;margin-top:1rem}
+.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px;
+ padding:13px 14px 12px;display:flex;flex-direction:column;gap:9px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)}
+.wname{color:var(--cream);font-size:.82rem;font-weight:700;display:flex;align-items:center;gap:8px}
+.wname .no{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.6rem;padding:0 5px;font-weight:400}
+.stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px;
+ min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap}
+.wnote{color:var(--dim);font-size:.72rem;line-height:1.4}
+.wnote b{color:var(--steel);font-weight:400}
+
+/* ---- shared primitives ---- */
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite}
+@keyframes pulse{50%{opacity:.25}}
+
+.switch{width:40px;height:21px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);
+ position:relative;cursor:pointer}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%;
+ background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:21px;background:var(--gold)}
+.switch.red{background:rgba(203,107,77,.2);border-color:var(--fail)}
+.switch.red::after{background:var(--fail);left:2px}
+
+.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer;
+ background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;
+ border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.key:hover{color:var(--gold);border-color:var(--gold)}
+.key:active{transform:translateY(1px)}
+.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)}
+.key.off{opacity:.4}
+
+.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px}
+.chip.on{color:var(--gold);border-color:var(--gold)}
+
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px}
+.badge.red{background:var(--fail);color:var(--cream)}
+.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)}
+
+/* fader */
+.fader{width:150px;height:16px;position:relative;cursor:pointer}
+.fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))}
+.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px;
+ background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)}
+/* vertical fader */
+.vfader{width:16px;height:64px;position:relative;cursor:pointer}
+.vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden}
+.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold))}
+.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px;
+ background:linear-gradient(90deg,#caa233,#f0d879);border:1px solid #7a6414}
+
+/* rotary knob */
+.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:pointer;
+ background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c;
+ box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)}
+.knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi);
+ margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.6)}
+.knob-scale{position:relative;width:64px;height:40px}
+.knob-scale .arc{position:absolute;inset:0 0 -24px 0;border:1.5px solid var(--wash);border-top-color:var(--gold);border-radius:50%;opacity:.5}
+
+/* needle gauge */
+.gauge{width:96px}
+.gauge .dial{position:relative;height:48px;overflow:hidden}
+.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%}
+.gauge .tk{position:absolute;left:50%;bottom:0;width:1.5px;height:8px;background:var(--steel);transform-origin:50% 48px}
+.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(255,215,95,.5);
+ transition:transform .5s cubic-bezier(.3,1.3,.5,1)}
+.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)}
+.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums}
+
+/* segmented VU / LED bar */
+.vu{width:170px;display:flex;flex-direction:column;gap:5px}
+.vurow{display:flex;align-items:center;gap:7px}
+.vurow .ch{color:var(--steel);font-size:.6rem;width:8px}
+.vubar{flex:1;display:flex;gap:2px;height:9px}
+.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3}
+.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)}
+.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
+
+/* mini 4-bar signal */
+.sig{display:flex;align-items:flex-end;gap:2px;height:18px}
+.sig i{width:4px;background:var(--wash);border-radius:1px}
+.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px}
+.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)}
+
+/* signal ladder (wifi bars) */
+.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px}
+.ladder i{width:5px;background:var(--wash);border-radius:1px}
+.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px}
+.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px}
+.ladder.l3 i:nth-child(-n+3){background:var(--gold)}
+
+/* linear progress / fuel bar */
+.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative}
+.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px}
+.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))}
+
+/* radial ring */
+.ring{width:60px;height:60px;border-radius:50%;
+ background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);
+ display:grid;place-items:center;position:relative}
+.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)}
+.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}
+
+/* tabular readout */
+.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em}
+.readout small{color:var(--dim);font-size:12px;font-weight:400}
+.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px}
+
+/* sparkline */
+.spark{width:170px;height:44px}
+.spark svg{display:block;width:100%;height:100%}
+
+/* lamp row (list item) */
+.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px}
+.lrow:hover{background:var(--wash)}
+.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)}
+.lrow .what{margin-left:auto;color:var(--dim);font-size:11px}
+
+/* arm-to-fire */
+.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;
+ border-radius:8px;padding:7px 12px}
+.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)}
+
+/* stepper / segmented selector */
+.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden}
+.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer}
+.seg button:last-child{border-right:0}
+.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700}
+
+/* engraved section label */
+.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;
+ display:flex;align-items:center;gap:9px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:10px}
+.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none}
+
+/* waveform strip */
+.wave{width:170px;height:38px}
+.wave svg{width:100%;height:100%;display:block}
+
+/* toast */
+.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px}
+
+/* output well (log step) */
+.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px}
+.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0}
+.ostep .lamp{margin-top:3px;width:7px;height:7px}
+.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px}
+
+@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+</style>
+</head>
+<body>
+<div class="wrap">
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family</div>
+ <h1>Widget gallery — the instrument-console kit</h1>
+ <p>Every control + display idiom we can build in the dupre faceplate language, all rendering from
+ the same tokens the net / bt / sound panels use. <b>Controls</b> take input; <b>meters &amp;
+ gauges</b> show a live analog value; <b>indicators &amp; readouts</b> show state or a number.
+ Live ones animate. Pick what fits each job — most cost pure CSS; the two that need a real
+ drawing surface (needle gauge, waveform) are flagged in their notes.</p>
+</header>
+
+<h2>Controls — take input</h2>
+<div class="grid" id="controls"></div>
+
+<h2>Meters &amp; gauges — live analog value</h2>
+<div class="grid" id="meters"></div>
+
+<h2>Indicators &amp; readouts — state or number</h2>
+<div class="grid" id="indicators"></div>
+
+</div>
+<script>
+const $ = id => document.getElementById(id);
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+function card(host, no, name, html, note){
+ const c=document.createElement('div'); c.className='card';
+ c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}</div>`+
+ `<div class="stagew">${html}</div><div class="wnote">${note}</div>`;
+ host.appendChild(c); return c;
+}
+function buildBars(el,n){el.innerHTML='';for(let k=0;k<n;k++)el.appendChild(document.createElement('i'));}
+
+/* ============ CONTROLS ============ */
+const C=$('controls');
+card(C,'01','Toggle switch',
+ `<span class="switch on" onclick="this.classList.toggle('on')"></span>
+ <span class="switch red"></span>`,
+ '<b>on / off / muted.</b> The faceplate master control — wifi radio, bt power, master-mute. Click to flip.');
+card(C,'02','Console key',
+ `<button class="key on">LIVE</button><button class="key">SCAN</button><button class="key red">MUTED</button>`,
+ '<b>physical push button.</b> DOCTOR / SPEED TEST / mic mode. Gold = engaged, terracotta = off.');
+card(C,'03','Horizontal fader',
+ `<div class="fader" id="f1"><div class="slot"><div class="fill" style="width:68%"></div></div><div class="cap" style="left:68%"></div></div>`,
+ '<b>continuous 0-100.</b> Per-device volume, brightness, kbd backlight. Drag; the gold cap tracks.');
+card(C,'04','Vertical fader',
+ `<div class="vfader"><div class="slot"><div class="fill" style="height:60%"></div></div><div class="cap" style="bottom:60%"></div></div>
+ <div class="vfader"><div class="slot"><div class="fill" style="height:35%"></div></div><div class="cap" style="bottom:35%"></div></div>`,
+ '<b>channel-strip style.</b> A mixer column per device if you want the classic board look.');
+card(C,'05','Rotary knob',
+ `<span class="knob" id="knob" onclick="bumpKnob()"><span class="ind" id="kind"></span></span>`,
+ '<b>dial in a value.</b> Volume/gain the analog way. Click to turn; drag in the real build. Pairs with a scale arc.');
+card(C,'06','Segmented selector',
+ `<div class="seg"><button class="on">TIMER</button><button>ALARM</button><button>POMO</button></div>`,
+ '<b>pick one of a few.</b> Timer type, layout mode, theme. One press-lit segment.');
+card(C,'07','Chip toggle',
+ `<span class="chip on" onclick="this.classList.toggle('on')">discoverable on</span>`,
+ '<b>inline binary.</b> A soft toggle inside a line of text — discoverable, auto-dim, DND. Gold when on.');
+card(C,'08','Arm-to-fire',
+ `<button class="arm" id="arm" onclick="armFire()">forget</button>`,
+ '<b>two-stage confirm.</b> Destructive/disruptive actions — forget network, disconnect. First click arms (red), second fires.');
+card(C,'09','Lamp row',
+ `<div class="lrow"><span class="lamp gold"></span><span class="who"><b>WH-1000XM4</b></span><span class="what">tap to connect</span></div>`,
+ '<b>actionable list item.</b> The net/bt/sound row: lamp + name + status, click acts. The workhorse.');
+
+/* ============ METERS & GAUGES ============ */
+const M=$('meters');
+card(M,'10','Needle gauge',
+ `<div class="gauge"><div class="dial"><div class="arc"></div>
+ <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div>
+ <div class="ndl" id="g1"></div><div class="hub"></div></div><div class="gv"><span id="g1v">0</span>%</div></div>`,
+ '<b>analog dial.</b> Throughput, battery, volume level. <b>Needs a Cairo/GTK drawing area</b> — CSS can fake a fixed angle but not a smooth sweep in waybar.');
+card(M,'11','Stereo VU (LED bar)',
+ `<div class="vu"><div class="vurow"><span class="ch">L</span><span class="vubar" id="vuL"></span></div>
+ <div class="vurow"><span class="ch">R</span><span class="vubar" id="vuR"></span></div></div>`,
+ '<b>live signal level.</b> The sound panel\'s second meter row. Peak-hold outline. Pure CSS — pango/box segments.');
+card(M,'12','Mini signal (4-bar)',
+ `<span class="sig" id="mini"></span>`,
+ '<b>compact activity.</b> Per-row "is this device playing" indicator. Cheap enough to sit in every list row.');
+card(M,'13','Signal ladder',
+ `<span class="ladder l3"><i></i><i></i><i></i><i></i></span>`,
+ '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Already in the net panel.');
+card(M,'14','Linear fuel bar',
+ `<div class="bar"><span style="width:72%"></span></div><div class="bar warn"><span style="width:12%"></span></div>`,
+ '<b>a single 0-100.</b> Battery, disk, download progress. Warn tint under threshold. Trivial in CSS.');
+card(M,'15','Radial ring',
+ `<span class="ring" style="--p:68"><b>68</b></span>`,
+ '<b>percentage as a donut.</b> CPU, battery, a single meter where a needle is overkill. conic-gradient, pure CSS.');
+card(M,'16','Sparkline',
+ `<span class="spark" id="spark"><svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline id="sparkp" fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg></span>`,
+ '<b>recent history.</b> Throughput/CPU over the last minute. SVG here; a drawing area in GTK.');
+card(M,'17','Waveform strip',
+ `<span class="wave" id="wave"><svg viewBox="0 0 170 38" preserveAspectRatio="none"><path id="wavep" fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg></span>`,
+ '<b>audio waveform / scope.</b> A richer signal view for the sound panel. <b>Needs a drawing surface.</b>');
+
+/* ============ INDICATORS & READOUTS ============ */
+const I=$('indicators');
+card(I,'18','Status lamp',
+ `<span class="lamp"></span><span class="lamp gold"></span><span class="lamp red"></span><span class="lamp off"></span><span class="lamp busy"></span>`,
+ '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. The family signature.');
+card(I,'19','Badge / tag',
+ `<span class="badge">TUNNEL</span> <span class="badge red">LOW BATT</span> <span class="badge ghost">2.4G</span>`,
+ '<b>a labelled flag.</b> On the faceplate or a row — MUTED, AIRPLANE, DEF, a band tag.');
+card(I,'20','Tabular readout',
+ `<div style="text-align:center"><div class="readout">24:10</div><span class="u">timer</span></div>
+ <div style="text-align:center"><div class="readout">68<small>%</small></div></div>`,
+ '<b>a precise number.</b> Clock, countdown, volume %. BerkeleyMono tabular-nums so digits don\'t jitter.');
+card(I,'21','Engraved label',
+ `<span class="engrave">outputs<span class="cnt">· 3</span></span>`,
+ '<b>section divider.</b> The hairline-flanked caps label with a count. Groups a panel into readable blocks.');
+card(I,'22','Output well',
+ `<div class="owell"><div class="ostep"><span class="lamp"></span><span><b>Link</b><span class="ev">wlp170s0 · @Hyatt</span></span></div>
+ <div class="ostep"><span class="lamp gold"></span><span><b>DNS</b><span class="ev">resolving…</span></span></div></div>`,
+ '<b>streaming step log.</b> The doctor/scan output — lamp-per-step with evidence. For any run-and-report action.');
+card(I,'23','Toast / status line',
+ `<span class="toastw">joined @Hyatt_WiFi — saved</span>`,
+ '<b>transient confirmation.</b> The one-line result after an action. Auto-dismiss; red variant for errors.');
+
+/* ---- live animation ---- */
+let ph=0, kang=140;
+function bumpKnob(){ kang=(kang+35)%300-0; $('kind').style.transform=`rotate(${kang-150}deg)`; }
+function armFire(){ const a=$('arm'); if(a.classList.contains('armed')){a.classList.remove('armed');a.textContent='forget';}
+ else{a.classList.add('armed');a.textContent='forget? again';} }
+buildBars($('vuL'),16); buildBars($('vuR'),16); buildBars($('mini'),0);
+$('mini').innerHTML='<i></i><i></i><i></i><i></i>';
+$('kind').style.transform=`rotate(${kang-150}deg)`;
+const hist=Array.from({length:40},()=>0.5);
+function paintVU(el,l,pk){const b=el.children,n=b.length,lit=Math.round(l*n);
+ pk.v=Math.max(lit,(pk.v||0)-0.4);const p=Math.round(pk.v);
+ for(let k=0;k<n;k++){let c=k<lit?(k>=n-2?'clip':k>=n-4?'hot':'on'):'';if(p>0&&k===p-1)c=(c?c+' ':'')+'peak';b[k].className=c;}}
+const pkL={v:0},pkR={v:0};
+function paintMini(el,l){const b=el.children,lit=Math.round(l*4);for(let k=0;k<4;k++)b[k].className=k<lit?(k>=3?'clip':k>=2?'hot':'on'):'';}
+function lvl(){return Math.max(0,Math.min(1,0.5+0.4*Math.sin(ph*1.3)+ (Math.random()<0.15?Math.random()*0.4:0) - Math.random()*0.08));}
+function tick(){
+ ph+=0.09;
+ const a=lvl(), b=lvl();
+ paintVU($('vuL'),a,pkL); paintVU($('vuR'),b,pkR); paintMini($('mini'),a);
+ // needle sweeps 0..100
+ const gv=Math.round(50+45*Math.sin(ph*0.7));
+ $('g1').style.transform=`rotate(${-60+gv/100*120}deg)`; $('g1v').textContent=gv;
+ // sparkline
+ hist.push(0.5+0.42*Math.sin(ph*0.9)+ (Math.random()-0.5)*0.25); hist.shift();
+ $('sparkp').setAttribute('points',hist.map((v,i)=>`${i/(hist.length-1)*170},${44-Math.max(0,Math.min(1,v))*40-2}`).join(' '));
+ // waveform
+ let d='M0 19'; for(let x=0;x<=170;x+=3){const y=19+Math.sin(x*0.18+ph*3)*Math.sin(x*0.05)*14; d+=` L${x} ${y.toFixed(1)}`;}
+ $('wavep').setAttribute('d',d);
+}
+if(!reduced) setInterval(tick,80); else tick();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-sound-panel-prototype.html b/docs/prototypes/2026-07-03-sound-panel-prototype.html
new file mode 100644
index 0000000..d75f566
--- /dev/null
+++ b/docs/prototypes/2026-07-03-sound-panel-prototype.html
@@ -0,0 +1,417 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sound — instrument console (pulsemixer)</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.masthead{max-width:1280px;margin:0 auto 1.8rem}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch}
+.masthead p b{color:var(--silver);font-weight:700}
+
+.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start}
+.slot{width:400px}
+.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem}
+
+.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px;
+ box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;overflow:hidden}
+
+.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+
+.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px}
+.b-id{display:flex;align-items:center;gap:9px}
+.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em}
+.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto}
+.b-id .g{font-size:17px;color:var(--cream)}
+.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);
+ border-radius:4px;padding:1px 6px;display:none}
+.badge.show{display:inline-block}
+.badge.red{background:var(--fail);color:var(--cream)}
+.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;
+ cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto}
+.x-btn:hover{background:var(--wash);color:var(--silver)}
+/* faceplate master quick-mute — same switch idiom as net wifi / bt power */
+.switch{width:38px;height:20px;border-radius:10px;background:var(--wash);
+ border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer}
+.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;
+ border-radius:50%;background:var(--dim);transition:left .15s}
+.switch.on{background:var(--slate);border-color:var(--gold)}
+.switch.on::after{left:19px;background:var(--gold)}
+.switch.muted{background:rgba(203,107,77,.2);border-color:var(--fail)}
+.switch.muted::after{background:var(--fail);left:2px}
+
+.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase;
+ display:flex;align-items:center;gap:10px;margin:13px 0 6px}
+.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}
+.engrave::before{max-width:12px}
+.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px;text-transform:none;font-size:.62rem}
+
+/* device row — a FIXED grid so nothing can overflow the plate:
+ [signal] [name] [fader] [pct] [mute]. minmax(0,1fr) lets the name shrink
+ and ellipsis instead of forcing the row wider than the panel. */
+.dev{display:grid;grid-template-columns:15px minmax(0,1fr) 84px 32px 20px;
+ align-items:center;gap:8px;padding:6px 5px;border-radius:8px;cursor:default}
+.dev:hover{background:var(--wash)}
+.dev .who{min-width:0;display:flex;align-items:center;gap:6px;color:var(--silver)}
+.dev .who .g{font-size:14px;color:var(--dim);flex:0 0 auto}
+.dev.active .who .g{color:var(--gold)}
+.dev .who .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.dev.active .who .nm{color:var(--cream);font-weight:700}
+.dev.muted .who .nm{color:var(--dim)}
+.dev .def{font-size:.5rem;letter-spacing:.14em;color:var(--panel);background:var(--gold);
+ border-radius:3px;padding:0 4px;flex:0 0 auto;display:none}
+.dev.active .def{display:inline-block}
+/* fader — machined slot + gold cap; width-based fill, bounded to its cell */
+.fader{height:16px;position:relative;cursor:pointer}
+.fader .trk{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:var(--well);
+ border:1px solid #231f18;overflow:hidden}
+.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))}
+.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px;
+ background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)}
+.dev.muted .fill{background:var(--wash)}
+.dev.muted .cap{background:linear-gradient(180deg,#6a6a6a,#3f3f3f);border-color:#2a2a2a}
+.pct{color:var(--cream);font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right}
+.dev.muted .pct{color:var(--fail)}
+.mute-b{color:var(--dim);border:0;background:transparent;font:inherit;font-size:.9rem;cursor:pointer;
+ border-radius:5px;padding:0;justify-self:center;line-height:1}
+.mute-b:hover{color:var(--silver)}
+.dev.muted .mute-b{color:var(--fail)}
+/* per-row signal mini-meter — 4 bars that light when THIS device has a live
+ stream, so the sink/source actually playing is visible before you pick it */
+.sig{display:flex;align-items:flex-end;gap:1.5px;height:14px;justify-self:center}
+.sig i{width:2.5px;background:var(--wash);border-radius:1px}
+.sig i:nth-child(1){height:4px}.sig i:nth-child(2){height:7px}
+.sig i:nth-child(3){height:10px}.sig i:nth-child(4){height:13px}
+.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)}
+
+/* mic mode — three console keys */
+.modes{display:flex;gap:8px;margin-top:2px}
+.mode{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11px;letter-spacing:.06em;
+ background:linear-gradient(180deg,#23211e,#191715);color:var(--silver);
+ border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.mode:hover{color:var(--gold);border-color:var(--gold)}
+.mode.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700}
+.mode.on.mute{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream)}
+.mode .k{display:block;font-size:.54rem;letter-spacing:.14em;color:var(--dim);margin-top:2px}
+.mode.on .k{color:rgba(16,15,15,.7)}
+.ptt-hint{color:var(--dim);font-size:10.5px;text-align:center;margin-top:6px;min-height:1.2em}
+.ptt-hint.live{color:var(--gold)}
+
+/* meter row 1 — the volume dials you set (needles), OUT + IN */
+.meters{display:flex;gap:12px;margin-top:10px}
+.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;position:relative}
+.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)}
+.meter .mode-tag.mut{color:var(--fail)}
+.meter .dial{position:relative;height:50px;overflow:hidden;margin-top:13px}
+.meter .arc{position:absolute;inset:0 0 -50px 0;border:2px solid var(--wash);border-radius:50%}
+.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:9px;background:var(--steel);transform-origin:50% 50px}
+.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:42px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(20deg);border-radius:2px;
+ box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .3s cubic-bezier(.3,1.3,.5,1)}
+.meter .needle.mut{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)}
+.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)}
+.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums}
+.meter .m-value small{color:var(--dim);font-weight:400}
+.meter .m-label{color:var(--steel);font-size:.6rem;letter-spacing:.2em;text-align:center;margin-top:2px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+/* meter row 2 — the stereo VU pair: the live SIGNAL through the selected
+ output. Confirms which device is actually carrying the audio. */
+.vupair{margin-top:10px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 11px 8px}
+.vuhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px}
+.vuhead .t{color:var(--steel);font-size:.56rem;letter-spacing:.2em;text-transform:uppercase}
+.vuhead .src{color:var(--cream);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
+.vurow{display:flex;align-items:center;gap:8px;margin:4px 0}
+.vurow .ch{color:var(--steel);font-size:.62rem;width:9px;flex:0 0 auto}
+.vubar{flex:1;display:flex;gap:2px;height:9px}
+.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3}
+.vubar i.on{opacity:1;background:var(--pass)}
+.vubar i.hot{opacity:1;background:var(--gold)}
+.vubar i.clip{opacity:1;background:var(--fail)}
+.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px}
+.vupair.mute .vubar i{background:var(--wash);opacity:.22}
+.vupair.mute .vuhead .src{color:var(--fail)}
+
+.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;
+ padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em}
+.toast.show{opacity:1}
+.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)}
+
+.aside{flex:1 1 320px;min-width:300px}
+.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem}
+.aside h3:first-child{margin-top:.2rem}
+.aside ul{list-style:none}
+.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative}
+.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.aside li b{color:var(--cream);font-weight:700}
+.aside li em{color:var(--dim);font-style:normal}
+.barbits{display:flex;gap:14px;margin:.4rem 0 .2rem;align-items:center;flex-wrap:wrap}
+.barbits span{display:flex;align-items:center;gap:7px;color:var(--silver);font-size:.82rem}
+.barbits .g{font-size:19px;color:var(--cream)}
+.barbits .g.mut{color:var(--fail)}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem;color:var(--silver)}
+.rec b{color:var(--gold)}
+*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent}
+::-webkit-scrollbar{width:6px;height:6px}
+::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px}
+@media (prefers-reduced-motion:reduce){.needle{transition:none}}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div>
+ <h1>Sound — the pulsemixer console</h1>
+ <p>Same faceplate as net + bluetooth. The bar's <b>sound glyph</b> opens this. Every sink and
+ source is a row: <b>click the row body to make it default</b>, drag the fader for its volume,
+ hit the glyph to mute just it. Each row has a <b>live-signal meter</b> — the device actually
+ carrying audio dances even when it isn't the default, so you can <b>find the one playing the
+ meeting</b> and click it. The faceplate switch is the <b>master quick-mute</b>; the mic carries
+ <b>live · muted · push-to-talk</b> (hold Space). Row 1 of gauges is the volume you set; row 2 is
+ the <b>stereo VU</b> of the selected output's live signal.</p>
+</header>
+
+<div class="stage">
+ <div class="slot">
+ <div class="slot-label">snd·01 — pulsemixer in console form</div>
+ <div class="panel">
+
+ <div class="b-face">
+ <div class="b-id">
+ <span class="lamp" id="lamp"></span>
+ <span class="g" id="face-g">󰕾</span>
+ <span class="state-word" id="state">PLAYBACK</span>
+ <span class="badge red" id="mute-badge">MUTED</span>
+ <span class="unit">SND·01</span>
+ <span class="switch on" id="master" onclick="masterMute()" title="Master quick-mute (Super+Shift+M)"></span>
+ <button class="x-btn" title="Close (Esc)">✕</button>
+ </div>
+ </div>
+
+ <div class="engrave">outputs<span class="cnt" id="out-cnt"></span></div>
+ <div id="outputs"></div>
+
+ <div class="engrave">inputs<span class="cnt" id="in-cnt"></span></div>
+ <div id="inputs"></div>
+
+ <div class="engrave">mic mode</div>
+ <div class="modes">
+ <button class="mode" id="md-toggle" onclick="micToggle()">LIVE<span class="k">Super+Shift+A</span></button>
+ <button class="mode" id="md-ptt" onclick="micPtt()">PUSH·TALK<span class="k">hold Space</span></button>
+ </div>
+ <div class="ptt-hint" id="ptt-hint"></div>
+
+ <!-- meter row 1 — volume you set -->
+ <div class="meters">
+ <div class="meter">
+ <span class="mode-tag" id="vt-out">OUT VOL</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-out"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-out">68</span> <small>%</small></div>
+ <div class="m-label" id="l-out">SPEAKERS</div>
+ </div>
+ <div class="meter">
+ <span class="mode-tag" id="vt-in">IN VOL</span>
+ <div class="dial"><div class="arc"></div>
+ <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div>
+ <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div>
+ <div class="tick" style="transform:rotate(60deg)"></div>
+ <div class="needle" id="n-in"></div><div class="hub"></div></div>
+ <div class="m-value"><span id="v-in">54</span> <small>%</small></div>
+ <div class="m-label" id="l-in">BUILT-IN MIC</div>
+ </div>
+ </div>
+
+ <!-- meter row 2 — stereo VU of the selected output's live signal -->
+ <div class="vupair" id="vupair">
+ <div class="vuhead"><span class="t">signal · VU peak</span><span class="src" id="vu-src">SPEAKERS</span></div>
+ <div class="vurow"><span class="ch">L</span><span class="vubar" id="vu-l"></span></div>
+ <div class="vurow"><span class="ch">R</span><span class="vubar" id="vu-r"></span></div>
+ </div>
+
+ <div class="toast" id="toast"></div>
+ </div>
+ </div>
+
+ <div class="aside">
+ <h3>The bar glyph</h3>
+ <div class="barbits">
+ <span><span class="g">󰕾</span> normal — speaker + arcs</span>
+ <span><span class="g mut">󰝟</span> muted — speaker ✕</span>
+ <span><span class="g" style="color:var(--gold)">󰢮</span> ptt armed</span>
+ </div>
+ <h3>Idiom map (same as net / bt)</h3>
+ <ul>
+ <li><b>Faceplate switch = master quick-mute</b> — the net wifi / bt power switch, here muting all output. Flip: state → MUTED, lamp red, bar glyph → speaker-✕.</li>
+ <li><b>Rows are devices</b> — every sink + source. <b>Click the row body</b> to set default (gold DEF moves); the fader sets that device's volume; the trailing glyph mutes just it.</li>
+ <li><b>Per-row signal meter</b> — the 4 bars at the left of each row read that device's <b>live</b> level. A sink can carry a stream without being default, so the one playing lights up — <em>demo: the music is on WH-1000XM4 while Speakers is still default. Click WH-1000XM4 to move to it.</em></li>
+ <li><b>Two meter rows</b> — row 1 = the volume you set (OUT + IN needles); row 2 = the <b>stereo VU</b> (L/R) of the selected output's live signal, red when muted.</li>
+ <li><b>Mic = two console keys</b> — one toggles LIVE↔MUTED (the label flips to show the state), the other is PUSH·TALK: the mic sits muted and un-mutes only while Space is held.</li>
+ <li><b>Verify-everything</b> — every action re-reads pactl/wpctl state after firing, like net/bt.</li>
+ </ul>
+ <h3>Push-to-talk — the one hard part</h3>
+ <div class="rec">
+ Hold-to-talk needs a global key grab under Wayland. Two routes to spec: <b>(a)</b> a Hyprland
+ <b>bind pair</b> — <em>bindp</em> Space press → unmute, release → re-mute, armed only while PTT
+ mode is active (so it doesn't steal Space everywhere); or <b>(b)</b> an <b>evdev/libinput
+ listener</b> reading the key directly. (a) is lighter; (b) survives focus changes but needs
+ input-group permissions. Feasibility research is phase 1 of the spec.
+ </div>
+ </div>
+</div>
+
+<script>
+const $ = id => document.getElementById(id);
+const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+/* sig = base live level 0..1 (what stream is on this device right now).
+ Note the demo: music is on WH-1000XM4 (sig .72) while Speakers is default. */
+let OUT = [
+ {id:'spk', name:'Built-in Speakers', g:'󰓃', vol:68, mute:false, def:true, sig:0, ph:0.0},
+ {id:'xm4', name:'WH-1000XM4', g:'󰋋', vol:80, mute:false, def:false, sig:0.72,ph:1.7},
+ {id:'hdmi',name:'HDMI · Dell U2720', g:'󰡁', vol:100,mute:true, def:false, sig:0, ph:2.9},
+];
+let IN = [
+ {id:'bmic', name:'Built-in Mic', g:'󰍬', vol:54, mute:false, def:true, sig:0.16,ph:0.6},
+ {id:'yeti', name:'Blue Yeti USB', g:'󰦉', vol:70, mute:false, def:false, sig:0, ph:2.1},
+ {id:'xm4m', name:'WH-1000XM4 Headset',g:'󰋎', vol:60, mute:true, def:false, sig:0, ph:3.3},
+];
+let master = false, micMode = 'live', pttHeld = false;
+const pkL={v:0}, pkR={v:0};
+
+function outMutedOf(d){ return master || d.mute; }
+function inMutedOf(d){ return d.mute || (d.def && (micMode==='mute' || (micMode==='ptt' && !pttHeld))); }
+
+function fader(dev){
+ const w=document.createElement('div'); w.className='fader';
+ w.innerHTML=`<div class="trk"><div class="fill" style="width:${dev.vol}%"></div></div>`+
+ `<div class="cap" style="left:${dev.vol}%"></div>`;
+ w.onclick=(e)=>{e.stopPropagation();
+ const r=w.getBoundingClientRect();
+ dev.vol=Math.max(0,Math.min(100,Math.round((e.clientX-r.left)/r.width*100)));
+ if(dev.vol>0) dev.mute=false;
+ render(); toast(`${dev.name} → ${dev.vol}%`);
+ };
+ return w;
+}
+function row(dev,kind){
+ const muted = kind==='out'?outMutedOf(dev):inMutedOf(dev);
+ const r=document.createElement('div');
+ r.className='dev'+(dev.def?' active':'')+(muted?' muted':'');
+ r.title=dev.name;
+ const sig=document.createElement('span'); sig.className='sig'; sig.id='sig-'+dev.id;
+ sig.innerHTML='<i></i><i></i><i></i><i></i>';
+ const who=document.createElement('div'); who.className='who';
+ who.innerHTML=`<span class="g">${dev.g}</span><span class="nm">${dev.name}</span><span class="def">DEF</span>`;
+ const pct=document.createElement('span'); pct.className='pct'; pct.textContent=dev.mute?'mute':dev.vol+'%';
+ const mb=document.createElement('button'); mb.className='mute-b'; mb.textContent=dev.mute?'󰝟':'󰕾';
+ mb.title='mute '+dev.name;
+ mb.onclick=(e)=>{e.stopPropagation();dev.mute=!dev.mute;render();toast(`${dev.name} ${dev.mute?'muted':'unmuted'}`);};
+ r.append(sig,who,fader(dev),pct,mb);
+ r.onclick=()=>{ if(dev.def) return;
+ (kind==='out'?OUT:IN).forEach(d=>d.def=false); dev.def=true; render();
+ toast(`default ${kind==='out'?'output':'input'} → ${dev.name}`);
+ };
+ return r;
+}
+function render(){
+ const o=$('outputs'); o.innerHTML=''; OUT.forEach(d=>o.appendChild(row(d,'out')));
+ const i=$('inputs'); i.innerHTML=''; IN.forEach(d=>i.appendChild(row(d,'in')));
+ $('out-cnt').textContent='· '+OUT.length;
+ $('in-cnt').textContent='· '+IN.length;
+ const od=OUT.find(d=>d.def), id=IN.find(d=>d.def);
+ const oM=outMutedOf(od), iM=inMutedOf(id);
+ $('master').className='switch'+(master?' muted':' on');
+ $('state').textContent=oM?'MUTED':'PLAYBACK';
+ $('lamp').className='lamp'+(oM?' red':'');
+ $('face-g').textContent=oM?'󰝟':'󰕾';
+ $('mute-badge').classList.toggle('show',oM);
+ setNeedle('out',oM?0:od.vol,oM); $('v-out').textContent=oM?0:od.vol; $('l-out').textContent=od.name.toUpperCase();
+ setNeedle('in',iM?0:id.vol,iM); $('v-in').textContent=iM?0:id.vol; $('l-in').textContent=id.name.toUpperCase();
+ $('vt-out').textContent=oM?'OUT·MUTE':'OUT VOL'; $('vt-out').className='mode-tag'+(oM?' mut':'');
+ $('vt-in').textContent=iM?'IN·MUTE':'IN VOL'; $('vt-in').className='mode-tag'+(iM?' mut':'');
+ $('vu-src').textContent=od.name.toUpperCase();
+ $('vupair').classList.toggle('mute',oM);
+ // mic controls: one live/muted toggle + one push-to-talk key
+ const tg=$('md-toggle');
+ tg.textContent = micMode==='mute' ? 'MUTED' : 'LIVE';
+ tg.appendChild(Object.assign(document.createElement('span'),{className:'k',textContent:'Super+Shift+A'}));
+ tg.className = 'mode' + (micMode==='mute' ? ' on mute' : micMode==='live' ? ' on' : '');
+ $('md-ptt').className='mode'+(micMode==='ptt'?' on':'');
+ $('ptt-hint').textContent = micMode==='ptt'
+ ? (pttHeld?'▸ transmitting — Space held':'mic muted — hold Space to talk')
+ : micMode==='mute' ? 'mic muted' : '';
+ $('ptt-hint').className='ptt-hint'+(micMode==='ptt'&&pttHeld?' live':'');
+}
+function setNeedle(side,val,muted){
+ const deg=-60+Math.max(0,Math.min(1,val/100))*120;
+ const n=$('n-'+side); n.style.transform=`rotate(${deg}deg)`;
+ n.className='needle'+(muted?' mut':'');
+}
+
+/* live-signal animation — the per-row minis + the stereo VU pair */
+let phase=0;
+function level(dev,muted){
+ if(muted||!dev.sig) return 0;
+ const env=0.5+0.5*Math.abs(Math.sin(phase*1.25+dev.ph));
+ const trans=Math.random()<0.14?Math.random()*0.4:0;
+ return Math.max(0,Math.min(1,dev.sig*env+trans-Math.random()*0.07));
+}
+function paintMini(el,lvl){
+ const b=el.children, lit=Math.round(lvl*4);
+ for(let k=0;k<4;k++){ b[k].className = k<lit ? (k>=3?'clip':k>=2?'hot':'on') : ''; }
+}
+function paintVU(el,lvl,pk){
+ const b=el.children, n=b.length, lit=Math.round(lvl*n);
+ pk.v=Math.max(lit,pk.v-0.35); const p=Math.round(pk.v);
+ for(let k=0;k<n;k++){ let c = k<lit ? (k>=n-2?'clip':k>=n-4?'hot':'on') : '';
+ if(p>0 && k===p-1) c=(c?c+' ':'')+'peak'; b[k].className=c; }
+}
+function buildVU(el,n){ el.innerHTML=''; for(let k=0;k<n;k++) el.appendChild(document.createElement('i')); }
+function tick(){
+ phase+=0.09;
+ OUT.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,outMutedOf(d)));});
+ IN.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,inMutedOf(d)));});
+ const od=OUT.find(d=>d.def), oM=outMutedOf(od);
+ const base=oM?0:level(od,false);
+ paintVU($('vu-l'),Math.min(1,base*(0.92+Math.random()*0.16)),pkL);
+ paintVU($('vu-r'),Math.min(1,base*(0.92+Math.random()*0.16)),pkR);
+}
+
+function masterMute(){ master=!master; render(); toast(master?'ALL OUTPUT MUTED':'output unmuted'); }
+/* two mic buttons: toggle flips live<->muted (and leaves ptt); ptt arms/disarms */
+function micToggle(){ micMode = micMode==='mute' ? 'live' : 'mute'; pttHeld=false; render();
+ toast(micMode==='mute'?'mic muted':'mic live'); }
+function micPtt(){ micMode = micMode==='ptt' ? 'live' : 'ptt'; pttHeld=false; render();
+ toast(micMode==='ptt'?'push-to-talk armed — hold Space':'mic live'); }
+let tT;
+function toast(msg,err){ const t=$('toast'); t.textContent=msg;
+ t.className='toast show'+(err?' err':''); clearTimeout(tT); tT=setTimeout(()=>t.className='toast',2200); }
+addEventListener('keydown',e=>{ if(e.code==='Space'&&micMode==='ptt'&&!e.repeat){e.preventDefault();pttHeld=true;render();}});
+addEventListener('keyup', e=>{ if(e.code==='Space'&&micMode==='ptt'){e.preventDefault();pttHeld=false;render();}});
+
+buildVU($('vu-l'),16); buildVU($('vu-r'),16);
+render();
+if(!reduced) setInterval(tick,70); else tick();
+</script>
+</body>
+</html>
diff --git a/docs/prototypes/2026-07-03-waybar-redesign-prototype.html b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html
new file mode 100644
index 0000000..3f3e7c1
--- /dev/null
+++ b/docs/prototypes/2026-07-03-waybar-redesign-prototype.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>Waybar redesign — dupre instrument console</title>
+<style>
+:root{
+ --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917;
+ --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5;
+ --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d;
+ --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground)}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:1400px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem}
+.masthead p{color:var(--dim);font-size:.86rem;max-width:88ch}
+.masthead p b{color:var(--silver);font-weight:700}
+
+/* each variation sits on a "desk" — a strip of desktop so the bar reads as a
+ real top bar floating over a window, exactly how it looks on-screen */
+.desk{margin:1.9rem 0 0;border-radius:14px;overflow:hidden;
+ border:1px solid #211e1a;
+ background:
+ linear-gradient(180deg,#0e0d0c 0 62px,transparent 62px),
+ repeating-linear-gradient(135deg,#191613 0 14px,#171512 14px 28px);
+ position:relative}
+.desk-label{display:flex;align-items:baseline;gap:.7rem;padding:.5rem .2rem .1rem}
+.desk-label .n{color:var(--gold);font-size:.82rem;letter-spacing:.08em}
+.desk-label .d{color:var(--dim);font-size:.76rem}
+.desk-window{position:absolute;inset:70px 26px 22px;border:1px solid #262320;border-radius:10px;
+ background:linear-gradient(180deg,#131110,#0e0d0c);opacity:.6}
+.desk-window::before{content:"emacs — instrument-console.el";position:absolute;top:8px;left:14px;
+ color:#3a3630;font-size:.72rem}
+
+/* the bar frame: matches waybar's -54 top strip. two clusters, gold-bordered,
+ floating with a gap between them (modules-center is empty in the real config) */
+.bar{position:relative;z-index:2;display:flex;justify-content:space-between;align-items:flex-start;
+ gap:1rem;padding:10px 12px;height:132px}
+.cluster{display:flex;align-items:center;gap:2px;
+ background:var(--panel);border:1.4px solid var(--gold);border-radius:15px;
+ padding:2px 9px;box-shadow:0 4px 9px rgba(0,0,0,.5)}
+.mod{display:flex;align-items:center;gap:7px;color:var(--silver);
+ padding:7px 9px;border-radius:11px;font-size:14px;cursor:default;white-space:nowrap;position:relative}
+.mod .g{font-size:16px;line-height:1}
+.mod .g.xl{font-size:19px}
+.mod:hover{background:var(--wash)}
+.val{font-variant-numeric:tabular-nums}
+.cream{color:var(--cream)}.gold{color:var(--gold)}.dim{color:var(--dim)}
+.fail{color:var(--fail)}.pass{color:var(--pass)}.steel{color:var(--steel)}
+
+/* workspaces — circular tokens like the real ws-icons */
+.ws{display:flex;gap:5px;padding:0 3px}
+.ws b{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-size:13px;
+ color:var(--silver);border:1.4px solid var(--slate);background:#141210}
+.ws b.on{color:var(--panel);background:var(--gold);border-color:var(--gold);font-weight:700;
+ box-shadow:0 0 8px 1px rgba(218,181,61,.4)}
+.ws b.busy{border-color:var(--steel);color:var(--cream)}
+.menu{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;color:var(--gold);
+ font-size:17px;background:linear-gradient(180deg,#211e19,#151210);border:1px solid #33302b}
+.title{color:var(--dim);font-size:13px;max-width:230px;overflow:hidden;text-overflow:ellipsis}
+
+/* collapse arrows — recessed dim wells, per the current design */
+.arrow{color:var(--dim);font-size:12px;background:rgba(0,0,0,.35);
+ box-shadow:inset 0 1px 2px rgba(0,0,0,.7);border-radius:7px;padding:8px 7px}
+
+/* lamp — the panel's signature status dot, glow and all */
+.lamp{width:8px;height:8px;border-radius:50%;background:var(--pass);flex:0 0 auto;
+ box-shadow:0 0 6px 1px rgba(116,147,47,.55)}
+.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}
+.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)}
+.lamp.off{background:var(--wash);box-shadow:none}
+
+/* engraved hairline divider between functional groups (echoes .engrave rules) */
+.sep{width:1px;align-self:stretch;margin:6px 3px;
+ background:linear-gradient(180deg,transparent,var(--wash) 22%,var(--wash) 78%,transparent)}
+
+.notes{margin-top:2.4rem;display:flex;gap:2rem;flex-wrap:wrap}
+.note{flex:1 1 300px;min-width:280px}
+.note h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem}
+.note ul{list-style:none}
+.note li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative;color:var(--silver)}
+.note li::before{content:"·";color:var(--gold);position:absolute;left:.25rem}
+.note li b{color:var(--cream);font-weight:700}
+.note li em{color:var(--dim);font-style:normal}
+.rec{border:1px dashed var(--wash);border-radius:10px;padding:.9rem 1.1rem;margin-top:1.1rem;
+ font-size:.83rem;color:var(--silver)}
+.rec b{color:var(--gold)}
+
+/* ============ V1 · FACEPLATE ============ */
+/* machined faceplate: vertical gradient + a 1px top highlight + deeper shadow.
+ otherwise the current layout — lowest-risk, faithful to GTK CSS. */
+.v1 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)}
+.v1 .val.clock{color:var(--cream)}
+
+/* ============ V2 · INSTRUMENT SEGMENTS ============ */
+/* each functional group is its own recessed sub-faceplate with an engraved unit
+ label underneath, lamps on status modules, gold-hi active values */
+.v2 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));padding:3px 6px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)}
+.v2 .seg{display:flex;flex-direction:column;align-items:center;gap:2px;
+ background:var(--well);border:1px solid #232019;border-radius:10px;padding:3px 5px 2px;margin:0 2px}
+.v2 .seg .row{display:flex;align-items:center;gap:6px}
+.v2 .seg .unit{color:var(--steel);font-size:.52rem;letter-spacing:.24em;text-transform:uppercase}
+.v2 .mod{padding:5px 7px}
+.v2 .mod:hover{background:var(--wash)}
+.v2 .menu{background:linear-gradient(180deg,#2a251d,#161310);border-color:var(--gold)}
+/* mini gauge for sysmon — a squat needle echoing the panel meters */
+.gauge{width:26px;height:15px;position:relative;overflow:hidden}
+.gauge .arc{position:absolute;inset:0 0 -26px 0;border:1.5px solid var(--wash);border-radius:50%}
+.gauge .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:13px;background:var(--gold-hi);
+ transform-origin:50% 100%;transform:rotate(18deg);border-radius:1px;
+ box-shadow:0 0 4px rgba(255,215,95,.5)}
+.gauge .hub{position:absolute;left:50%;bottom:-2px;width:5px;height:5px;margin-left:-2.5px;
+ border-radius:50%;background:var(--gold)}
+
+/* ============ V3 · FULL CONSOLE ============ */
+/* every module a recessed well with a lamp; console-key toggles with full
+ physical-key gradient + inset; sysmon as twin analog gauges; cream clock */
+.v3 .cluster{background:linear-gradient(180deg,#0d0c0b,#080807);border-width:1.8px;
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 8px 18px rgba(0,0,0,.6);padding:3px 8px}
+.v3 .mod{background:var(--well);border:1px solid #201d17;border-radius:9px;margin:0 2px;padding:6px 9px}
+.v3 .mod:hover{background:#141210;border-color:var(--slate)}
+/* console-key toggles — the physical key from the panels' .c-btn */
+.v3 .key{background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;
+ border-bottom-color:#0c0b0a;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)}
+.v3 .key.engaged{color:var(--gold);border-color:var(--gold)}
+.v3 .key.off{color:var(--fail)}
+.v3 .menu{background:linear-gradient(180deg,#2a251d,#141110);border:1px solid var(--gold);
+ box-shadow:0 0 8px rgba(218,181,61,.25)}
+.v3 .ws b{background:var(--well);border-color:#201d17}
+.v3 .ws b.on{background:var(--gold);border-color:var(--gold)}
+.v3 .val.clock{color:var(--cream);font-weight:700}
+.v3 .tz{color:var(--steel);font-size:.55rem;letter-spacing:.2em}
+.v3 .twin{display:flex;gap:6px}
+.v3 .gauge2{width:22px;height:14px;position:relative;overflow:hidden}
+.v3 .gauge2 .arc{position:absolute;inset:0 0 -22px 0;border:1.5px solid var(--wash);border-radius:50%}
+.v3 .gauge2 .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:12px;transform-origin:50% 100%;
+ border-radius:1px;background:var(--gold-hi);box-shadow:0 0 4px rgba(255,215,95,.5)}
+.v3 .gauge2 .ndl.warn{background:var(--gold)}
+.v3 .gauge2 .hub{position:absolute;left:50%;bottom:-2px;width:4px;height:4px;margin-left:-2px;
+ border-radius:50%;background:var(--gold)}
+</style>
+</head>
+<body>
+<div class="wrap">
+
+<header class="masthead">
+ <div class="eyebrow">archsetup · dupre panel family · waybar</div>
+ <h1>Waybar — three ways to spruce it</h1>
+ <p>The bar already runs the dupre palette and a gold border. These three push it toward the
+ <b>instrument-console faceplate</b> language of the net + bluetooth panels — machined
+ gradient plates, engraved unit labels, glowing status lamps, physical console keys, analog
+ gauges — dialing the intensity up from left-touch to full console. Same real module set in
+ each so you're comparing <b>treatment, not content</b>. All three stay inside what GTK3 CSS
+ (waybar's engine) can actually render.</p>
+</header>
+
+<!-- ============ CURRENT (reference) ============ -->
+<div class="desk">
+ <div class="bar" style="padding-top:14px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="mod"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="g gold">󰤨</span></span>
+ <span class="mod"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val">62%</span></span>
+ <span class="mod"><span class="g">󰹇</span></span>
+ <span class="mod"><span class="g">󰃟</span></span>
+ <span class="mod"><span class="g">󰒲</span></span>
+ <span class="mod"><span class="g">󰂯</span></span>
+ <span class="mod"><span class="g">󰻠</span> <span class="val">8%</span></span>
+ <span class="mod"><span class="g">󰔛</span> <span class="val">24:10</span></span>
+ <span class="mod"><span class="g">󰃭</span> <span class="val">Fri Jul 3</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">current</span><span class="d">— flat pills, colour-only states. the baseline these three build on.</span></div>
+
+<!-- ============ V1 · FACEPLATE ============ -->
+<div class="desk v1">
+ <div class="bar" style="padding-top:14px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="lamp gold"></span><span class="g gold">󰤨</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val cream">62%</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰹇</span></span>
+ <span class="mod"><span class="g">󰃟</span></span>
+ <span class="mod"><span class="g gold">󰒲</span></span>
+ <span class="mod"><span class="lamp"></span><span class="g">󰂯</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g">󰻠</span> <span class="val">8%</span></span>
+ <span class="mod"><span class="g gold">󰔛</span> <span class="val gold">24:10</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g dim">󰃭</span> <span class="val clock">Fri Jul 3</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 1 · faceplate</span><span class="d">— machined gradient + top highlight, engraved hairline dividers, status lamps on net/bt, cream clock. Nearest to today; drop-in GTK CSS.</span></div>
+
+<!-- ============ V2 · INSTRUMENT SEGMENTS ============ -->
+<div class="desk v2">
+ <div class="bar" style="padding-top:12px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="seg"><span class="row ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span><span class="unit">wksp</span></span>
+ <span class="seg"><span class="row"><span class="g">󰕰</span><span class="title" style="max-width:180px">instrument-console.el</span></span><span class="unit">layout · window</span></span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="seg"><span class="row"><span class="lamp gold"></span><span class="g gold">󰤨</span><span class="gold">CAPTIVE</span></span><span class="unit">net</span></span>
+ <span class="seg"><span class="row"><span class="g xl">󰍬</span><span class="g xl">󰕾</span><span class="val cream">62%</span></span><span class="unit">sound</span></span>
+ <span class="seg"><span class="row"><span class="g">󰹇</span><span class="g">󰃟</span><span class="g gold">󰒲</span></span><span class="unit">toggles</span></span>
+ <span class="seg"><span class="row"><span class="lamp"></span><span class="g">󰂯</span><span class="dim">M650</span></span><span class="unit">bt</span></span>
+ <span class="seg"><span class="row"><span class="gauge"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span><span class="val">8%</span></span><span class="unit">cpu</span></span>
+ <span class="seg"><span class="row"><span class="g gold">󰔛</span><span class="val gold">24:10</span></span><span class="unit">timer</span></span>
+ <span class="seg"><span class="row"><span class="val cream">Fri Jul 3</span><span class="val">11:23</span></span><span class="unit">clock</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 2 · instrument segments</span><span class="d">— each group a recessed sub-plate with an engraved unit label; a squat needle gauge for cpu. Reads like a row of instruments. Taller; label row costs a few px.</span></div>
+
+<!-- ============ V3 · FULL CONSOLE ============ -->
+<div class="desk v3">
+ <div class="bar" style="padding-top:12px">
+ <div class="cluster">
+ <span class="menu"></span>
+ <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span>
+ <span class="sep"></span>
+ <span class="mod key"><span class="g">󰕰</span></span>
+ <span class="title">instrument-console.el</span>
+ <span class="mod arrow"></span>
+ </div>
+ <div class="cluster">
+ <span class="mod arrow"></span>
+ <span class="mod"><span class="lamp gold"></span><span class="g gold">󰤨</span> <span class="gold">CAPTIVE</span></span>
+ <span class="sep"></span>
+ <span class="mod key"><span class="g xl">󰍬</span></span>
+ <span class="mod"><span class="g xl">󰕾</span> <span class="val cream">62%</span></span>
+ <span class="sep"></span>
+ <span class="mod key engaged"><span class="g">󰹇</span></span>
+ <span class="mod key"><span class="g">󰃟</span></span>
+ <span class="mod key engaged"><span class="g">󰒲</span></span>
+ <span class="mod"><span class="lamp"></span><span class="g">󰂯</span> <span class="dim val">72%</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="twin">
+ <span class="gauge2"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span>
+ <span class="gauge2"><span class="arc"></span><span class="ndl warn" style="transform:rotate(38deg)"></span><span class="hub"></span></span>
+ </span></span>
+ <span class="mod key"><span class="g gold">󰔛</span> <span class="val gold">24:10</span></span>
+ <span class="sep"></span>
+ <span class="mod"><span class="g dim">󰃭</span> <span class="val clock">Fri Jul 3</span> <span class="val clock">11:23</span><span class="tz">&nbsp;EDT</span></span>
+ </div>
+ </div>
+ <div class="desk-window"></div>
+</div>
+<div class="desk-label"><span class="n">variation 3 · full console</span><span class="d">— every module a recessed well, physical console keys for toggles (gold when engaged, terracotta when off), twin cpu/mem gauges, cream tabular clock with engraved TZ. Furthest from today; closest to the panels.</span></div>
+
+<!-- ============ NOTES ============ -->
+<div class="notes">
+ <div class="note">
+ <h3>What carries over from the panels</h3>
+ <ul>
+ <li><b>Lamps</b> — the glowing status dot lands on net + bt so health reads at a glance, not just by glyph colour <em>(gold = captive/engaged, green = ok, red = fail, dim = off)</em>.</li>
+ <li><b>Machined faceplate</b> — the cluster gets the b-face vertical gradient + 1px top highlight + deeper shadow, so it looks milled rather than printed.</li>
+ <li><b>Engraved dividers</b> — hairline separators group the right cluster into net · sound · toggles · system · clock, echoing the panels' engraved section rules.</li>
+ <li><b>Console keys</b> — the toggles (touchpad, dim, caffeine) borrow .c-btn: gradient fill, inset highlight, gold border when engaged.</li>
+ <li><b>Gauges</b> — sysmon becomes a squat needle (or twin needles for cpu/mem), the same instrument the panels use for throughput and battery.</li>
+ <li><b>Cream + tabular</b> — the clock and live values shift to cream with tabular-nums, matching the panels' readouts.</li>
+ </ul>
+ </div>
+ <div class="note">
+ <h3>GTK3 translation caveats</h3>
+ <ul>
+ <li><b>Dividers</b> need real separator modules or per-module borders — waybar can't inject <em>::before</em> content between modules the way this HTML does.</li>
+ <li><b>Lamps</b> render as a small pango glyph (● with colour + text-shadow glow) prepended in each script, or a tiny bordered box widget — both are GTK-safe.</li>
+ <li><b>Gauges</b> are the real work: GTK CSS can't draw a rotating needle. Options — a Cairo/GTK drawing area in a custom module, or fake it with a unicode gauge glyph that steps by load band. V2's single gauge is cheaper than V3's twin.</li>
+ <li><b>V2's unit labels</b> raise the bar height (the label row). Fine at 54px reserved, but worth eyeballing against the -54 margin strip.</li>
+ <li>Gradients, inset box-shadow, border colour states, tabular-nums — all already proven in the current stylesheet.</li>
+ </ul>
+ </div>
+ <div class="note">
+ <h3>My read</h3>
+ <div class="rec">
+ <b>Variation 1 (faceplate)</b> is the one I'd ship first: it lands ~80% of the instrument-console feel — lamps, milled plates, engraved grouping, cream clock — for pure CSS plus a lamp glyph in the net/bt scripts. No custom drawing, no height risk.
+ <br><br>
+ <b>Variation 3</b> is the aspirational target once a gauge-drawing module exists (it'd also upgrade the sysmon popup). <b>Variation 2</b> is the middle path if you want the unit labels' legibility but not the full recessed-well density. They're not exclusive — 1 can grow into 3.
+ </div>
+ </div>
+</div>
+
+</div>
+</body>
+</html>
diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org
new file mode 100644
index 0000000..9df85cb
--- /dev/null
+++ b/docs/prototypes/README.org
@@ -0,0 +1,21 @@
+#+TITLE: Panel & Waybar Design Prototypes
+#+AUTHOR: Craig Jennings
+
+Self-contained HTML/CSS design prototypes for the instrument-console panel
+family and the waybar redesign. Each opens standalone in a browser (no external
+assets). These are the normative visual references the specs in [[file:../specs/][docs/specs/]]
+point at.
+
+* Prototypes
+
+- [[file:2026-07-03-instrument-console-panels-prototype.html][2026-07-03-instrument-console-panels-prototype.html]] — the net + bluetooth
+ pair; the approved faceplate design that shipped. Normative reference for
+ [[file:../specs/2026-07-03-instrument-console-panels-spec.org][the instrument-console spec]].
+- [[file:2026-07-03-net-panel-rescan-prototype.html][2026-07-03-net-panel-rescan-prototype.html]] — the manual rescan/scan ⟳
+ affordance for the NETWORKS/NEARBY headers (busy-style throbber + list fade).
+- [[file:2026-07-03-sound-panel-prototype.html][2026-07-03-sound-panel-prototype.html]] — the audio/pulsemixer console; layout
+ reference for [[file:../specs/2026-07-03-audio-panel-spec.org][the audio-panel spec]].
+- [[file:2026-07-03-panel-widget-gallery-prototype.html][2026-07-03-panel-widget-gallery-prototype.html]] — the shared instrument-console
+ widget kit (lamps, engraved sections, console keys, needle gauges).
+- [[file:2026-07-03-waybar-redesign-prototype.html][2026-07-03-waybar-redesign-prototype.html]] — three directions for sprucing up
+ waybar in the dupre instrument-console aesthetic (future work).
diff --git a/docs/design/2026-07-02-bluetooth-panel-spec.org b/docs/specs/2026-07-02-bluetooth-panel-spec.org
index 121197a..f1b3ac1 100644
--- a/docs/design/2026-07-02-bluetooth-panel-spec.org
+++ b/docs/specs/2026-07-02-bluetooth-panel-spec.org
@@ -4,6 +4,12 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+* IMPLEMENTED Bluetooth Panel — CLI-Driven, Net-Panel Kin
+:PROPERTIES:
+:ID: 8af6a76a-5665-4d20-9efd-ffdf7460c981
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped through phase 3; build task DONE and manual tests filed.)
+
* IMPLEMENTED Status
:PROPERTIES:
:ID: 1271a845-4463-4831-9902-990eda6b2265
diff --git a/docs/design/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org
index 8becf71..50853f3 100644
--- a/docs/design/2026-07-02-desktop-settings-panel-spec.org
+++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org
@@ -4,10 +4,23 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+* DRAFT Desktop-Settings Dropdown Panel
+:PROPERTIES:
+:ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed)
+
* DRAFT Status
:PROPERTIES:
:ID: fb7eec22-a214-4568-82c4-903612f4832f
:END:
+- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (dim + airplane collapse into the panel, touchpad + caffeine stay on the bar; Super+Shift+G keybind; code in dotfiles settings/ beside net/; 5% brightness floor). The four collapse/keybind/location/floor decisions are closed; one open scoping question remains ("few other things" — see Decisions) before a spec-review can flip it READY.
+- [2026-07-03 Fri] DRAFT update — from the waybar/panel-family design
+ discussion: adopt the instrument-console faceplate aesthetic net + bt
+ shipped (lamps, engraved sections, console keys, machined plate), not just
+ the raw palette; add the audio panel as a sibling in the panel family;
+ cross-reference the shared faceplate CSS. Toggle-consolidation scope (the
+ "few other things") still open — see the Decisions section.
- [2026-07-02 Thu] DRAFT — initial spec from the todo.org task "Desktop-settings
dropdown panel" (2026-06-24 review), updated for the Blueprint/GTK4 pipeline
the net panel stood up 2026-07-01.
@@ -23,6 +36,7 @@
| Repo | dotfiles |
|--------+----------------------------------------------|
| Kin | net panel (architecture donor), theme studio |
+| | audio panel (sibling), bt panel (aesthetic) |
|--------+----------------------------------------------|
* Problem
@@ -61,9 +75,12 @@ gathers them.
suite.
- One gated AT-SPI smoke (the run-panel-smoke.sh pattern), no bespoke
headless widget suite.
-- Dupre WIP palette CSS, shared with the net panel — factor the palette
- block into a common css asset both panels load rather than duplicating
- (feeds the theme-studio task later).
+- Instrument-console faceplate aesthetic, consistent with net + bt + audio:
+ the machined gradient plate, glowing status lamps, engraved section labels,
+ physical console keys for the toggles, and (where a level applies) needle
+ gauges. Load the shared instrument-console palette/faceplate CSS asset all
+ the family panels use — factor it once, don't duplicate (feeds the
+ theme-studio task later).
** Controls and their backings
@@ -93,28 +110,43 @@ signals so both surfaces agree.
Gear glyph module on the bar right cluster; click toggles the panel
(layer-shell anchored under the bar, right-aligned). Focus-out auto-hide +
-Close button, matching the net panel. Keybind decision below.
+Close button, matching the net panel. Keybind: Super+Shift+G (decision B).
* Decisions (Craig)
-** TODO Which standalone bar indicators collapse into the panel?
-Options per module (dim, touchpad, caffeine): keep on bar + mirrored in
-panel; or panel-only (frees bar width). Recommendation: keep touchpad and
-caffeine visible on the bar (state you glance at), move dim into the panel
-(you set it rarely), keep airplane where it is.
-
-** TODO Keybind for the panel?
-Super+Shift+G (gear) is free. Or no keybind — mouse-only surface.
-
-** TODO Where does the code live?
-Recommendation: dotfiles =settings/= sibling to =net/= (same src-layout,
-tests in tests/settings/), sharing the palette css. In-tree pocketbook-style
-was the old note; the net panel is the better donor now.
-
-** TODO Slider granularity and floor
-brightnessctl exposes 0-100%; a 5% floor stops "screen went black in a dark
-room" lockouts. Confirm the floor (or allow 0 with a long-press escape
-hatch).
+** DONE Which standalone bar indicators collapse into the panel?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): touchpad and caffeine stay on the bar (glanceable state); auto-dim and airplane move into the panel (panel-only, freeing bar width). The airplane Super+Shift+A toggle keybind stays as the quick lane — only its bar indicator collapses in.
+
+** DONE Keybind for the panel?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): Super+Shift+G (gear), for parity with the other panels' fast path.
+
+** DONE Where does the code live?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): dotfiles =settings/= sibling to =net/= (same src-layout, tests in tests/settings/), sharing the palette css. The net panel is the architecture donor; the old in-tree pocketbook-style note is out.
+
+** DONE Slider granularity and floor
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): 5% floor on the brightness slider, so a dark-room drag can't black the screen out and lock you out. brightnessctl's 0-100% range clamps to a 5% minimum.
+
+** TODO What are the "few other things" beyond the toggles?
+The 2026-07-03 discussion named consolidating the toggle buttons "and a few
+other things" into this panel, but the extras weren't enumerated. Current
+control list (above): auto-dim, idle/caffeine, touchpad, mouse, airplane,
+screen brightness, keyboard backlight. Candidates raised or adjacent —
+confirm which belong here vs the audio panel vs the bar: night-light / color
+temperature, a theme/dupre-vs-hudson switch (theme-studio kin), volume or a
+master-mute mirror (or leave all audio to the audio panel), a
+notifications/do-not-disturb toggle (dunst), lock/suspend actions. Craig to
+name the set.
+
+*** 2026-07-04 Sat — Craig's input (roam capture): the set includes a wallpaper manager
+Confirmed the panel gathers the mouse/trackpad toggle, a no-sleep (idle-inhibit) toggle, and the auto-dim toggle, and adds a *wallpaper manager* (this is where the displaced waypaper functionality lands — see the media/keybind change that freed Super+Shift+P). The wallpaper manager needs its own depth:
+- take a number of directories to look in;
+- switch the wallpaper with the change persisting across sessions;
+- switch between two pictures at sunup / sundown (a day/night pair).
+That last one implies a sun-time source (a lat/long or a sunrise/sunset lookup). The wallpaper manager is sizable enough it may want its own sub-spec rather than a single panel row; decide during the spec-review whether it's a row that opens a sub-view or a separate panel. Remaining "few other things" candidates above (night-light, theme switch, DND, lock/suspend) still await Craig's yes/no.
* Implementation phases
@@ -124,5 +156,6 @@ hatch).
semantics) — unit-tested, no GTK.
3. Blueprint UI + gear bar module + open/close wiring; palette css factored
to a shared asset; AT-SPI smoke.
-4. Bar-module consolidation per the decision above (drop/keep indicators,
- refresh-signal wiring, keybind).
+4. Bar-module consolidation per decision A: drop the dim and airplane bar
+ modules (now panel-only), keep touchpad and caffeine on the bar, wire the
+ refresh signals so bar and panel agree, and bind Super+Shift+G.
diff --git a/docs/design/2026-07-02-file-manager-swallow-spec.org b/docs/specs/2026-07-02-file-manager-swallow-spec.org
index 4c61be1..b898f11 100644
--- a/docs/design/2026-07-02-file-manager-swallow-spec.org
+++ b/docs/specs/2026-07-02-file-manager-swallow-spec.org
@@ -4,6 +4,12 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+* CANCELLED File-Manager Swallow Pattern
+:PROPERTIES:
+:ID: 179a1cd2-7a02-4c44-a09d-685c5a154895
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to CANCELLED (reason: Native swallow ruled out by test; reassigned to .emacs.d dirvish handling.)
+
* CANCELLED Status
:PROPERTIES:
:ID: d92e0074-f594-4e83-81a0-faf282e15ed0
diff --git a/docs/design/2026-07-02-net-panel-other-interfaces-spec.org b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org
index 6b0a72d..0d63feb 100644
--- a/docs/design/2026-07-02-net-panel-other-interfaces-spec.org
+++ b/docs/specs/2026-07-02-net-panel-other-interfaces-spec.org
@@ -4,6 +4,12 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+* IMPLEMENTED Net Panel — Tailscale, VPN, and WireGuard Interfaces
+:PROPERTIES:
+:ID: 09f4cd40-f391-4eba-a4ff-c22bad00ad7f
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Tunnels track shipped detection, diagnose, and panel bring-up; build task DONE.)
+
* IMPLEMENTED Status
:PROPERTIES:
:ID: 79a1075a-4b56-4f25-a861-b69f120a636a
diff --git a/docs/specs/2026-07-02-timer-panel-spec.org b/docs/specs/2026-07-02-timer-panel-spec.org
new file mode 100644
index 0000000..275bb2c
--- /dev/null
+++ b/docs/specs/2026-07-02-timer-panel-spec.org
@@ -0,0 +1,221 @@
+#+TITLE: Timer GTK Panel
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-02
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Timer GTK Panel
+:PROPERTIES:
+:ID: 25ed5321-f035-42b3-b115-69364d775f41
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed)
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: 1770af2e-b093-4024-a512-ae4324a2869f
+:END:
+- [2026-07-05 Sun] IMPLEMENTED — redesign built and shipped to dotfiles in a no-approvals speedrun (5 commits =c7ac193=..=5a863b5=): the wtimer engine (timer repeat, recurring alarms with snooze/ringing/dismiss, =@half=/=@hour=/=+dur= alarm parse, the rebuilt configurable pomodoro cycle, bar-tooltip parity), the PanelModel view-data rebuild (=row_view=, ringing-first sort, per-type create options as flags, locked presets + half-past + named pomodoro cycles), the GTK hero-on-top panel (Cairo progress ring + stopwatch sweep dial, per-type create strips, one transport row, close ✕/Esc), and the bar tooltip parity. wtimer + timer suites 231 green, full =make test= green. Live GTK render is the manual checklist (todo.org). Stopwatch run-save deferred to vNext.
+- [2026-07-05 Sun] DOING — UI/UX redesign decided through a prototype process (research → brainstorm → several directions → iterate to final; see Prototype iterations below). The shipped v1 panel stands, but this rewrite supersedes its layout and adds functionality: a hero-on-top + rack layout, a live waybar-module preview at parity with =wtimer render=, per-type create-strip features (timer auto-repeat; alarm recurring weekdays + snooze + a ringing state; a configurable pomodoro cycle with work/rest short+long and a long-break interval; a stopwatch analog sweep dial + last-lap badge), presets (renamed from "chips") whose shipped defaults are locked and whose load flashes the fields, and a header close button. Stopwatch run-save is cut to a vNext. Rebuild pending — folds this decided design into the shipped =timer/= package.
+- [2026-07-05 Sun] IMPLEMENTED — v1 built and shipped to dotfiles in a no-approvals speedrun (4 commits 1f4f270..78d3cbb): wtimer watch/lap/save; a new timer/ package with a GTK-free PanelModel (62 tests) + the GTK instrument-console panel; bar integration (custom/timer opens the panel, the fuzzel creation flow retired, Hyprland float rule added). This is the base the 2026-07-05 redesign iterates on.
+- [2026-07-05 Sun] DOING — Craig directed the build (no-approvals speedrun). Folded in the cj input from the sibling waybar-timer-module spec (GTK app styled like the panels; a queue/output-wall auto-sorted by fire time; stopwatch lap/stop + saveable runs; notify integration; 5/25-min configurable+deletable defaults; up to 10 timers; widget-gallery elements) — see Build scope below. Bypassed the READY spec-review step at Craig's direction; the four decisions were already resolved.
+- [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (standalone; retire fuzzel once the panel lands; timer chips gain 10m/30m/2h; wtimer watch mode over polling). Decision-complete; ready for a spec-review to flip it READY before build.
+- [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture "give the
+ timer a gtk UI/UX like the network panel. spec this out."
+
+** Prototype iterations
+The redesign ran through the UI/UX prototype process (see the =ui-prototyping= rule proposed to rulesets, 2026-07-05). Full working HTML prototypes over one shared engine, in the dupre instrument-console aesthetic; each iteration links here, newest last, so the design history is walkable.
+- [[file:../prototypes/2026-07-02-timer-panel-prototype-1.html][prototype-1]] — three initial directions over one shared engine: rack unit (faithful vertical list), transport deck (hero + track list), channel-strip board (vertical faders). Predates the formalized five-direction count.
+- [[file:../prototypes/2026-07-02-timer-panel-prototype-2.html][prototype-2]] — chose the rack direction; flipped to hero-on-top → create strip → list; made pomodoro a configurable cycle; locked the shipped presets (default cycle undeletable); dropped the stopwatch/pomodoro value entry.
+- [[file:../prototypes/2026-07-02-timer-panel-prototype-3.html][prototype-3]] — FINAL. Live waybar preview; hero donut moved right with one full-width button row; stopwatch sweep dial + ghost lap badge; alarm recurring days + snooze + ringing state; timer repeat; half-past alarm preset; presets flash on load; header close (Esc / bar-click reopen); verbatim tooltip labels; stopwatch save deferred.
+
+* Metadata
+
+| Field | Value |
+|--------+-----------------------------------------------------------------------------------------|
+| Status | implemented |
+|--------+-----------------------------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+-----------------------------------------------------------------------------------------|
+| Repo | dotfiles |
+|--------+-----------------------------------------------------------------------------------------|
+| Kin | net panel (architecture donor), wtimer (backing), desktop-settings panel spec (sibling) |
+|--------+-----------------------------------------------------------------------------------------|
+
+* Problem
+
+The timer's whole UI is a chain of three fuzzel prompts (type, value, label)
+plus a fourth for cancel. That flow can't show what's already running while
+you create, can't offer one-tap presets, gives no feedback on a typo until
+the add silently fails, and pomodoro state (phase, cycle) is only visible in
+a tooltip. The 2026-07-02 styling pass made the dialogs presentable, but the
+shape is still four blind modals for what is really one small control
+surface.
+
+* Goals
+
+1. One panel, opened from the bar's timer module, that shows everything
+ running (live countdowns, pomodoro phase/cycle, paused state) and creates
+ new items without leaving it.
+2. One-tap presets for the common cases (tea, pomodoro, quick alarm) next to
+ freeform entry, with inline validation before the add.
+3. Per-item controls: pause/resume, cancel, promote to primary (the bar
+ glyph slot).
+4. wtimer stays the single owner of timer state and the notification path;
+ the panel is a view over it, never a second engine.
+
+* Design sketch
+
+** Architecture — clone the net panel's proven stack
+
+- GTK4 + gtk4-layer-shell dropdown anchored under the timer module, Blueprint
+ .blp compiled to committed .ui (=make ui=; compiler is dev-only).
+- Humble-object split: GTK-free PanelModel presenter, unit-tested to 100%,
+ with thin widget bindings; one gated AT-SPI smoke via the
+ run-panel-smoke.sh pattern.
+- Backing: shell out to the existing wtimer CLI (=add=, =toggle=, =cancel=,
+ =cycle=, =render=). =render= already emits a JSON payload. Live state comes
+ from a new wtimer watch/subscribe mode (decision D), which the panel
+ subscribes to for push updates instead of polling =render= on a timer.
+ wtimer's 89-case suite keeps owning the logic; panel tests fake the CLI
+ like every dotfiles suite fakes binaries.
+- Dupre WIP palette CSS shared with the net panel (same factoring the
+ desktop-settings spec calls for — one palette asset, three panels).
+
+** Layout sketch (decided in prototype-3)
+
+Top-to-bottom, one column:
+
+- Header: brand + live item count + Clear All + a flat circular close ✕
+ (tooltip "Close (Esc)"), matching the net/bt/audio panels. Esc closes;
+ clicking the bar's timer module reopens it (mirrors =on-click: timer-panel=).
+- Hero (the primary / bar-slot item): the info block (type badge, any feature
+ badges, pomodoro cycle dots, label, big countdown, subline) on the left with
+ the progress donut on the right, and all its controls in one full-width,
+ left-justified button row beneath. Countdown types show a filling progress
+ ring; a stopwatch shows an analog sweep dial (a gold second-hand, one
+ revolution per minute) with its last lap as a bordered ghost badge beside the
+ count — no fake progress ring for a count-up. The ‹ › keys cycle the primary
+ through the whole queue, wrapping at either end.
+- Create strip: the four type buttons, then a per-type body — presets (renamed
+ from "chips") + a freeform entry validated by wtimer's parsers + an optional
+ label, plus per-type extras (see Build scope). Loading a preset flashes the
+ target fields rather than toasting. Shipped presets are locked (no delete);
+ only presets you add carry a ×.
+- Queue list: the rest of the items (everything but the hero), soonest-fire
+ first, one rack row each — lamp, glyph, label, subline, countdown, and inline
+ pause / promote / cancel (two-stage arm). Stopwatches are promotable to the
+ hero like any other item. With a single item the list reads "Only one item is
+ queued. Add more above." Empty state: hero shows "No timers running", create
+ strip below.
+
+** Waybar module parity
+
+A live preview above the panel renders exactly what =wtimer render= emits for
+the bar: =<large glyph> <countdown>= plus =+N= for the other items, recoloured
+by state (urgent < 60 s terracotta, paused dim, pomodoro-work gold,
+pomodoro-break sage, idle silver), with the full per-item hover tooltip. Tooltip
+lines show each item's label verbatim — no phase word appended. The panel and
+the bar stay one source of truth via the wtimer watch subscription.
+
+** What happens to the fuzzel flow
+
+Decision B (below) resolved this: the fuzzel chain retires once the panel
+lands. The panel becomes the single creation surface, replacing both the
+click-driven bar path and the keybind/fuzzel path. Until the panel ships the
+fuzzel flow stays (it's styled and tested); phase 4 removes it after the
+panel proves out.
+
+* Build scope (decided design — folds the prototype-3 redesign into the shipped =timer/= package)
+
+The panel is the existing =timer/= dotfiles package (src-layout, GTK4 + gtk4-layer-shell, humble-object PanelModel, instrument-console faceplate). wtimer stays the state engine; the panel is a view over it. This rebuild reshapes the layout (see Layout sketch) and adds the per-type functionality below. UI idioms draw from the widget gallery (=docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html=); the reference build is prototype-3.
+
+Queue + primary:
+- Up to 10 items, auto-sorted by soonest fire time (four buckets: active countdown < paused countdown < active stopwatch < paused stopwatch). The soonest-firing is the hero/primary (the bar glyph slot). Promote via a row's promote key or by cycling ‹ ›; cycling and promotion include stopwatches and wrap around the whole list.
+- The hero shows the primary big; the list shows the rest. Clear All cancels everything.
+
+Types + create strip:
+- *Timer*: preset durations 5m / 25m / 10m / 15m / 30m / 60m / 2h (5m and 25m first), freeform entry (wtimer parser), optional label, and a *repeat* toggle — a repeating timer re-arms itself on fire instead of clearing.
+- *Alarm*: presets +30m / top-of-hour / *half-past* (next X:30) / 07:00, freeform HH:MM, optional label, a *recurring weekday* selector (S M T W T F S, with weekdays / daily shortcuts) and a *snooze* duration. An alarm fires into a *ringing* state rather than silently vanishing: the hero/row shows SNOOZE (re-arm by the snooze minutes) and DISMISS (a recurring alarm re-arms to its next matching day; a one-shot clears).
+- *Stopwatch*: no value entry — counts up from zero. Lap (unlimited) and Stop. The hero shows an analog sweep dial and the last lap as a ghost badge beside the count. *Run-save is deferred to a vNext* (cut from v1's org-save plan — see the status history).
+- *Pomodoro*: a configurable cycle — Work and Rest each with a short and a long duration, a "long break every N cycles" interval, and an auto-advance toggle. Every Nth ("deep") cycle uses the long work + long rest together. Cycle progress shows as dots in the hero and row. With auto-advance off, each phase end waits for a Start press. Preset cycles (Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10) load the fields.
+- *Presets*: shipped defaults are locked (undeletable — the pomodoro default cycle can't be removed); presets you add carry a × and are deletable. Loading any preset flashes the target fields (no toast).
+
+Live updates + notifications:
+- A =wtimer watch= subcommand emits state on every change (state-file watch → JSON lines on stdout); the panel subscribes for push updates instead of polling (decision D). Notifications for alarms and timers go through the =notify= script — wtimer stays the single notification owner.
+
+Bar + window:
+- =custom/timer= left-click opens the panel; =wtimer render= stays the bar indicator (glyph + countdown + =+N=, state-coloured, verbatim tooltip labels). A header close ✕ and Esc close the panel; clicking the bar module reopens it. The =wtimer new= fuzzel creation flow is retired (decision B).
+
+* Decisions (Craig)
+
+** DONE Panel scope: standalone timer panel, or a page in the desktop-settings panel?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): standalone, sharing the palette/css asset. Matches the net panel's one-domain-one-panel shape and keeps the timer dropdown small.
+
+** DONE Fuzzel flow: keep as keyboard fast lane, or retire once the panel lands?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): retire the fuzzel flow once the panel lands. The panel becomes the single creation surface; the keybind chain goes away rather than staying as a parallel path. (Implementation phase 4's "decide the fuzzel flow's future" is now decided — retire, don't keep.)
+
+** DONE Presets: which chips per type?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): timer chips are 5m / 10m / 15m / 25m / 30m / 60m / 2h (the strawman plus 10m, 30m, 2h). Alarm +30m / top-of-hour / 07:00, pomodoro default cycle only, stopwatch none — as the strawman.
+
+** DONE Live updates: poll render (1s, like the bar) or a wtimer "watch" mode?
+CLOSED: [2026-07-04 Sat]
+Resolved (Craig, 2026-07-04): a wtimer watch/subscribe mode, not 1s polling. This grows wtimer with a new watch capability that the panel (and potentially the bar) subscribes to for live state, rather than reusing the poll cadence — cleaner at the cost of a wtimer addition. Fold the watch mode into the phase 1 CLI-backing seam.
+
+The decisions below were resolved live through the prototype iteration (2026-07-05), each seen working in a prototype before being written down.
+
+** DONE Layout: hero on top, then create strip, then the queue list
+CLOSED: [2026-07-05 Sun]
+Resolved: the primary item rides a hero at the top (info left, donut right, all controls in one full-width button row), the create strip sits under it, the rest of the queue below. Chosen over the transport-deck and channel-strip directions in prototype-1.
+
+** DONE Stopwatch hero visual: analog sweep dial, not a progress ring
+CLOSED: [2026-07-05 Sun]
+Resolved: a count-up stopwatch shows a gold second-hand sweeping once per minute, with its last lap as a bordered ghost badge beside the count — not a fake progress ring (a stopwatch has no target to be a fraction of).
+
+** DONE Alarm: recurring weekdays + snooze + a ringing state; add a half-past preset
+CLOSED: [2026-07-05 Sun]
+Resolved: alarms carry a weekday-repeat selector and a snooze duration, and fire into a ringing state with SNOOZE / DISMISS rather than vanishing. A half-past preset joins top-of-hour (fires at the next X:30). Drawn from Alarm Clock Xtreme / Alarmy.
+
+** DONE Timer: auto-repeat toggle
+CLOSED: [2026-07-05 Sun]
+Resolved: a timer can repeat — it re-arms itself on fire instead of clearing. Drawn from MultiTimer / Multi Timer.
+
+** DONE Pomodoro: a fully configurable cycle
+CLOSED: [2026-07-05 Sun]
+Resolved: Work and Rest each get a short and a long duration, plus a long-break-every-N interval and an auto-advance toggle; every Nth deep cycle uses the long work + long rest; progress shows as cycle dots. The default cycle preset is locked (undeletable). Drawn from Pomofocus / the classic technique.
+
+** DONE Presets (formerly "chips"): lock defaults, flash on load
+CLOSED: [2026-07-05 Sun]
+Resolved: rename "chips" to "presets"; shipped defaults are locked (no delete), presets you add are deletable; loading a preset flashes the target fields instead of firing a toast.
+
+** DONE Stopwatch run-save: deferred to a vNext
+CLOSED: [2026-07-05 Sun]
+Resolved: v1's "save the run's splits to an org file on stop" is cut from this build. Stop just stops. Revisit in a vNext if the need is real.
+
+* Implementation phases (redesign rebuild)
+
+Folding prototype-3 into the shipped =timer/= package. TDD throughout — GTK-free
+logic first, GUI last — reviewing between phases. Each phase is a dotfiles commit
+under the archsetup-owns-dotfiles rule.
+
+1. wtimer engine: alarm recurring-days + snooze + a ringing state, timer repeat,
+ the configurable pomodoro cycle (work/rest short+long, long-break interval,
+ auto-advance, deep cycle), half-past parsing, and the watch/subscribe mode
+ (decision D). Extend wtimer's own suite per addition.
+2. PanelModel: the four-bucket soonest-fire sort, promote/cycle wrap (stopwatches
+ included), per-type create validation + presets (locked defaults, custom
+ delete, flash-on-load), and the row/hero view data (sweep fraction, cycle
+ dots, last lap, feature badges, ringing controls). GTK-free, unit-tested like
+ the net PanelModel.
+3. GTK GUI: the hero (progress ring / sweep dial, one full-width button row, lap
+ badge), the per-type create strip (timer repeat toggle; alarm weekday selector
+ + snooze; pomodoro config grid; presets that flash), the header close ✕,
+ Esc-to-close, and bar-click reopen.
+4. Bar parity: =wtimer render= tooltip labels verbatim, state classes confirmed;
+ panel and bar track one state via the watch subscription.
+5. AT-SPI smoke + a manual-testing checklist (todo.org). Retire the =wtimer new=
+ fuzzel flow (decision B) after the panel proves out.
+
+Deferred to a vNext: stopwatch run-save (an org log of splits).
diff --git a/docs/specs/2026-07-03-audio-panel-spec.org b/docs/specs/2026-07-03-audio-panel-spec.org
new file mode 100644
index 0000000..5b678a8
--- /dev/null
+++ b/docs/specs/2026-07-03-audio-panel-spec.org
@@ -0,0 +1,166 @@
+#+TITLE: Audio Panel — the pulsemixer console
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-03
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* IMPLEMENTED Audio Panel — the pulsemixer console
+:PROPERTIES:
+:ID: 9175e017-46ad-4887-ae45-887e9551c005
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Shipped; build task DONE, manual tests filed.)
+
+* IMPLEMENTED Status
+:PROPERTIES:
+:ID: 71f556c6-ee02-47cc-a3be-68c8289380f3
+:END:
+- [2026-07-03 Fri] IMPLEMENTED — built in a no-approvals speedrun in the
+ dotfiles repo (branch panel-bugfixing): engine (pactl), presenter, GTK
+ panel, PTT arming, bar indicator, and the bar/keybind wiring, across four
+ commits 65e5bb0..9601420. 102 unit tests + a passing AT-SPI smoke on velox.
+ All five Decisions below resolved. Live-eyeball validation (visual polish,
+ PTT-in-a-meeting, fader feel, the master-mute hardware key) is the one open
+ follow-up, tracked as a manual-testing task in todo.org.
+- [2026-07-03 Fri] DRAFT — stub from the todo.org task "Audio panel spec"
+ (roam ask 2026-07-02) plus the 2026-07-03 waybar/sound design discussion.
+ Written to iterate alongside the prototype
+ (=docs/prototypes/2026-07-03-sound-panel-prototype.html=). Spine is present; the
+ Decisions and Design detail get filled in as we go.
+
+* Metadata
+
+| Field | Value |
+|--------+---------------------------------------------------|
+| Status | implemented |
+|--------+---------------------------------------------------|
+| Owner | Craig Jennings |
+|--------+---------------------------------------------------|
+| Repo | dotfiles |
+|--------+---------------------------------------------------|
+| Kin | net panel + bt panel (architecture + aesthetic |
+| | donors), desktop-settings panel (sibling) |
+|--------+---------------------------------------------------|
+
+* Problem
+
+Audio control today is the pyprland audio scratchpad (Super+A) — a floating
+pulsemixer TUI — plus scattered bar affordances: =pulseaudio= (volume, click
+to mute sink), =pulseaudio#mic= (mic glyph + mic-toggle), Super+M audio-cycle
+ring, Super+Shift+A mic-toggle. There's no single glanceable surface that
+shows every sink and source, lets you set the default output/input, and
+carries the meeting-grade mic controls Craig wants (a clean muted mode and a
+hold-to-talk mode). The net + bluetooth panels set the pattern for exactly
+this shape; audio is the third instrument in the family.
+
+* Goals
+
+1. One panel, opened from the bar's sound glyph, exposing the full pulsemixer
+ surface: every sink and source, per-device volume, per-device mute, and
+ switching the default output and input.
+2. Replace the pyprland audio scratchpad (Super+A) as the primary audio UI.
+3. Mic modes for meetings: *live*, *muted*, and *push-to-talk* (mic stays
+ muted except while Space is held, releasing re-mutes).
+4. A *master quick-mute* — one action mutes all output — reachable from the
+ faceplate and a keybind.
+5. Instrument-console aesthetic and architecture consistent with net + bt:
+ same faceplate, lamps, engraved sections, console keys, needle gauges,
+ verify-everything contract.
+6. The bar glyph reflects live state: speaker + three arcs normally, a
+ speaker-with-✕ when muted (Craig's called glyphs).
+
+* Design sketch
+
+Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html= (the reference for
+layout + idioms below).
+
+** Surface (from the prototype)
+
+- *Faceplate* — status lamp, sound glyph, state word (PLAYBACK / MUTED), a
+ MUTED badge, the SND·01 unit label, and the *master quick-mute switch*
+ (same switch idiom as net wifi / bt power), plus the close ✕.
+- *OUTPUTS section* — one row per sink. Row body click = set default (gold
+ DEF tag). A machined fader sets that sink's volume; the trailing glyph
+ mutes just that device. Active/default row is emphasized (cream name, gold
+ lamp/glyph).
+- *INPUTS section* — one row per source, same idioms.
+- *Mic mode* — three console keys: LIVE / MUTED / PUSH·TALK. Push-to-talk
+ keeps the mic muted (red IN needle) and un-mutes only while Space is held.
+- *Twin VU needles* — output level + input level, the sound analog of net
+ throughput and bt battery gauges. Needle goes red when its side is muted.
+
+** Architecture — clone the net/bt panel stack
+
+- GTK4 + gtk4-layer-shell, Blueprint =.blp= → committed =.ui= (=make ui=,
+ dev-only build dep).
+- Humble-object split: a GTK-free PanelModel presenter (unit-tested like the
+ net/bt PanelModels) + thin composite-widget pages. Backing actions in a
+ GTK-free =audio.py= that shells to the audio control layer (pactl /
+ wpctl / pulsemixer — pick below), TDD'd with fake binaries.
+- One gated AT-SPI smoke (=run-panel-smoke.sh= pattern).
+- Shared instrument-console palette CSS asset (the one net/bt/settings all
+ load) — do not duplicate the palette block.
+- Code lives in dotfiles =audio/= sibling to =net/= (src-layout, tests in
+ =tests/audio/=).
+
+* Decisions (Craig)
+
+** DONE Audio control backend — pactl vs wpctl vs pulsemixer
+CLOSED: [2026-07-03 Fri]
+Resolved: =pactl= (the engine module is =pactl.py=). Both ratio and velox run
+PipeWire with the pipewire-pulse compat layer and no PulseAudio daemon, so
+pactl and wpctl hit the same graph — but =pactl -f json= gives structured,
+name-addressable output where wpctl offers only a volatile-id tree. Reads go
+through =pactl -f json list sinks|sources= + =get-default-*=; writes target
+devices by stable name behind an argv-charset guard.
+
+** DONE Push-to-talk mechanism under Wayland (feasibility — phase 1)
+CLOSED: [2026-07-03 Fri]
+Resolved: route (a), Hyprland dynamic binds. The phase-1 spike confirmed all
+three primitives on velox (Hyprland 0.55.4): =hyprctl keyword bind/unbind=
+adds and removes a bind live, =bindr= fires on release, and =pactl
+set-source-mute @DEFAULT_SOURCE@ 0|1= toggles the mic cleanly. =ptt.py= arms a
+press bind (un-mute) + a bindr (re-mute) on entering PTT mode and unbinds on
+leaving, so the talk key isn't grabbed globally otherwise. No evdev needed.
+Documented behavior: while PTT is armed, the talk key is the talk key.
+
+** DONE Quick-mute keybind + scope
+CLOSED: [2026-07-03 Fri]
+Resolved: the XF86AudioMute hardware key (Super+Shift+M turned out to be taken
+by the monocle-layout bind, so the spec's assumption was wrong). The mute key
+now runs =audio quick-mute=, which mutes every output (master), not just the
+default sink — identical on a single-sink machine, correct on a multi-sink
+one. Also reachable from the faceplate master switch and the panel. Scope:
+master mute of all sinks, with verify-after-apply per sink.
+
+** DONE Bar glyph click map
+CLOSED: [2026-07-03 Fri]
+Resolved with the low-regret wiring: kept the existing =pulseaudio= waybar
+module (left-click mute, scroll volume — no regression) and repointed its
+right-click from the retired pulsemixer scratchpad to =audio-panel=. So: left
+= mute, right = open panel, scroll = volume. A fuller =custom/audio= indicator
+(state-following speaker glyph + its own click map) is built and tested
+(=indicator.py= + =waybar-audio=) but stays unwired until the new bar glyph
+gets a live eyeball — the swap is a one-line waybar edit when Craig's ready.
+
+** DONE Fate of the existing audio affordances
+CLOSED: [2026-07-03 Fri]
+Resolved: Super+A repurposed from =pypr toggle audio= (the pulsemixer
+scratchpad) to =audio-panel= — the panel is the primary audio UI now, so the
+scratchpad is retired. Its definition still sits in the machine-local
+=pyprland.toml= (not stowed) and can be deleted by hand. Kept: =pulseaudio= +
+=pulseaudio#mic= waybar modules (glance + scroll + the mic-mute glance),
+Super+M cycle, Super+Shift+A + XF86AudioMicMute mic-toggle. Changed:
+XF86AudioMute → master quick-mute (see the quick-mute decision above).
+
+* Implementation phases
+
+1. Push-to-talk feasibility spike (decision above) — the one unknown; settle
+ the mechanism before committing the mic-mode design.
+2. =audio.py= backings (list/get/set/mute/default for sinks + sources) —
+ pure engine, TDD with a fake audio backend.
+3. PanelModel presenter (rows, default tracking, mic modes, master mute,
+ verify-after-apply) — unit-tested, no GTK.
+4. Blueprint UI + sound bar glyph (normal / muted / ptt states) + open/close
+ wiring; shared palette css; AT-SPI smoke.
+5. Bar-affordance consolidation per the decision above; retire the Super+A
+ scratchpad; keybinds.
diff --git a/docs/design/2026-07-03-instrument-console-panels-spec.org b/docs/specs/2026-07-03-instrument-console-panels-spec.org
index 315e0b4..2c80aa9 100644
--- a/docs/design/2026-07-03-instrument-console-panels-spec.org
+++ b/docs/specs/2026-07-03-instrument-console-panels-spec.org
@@ -3,10 +3,26 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+* IMPLEMENTED Instrument-console rebuild — net + bluetooth panels
+:PROPERTIES:
+:ID: ac23e996-a51a-466b-ad80-2faff46447bf
+:END:
+- 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to IMPLEMENTED (reason: Panel rebuild shipped (dotfiles e993c3f); build task DONE.)
+
* IMPLEMENTED Status
:PROPERTIES:
:ID: e73877f5-4f5b-4f81-b946-dbaa6145e0d5
:END:
+- 2026-07-03 Fri @ 17:35 -0400 :: Post-impl increment (stays IMPLEMENTED): added
+ a manual rescan/scan ⟳ affordance to both panels — net NETWORKS header
+ (drives manage.rescan) and bt NEARBY header (drives on_scan / pair-mode
+ discovery), with the approved "all" busy style (Gtk.Spinner throbber + a
+ GLib breathe on "scanning…" + a one-shot list fade; no CSS keyframes exist in
+ this GTK setup). Prototype: archsetup docs/prototypes/2026-07-03-net-panel-rescan-prototype.html. Code shipped
+ UNCOMMITTED into the dotfiles repo from an archsetup session (cross-project);
+ handoff at ~/.dotfiles/inbox/2026-07-03-1733-from-archsetup-rescan-handoff.org.
+ Verified: net 584 + bt 223 unit OK, both AT-SPI smokes green (⟳ present);
+ live busy-animation feel pending Craig's eyeball.
- 2026-07-03 Fri @ 06:49 -0400 :: DOING → IMPLEMENTED: all six phases shipped
(net GTK-free layer 81ec9c3, net view 800ef60; bt GTK-free layer 5318b34, bt
view 66f03d9; phase-6 dead-code removal f4e688e). Both panels are single-screen
@@ -30,7 +46,7 @@
|---------------------+------------------------------------------------------------|
| Repos | dotfiles (net/, bluetooth/, themes), archsetup |
|---------------------+------------------------------------------------------------|
-| Normative reference | [[file:../../assets/2026-07-03-instrument-console-panels-prototype.html][assets/2026-07-03-instrument-console-panels-prototype.html]] |
+| Normative reference | [[file:../prototypes/2026-07-03-instrument-console-panels-prototype.html][docs/prototypes/2026-07-03-instrument-console-panels-prototype.html]] |
|---------------------+------------------------------------------------------------|
* Summary