aboutsummaryrefslogtreecommitdiff
path: root/assets
diff options
context:
space:
mode:
Diffstat (limited to 'assets')
-rw-r--r--assets/2026-06-18-collapsible-waybar-sides-spike-findings.org42
-rw-r--r--assets/2026-06-19-collapsible-waybar-sides-spec.org120
-rw-r--r--assets/2026-07-03-instrument-console-panels-build-summary.org107
-rw-r--r--assets/2026-07-03-instrument-console-panels-prototype.html1359
-rwxr-xr-xassets/color-themes/generate-palette.sh1
-rwxr-xr-xassets/easyeffects-eq-presets.sh1
-rw-r--r--assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org5
-rw-r--r--assets/outbox/2026-06-24-lint-followups-resolved.org6
-rw-r--r--assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org5
-rw-r--r--assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org5
-rw-r--r--assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org5
-rw-r--r--assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org5
-rw-r--r--assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org15
-rw-r--r--assets/outbox/2026-06-28-lint-followups-task-review-health.org2
-rw-r--r--assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt5
-rw-r--r--assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt12
-rw-r--r--assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org5
-rw-r--r--assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org5
-rw-r--r--assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org5
-rw-r--r--assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org5
-rw-r--r--assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org4
21 files changed, 1719 insertions, 0 deletions
diff --git a/assets/2026-06-18-collapsible-waybar-sides-spike-findings.org b/assets/2026-06-18-collapsible-waybar-sides-spike-findings.org
new file mode 100644
index 0000000..4d45ed1
--- /dev/null
+++ b/assets/2026-06-18-collapsible-waybar-sides-spike-findings.org
@@ -0,0 +1,42 @@
+#+TITLE: Collapsible waybar — spike findings (mechanism)
+#+DATE: 2026-06-18
+
+* Question
+Which mechanism actually collapses a waybar side to a base set, given the right
+side is a mix of native modules (group/sysmonitor, pulseaudio, pulseaudio#mic,
+idle_inhibitor, tray) and custom exec modules?
+
+* Method
+Two transient waybar instances against /tmp copies of the live config, captured
+with grim (live bar briefly down, restored after). Variants in this dir:
+- spike-style-csshide.css : option (c) — CSS-hide the native modules
+ (min-width:0; padding:0; margin:0; opacity:0) on #sysmonitor #pulseaudio
+ #idle_inhibitor.
+- spike-config-collapsed.json : option (b) — modules-right rewritten to the base
+ set [tray, custom/date, custom/worldclock].
+
+* Result
+- *CSS-hide (option c): FAILS.* sysmonitor and pulseaudio rendered invisible but
+ held their space — a gap remained where they were, no reflow. GTK3 has no
+ =display:none=, and opacity/zero-size leaves the label's intrinsic width. The
+ right side ends up ragged and half-collapsed, not narrowed. Not viable for the
+ native modules.
+- *Config-swap (option b): WORKS.* The collapsed config reflowed the right side
+ tight to tray + date, everything else fully gone, no gaps. Hides native and
+ custom modules alike. Tray icons survived the swap.
+
+* Decision
+Mechanism is config-swap + =killall -SIGUSR2 waybar= (reload), NOT the state-file
++ CSS approach the original task leaned toward. The original "heavy" label on
+option (b) is the cost of a full reload (brief flicker, module state resets, tray
+re-registers) — acceptable, and the only approach that actually collapses a mixed
+module set.
+
+* Implications for the spec
+- Don't maintain two static configs (drift-prone). A toggle script rewrites the
+ active config's modules-left / modules-right between the full set and the base
+ set, then SIGUSR2. Base sets defined once; collapsed set is the base set, full
+ set is restored from the canonical module list.
+- Per-side state in $XDG_RUNTIME_DIR; the arrow module reads it to pick its
+ direction. Arrow lives IN the base set (always visible, it's the expand control).
+- Reload cost is per-toggle, not idle — fine for a click action.
diff --git a/assets/2026-06-19-collapsible-waybar-sides-spec.org b/assets/2026-06-19-collapsible-waybar-sides-spec.org
new file mode 100644
index 0000000..b9ddc0d
--- /dev/null
+++ b/assets/2026-06-19-collapsible-waybar-sides-spec.org
@@ -0,0 +1,120 @@
+#+TITLE: Collapsible waybar sides — implementation spec
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-19
+
+* Goal
+Let each side of the waybar collapse to a minimal base set with a click, and
+expand again with another click. Each side carries a small arrowhead that points
+toward the screen edge when expanded (click to collapse outward) and flips to
+point toward center when collapsed (click to expand). Left and right collapse
+independently.
+
+This is dotfiles work (=~/.dotfiles=, =hyprland/= tier). Tracked by the
+=Collapsible waybar sides= task in archsetup =todo.org=.
+
+* Decisions (locked 2026-06-19)
+- *Mechanism*: config-swap + =killall -SIGUSR2 waybar=. NOT state-file + CSS —
+ the spike proved CSS can't collapse native modules (they go invisible but hold
+ their space; GTK3 has no =display:none=). See [[file:spike-findings.org]].
+- *Right base set*: =custom/date=, =custom/worldclock=, =tray= (plus the right
+ arrow). Tray reflows cleanly and survives the reload (spike-confirmed).
+- *Left base set*: =custom/menu=, =hyprland/workspaces= (plus the left arrow).
+- *Per-side*: left and right toggle independently, each with its own arrow.
+- *Per-host*: host-agnostic. The base set is constant; the full set is whatever
+ each host's config already defines. ratio (no battery/touchpad/airplane) needs
+ no special-casing — collapse hides whatever modules that host has. Build once.
+
+* Architecture
+
+** The active-config indirection (the core piece)
+=~/.config/waybar/config= is a stow symlink into the dotfiles, so the toggle
+can't rewrite it in place (that would edit the repo). Instead:
+
+1. *Canonical* config: the committed dotfiles config. Always holds the FULL
+ module arrays. Read-only source of truth. Unchanged by this feature except
+ for adding the two arrow modules and their definitions.
+2. *Active* config: a generated copy at =$XDG_RUNTIME_DIR/waybar/config=. This is
+ what waybar loads (=waybar -c=). The toggle rewrites its =modules-left= /
+ =modules-right= between full and base.
+3. *Launch change* (hyprland.conf exec-once): before launching waybar, generate
+ the active config from the canonical (initial state = expanded/full), then
+ =waybar -c "$XDG_RUNTIME_DIR/waybar/config" -s <style>=.
+
+The style.css stays shared (canonical, stowed) — only the config (module arrays)
+needs the runtime copy.
+
+** Toggle scripts
+=waybar-collapse <side>= where side ∈ {left, right}:
+1. Read per-side state from =$XDG_RUNTIME_DIR/waybar/<side>.state= (expanded |
+ collapsed; absent = expanded).
+2. Flip it.
+3. Regenerate the active config's =modules-<side>= array:
+ - expanded → the canonical full array for that side.
+ - collapsed → the base set for that side (constant in the script) with the
+ arrow module included.
+4. Write the new state file.
+5. =killall -SIGUSR2 waybar=.
+
+The full array is read from the canonical config each time, so the script never
+loses it and stays correct as modules are added/removed upstream. The base set is
+the only constant the script hardcodes (or reads from a tiny sidecar).
+
+** Arrow modules
+Two custom modules, =custom/arrow-left= and =custom/arrow-right=, each an exec
+script (=waybar-arrow left= / =waybar-arrow right=) that:
+- Reads the side's state file.
+- Emits the glyph: expanded → points outward (left side ◀ toward left edge,
+ right side ▶ toward right edge); collapsed → points inward (left ▶, right ◀).
+- =on-click= runs =waybar-collapse <side>=.
+
+The arrow is always in the base set (it's the expand control), so it's present in
+both states. Place the left arrow as the LAST module in =modules-left= (innermost,
+nearest center) and the right arrow as the FIRST module in =modules-right=
+(innermost), so each arrow sits at the inner edge of its side and the collapse
+pulls outward away from it. (Confirm placement during implementation — the glyph
+direction and module order must agree so the arrow visually points the right way.)
+
+** State + reload
+- State dir: =$XDG_RUNTIME_DIR/waybar/= (per-boot, ephemeral — collapse state
+ resets on logout, which is fine).
+- Reload is =SIGUSR2= (full waybar reload). Cost: brief flicker, module state
+ resets, tray re-registers. Acceptable for a click action; spike confirmed tray
+ survives. This cost is per-toggle, never idle.
+
+* Files (all in =~/.dotfiles/hyprland/=)
+- =.local/bin/waybar-collapse= — the toggle (reads canonical, writes active,
+ signals). New.
+- =.local/bin/waybar-arrow= — the arrow module exec (state → glyph + class). New.
+- =.local/bin/waybar-active-config= — generates the active config from canonical
+ at login (used by exec-once and reused by waybar-collapse to resolve the full
+ arrays). New. (Or fold generation into waybar-collapse + a one-shot init call.)
+- =.config/waybar/config= — add =custom/arrow-left= / =custom/arrow-right= module
+ defs + place them in the arrays. Edit.
+- =.config/hypr/hyprland.conf= — exec-once: generate active config, then
+ =waybar -c "$XDG_RUNTIME_DIR/waybar/config"=. Edit.
+- Optional keybinds: =$mod+[= / =$mod+]= to collapse left/right without the mouse.
+
+* TDD plan (per the dotfiles suite)
+- =tests/waybar-collapse/=: full↔base array rewrite against a fixture canonical
+ config; expanded→collapsed→expanded round-trips to the original arrays; state
+ file flips; base set always includes the arrow; SIGUSR2 sent (fake killall).
+ Use a fake canonical config + temp =$XDG_RUNTIME_DIR=, fake killall on PATH
+ (same harness style as tests/waybar-toggle).
+- =tests/waybar-arrow/=: state → correct glyph + class for each side and state;
+ missing state file = expanded glyph (fail-safe).
+- JSON validity of the generated active config (parse it back).
+
+* Open / to-confirm during implementation
+- Exact arrow glyphs (nerd-font triangles) and that order-vs-direction agree.
+- Whether to keep =hyprland/window= (the title) out of the left base set — it's
+ long and variable-width; collapsing the left should drop it (it's not in the
+ base set, so it hides — correct).
+- Animation: none (waybar doesn't animate width; the collapse snaps). Accepted.
+
+* Risks
+- Reload flicker on every toggle. Mitigation: none needed unless it annoys in use.
+- If a future module is added to the canonical config, it lands in the full set
+ automatically (good) but the author should decide if it belongs in a base set.
+- $XDG_RUNTIME_DIR active config must exist before waybar starts; the exec-once
+ ordering must generate it first. waybar-toggle (the crash-relaunch path, mod+B)
+ must also point at the active config, not the canonical — update it to match.
diff --git a/assets/2026-07-03-instrument-console-panels-build-summary.org b/assets/2026-07-03-instrument-console-panels-build-summary.org
new file mode 100644
index 0000000..a7a3768
--- /dev/null
+++ b/assets/2026-07-03-instrument-console-panels-build-summary.org
@@ -0,0 +1,107 @@
+#+TITLE: Instrument-Console Panel Rebuild — Build Summary
+#+DATE: 2026-07-03
+#+AUTHOR: Craig Jennings & Claude
+
+Findings summary for the no-approvals speedrun that rebuilt the net and
+bluetooth waybar panels as single-screen instrument consoles. Spec:
+[[file:../docs/design/2026-07-03-instrument-console-panels-spec.org][2026-07-03-instrument-console-panels-spec.org]] (ID e73877f5, IMPLEMENTED).
+Normative design: [[file:2026-07-03-instrument-console-panels-prototype.html][2026-07-03-instrument-console-panels-prototype.html]]
+(Craig approved through five prototype iterations).
+
+* What shipped
+
+Both panels went from a tabbed Blueprint UI (Connections / Diagnostics /
+Performance style tabs) to one always-visible instrument console: a faceplate
+with a state lamp and word, engraved section labels, scrolled lamp rows for
+the live entities, a row of console keys, cairo dial meters, and an output
+well that streams diagnostics in place. No terminals, no tabs.
+
+Six phases, each committed and pushed on landing:
+
+| Phase | Commit (dotfiles) | What landed |
+|-------+----------------------+-------------------------------------------------|
+| 1 | (spec + task wiring) | Spec authored, parent task wired with :SPEC_ID: |
+|-------+----------------------+-------------------------------------------------|
+| 2 | 81ec9c3 | Net GTK-free presenter layer + engine verbs |
+| | | (52 new tests) |
+|-------+----------------------+-------------------------------------------------|
+| 3+4 | 800ef60 | Net view rebuilt as the console + all |
+| | | interactions wired |
+|-------+----------------------+-------------------------------------------------|
+| 5a | 5318b34 | Bt GTK-free layer + engine gaps (47 new tests) |
+|-------+----------------------+-------------------------------------------------|
+| 5b | 66f03d9 | Bt view rebuilt as the console + all |
+| | | interactions wired |
+|-------+----------------------+-------------------------------------------------|
+| 6 | f4e688e | Dead-code removal, build close-out |
+|-------+----------------------+-------------------------------------------------|
+
+* Engine gaps closed
+
+The console needed capabilities the tabbed panels never had:
+
+- Net: =manage.wifi_radio= (nmcli radio wifi on/off), =manage.device_up=
+ (ethernet takes the route), =sysio.link_speed_mbps= (/sys wired speed),
+ =connections.ethernet_devices=, and a hidden-SSID flag on =manage.add=.
+- Bt: =btctl.set_alias= renames a device through the bluez D-Bus Alias via
+ busctl (there is no MAC-addressed one-shot for set-alias, so =device_path=
+ discovers the controller node from the object tree), =manage.rename= wraps
+ it with a verify-after read, =parse_info= reads the Alias as the display
+ name, and =doctor= grew =on_report= / =on_begin= callbacks so its checks and
+ repairs stream into the output well.
+
+* Tests added
+
+- Phase 2: 52 new net presenter tests (581 net total at the time).
+- Phase 5a: 47 new bt console tests.
+- Both panels' AT-SPI smokes rewritten to drive the single-screen console and
+ anchor on stable engraved labels + the panel-unique console key (net DOCTOR,
+ bt SCAN) rather than the flaky count labels.
+- Full suite through phase 6: 46 suites, zero failures.
+
+* Live-verify results (velox, 2026-07-03)
+
+- =make test=: 46 suites green, zero FAILED/ERROR.
+- =make test-panel= (net): green end to end — faceplate NET·01 / ONLINE, one
+ Close, DOCTOR + SPEED TEST keys, engraved CHANNEL/CONSOLE/NETWORKS/TUNNELS,
+ live route line, tunnel rows (tailscale + 7 WireGuard NM), DOCTOR streamed
+ real diagnose steps, output-well dismiss, panel closed on Close.
+- =make test-panel-bt= (bt): green end to end — faceplate BT·01 / POWERED, one
+ Close, adapter-power switch, DOCTOR + SCAN keys, engraved
+ ADAPTER/CONSOLE/NEARBY/PAIRED, discoverable chip, battery gauge slots, DOCTOR
+ streamed real checks, output-well dismiss, panel closed on Close.
+- Both =gui.py= files are byte-identical to their screenshot-verified commits
+ (net 800ef60, bt 66f03d9), so the phase-3/4/5 screenshots (render matching
+ the prototype) still stand — phase 6 touched no view code.
+
+* Phase-6 dead code removed (dotfiles f4e688e)
+
+The console builds its widget tree in Python, so the old Gtk.Template page
+classes and Blueprint sources had no caller:
+
+- =net/src/net/pages.py=, =bluetooth/src/bt/pages.py=
+- both panels' =ui/= dirs (=window_content=, =diagnostics_page=,
+ =connections_page= / =devices_page= — the =.blp= sources and compiled =.ui=)
+- the =make ui= Blueprint-compile target and its =.PHONY= entry (no =.blp=
+ files remain to compile)
+- a stale =gui.py / pages.py= mention in the bt =viewmodel.py= docstring
+
+Confirmed nothing imported the removed modules before deleting; 46 suites and
+both smokes stayed green after.
+
+* Folded tasks closed with this build
+
+- Network panel redesign — no terminals, verify-everything, full failure
+ coverage (its failure-mode catalog stays as the standing diagnose/repair
+ completeness reference).
+- Bluetooth panel: switch placement + panel title.
+- Bluetooth panel: rename devices.
+
+* Deferrals
+
+Interactions that mutate Craig's real bluetooth state can't be auto-driven —
+they need a human at the keyboard with real devices. Filed as a manual-test
+checklist under "Manual testing and validation" in todo.org: pair-passkey
+flow, rename a real device, connect/disconnect, forget, discoverable toggle,
+power toggle, and the LOW BATT badge with a real sub-15% device. Net's
+in-panel speedtest and timer-dialog manual tests were already pending there.
diff --git a/assets/2026-07-03-instrument-console-panels-prototype.html b/assets/2026-07-03-instrument-console-panels-prototype.html
new file mode 100644
index 0000000..0258f20
--- /dev/null
+++ b/assets/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/assets/color-themes/generate-palette.sh b/assets/color-themes/generate-palette.sh
index 456d1a4..5a11264 100755
--- a/assets/color-themes/generate-palette.sh
+++ b/assets/color-themes/generate-palette.sh
@@ -1,4 +1,5 @@
#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
# Generate dupre-palette.png from color definitions using ImageMagick.
# Output: assets/color-themes/dupre/dupre-palette.png
diff --git a/assets/easyeffects-eq-presets.sh b/assets/easyeffects-eq-presets.sh
index 40e9cd9..9a2ecef 100755
--- a/assets/easyeffects-eq-presets.sh
+++ b/assets/easyeffects-eq-presets.sh
@@ -1,4 +1,5 @@
#!/usr/bin/env bash
+# SPDX-License-Identifier: GPL-3.0-or-later
# Install EasyEffects parametric EQ presets (Harman target)
#
# Presets:
diff --git a/assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org b/assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org
new file mode 100644
index 0000000..32d8940
--- /dev/null
+++ b/assets/outbox/2026-06-24-2314-from-.emacs.d-delivered-side-pointed-dirvish-bg-cj.org
@@ -0,0 +1,5 @@
+#+TITLE: Delivered side: pointed dirvish 'bg' (cj/set-wallpaper in mo
+#+SOURCE: from .emacs.d
+#+DATE: 2026-06-24 23:14:07 -0400
+
+Delivered side: pointed dirvish 'bg' (cj/set-wallpaper in modules/dirvish-config.el) at the set-wallpaper script for the Wayland branch, replacing the dead swww call. Test updated + green, live-reloaded into the daemon, set-wallpaper confirmed on PATH (your dotfiles 8be2484 symlink). The wallpaper dependency is closed — you can drop the :blocker:. The separate 'dirvish doesn't preview images' item stays open on my side.
diff --git a/assets/outbox/2026-06-24-lint-followups-resolved.org b/assets/outbox/2026-06-24-lint-followups-resolved.org
new file mode 100644
index 0000000..5f3b06f
--- /dev/null
+++ b/assets/outbox/2026-06-24-lint-followups-resolved.org
@@ -0,0 +1,6 @@
+* 2026-06-24 Wed — Task-review health: 34 top-level [#A]/[#B]/[#C] tasks unreviewed for >30 days (daily review may have slipped)
+
+* lint-org follow-ups — todo.org (2026-06-24)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 138)
+
+* 2026-06-24 Wed — Task-review health: 27 top-level [#A]/[#B]/[#C] tasks unreviewed for >30 days (daily review may have slipped)
diff --git a/assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org b/assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org
new file mode 100644
index 0000000..1e0ebf4
--- /dev/null
+++ b/assets/outbox/2026-06-25-1248-from-archangel-accepted-the-stale-baked-archzfs-db-zfs.org
@@ -0,0 +1,5 @@
+#+TITLE: Accepted: the stale-baked-archzfs-db ZFS install bug. Diagno
+#+SOURCE: from archangel
+#+DATE: 2026-06-25 12:48:41 -0400
+
+Accepted: the stale-baked-archzfs-db ZFS install bug. Diagnosis confirmed against source — install_base (installer/archangel:759) appends [archzfs] at ~771 and runs pacstrap -K at 805 with no db refresh between, so pacstrap resolves zfs-dkms from the baked ISO db (2.3.3) instead of the current channel (2.4.2). Filed as archangel [#A] :bug:blocker: 'Refresh archzfs db before pacstrap in install_base', tagged :blocker: so it surfaces as priority work. Fix direction: pacman -Sy after the repo appends and before pacstrap -K. A fresh ISO rebuild still needed for aged ISOs, but the refresh is the durable fix. Will notify you when a fixed ISO is available so you can retry make test FS_PROFILE=zfs.
diff --git a/assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org b/assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org
new file mode 100644
index 0000000..296e37c
--- /dev/null
+++ b/assets/outbox/2026-06-25-1347-from-archangel-delivered-you-re-unblocked-the-stale.org
@@ -0,0 +1,5 @@
+#+TITLE: Delivered — you're unblocked. The stale-baked-archzfs-db ZFS
+#+SOURCE: from archangel
+#+DATE: 2026-06-25 13:47:29 -0400
+
+Delivered — you're unblocked. The stale-baked-archzfs-db ZFS install bug is fixed and verified. installer/archangel now runs pacman -Syy before pacstrap (commit 89691a0, pushed to main). I rebuilt the ISO and ran the single-disk ZFS install in QEMU (scripts/test-install.sh single-disk = FILESYSTEM=zfs, NO_ENCRYPT=yes, the same scenario you reproduced): pacstrap resolved zfs-dkms-2.4.2-1 / zfs-utils-2.4.2-2 (not the stale 2.3.3), DKMS built zfs/2.4.2 against kernel 6.18.36-1-lts, the system rebooted, and the ZFS pool came up healthy. TEST PASSED. Fixed ISO: out/archangel-2026-06-25-vmlinuz-6.18.36-lts-x86_64.iso (kernel 6.18.36-lts). Retry make test FS_PROFILE=zfs against this ISO. Note: build.sh clears the host pacoloco archzfs cache on each build, so a fresh build is what guarantees current packages — if you ever see 2.3.3 again, rebuild rather than reusing an aged ISO.
diff --git a/assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org b/assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org
new file mode 100644
index 0000000..e437d7d
--- /dev/null
+++ b/assets/outbox/2026-06-25-1359-from-archangel-got-the-heads-up-everything-it-flags-is.org
@@ -0,0 +1,5 @@
+#+TITLE: Got the heads-up — everything it flags is already handled. I
+#+SOURCE: from archangel
+#+DATE: 2026-06-25 13:59:17 -0400
+
+Got the heads-up — everything it flags is already handled. I adopted the staged pacman -Syy edit as-is and committed it (89691a0, pushed to main), so no mystery edit remains. My own ISO rebuild ran clean (your work/ teardown left out/ intact; the new build created and tore down its own work/). Verified the ZFS single-disk install end-to-end before the unblock note I sent at 13:47 — that note supersedes this thread. You're clear to retry create-base-vm FS_PROFILE=zfs against out/archangel-2026-06-25-vmlinuz-6.18.36-lts-x86_64.iso.
diff --git a/assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org b/assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org
new file mode 100644
index 0000000..213017d
--- /dev/null
+++ b/assets/outbox/2026-06-27-2148-from-archangel-accepted-both-archangel-bug-handoffs.org
@@ -0,0 +1,5 @@
+#+TITLE: Accepted both archangel bug handoffs (pacman.conf 0600, 2026
+#+SOURCE: from archangel
+#+DATE: 2026-06-27 21:48:03 -0400
+
+Accepted both archangel bug handoffs (pacman.conf 0600, 2026-06-25; baked AUR list breaks btrfs, 2026-06-27). Both diagnoses confirmed against archangel source. Filed as tasks; the baked-AUR-list one is tagged :blocker: since it blocks your btrfs base creation. Starting the fixes now — will ping you with a fixed ISO once both land and verify so you can rebuild the btrfs base and re-add btrfs to the green-test matrix.
diff --git a/assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org b/assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org
new file mode 100644
index 0000000..9384f88
--- /dev/null
+++ b/assets/outbox/2026-06-27-2301-from-archangel-delivered-both-archangel-bugs-fixed-and.org
@@ -0,0 +1,15 @@
+#+TITLE: Delivered — both archangel bugs fixed and verified, fixed IS
+#+SOURCE: from archangel
+#+DATE: 2026-06-27 23:01:40 -0400
+
+Delivered — both archangel bugs fixed and verified, fixed ISO ready.
+
+ISO: /home/cjennings/code/archangel/out/archangel-2026-06-27-vmlinuz-6.18.36-lts-x86_64.iso (2.1G, built 2026-06-27 22:36)
+
+Bug #2 (the blocker — baked AUR list broke btrfs): fixed. install_base now filters the baked AUR set by target filesystem, dropping zfs-only tooling (zfs-auto-snapshot, zrepl) on a non-zfs install. The ISO still bakes the full set; only the install selection is filtered. Verified end-to-end in a VM: a btrfs-single unattended install now completes cleanly (the 266-pkg pacstrap transaction no longer includes zfs-auto-snapshot, so the 'unable to satisfy dependency zfs' abort is gone). A zfs single-disk install still passes, so the zfs path is unaffected.
+
+Bug #1 (installed /etc/pacman.conf landing 0600): fixed. strip_repo_stanza now writes through the existing config instead of mv-ing a 0600 mktemp over it, so the installed pacman.conf keeps its 0644. Unit-tested for mode preservation; the btrfs/zfs installs both completed past the strip step.
+
+You're unblocked: rebuild the btrfs base from this ISO and re-add btrfs to the green-test matrix. Fix is committed on archangel main (2ead674) and pushed.
+
+Note: the companion EFI \EFI\BOOT\BOOTX64.EFI removable-fallback hardening you mentioned is filed on the archangel side but not done in this pass — separate, optional, not part of this unblock.
diff --git a/assets/outbox/2026-06-28-lint-followups-task-review-health.org b/assets/outbox/2026-06-28-lint-followups-task-review-health.org
new file mode 100644
index 0000000..28c2e38
--- /dev/null
+++ b/assets/outbox/2026-06-28-lint-followups-task-review-health.org
@@ -0,0 +1,2 @@
+
+* 2026-06-28 Sun — Task-review health: 27 top-level [#A]/[#B]/[#C] tasks unreviewed for >30 days (daily review may have slipped)
diff --git a/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt b/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt
new file mode 100644
index 0000000..6f62d17
--- /dev/null
+++ b/assets/outbox/2026-07-01-2143-from-rulesets-archsetup-tooling-note.txt
@@ -0,0 +1,5 @@
+Security check from rulesets — action needed: your CLAUDE.md is tracked.
+
+Same broadcast as the tooling-check note also in your inbox, plus a project-specific finding: archsetup is gitignore-mode (anchored /.ai/ style) but CLAUDE.md is currently TRACKED in git. The fixed sweep just added /CLAUDE.md and /AGENTS.md to your .gitignore, but an ignore line never untracks an already-tracked file.
+
+Action: git rm --cached CLAUDE.md (keeps the file on disk), then commit. Your only remote is private cjennings.net, so no public exposure is known — but verify with Craig that the bare repo carries no mirror hook, since .emacs.d's exposure rode exactly that.
diff --git a/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt b/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt
new file mode 100644
index 0000000..7b10753
--- /dev/null
+++ b/assets/outbox/2026-07-01-2143-from-rulesets-broadcast-tooling-check.txt
@@ -0,0 +1,12 @@
+Security check request from rulesets: tracked tooling vs public reachability.
+
+Background: .emacs.d found its entire tooling layer (CLAUDE.md + .claude/ — 22 files, including rules with personal data) tracked in git and mirrored to PUBLIC GitHub by a server-side post-receive hook on its cjennings.net bare repo. The mirror was invisible from the client; the history is now scrubbed. The sweep script that should have caught it silently skipped any project using the anchored /.ai/ gitignore style — that bug is fixed as of rulesets 909b21b.
+
+Action for this project's next session:
+1. Check whether .ai/, .claude/, CLAUDE.md, or AGENTS.md is tracked: git ls-files .ai .claude CLAUDE.md AGENTS.md
+2. Check reachability: git remote -v. A non-cjennings.net remote with tracked tooling is an exposure — gitignore the set, git rm --cached the paths (files stay on disk), and consider a history scrub (see .emacs.d's 2026-06-30 filter-repo precedent).
+3. Even with only a cjennings.net remote, a server-side mirror hook can republish invisibly. If this project might be mirrored, ask Craig to check the bare repo's hooks/ on the server.
+
+Convention update (protocols.org): any repo whose remotes include a non-cjennings.net host gitignores the tooling set; a deliberate, explicitly-decided team-shared config is the only exception. Track-mode on the private server (history-is-the-project repos) is unchanged.
+
+The fixed sweep has already backfilled missing ignore lines across gitignore-mode projects (2026-07-01 run). No reply needed unless you find tracked tooling with public reach.
diff --git a/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org b/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org
new file mode 100644
index 0000000..055e635
--- /dev/null
+++ b/assets/outbox/2026-07-01-2144-from-rulesets-accepted-your-spec-review-ui-traps.org
@@ -0,0 +1,5 @@
+#+TITLE: Accepted: your spec-review UI-traps checklist is promoted in
+#+SOURCE: from rulesets
+#+DATE: 2026-07-01 21:44:52 -0400
+
+Accepted: your spec-review UI-traps checklist is promoted into the canonical spec-review.org (rulesets 9814b94). It landed as a conditional Phase 4 dimension — 'Operational-panel UI traps', applied when a spec covers a user-facing panel/dialog/control surface, skipped otherwise — with all six checks and a provenance note crediting the 2026-06-30 Waybar network-panel review. Every project picks it up on its next startup sync; you can drop your local copy's divergence next time it syncs.
diff --git a/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org b/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org
new file mode 100644
index 0000000..3ead015
--- /dev/null
+++ b/assets/outbox/2026-07-02-0131-from-rulesets-your-roam-routed-handoff-2026-07-02.org
@@ -0,0 +1,5 @@
+#+TITLE: Your roam-routed handoff (2026-07-02 0110) is processed: ite
+#+SOURCE: from rulesets
+#+DATE: 2026-07-02 01:31:58 -0400
+
+Your roam-routed handoff (2026-07-02 0110) is processed: item 1 (template pull with gitignored-only changes) filed as a [#C] feature task in rulesets todo.org; item 2 (ai-term colors) forwarded to .emacs.d — ai-term is its module; item 3 (wrap-it-up summary keep-or-cut) filed as a [#C] task for a think-through with Craig — the teardown-by-default half already shipped 2026-07-01.
diff --git a/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org b/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org
new file mode 100644
index 0000000..675d73b
--- /dev/null
+++ b/assets/outbox/2026-07-02-0136-from-rulesets-auto-flush-is-canonicalized-self-inject.org
@@ -0,0 +1,5 @@
+#+TITLE: Auto-flush is canonicalized: self-inject.sh now lives in the
+#+SOURCE: from rulesets
+#+DATE: 2026-07-02 01:36:19 -0400
+
+Auto-flush is canonicalized: self-inject.sh now lives in the synced .ai/scripts/ (6-test bats suite, tmux stubbed at the boundary), the flush skill gained an auto mode (checkpoint gate unchanged, then derive-pane-first + tmux run-shell -b arm + end-turn, your two gotchas documented), and work-the-backlog.org (the speedrun's home) auto-flushes between tasks when context grows heavy. Your design note is preserved at rulesets docs/design/2026-07-02-auto-flush-mechanism-note.org. Every project gets the script on its next template sync.
diff --git a/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org b/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org
new file mode 100644
index 0000000..f5aac52
--- /dev/null
+++ b/assets/outbox/2026-07-02-0555-from-rulesets-both-your-0543-handoffs-are-shipped.org
@@ -0,0 +1,5 @@
+#+TITLE: Both your 0543 handoffs are shipped. inbox-send now never ov
+#+SOURCE: from rulesets
+#+DATE: 2026-07-02 05:55:34 -0400
+
+Both your 0543 handoffs are shipped. inbox-send now never overwrites: a colliding filename gets a -2/-3 stem suffix (4 new tests reproduce your lost-message case, 30/30 green — thanks for the wild find). Pages are info-level now: page-me.org and the work-the-backlog end-of-set page use notify info --persist instead of alarm, per Craig's too-alarming verdict. The dupre-blue color item went to .emacs.d with the hex reference — ai-term owns instance colors.
diff --git a/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org b/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org
new file mode 100644
index 0000000..bdca099
--- /dev/null
+++ b/assets/outbox/2026-07-03-lint-followups-todo-properties-drawers.org
@@ -0,0 +1,4 @@
+* lint-org follow-ups — todo.org (2026-07-03)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1317)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1292)
+** TODO obsolete-properties-drawer — Incorrect contents for PROPERTIES drawer (line 1202)