diff options
22 files changed, 2882 insertions, 1419 deletions
@@ -27,7 +27,7 @@ The provisioning step is resumable: it records progress as marker files, so an i - Builds suckless tools (dwm, dmenu, st, slock) and an Emacs config from configurable Git repos, compiled on a tmpfs RAM disk - Stow-managed dotfiles in a separate repo, split into `common/`, `dwm/`, `hyprland/`, and `minimal/` trees - That repo's `Makefile` carries the stow / restow / reset / unstow / import targets; the installer clones and stows it automatically -- Two-theme system (`dupre`, `hudson`) switchable with the `set-theme` script +- Dupre theme managed by the `set-theme` script - VM-based integration test harness (QEMU/KVM) that installs into a throwaway VM and validates the result - Logs everything to `/var/log/archsetup-*.log` @@ -132,7 +132,7 @@ This `archsetup` repo no longer carries the dotfiles itself — the in-repo `dot ### Theme system -Two themes ship in the dotfiles repo, `dupre` (default) and `hudson`. Switch with the `set-theme` script (installed via the `common` dotfiles). Theme files live under `~/.dotfiles/hyprland/.config/themes/<theme>/` and cover foot, fuzzel, waybar, dunst, hyprlock, and Xresources. +The Dupre theme ships in the dotfiles repo and is applied with the `set-theme` script (installed via the `hyprland` dotfiles). Its files live under `~/.dotfiles/hyprland/.config/themes/dupre/` and cover foot, fuzzel, waybar, dunst, hyprlock, and Xresources. ## Desktop Environments diff --git a/archive/task-archive.org b/archive/task-archive.org index f1a6938..2b5d26e 100644 --- a/archive/task-archive.org +++ b/archive/task-archive.org @@ -1430,3 +1430,141 @@ Stable within each backend group. dotfiles 2671472. tailscale is a mesh overlay, not a full tunnel, so it never conflicts. dotfiles 307a0fe. *** 2026-07-05 Sun @ 17:49:51 -0400 Net panel dedupes Proton's own wireguard row The proton CLI's =ProtonVPN <server>= NM profile no longer shows alongside the proton backend row. dotfiles dbc9ee8. +** DONE [#B] Maintenance console build :feature: +CLOSED: [2026-07-08 Wed] +:PROPERTIES: +:SPEC_ID: 9d9df833-c592-4aec-a7df-50d588e943ce +:END: +Build the maintenance console per [[file:docs/specs/2026-07-07-maintenance-console-spec.org][maintenance-console-spec.org]] (DOING; review incorporated 2026-07-07 — two rounds, all 13 decisions DONE, all 10 findings DONE, verdict Ready; Craig approved config paths + sysmon right-click re-homing). Design source of truth: [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]; pixel reference: [[file:docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype]]. CLI-first =maint= package in dotfiles (archsetup-owns-dotfiles applies: edit, test, commit, push, note dotfiles inbox per phase); thresholds TOML + VM harness in archsetup. Each phase gates on green tests before its commit. + +*** 2026-07-07 Tue @ 20:11:23 -0500 Built Phase 1 — package skeleton, thresholds, contract (dotfiles 43a39ac) +maint/ package + shim + tests/maint/ (48 tests, fake-tool harness); thresholds two-layer merge with disable flags + env overrides; capability probe (battery ⇔ type==Battery, verified against ratio's Mains+USB tree); =maint status --json= with disk-usage/pacman-cache/failed-units pilots. Seeded configs/maintenance-thresholds.toml in archsetup from the workflow's values and installed a copy to ~/.config/archsetup/ on ratio (installer wiring stays Phase 13). Full make test green (50 suites). Live on ratio: worst=warn from the real 10.75 GB cache; du grades on its printed total, not its exit code (root-owned partial-download dirs make it exit 1) — pinned in a test. + +*** 2026-07-07 Tue @ 20:49:05 -0500 Built Phase 2 — storage & snapshot collectors (dotfiles 94e8371) +All Phase 2 collectors TDD'd against the fake-tool harness (argv-matched cases added): btrfs unallocated/scrub-age/device-errors (SMART cross-check), per-disk SMART health + last self-test, fstrim posture (discard mount option = continuous trim, ratio's real setup), disk top-consumers on a new slow-local cache (=maint scan --slow=, ~/.local/state/maint), snapper counts split timeline·single·pre-post (split keys on cleanup, not type), full ZFS family. =priv.py= pulled forward from Phase 6 (read-only verbs =smart_json= + =btrfs_scrub_status=, validated args, MAINT_SUDO) so probes stay elevation-free. maint suites 48→118; make test green (51). Live read-only verified on BOTH hosts: ratio worst=warn (10.75 GB cache; scrub 0d, SMART ok), velox over tailscale shows the ZFS family live (ONLINE/autotrim-on/scrub 1d/231 snaps) with a genuine fstrim warn (timer off — Phase 6 Confirm remedy). TOML gained storage keys (btrfs_unalloc_warn_gb, smart_*, fstrim_stale_days, hog_*); installed copies refreshed on ratio + velox. + +*** 2026-07-07 Tue @ 21:30:59 -0500 Built Phase 3 — packages, security, systemd collectors (dotfiles 3eaab6e) +All Phase 3 collectors TDD'd: orphans name+size with [curation.kept_orphans], pacnew safe-delete vs needs-merge, keyring freshness, reboot-required (uname -r vs modules dir), -Qkk on =maint scan --slow= with a noise split (unverifiable-as-user reads, mtime-only, [curation.qkk_known] paths tallied but never counted — an unfiltered live run sat permanently red at 68 "altered" files; classified, ratio shows 10 real findings); =maint scan --net= writes checkupdates/yay/arch-audit/fwupd caches (yay rc-1-silent = AUR-clean zero; failures never clobber good cache; malformed payloads degrade to unprobed), readers grade with age + stale bump; is-system-running with state-aware cause, failed-unit roster since/exit/journalctl hint, maintenance-timers meta-metric (capability-derived expected set, @-prefix instance match), taint letters decoded. Prereq done: arch-audit added to archsetup deps + installed both hosts. Review subagent (live-verified) found 2 Important (yay AUR-clean rc quirk, cache-shape envelope kill) + 2 minor — all fixed red-first; archsetup now enables btrfs-scrub@-.timer on btrfs installs (was ratio-only out-of-band, the meta-metric expects it). maint suites 118→169; full make test green. Live on ratio: 23-metric envelope, worst=warn from real findings (10.75GB cache, 8 orphans, mirrorlist.pacnew, 713 repo updates, 32 CVEs, 1 firmware). + +*** 2026-07-07 Tue @ 21:59:27 -0500 Built Phase 4 — logs + mem·pwr collectors (dotfiles 40c448b) +All Phase 4 collectors TDD'd: journal error digest (identifier groups w/ first/last seen + journalctl hint, known-noise curation tallied but never counted, byte-array MESSAGE decoded), coredumps by binary over a TOML window, kernel/hw event classes (MCE/EDAC/IO/thermal/GPU — generic driver noise excluded), journald disk usage, app-log staleness mirroring the log-cleanup cron's filename-date rules; memory pressure + top-5 RSS, OOM kills (kill-verdict lines only — one event spans several matching lines), swap/zram, temps via sensors -j (k10temp Tctl / coretemp package / amdgpu edge), Intel thermal-throttle count, EPP read, battery capacity/health/charge-cap, unclean-shutdown rate from wtmp (last -x pairing). Throttle/EPP/battery self-gate on sysfs and drop out of the envelope. Review subagent (live-verified) found 1 Blocking — windowed-empty coredumpctl exits 1 (my earlier --since-now pin was wrong), which left the metric permanently unprobed on healthy hosts — plus OOM ~3× overcount and a malformed-curation-entry envelope crash; all fixed red-first, re-review Approve. maint suites 169→227; full make test green. Live read-only both hosts: ratio 34 metrics (telega-server crashing 3× in window — real find), velox 38 (genuine 75% battery-health warn; charge_control_end_threshold ABSENT on velox's cros_ec hardware → Phase 9's SET 80% lever must gate on the file). TOML +journal_disk_warn_gb/coredump_window_days, new [memory], [power] temp/health/unclean keys; installed both hosts. + +*** 2026-07-07 Tue @ 22:35:28 -0500 Built Phase 5 — network posture + services collectors (dotfiles 2e92a9f) +All Phase 5 collectors TDD'd after live-pinning every tool format on both hosts (velox presence sweep over tailscale: full tool set present): nm_state (passive — NM's own connectivity check covers DNS+HTTP, so status never generates traffic), chronyc offset vs [network].ntp_offset_warn_ms, tailscale Health + peer tally, fail2ban per-jail bans, listeners digest via priv ss (expected-entry curation tallied-not-counted, unexpected+wildcard=crit, v4/v6 dedup, multi-holder sockets match any name), firewall (priv ufw status; fallback requires unit-active AND ufw.conf ENABLED=yes; names "ufw down — N public binds exposed" from the digest), rsyncshot freshness (1MB log-tail parse; hourly graded only where the log shows hourly runs — velox is daily-only; daily ≥48h/missing = crit), docker df JSONL + stopped containers w/ [curation.expected_containers], libvirt roster (queried only when libvirtd already active — a read must not socket-activate), cron drift (root rsyncshot expectations gate on backup-source). priv.py +4 read-only verbs; thresholds.curation_entries() tolerant helper. Review subagent (live-verified): Request Changes — 1 Important (malformed fail2ban counts raise ValueError into the envelope) + ufw-disable unit-state false positive, docker unknown-unit sizes reading 0, unfloatable chrony offset; all fixed red-first, re-review Approve. maint suites 227→297; full make test green (54 suites, unpiped exit 0 — an earlier tail-piped run masked make's exit code, rerun properly). Live both hosts: ratio 45 metrics, velox 49. REAL FINDS: listeners crit on both (ratio postgres/8080 wildcard binds, velox syncthing *:8384/*:22000) — curation material for Craig; backups stale on both since 2026-07-06 (truenas down for repairs, Craig confirmed — clears when it returns). TOML +[network]/hourly-backup/cron keys, installed both hosts. + +*** 2026-07-07 Tue @ 23:14:56 -0500 Built Phase 6 — remedy layer, priv verbs, guard, doctor CLI (dotfiles e171c08) +The full remedy layer TDD'd in one pass (no 6a/6b split needed): remedies.py allowlisted table (~30 remedies — exact argv, tiers per the design tables as amended by the dated decisions, re_probe_ids, long flags, RECLAIM SPACE macro resolving steps against the envelope incl. per-config snapper expansion); priv.py +28 write verbs (closed builders, type-validated args, per-verb timeouts; =build()= returns the exact sudo-prefixed argv so dry-run and the GUI arm-press share one string); guard.py pure live-update matcher over new TOML =[updates] guard_patterns= (fnmatch, case-sensitive, pairwise-tested; cold pending-cache is surfaced as a note, never silent); curation.py user-layer writes (mark/unmark/clear + KEEP/MARK KNOWN/EXPECTED wrappers, disable-flags for shipped defaults, self-verifying TOML emitter); doctor.py clean|review + iter_fix streaming the event feed (begin/ok/fail/guard/note; failures red without aborting; re-probed truth after each action); cli =maint fix <id> [--dry-run] [--force]= + =maint doctor clean|review= wall renderer; status envelope now stamps =levers= (always-on for free controls: epp_set, charge_limit, update, topgrade). KILL four guards: pid:name victim, TERM-only verb, session-critical denylist ([curation.session_critical], new TOML table) at preview, comm revalidation + denylist re-check at fire. Live-pinned before coding: coredumpctl HAS NO clean verb (find -mtime +N -delete via priv), log-cleanup at ~/.local/bin/cron/, rsyncshot takes <mode> <keep>, ufw enable needs --force, EPP value enum. Review subagent (live-verified): Request Changes — 1 Blocking (json.dumps astral-char surrogate pairs = invalid TOML: one emoji in a MARK KNOWN example would brick thresholds.load() for the whole console → ensure_ascii=False + write self-verifies by re-parsing before replace) + 1 Important (KeyError from sparse installed TOML escapes the event stream → caught, degrades to a red wall entry) + 2 Minor (cold-cache guard note, comm-vs-denylist re-check); all fixed red-first (6 new tests), re-review Approve. maint suites 297→393 (96 in the new suite); full make test green ×2 (unpiped, exit 0). VERIFIED LIVE on ratio: doctor clean executed the Auto tier for real — paccache -ruk0 freed 223.91 MiB and the re-probe measured the cache drop exactly; the live-update guard trips on ratio's genuinely-pending mesa/vulkan-radeon set. TOML +guard_patterns/+session_critical/+timeline_quarterly installed both hosts. + +*** 2026-07-07 Tue @ 23:52:40 -0500 Built Phase 7 — GUI shell (dotfiles d28435e) +The GTK4 window lands as the fourth panel sibling (net.cjennings.maintpanel, humble-object layering: GTK-free PanelModel presenter + pure viewmodel formatters + thin gui.py view). Read-only shell: faceplate (worst lamp, CVE/ATTN badges, MNT·01, close), inert doctor/update keys, updates strip (pending/CVE/AUR/firmware from the strip ids), eight-band selector (lamp + fixable/watch split from the envelope's levers; updates/security categories fold into PACKAGES), metric-row subpanel per band with per-id value formatting (bytes/percent/days/°C/ms/bool). Hydration off-thread; 3s live tier (build_live: cheap mem·pwr reads) + 30s full rebuild, both visibility-gated; MAINT_PANEL_FIXTURE=good|bad renders shipped envelope fixtures (reshaped from the E5 prototype by tests/maint/gen_fixtures.py, deterministic, real remedy ids) and freezes both tiers so the degraded board stays put on a healthy machine. Review subagent: Request Changes — 1 Blocking (build_live returned bare metrics, so the 3s merge stripped the levers the full envelope established and the band splits oscillated between tiers → attach_levers in build_live) + 2 Minor (synthetic error envelope missing worst read as healthy green → panel.error_envelope with worst=unprobed; smoke leaked the panel process on mid-run assertion errors → try/finally reap); all fixed red-first, re-review Approve (+1 hardening: merge into an error envelope is a no-op). maint suites 393→447 (54 in test_panel.py); full make test green (56 suites, unpiped exit 0). AT-SPI smoke PASS on live Hyprland (faceplate, badges, strip, all 8 tiles, band switch, clean close; window parked on a special workspace, never the active one). Screenshots off-workspace via headless output: bad fixture + live board vs the E5 reference. Live board on ratio honest: 32 CVE / 13 ATTN, NETWORK red from the real listeners crit, SERVICES fixable from the truenas-down backup staleness. GOTCHAS: windowrules need hyprctl reload before first launch (panel opens tiled otherwise); pkill -f "maint panel" self-matches the invoking shell — anchor with "maint panel$". + +*** 2026-07-08 Wed @ 00:39:23 -0500 Built Phase 8 — GUI levers: digests, armed keys, rotary band, live strip (dotfiles 388521e) +The storage/snapshots/packages subpanels grew their evidence digests (disk hogs, per-drive SMART, device errors, snapper by-type, orphans KEEP/REMOVE, pacnew DELETE/MERGE, CVE advisories), packages got the E5 rotary band selector (ORPHANS/PACNEW/ADVISORIES), and the updates strip went live: state-tiered border, UPDATE/TOPGRADE behind the arm-to-override live-update guard, REBOOT offered when required or after an update lands. Every lever is arm-then-fire — first press shows the exact argv on the new act line (identical to =maint fix --dry-run=, the parity criterion), second press fires through =doctor.iter_fix= with the re-probe landing back on the act line; one key armed at a time, band/deck switch disarms; fixture boards always dry-run and never write curation, which is what lets the AT-SPI smoke exercise the full press-press path safely. Engine support: snapper probe emits =stale_singles= (DELETE STALE's candidates, newest kept), both scrub probes parse running-%, =doctor.pending_updates()= public for the arm-press guard read, fixtures regenerated with deterministic evidence. Review subagent (live-verified): Request Changes — 2 Important (refresh tiers raced a firing remedy: a 30s tick could probe the package DB mid-transaction and land stale data after the fire's own re-probe → tiers pause on =firing= + post-fire rebuild retries past an in-flight tick; digest builders KeyError'd on identity-less evidence rows replayed from cache files → skip-and-degrade, 8 new red-first tests) + 4 Minor (all taken: broadened strip-key except, scrub key offers first idle target instead of vanishing while any runs, busy-refused press no longer consumes the arm, fire-time guard refusal names the tripped set in GUI wording). Re-review: Approve. maint suites 447→516; full =make test= green (exit 0, unpiped); AT-SPI smoke PASS end-to-end on the bad fixture; headless-output screenshots vs E5; live board honest on ratio (real orphans w/ KEEP/REMOVE, 10.5 GB cache CLEAN, arm-press names the genuinely pending mesa set). Velox pulled to 388521e (no restow/TOML/windowrule changes). Scroll-position-on-arm check added to Manual testing. + +*** 2026-07-08 Wed @ 01:51:26 -0500 Built Phase 9 — GUI: systemd, logs, mem·pwr, network, services (dotfiles 9a2985f + a3da942) +Shipped in two gated halves. 9a (9a2985f): failed-unit + timer rosters (armed RESTART/RESET per unit; ENABLE only on present-but-inactive timers), logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, row levers VACUUM/CLEAR/RUN. 9b (a3da942): CPU-mode segmented control (current + "default" excluded — priv's closed verb refuses "default"), top-memory guarded KILL (session-critical disabled pre-press, arm refusal behind it, fire-time comm revalidation behind both), battery SET 80% gated on the sysfs knob (velox: unsupported → withheld, verified live), listeners curation (probe emits pid + owning unit from the cgroup v2 line; STOP for system.slice services, KILL otherwise, "?" watch-only), firewall ENABLE / tailscale UP / chrony STEP row levers, services deck (containers MARK EXPECTED + START, docker df, cron roster + backups w/ worst-of chip lamp). RECLAIM SPACE deferred to Phase 10's wall by design — the act line can't honestly render a 7-step stream. Reviews: 9a Request Changes (curation done-callbacks bypassed the busy-retry rebuild → routed through _refresh_after_fire) → Approve; 9b Request Changes (DEFAULT EPP dead control + cgroup hybrid-hierarchy, "?"-curation, cron chip lamp, unprobed ✓ minors — all fixed red-first) → Approve. maint 516→612 tests; make test 58 suites exit 0; AT-SPI smoke PASS across all 8 bands; headless screenshots vs E5; live envelopes verified both hosts. +**** 2026-07-08 Wed @ 01:13:48 -0500 Built 9a — systemd + logs subpanels (dotfiles 9a2985f) +Failed-unit roster (armed RESTART/RESET per unit, since/exit context, journalctl hint) + expected-timer roster (ENABLE only on present-but-inactive timers); logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, and row levers VACUUM / CLEAR / RUN. Review subagent (live-verified): Request Changes — 1 Important (curation done-callbacks called refresh_full, which silently no-ops while a tick refresh holds busy → a mid-tick write left a pre-write envelope on the board up to 30s while the act line claimed success; routed all four incl. Phase 8's keep through _refresh_after_fire) + minors taken (single _mark_token definition, three test gaps closed, docstring truthfulness, stale arm wording cleared on band/deck switch — my own find during screenshot eyeballing). Re-review: Approve. maint 516→562 tests; make test 58 suites exit 0 unpiped; AT-SPI smoke PASS incl. the new rosters/deck/mark path; headless screenshots vs E5. velox pulled (no restow). Accepted gap: KNOWN NOISE subsec lists the live curation table on a fixture board — fixture-sourced entries list is later polish. + +*** 2026-07-08 Wed @ 02:36:54 -0500 Built Phase 10 — results wall + doctor keys (dotfiles cc2fb5d) +Results wall shipped as the session action log: doctor events stream per-event (begin opens a running entry with the exact argv, ok/fail resolves in place, re-probe notes follow), 3.5-visible-row scroll cap with autoscroll, HIDE/COPY (COPY emits CLI-wall text), curation writes logged (fixture-suppressed as dim notes). CLEAN UP streams the auto tier with no arm press; REVIEW & FIX swaps the subpanel to the confirm-tier roster (item-less remedies get armed FIX keys, per-item ones point at their subpanel; review events carry item + worst severity). RECLAIM SPACE macro landed on the disk-usage row (deferred from 9b); macro arm line shows first step + remaining count; zero-step macros refuse by name. Review (live-verified): Request Changes — 1 Important: the roster's FIX for update/topgrade could never override the live-update guard (fire-time re-arm used the bare rid while the FIX key arms on "rid:", and _on_lever never passed force — an infinite arm→guard loop); fixed by threading the pressed token through _fire and sharing the strip's guard arming (_guard_arm_line + panel.guarded). Minors all taken: wall_abort resolves stranded running entries red when a stream dies, empty-macro IndexError → named RemedyError, test predicate tightened. Re-review: Approve, no residuals. maint 612→645; make test 59 suites exit 0 ×2; AT-SPI smoke extended (wall, HIDE/SHOW, CLEAN UP stream, review roster, 14-line RECLAIM stream) PASS; screenshots vs E5; LIVE verify on ratio: real CLEAN UP on the live board streamed "3 done" with real re-probes (coredumps warn 5, disk_usage ok 60). Velox pulled cc2fb5d. + +*** 2026-07-08 Wed @ 03:24:44 -0500 Built Phase 11 — waybar glyph + timers (dotfiles 10033be) +custom/maint replaced custom/sysmon: waybar-maint renders from the state file maint-scan.timer writes every 30 min; color tracks the worst diagnostic (lever-less) metric only, actionable findings never color the bar; missing/stale scan data dims to an honest unprobed state naming the timer. Battery hosts show live battery % + charging icon from sysfs on waybar's interval; low charge forces red. Left-click toggles the console (new maint-panel wrapper); right-click keeps the btop scratchpad. Bare =maint scan= became the glyph scan (envelope → state → RTMIN+12); maint-net-scan.timer drives =--net --slow= hourly. Retired same commit: waybar-sysmon, sysmon-cycle, both suites, #custom-sysmon CSS ×3 — plus unnamed collateral the grep caught: waybar-collapse's collapsed base set (maint glyph stays on battery hosts — it IS the battery display now) and a stale dotfiles todo. Review (live-verified): Approve with 4 Minor, all taken red-first — scope!=Device gate so a wireless mouse's battery can't masquerade as the machine's (shared capability.device_scope), unprobed diagnostics named in the tooltip instead of "all clear" under a dimmed glyph, comment truth, dead state field dropped; re-confirm clean (677 maint tests). Full make test exit 0; smoke PASS (first run's pipe masked a transient AT-SPI race — rerun unpiped clean). LIVE both hosts: ratio timer fired for real (glyph amber from genuine diagnostics: firmware update + journal errors; 11 actionable NOT coloring), velox pulled + one-time stow + battery 100% text w/ warning class. velox's user systemd manager found wedged (pre-existing) — timers enabled via manual symlinks + state seeded; Craig rebooting to clear. + +*** 2026-07-08 Wed @ 04:15:45 -0500 Built Phase 11b — prototype fidelity pass (dotfiles 3ee22a8) +Rebuilt the GUI presentation to the E5 instrument-card idiom. Each band renders a 4-column card grid: big-number readouts (viewmodel.card_spec registry — one builder per bespoke metric id, count-card fallback for the rest), cairo progress bars w/ threshold ticks (cache scaled to 1.5× the warn line), radial gauges (scrub cadence, keyring/topgrade age, temperatures — sweep scaled to the metric's own threshold), status chips (PASSED/FAILING/INACTIVE/YES; firewall chips the state word, exposure detail moves to the caption), and the CPU-mode segmented control promoted from digest section to card (current mode lit, alternatives as joined armed keys — same DIGEST_ONLY epp_set arm-then-fire). Evidence digests stay full-width below the grid. Selector became a two-row 4-col grid of wide tiles w/ count chips (3! · 7✓, crit reddens) + severity left borders; subpanel header carries the attention/ok/fixable/watch summary right-aligned beside a gold band title. The scoped NVMe wear dial was skipped honestly — no standalone wear metric exists (wear rides SMART evidence rows). Presentation only: engine/remedies/doctor/curation and the lever contracts unchanged. New 42-test suite (card kinds, threshold scales, running-scrub override, unprobed degrade incl. tick drop, seg exclusions, chips/subhead/band_counts, both-fixture sweep). Review (live-verified): Approve w/ 1 Important (pre-existing: chrony "not running" string crashed format_value's ms branch — fixed w/ card guard) + 4 Minor all taken red-first (disk None guard, _pct negative clamp, unprobed tick, dead row_detail/split_label/.maint-val removed); re-review Approve. make test 61 suites exit 0 ×2 unpiped; AT-SPI smoke 92 checks PASS (new gauge/chip/subhead assertions — DrawingAreas carry accessible_role=IMG + names so the smoke can see them); all 8 bands + review roster screenshotted on both fixtures + the live board against the settled headless-Chrome prototype render. Velox pulled (no restow). Gotcha pinned: a card body's vexpand propagates up a Gtk.Grid — a one-card band ballooned until grid.set_vexpand(False). + +*** 2026-07-08 Wed @ 05:05:20 -0500 Ran the granted /refactor sweep over the maint package (dotfiles a178470..73e9d94) +Four parallel read-only scans (complexity, duplication, dead-code, simplification) converged on the same hotspots; ten behavior-preserving commits applied and pushed. Headlines: six curation handlers folded into one _curation helper (~150 lines of repeated fixture/done plumbing, act/wall strings byte-identical), _on_lever/_on_strip_key merged into _press_lever, _on_fired split (_rearm_after_guard + _fire_summary), the "tool: not found / exit N" wording single-sited in cmd.why() across 20 probe sites, exact clones deduped (packages curation_entries, _parse_asctime, double VERSION), ten uniform digest sections folded into _evidence_digest, _card_body split per kind, listener key rule named, plus a small-simplifications batch. Deliberately skipped: format_value's flat chain (readable as-is), _journal_lines generalization (rc-semantics risk), test-panel-maint Makefile target (Phase 12 scope). Verification: suite parity exact pre/post (59 suites / 2490 tests, exit 0 unpiped), AT-SPI smoke PASS end-to-end, live ratio envelope 45 metrics zero unprobed, review subagent walked the full diff against 3ee22a8 and approved (no behavior change; accessible names untouched; its one latent-robustness minor pinned as an invariant comment). gui.py 1680→1605; net −103 lines. Velox pulled. + +*** 2026-07-08 Wed @ 05:46:02 -0500 Built Phase 12 — VM remedy scenarios + AT-SPI target (archsetup d6993d3, dotfiles 0636554) +Scenario orchestration landed as =run-maint-scenarios.sh= over the existing vm-utils.sh snapshot primitives: nine break → =maint fix= → assert scenarios in three groups (logs, packages, systemd), batched per Decision 11 (one boot per group, maint-ready snapshot restored between groups, clean-install restored at the end). =run-maint-nspawn.sh= is the fast lane — the packages group against a cached pacstrap rootfs in seconds. Plan layer is pure (=--list=, no KVM) with a 19-test unit suite; =make test-maint= wires it in. Dotfiles side: panel smoke now runs GOOD + BAD fixture passes (healthy render, badge tallies from the fixture, no-REBOOT, leak check) and =make test-panel-maint= is enumerated. Verified: full VM run 9/9 green from pristine snapshot ×2 (test-results/maint-20260708-054029), nspawn 4/4, smoke 100 checks exit 0, unit suite green under both FS_PROFILE values. Review subagent: Request Changes (FS_PROFILE env leak in the unit suite, journal-vacuum assert vacuous both directions) — all six findings fixed red-first, re-review Approve. FS_PROFILE=zfs scenarios: filtered but unexercised — the zfs base image fails to build (see the zfs DKMS task). + +*** 2026-07-08 Wed @ 06:18:58 -0500 Built Phase 13 — install wiring, workflow move, docs (archsetup bef7053 + 18c081f, dotfiles 9a3f0c7) +Installer: install_maintenance_config in user_customizations installs the shipped TOML to ~/.config/archsetup/ (re-run refreshes it, ~/.config/maint/ user layer never touched) and enables maint-scan/maint-net-scan timers via wants-symlinks (hyprland-only — the units live in that stow tier; no session bus during install, syncthing idiom); orchestrator + dispatch-branch tests pin the wiring. Deps swept from the probes' argv: expac, lm_sensors, fwupd added (arch-audit was already in from Phase 3; the rest ride base/archinstall or existing sections). maint/README.md in dotfiles covers user (CLI, panel, glyph, config layers, guard, privilege) + developer (module map, env seams, four test layers, add-a-metric recipe). system-health-check.org moved home → archsetup docs/workflows/ and rewired: TOML-authoritative severity section, threshold lines cite TOML keys (TOML wins on disagreement), inventory paths → docs/homelab-inventory/, maint-CLI fast-path note; only the four #+HOSTNAME: host inventories moved (+ the truenas specs asset they link) — the personal gear records stay in home per the domain split; discoverability via .ai/project-workflows symlink + notes.org entry; handoff note in home's inbox (2026-07-08-0607) lists home-side removals and flags strix-soak-watch as a candidate to move later. Verified: fresh-shell =maint status= green from installed paths on ratio; make test-unit exit 0; dotfiles make test exit 0; shellcheck count unchanged; review subagent Approve (2 Minor doc nits, both taken). + +*** 2026-07-08 Wed @ 06:18:58 -0500 Flipped the spec to IMPLEMENTED +Spec keyword DOING → IMPLEMENTED with a dated history line naming the closing commits (phases 1-13, dotfiles 43a39ac..9a3f0c7, archsetup d6993d3/bef7053/18c081f) and the Status mirror updated. Residuals tracked as their own tasks: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D]. +** DONE [#B] Signal and known-noise counts disagree with the journal :bug:maint:solo: +CLOSED: [2026-07-09 Thu] +Not a counting bug — a semantics bug (dotfiles eded0c0). journal_errors grouped by identifier and summed lines, but displayed one sample message, and logs.py overwrote that sample on every iteration so it named the *last* error rather than the counted one. Live on ratio: systemd had 10 error lines this boot — 6 "Failed to start Emacs text editor." and 4 org-roam auto-sync — which is exactly the 10-vs-6. Now grouped by identifier + normalized message (pids, hex addresses, uuids, long integer runs blanked), so a row's count is that message's count, and each row's hint carries a =--grep= reproducing it (verified: 6 and 4). MARK KNOWN binds the volatile-free literal instead of the pid-bearing sample, so marking suppresses exactly what the row counted. The KNOWN NOISE headline counts matched lines while the deck lists patterns; the caption now names both units. +Logs > SIGNAL reports 10 journal errors and the signal item claims 10, but the group holds one item and the open journal shows it 6 times. KNOWN NOISE reports 19 against 12 visible entries. The counts are the point of the band, so a wrong number undermines the whole diagnostic. Find where the tally diverges from the rendered set — likely counting matched journal lines rather than distinct grouped entries, and counting curated entries against a different window than the one displayed. +** DONE [#B] Persist the CPU pill setting across reboot :feature:maint: +CLOSED: [2026-07-09 Thu] +The kernel resets energy_performance_preference to the driver default every boot. A successful epp_set now records the preference in the maint user config; maint-epp-restore.service replays it at login. No-op when nothing is remembered, the host lacks EPP, or the mode already matches. A hand-edited bogus value is refused by priv's closed verb. Installer enables the unit via the existing wants-symlink idiom. Verified live on ratio: unit enabled, ran clean, EPP untouched. (dotfiles a7f34bd, archsetup 430ef1a) + +The power/perf/balance pill sets EPP live, but the value reverts to its original setting after a reboot. Add a way to make a selection permanent. Needs a call on where persistence lives — a systemd unit, a udev rule, or a config file the maint package owns. +** DONE [#B] Preview the pacman + AUR update queue before upgrade :feature:maint:solo: +CLOSED: [2026-07-09 Thu] +A QUEUE key on the updates strip streams the pending pacman + AUR set onto the results wall, each package with its version move; `maint queue` prints the same in a terminal. The cap reports how many it dropped rather than truncating silently, and a cold cache says it has no pending set instead of rendering "nothing pending". Verified live against ratio's 105 pending packages. (dotfiles 8837a47) + +Show which pacman and AUR packages are queued before UPDATE or topgrade runs, so the armed live-update guard isn't the first place the package set becomes visible. +** DONE [#B] Audio PTT state desync across panel, waybar icon, and actual state :bug:audio:solo: +CLOSED: [2026-07-09 Thu] +Two state stores and two copies of the logic. The panel flipped an in-memory ptt_armed flag with its own enter/exit rules; the CLI toggle and the bar tag read a runtime state file. The file is now the only state and ptt.toggle_plan the only decision. The panel adopts the persisted state on every refresh, routes its toggle through the shared plan, and persists before signalling waybar (writing after would race the bar into re-rendering the pre-toggle class). Tests drive both entry points against one state across armed/disarmed x muted/live. (dotfiles 9524a44) + +The audio panel's push-to-talk, the waybar PTT icon, and the real PTT state disagree. All three must agree at all times. Cover every state transition with tests, not just the happy path — this is the second PTT bug after the pre-talk mute restore landed 2026-07-05 (dotfiles 1443b9e). +** DONE [#B] Publish the homelab inventory into the agent knowledge base :chore:docs: +CLOSED: [2026-07-09 Thu] +The four host inventories, the TrueNAS specs asset, and the ratio USB/xHCI record now live as roam nodes in =~/org/roam/hardware/=, linked from the "Homelab Hardware Inventory" index node. =docs/homelab-inventory/= is gone; roam is canonical. + +Carried rather than pointed, per home's argument, which beat mine. Pointing keeps one canonical copy but breaks the write path: a third project that discovers a durable hardware fact would have to write into archsetup's repo to record it, which the cross-project rules forbid, so the fact would sit in an inbox instead of on the device's page. Moving answers the canonical-copy worry without that cost. + +The =* Automated Capabilities= drawer moved with the pages. =system-health-check.org= now resolves a host's node by its =#+HOSTNAME:= keyword under =${ROAM_DIR:-$HOME/org/roam}/hardware/= — never by filename, since the timestamp prefix isn't stable. Verified: all four hosts resolve, an unknown host degrades to "NO INVENTORY FILE", and ratio's capability drawer still parses. A host without the roam clone (mybitch, truenas) takes the same no-inventory path it always did. + +The strix kernel watch stayed at =docs/workflows/strix-soak-watch.org= — it's a workflow, not an inventory page. +** DONE [#C] Optional ticking sound for the pomodoro :feature:timer: +CLOSED: [2026-07-09 Thu] +WTIMER_TICK=1 plays a quiet tick each second through a pomodoro's work phase; off by default, silent through rest (a tick there would undo the break), while paused, and while awaiting a manual advance. Rides the bar's existing once-a-second `wtimer render` call. Playback is fire-and-forget so it can never stall or break the bar. Sound is an 18ms 1.4kHz sine at a tenth of full scale (notify/tick.ogg); WTIMER_TICK_SOUND overrides. Craig still has to judge whether the tick sounds right. (dotfiles 886dafd) + +Timers module. Off by default; needs a sound choice and a volume decision. +** DONE [#C] Refresh indicator for the top-like items :feature:maint: +CLOSED: [2026-07-09 Thu] +A hairline under the top-memory section drains toward the next live refresh, so a slow board reads differently from a frozen one. Withheld on a fixture board, which never live-ticks — a countdown that never counts is worse than none. The fraction is a pure viewmodel function; only the cairo stroke lives in the GUI. (dotfiles 5d384d9) + +Show when the process/memory rows are about to refresh, so a stale-looking board is distinguishable from a frozen one. Shape is a design call — a countdown, a pulse, or a progress hairline. +** DONE [#C] CPU pill should not reposition on selection :bug:maint:quick:solo: +CLOSED: [2026-07-09 Thu] +epp_rows dropped the current preference from the key list, so the remaining segments slid sideways on every selection. Every preference now renders in a fixed order with the current one lit and unpressable in place, at full contrast rather than GTK's disabled dimming. The separate lit label is gone — the active segment is the lit one. Three separate tests encoded the old contract (unit, AT-SPI smoke, phase11b card); all moved with the behavior. The smoke caught a real gap the unit tests missed: the EPP row renders through the card path, which ignored the disabled flag. (dotfiles 5d384d9) + +The perf/balance/power pill moves position depending on which option is selected. The segmented control should hold a fixed layout and mark the active segment in place. +** DONE [#C] Session identifier needs more width :bug:waybar:quick: +CLOSED: [2026-07-09 Thu] +Not waybar — the tmux status line. status-left-length was never set, so tmux clipped the drawn status-left to its default of 10 columns: "[aiv-archsetup] " came out "[aiv-archs", losing the closing bracket and the separating space, which is why the window list ran into it. Set to 40. Applied to the live tmux server too. (dotfiles dc3c27f) + +The session section truncates; give it more characters, and keep the window sections that follow from running into it. Screenshot: [[file:/home/cjennings/pictures/screenshots/2026-07-07_145236.png][2026-07-07_145236.png]] +** DONE [#C] minimal tier zsh login PATH gap :bug:dotfiles:quick:solo: +CLOSED: [2026-07-09 Thu] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +Fixed (dotfiles =c6a7878=). =minimal/.zprofile= added, mirroring =common/='s PATH-only prepend and its rationale comment. + +Tested the invariant rather than the file, because the bug *was* a per-file fix missing a tier: any stow tier shipping a =.zshrc= must ship a =.zprofile= that prepends =~/.local/bin=. Red first, and it named =minimal=. Verified behaviorally as the task asked — stowed the tier into a throwaway HOME and read a real =zsh -lc 'echo $PATH'=, which now leads with =~/.local/bin=. Full =make test= green. + +The 2026-07-08 fix gave common/ a .zprofile (zsh logins never read .profile, so ~/.local/bin was missing from TTY/ssh PATH — dotfiles 39cef40). The minimal/ tier has the same gap: it carries .zshrc and .profile but no .zprofile, so headless installs (DESKTOP_ENV=none stows minimal/ instead of common/) still get zsh logins without ~/.local/bin. Add a matching minimal/.zprofile (PATH-only prepend, same rationale comment). Verify: fresh minimal stow, zsh -lc 'echo $PATH' shows ~/.local/bin first. The installer itself is clean — it writes no shell profiles; the stow layer owns this. +** DONE [#B] Doctor button for the audio panel :feature:audio: +CLOSED: [2026-07-10 Fri] +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +What happens when pipewire or pulseaudio is broken — would we know? Spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] — *READY*, all eight decisions closed 2026-07-09, Phase 0 shipped. Buildable: Phase 1 diag, Phase 2 classify, Phase 3 repair + doctor, Phase 4 the DOCTOR key and wall. Grounded in a live survey of ratio's stack: there is no PulseAudio (pipewire + wireplumber + pipewire-pulse, all user-scope, so no sudo anywhere), =pactl= hangs against a server that accepts and never answers, and the panel can't diagnose itself because =pactl= is the layer most likely to be down. Mirrors the net panel's diag/classify/repair/doctor split, not the maint console's priv-verb table. + +Re-graded =[#C]= → =[#B]= by the 2026-07-09 audit: the spec is written, Phase 0 shipped, and six decisions are all that stand between this and a build. Active backlog, not parking lot. + +*** 2026-07-09 Thu @ 14:12:00 -0500 Fixed: waybar's audio module died against a half-wedged server +My first read of this was wrong. =pactl.run()= already carries =timeout=8= and raises =PactlTimeout=, so the panel degrades and does not freeze. Correcting the record rather than leaving the claim standing. + +The real bug, found by driving it: =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard. A server that answered the lists and then stopped answering raised =PactlTimeout= straight out of a function whose whole contract is to degrade to =ok: False=. =waybar-audio= calls it with nothing catching it, so the bar's audio module died instead of dimming; the GTK panel survived only because its background worker catches everything. + +A server that hangs on the *first* call never exposes this — =list_sinks= raises inside the guard and masks the unguarded reads behind it — so the obvious regression test passes against broken code. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults". Verified end to end: =waybar-audio= now emits =class: degraded= / "Audio graph unavailable" and exits 0. dotfiles =4d42eb3=; full =make test= green. + +*** 2026-07-10 Fri @ 06:41:00 -0500 Shipped: the audio doctor, phases 1 through 4 +=audio diag=, =audio doctor=, =audio doctor --fix=, and the panel's DOCTOR section header with DIAGNOSE / FIX keys streaming into a results wall (copy + close). Spec is IMPLEMENTED. dotfiles =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=. + +Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, calling a repaired stack broken. Both fixed. + +Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs. + +Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy. @@ -1945,7 +1945,12 @@ configure_pre_pacman_snapshots() { action="installing pre-pacman snapshot hook" && display "task" "$action" mkdir -p /etc/pacman.d/hooks - cat << 'EOF' > /etc/pacman.d/hooks/zfs-snapshot.hook + # Remove the pre-ordering filename from earlier archsetup runs; leaving it + # would execute a second snapshot after mkinitcpio's removal hook. + rm -f /etc/pacman.d/hooks/zfs-snapshot.hook + # Pacman runs hooks lexically. Keep this before the distribution's + # 60-mkinitcpio-remove hook so the snapshot preserves a bootable /boot. + cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook [Trigger] Operation = Upgrade Operation = Install @@ -2104,7 +2109,12 @@ UDEVEOF action="installing the live-update guard pacman hook" && display "task" "$action" mkdir -p /etc/pacman.d/hooks - cat > /etc/pacman.d/hooks/hypr-live-update-guard.hook << 'HOOKEOF' + # Migrate installations made before the numeric ordering prefix. + rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook + # This AbortOnFail guard must run before 60-mkinitcpio-remove; otherwise a + # blocked transaction can remove the current initramfs without reaching the + # PostTransaction hook that rebuilds it. + cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook << 'HOOKEOF' [Trigger] Operation = Upgrade Type = Package @@ -2131,7 +2141,7 @@ Exec = /usr/local/bin/hypr-live-update-guard AbortOnFail NeedsTargets HOOKEOF - chmod 644 /etc/pacman.d/hooks/hypr-live-update-guard.hook + chmod 644 /etc/pacman.d/hooks/10-hypr-live-update-guard.hook } ### Display Server (conditional) diff --git a/assets/color-themes/dupre/dupre-palette.org b/assets/color-themes/dupre/dupre-palette.org index cd4f26a..5df2778 100644 --- a/assets/color-themes/dupre/dupre-palette.org +++ b/assets/color-themes/dupre/dupre-palette.org @@ -202,24 +202,16 @@ Point-based configs (foot, fuzzel, dunst, Xresources) use 13pt. Waybar CSS uses | =hyprlock.conf= | Hyprlock screen | | =Xresources= | X11 apps / Emacs | -** Switching Themes +** Applying Dupre =set-theme= (=~/.local/bin/set-theme=) copies theme configs into place, applies Hyprland/hy3 colors via =hyprctl=, merges Xresources, and reloads waybar and dunst. #+begin_src sh set-theme dupre # apply dupre -set-theme --toggle # toggle between dupre and hudson -set-theme --pick # fuzzel picker set-theme --current # print current theme name #+end_src -Keybindings: =$mod+Y= toggles, =$mod+Shift+Y= opens picker. - -** Adding a New Theme - -1. Create =~/.config/themes/<name>/= with all seven config files -2. Add a case block in =set-theme= for Hyprland/hy3 runtime colors -3. The theme will appear automatically in =set-theme --list= and the fuzzel picker +Dupre is the single supported theme; it has no theme-toggle or picker keybinding. * Palette Image diff --git a/docs/2026-06-10-tool-evaluation-criteria.org b/docs/2026-06-10-tool-evaluation-criteria.org index f5fec34..be0a79a 100644 --- a/docs/2026-06-10-tool-evaluation-criteria.org +++ b/docs/2026-06-10-tool-evaluation-criteria.org @@ -14,7 +14,7 @@ A replacement tool earns adoption only when it beats the incumbent on a dimensio * Weighting criteria (compare on these) - Daily-use win: does it remove a real friction, or is it novelty? "Answers in one command what took a pipeline" (dust) beats "same answer, prettier" (gping). - Workflow fit: Emacs (dired, vterm, magit) and tmux already own many niches. A candidate duplicating them adds maintenance surface without adding capability (see: terminal tabs/splits, broot vs dired+zoxide). -- Theme-system fit: can set-theme drive it (INI/TOML include, runtime color escape, reload signal)? Tools that can't follow dupre/hudson stay second-class. +- Theme-system fit: can the Dupre apply path drive it (INI/TOML include, runtime color escape, reload signal)? Tools that cannot follow the desktop palette stay second-class. - Packaging: official repo > AUR > git build. Every step down adds update fragility to fresh installs. - Migration cost: config translation, muscle-memory retraining, installer changes. Cost scales with how load-bearing the incumbent is. diff --git a/docs/design/2026-06-29-waybar-timer-module-spec.org b/docs/design/2026-06-29-waybar-timer-module-spec.org index e8eecbe..4db4c8f 100644 --- a/docs/design/2026-06-29-waybar-timer-module-spec.org +++ b/docs/design/2026-06-29-waybar-timer-module-spec.org @@ -157,14 +157,11 @@ Update the =tests/waybar-collapse= base-set expectations to match (TDD the chang * CSS -Add =#custom-timer= plus the state classes to all three stylesheets. Keep the -*selectors and structure* parallel across the three (what the theme-drift test -checks); the actual color *values* are per-theme (dupre vs hudson) and differ by -design, so this is structural parity, not byte-identity. Confirm against the real -CSS files what the drift test compares before editing. +Add =#custom-timer= plus the state classes to the live and Dupre stylesheets. +Keep them byte-identical: the apply script copies Dupre over the live stylesheet. +Confirm against the real CSS files what the drift test compares before editing. - =hyprland/.config/waybar/style.css= - =hyprland/.config/themes/dupre/...= waybar css -- =hyprland/.config/themes/hudson/...= waybar css Colors: normal = foreground; =urgent= = a warning hue (reuse the sysmon warn/crit palette); =paused= = dimmed; =pomodoro-break= = a calmer accent. @@ -194,7 +191,7 @@ dotfiles branch =waybar-timer-module=: - =tests/wtimer/test_wtimer.py= (new) - =hyprland/.config/waybar/config= (module def + modules-right position) - =hyprland/.local/bin/waybar-collapse= (base-set) + =tests/waybar-collapse/...= (expectations) -- =hyprland/.config/waybar/style.css= + dupre + hudson waybar css (CSS) +- =hyprland/.config/waybar/style.css= + Dupre waybar css (CSS) archsetup (main, at the end): - this spec diff --git a/docs/design/2026-06-29-zfs-pre-snapshot-installer.org b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org index e5a339e..1b9235e 100644 --- a/docs/design/2026-06-29-zfs-pre-snapshot-installer.org +++ b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org @@ -30,9 +30,9 @@ version below); live backup at =/usr/local/bin/zfs-pre-snapshot.bak-2026-06-29=. In the archzfs / ZFS-on-root install path, gated to ZFS-root installs (velox is the only ZFS daily driver; ratio is btrfs), install: -1. =/etc/pacman.d/hooks/zfs-snapshot.hook= — the =PreTransaction= hook that +1. =/etc/pacman.d/hooks/05-zfs-snapshot.hook= — the =PreTransaction= hook that runs the script. *Not included in the handoff* — source it from velox - (=/etc/pacman.d/hooks/zfs-snapshot.hook=) or write it. + (=/etc/pacman.d/hooks/05-zfs-snapshot.hook=) or write it. 2. =/usr/local/bin/zfs-pre-snapshot= — the =KEEP=10= self-pruning version below. @@ -80,8 +80,10 @@ fi * Implementation (2026-06-30) -- Hook sourced from velox (=/etc/pacman.d/hooks/zfs-snapshot.hook=) and embedded +- Hook sourced from velox (=/etc/pacman.d/hooks/05-zfs-snapshot.hook=) and embedded as a heredoc in =configure_pre_pacman_snapshots()=. +- The installer removes the legacy unprefixed hook filenames during migration; + leaving them would run duplicate hooks, including a too-late snapshot. - Insertion point: a new =configure_pre_pacman_snapshots()= gated on =is_zfs_root=, called from =boot_ux= (the last step) so the hook doesn't fire during the install's own package operations — the first pre-pacman snapshot is diff --git a/docs/design/system-monitor-design-ideas.org b/docs/design/system-monitor-design-ideas.org index 26619a0..b536604 100644 --- a/docs/design/system-monitor-design-ideas.org +++ b/docs/design/system-monitor-design-ideas.org @@ -395,7 +395,7 @@ Representation: Tools: - ZFS: =zfs list -t snapshot=, =zfs-pre-snapshot=, - =/etc/pacman.d/hooks/zfs-snapshot.hook=. + =/etc/pacman.d/hooks/05-zfs-snapshot.hook=. - Btrfs: =snapper list=, =snap-pac=, =grub-btrfs-mkconfig=, =/.snapshots=. - Common: =pacman -Q= for snapshot packages. diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org index 1cb75c6..a4f591f 100644 --- a/docs/prototypes/README.org +++ b/docs/prototypes/README.org @@ -15,32 +15,32 @@ point at. affordance for the NETWORKS/NEARBY headers (busy-style throbber + list fade). - [[file:2026-07-03-sound-panel-prototype.html][2026-07-03-sound-panel-prototype.html]] — the audio/pulsemixer console; layout reference for [[file:../specs/2026-07-03-audio-panel-spec.org][the audio-panel spec]]. -- [[file:panel-widget-gallery.html][panel-widget-gallery.html]] — the shared instrument-console - widget kit (lamps, engraved sections, console keys, needle gauges). The - living catalogue: every widget here is the visual + behavioral spec for - its reusable-component ports. All widgets build from [[file:widgets.js][widgets.js]] (below); +- [[file:panel-widget-gallery.html][panel-widget-gallery.html]] — The Dupre Kit: the shared instrument-console + gallery (lamps, engraved sections, console keys, needle gauges). The + living catalogue: every instrument here is the visual + behavioral spec for + its reusable-component ports. All instruments build from [[file:widgets.js][widgets.js]] (below); the gallery page is the reference consumer. - [[file:2026-07-03-waybar-redesign-prototype.html][2026-07-03-waybar-redesign-prototype.html]] — three directions for sprucing up waybar in the dupre instrument-console aesthetic (future work). -* Widget library ([[file:widgets.js][widgets.js]]) +* The Dupre Kit ([[file:widgets.js][widgets.js]]) The whole kit lives in =widgets.js= — a classic script (no modules, no build -step) exposing the =GW= namespace with one builder per gallery card. Load it +step) exposing the =DUPRE= namespace with one builder per gallery card. Load it with =<script src="widgets.js"></script>= and call builders directly. ** Builder contract -=GW.name(host, opts)= → handle ={el, get, set, ...}=. =host= is an empty -element the widget renders into. =opts.onChange(value, text)= fires on every -state change, including the initial paint; =text= is the widget's canonical +=DUPRE.name(host, opts)= → handle ={el, get, set, ...}=. =host= is an empty +element the instrument renders into. =opts.onChange(value, text)= fires on every +state change, including the initial paint; =text= is the instrument's canonical readout string. (Exception: live meters whose original init was silent — they paint on the first =set=/=push=.) ** Styling -Widget-internal CSS ships inside =widgets.js= (the =GW_CSS= block, injected -once as =<style id="gw-css">= at load), so widgets are fully styled on any +Instrument-internal CSS ships inside =widgets.js= (the =DUPRE_CSS= block, injected +once as =<style id="dupre-css">= at load), so instruments are fully styled on any page. The design tokens (=--gold=, =--glow-hi=, the amber family, =--mono=, =--pulse-rate=, …) must be defined on =:root= by the consumer — the gallery's generated =:root= block is the reference; =tokens.json= is the source. @@ -56,60 +56,60 @@ Live meters expose value-driven handles that repaint synchronously and fire the ring buffer) - =set(samples, amp)= for sampled traces (samples normalized) -Widget-owned animation (the R17 scope trace, R31 radar sweep, R44 servo +Instrument-owned animation (the R17 scope trace, R31 radar sweep, R44 servo chase, R52 pseudo-PC, R53 day clock, R10 TIME page) runs inside the builder, gated on =prefers-reduced-motion=. -** Keyboard contract (widgets that take keys) +** Keyboard contract (instruments that take keys) Sibling of the tick contract above, and the same split: focus is an ambient resource like the clock — exactly one thing holds it across the whole page — so *the target owns focus and delivery, the builder declares what it accepts*. -Neither half works alone. Only the page knows which widget is live; only the -widget knows that Backspace means DEL. +Neither half works alone. Only the page knows which instrument is live; only the +instrument knows that Backspace means DEL. *The builder declares* a =KEYS= table on itself, mapping a canonical key name to the argument its handle's =press= takes: -: GW.abcKeypad.KEYS = { A:'A', ..., '0':'0', ..., Space:'SPC', Backspace:'DEL', Enter:'ENT' } +: DUPRE.abcKeypad.KEYS = { A:'A', ..., '0':'0', ..., Space:'SPC', Backspace:'DEL', Enter:'ENT' } One declaration, wired natively per target: - *Web* — the builder attaches a =keydown= listener *to its own focusable - element* (=tabindex=0=, focused on click). =GW.slideRule= is the precedent: + element* (=tabindex=0=, focused on click). =DUPRE.slideRule= is the precedent: arrows step it, scoped to its own element. The browser's focus system does the arbitration, which is why it doesn't fight the gallery's own global Escape handler. - *Emacs* — the SVG region is an image and never sees a keypress, so the mode installs the same table into its keymap and calls =press=. This is why the declaration is a table and not a function over a DOM event: a port that - can't read the widget's intent has to re-derive it, and then the two drift. + can't read the instrument's intent has to re-derive it, and then the two drift. - *waybar* — GTK focus, same shape. Rules, each of them a bug someone will otherwise ship: -- *Never listen on =document= or =window=.* A widget that does fights every +- *Never listen on =document= or =window=.* An instrument that does fights every sibling and the page's own handlers; on the gallery, every keystroke anywhere would type into whichever card bound last. - *=preventDefault= only what has a default worth suppressing* — Space scrolls the page, Backspace can navigate back. Nothing else. - *Let Tab and Escape bubble.* Tab is how the page is navigable at all, and - the gallery's audit stepper owns Escape. A widget that swallows either + the gallery's audit stepper owns Escape. An instrument that swallows either breaks something it can't see. - *=press= filters, it does not trust.* A handle that appends whatever it is handed will cheerfully append "F1". The table is the allowlist. - *Click and key must land in the same place* — both route through =press=, so the two paths cannot drift apart or fire different events. -A widget with no =KEYS= table takes no keys, which is most of them: the kit is +An instrument with no =KEYS= table takes no keys, which is most of them: the kit is click-first and that is what makes it port. Keys are additive, and in Emacs they are the *more* native idiom, not the less. ** Style options -Widgets with named style axes take them as constructor opts backed by a -=STYLES= table on the builder — e.g. =GW.slideToggle(host, {onStyle:'green', -thumb:'brass'})= with the axes enumerated in =GW.slideToggle.STYLES=, and +Instruments with named style axes take them as constructor opts backed by a +=STYLES= table on the builder — e.g. =DUPRE.slideToggle(host, {onStyle:'green', +thumb:'brass'})= with the axes enumerated in =DUPRE.slideToggle.STYLES=, and =handle.setStyle(axis, name)= to restyle a live instance. The gallery's option chips are demo rigs driving =setStyle=. @@ -130,8 +130,8 @@ family, glows, pulse rate). [[file:gen_tokens.py][gen_tokens.py]] regenerates al - [[file:tokens-waybar.css][tokens-waybar.css]] — GTK =@define-color= declarations for the waybar panels - [[file:gallery-tokens.el][gallery-tokens.el]] — an elisp alist for svg.el renderers -[[file:gallery-widget.el][gallery-widget.el]] is the Emacs renderer (proof widget: the needle gauge, -gallery card 10) — it reads gallery-tokens.el and emits the widget as SVG via +[[file:gallery-widget.el][gallery-widget.el]] is the Emacs renderer (proof instrument: the needle gauge, +gallery card 10) — it reads gallery-tokens.el and emits the instrument as SVG via svg.el, so the same look renders inside Emacs. Tests: =tests/gallery-tokens/= (generator, unittest) and =tests/gallery-widgets/= (renderer, ERT), both in =make test-unit=. diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el index 1c96f45..5d69148 100644 --- a/docs/prototypes/gallery-widget.el +++ b/docs/prototypes/gallery-widget.el @@ -85,7 +85,7 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below." svg (dom-node 'defs nil (dom-node 'filter - '((id . "gw-glow") (x . "-75%") (y . "-75%") + '((id . "dupre-glow") (x . "-75%") (y . "-75%") (width . "250%") (height . "250%")) (dom-node 'feGaussianBlur '((in . "SourceGraphic") @@ -119,7 +119,7 @@ bottom center, 40px needle sweeping -60..+60 degrees, readout below." :stroke-width "4" :stroke-linecap "round" :opacity "0.55" - :filter "url(#gw-glow)") + :filter "url(#dupre-glow)") (gallery-widget--node svg 'line :class "needle" :x1 (gallery-widget--fmt cx) diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 5db6fab..9ff492d 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -3,7 +3,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>Panel widget gallery — dupre instrument console (interactive)</title> +<title>The Dupre Kit — retro instrument console (interactive)</title> <style> :root{ /* @tokens:start */ @@ -72,8 +72,8 @@ h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} .toc .scr{position:absolute;width:5px;height:5px;border-radius:50%; background:radial-gradient(circle at 35% 30%,#524d43,#161310);box-shadow:0 .5px 0 rgba(255,255,255,.08)} html{scroll-behavior:smooth} -/* WIDGETS/ROW sets an explicit column count (1-4); the stage zoom scales with it - so a widget fills its column — fewer per row, bigger widgets. The card text +/* INSTRUMENTS/ROW sets an explicit column count (1-4); the stage zoom scales with it + so an instrument fills its column — fewer per row, bigger instruments. The card text stays at its own (slightly raised) size, untouched by the zoom. */ body[data-cols="1"] .grid{grid-template-columns:1fr} body[data-cols="2"] .grid{grid-template-columns:repeat(2,1fr)} @@ -83,7 +83,7 @@ body[data-cols="1"] .stagew{zoom:2.8} body[data-cols="2"] .stagew{zoom:2.1} body[data-cols="3"] .stagew{zoom:1.55} body[data-cols="4"] .stagew{zoom:1.15} -/* intrinsically tiny widgets get an extra bump so they do not float in an empty stage */ +/* intrinsically tiny instruments get an extra bump so they do not float in an empty stage */ .stagew.boost{zoom:1.5} body[data-cols="1"] .stagew.boost{zoom:4.2} body[data-cols="2"] .stagew.boost{zoom:3.15} @@ -208,21 +208,21 @@ a.no{text-decoration:none;cursor:pointer} <div class="wrap"> <header class="masthead"> <div class="eyebrow">archsetup · dupre panel family · live kit</div> - <h1>Widget gallery — the instrument-console kit, fully driveable</h1> + <h1>The Dupre Kit — the instrument console, fully driveable</h1> <div class="mastrow"> <div class="mastintro"> <p>Every control + display idiom in the dupre faceplate language, merged into one live console — the base kit, the candidate batch, and the growing reference batch (R: modeled on period hardware), all rendering from the same tokens the net / bt / sound panels use. <b>Controls</b> take input; <b>meters & gauges</b> show a live analog value; <b>indicators & - readouts</b> show state or a number. Every widget is operable: drag the faders, knobs and gauges; click the + readouts</b> show state or a number. Every instrument is operable: drag the faders, knobs and gauges; click the switches, keys and lamps. Each card carries a live readout that tracks what you do. The trace displays run a live signal; the pointer types drive from your drag.</p> - <div class="szbar"><span class="lab">widgets/row</span> - <button class="key" data-cols="1">1</button> - <button class="key" data-cols="2">2</button> - <button class="key on" data-cols="3">3</button> - <button class="key" data-cols="4">4</button> + <div class="szbar"><span class="lab">instruments/row</span> + <button class="dupre-key" data-cols="1">1</button> + <button class="dupre-key" data-cols="2">2</button> + <button class="dupre-key dupre-on" data-cols="3">3</button> + <button class="dupre-key" data-cols="4">4</button> </div> </div> <nav class="toc"> @@ -240,7 +240,7 @@ a.no{text-decoration:none;cursor:pointer} <span class="scr" style="top:6px;left:6px"></span><span class="scr" style="top:6px;right:6px"></span> <span class="scr" style="bottom:6px;left:6px"></span><span class="scr" style="bottom:6px;right:6px"></span> <div class="tt">Policy</div> - <div class="pnote">What each widget's colour is bound by, and what it may change and stay true. Click a row to walk its cards; open a card's spec sheet for the reason and the authentic range.</div> + <div class="pnote">What each instrument's colour is bound by, and what it may change and stay true. Click a row to walk its cards; open a card's spec sheet for the reason and the authentic range.</div> <div class="vtally ptally" id="ptally"></div> </nav> </div> @@ -264,18 +264,18 @@ a.no{text-decoration:none;cursor:pointer} <script src="widgets.js"></script> <script> const $ = id => document.getElementById(id); -const {svgEl,polar,dragX,dragY,dragDelta,seg7,buildBars,VUDB,vuDb,SCREEN_FAMS}=GW; +const {svgEl,polar,dragX,dragY,dragDelta,seg7,buildBars,VUDB,vuDb,SCREEN_FAMS}=DUPRE; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; -document.querySelectorAll('.szbar .key').forEach(b=>b.addEventListener('click',()=>{ +document.querySelectorAll('.szbar .dupre-key').forEach(b=>b.addEventListener('click',()=>{ document.body.dataset.cols=b.dataset.cols; localStorage.setItem('gv-cols',b.dataset.cols); - document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k===b)); + document.querySelectorAll('.szbar .dupre-key').forEach(k=>k.classList.toggle('dupre-on',k===b)); })); (function(){const s=localStorage.getItem('gv-cols'); if(!s||!/^[1234]$/.test(s))return; document.body.dataset.cols=s; - document.querySelectorAll('.szbar .key').forEach(k=>k.classList.toggle('on',k.dataset.cols===s));})(); -/* boost the intrinsically tiny widgets (audit: content under 10% of the stage) */ + document.querySelectorAll('.szbar .dupre-key').forEach(k=>k.classList.toggle('dupre-on',k.dataset.cols===s));})(); +/* boost the intrinsically tiny instruments (audit: content under 10% of the stage) */ const BOOST=['01','07','08','12','13','N14','18']; /* ---- palette: the named instrument colors, grouped by role. No codes shown — names and uses. ---- */ @@ -339,7 +339,7 @@ const PALETTE=[ host.appendChild(tile);}} })(); -/* ---- screen-color families: period-authentic alternates for the screen widgets ---- +/* ---- screen-color families: period-authentic alternates for the screen instruments ---- green = P1 CRT phosphor (radar/scopes; hi reuses --phos, graticule keeps the kit's scope green) amber = P3 phosphor / backlit amber (the gold family the kit already speaks) red = neon orange-red, the nixie's color pulled out as a family @@ -348,7 +348,7 @@ const PALETTE=[ white = monochrome LCD/CRT white Exploration data, deliberately NOT in tokens.json yet — families get tokenized once picked, the same way amber earned its tokens. Values keyed by the CSS custom property they drive. */ -/* SCREEN_FAMS lives in widgets.js (GW.SCREEN_FAMS) */ +/* SCREEN_FAMS lives in widgets.js (DUPRE.SCREEN_FAMS) */ function screenChips(no,el,fams,def){ const cardEl=el.closest('.card'); if(!cardEl)return; const row=document.createElement('div'); row.className='famchips'; @@ -364,9 +364,9 @@ function screenChips(no,el,fams,def){ cardEl.querySelector('.opts').appendChild(row); } /* ---- per-card spec sheets ---- - input how you drive it, incl. the model: click widgets port to every target (Emacs click-regions - included); drag widgets have no Emacs drag equivalent and need a click/key idiom there; - display widgets take no input in real use (gallery interaction is demo only). + input how you drive it, incl. the model: click instruments port to every target (Emacs click-regions + included); drag instruments have no Emacs drag equivalent and need a click/key idiom there; + display instruments take no input in real use (gallery interaction is demo only). period present only when the component is clearly not timeless — use it to keep a console period-consistent (swap a modern part for its historical counterpart or vice versa). */ const INFO={ @@ -1214,26 +1214,26 @@ function auditStop(){const p=$('vaudit');if(p)p.remove();} addEventListener('keydown',e=>{if(e.key==='Escape')auditStop();}); /* card(host, no, name, htmlOrBuild, note) — the declarative card record. htmlOrBuild: a legacy stage-HTML string, or a builder function (stage, rd) => handle - that instantiates a GW.* widget into the stage; rd(txt) writes the card readout. */ -/* Recover a card's builder from the arrow it was handed — the first GW.<name>( + that instantiates a DUPRE.* instrument into the stage; rd(txt) writes the card readout. */ +/* Recover a card's builder from the arrow it was handed — the first DUPRE.<name>( in the source — and read its POLICY record off it. Single source: the record - lives on GW.<builder>.POLICY in widgets.js and the card only displays it, so the + lives on DUPRE.<builder>.POLICY in widgets.js and the card only displays it, so the page can't drift from the code. Unclassified cards read '—', which is the point: the gallery becomes the colour pass's visible worklist. */ function cardPolicy(html){ if(typeof html!=='function') return null; - /* Assumes the FIRST GW.<name>( in the arrow is the builder — true because every - card is (st,rd)=>GW.builder(st,{...}) and helpers are destructured, called - bare. An arrow that referenced a second GW.<name>( before its builder would + /* Assumes the FIRST DUPRE.<name>( in the arrow is the builder — true because every + card is (st,rd)=>DUPRE.builder(st,{...}) and helpers are destructured, called + bare. An arrow that referenced a second DUPRE.<name>( before its builder would mis-derive; none do. A wrapped or renamed builder reads '—' rather than wrong, which is safe. */ - const m=html.toString().match(/GW\.(\w+)\s*\(/); - return (m && GW[m[1]] && GW[m[1]].POLICY) || null; + const m=html.toString().match(/DUPRE\.(\w+)\s*\(/); + return (m && DUPRE[m[1]] && DUPRE[m[1]].POLICY) || null; } function card(host, no, name, html, note){ const isBuild=typeof html==='function'; const P=cardPolicy(html), kind=P&&P.kind; - const meta=kind&&GW.POLICIES[kind]; + const meta=kind&&DUPRE.POLICIES[kind]; const free=meta&&meta.free; const polTip=meta?(meta.gist||''):'policy not yet classified'; const c=document.createElement('div'); c.className='card'; c.id='card-'+no; @@ -1264,7 +1264,7 @@ function card(host, no, name, html, note){ d.innerHTML=`<summary>spec sheet</summary><div class="igrid">${rows}</div>`; c.appendChild(d);} host.appendChild(c); - if(isBuild)c.gw=html(c.querySelector('.stagew'),txt=>setRd(no,txt)); + if(isBuild)c.dupre=html(c.querySelector('.stagew'),txt=>setRd(no,txt)); return c; } function setRd(no,txt){const e=$('rd-'+no);if(e)e.textContent=txt;} @@ -1290,376 +1290,376 @@ function updatePolTally(){ if(!hits.length)return; const i=PJUMP[g]++%hits.length; location.hash=''; location.hash=hits[i].id;})); } -/* drag helpers, seg7, buildBars live in widgets.js (GW) */ +/* drag helpers, seg7, buildBars live in widgets.js (DUPRE) */ /* ============ CONTROLS ============ */ const C=$('controls'); card(C,'01','Slide toggle', - (st,rd)=>GW.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.slideToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>on / off, the touchscreen way.</b> The one born-digital control in the kit — the smartphone pill toggle the live net/bt/audio panels use. Click to flip; readout shows the state. Physical cousins: the bat-handle (R03) and the rocker (N01).'); card(C,'02','Console key', - (st,rd)=>GW.consoleKeys(st,{onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.consoleKeys(st,{onChange:(i,t)=>rd(t)}), '<b>physical push button.</b> Mutually exclusive — click one to engage. Green = live, terracotta = muted, gold = any other engaged key.'); card(C,'03','Horizontal fader', - (st,rd)=>GW.faderH(st,{value:68,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.faderH(st,{value:68,onChange:(v,t)=>rd(t)}), '<b>continuous 0-100.</b> Per-device volume, brightness. Drag the gold cap; readout tracks.'); card(C,'04','Vertical fader', (st,rd)=>{let ready=false; const upd=()=>{if(ready)rd('A '+Math.round(a.get())+' · B '+Math.round(b.get()));}; - const a=GW.faderV(st,{value:60,onChange:upd}); - const b=GW.faderV(st,{value:35,onChange:upd}); + const a=DUPRE.faderV(st,{value:60,onChange:upd}); + const b=DUPRE.faderV(st,{value:35,onChange:upd}); ready=true;upd();return {a,b};}, '<b>channel-strip style.</b> A mixer column per device. Drag each fader up or down.'); card(C,'05','Rotary knob', - (st,rd)=>GW.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}), + (st,rd)=>DUPRE.knob(st,{value:53,onChange:v=>rd('gain '+Math.round(v))}), '<b>dial in a value.</b> Volume/gain the analog way. Drag up/down to turn; readout shows the level.'); card(C,'06','Segmented selector', - (st,rd)=>GW.segmented(st,{onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.segmented(st,{onChange:(i,t)=>rd(t)}), '<b>pick one of a few.</b> Timer type, layout mode. Click a segment; readout names the choice. The lit segment ships in amber, green, or red — the accent chips below switch it.'); card(C,'07','Chip toggle', - (st,rd)=>GW.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>inline binary.</b> A soft toggle inside a line of text. Click to flip. The lit colour is the consumer\'s to pick from the accent family, because "on" is good news in one panel and a fault in the next — amber by default.'); card(C,'08','Arm-to-fire', - (st,rd)=>GW.armButton(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.armButton(st,{onChange:(v,t)=>rd(t)}), '<b>two-stage confirm.</b> Destructive actions. First click arms (red), second fires. Readout shows the stage.'); card(C,'09','Lamp row', - (st,rd)=>GW.lampRow(st,{onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.lampRow(st,{onChange:(i,t)=>rd(t)}), '<b>actionable list item.</b> Lamp + name + status. Click to cycle idle → connecting → connected.'); card(C,'24','Rotary selector', - (st,rd)=>GW.rotarySelector(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.rotarySelector(st,{onChange:(v,t)=>rd(t)}), '<b>pick one of N by position.</b> Printed detents, the pointer names the value. Click to turn; readout shows it.'); card(C,'25','Slide-rule dial', - (st,rd)=>GW.slideRule(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.slideRule(st,{onChange:(v,t)=>rd(t)}), '<b>value on a printed scale.</b> A lit pointer glides a warm-backlit strip. Printed numerals are the majors; the units between them carry minor ticks. Click any of them to jump, or focus and press ←/→ (↑/↓) to step a unit; readout shows it. Four faces — warm backlit, chrome, 80s black glass, marantz blue — on the chips below.'); card(C,'N01','Rocker power switch', - (st,rd)=>GW.rocker(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.rocker(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>hard on / off, lit legend.</b> A master power paddle — the pressed half glows. Click to rock.'); card(C,'N02','Transport cluster', - (st,rd)=>GW.transport(st,{mode:'play',animate:!reduced,onChange:(m,t)=>rd(t)}), + (st,rd)=>DUPRE.transport(st,{mode:'play',animate:!reduced,onChange:(m,t)=>rd(t)}), '<b>run / pause / capture.</b> One mode lit at a time, reels turn while it plays. Click a button.'); card(C,'N03','Radio preset bank', - (st,rd)=>GW.presetBank(st,{onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.presetBank(st,{onChange:(i,t)=>rd(t)}), '<b>pick one, the rest release.</b> The car-radio preset row — mechanically exclusive. Click one.'); card(C,'N04','Concentric dual knob', - (st,rd)=>GW.dualKnob(st,{outer:50,inner:50,onChange:(o,i)=>rd('bass '+Math.round(o)+' · treble '+Math.round(i))}), + (st,rd)=>DUPRE.dualKnob(st,{outer:50,inner:50,onChange:(o,i)=>rd('bass '+Math.round(o)+' · treble '+Math.round(i))}), '<b>two values on one spindle.</b> Bass / treble, coarse / fine. Drag the outer ring or inner cap — each is independent.'); card(C,'N05','Rotary encoder + LED ring', - (st,rd)=>GW.encoder(st,{value:7,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.encoder(st,{value:7,onChange:(v,t)=>rd(t)}), '<b>endless dial, lit position.</b> A detentless encoder; the LED arc lights to the level and the value keeps accumulating. Drag to turn.'); card(C,'N06','Keyed mode switch', - (st,rd)=>GW.keySwitch(st,{index:1,onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.keySwitch(st,{index:1,onChange:(i,t)=>rd(t)}), '<b>guarded three-position mode.</b> Off · on · run. The key-bit points at the live position. Click to turn.'); card(C,'N07','Center-detented crossfader', - (st,rd)=>GW.crossfader(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.crossfader(st,{onChange:(v,t)=>rd(t)}), '<b>throw to either side of zero.</b> A fader notched at center — balance, L/R mix. Drag; readout shows the signed value.'); card(C,'N08','Thumbwheel', - (st,rd)=>GW.thumbwheel(st,{value:42,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.thumbwheel(st,{value:42,onChange:(v,t)=>rd(t)}), '<b>ribbed edge-wheel, one value.</b> A knurled wheel with a windowed number. Drag up/down to roll.'); card(C,'N09','DIP-switch bank', - (st,rd)=>GW.dipBank(st,{onChange:(w,t)=>rd(t)}), + (st,rd)=>DUPRE.dipBank(st,{onChange:(w,t)=>rd(t)}), '<b>a bank of hard flags.</b> Hardware slide switches — up is on. Click each to flip; readout is the binary word.'); card(C,'N10','Jog / shuttle wheel', - (st,rd)=>GW.jogWheel(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.jogWheel(st,{onChange:(v,t)=>rd(t)}), '<b>scrub fine, shuttle fast.</b> The edit-deck wheel. Drag to jog the inner wheel; readout shows the position.'); card(C,'R02','Calibrated vernier dial', - (st,rd)=>GW.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.vernierDial(st,{value:42.0,onChange:(v,t)=>rd(t)}), '<b>precision on a rotating disc.</b> The big lab dial — the calibrated disc turns under a fixed hairline. Drag up/down; readout reads to a tenth. After a 1950s lab-oscillator vernier.'); card(C,'R03','Bat-handle toggle', - (st,rd)=>GW.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.batToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>the period power switch.</b> Chrome bat lever on a hex bushing, snaps up ON / down OFF. Click to throw it. After a lab-instrument panel toggle.'); card(C,'R04','Bakelite fluted knob', - (st,rd)=>GW.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.flutedKnob(st,{value:63,onChange:(v,t)=>rd(t)}), '<b>the skirted console knob.</b> Scalloped bakelite skirt, glossy dome, amber index over a printed 0-10 scale. Drag up/down to turn. After a vintage console mixer knob.'); card(C,'R05','Filter slider bank', - (st,rd)=>GW.filterBank(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.filterBank(st,{onChange:(v,t)=>rd(t)}), '<b>a wall of band faders.</b> Twelve bands on a screwed faceplate, dB rails both sides. Drag any cap; readout names band and level. Skins split three independent axes — panel (silver hi-fi after the Pioneer SG-9500 / studio black after the Technics SH-8065), cap shape (tall block fader after the Zaxcom Oasis / short ribbed / chrome T), and cap color (black white-index / red / green / blue / amber stripes / chrome / cream). The chips below mix them freely.'); card(C,'R06','Chicken-head selector', - (st,rd)=>GW.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}), + (st,rd)=>DUPRE.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}), '<b>the pointer-lever switch.</b> The tapered bakelite lever IS the indicator — it aims at the engraved position. Click to step through. After a modulator mode switch.'); card(C,'R12','Chrome slot fader', - (st,rd)=>GW.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.slotFader(st,{value:-4,onChange:(v,t)=>rd(t)}), '<b>the console gain slide.</b> A chrome T-handle rides a recessed slot, the dB scale engraved on the plate — gain above zero, cut below. Drag; readout shows signed dB. After a vintage channel-strip output fader.'); card(C,'R14','Spade-pointer tuning knob', - (st,rd)=>GW.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.spadeKnob(st,{value:8.3,onChange:(v,t)=>rd(t)}), '<b>the pointer rides the knob.</b> A knurled bakelite knob carries a bright spade pointer over a scale engraved into the panel itself. Drag up/down to tune. After a 1920s radio receiver dial.'); card(C,'R15','Multi-band dial', - (st,rd)=>GW.multiBandDial(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.multiBandDial(st,{onChange:(v,t)=>rd(t)}), '<b>four scales, one needle.</b> Nested band arcs on a cream face; the active ring reads bold. Drag the left dial to tune; click the bandspread dial to switch bands. After a shortwave receiver dial pair.'); card(C,'R16','Entry keypad', - (st,rd)=>GW.entryKeypad(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.entryKeypad(st,{onChange:(v,t)=>rd(t)}), '<b>a composed entry instrument.</b> Worn keycaps feed the amber display; the lamp watches the buffer; the amber keys confirm and clear. Click the keys. After an industrial keypad prop.'); card(C,'R18','Thumb-slide attenuator pair', - (st,rd)=>GW.thumbSlide(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.thumbSlide(st,{onChange:(v,t)=>rd(t)}), '<b>a lit column of numbers, a thumb tab.</b> The scale glows inside the rail; the cream tab rides beside it and the nearest figure lights. Drag either channel. After a compressor blend/mix pair.'); card(C,'R19','Waveform region editor', - (st,rd)=>GW.waveRegion(st,{start:22,end:76,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.waveRegion(st,{start:22,end:76,onChange:(v,t)=>rd(t)}), '<b>pick a region on the wave.</b> The monochrome LCD shows the sample; drag near either flag to move START or END, and the selection draws bright. After a sampler edit screen.'); card(C,'R20','Drum roller selector', - (st,rd)=>GW.drumRoller(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.drumRoller(st,{onChange:(v,t)=>rd(t)}), '<b>roll to the number.</b> The numbered drum shows through a tall window and the center value sits on an inverted chip. Drag either drum. After an equalizer tone selector.'); card(C,'R21','LED program row', - (st,rd)=>GW.ledRow(st,{index:5,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.ledRow(st,{index:5,onChange:(v,t)=>rd(t)}), '<b>identical keys, one lit dot.</b> The LED above the button carries the selection, not the key itself — the classic program-select row. Click a key; readout names the program. After a digital reverb’s program bank.'); card(C,'R22','Three-position slide', - (st,rd)=>GW.pillSlide(st,{index:1,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.pillSlide(st,{index:1,onChange:(v,t)=>rd(t)}), '<b>a detented pill, three stops.</b> The chrome pill parks at A, AB, or B, and the LED pair below tells the truth — both light on AB. Click a position. After a drum machine variation switch.'); card(C,'R23','Spun-aluminum knob', - (st,rd)=>GW.spunKnob(st,{value:62,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.spunKnob(st,{value:62,onChange:(v,t)=>rd(t)}), '<b>bright metal, machined rings.</b> A spun face with concentric tooling marks in a knurled grip ring, red index over dot ticks. Drag up/down. After a boutique pedal knob, confirmed by an avionics cluster.'); card(C,'R24','Stomp switch + jewel', - (st,rd)=>GW.stompSwitch(st,{on:false,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.stompSwitch(st,{on:false,onChange:(v,t)=>rd(t)}), '<b>the engage pair.</b> A chrome dome stomp and the faceted jewel that reports it — press to engage, the jewel lights; press again to bypass. Click it. After a tremolo pedal footswitch.'); card(C,'R27','Winged gain selector', - (st,rd)=>GW.wingSelector(st,{index:7,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.wingSelector(st,{index:7,onChange:(v,t)=>rd(t)}), '<b>the red T-bar, stepped.</b> A bar-grip handle over a dot ring, snapping between labeled gain detents. Drag up/down to step it. After a classic mic-preamp gain switch.'); card(C,'R28','Rotary disc switch', - (st,rd)=>GW.discSwitch(st,{index:1,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.discSwitch(st,{index:1,onChange:(v,t)=>rd(t)}), '<b>the master power disc.</b> The whole red disc turns, grip bar molded in — a heavy three-position selector for OFF, ON, and COMBINE. Click to step. After a marine battery switch.'); card(C,'R29','Guarded toggle', - (st,rd)=>GW.guardedToggle(st,{on:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.guardedToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>friction sized to consequence.</b> Guard posts flank the lever and a red collar marks it critical — the avionics way to prevent an accidental throw. Click to throw it deliberately. After a flight-deck switch panel.'); card(C,'R32','Mechanical timer dial', - (st,rd)=>GW.timerDial(st,{minutes:0,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.timerDial(st,{minutes:0,onChange:(v,t)=>rd(t)}), '<b>turn to start, push to stop.</b> Drag the knurled dial to wind minutes onto the fixed red index; it winds back down to OFF on its own (demo runs a minute per second). Click the red knob to stop it dead. After a mechanical interval timer.'); card(C,'R33','Four-way rocker', - (st,rd)=>GW.rockerPad(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.rockerPad(st,{onChange:(v,t)=>rd(t)}), '<b>discrete direction, one press at a time.</b> Click an arrow (the whole quadrant is the target) to step that way; the readout tracks the cursor position it drives. After a remote-control navigation pad.'); card(C,'R34','Four-way toggle selector', - (st,rd)=>GW.fourWayToggle(st,{position:'C',onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.fourWayToggle(st,{position:'C',onChange:(v,t)=>rd(t)}), '<b>a lever that lives in one of four states.</b> Click a quadrant and the ball lever throws to A, B, C or D; the matching corner lamp takes the light. The rocker steps — this one selects. After a test-panel quadrant switch.'); card(C,'R37','Pin routing matrix', - (st,rd)=>GW.pinMatrix(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.pinMatrix(st,{onChange:(v,t)=>rd(t)}), '<b>many-to-many routing you can read.</b> Sources are rows, destinations are columns; click an intersection to seat a pin and the route exists. Every simultaneous route stays visible. After the EMS VCS3 pin matrix.'); card(C,'R38','Dead-man button', - (st,rd)=>GW.deadMan(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.deadMan(st,{onChange:(v,t)=>rd(t)}), '<b>active only while held.</b> Press and hold — the ring arms, the lamp runs, the dwell clock counts. Let go anywhere and it drops to SAFE. The kit has toggles and momentaries; this is the only control whose state IS your grip.'); card(C,'R39','Rotary telephone dial', - (st,rd)=>GW.telephoneDial(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.telephoneDial(st,{onChange:(v,t)=>rd(t)}), '<b>numeric entry embodied as time.</b> Click a hole: the finger wheel winds to the stop and spins back, and the digit lands in the number. Bigger digits take longer, exactly like the pulses did. After a Western Electric dial.'); card(C,'R40','Circuit breaker panel', - (st,rd)=>GW.breakerPanel(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.breakerPanel(st,{onChange:(v,t)=>rd(t)}), '<b>the first control the system can throw.</b> Click a breaker to close or open it; hit TRIP and a fault pops one out to the tripped mid-position, amber collar showing. A tripped breaker must be pulled to reset before it closes again. After an aircraft breaker row.'); card(C,'R41','DSKY verb-noun panel', - (st,rd)=>GW.dsky(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dsky(st,{onChange:(v,t)=>rd(t)}), '<b>a command grammar, not a button per command.</b> VERB is the action, NOUN is the operand: press VERB 3 5 ENTR for the lamp test. Wrong grammar lights OPR ERR. Two keys plus digits reach every function — the Apollo answer to a panel with too few buttons.'); card(C,'R42','Cam-timer program drum', - (st,rd)=>GW.camTimer(st,{position:0,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.camTimer(st,{position:0,onChange:(v,t)=>rd(t)}), '<b>a procedure as a rotating position.</b> The ring is the whole program — fill, wash, rinse, spin — and the pointer is where you are in it. Click to advance (or start from OFF); it then walks itself through the steps. After a washing-machine cam timer.'); card(C,'R48','Knife switch', - (st,rd)=>GW.knifeSwitch(st,{closed:true,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.knifeSwitch(st,{closed:true,onChange:(v,t)=>rd(t)}), '<b>the visible disconnect.</b> Copper blades either sit in their jaws or hang open in the air — you can SEE the circuit is broken, which is the entire safety argument. Click the handle to throw it. After early power-station switchboards.'); card(C,'R49','Decade switch box', - (st,rd)=>GW.decadeBox(st,{digits:[3,5,0,0],onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.decadeBox(st,{digits:[3,5,0,0],onChange:(v,t)=>rd(t)}), '<b>a number composed a digit at a time.</b> Four skirted knobs, one per decade — drag each to its digit and the total just IS their sum. The thumbwheel does one digit; this is how a lab built a precise value. After a General Radio decade resistance box.'); card(C,'R50','Two-hand safety control', - (st,rd)=>GW.twoHandSafety(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.twoHandSafety(st,{onChange:(v,t)=>rd(t)}), '<b>both hands, deliberately.</b> Click one palm button to arm the window, the other within half a second to cycle the press. Too slow — TIE-DOWN fault, start over. The hardware keeps hands out of the die; the grammar survives on screen. After OSHA press controls.'); card(C,'R51','Voice-loop keyset', - (st,rd)=>GW.voiceLoop(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.voiceLoop(st,{onChange:(v,t)=>rd(t)}), '<b>attention management, not selection.</b> Each loop is independent: click to monitor (green bar, flickering with activity), again to talk (amber, exclusive — one voice out), again to drop. Flight controllers ran a dozen of these at once. After a mission-control comm keyset.'); card(C,'R57','ABC entry keypad', - (st,rd)=>GW.abcKeypad(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.abcKeypad(st,{onChange:(v,t)=>rd(t)}), '<b>letters on a faceplate.</b> A-Z laid out alphabetically, not QWERTY — the industrial convention wherever the operator is not assumed to touch-type. Click the keys, or click the plate and type: it takes the keyboard once focused, and every key routes through the same handle a click does. DEL takes back one character, CLR wipes the lot, ENT commits. R16 enters digits; this is its alphanumeric sibling, and the only card in the kit that takes free text. After fleet and kiosk keypads: the membrane plate\'s colour-coded CLR / ENT, the stainless plate\'s letters-left arrangement and its backspace. Its CANCEL is dropped — that plate is a whole terminal with a transaction to abandon, where this is one control in a panel that owns its own dismiss.'); card(C,'R58','Index typewriter', - (st,rd)=>GW.indexPlate(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.indexPlate(st,{onChange:(v,t)=>rd(t)}), '<b>type without a keyboard.</b> One hand walks the stylus over a printed plate, the other pulls the lever, and only the lever prints — hunt as long as you like and nothing happens until the second hand moves. Select and commit on two separate controls, which no other card in the kit does. The plate is the AEG Mignon Model 4\'s own (1924), transcribed from the reference: both full cases so there is no shift key, the accents and fractions a keyboard skips, and two economies worth noticing. There is <b>no 1 and no 0</b> — you type them with lowercase l and capital O. And the inversion is positional, not by case: the capitals block is dark discs on a light panel and the lowercase block is its photographic negative, but J and j sit out on the halftone ring, un-inverted, with the punctuation.'); /* ============ METERS & GAUGES ============ */ const M=$('meters'); card(M,'10','Needle gauge', - (st,rd)=>GW.needleGauge(st,{value:50,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.needleGauge(st,{value:50,onChange:(v,t)=>rd(t)}), '<b>analog dial.</b> Throughput, battery, volume. Drag up/down to sweep the needle; readout tracks. <b>Smooth sweep wants a Cairo/GTK area.</b>'); card(M,'11','Stereo VU (LED bar)', - (st,rd)=>GW.vuPair(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.vuPair(st,{onChange:(v,t)=>rd(t)}), '<b>live signal level.</b> The sound panel meter row, peak-hold outline. Runs a live signal; readout shows L/R.'); card(M,'12','Mini signal (4-bar)', - (st,rd)=>GW.miniSig(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.miniSig(st,{onChange:(v,t)=>rd(t)}), '<b>compact activity.</b> Per-row "is this device playing". Live signal; readout shows activity %.'); card(M,'13','Signal ladder', - (st,rd)=>GW.signalLadder(st,{value:3,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.signalLadder(st,{value:3,onChange:(v,t)=>rd(t)}), '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Click to cycle strength; readout shows bars.'); card(M,'14','Linear fuel bar', - (st,rd)=>{const a=GW.fuelBar(st,{value:72,onChange:(v,t)=>rd(t)}); - const b=GW.fuelBar(st,{value:40,onChange:(v,t)=>rd(t)}); + (st,rd)=>{const a=DUPRE.fuelBar(st,{value:72,onChange:(v,t)=>rd(t)}); + const b=DUPRE.fuelBar(st,{value:40,onChange:(v,t)=>rd(t)}); a.set(72);return {a,b};}, '<b>a single 0-100.</b> Battery, disk, download. Drag either bar; warn tint under threshold; readout tracks.'); card(M,'15','Radial ring', - (st,rd)=>GW.radialRing(st,{value:68,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.radialRing(st,{value:68,onChange:(v,t)=>rd(t)}), '<b>percentage as a donut.</b> CPU, battery. Drag up/down to set; conic-gradient tracks the readout.'); card(M,'16','Sparkline', - (st,rd)=>GW.sparkline(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.sparkline(st,{onChange:(v,t)=>rd(t)}), '<b>recent history.</b> Throughput/CPU over the last minute. Live trace; readout shows the current value.'); card(M,'17','Waveform strip', - (st,rd)=>GW.waveStrip(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.waveStrip(st,{onChange:(v,t)=>rd(t)}), '<b>audio waveform / scope.</b> A richer signal view. Live trace; readout shows amplitude. <b>Needs a drawing surface.</b>'); card(M,'N11','Oscilloscope', - (st,rd)=>GW.scope(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.scope(st,{onChange:(v,t)=>rd(t)}), '<b>a live phosphor trace.</b> A waveform swept over a graticule. Live; readout shows Vpp. <b>Needs a Cairo/GTK area for a smooth trace.</b>'); card(M,'N12','Spectrum / EQ bars', - (st,rd)=>GW.eqBars(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.eqBars(st,{onChange:(v,t)=>rd(t)}), '<b>level across bands.</b> A live multi-band bar graph — spectrum, per-core CPU. Live; readout shows the peak band.'); card(M,'N13','Crossed-needle meter', - (st,rd)=>GW.crossNeedle(st,{value:55,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.crossNeedle(st,{value:55,onChange:(v,t)=>rd(t)}), '<b>two readings, one face.</b> Forward + reflected needles cross. Drag up/down to drive power; readout shows both. <b>Round build wants a drawing surface.</b>'); card(M,'N14','Thermometer column', - (st,rd)=>GW.thermometer(st,{value:58,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.thermometer(st,{value:58,onChange:(v,t)=>rd(t)}), '<b>a rising column.</b> The classic mercury tube — temperature, a climbing load. Drag up/down; readout shows the value.'); card(M,'N15','Bourdon pressure gauge', - (st,rd)=>GW.bourdon(st,{value:45,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.bourdon(st,{value:45,onChange:(v,t)=>rd(t)}), '<b>a round dial with a red zone.</b> Needle over a printed arc, redline in terracotta. Drag to set PSI; readout tracks. <b>Round sweep is a drawing-area job.</b>'); card(M,'N16','Strip-chart recorder', - (st,rd)=>GW.stripChart(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.stripChart(st,{onChange:(v,t)=>rd(t)}), '<b>a scrolling pen trace.</b> History scrolls left, the pen writes at the right edge. Live; readout shows the current value. <b>Real trace wants a drawing surface.</b>'); card(M,'N17','Correlation meter (±)', - (st,rd)=>GW.corrMeter(st,{value:58,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.corrMeter(st,{value:58,onChange:(v,t)=>rd(t)}), '<b>deviation from center.</b> The needle rests at 0 and swings ±. Drag left/right; readout shows the signed value.'); card(M,'N18','Battery-cell gauge', - (st,rd)=>GW.battCells(st,{value:62,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.battCells(st,{value:62,onChange:(v,t)=>rd(t)}), '<b>charge as discrete cells.</b> Filled cells count up, the last warn in terracotta. Drag left/right to set; readout shows %.'); card(M,'R01','Moving-coil VU meter', - (st,rd)=>GW.mcVu(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.mcVu(st,{onChange:(v,t)=>rd(t)}), '<b>the classic cream-faced VU.</b> Printed dB arc, red zone past 0, ballistic needle. Runs the live signal; readout shows VU. After a vintage console meter pair.'); card(M,'R07','Round panel meter', - (st,rd)=>GW.roundMeter(st,{value:50,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.roundMeter(st,{value:50,onChange:(v,t)=>rd(t)}), '<b>the porthole lab meter.</b> Cream dial in a round black bezel, printed dB arc, black needle. Drag up/down to drive it; readout shows dB. After a modulator front meter.'); card(M,'R08','Chrome MIN/MAX indicator', - (st,rd)=>GW.chromeMinMax(st,{value:50,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.chromeMinMax(st,{value:50,onChange:(v,t)=>rd(t)}), '<b>the hi-fi trim window.</b> Polished chrome ring, brushed dome, a dark pointer sweeping MIN to MAX over the metal. Drag up/down; readout shows the position. After a hi-fi amplifier level window.'); card(M,'R09','Black-face aviation gauge', - (st,rd)=>GW.aviationGauge(st,{value:52,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.aviationGauge(st,{value:52,onChange:(v,t)=>rd(t)}), '<b>amber on black, zone-marked.</b> The cockpit round: amber ticks and numerals on a black face, painted caution and limit sectors, amber needle. Drag up/down; readout tracks. After a flight-instrument cluster.'); card(M,'R13','Edgewise strip meter', - (st,rd)=>GW.edgeMeter(st,{value:.62,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.edgeMeter(st,{value:.62,onChange:(v,t)=>rd(t)}), '<b>a needle on a vertical scale.</b> The parchment strip reads attenuation down a compressed log scale; the bar rides its edge. Drag up/down; readout shows dB. After a channel-strip gain-reduction meter.'); card(M,'R43','Attitude indicator', - (st,rd)=>GW.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.attitudeIndicator(st,{onChange:(v,t)=>rd(t)}), '<b>orientation, not a number.</b> The horizon rolls and shifts behind the fixed amber airplane — two axes in one read. Drag it: left/right banks, up/down pitches. The first two-axis instrument in the kit. After a cockpit artificial horizon.'); card(M,'R44','Heading bug + servo needle', - (st,rd)=>GW.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.headingBug(st,{value:90,onChange:(v,t)=>rd(t)}), '<b>commanded versus actual on one dial.</b> Drag to park the amber bug where you want to go; the needle chases it with honest servo lag. The gap between them IS the information. After an HSI heading bug and synchro repeaters.'); card(M,'R54','Vertical tape instrument', - (st,rd)=>GW.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.verticalTape(st,{value:24,onChange:(v,t)=>rd(t)}), '<b>the scale moves, the index does not.</b> A scrolling tape behind a fixed amber index — your value is always at eye level and the neighborhood above and below stays visible. Drag to spin the tape. After the Ki-57 remote tachometer; modern cockpits tape everything.'); card(M,'R55','Twin-needle gauge', - (st,rd)=>GW.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.twinNeedle(st,{fuel:2.4,oil:3.1,onChange:(v,t)=>rd(t)}), '<b>two values, one housing, mirrored scales.</b> Fuel presses up the left arc, oil up the right, each with its own needle from the shared hub. Drag either half to set that side. Not the crossed-needle (N13) — these are read separately, not at their intersection. After the Ki-57 fuel/oil pressure gauge.'); card(M,'R56','Comfort-zone crossed needles', - (st,rd)=>GW.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.comfortMeter(st,{temp:72,humidity:45,onChange:(v,t)=>rd(t)}), '<b>two values, one verdict.</b> Temperature and humidity needles cross over a face printed with judgments — the crossing point lands in TOO WARM, TOO DRY, or JUST RIGHT. N13 derives a number from the crossing; this one derives an opinion. Drag each half. After a brass weather-station comfort meter.'); card(M,'R53','Circular chart recorder', - (st,rd)=>GW.chartRecorder(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.chartRecorder(st,{onChange:(v,t)=>rd(t)}), '<b>cyclic time on round paper.</b> The pen sweeps a full day per revolution, so "same time yesterday" is the same angle and daily rhythm draws itself as a flower. Click for fresh paper. The strip chart shows a window; this shows the cycle. After a Partlow round-chart recorder.'); card(M,'R17','Round CRT scope', - (st,rd)=>GW.roundCrt(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.roundCrt(st,{onChange:(v,t)=>rd(t)}), '<b>the round-tube scope.</b> Pale phosphor face in a screwed porthole bezel, dark graticule, a bright live trace. Runs live; readout shows Vpp. After a 1950s bench oscilloscope.'); /* ============ INDICATORS & READOUTS ============ */ const I=$('indicators'); card(I,'18','Status lamp', - (st,rd)=>GW.statusLamps(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.statusLamps(st,{onChange:(v,t)=>rd(t)}), '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. Click any lamp to cycle its state.'); card(I,'19','Badge / tag', - (st,rd)=>GW.badges(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.badges(st,{onChange:(v,t)=>rd(t)}), '<b>a labelled flag.</b> MUTED, AIRPLANE, a band tag. Click a badge to cycle its variant.'); card(I,'20','Tabular readout', - (st,rd)=>GW.tabularReadout(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.tabularReadout(st,{onChange:(v,t)=>rd(t)}), '<b>a precise number.</b> Clock, countdown, volume %. Runs a live countdown; click to pause/resume.'); card(I,'21','Engraved label', - (st,rd)=>GW.engravedLabel(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.engravedLabel(st,{onChange:(v,t)=>rd(t)}), '<b>section divider.</b> The hairline-flanked caps label with a count. Click to bump the count.'); card(I,'22','Output well', - (st,rd)=>GW.outputWell(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.outputWell(st,{onChange:(v,t)=>rd(t)}), '<b>streaming step log.</b> Lamp-per-step with evidence. Click to stream the next step.'); card(I,'23','Toast / status line', - (st,rd)=>GW.toast(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.toast(st,{onChange:(v,t)=>rd(t)}), '<b>transient confirmation.</b> The one-line result after an action. Click to fire the next toast.'); card(I,'26','Nixie tube', - (st,rd)=>GW.nixie(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.nixie(st,{onChange:(v,t)=>rd(t)}), '<b>a single warm-glowing numeral.</b> One lit digit per tube, leading zeros dark. Click to increment the count.'); card(I,'N20','Split-flap display', - (st)=>GW.splitFlap(st,{rows:3,cells:6,words:['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','STATUS','FILTER','TUNING','NEEDLE','SWITCH','TOGGLE','DIMMER','CHROME','MODULE','OUTPUT','SCREEN','COPPER','DYNAMO','PISTON','MAGNET','BEACON','RADIAL','BRIDGE'],animate:!reduced,skin:'white'}), - '<b>flips to the new value.</b> A three-row, six-cell board; every advance sends each row to a different random word from the pool, and each cell runs at its own mechanical rate, so letters and whole words finish at different times. After settling it dwells two seconds, then advances. Click to advance now. Skins: white ink on black (default), the kit cream, the ivory board, or white cards with dark lettering; face in Berkeley Mono or Helvetica. The slider sets the flap rate.'); + (st)=>DUPRE.splitFlap(st,{rows:3,cells:6,words:['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','STATUS','FILTER','TUNING','NEEDLE','SWITCH','TOGGLE','DIMMER','CHROME','MODULE','OUTPUT','SCREEN','COPPER','DYNAMO','PISTON','MAGNET','BEACON','RADIAL','BRIDGE'],animate:!reduced,skin:'paper',font:'helv'}), + '<b>flips to the new value.</b> A three-row, six-cell board; every advance sends each row to a different random word from the pool, and each cell runs at its own mechanical rate, so letters and whole words finish at different times. After settling it dwells two seconds, then advances. Click to advance now. Skins: white cards with dark lettering (default), white ink on black, the kit cream, or the ivory board; face in Helvetica (default) or Berkeley Mono. The slider sets the flap rate.'); card(I,'N21','Seven-segment display', - (st,rd)=>GW.sevenSeg(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.sevenSeg(st,{onChange:(v,t)=>rd(t)}), '<b>a lit-segment number.</b> Green segments, distinct from the nixie glow. Live countdown; click to add a minute.'); card(I,'N22','VFD marquee', - (st,rd)=>GW.vfdMarquee(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.vfdMarquee(st,{onChange:(v,t)=>rd(t)}), '<b>scrolling status line.</b> Teal glow, dot-matrix mesh, text tracks across. Scrolls live; click to change the message.'); card(I,'N23','Annunciator panel', - (st,rd)=>GW.annunciator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.annunciator(st,{onChange:(v,t)=>rd(t)}), '<b>a grid of named alarms with the alarm lifecycle.</b> Cells light amber to warn, red to fault; any active alarm flashes MSTR CAUTION until ACK steadies it, a new alarm re-flashes it, RESET clears the board, TEST proves the bulbs. Click cells to raise alarms.'); card(I,'N24','Jewel pilot lamps', - (st,rd)=>GW.jewels(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.jewels(st,{onChange:(v,t)=>rd(t)}), '<b>a faceted glass indicator.</b> The chunky bezel pilot lamp with a real bloom. Click a jewel to cycle red · amber · green · dark.'); card(I,'N25','Tape counter', - (st,rd)=>GW.tapeCounter(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.tapeCounter(st,{onChange:(v,t)=>rd(t)}), '<b>rolling odometer digits.</b> Numbered wheels roll to the next figure. Rolls live; readout shows the total.'); card(I,'N26','Analog clock', - (st,rd)=>GW.analogClock(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.analogClock(st,{onChange:(v,t)=>rd(t)}), '<b>time on a real face.</b> Engraved ticks, three hands, live seconds sweep. Runs live; readout shows the time.'); card(I,'N27','Frequency-dial scale', - (st,rd)=>GW.freqScale(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.freqScale(st,{onChange:(v,t)=>rd(t)}), '<b>a printed log scale.</b> Marks crowd low, spread high — a real logarithmic axis. Drag the pointer; readout shows the frequency.'); card(I,'N28','Patch-bay jack field', - (st,rd)=>GW.patchBay(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.patchBay(st,{onChange:(v,t)=>rd(t)}), '<b>routing shown as patches.</b> A grid of jacks with cables between connected pairs. Click one jack then another to add/remove a cable.'); card(I,'R10','Data matrix readout', - (st,rd)=>GW.dataMatrix(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dataMatrix(st,{onChange:(v,t)=>rd(t)}), '<b>a page of amber fields.</b> The console data block — several labeled values on one lit matrix. Click to cycle pages; readout names the page. After a cockpit data readout.'); card(I,'R11','Warning flag window', - (st,rd)=>GW.warningFlag(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.warningFlag(st,{onChange:(v,t)=>rd(t)}), '<b>the mechanical alarm flag.</b> A striped barber-pole slides into a window when the condition trips — not a lamp, a flag. Click to trip and clear it. After the VIB flag on an altimeter.'); card(I,'R25','Fourteen-segment display', - (st,rd)=>GW.seg14(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.seg14(st,{onChange:(v,t)=>rd(t)}), '<b>segments that spell.</b> The starburst alphanumeric — diagonals and split bars let it write words, not just digits. Click to cycle the word. After a pedal preset display.'); card(I,'R26','Response graph', - (st,rd)=>GW.responseGraph(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.responseGraph(st,{onChange:(v,t)=>rd(t)}), '<b>a curve on labeled axes.</b> Log frequency across, dB up the side, and the amber peak is yours to place — drag it anywhere on the plot. After the response display of a mastering processor.'); card(I,'R30','Telegraph indicator', - (st,rd)=>GW.telegraphIndicator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.telegraphIndicator(st,{onChange:(v,t)=>rd(t)}), '<b>state on a pie of sectors.</b> The pointer and its little flag name the active state, engine-telegraph style. Click to step the state. After an electric indicator dial.'); card(I,'R31','Radar sweep', - (st,rd)=>GW.radarSweep(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.radarSweep(st,{onChange:(v,t)=>rd(t)}), '<b>the rotating scan.</b> A sweep beam circles the bearing ring, contacts bloom as it passes and fade after. Click to mark the current bearing. After a radar PPI scope.'); card(I,'R35','Day-date disc calendar', - (st,rd)=>GW.dayDateCal(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dayDateCal(st,{onChange:(v,t)=>rd(t)}), '<b>two coaxial discs, one index.</b> The outer ring carries the date, the inner disc the weekday; both read where the yellow hand points. Starts on today; click to roll midnight forward and watch the discs step. After a watch day-date movement.'); card(I,'R36','LED dot matrix', - (st,rd)=>GW.dotMatrix(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dotMatrix(st,{onChange:(v,t)=>rd(t)}), '<b>a bitmap, one red dot at a time.</b> 64 discrete LEDs behind a tinted window — segments draw digits, but a matrix draws anything. Click dots to paint the pattern; the readout counts what is lit. After the K4816 pattern generator faceplate.'); card(I,'R45','Flip-disc tile array', - (st,rd)=>GW.flipDisc(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.flipDisc(st,{onChange:(v,t)=>rd(t)}), '<b>the mechanical pixel.</b> Each disc flips between its yellow face and its black back and then STAYS — no power, no glow, state that persists. Click a disc and watch it flip. The dot matrix glows; this one clacks. After transit flip-dot signs.'); card(I,'R46','Dekatron counting ring', - (st,rd)=>GW.dekatron(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.dekatron(st,{onChange:(v,t)=>rd(t)}), '<b>a count as a position, and the counting is visible.</b> Each click is a pulse; the neon glow steps one cathode around the ring and carries on wrap. Nixies show the digit — the Dekatron shows the arithmetic. After glow-transfer counter tubes.'); card(I,'R47','Landing gear indicator', - (st,rd)=>GW.gearIndicator(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.gearIndicator(st,{onChange:(v,t)=>rd(t)}), '<b>three greens or nothing.</b> A spatial lamp panel: nose and mains each get a light, green only when down and locked; the amber transit pulse means neither state. Click the wheel lever to cycle the gear. After a cockpit gear panel.'); card(I,'R52','Blinkenlights front panel', - (st,rd)=>GW.blinkenlights(st,{onChange:(v,t)=>rd(t)}), + (st,rd)=>DUPRE.blinkenlights(st,{onChange:(v,t)=>rd(t)}), '<b>the machine thinking, out loud.</b> Address and data lamps ripple with the live computation; the switch register below is yours — flip bits and watch the pattern change. DIP switches hold config; this shows work happening. After a PDP-11/70 front panel.'); /* ============ CONTROL WIRING ============ */ /* 01 toggle */ -/* 01-09, 24, 25, N01-N10 extracted to widgets.js (GW.*) — wired at their card records */ +/* 01-09, 24, 25, N01-N10 extracted to widgets.js (DUPRE.*) — wired at their card records */ /* ============ METER WIRING (drag-driven) ============ */ -/* 10-17, N11-N18 extracted to widgets.js (GW.*) — wired at their card records */ +/* 10-17, N11-N18 extracted to widgets.js (DUPRE.*) — wired at their card records */ /* ============ INDICATOR WIRING ============ */ -/* 18-23, 26 extracted to widgets.js (GW.*) — wired at their card records */ -/* N20-N28 extracted to widgets.js (GW.*) — wired at their card records */ +/* 18-23, 26 extracted to widgets.js (DUPRE.*) — wired at their card records */ +/* N20-N28 extracted to widgets.js (DUPRE.*) — wired at their card records */ -/* ============ REFERENCE-BATCH (R) WIDGETS — SVG builders ============ */ -/* svgEl, polar, VUDB live in widgets.js (GW) */ +/* ============ REFERENCE-BATCH (R) INSTRUMENTS — SVG builders ============ */ +/* svgEl, polar, VUDB live in widgets.js (DUPRE) */ /* R01 ballistics: the page owns the signal chase; the meter just takes set(t) */ function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12); const t=MH.mcvu.get();MH.mcvu.set(t+(target-t)*(target>t?0.32:0.10));} -/* R02-R06, R12, R14, R15 extracted to widgets.js (GW.*) */ +/* R02-R06, R12, R14, R15 extracted to widgets.js (DUPRE.*) */ @@ -1669,26 +1669,26 @@ function tickMcVu(){const a=lvl();const target=Math.min(1.02,a*1.12); -/* R07-R09 extracted to widgets.js (GW.*) */ +/* R07-R09 extracted to widgets.js (DUPRE.*) */ -/* R10, R11 extracted to widgets.js (GW.*) */ +/* R10, R11 extracted to widgets.js (DUPRE.*) */ -/* R13, R17 extracted to widgets.js (GW.*) */ +/* R13, R17 extracted to widgets.js (DUPRE.*) */ -/* R25, R26, R30, R31 extracted to widgets.js (GW.*) */ +/* R25, R26, R30, R31 extracted to widgets.js (DUPRE.*) */ /* ============ LIVE SIGNAL LOOPS ============ */ /* extracted meters: the page keeps the clock + demo signal, drives card handles */ -const MH={vu:$('card-11').gw,mini:$('card-12').gw,spark:$('card-16').gw,wave:$('card-17').gw, - scope:$('card-N11').gw,eq:$('card-N12').gw,strip:$('card-N16').gw,mcvu:$('card-R01').gw}; -const IH={flap:$('card-N20').gw,seven:$('card-N21').gw,vfd:$('card-N22').gw, - counter:$('card-N25').gw,clock:$('card-N26').gw}; +const MH={vu:$('card-11').dupre,mini:$('card-12').dupre,spark:$('card-16').dupre,wave:$('card-17').dupre, + scope:$('card-N11').dupre,eq:$('card-N12').dupre,strip:$('card-N16').dupre,mcvu:$('card-R01').dupre}; +const IH={flap:$('card-N20').dupre,seven:$('card-N21').dupre,vfd:$('card-N22').dupre, + counter:$('card-N25').dupre,clock:$('card-N26').dupre}; let ph=0; const eqBands=Array.from({length:11},(_,i)=>({v:0.4,ph:i*0.6})); @@ -1716,7 +1716,7 @@ const FLAP_DWELL_MS=2000; let flapTimer; // a fixed interval would retarget mid-cascade and the panel would never rest. if(!reduced)IH.flap.onSettle(()=>{clearTimeout(flapTimer);flapTimer=setTimeout(()=>IH.flap.next(),FLAP_DWELL_MS);}); function tickSeven(){IH.seven.tick();} -function tickT(){$('card-20').gw.tick();} +function tickT(){$('card-20').dupre.tick();} function tickVfd(){IH.vfd.tick();} function tickCounter(){IH.counter.set(IH.counter.get()+137);} @@ -1742,35 +1742,35 @@ if(!reduced){ setInterval(tickCounter,1600); setInterval(tickSeven,1000); setInterval(tickT,1000); - document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='running'); + document.querySelectorAll('.dupre-reel.dupre-spin').forEach(r=>r.style.animationPlayState='running'); }else{ paintStatic(); - document.querySelectorAll('.reel.spin').forEach(r=>r.style.animationPlayState='paused'); + document.querySelectorAll('.dupre-reel.dupre-spin').forEach(r=>r.style.animationPlayState='paused'); } -/* R35, R36 extracted to widgets.js (GW.*) */ +/* R35, R36 extracted to widgets.js (DUPRE.*) */ -/* R43, R44 extracted to widgets.js (GW.*) */ +/* R43, R44 extracted to widgets.js (DUPRE.*) */ -/* R45-R47, R52 extracted to widgets.js (GW.*) */ +/* R45-R47, R52 extracted to widgets.js (DUPRE.*) */ -/* R53-R56 extracted to widgets.js (GW.*) */ +/* R53-R56 extracted to widgets.js (DUPRE.*) */ -/* ---- screen-family chips: first round, the clearly-screen widgets ---- - Defaults match each widget's shipped color (var fallbacks), so no family is applied until a chip is clicked. +/* ---- screen-family chips: first round, the clearly-screen instruments ---- + Defaults match each instrument's shipped color (var fallbacks), so no family is applied until a chip is clicked. The nixie stays chipless on purpose: neon is only ever orange, so alternates would be historically false. */ -screenChips('R10',$('card-R10').gw.el,['amber','green','red','blue','vfd'],'amber'); -screenChips('R17',$('card-R17').gw.el,['green','amber','red','blue','vfd'],'green'); -screenChips('R19',$('card-R19').gw.el,['white','green','amber','blue','vfd'],'white'); -screenChips('R31',$('card-R31').gw.el,['amber','green','red','blue','vfd'],'amber'); -screenChips('N11',$('card-N11').gw.el,['green','amber','red','blue','vfd'],'green'); +screenChips('R10',$('card-R10').dupre.el,['amber','green','red','blue','vfd'],'amber'); +screenChips('R17',$('card-R17').dupre.el,['green','amber','red','blue','vfd'],'green'); +screenChips('R19',$('card-R19').dupre.el,['white','green','amber','blue','vfd'],'white'); +screenChips('R31',$('card-R31').dupre.el,['amber','green','red','blue','vfd'],'amber'); +screenChips('N11',$('card-N11').dupre.el,['green','amber','red','blue','vfd'],'green'); /* all six, where its siblings each carry five: a passphrase window has no reason to prefer one phosphor, so it offers the whole set including the marquee cyan */ -screenChips('R57',$('card-R57').gw.el,['amber','green','red','blue','vfd','white'],'amber'); +screenChips('R57',$('card-R57').dupre.el,['amber','green','red','blue','vfd','white'],'amber'); BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no); if(rd)rd.closest('.card').querySelector('.stagew').classList.add('boost');}); @@ -1786,16 +1786,16 @@ BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no); PRESETS (optional) adds a leading group of named combinations: a preset chip applies all axes at once and resyncs the axis chips beneath it, and changing any axis afterwards clears the preset — the chips must never claim a preset - the widget has since diverged from. */ + the instrument has since diverged from. */ function styleChips(no,STYLES,AXES,PRESETS,defPreset){ - const h=$('card-'+no)?.gw; if(!h)return; + const h=$('card-'+no)?.dupre; if(!h)return; const cardEl=h.el.closest('.card'); const row=document.createElement('div'); row.className='famchips'; const sel={}, sync={}, groups=[]; let clearPreset=()=>{}; /* Re-apply every axis on any change rather than just the one clicked: axes are not independent (onText overrides the ink `on` sets), so a lone setStyle can drop a selection the chips still show as lit — the card would then display a - combination the widget isn't in. Applying all of them in AXES order makes + combination the instrument isn't in. Applying all of them in AXES order makes the outcome independent of click order. */ const applyAll=()=>{for(const [,axis] of AXES) if(sel[axis]) h.setStyle(axis,sel[axis]);}; for(const [label,axis,def] of AXES){ @@ -1829,21 +1829,21 @@ function styleChips(no,STYLES,AXES,PRESETS,defPreset){ groups.forEach(g=>row.appendChild(g)); cardEl.querySelector('.opts').appendChild(row); } -styleChips('N20',GW.splitFlap.STYLES,[['skin','skin','white'],['font','font','mono']]); -(()=>{const h=$('card-N20').gw,row=document.querySelector('#card-N20 .famchips');if(!h||!row)return; +styleChips('N20',DUPRE.splitFlap.STYLES,[['skin','skin','paper'],['font','font','helv']]); +(()=>{const h=$('card-N20').dupre,row=document.querySelector('#card-N20 .famchips');if(!h||!row)return; const gr=document.createElement('span');gr.className='fgroup'; - gr.innerHTML='<span class="lab">flap</span><input type="range" min="30" max="160" value="85" style="width:70px;accent-color:var(--gold);vertical-align:middle"><span class="lab fms">85ms</span>'; + gr.innerHTML='<span class="lab">flap</span><input type="range" min="30" max="160" value="100" style="width:70px;accent-color:var(--gold);vertical-align:middle"><span class="lab fms">100ms</span>'; const sl=gr.querySelector('input'),ms=gr.querySelector('.fms'); sl.addEventListener('input',()=>{h.setFlapMs(+sl.value);ms.textContent=sl.value+'ms';}); row.appendChild(gr);})(); -styleChips('01',GW.slideToggle.STYLES, +styleChips('01',DUPRE.slideToggle.STYLES, [['on','on','amber'],['on text','onText','panel'],['off','off','dark'], ['off text','offText','white'],['thumb','thumb','light']], - GW.slideToggle.PRESETS,'panel'); -styleChips('R05',GW.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]); -styleChips('06',GW.segmented.STYLES,[['accent','accent','amber']]); -styleChips('07',GW.chipToggle.STYLES,[['accent','accent','amber']]); -styleChips('25',GW.slideRule.STYLES,[['face','skin','warm']]); + DUPRE.slideToggle.PRESETS,'panel'); +styleChips('R05',DUPRE.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]); +styleChips('06',DUPRE.segmented.STYLES,[['accent','accent','amber']]); +styleChips('07',DUPRE.chipToggle.STYLES,[['accent','accent','amber']]); +styleChips('25',DUPRE.slideRule.STYLES,[['face','skin','warm']]); /* final tally pass: setV fires per card during build, but each card is still detached at that moment, so the running counts lag by one — recount now */ diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 0126949..57ad33e 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -1,12 +1,12 @@ -/* widgets.js — retro-instrument widget library (GW namespace). +/* widgets.js — The Dupre Kit: retro-instrument component library (DUPRE namespace). Classic script: load after the token :root block (tokens.json → gen_tokens.py), - before any GW.* call. Works from file:// — no modules, no build step. + before any DUPRE.* call. Works from file:// — no modules, no build step. Contract: each gallery card in panel-widget-gallery.html is the visual + behavioral spec its builder is judged against. Spec: docs/specs/2026-07-12-component-generation-spec.org */ (function () { 'use strict'; -const GW = {}; +const DUPRE = {}; /* ================= shared engine ================= */ @@ -107,19 +107,19 @@ const SCREEN_FAMS = { '--crt-face1': '#d0d2d0', '--crt-face2': '#a8aaa8', '--crt-glow': '#f7f7f7' }, }; -/* ================= widget builders ================= - Every builder: GW.name(host, opts) → handle. host is an empty element the - widget renders into. opts.onChange(value, text) fires on every state change, - including the initial paint; text is the widget's canonical readout string. */ +/* ================= instrument builders ================= + Every builder: DUPRE.name(host, opts) → handle. host is an empty element the + instrument renders into. opts.onChange(value, text) fires on every state change, + including the initial paint; text is the instrument's canonical readout string. */ const noop = () => {}; /* ---- the accent family ---- - One named set of lit colours for the widgets whose colour IS their claim: a + One named set of lit colours for the instruments whose colour IS their claim: a chip, a lamp, a badge, a status line. "On" is good in one panel, a warning in the next and a fault in the one after, so the colour belongs to the consumer rather than the builder. - Deliberately NOT applied to widgets whose colour is the object rather than a + Deliberately NOT applied to instruments whose colour is the object rather than a state — the nixie (the palette is explicit that neon is only ever orange), the flip-disc's yellow, the dekatron's glow, a red needle — nor to the ones where the colour is a standard rather than a preference: three greens on the landing @@ -134,60 +134,60 @@ const ACCENTS = { white: 'var(--cream)', vfd: 'var(--vfd)', }; -GW.accentStyles = varName => Object.fromEntries( +DUPRE.accentStyles = varName => Object.fromEntries( Object.entries(ACCENTS).map(([name, colour]) => [name, { dot: colour, vars: { [varName]: colour } }])); /* ---- policy ---- - Every widget's colour is either the consumer's to pick or locked for a reason, - and the reason is one of these six kinds. Declared per builder (GW.<name>.POLICY) + Every instrument's colour is either the consumer's to pick or locked for a reason, + and the reason is one of these six kinds. Declared per builder (DUPRE.<name>.POLICY) so it's a checked property rather than something the next person recalls — the colour pass kept turning up cards where "is this one a standard?" was answered from memory and answered wrong. The two FREE kinds get chips; the four LOCKED ones must not, and the probe enforces the split. - Policy is a property of the WIDGET, not the colour: the same vfd cyan is a free + Policy is a property of the INSTRUMENT, not the colour: the same vfd cyan is a free accent on a chip and a locked emissive identity on the marquee. A few cards are mixed (a free display with a coded red-line) and declare per element in time. Each builder's POLICY is a record { kind, why, authentic }: kind — one of the six below. - why — why THIS widget is bound that way, in its own terms. + why — why THIS instrument is bound that way, in its own terms. authentic — what may change and still be true to the reference (the range that actually existed), or 'nothing' when the colour is fixed. */ -GW.POLICIES = { +DUPRE.POLICIES = { accent: { free: true, gist: 'A lit state whose meaning varies by panel; the consumer picks from the accent family.' }, screen: { free: true, gist: 'A display whose phosphor was made in several real colours; the consumer picks from the screen family.' }, coded: { free: false, gist: 'Colour is meaning fixed by an external standard; a recolour misleads a trained operator (landing-gear three-greens, breaker trip).' }, emissive: { free: false, gist: 'Colour is what the physical source emits and no other was made; unrecognisable otherwise (nixie neon, dekatron glow).' }, - relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same widget; the set is a scale or legend (VU zones, crossed needles).' }, + relational: { free: false, gist: 'Colour means what it does only by contrast with another on the same instrument; the set is a scale or legend (VU zones, crossed needles).' }, material: { free: false, gist: 'Colour of a static physical part, not a state — nothing to parameterise as a signal (needle, brass bezel, knife blade).' }, }; /* 01 slide toggle — on/off pill. State colors ride CSS vars (--sw-*). opts.onStyle / offStyle / offText / thumb pick a named style per axis from - GW.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's + DUPRE.slideToggle.STYLES; defaults match the stylesheet fallbacks. The handle's setStyle(axis, name) restyles a live instance (the gallery chips use it). */ -GW.slideToggle = function (host, opts = {}) { +DUPRE.slideToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const sw = document.createElement('span'); sw.className = 'switch'; host.appendChild(sw); const setStyle = (axis, name) => { - const o = (GW.slideToggle.STYLES[axis] || {})[name]; + const o = (DUPRE.slideToggle.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) sw.style.setProperty(k, v); }; /* opts.preset names an intent; per-axis opts still win over it, so a caller can take 'armed' and swap only the thumb. setPreset re-applies all four axes at once and returns the axis map so a host UI can resync its own controls. */ - const preset = GW.slideToggle.PRESETS[opts.preset] || GW.slideToggle.PRESETS.panel; + const preset = DUPRE.slideToggle.PRESETS[opts.preset] || DUPRE.slideToggle.PRESETS.panel; /* AXIS_ORDER is load-bearing, not cosmetic: onText overrides the ink that the `on` style sets, so applying the axes in any other order silently drops it. */ const setPreset = name => { - const p = GW.slideToggle.PRESETS[name]; + const p = DUPRE.slideToggle.PRESETS[name]; if (!p) return null; - for (const axis of GW.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]); + for (const axis of DUPRE.slideToggle.AXIS_ORDER) if (p[axis]) setStyle(axis, p[axis]); return p; }; const pick = { on: opts.onStyle, onText: opts.onText, off: opts.offStyle, offText: opts.offText, thumb: opts.thumb }; - for (const axis of GW.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]); + for (const axis of DUPRE.slideToggle.AXIS_ORDER) setStyle(axis, pick[axis] || preset[axis]); let on; const set = v => { on = !!v; sw.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; sw.addEventListener('click', () => set(!on)); @@ -198,8 +198,8 @@ GW.slideToggle = function (host, opts = {}) { actually reaches for; the STYLES axes below are how one is built. The `on` tone does the semantic work and the thumb material backs it: a touchscreen pill wears plastic, a run or armed switch wears metal. */ -GW.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb']; -GW.slideToggle.PRESETS = { +DUPRE.slideToggle.AXIS_ORDER = ['on', 'onText', 'off', 'offText', 'thumb']; +DUPRE.slideToggle.PRESETS = { panel: { on: 'amber', onText: 'panel', off: 'dark', offText: 'white', thumb: 'light' }, run: { on: 'green', onText: 'panel', off: 'dark', offText: 'dim', thumb: 'chrome' }, armed: { on: 'red', onText: 'cream', off: 'dark', offText: 'white', thumb: 'chrome' }, @@ -210,7 +210,7 @@ GW.slideToggle.PRESETS = { dark: { on: 'dark', onText: 'green', off: 'dark', offText: 'red', thumb: 'chrome' }, }; /* named styles per axis: dot = swatch color for pickers, vars = --sw-* overrides */ -GW.slideToggle.STYLES = { +DUPRE.slideToggle.STYLES = { on: { amber: { dot: 'var(--gold-hi)', vars: { '--sw-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--sw-on-brd': 'var(--gold-hi)', '--sw-on-ink': 'var(--panel)' } }, green: { dot: '#8fb944', vars: { '--sw-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--sw-on-brd': '#a9c95f', '--sw-on-ink': 'var(--panel)' } }, @@ -258,21 +258,28 @@ GW.slideToggle.STYLES = { tone picks the engaged look: undefined = gold (the default lit key), 'green' = run green, 'red' = terracotta. Reading order runs safe -> live -> muted, and LIVE takes green because that is what --pass means everywhere else in the kit - (the palette names it "run lamps, gear greens, monitor bars, LIVE lamps"). */ -GW.consoleKeys = function (host, opts = {}) { + (the palette names it "run lamps, gear greens, monitor bars, LIVE lamps"). + Contract (everything a consumer needs; no page globals touched): + opts: keys ([{label, tone}], default DEFAULT_KEYS); active (index; the + default set rests on LIVE, a caller's own set on its first key); + onChange(idx, label) fires on every engage. + handle: el, get() (engaged index), set(i) (clamped). + CSS lives in the "console keys" block of DUPRE_CSS; dsky and annunciator + reuse the key class for their pads. */ +DUPRE.consoleKeys = function (host, opts = {}) { const onChange = opts.onChange || noop; - const keys = opts.keys || GW.consoleKeys.DEFAULT_KEYS; + const keys = opts.keys || DUPRE.consoleKeys.DEFAULT_KEYS; const wrap = document.createElement('span'); host.appendChild(wrap); const btns = keys.map(k => { - const b = document.createElement('button'); b.className = 'key'; b.textContent = k.label; + const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k.label; wrap.appendChild(b); return b; }); let idx; const set = i => { idx = Math.max(0, Math.min(keys.length - 1, i)); btns.forEach((b, j) => { - b.classList.remove('on', 'green', 'red'); - if (j === idx) b.classList.add(keys[idx].tone || 'on'); + b.classList.remove('dupre-on', 'dupre-green', 'dupre-red'); + if (j === idx) b.classList.add('dupre-' + (keys[idx].tone || 'on')); }); onChange(idx, keys[idx].label); }; @@ -281,10 +288,10 @@ GW.consoleKeys = function (host, opts = {}) { resting state for a console and it shows the green. A caller supplying its own keys gets the first one unless it says otherwise. */ set(opts.active !== undefined ? opts.active - : (keys === GW.consoleKeys.DEFAULT_KEYS ? 1 : 0)); + : (keys === DUPRE.consoleKeys.DEFAULT_KEYS ? 1 : 0)); return { el: wrap, get: () => idx, set }; }; -GW.consoleKeys.DEFAULT_KEYS = [ +DUPRE.consoleKeys.DEFAULT_KEYS = [ { label: 'SCAN' }, { label: 'LIVE', tone: 'green' }, { label: 'MUTED', tone: 'red' }, ]; @@ -302,8 +309,16 @@ GW.consoleKeys.DEFAULT_KEYS = [ CLEAR/NO and ENTER/YES keep the reference's red and green, which are already --fail and --pass. - Letters left, digits right (the stainless reference's arrangement). - - DEL added, CANCEL dropped. See the layout block below for both. */ -GW.abcKeypad = function (host, opts = {}) { + - DEL added, CANCEL dropped. See the layout block below for both. + Contract (everything a consumer needs; no page globals touched): + opts: max (16, buffer cap); onChange(buf, label) on every press — ENT + reports 'ENTER · <buf>', DEL/CLR report the remaining buffer. + handle: el (focusable; keyboard per KEYS), get() (the buffer), press(k) + (any ACTIONS member: plate characters, SPC, DEL, ENT, CLR). + CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the + faceplate itself is drawn SVG); the entry window recolours with the shared + --scr-* screen vars. */ +DUPRE.abcKeypad = function (host, opts = {}) { const onChange = opts.onChange || noop; const MAX = opts.max || 16; const s = stageSvg(host, 'rsvg', 232, 226); @@ -318,7 +333,7 @@ GW.abcKeypad = function (host, opts = {}) { screen families through the --scr-* vars, shipped colours as the fallbacks: nothing moves until a chip is clicked. Both the glass and the ink recolour — a screen that changes its text and keeps its backlight isn't a screen. */ - svgEl(s, 'rect', { class: 'kp-win', x: 12, y: 10, width: 208, height: 30, rx: 4, + svgEl(s, 'rect', { class: 'dupre-kp-win', x: 12, y: 10, width: 208, height: 30, rx: 4, fill: 'var(--scr-bg1, #0a0806)', stroke: 'var(--scr-brd, #2c261d)', 'stroke-width': 2 }); const disp = svgEl(s, 'text', { x: 20, y: 31, 'font-size': 14, 'letter-spacing': '.14em', 'font-family': 'var(--mono)', fill: 'var(--scr-hi, var(--gold-hi))' }); @@ -342,7 +357,7 @@ GW.abcKeypad = function (host, opts = {}) { Gated on ACTIONS rather than on KEYS because they are different sets — CLR is a real key on the plate that no keystroke maps to. */ const press = k => { - if (!GW.abcKeypad.ACTIONS.has(k)) return; + if (!DUPRE.abcKeypad.ACTIONS.has(k)) return; if (k === 'ENT') { onChange(buf, buf ? 'ENTER · ' + buf : 'empty'); return; } if (k === 'DEL') { buf = buf.slice(0, -1); render(); onChange(buf, buf || 'empty'); return; } if (k === 'CLR') { buf = ''; render(); onChange(buf, 'cleared'); return; } @@ -392,7 +407,7 @@ GW.abcKeypad = function (host, opts = {}) { const jitter = i => [-1.4, .9, -.6, 1.2, 0, -1.1, .7, -.9, 1.3, .4, -.5, 1][i % 12]; L.forEach(([k, c, r, w, tone], i) => { const x = 14 + c * 35.5, y = 50 + r * 24.5, wd = w * 35.5 - 5.5; - const g = svgEl(s, 'g', {}); g.setAttribute('class', 'kp-key'); g.dataset.k = k; + const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-kp-key'); g.dataset.k = k; g.style.cursor = 'pointer'; g.style.transition = 'transform .07s'; svgEl(g, 'rect', { x, y, width: wd, height: 20, rx: 3.5, fill: FILL[tone], stroke: '#4e4a42', 'stroke-width': 1, 'stroke-opacity': .8 }); const t = svgEl(g, 'text', { x: x + wd / 2, y: y + 14.5, 'text-anchor': 'middle', @@ -408,14 +423,14 @@ GW.abcKeypad = function (host, opts = {}) { /* Keyboard, per the README's keyboard contract. The listener is bound to the pad's own focusable element, never to the document: a global binding would type into this card from anywhere on a 110-card page and fight the gallery's - own Escape handler. GW.slideRule is the precedent. */ - s.setAttribute('class', 'rsvg kp-pad'); + own Escape handler. DUPRE.slideRule is the precedent. */ + s.setAttribute('class', 'rsvg dupre-kp-pad'); s.setAttribute('tabindex', '0'); s.addEventListener('click', () => s.focus()); s.addEventListener('keydown', e => { if (e.ctrlKey || e.metaKey || e.altKey) return; /* leave shortcuts alone */ const name = e.key === ' ' ? 'Space' : (e.key.length === 1 ? e.key.toUpperCase() : e.key); - const k = GW.abcKeypad.KEYS[name]; + const k = DUPRE.abcKeypad.KEYS[name]; if (!k) return; /* not on the plate: let it bubble */ /* Spend preventDefault only where there is a default worth killing — Space scrolls the page, Backspace can navigate back. Tab and Escape are never @@ -430,10 +445,10 @@ GW.abcKeypad = function (host, opts = {}) { /* The plate's keys, declared as a table so every target reads the same intent. Deliberately not a function over a DOM event: the Emacs port installs this into a keymap and never sees a keydown, so a function would force it to - re-derive what the widget accepts and the two bindings would drift apart. + re-derive what the instrument accepts and the two bindings would drift apart. This is also press()'s allowlist — press appends whatever it is handed, so without the table a stray 'F1' would land in the buffer as text. */ -GW.abcKeypad.KEYS = (() => { +DUPRE.abcKeypad.KEYS = (() => { const m = {}; for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') m[c] = c; m.Space = 'SPC'; m.Backspace = 'DEL'; m.Enter = 'ENT'; @@ -442,7 +457,7 @@ GW.abcKeypad.KEYS = (() => { /* The plate's whole vocabulary — every argument press accepts, from any caller. A superset of the KEYS values: CLR is on the plate but no keystroke reaches it (Escape is the obvious candidate and belongs to the gallery's audit stepper). */ -GW.abcKeypad.ACTIONS = new Set([ +DUPRE.abcKeypad.ACTIONS = new Set([ ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 'SPC', 'DEL', 'ENT', 'CLR', ]); @@ -462,11 +477,20 @@ GW.abcKeypad.ACTIONS = new Set([ the entire interaction. Ours is alphabetical, capitals beside lowercase at the same column offset: find the letter, then pick the case. The layout is a table rather than drawing, because Craig has already said the - keys will be revisited and a layout welded into the geometry never is. */ -GW.indexPlate = function (host, opts = {}) { + keys will be revisited and a layout welded into the geometry never is. + Contract (everything a consumer needs; no page globals touched): + opts: max (22, line cap); onChange(buf, label) on select, print and clear. + handle: el (focusable; typing selects a cell, Enter pulls the lever), + get() (the printed line), select(c), print(), press(k) (any + ACTIONS member: plate characters, PRINT, CLR), selected() (the + stylus position, null before the first select). + CSS lives in the "keypad / index-plate focus" block of DUPRE_CSS (the + machine itself is drawn SVG); the paper recolours with the shared --scr-* + screen vars. */ +DUPRE.indexPlate = function (host, opts = {}) { const onChange = opts.onChange || noop; const MAX = opts.max || 22; - const L = GW.indexPlate.LAYOUT; + const L = DUPRE.indexPlate.LAYOUT; const COLS = Math.max(...L.map(r => r.length)), ROWS = L.length; /* Width is driven by the plate, not guessed: the lever and CLR live in a gutter to its right. Sized from the layout table so a wider plate can't slide them @@ -480,7 +504,7 @@ GW.indexPlate = function (host, opts = {}) { legend and then the lever. Growth was always safe; shrink was the trap. */ const VW = PLATE_R + GUT; const VH = Math.max(PY - 10 + ROWS * CH + 16 + 16, PY + 130); - const s = stageSvg(host, 'rsvg ix-pad', VW, VH); + const s = stageSvg(host, 'rsvg dupre-ix-pad', VW, VH); gradDef('ixPlate', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#efe6c4'], ['1', '#d8caa0']]); gradDef('ixBody', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#26221c'], ['1', '#100e0b']]); gradDef('ixSteel', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e6e9ef'], ['1', '#8d93a1']]); @@ -495,7 +519,7 @@ GW.indexPlate = function (host, opts = {}) { The ring area carries a halftone screen, which is what the real plate prints its outer characters onto. */ if (!document.getElementById('ixHalf')) { - const defs = s.ownerDocument.querySelector('#gw-defs') || svgEl(s, 'defs', {}); + const defs = svgEl(s, 'defs', {}); const pat = svgEl(defs, 'pattern', { id: 'ixHalf', width: 4, height: 4, patternUnits: 'userSpaceOnUse' }); svgEl(pat, 'rect', { width: 4, height: 4, fill: 'url(#ixPlate)' }); svgEl(pat, 'circle', { cx: 1, cy: 1, r: 1.05, fill: '#2b2318', 'fill-opacity': .55 }); @@ -507,11 +531,11 @@ GW.indexPlate = function (host, opts = {}) { a dark one for the lowercase — the plate's photographic-negative trick */ const ZFILL = { caps: '#e9e0bb', lower: '#231d13' }; const zoneAt = (r, c) => { - const z = GW.indexPlate.ZONES.find(z => + const z = DUPRE.indexPlate.ZONES.find(z => r >= z.rows[0] && r <= z.rows[1] && c >= z.cols[0] && c <= z.cols[1]); return z ? z.zone : 'ring'; }; - GW.indexPlate.ZONES.forEach(z => { + DUPRE.indexPlate.ZONES.forEach(z => { svgEl(s, 'rect', { x: PX + z.cols[0] * CW - 1, y: PY + z.rows[0] * CH - 1, width: (z.cols[1] - z.cols[0] + 1) * CW + 2, height: (z.rows[1] - z.rows[0] + 1) * CH + 2, @@ -527,8 +551,8 @@ GW.indexPlate = function (host, opts = {}) { /* The stylus: a cone on a short shaft, hovering over the selected cell. The Mignon's arm reaches back to a pivot, but a full arm drawn here crosses the plate and hides the characters the operator is trying to read — the one thing - this widget must not do. A tip is enough to say "the pointer is here". */ - const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'ix-stylus'); + this instrument must not do. A tip is enough to say "the pointer is here". */ + const arm = svgEl(s, 'g', {}); arm.setAttribute('class', 'dupre-ix-stylus'); svgEl(arm, 'line', { x1: 0, y1: -9, x2: 0, y2: -20, stroke: 'url(#ixSteel)', 'stroke-width': 2.6 }); svgEl(arm, 'circle', { cx: 0, cy: -21, r: 3.4, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .6 }); svgEl(arm, 'path', { d: 'M -3.6 -9 L 3.6 -9 L 0 0 Z', fill: 'url(#ixSteel)', stroke: '#41464f', 'stroke-width': .7 }); @@ -553,7 +577,7 @@ GW.indexPlate = function (host, opts = {}) { const z = zoneAt(r, i); const DISC = { caps: '#231d13', lower: '#f2ecd6', ring: '#f2ecd6' }; const INK = { caps: '#f2ecd6', lower: '#231d13', ring: '#231d13' }; - const g = svgEl(s, 'g', {}); g.setAttribute('class', 'ix-cell'); + const g = svgEl(s, 'g', {}); g.setAttribute('class', 'dupre-ix-cell'); g.dataset.c = c; g.dataset.zone = z; g.style.cursor = 'pointer'; const ring = svgEl(g, 'circle', { cx: x, cy: y, r: 8.6, fill: DISC[z], @@ -568,7 +592,7 @@ GW.indexPlate = function (host, opts = {}) { /* the lever: the only thing that prints. Lives in the gutter right of the plate */ const LX = PLATE_R + GUT / 2; - const lever = svgEl(s, 'g', {}); lever.setAttribute('class', 'ix-lever'); + const lever = svgEl(s, 'g', {}); lever.setAttribute('class', 'dupre-ix-lever'); lever.style.cursor = 'pointer'; lever.style.transition = 'transform .08s'; svgEl(lever, 'rect', { x: LX - 3.5, y: PY + 6, width: 7, height: 72, rx: 3.5, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': .8 }); svgEl(lever, 'circle', { cx: LX, cy: PY + 2, r: 8, fill: 'url(#ixSteel)', stroke: '#5c626e', 'stroke-width': 1 }); @@ -585,7 +609,7 @@ GW.indexPlate = function (host, opts = {}) { s.appendChild(lever); /* fresh paper */ - const clr = svgEl(s, 'g', {}); clr.setAttribute('class', 'ix-clear'); clr.style.cursor = 'pointer'; + const clr = svgEl(s, 'g', {}); clr.setAttribute('class', 'dupre-ix-clear'); clr.style.cursor = 'pointer'; /* anchored to PY like the rest of the gutter stack, not to VH — mixing the two is what let a shorter plate slide this up onto the PRINT legend */ svgEl(clr, 'rect', { x: LX - 16, y: PY + 104, width: 32, height: 18, rx: 3, fill: '#3a332a', stroke: '#5c5348', 'stroke-width': 1 }); @@ -601,7 +625,7 @@ GW.indexPlate = function (host, opts = {}) { primary input on this card would skip it and every probe would stay green. Gated on ACTIONS, so it selects nothing it has no cell for. */ const press = k => { - if (!GW.indexPlate.ACTIONS.has(k)) return; + if (!DUPRE.indexPlate.ACTIONS.has(k)) return; if (k === 'PRINT') return print(); if (k === 'CLR') return fresh(); select(k); @@ -617,7 +641,7 @@ GW.indexPlate = function (host, opts = {}) { s.addEventListener('click', () => s.focus()); s.addEventListener('keydown', e => { if (e.ctrlKey || e.metaKey || e.altKey) return; - const k = GW.indexPlate.KEYS[e.key]; + const k = DUPRE.indexPlate.KEYS[e.key]; if (!k) return; e.preventDefault(); /* Enter would submit a form; a cell key has no default worth keeping */ press(k); @@ -639,7 +663,7 @@ GW.indexPlate = function (host, opts = {}) { - J and j live out on the ring, not in the case blocks, which is why the inversion below is positional and not "capitals are dark". The two dashes differ: a long one top-right, a hyphen bottom-right. */ -GW.indexPlate.LAYOUT = [ +DUPRE.indexPlate.LAYOUT = [ ['&','(',')',':','"','!', '?',"'",'ä','ö','ü','—'], ['§','P','F','U','G','Q', 'p','f','u','g','q',';'], ['J','V','I','N','A','B', 'v','i','n','a','b','j'], @@ -654,7 +678,7 @@ GW.indexPlate.LAYOUT = [ the outer ring stays light-on-halftone — including the capital J, which is why no case-based rule can describe this. Move a block, move its rectangle. rows and cols are inclusive [start, end] indices into LAYOUT. */ -GW.indexPlate.ZONES = [ +DUPRE.indexPlate.ZONES = [ { rows: [1, 5], cols: [1, 5], zone: 'caps' }, { rows: [1, 5], cols: [6, 10], zone: 'lower' }, ]; @@ -663,27 +687,32 @@ GW.indexPlate.ZONES = [ Every plate character maps to itself (no case folding — the plate has both, so Shift does the work a shift key would), and Enter is the lever. Space is deliberately absent: there's no space cell yet, so Space isn't ours to claim. */ -GW.indexPlate.KEYS = (() => { +DUPRE.indexPlate.KEYS = (() => { const m = {}; - for (const c of GW.indexPlate.LAYOUT.flat()) if (c) m[c] = c; + for (const c of DUPRE.indexPlate.LAYOUT.flat()) if (c) m[c] = c; m.Enter = 'PRINT'; return m; })(); /* Every argument press accepts. A superset of the KEYS values: CLR is a real control that no keystroke reaches, the same shape as the keypad's. */ -GW.indexPlate.ACTIONS = new Set([...GW.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']); +DUPRE.indexPlate.ACTIONS = new Set([...DUPRE.indexPlate.LAYOUT.flat().filter(Boolean), 'PRINT', 'CLR']); -/* 03 horizontal fader — continuous 0-100 */ -GW.faderH = function (host, opts = {}) { +/* 03 horizontal fader — continuous 0-100; drag anywhere on the slot to seek. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 68); onChange(value, label) on every move. + handle: el, get(), set(v) (clamped 0-100). + CSS lives in the "fader" block of DUPRE_CSS; no other styles involved. */ +DUPRE.faderH = function (host, opts = {}) { const onChange = opts.onChange || noop; - const f = document.createElement('div'); f.className = 'fader'; - f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; + const f = document.createElement('div'); f.className = 'dupre-fader'; + f.innerHTML = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>'; host.appendChild(f); + const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap'); let val; const set = p => { val = Math.max(0, Math.min(100, p)); - f.querySelector('.fill').style.width = val + '%'; - f.querySelector('.cap').style.left = val + '%'; + fill.style.width = val + '%'; + cap.style.left = val + '%'; onChange(val, 'level ' + Math.round(val)); }; dragX(f, set); @@ -691,17 +720,24 @@ GW.faderH = function (host, opts = {}) { return { el: f, get: () => val, set }; }; -/* 04 vertical fader — one channel-strip fader; compose per channel */ -GW.faderV = function (host, opts = {}) { +/* 04 vertical fader — continuous 0-100, one channel-strip fader; compose per + channel. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 60); onChange(value, label) on every move. + handle: el, get(), set(v) (clamped 0-100). + CSS lives in the "vertical fader" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.faderV = function (host, opts = {}) { const onChange = opts.onChange || noop; - const f = document.createElement('div'); f.className = 'vfader'; - f.innerHTML = '<div class="slot"><div class="fill"></div></div><div class="cap"></div>'; + const f = document.createElement('div'); f.className = 'dupre-vfader'; + f.innerHTML = '<div class="dupre-slot"><div class="dupre-fill"></div></div><div class="dupre-cap"></div>'; host.appendChild(f); + const fill = f.querySelector('.dupre-fill'), cap = f.querySelector('.dupre-cap'); let val; const set = p => { val = Math.max(0, Math.min(100, p)); - f.querySelector('.fill').style.height = val + '%'; - f.querySelector('.cap').style.bottom = val + '%'; + fill.style.height = val + '%'; + cap.style.bottom = val + '%'; onChange(val, 'level ' + Math.round(val)); }; dragY(f, set); @@ -709,17 +745,23 @@ GW.faderV = function (host, opts = {}) { return { el: f, get: () => val, set }; }; -/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep */ -GW.knob = function (host, opts = {}) { +/* 05 rotary knob — drag up/down to turn, -150°..+150° sweep. + Contract (everything a consumer needs; no page globals touched): + opts: min (0), max (100), value (53); onChange(value, label) per step. + handle: el, get(), set(v) (clamped min..max). + CSS lives in the "rotary knob" block of DUPRE_CSS; rotarySelector and + encoder reuse the knob classes for their spindles. */ +DUPRE.knob = function (host, opts = {}) { const onChange = opts.onChange || noop; const min = opts.min !== undefined ? opts.min : 0, max = opts.max !== undefined ? opts.max : 100; - const k = document.createElement('span'); k.className = 'knob'; - k.innerHTML = '<span class="ind"></span>'; + const k = document.createElement('span'); k.className = 'dupre-knob'; + k.innerHTML = '<span class="dupre-ind"></span>'; host.appendChild(k); + const ind = k.querySelector('.dupre-ind'); let val; const set = v => { val = Math.max(min, Math.min(max, v)); - k.querySelector('.ind').style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`; + ind.style.transform = `rotate(${-150 + (val - min) / (max - min) * 300}deg)`; onChange(val, String(Math.round(val))); }; dragDelta(k, () => val, set, { min, max }); @@ -728,27 +770,37 @@ GW.knob = function (host, opts = {}) { }; /* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment - color from GW.segmented.STYLES (amber / green / red); defaults match the - stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */ -GW.segmented = function (host, opts = {}) { + color from DUPRE.segmented.STYLES (amber / green / red); defaults match the + stylesheet fallbacks. setStyle(axis, name) restyles a live instance. + Contract (everything a consumer needs; no page globals touched): + opts: items (labels, default TIMER/ALARM/POMO); active (0); accent + ('amber', a STYLES name); onChange(idx, label) on every pick. + handle: el, get(), set(i) (clamped), setStyle(axis, name). + CSS lives in the "stepper / segmented selector" block of DUPRE_CSS; no + other styles involved. */ +DUPRE.segmented = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['TIMER', 'ALARM', 'POMO']; - const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg); + const seg = document.createElement('div'); seg.className = 'dupre-seg'; host.appendChild(seg); const setStyle = (axis, name) => { - const o = (GW.segmented.STYLES[axis] || {})[name]; + const o = (DUPRE.segmented.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v); }; setStyle('accent', opts.accent || 'amber'); const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; }); let idx; - const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); }; + const set = i => { + idx = Math.max(0, Math.min(items.length - 1, i)); + btns.forEach((b, j) => b.classList.toggle('dupre-on', j === idx)); + onChange(idx, items[idx]); + }; btns.forEach((b, i) => b.addEventListener('click', () => set(i))); set(opts.active || 0); return { el: seg, get: () => idx, set, setStyle }; }; /* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */ -GW.segmented.STYLES = { +DUPRE.segmented.STYLES = { accent: { amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } }, green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } }, @@ -756,20 +808,26 @@ GW.segmented.STYLES = { }, }; -/* 07 chip toggle — inline binary inside a line of text */ -GW.chipToggle = function (host, opts = {}) { +/* 07 chip toggle — inline binary inside a line of text. + Contract (everything a consumer needs; no page globals touched): + opts: label ('discoverable on'); on (true); accent ('amber', a STYLES + name); onChange(on, 'ON'|'OFF') on every flip. + handle: el, get(), set(bool), setStyle(axis, name). + CSS lives in the "chip toggle" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.chipToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; - const chip = document.createElement('span'); chip.className = 'chip'; + const chip = document.createElement('span'); chip.className = 'dupre-chip'; chip.textContent = opts.label || 'discoverable on'; host.appendChild(chip); const setStyle = (axis, name) => { - const o = (GW.chipToggle.STYLES[axis] || {})[name]; + const o = (DUPRE.chipToggle.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) chip.style.setProperty(k, v); }; setStyle('accent', opts.accent || 'amber'); let on; - const set = v => { on = !!v; chip.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; + const set = v => { on = !!v; chip.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); }; chip.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); return { el: chip, get: () => on, set, setStyle }; @@ -778,63 +836,88 @@ GW.chipToggle = function (host, opts = {}) { let the chip say exactly one thing — but "on" is good in one panel, a warning in the next, and a fault in the one after. The colour is the claim, so it belongs to the consumer. */ -GW.chipToggle.STYLES = { accent: GW.accentStyles('--chip-on') }; +DUPRE.chipToggle.STYLES = { accent: DUPRE.accentStyles('--chip-on') }; -/* 08 arm-to-fire — two-stage confirm for destructive actions */ -GW.armButton = function (host, opts = {}) { +/* 08 arm-to-fire — two-stage confirm for destructive actions: first click + arms, second fires and resets to safe. + Contract (everything a consumer needs; no page globals touched): + opts: label ('forget'); armLabel (label + '? again'); onChange(state, + label) with state 'safe' | 'armed' | 'fired'. + handle: el, get() ('armed'|'safe'), fire() (clicks through one stage). + CSS lives in the "arm-to-fire" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.armButton = function (host, opts = {}) { const onChange = opts.onChange || noop; const label = opts.label || 'forget', armLabel = opts.armLabel || label + '? again'; - const a = document.createElement('button'); a.className = 'arm'; a.textContent = label; + const a = document.createElement('button'); a.className = 'dupre-arm'; a.textContent = label; host.appendChild(a); let armed = false; a.addEventListener('click', () => { - if (!armed) { armed = true; a.classList.add('armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); } - else { armed = false; a.classList.remove('armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); } + if (!armed) { armed = true; a.classList.add('dupre-armed'); a.textContent = armLabel; onChange('armed', 'ARMED'); } + else { armed = false; a.classList.remove('dupre-armed'); a.textContent = label; onChange('fired', 'FIRED · reset'); } }); onChange('safe', 'SAFE'); return { el: a, get: () => (armed ? 'armed' : 'safe'), fire: () => a.click() }; }; -/* 09 lamp row — actionable list item: lamp + name + status, click to cycle */ -GW.lampRow = function (host, opts = {}) { +/* 09 lamp row — actionable list item: lamp + name + status, click to cycle. + states are [lampTone, label] pairs; lampTone '' | 'gold' | 'busy' | 'red' + maps onto the shared dupre-lamp classes. + Contract (everything a consumer needs; no page globals touched): + opts: name ('WH-1000XM4'); states ([[tone, label], ...]); state (0); + onChange(idx, label) on every step. + handle: el, get(), set(i) (clamped). + CSS lives in the "lamp row (list item)" block of DUPRE_CSS; the lamp + itself is the shared dupre-lamp. */ +DUPRE.lampRow = function (host, opts = {}) { const onChange = opts.onChange || noop; const name = opts.name || 'WH-1000XM4'; const states = opts.states || [['gold', 'tap to connect'], ['busy', 'connecting…'], ['', 'connected']]; - const row = document.createElement('div'); row.className = 'lrow'; - row.innerHTML = `<span class="lamp"></span><span class="who"><b></b></span><span class="what"></span>`; + const row = document.createElement('div'); row.className = 'dupre-lrow'; + row.innerHTML = `<span class="dupre-lamp"></span><span class="dupre-who"><b></b></span><span class="dupre-what"></span>`; row.querySelector('b').textContent = name; host.appendChild(row); + const lamp = row.querySelector('.dupre-lamp'), what = row.querySelector('.dupre-what'); let idx; const set = i => { - idx = i; - row.querySelector('.lamp').className = 'lamp ' + states[i][0]; - row.querySelector('.what').textContent = states[i][1]; - onChange(i, states[i][1]); + idx = Math.max(0, Math.min(states.length - 1, i)); + lamp.className = 'dupre-lamp' + (states[idx][0] ? ' dupre-' + states[idx][0] : ''); + what.textContent = states[idx][1]; + onChange(idx, states[idx][1]); }; row.addEventListener('click', () => set((idx + 1) % states.length)); set(opts.state || 0); return { el: row, get: () => idx, set }; }; -/* 24 rotary selector — pick one of five printed detents by position */ -GW.rotarySelector = function (host, opts = {}) { +/* 24 rotary selector — pick one of five printed detents by position; click + the knob to step (wraps). Position/angle plates are fixed at five stops, + so values takes at most five entries. + Contract (everything a consumer needs; no page globals touched): + opts: values (up to 5 labels, default [4,6,8,10,12]); index (2); + fmt(v) → readout label; onChange(value, label) per step. + handle: el, get() (the value), set(i) (wraps modulo values). + CSS lives in the "rotary selector" block of DUPRE_CSS; the spindle + reuses the shared dupre-knob classes from the "rotary knob" block. */ +DUPRE.rotarySelector = function (host, opts = {}) { const onChange = opts.onChange || noop; - const values = opts.values || [4, 6, 8, 10, 12]; + const values = (opts.values || [4, 6, 8, 10, 12]).slice(0, 5); const fmt = opts.fmt || (v => 'size ' + v); const POS = [[14, 53], [28, 18], [50, 7], [72, 18], [86, 53]], ANG = [-70, -35, 0, 35, 70]; - const rs = document.createElement('span'); rs.className = 'rotsel'; - values.forEach((v, i) => { - const p = document.createElement('span'); p.className = 'pos'; + const rs = document.createElement('span'); rs.className = 'dupre-rotsel'; + const marks = values.map((v, i) => { + const p = document.createElement('span'); p.className = 'dupre-pos'; p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = v; - rs.appendChild(p); + rs.appendChild(p); return p; }); - const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; rs.appendChild(k); + const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; rs.appendChild(k); host.appendChild(rs); + const ind = k.querySelector('.dupre-ind'); let idx; const set = i => { idx = ((i % values.length) + values.length) % values.length; - rs.querySelector('.ind').style.transform = `rotate(${ANG[idx]}deg)`; - rs.querySelectorAll('.pos').forEach((p, j) => p.classList.toggle('on', j === idx)); + ind.style.transform = `rotate(${ANG[idx]}deg)`; + marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx)); onChange(values[idx], fmt(values[idx])); }; k.addEventListener('click', () => set(idx + 1)); @@ -845,10 +928,16 @@ GW.rotarySelector = function (host, opts = {}) { /* 25 slide-rule dial — lit pointer on a printed scale. The printed numerals are the majors; the integer units between them get minor ticks and are selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step - one unit. set() takes a stop index; get() returns the value. opts.skin picks - a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue); - setStyle(axis, name) restyles a live instance. */ -GW.slideRule = function (host, opts = {}) { + one unit. + Contract (everything a consumer needs; no page globals touched): + opts: values (majors, default [4,6,8,10,12]); value or index (2) for the + initial stop; fmt(v) → readout label; skin ('warm', a STYLES name: + warm backlit / chrome / 80s black glass / marantz blue); + onChange(value, label) per step. + handle: el, get() (the value), set(stopIndex) (clamped), setStyle(axis, name). + CSS lives in the "slide-rule tuner dial" block of DUPRE_CSS; skins override + its --tn-* vars. */ +DUPRE.slideRule = function (host, opts = {}) { const onChange = opts.onChange || noop; const majors = opts.values || [4, 6, 8, 10, 12]; const fmt = opts.fmt || (v => 'pos ' + v); @@ -862,26 +951,26 @@ GW.slideRule = function (host, opts = {}) { for (let u = v + 1; u < b; u++) stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false }); }); - const t = document.createElement('span'); t.className = 'tuner'; + const t = document.createElement('span'); t.className = 'dupre-tuner'; t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); const setStyle = (axis, name) => { - const o = (GW.slideRule.STYLES[axis] || {})[name]; + const o = (DUPRE.slideRule.STYLES[axis] || {})[name]; if (!o) return; for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v); }; setStyle('skin', opts.skin || 'warm'); stops.forEach(s => { t.insertAdjacentHTML('beforeend', s.major - ? `<span class="tick" style="left:${s.x}px"></span><span class="mk" style="left:${s.x}px">${s.v}</span>` - : `<span class="tick mn" style="left:${s.x}px"></span>`); + ? `<span class="dupre-tick" style="left:${s.x}px"></span><span class="dupre-mk" style="left:${s.x}px">${s.v}</span>` + : `<span class="dupre-tick dupre-mn" style="left:${s.x}px"></span>`); }); - const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl); + const ndl = document.createElement('span'); ndl.className = 'dupre-ndl'; t.appendChild(ndl); host.appendChild(t); let idx; const set = i => { idx = Math.max(0, Math.min(stops.length - 1, i)); ndl.style.left = stops[idx].x + 'px'; - t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v)); + t.querySelectorAll('.dupre-mk').forEach(m => m.classList.toggle('dupre-on', +m.textContent === stops[idx].v)); onChange(stops[idx].v, fmt(stops[idx].v)); }; t.addEventListener('click', e => { @@ -902,7 +991,7 @@ GW.slideRule = function (host, opts = {}) { }; /* named faces: dot = swatch color for pickers, vars = --tn-* overrides. warm restates the stylesheet fallbacks so switching back is exact. */ -GW.slideRule.STYLES = { +DUPRE.slideRule.STYLES = { skin: { warm: { dot: 'var(--gold-hi)', vars: { '--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c', @@ -927,33 +1016,45 @@ GW.slideRule.STYLES = { }, }; -/* N01 rocker power switch — hard on/off, lit legend */ -GW.rocker = function (host, opts = {}) { +/* N01 rocker power switch — hard on/off, lit legend. + Contract (everything a consumer needs; no page globals touched): + opts: onLabel ('ON'); offLabel ('OFF'); on (true); onChange(on, + 'ON'|'OFF') on every flip. + handle: el, get(), set(bool). + CSS lives in the "rocker" block of DUPRE_CSS; no other styles involved. */ +DUPRE.rocker = function (host, opts = {}) { const onChange = opts.onChange || noop; - const r = document.createElement('span'); r.className = 'rocker'; - r.innerHTML = `<span class="half top">${opts.onLabel || 'ON'}</span><span class="half bot">${opts.offLabel || 'OFF'}</span>`; + const r = document.createElement('span'); r.className = 'dupre-rocker'; + r.innerHTML = `<span class="dupre-half dupre-top">${opts.onLabel || 'ON'}</span><span class="dupre-half dupre-bot">${opts.offLabel || 'OFF'}</span>`; host.appendChild(r); let on; - const set = v => { on = !!v; r.classList.toggle('on', on); onChange(on, on ? 'ON' : 'OFF'); }; + const set = v => { on = !!v; r.classList.toggle('dupre-on', on); onChange(on, on ? 'ON' : 'OFF'); }; r.addEventListener('click', () => set(!on)); set(opts.on !== undefined ? opts.on : true); return { el: r, get: () => on, set }; }; /* N02 transport cluster — rew/play/stop/rec, one lit; reels turn while playing. - opts.animate: reels obey play state (default: not prefers-reduced-motion) */ -GW.transport = function (host, opts = {}) { + Contract (everything a consumer needs; no page globals touched): + opts: mode ('play', one of rew|play|stop|rec); animate (reels obey play + state; default: not prefers-reduced-motion); onChange(mode, NAME) + on every pick. + handle: el, get() (the mode key), set(mode). + CSS lives in the "transport" block of DUPRE_CSS plus the reelspin + keyframes in the motion section. */ +DUPRE.transport = function (host, opts = {}) { const onChange = opts.onChange || noop; const animate = opts.animate !== undefined ? opts.animate : !matchMedia('(prefers-reduced-motion: reduce)').matches; const MODES = { rew: '⏮', play: '▶', stop: '⏹', rec: '⏺' }; const NAMES = { rew: 'REW', play: 'PLAY', stop: 'STOP', rec: 'REC' }; const wrap = document.createElement('div'); wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;align-items:center'; - wrap.innerHTML = `<div class="reels"><span class="reel spin"><i></i><i></i><i></i></span><span class="reel spin"><i></i><i></i><i></i></span></div><div class="transport"></div>`; - const bar = wrap.querySelector('.transport'); + wrap.innerHTML = `<div class="dupre-reels"><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span><span class="dupre-reel dupre-spin"><i></i><i></i><i></i></span></div><div class="dupre-transport"></div>`; + const bar = wrap.querySelector('.dupre-transport'); + const reels = wrap.querySelectorAll('.dupre-reel'); const btns = {}; for (const m of Object.keys(MODES)) { - const b = document.createElement('button'); b.className = 'tbtn' + (m === 'rec' ? ' rec' : ''); + const b = document.createElement('button'); b.className = 'dupre-tbtn' + (m === 'rec' ? ' dupre-rec' : ''); b.textContent = MODES[m]; bar.appendChild(b); btns[m] = b; b.addEventListener('click', () => set(m)); } @@ -961,37 +1062,51 @@ GW.transport = function (host, opts = {}) { let mode; const set = m => { mode = m; - for (const k in btns) btns[k].classList.toggle('on', k === m); - wrap.querySelectorAll('.reel.spin').forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused'); + for (const k in btns) btns[k].classList.toggle('dupre-on', k === m); + reels.forEach(r => r.style.animationPlayState = (m === 'play' && animate) ? 'running' : 'paused'); onChange(m, NAMES[m]); }; /* initial: light the mode but leave reel play-state to CSS, matching load behavior */ mode = opts.mode || 'play'; - btns[mode].classList.add('on'); + btns[mode].classList.add('dupre-on'); onChange(mode, NAMES[mode]); return { el: wrap, get: () => mode, set }; }; -/* N03 radio preset bank — mechanically exclusive presets */ -GW.presetBank = function (host, opts = {}) { +/* N03 radio preset bank — mechanically exclusive presets. + Contract (everything a consumer needs; no page globals touched): + opts: items (labels, default WIFI/ETH/CELL/OFF); active (0); + onChange(idx, label) on every pick. + handle: el, get(), set(i) (clamped). + CSS lives in the "radio bank" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.presetBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['WIFI', 'ETH', 'CELL', 'OFF']; - const bank = document.createElement('div'); bank.className = 'radiobank'; host.appendChild(bank); - const btns = items.map(t => { const b = document.createElement('button'); b.className = 'preset'; b.textContent = t; bank.appendChild(b); return b; }); + const bank = document.createElement('div'); bank.className = 'dupre-radiobank'; host.appendChild(bank); + const btns = items.map(t => { const b = document.createElement('button'); b.className = 'dupre-preset'; b.textContent = t; bank.appendChild(b); return b; }); let idx; - const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); }; + const set = i => { idx = Math.max(0, Math.min(items.length - 1, i)); btns.forEach((b, j) => b.classList.toggle('dupre-on', j === idx)); onChange(idx, items[idx]); }; btns.forEach((b, i) => b.addEventListener('click', () => set(i))); set(opts.active || 0); return { el: bank, get: () => idx, set }; }; -/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap */ -GW.dualKnob = function (host, opts = {}) { +/* N04 concentric dual knob — two values on one spindle, outer ring + inner cap. + Contract (everything a consumer needs; no page globals touched): + opts: outer (50), inner (50), each 0..100; onChange(outer, inner) on + every move of either. + handle: el, get() ([outer, inner]), set(outer, inner) (either may be + undefined to hold; clamped 0..100). + CSS lives in the "concentric dual knob" block of DUPRE_CSS; its indicator + is self-styled, independent of the shared dupre-knob classes. */ +DUPRE.dualKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; - const dk = document.createElement('span'); dk.className = 'dualknob'; - dk.innerHTML = `<span class="outer"><span class="tick" style="transform:rotate(-120deg)"></span><span class="tick" style="transform:rotate(120deg)"></span></span><span class="inner"><span class="ind"></span></span>`; + const clamp = v => Math.max(0, Math.min(100, v)); + const dk = document.createElement('span'); dk.className = 'dupre-dualknob'; + dk.innerHTML = `<span class="dupre-outer"><span class="dupre-tick" style="transform:rotate(-120deg)"></span><span class="dupre-tick" style="transform:rotate(120deg)"></span></span><span class="dupre-inner"><span class="dupre-ind"></span></span>`; host.appendChild(dk); - const o = dk.querySelector('.outer'), n = dk.querySelector('.inner'); + const o = dk.querySelector('.dupre-outer'), n = dk.querySelector('.dupre-inner'); let oV, iV; const paint = () => { o.style.transform = `rotate(${-150 + oV / 100 * 300}deg)`; @@ -1000,29 +1115,37 @@ GW.dualKnob = function (host, opts = {}) { }; dragDelta(o, () => oV, v => { oV = v; paint(); }, { min: 0, max: 100 }); dragDelta(n, () => iV, v => { iV = v; paint(); }, { min: 0, max: 100, stop: true }); - oV = opts.outer !== undefined ? opts.outer : 50; - iV = opts.inner !== undefined ? opts.inner : 50; + oV = clamp(opts.outer !== undefined ? opts.outer : 50); + iV = clamp(opts.inner !== undefined ? opts.inner : 50); paint(); - return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = a; if (b !== undefined) iV = b; paint(); } }; + return { el: dk, get: () => [oV, iV], set: (a, b) => { if (a !== undefined) oV = clamp(a); if (b !== undefined) iV = clamp(b); paint(); } }; }; -/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level */ -GW.encoder = function (host, opts = {}) { +/* N05 rotary encoder + LED ring — endless dial, lit arc tracks the level. + Contract (everything a consumer needs; no page globals touched): + opts: value (7, accumulates without bound); onChange(value, label) per + step. + handle: el, get(), set(v). + CSS lives in the "rotary encoder + LED ring" block of DUPRE_CSS; the + spindle reuses the shared dupre-knob classes from the "rotary knob" + block. */ +DUPRE.encoder = function (host, opts = {}) { const onChange = opts.onChange || noop; - const enc = document.createElement('span'); enc.className = 'encoder'; host.appendChild(enc); + const enc = document.createElement('span'); enc.className = 'dupre-encoder'; host.appendChild(enc); const R = 27, cx = 33, cy = 33; + const leds = []; for (let i = 0; i < 12; i++) { const a = (i * 30 - 90) * Math.PI / 180; - const d = document.createElement('span'); d.className = 'led'; + const d = document.createElement('span'); d.className = 'dupre-led'; d.style.left = (cx + R * Math.cos(a)) + 'px'; d.style.top = (cy + R * Math.sin(a)) + 'px'; - enc.appendChild(d); + enc.appendChild(d); leds.push(d); } - const k = document.createElement('span'); k.className = 'knob'; k.innerHTML = '<span class="ind"></span>'; enc.appendChild(k); + const k = document.createElement('span'); k.className = 'dupre-knob'; k.innerHTML = '<span class="dupre-ind"></span>'; enc.appendChild(k); let val; const set = v => { val = v; const lit = ((Math.floor(val) % 12) + 12) % 12; - enc.querySelectorAll('.led').forEach((l, i) => l.classList.toggle('on', i <= lit)); + leds.forEach((l, i) => l.classList.toggle('dupre-on', i <= lit)); k.style.transform = `rotate(${val * 30}deg)`; onChange(val, 'pos ' + Math.round(val)); }; @@ -1031,25 +1154,34 @@ GW.encoder = function (host, opts = {}) { return { el: enc, get: () => val, set }; }; -/* N06 keyed mode switch — guarded three-position mode, key-bit points at the live one */ -GW.keySwitch = function (host, opts = {}) { +/* N06 keyed mode switch — guarded three-position mode, key-bit points at the + live one; click the barrel to step (wraps). Position/angle plates are fixed + at three stops, so items takes at most three entries. + Contract (everything a consumer needs; no page globals touched): + opts: items (up to 3 labels, default OFF/ON/RUN); index (1); + onChange(idx, label) per step. + handle: el, get(), set(i) (wraps modulo items). + CSS lives in the "keyed mode switch" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.keySwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; - const items = opts.items || ['OFF', 'ON', 'RUN']; + const items = (opts.items || ['OFF', 'ON', 'RUN']).slice(0, 3); const ANG = [-70, 0, 70], POS = [[16, 64], [50, 12], [84, 64]]; - const kl = document.createElement('span'); kl.className = 'keylock'; - items.forEach((t, i) => { - const p = document.createElement('span'); p.className = 'kpos'; + const kl = document.createElement('span'); kl.className = 'dupre-keylock'; + const marks = items.map((t, i) => { + const p = document.createElement('span'); p.className = 'dupre-kpos'; p.style.left = POS[i][0] + '%'; p.style.top = POS[i][1] + '%'; p.textContent = t; - kl.appendChild(p); + kl.appendChild(p); return p; }); - const body = document.createElement('span'); body.className = 'body'; - body.innerHTML = '<span class="bit"></span><span class="barrel"></span>'; + const body = document.createElement('span'); body.className = 'dupre-body'; + body.innerHTML = '<span class="dupre-bit"></span><span class="dupre-barrel"></span>'; kl.appendChild(body); host.appendChild(kl); + const bit = body.querySelector('.dupre-bit'); let idx; const set = i => { idx = ((i % items.length) + items.length) % items.length; - body.querySelector('.bit').style.transform = `rotate(${ANG[idx]}deg)`; - kl.querySelectorAll('.kpos').forEach((p, j) => p.classList.toggle('on', j === idx)); + bit.style.transform = `rotate(${ANG[idx]}deg)`; + marks.forEach((p, j) => p.classList.toggle('dupre-on', j === idx)); onChange(idx, items[idx]); }; body.addEventListener('click', () => set(idx + 1)); @@ -1057,16 +1189,23 @@ GW.keySwitch = function (host, opts = {}) { return { el: kl, get: () => idx, set }; }; -/* N07 center-detented crossfader — throw to either side of zero */ -GW.crossfader = function (host, opts = {}) { +/* N07 center-detented crossfader — throw to either side of zero. + Contract (everything a consumer needs; no page globals touched): + opts: aLabel ('A'), bLabel ('B'); value (50, the cap position 0..100); + onChange(v, label) with v = position - 50 (so -50..+50) per move. + handle: el, get() (-50..+50), set(pct) (0..100, clamped). + CSS lives in the "crossfader" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.crossfader = function (host, opts = {}) { const onChange = opts.onChange || noop; - const xf = document.createElement('div'); xf.className = 'xfader'; - xf.innerHTML = `<div class="slot"></div><div class="detent"></div><span class="end" style="left:0">${opts.aLabel || 'A'}</span><span class="end" style="right:0">${opts.bLabel || 'B'}</span><div class="cap"></div>`; + const xf = document.createElement('div'); xf.className = 'dupre-xfader'; + xf.innerHTML = `<div class="dupre-slot"></div><div class="dupre-detent"></div><span class="dupre-end" style="left:0">${opts.aLabel || 'A'}</span><span class="dupre-end" style="right:0">${opts.bLabel || 'B'}</span><div class="dupre-cap"></div>`; host.appendChild(xf); + const cap = xf.querySelector('.dupre-cap'); let pct; const set = p => { pct = Math.max(0, Math.min(100, p)); - xf.querySelector('.cap').style.left = pct + '%'; + cap.style.left = pct + '%'; const v = Math.round(pct - 50); onChange(v, (v > 0 ? '+' : '') + v); }; @@ -1075,50 +1214,71 @@ GW.crossfader = function (host, opts = {}) { return { el: xf, get: () => Math.round(pct - 50), set }; }; -/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value */ -GW.thumbwheel = function (host, opts = {}) { +/* N08 thumbwheel — knurled edge-wheel with a windowed two-digit value. + Contract (everything a consumer needs; no page globals touched): + opts: value (42); onChange(value, label) per step, value wrapped to + 0..99. + handle: el, get() (0..99), set(v) (wraps modulo 100). + CSS lives in the "thumbwheel" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.thumbwheel = function (host, opts = {}) { const onChange = opts.onChange || noop; - const w = document.createElement('div'); w.className = 'thumbw'; - w.innerHTML = '<span class="thumbwheel"></span><span class="win"></span>'; + const w = document.createElement('div'); w.className = 'dupre-thumbw'; + w.innerHTML = '<span class="dupre-thumbwheel"></span><span class="dupre-win"></span>'; host.appendChild(w); + const win = w.querySelector('.dupre-win'); let val; const set = v => { val = Math.round(v); const s = ((val % 100) + 100) % 100; - w.querySelector('.win').textContent = String(s).padStart(2, '0'); + win.textContent = String(s).padStart(2, '0'); onChange(s, 'value ' + s); }; - dragDelta(w.querySelector('.thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 }); + dragDelta(w.querySelector('.dupre-thumbwheel'), () => val, set, { min: 0, max: 99, sens: 0.2 }); set(opts.value !== undefined ? opts.value : 42); return { el: w, get: () => ((val % 100) + 100) % 100, set }; }; -/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word */ -GW.dipBank = function (host, opts = {}) { +/* N09 DIP-switch bank — hard flags, up is on; readout is the binary word. + Contract (everything a consumer needs; no page globals touched): + opts: bits (array of booleans, one switch each, default 6 switches); + onChange(word, label) — both are the binary word string ('101100'). + handle: el, get() (the word string), set(w) — a word string; position i + goes up on '1', down on any other character. + CSS lives in the "DIP bank" block of DUPRE_CSS; no other styles involved. */ +DUPRE.dipBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const bits = opts.bits || [true, false, true, true, false, false]; - const bank = document.createElement('span'); bank.className = 'dip'; host.appendChild(bank); + const bank = document.createElement('span'); bank.className = 'dupre-dip'; host.appendChild(bank); const sws = bits.map(on => { - const s = document.createElement('span'); s.className = 'dipsw' + (on ? ' on' : ''); + const s = document.createElement('span'); s.className = 'dupre-dipsw' + (on ? ' dupre-on' : ''); s.innerHTML = '<i></i>'; bank.appendChild(s); return s; }); - const word = () => sws.map(x => x.classList.contains('on') ? '1' : '0').join(''); + const word = () => sws.map(x => x.classList.contains('dupre-on') ? '1' : '0').join(''); const upd = () => onChange(word(), word()); - sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('on'); upd(); })); + sws.forEach(s => s.addEventListener('click', () => { s.classList.toggle('dupre-on'); upd(); })); upd(); - return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('on', w[i] === '1')); upd(); } }; + return { el: bank, get: word, set: w => { sws.forEach((s, i) => s.classList.toggle('dupre-on', w[i] === '1')); upd(); } }; }; -/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit */ -GW.jogWheel = function (host, opts = {}) { +/* N10 jog / shuttle wheel — scrub fine; position accumulates without limit. + Contract (everything a consumer needs; no page globals touched): + opts: value (initial position, default 0); onChange(v, label) on every + move. + handle: el, get() (the position), set(v) — unclamped by design; the jog + is a relative scrub, not an absolute dial (drag spans ±100000). + CSS lives in the "jog / shuttle" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.jogWheel = function (host, opts = {}) { const onChange = opts.onChange || noop; - const j = document.createElement('span'); j.className = 'jog'; - j.innerHTML = `<span class="shuttle" style="--sh:40deg"></span><span class="inner"><span class="dimple"></span></span>`; + const j = document.createElement('span'); j.className = 'dupre-jog'; + j.innerHTML = `<span class="dupre-shuttle" style="--sh:40deg"></span><span class="dupre-inner"><span class="dupre-dimple"></span></span>`; host.appendChild(j); + const inner = j.querySelector('.dupre-inner'); let val; const set = v => { val = v; - j.querySelector('.inner').style.transform = `rotate(${val * 4}deg)`; + inner.style.transform = `rotate(${val * 4}deg)`; onChange(val, 'pos ' + Math.round(val)); }; dragDelta(j, () => val, set, { min: -100000, max: 100000, sens: 0.5 }); @@ -1127,7 +1287,7 @@ GW.jogWheel = function (host, opts = {}) { }; /* ---- shared SVG defs: gradients/filters referenced by url(#id) are - document-scoped, and several are used across widgets. Builders ensure the + document-scoped, and several are used across instruments. Builders ensure the defs they use; the first caller creates it, later calls are no-ops. ---- */ let defsRoot = null; const defsMade = new Set(); @@ -1160,8 +1320,13 @@ function stageSvg(host, cls, vw, vh) { let uidN = 0; function uid(prefix) { return prefix + '-' + (++uidN); } -/* R02 calibrated vernier dial — the disc turns under a fixed hairline */ -GW.vernierDial = function (host, opts = {}) { +/* R02 calibrated vernier dial — the disc turns under a fixed hairline. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 42.0); onChange(v, label) on every move. + handle: el, get(), set(v) (clamped 0-100). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.vernierDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 150, 150), cx = 75, cy = 75; gradDef('vernFace', 'radialGradient', { cx: '50%', cy: '45%', r: '60%' }, [['0', '#f7f2da'], ['1', '#e4dfc2']]); @@ -1184,14 +1349,20 @@ GW.vernierDial = function (host, opts = {}) { svgEl(s, 'ellipse', { cx: 69, cy: 66, rx: 7, ry: 4, fill: 'rgba(255,255,255,.10)', transform: 'rotate(-32,69,66)' }); svgEl(s, 'line', { x1: cx, y1: 6, x2: cx, y2: 22, stroke: 'var(--fail)', 'stroke-width': 2.2, 'stroke-linecap': 'round' }); let val; - const set = v => { val = v; disc.setAttribute('transform', `rotate(${-v * 3},75,75)`); onChange(val, val.toFixed(1)); }; + const set = v => { val = Math.max(0, Math.min(100, v)); disc.setAttribute('transform', `rotate(${-val * 3},75,75)`); onChange(val, val.toFixed(1)); }; dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.15 }); set(opts.value !== undefined ? opts.value : 42.0); return { el: s, get: () => val, set }; }; -/* R03 bat-handle toggle — chrome lever throws between lit legends */ -GW.batToggle = function (host, opts = {}) { +/* R03 bat-handle toggle — chrome lever throws between lit legends. + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default true); onLabel / offLabel (legends, + default 'ON' / 'OFF'); onChange(on, label) on every throw. + handle: el, get() (boolean), set(v) (coerced to boolean). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.batToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 70, 90), cx = 35, cy = 44; gradDef('nutG', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#8a8578'], ['1', '#4e4a42']]); @@ -1221,8 +1392,14 @@ GW.batToggle = function (host, opts = {}) { return { el: s, get: () => on, set }; }; -/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc */ -GW.flutedKnob = function (host, opts = {}) { +/* R04 bakelite fluted knob — scallop skirt over a printed 0-10 arc. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100 internally, default 63; the arc prints it as 0-10); + onChange(v, label) on every move, label as 'x.x / 10'. + handle: el, get(), set(v) (clamped 0-100). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.flutedKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 54; gradDef('bakeSk', 'radialGradient', { cx: '42%', cy: '36%', r: '80%' }, [['0', '#312d27'], ['1', '#050404']]); @@ -1242,28 +1419,35 @@ GW.flutedKnob = function (host, opts = {}) { svgEl(s, 'circle', { cx, cy, r: 19, fill: 'url(#bakeDome)' }); svgEl(s, 'ellipse', { cx: 49, cy: 46, rx: 6, ry: 3.5, fill: 'rgba(255,255,255,.14)', transform: 'rotate(-28,49,46)' }); let val; - const set = v => { val = v; idx.setAttribute('transform', `rotate(${v * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); }; + const set = v => { val = Math.max(0, Math.min(100, v)); idx.setAttribute('transform', `rotate(${val * 2.7 - 135},55,54)`); onChange(val, (val / 10).toFixed(1) + ' / 10'); }; dragDelta(s, () => val, set, { min: 0, max: 100, sens: 0.25 }); set(opts.value !== undefined ? opts.value : 63); return { el: s, get: () => val, set }; }; -/* R05 filter slider bank — a dense fader wall on a real faceplate. Period skins - as constructor opts backed by GW.filterBank.STYLES, three independent axes: - panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE - (chrome T / short ribbed / tall block fader), and capColor — the cap - FINISH (black white-index / color stripes / chrome / cream). opts.style - picks a native trio (silver+chrome+chrome, studio+ribbed+red); - opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name) - restyles live (values kept). After the Pioneer SG-9500, the Technics - SH-8065, and the Zaxcom Oasis block faders. */ -GW.filterBank = function (host, opts = {}) { +/* R05 filter slider bank — a dense fader wall on a real faceplate. After the + Pioneer SG-9500, the Technics SH-8065, and the Zaxcom Oasis block faders. + Contract (everything a consumer needs; no page globals touched): + opts: freqs (band centers in Hz, default 12 bands); values (initial dB + cuts 0-60, padded/truncated to freqs); onChange({band, hz, db}, + label) per move. Period skins via DUPRE.filterBank.STYLES, three + independent axes: panel (silver hi-fi aluminum / studio black), + caps — the cap SHAPE (chrome T / short ribbed / tall block fader), + capColor — the cap FINISH (black white-index / color stripes / + chrome / cream). opts.style picks a native trio (silver+chrome+ + chrome, studio+ribbed+red); opts.panel / opts.caps / opts.capColor + override an axis. + handle: el, get() (dB array copy), set(i, db) (band clamped, dB clamped + 0-60), setStyle(axis, name) — restyles live, values kept. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.filterBank = function (host, opts = {}) { const onChange = opts.onChange || noop; const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340]; - const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length); + const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length).map(v => Math.max(0, Math.min(60, v))); while (vals.length < freqs.length) vals.push(30); const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; - const ST = GW.filterBank.STYLES; + const ST = DUPRE.filterBank.STYLES; const native = { silver: { caps: 'chrome', capColor: 'chrome' }, studio: { caps: 'ribbed', capColor: 'red' }, @@ -1306,6 +1490,7 @@ GW.filterBank = function (host, opts = {}) { } }; const set = (i, db) => { + i = Math.max(0, Math.min(freqs.length - 1, i)); db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i); onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`); }; @@ -1352,7 +1537,7 @@ GW.filterBank = function (host, opts = {}) { /* named styles per axis: dot = swatch color for pickers. caps is the cap shape; capColor is the cap finish (body + rib + index line), applicable to any shape. */ -GW.filterBank.STYLES = { +DUPRE.filterBank.STYLES = { panel: { silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null }, studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' }, @@ -1373,8 +1558,15 @@ GW.filterBank.STYLES = { }, }; -/* R06 chicken-head selector — tapered lever aims at the position */ -GW.chickenHead = function (host, opts = {}) { +/* R06 chicken-head selector — tapered lever aims at the position. + Contract (everything a consumer needs; no page globals touched): + opts: items ([label, angleDeg] pairs, default OFF/LO/MID/HI); index + (initial position, default 2); onChange(index, label) per step. + Click advances one position, wrapping. + handle: el, get() (the index), set(i) (wraps modulo items.length). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.chickenHead = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || [['OFF', -60], ['LO', -20], ['MID', 20], ['HI', 60]]; const s = stageSvg(host, 'rsvg press', 110, 96), cx = 55, cy = 58; @@ -1404,8 +1596,13 @@ GW.chickenHead = function (host, opts = {}) { return { el: s, get: () => idx, set }; }; -/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate */ -GW.slotFader = function (host, opts = {}) { +/* R12 chrome slot fader — engraved dB scale, chrome T-handle in a screwed plate. + Contract (everything a consumer needs; no page globals touched): + opts: value (dB, default -4); onChange(db, label) on every move. + handle: el, get() (the dB), set(v) (clamped -24..+12). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.slotFader = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 90, 150), cx = 45, yTop = 30, yBot = 120; const yOf = db => yTop + (12 - db) / 36 * (yBot - yTop); @@ -1441,8 +1638,13 @@ GW.slotFader = function (host, opts = {}) { return { el: s, get: () => db, set }; }; -/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it */ -GW.spadeKnob = function (host, opts = {}) { +/* R14 spade-pointer tuning knob — engraved relief arc, knurl ring turns with it. + Contract (everything a consumer needs; no page globals touched): + opts: value (0-10, default 8.3); onChange(v, label) on every move. + handle: el, get(), set(v) (clamped 0-10). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.spadeKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 140, 124), cx = 70, cy = 82; const sweep = v => -80 + v / 10 * 160; @@ -1480,8 +1682,17 @@ GW.spadeKnob = function (host, opts = {}) { return { el: s, get: () => val, set }; }; -/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring */ -GW.multiBandDial = function (host, opts = {}) { +/* R15 multi-band dial — nested arcs, one needle; the bandspread dial selects the ring. + Contract (everything a consumer needs; no page globals touched): + opts: ranges ([lo, hi] Mc pairs, one ring each, default 4 bands); value + (0-100 across the ring, default 45); band (initial ring index, + default 2); onChange({band, mc}, label) on every change. Dragging + the main dial tunes; clicking the bandspread dial cycles bands. + handle: el, get() ([value, band]), set(v, b) (value clamped 0-100, band + clamped to ranges; omit b to keep the current band). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.multiBandDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const ranges = opts.ranges || [[0.54, 1.6], [1.6, 5.1], [5.1, 15.5], [15.5, 30.5]]; const s = stageSvg(host, 'rsvg', 190, 110), cx = 62, cy = 62; @@ -1513,13 +1724,14 @@ GW.multiBandDial = function (host, opts = {}) { svgEl(s, 'circle', { cx: bx, cy: by, r: 6, fill: 'url(#spadeKnob)', stroke: '#000', 'stroke-width': .6 }); svgEl(s, 'text', { x: bx, y: 96, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: INK, opacity: .8 }).textContent = 'BANDSPREAD'; let val, band; - const set = (v, b) => { - val = Math.max(0, Math.min(100, v)); band = b; + const set = (v, b = band) => { + val = Math.max(0, Math.min(100, v)); + band = Math.max(0, Math.min(ranges.length - 1, b)); needle.setAttribute('transform', `rotate(${-70 + val / 100 * 140},62,62)`); - spread.setAttribute('transform', `rotate(${-45 + b * 30},142,56)`); - rings.forEach((g, i) => g.setAttribute('opacity', i === b ? '1' : '.4')); - const [lo, hi] = ranges[b]; const mc = lo + val / 100 * (hi - lo); - onChange({ band: b, mc }, `B${b + 1} · ${mc.toFixed(2)} Mc`); + spread.setAttribute('transform', `rotate(${-45 + band * 30},142,56)`); + rings.forEach((g, i) => g.setAttribute('opacity', i === band ? '1' : '.4')); + const [lo, hi] = ranges[band]; const mc = lo + val / 100 * (hi - lo); + onChange({ band, mc }, `B${band + 1} · ${mc.toFixed(2)} Mc`); }; const dragHit = svgEl(s, 'rect', { x: 9, y: 9, width: 100, height: 92, fill: 'transparent' }); dragHit.style.cursor = 'ns-resize'; @@ -1531,8 +1743,18 @@ GW.multiBandDial = function (host, opts = {}) { return { el: s, get: () => [val, band], set }; }; -/* R16 entry keypad — worn keys feed the amber display; lamps watch state */ -GW.entryKeypad = function (host, opts = {}) { +/* R16 entry keypad — worn keys feed the amber display; lamps watch state. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(buffer, text) fires on every keypress and on the initial + paint ('', 'enter a code'); ✓ reports 'OK · <code>' (or 'empty') + then clears, ✗ reports 'cleared' and flashes the lower lamp. + handle: el, get() (the buffer string, up to 6 digits), press(key) — any + of '0'-'9', '✓', '✗'; digits past 6 are ignored. The upper lamp + lights while the buffer is non-empty. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.entryKeypad = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 150, 190); gradDef('kpKey', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#dbd8cf'], ['1', '#a29d92']]); @@ -1554,7 +1776,7 @@ GW.entryKeypad = function (host, opts = {}) { lampBot.setAttribute('fill', 'var(--jewel-r)'); setTimeout(() => lampBot.setAttribute('fill', '#3a0f0a'), 350); return; } - if (buf.length < 6) { buf += k; render(); onChange(buf, buf); } + if (/^[0-9]$/.test(k) && buf.length < 6) { buf += k; render(); onChange(buf, buf); } }; const KEYS = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9'], ['✓', '0', '✗']]; const jitter = [-2, 1, -1, 2, 0, -2, 1, -1, 2, 0, -1, 1]; @@ -1569,8 +1791,18 @@ GW.entryKeypad = function (host, opts = {}) { return { el: s, get: () => buf, press }; }; -/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab */ -GW.thumbSlide = function (host, opts = {}) { +/* R18 thumb-slide attenuator pair — lit numeral strip, cream side tab. + + Contract (everything a consumer needs; no page globals touched): + opts: channels (array of { name, v } strips, v 0-100, default BLEND/MIX + pair — the layout is sized for two); onChange(values array, + 'NAME v · NAME v') fires on every set, including the initial paint. + handle: el, get() (values array), set(i, v) — v clamps 0-100; each strip + drags vertically on its own hit rect, and the numerals within 5 + of the value light up. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.thumbSlide = function (host, opts = {}) { const onChange = opts.onChange || noop; const chans = (opts.channels || [{ name: 'BLEND', v: 74 }, { name: 'MIX', v: 92 }]).map(c => ({ name: c.name, v: c.v })); const s = stageSvg(host, 'rsvg', 130, 150), y0 = 22, y1 = 122; @@ -1615,8 +1847,19 @@ GW.thumbSlide = function (host, opts = {}) { return { el: s, get: () => chans.map(c => c.v), set }; }; -/* R19 waveform region editor — monochrome LCD, draggable S/E flags */ -GW.waveRegion = function (host, opts = {}) { +/* R19 waveform region editor — monochrome LCD, draggable S/E flags. + + Contract (everything a consumer needs; no page globals touched): + opts: start / end (region bounds in percent, defaults 22 / 76); + onChange({ s, e }, 'S n% · E n%') fires on every set, including + the initial paint. + handle: el, get() ({ s, e }), set(s, e) — s clamps 0-96, e clamps 4-100, + and e is kept at least 4 above s; dragging moves whichever flag + is nearer the pointer. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the LCD inks read the --scr-* screen tokens with hex + fallbacks. */ +DUPRE.waveRegion = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 190, 100), x0 = 10, x1 = 180, yMid = 48, N = 85; svgEl(s, 'rect', { x: 1, y: 1, width: 188, height: 98, rx: 6, fill: 'var(--scr-bg1,#0b0c0b)', stroke: 'var(--scr-brd,#2a2c2a)', 'stroke-width': 2 }); @@ -1648,6 +1891,7 @@ GW.waveRegion = function (host, opts = {}) { let S, E; const set = (sv, ev) => { S = Math.max(0, Math.min(96, sv)); E = Math.max(4, Math.min(100, ev)); + if (E < S + 4) E = S + 4; const xOf = p => x0 + p / 100 * (x1 - x0); flagS.setAttribute('transform', `translate(${xOf(S)},0)`); flagE.setAttribute('transform', `translate(${xOf(E)},0)`); @@ -1666,8 +1910,18 @@ GW.waveRegion = function (host, opts = {}) { return { el: s, get: () => ({ s: S, e: E }), set }; }; -/* R20 drum roller selector — numbered paper drum in a window, center chip reads it */ -GW.drumRoller = function (host, opts = {}) { +/* R20 drum roller selector — numbered paper drum in a window, center chip reads it. + + Contract (everything a consumer needs; no page globals touched): + opts: title (engraved header, default 'EQUALIZER'); channels (array of + { name, v } drums, v 1-10, default HIGH/LOW pair — the layout is + sized for two); onChange(values array, 'NAME v · NAME v') fires on + every set, including the initial paint. + handle: el, get() (values array), set(i, v) — v clamps 1-10; each drum + drags vertically on its own hit strip. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.drumRoller = function (host, opts = {}) { const onChange = opts.onChange || noop; const chans = (opts.channels || [{ name: 'HIGH', v: 6 }, { name: 'LOW', v: 8 }]).map(c => ({ name: c.name, v: c.v })); const s = stageSvg(host, 'rsvg', 130, 140), cy = 76, step = 17; @@ -1709,8 +1963,19 @@ GW.drumRoller = function (host, opts = {}) { return { el: s, get: () => chans.map(c => c.v), set }; }; -/* R21 LED program row — exclusive select, the LED above the key carries the state */ -GW.ledRow = function (host, opts = {}) { +/* R21 LED program row — exclusive select, the LED above the key carries the state. + + Contract (everything a consumer needs; no page globals touched): + opts: items (program names, default 8 reverb programs — the layout is + sized for about eight); label (engraved caption, default + 'PROGRAM'); index (initial selection — default 5 for the default + set, first item for a caller's own set); onChange(index, + 'n · name') fires on every set, including the initial paint. + handle: el, get() (selected index), set(i) — i clamps to the items + range; exactly one LED is lit at a time. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.ledRow = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['Sm Hall B', 'VocPlate', 'Lg Hall B', 'Chamber', 'ParcPlate', 'Sm Hall A', 'Room A', 'Const Plate']; const s = stageSvg(host, 'rsvg', 190, 58); @@ -1718,9 +1983,9 @@ GW.ledRow = function (host, opts = {}) { const leds = []; let idx; const set = i => { - idx = i; + idx = Math.max(0, Math.min(items.length - 1, i)); leds.forEach((l, k) => { - const on = k === i; + const on = k === idx; l.setAttribute('fill', on ? 'var(--jewel-r)' : '#3a0f0a'); l.setAttribute('style', on ? 'filter:drop-shadow(0 0 3px rgba(255,91,69,.8))' : ''); }); @@ -1736,12 +2001,24 @@ GW.ledRow = function (host, opts = {}) { g.addEventListener('click', () => { g.style.transform = 'translateY(1.5px)'; setTimeout(() => g.style.transform = '', 80); set(i); }); }); svgEl(s, 'text', { x: 95, y: 56, 'text-anchor': 'middle', 'font-size': 6, 'letter-spacing': '.16em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = opts.label || 'PROGRAM'; - set(opts.index !== undefined ? opts.index : 5); + set(opts.index !== undefined ? opts.index : opts.items ? 0 : 5); return { el: s, get: () => idx, set }; }; -/* R22 three-position slide — chrome pill between detents, honest LED pair */ -GW.pillSlide = function (host, opts = {}) { +/* R22 three-position slide — chrome pill between detents, honest LED pair. + + Contract (everything a consumer needs; no page globals touched): + opts: positions (three detent labels, default A / AB / B — the detents + are fixed at three); title (engraved header, default + 'BASIC · VARIATION'); index (initial detent, default 1); + onChange(index, label) fires on every set, including the initial + paint. + handle: el, get() (detent index), set(i) — i clamps to the detents; + clicking the stage snaps the pill to the nearest detent, and the + LED pair reports A-side / B-side engagement (both lit at AB). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.pillSlide = function (host, opts = {}) { const onChange = opts.onChange || noop; const positions = opts.positions || ['A', 'AB', 'B']; const s = stageSvg(host, 'rsvg press', 110, 64), detX = [34, 55, 76]; @@ -1759,12 +2036,12 @@ GW.pillSlide = function (host, opts = {}) { const leds = [44, 66].map(x => svgEl(s, 'circle', { cx: x, cy: 50.5, r: 2.4, fill: '#3a0f0a' })); let idx; const set = i => { - idx = i; - pill.setAttribute('transform', `translate(${detX[i]},0)`); - lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)')); - const lit = [i === 0 || i === 1, i === 2 || i === 1]; + idx = Math.max(0, Math.min(positions.length - 1, i)); + pill.setAttribute('transform', `translate(${detX[idx]},0)`); + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + const lit = [idx === 0 || idx === 1, idx === 2 || idx === 1]; leds.forEach((l, k) => l.setAttribute('fill', lit[k] ? 'var(--jewel-r)' : '#3a0f0a')); - onChange(idx, positions[i]); + onChange(idx, positions[idx]); }; s.addEventListener('click', e => { const r = s.getBoundingClientRect(); @@ -1775,8 +2052,17 @@ GW.pillSlide = function (host, opts = {}) { return { el: s, get: () => idx, set }; }; -/* R23 spun-aluminum knob — machined rings in a knurled grip, red index */ -GW.spunKnob = function (host, opts = {}) { +/* R23 spun-aluminum knob — machined rings in a knurled grip, red index. + + Contract (everything a consumer needs; no page globals touched): + opts: label (engraved caption, default 'SPEED'); value (initial 0-100, + default 62); onChange(value, 'n%') fires on every set, including + the initial paint. + handle: el, get() (value), set(v) — v clamps 0-100; the whole stage + drags vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.spunKnob = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; gradDef('spunFace', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#e8e6e0'], ['.7', '#b3b0a8'], ['1', '#8a877e']]); @@ -1800,8 +2086,18 @@ GW.spunKnob = function (host, opts = {}) { return { el: s, get: () => val, set }; }; -/* R24 stomp switch + jewel — press to engage, the jewel reports */ -GW.stompSwitch = function (host, opts = {}) { +/* R24 stomp switch + jewel — press to engage, the jewel reports. + + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default false); onChange(on, 'ENGAGED' / + 'bypass') fires on every set, including the initial paint. + handle: el, get() (boolean), set(v) — coerced to boolean; clicking the + stage toggles, the dome dips, and the amber jewel glows while + engaged. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients and the avGlow filter register in the shared defs + plate. */ +DUPRE.stompSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55; gradDef('stompDome', 'radialGradient', { cx: '40%', cy: '30%', r: '80%' }, [['0', '#f4f2ec'], ['.55', '#b9b6ae'], ['1', '#6e6b63']]); @@ -1834,8 +2130,18 @@ GW.stompSwitch = function (host, opts = {}) { return { el: s, get: () => on, set }; }; -/* R27 winged gain selector — red T-bar over a dot ring, stepped detents */ -GW.wingSelector = function (host, opts = {}) { +/* R27 winged gain selector — red T-bar over a dot ring, stepped detents. + + Contract (everything a consumer needs; no page globals touched): + opts: steps (dB values, one detent each, default 12 from -80 to +10); + index (initial detent, default 7); onChange(db, '+n dB') fires on + every set with the step's dB value, including the initial paint. + handle: el, get() (detent index, not the dB value), set(i) — i rounds + and clamps to the steps range; the whole stage drags vertically + between detents. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.wingSelector = function (host, opts = {}) { const onChange = opts.onChange || noop; const steps = opts.steps || [-80, -70, -60, -50, -40, -30, -20, -10, -5, 0, 5, 10]; const s = stageSvg(host, 'rsvg drag', 110, 110), cx = 55, cy = 52; @@ -1867,8 +2173,17 @@ GW.wingSelector = function (host, opts = {}) { return { el: s, get: () => idx, set }; }; -/* R28 rotary disc switch — the whole disc turns between heavy positions */ -GW.discSwitch = function (host, opts = {}) { +/* R28 rotary disc switch — the whole disc turns between heavy positions. + + Contract (everything a consumer needs; no page globals touched): + opts: positions (array of [label, angle°] stops, default OFF / ON / + COMBINE); index (initial stop, default 1); onChange(index, label) + fires on every set, including the initial paint. + handle: el, get() (stop index), set(i) — i clamps to the stops; clicking + the stage advances to the next stop, wrapping. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.discSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const positions = opts.positions || [['OFF', -45], ['ON', 45], ['COMBINE', 135]]; const s = stageSvg(host, 'rsvg press', 110, 116), cx = 55, cy = 58; @@ -1892,18 +2207,28 @@ GW.discSwitch = function (host, opts = {}) { svgEl(s, 'ellipse', { cx: cx - 12, cy: cy - 14, rx: 12, ry: 6, fill: 'rgba(255,255,255,.10)', transform: `rotate(-28,${cx - 12},${cy - 14})` }); let idx; const set = i => { - idx = i; - grp.style.transform = `rotate(${positions[i][1]}deg)`; - lbls.forEach((t, k) => t.setAttribute('fill', k === i ? 'var(--gold-hi)' : 'var(--dim)')); - onChange(idx, positions[i][0]); + idx = Math.max(0, Math.min(positions.length - 1, i)); + grp.style.transform = `rotate(${positions[idx][1]}deg)`; + lbls.forEach((t, k) => t.setAttribute('fill', k === idx ? 'var(--gold-hi)' : 'var(--dim)')); + onChange(idx, positions[idx][0]); }; s.addEventListener('click', () => set((idx + 1) % positions.length)); set(opts.index !== undefined ? opts.index : 1); return { el: s, get: () => idx, set }; }; -/* R29 guarded toggle — guard posts + red collar mark the critical throw */ -GW.guardedToggle = function (host, opts = {}) { +/* R29 guarded toggle — guard posts + red collar mark the critical throw. + + Contract (everything a consumer needs; no page globals touched): + opts: on (initial state, default true); onLabel / offLabel (engraved + legends, defaults ON / OFF); onChange(on, label) fires on every + set, including the initial paint. + handle: el, get() (boolean), set(v) — coerced to boolean; clicking the + stage throws the lever, and the active legend carries the gold + ink. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.guardedToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 90, 100), cx = 45, cy = 52; gradDef('discRed', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#d98a6f'], ['.65', 'var(--fail)'], ['1', '#7a2a1a']]); @@ -1941,8 +2266,17 @@ GW.guardedToggle = function (host, opts = {}) { }; /* R32 mechanical timer dial — dial rotates under a fixed index; wind by drag, stop by the red knob. - Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. */ -GW.timerDial = function (host, opts = {}) { + Runs its own 1 Hz wind-down (a demo minute per second) unless reduced motion is set. + + Contract (everything a consumer needs; no page globals touched): + opts: minutes (initial wind, clamps 0-60, default 0); + onChange(minutes, 'T-N MIN'|'OFF'|'STOP · OFF'|'DING · OFF') fires + on every set, including the initial paint. + handle: el, get() (minutes remaining), set(m) — m clamps 0-60; the dial + face drags, the red knob clicks to 0. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.timerDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 158, 132), cx = 62, cy = 58; gradDef('mtFace', 'radialGradient', { cx: '50%', cy: '42%', r: '75%' }, [['0', '#282320'], ['1', '#14110e']]); @@ -2002,8 +2336,16 @@ GW.timerDial = function (host, opts = {}) { return { el: s, get: () => min, set }; }; -/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press */ -GW.rockerPad = function (host, opts = {}) { +/* R33 four-way rocker — quadrant clicks step a tracked cursor; arrows flash on press. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange({x, y, dir}, 'DIR · x N y N') fires on every press and + once at the initial paint (dir null, origin). + handle: el, get() ({x, y} cursor position). No set — the cursor is a + relative accumulator; position only moves by presses. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.rockerPad = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55; gradDef('rk4Pad', 'radialGradient', { cx: '42%', cy: '34%', r: '85%' }, [['0', '#33302b'], ['1', '#141210']]); @@ -2041,8 +2383,17 @@ GW.rockerPad = function (host, opts = {}) { return { el: s, get: () => ({ x: rx, y: ry }) }; }; -/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state */ -GW.fourWayToggle = function (host, opts = {}) { +/* R34 four-way toggle selector — ball lever throws to a diagonal; corner lamps show the state. + + Contract (everything a consumer needs; no page globals touched): + opts: position ('A'|'B'|'C'|'D', default 'C'; anything else falls back + to 'C'); onChange(pos, 'POS Q') fires on every set, including the + initial paint. + handle: el, get() (current quadrant letter), set(q) — invalid quadrants + are ignored; quadrant click zones select directly. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.fourWayToggle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; gradDef('fw4Chrome', 'linearGradient', { x1: 0, y1: 0, x2: 1, y2: 1 }, [['0', '#e8e6e0'], ['.5', '#9a968c'], ['1', '#55524a']]); @@ -2078,6 +2429,7 @@ GW.fourWayToggle = function (host, opts = {}) { svgEl(s, 'ellipse', { cx: cx - 2.6, cy: cy - 3, rx: 2.8, ry: 1.9, fill: 'rgba(255,255,255,.5)' }); let pos = null; const set = q => { + if (!Object.hasOwn(QUAD, q)) return; pos = q; lever.setAttribute('transform', `rotate(${QUAD[q]},${cx},${cy})`); for (const k of Object.keys(QUAD)) { @@ -2095,16 +2447,29 @@ GW.fourWayToggle = function (host, opts = {}) { zone('B', `${cx},${cy} ${cx},5 125,5 125,${cy}`); zone('C', `${cx},${cy} 125,${cy} 125,125 ${cx},125`); zone('D', `${cx},${cy} ${cx},125 5,125 5,${cy}`); - set(opts.position || 'C'); + set(Object.hasOwn(QUAD, opts.position || '') ? opts.position : 'C'); return { el: s, get: () => pos, set }; }; -/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many */ -GW.pinMatrix = function (host, opts = {}) { +/* R37 pin routing matrix — click an intersection to seat/pull a pin; many-to-many. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (source labels, default 5-row synth set); cols (destination + labels, default 6-col set; the layout is sized for 5x6); pins + (initial 'ROW>COL' keys — keys naming no intersection are + dropped); onChange(pins array, 'N routes') fires on every seat or + pull, including the initial paint. + handle: el, get() ('ROW>COL' keys array). No set — pins seat and pull + by intersection click. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.pinMatrix = function (host, opts = {}) { const onChange = opts.onChange || noop; const ROWS = opts.rows || ['OSC1', 'OSC2', 'LFO', 'NOIS', 'ENV']; const COLS = opts.cols || ['VCF', 'VCA', 'PAN', 'DLY', 'OUT', 'MOD']; - const pins = new Set(opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']); /* a legible default patch */ + /* a legible default patch; keys that name no intersection are dropped */ + const pins = new Set((opts.pins || ['OSC1>VCF', 'LFO>PAN', 'ENV>VCA']) + .filter(k => { const [r, c] = k.split('>'); return ROWS.includes(r) && COLS.includes(c) && k === r + '>' + c; })); const s = stageSvg(host, 'rsvg press', 160, 110); const X0 = 42, Y0 = 30, DX = 19, DY = 15.5; svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); @@ -2127,8 +2492,18 @@ GW.pinMatrix = function (host, opts = {}) { return { el: s, get: () => [...pins] }; }; -/* R38 dead-man button — state exists only while the pointer holds it down */ -GW.deadMan = function (host, opts = {}) { +/* R38 dead-man button — state exists only while the pointer holds it down. + + Contract (everything a consumer needs; no page globals touched): + opts: caption (plate legend, default 'HOLD TO RUN'); label (button + face, default 'RUN'); onChange(held, 'RUNNING N.Ns'|'SAFE...') + fires on press, release, every 100 ms while held, and the + initial paint. + handle: el, get() (true while held). No set — by design the state + exists only under a live pointer. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.deadMan = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 120, 110), cx = 60, cy = 58; gradDef('dmBtn', 'radialGradient', { cx: '40%', cy: '32%', r: '85%' }, [['0', '#3a3631'], ['1', '#16130f']]); @@ -2167,8 +2542,17 @@ GW.deadMan = function (host, opts = {}) { return { el: s, get: () => t0 !== null }; }; -/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */ -GW.telephoneDial = function (host, opts = {}) { +/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns. + Spin animation is reduced-motion-gated; clicks during a spin are ignored. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(dialed, dialed) fires when each digit's return spin + lands, plus once at the initial paint ('dial a number'). + handle: el, get() (dialed string, last 10 digits kept). No set — digits + only arrive through the dial. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.telephoneDial = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63; const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; @@ -2219,8 +2603,19 @@ GW.telephoneDial = function (host, opts = {}) { return { el: s, get: () => dialed }; }; -/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */ -GW.breakerPanel = function (host, opts = {}) { +/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step + (a tripped handle clicks to off, then to on). + + Contract (everything a consumer needs; no page globals touched): + opts: names (breaker labels, default MAIN/PUMP/LAMP/AUX — the layout is + sized for four); onChange(states array, 'N/M closed[ · NAME + TRIPPED]') fires on every click or trip, including the initial + paint. The first three breakers start on, the rest off. + handle: el, get() (per-breaker 'on'|'off'|'tripped' array). No set — + breakers move by handle click and the TRIP button. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.breakerPanel = function (host, opts = {}) { const onChange = opts.onChange || noop; const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX']; const s = stageSvg(host, 'rsvg press', 160, 110); @@ -2259,32 +2654,42 @@ GW.breakerPanel = function (host, opts = {}) { return { el: s, get: () => brk.map(b => b.state) }; }; -/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */ -GW.dsky = function (host, opts = {}) { +/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(vals {prog, verb, noun}, status text) fires on ENTR, + RSET, OPR ERR, and the initial paint. Grammar: VERB/NOUN arm a + window, digits fill it (2 max), ENTR commits — V35 runs the lamp + test, V16 N36 sets PROG 16 (monitor clock), a bare-verb commit + with under 2 digits flashes OPR ERR. + handle: el, get() ({prog, verb, noun} copy). + CSS lives in the "DSKY" block of DUPRE_CSS; keys reuse the shared + .dupre-key face and digits render as shared .seg7 glyphs. */ +DUPRE.dsky = function (host, opts = {}) { const onChange = opts.onChange || noop; const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR']; - const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el); + const el = document.createElement('div'); el.className = 'dupre-dsky'; host.appendChild(el); el.innerHTML = - `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` + - `<div class="right"><div class="wins">` + - `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` + - `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` + - `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` + - `</div><div class="pad"></div></div>`; + `<div class="dupre-dsky-lamps">${LAMPS.map(l => `<div class="dupre-dsky-sl" data-l="${l}">${l}</div>`).join('')}</div>` + + `<div class="dupre-dsky-right"><div class="dupre-dsky-wins">` + + `<div class="dupre-dsky-win" data-w="prog"><span class="dupre-dsky-wl">PROG</span><span class="dupre-dsky-wd"></span></div>` + + `<div class="dupre-dsky-win" data-w="verb"><span class="dupre-dsky-wl">VERB</span><span class="dupre-dsky-wd"></span></div>` + + `<div class="dupre-dsky-win" data-w="noun"><span class="dupre-dsky-wl">NOUN</span><span class="dupre-dsky-wd"></span></div>` + + `</div><div class="dupre-dsky-pad"></div></div>`; const win = w => el.querySelector(`[data-w="${w}"]`); - const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); }; + const setWin = (w, txt) => { win(w).querySelector('.dupre-dsky-wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); }; const lampEl = l => el.querySelector(`[data-l="${l}"]`); let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = ''; const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0']; - const pad = el.querySelector('.pad'); - const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); }; - const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); }; + const pad = el.querySelector('.dupre-dsky-pad'); + const hot = () => { el.querySelectorAll('.dupre-dsky-win').forEach(w => w.classList.toggle('dupre-hot', w.dataset.w === mode)); }; + const oprErr = () => { lampEl('OPR ERR').classList.add('dupre-on'); setTimeout(() => lampEl('OPR ERR').classList.remove('dupre-on'), 1200); onChange(vals, 'OPR ERR'); }; const commit = () => { if (vals.verb.trim().length < 2) { oprErr(); return; } const v = vals.verb, n = vals.noun.trim(); if (v === '35') { - LAMPS.forEach(l => lampEl(l).classList.add('on')); - setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400); + LAMPS.forEach(l => lampEl(l).classList.add('dupre-on')); + setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')), 1400); onChange(vals, 'V35 · lamp test'); } else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); } @@ -2292,11 +2697,11 @@ GW.dsky = function (host, opts = {}) { vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot(); }; KEYS.forEach(k => { - const b = document.createElement('button'); b.className = 'key'; b.textContent = k; + const b = document.createElement('button'); b.className = 'dupre-key'; b.textContent = k; b.addEventListener('click', () => { if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); } else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } } - else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); } + else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('dupre-on')); onChange(vals, 'RSET'); } else if (k === 'ENTR') commit(); else if (/\d/.test(k)) { if (!mode) { oprErr(); return; } @@ -2311,8 +2716,20 @@ GW.dsky = function (host, opts = {}) { }; /* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it. - Runs its own reduced-motion-gated 2 s step interval once started. */ -GW.camTimer = function (host, opts = {}) { + Runs its own reduced-motion-gated 2 s step interval once started. + + Contract (everything a consumer needs; no page globals touched): + opts: steps (ring labels, index 0 is OFF, default 12-step wash + program — the layout is sized for 12); colors (label→wedge + fill map, default wash palette); position (initial step, clamps + to the ring, default 0); onChange(pos, 'OFF'|'STEP N · + LABEL'|'CYCLE DONE · OFF') fires on every set, including the + initial paint. + handle: el, get() (step index), set(i) — i clamps to the ring; any + click advances one step. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.camTimer = function (host, opts = {}) { const onChange = opts.onChange || noop; const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL']; const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' }; @@ -2335,8 +2752,8 @@ GW.camTimer = function (host, opts = {}) { svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); let pos; const set = i => { - pos = i; - ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`); + pos = Math.max(0, Math.min(STEPS.length - 1, i | 0)); + ptr.setAttribute('transform', `rotate(${pos * 30 + 15},${cx},${cy})`); onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]); }; s.style.cursor = 'pointer'; @@ -2347,8 +2764,17 @@ GW.camTimer = function (host, opts = {}) { return { el: s, get: () => pos, set }; }; -/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */ -GW.knifeSwitch = function (host, opts = {}) { +/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw. + + Contract (everything a consumer needs; no page globals touched): + opts: closed (initial state, coerced to boolean, default true); + onChange(closed, 'CLOSED · live'|'OPEN · visibly dead') fires on + every set, including the initial paint. + handle: el, get() (true when closed), set(v) — coerced to boolean; any + click toggles. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.knifeSwitch = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 110); gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]); @@ -2382,11 +2808,20 @@ GW.knifeSwitch = function (host, opts = {}) { return { el: s, get: () => closed, set }; }; -/* R49 decade box — four skirted knobs, one digit each; the value is their sum */ -GW.decadeBox = function (host, opts = {}) { +/* R49 decade box — four skirted knobs, one digit each; the value is their sum. + + Contract (everything a consumer needs; no page globals touched): + opts: digits (array of four 0-9 digits, most-significant first, default + [3, 5, 0, 0] — missing entries read 0, values clamp to 0-9); + onChange(total, 'N,NNN Ω') fires on every redraw, including the + initial paint. + handle: el, get() (the summed value; digits weight x1000/x100/x10/x1). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; no gradients registered. */ +DUPRE.decadeBox = function (host, opts = {}) { const onChange = opts.onChange || noop; const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1']; - const digs = (opts.digits || [3, 5, 0, 0]).slice(); + const digs = MUL.map((_, i) => Math.round(Math.max(0, Math.min(9, +(opts.digits || [3, 5, 0, 0])[i] || 0)))); const s = stageSvg(host, 'rsvg', 160, 100); svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 }); const wins = [], knobs = []; @@ -2418,8 +2853,17 @@ GW.decadeBox = function (host, opts = {}) { return { el: s, get: () => total() }; }; -/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */ -GW.twoHandSafety = function (host, opts = {}) { +/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(state, text) fires on every transition — 'ready', + 'armed' (one palm down, 0.5s window), 'running' (1.5s cycle), + 'fault' (window expired or same hand twice); initial fire 'ready'. + handle: el, press(side) — side is 'L' or 'R' only, anything else is + ignored; presses during a running cycle are ignored. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; no gradients registered. */ +DUPRE.twoHandSafety = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 160, 100); svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); @@ -2438,6 +2882,7 @@ GW.twoHandSafety = function (host, opts = {}) { const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); }; const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); }; const press = side => { + if (side !== 'L' && side !== 'R') return; if (busy) return; if (armed === null) { armed = side; flash(side === 'L' ? L.cap : R.cap); @@ -2461,19 +2906,33 @@ GW.twoHandSafety = function (host, opts = {}) { return { el: s, press }; }; -/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */ -GW.voiceLoop = function (host, opts = {}) { +/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker. + + Contract (everything a consumer needs; no page globals touched): + opts: loops (array of key labels; the default 8-loop set also gets a + legible demo state — FD + A/G monitored, GNC talking — while a + caller's own set starts all-idle); onChange(states, text) fires + on every click and the initial paint — states is an array of + '0' idle / '1' monitored / '2' talking, one per loop. + handle: el, get() (the states array). Click cycles idle → monitored → + talking → idle; talk is exclusive across keys. Activity + flicker is display-side and honors prefers-reduced-motion. + CSS lives in the "voice-loop keyset" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.voiceLoop = function (host, opts = {}) { const onChange = opts.onChange || noop; const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO']; - const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el); + const el = document.createElement('div'); el.className = 'dupre-vloop'; host.appendChild(el); const keys = LOOPS.map(l => { - const k = document.createElement('div'); k.className = 'vk'; - k.innerHTML = l + '<span class="bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k; + const k = document.createElement('div'); k.className = 'dupre-vk'; + k.innerHTML = l + '<span class="dupre-vk-bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k; }); - /* a legible default: FD + A/G monitored, GNC talking */ - keys[0].dataset.state = '1'; keys[0].classList.add('mon'); - keys[4].dataset.state = '1'; keys[4].classList.add('mon'); - keys[1].dataset.state = '2'; keys[1].classList.add('mon', 'tlk'); + if (!opts.loops) { + /* a legible default: FD + A/G monitored, GNC talking */ + keys[0].dataset.state = '1'; keys[0].classList.add('dupre-mon'); + keys[4].dataset.state = '1'; keys[4].classList.add('dupre-mon'); + keys[1].dataset.state = '2'; keys[1].classList.add('dupre-mon', 'dupre-tlk'); + } const refresh = () => { const mon = keys.filter(k => k.dataset.state !== '0').length; const tlk = keys.findIndex(k => k.dataset.state === '2'); @@ -2481,20 +2940,20 @@ GW.voiceLoop = function (host, opts = {}) { }; keys.forEach(k => k.addEventListener('click', () => { const st = k.dataset.state; - if (st === '0') { k.dataset.state = '1'; k.classList.add('mon'); } + if (st === '0') { k.dataset.state = '1'; k.classList.add('dupre-mon'); } else if (st === '1') { - keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } }); - k.dataset.state = '2'; k.classList.add('tlk'); + keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('dupre-tlk'); } }); + k.dataset.state = '2'; k.classList.add('dupre-tlk'); } - else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', 'act'); } + else { k.dataset.state = '0'; k.classList.remove('dupre-mon', 'dupre-tlk', 'dupre-act'); } refresh(); })); if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setInterval(() => { const t = performance.now() / 1000; keys.forEach((k, i) => { - if (k.dataset.state === '0') { k.classList.remove('act'); return; } - k.classList.toggle('act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55); + if (k.dataset.state === '0') { k.classList.remove('dupre-act'); return; } + k.classList.toggle('dupre-act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55); }); }, 300); refresh(); @@ -2507,16 +2966,23 @@ GW.voiceLoop = function (host, opts = {}) { onChange(value, text) like every other builder. Display-side state that belongs to the instrument (peak-hold, history buffers) lives in here. */ -/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc */ -GW.needleGauge = function (host, opts = {}) { +/* 10 needle gauge — drag up/down sweeps the needle over a 120° arc. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, 'value N%') fires on + every set, including the initial paint. + handle: el, get() (current value), set(v) — no clamping beyond the + drag helper's 0-100; the needle maps value linearly to ±60°. + CSS lives in the "needle gauge" block of DUPRE_CSS; no other styles involved. */ +DUPRE.needleGauge = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 50; - const el = document.createElement('div'); el.className = 'gauge'; - el.innerHTML = `<div class="dial"><div class="arc"></div> - <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div> - <div class="ndl"></div><div class="hub"></div></div><div class="gv"><span>0</span>%</div>`; + const el = document.createElement('div'); el.className = 'dupre-gauge'; + el.innerHTML = `<div class="dupre-dial"><div class="dupre-arc"></div> + <div class="dupre-tk" style="transform:rotate(-60deg)"></div><div class="dupre-tk" style="transform:rotate(0)"></div><div class="dupre-tk" style="transform:rotate(60deg)"></div> + <div class="dupre-ndl"></div><div class="dupre-hub"></div></div><div class="dupre-gv"><span>0</span>%</div>`; host.appendChild(el); - const ndl = el.querySelector('.ndl'), num = el.querySelector('.gv span'); + const ndl = el.querySelector('.dupre-ndl'), num = el.querySelector('.dupre-gv span'); function set(nv) { v = nv; ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; @@ -2528,23 +2994,33 @@ GW.needleGauge = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both */ -GW.vuPair = function (host, opts = {}) { +/* 11 stereo VU — two LED bars with peak-hold; set(l, r) drives both. + + Contract (everything a consumer needs; no page globals touched): + opts: bars (cells per channel, default 16); onChange([l, r], 'L n · R n') + fires on every set (values 0-1, readout in percent). + handle: el, get() ([l, r] last set), set(l, r). Peak-hold is display-side + state owned in here: each channel's peak cell decays 0.4 cells + per set() call, so the page's tick cadence is the decay clock + (the tick contract — the page owns the clock and the signal). + CSS lives in the "segmented VU / LED bar" block of DUPRE_CSS; no other + styles involved. */ +DUPRE.vuPair = function (host, opts = {}) { const onChange = opts.onChange || noop; const n = opts.bars || 16; - const el = document.createElement('div'); el.className = 'vu'; - el.innerHTML = `<div class="vurow"><span class="ch">L</span><span class="vubar"></span></div> - <div class="vurow"><span class="ch">R</span><span class="vubar"></span></div>`; + const el = document.createElement('div'); el.className = 'dupre-vu'; + el.innerHTML = `<div class="dupre-vurow"><span class="dupre-ch">L</span><span class="dupre-vubar"></span></div> + <div class="dupre-vurow"><span class="dupre-ch">R</span><span class="dupre-vubar"></span></div>`; host.appendChild(el); - const bars = el.querySelectorAll('.vubar'); + const bars = el.querySelectorAll('.dupre-vubar'); bars.forEach(b => buildBars(b, n)); const pkL = { v: 0 }, pkR = { v: 0 }; function paint(bar, l, pk) { const b = bar.children, lit = Math.round(l * n); pk.v = Math.max(lit, (pk.v || 0) - 0.4); const p = Math.round(pk.v); for (let k = 0; k < n; k++) { - let c = k < lit ? (k >= n - 2 ? 'clip' : k >= n - 4 ? 'hot' : 'on') : ''; - if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'peak'; + let c = k < lit ? (k >= n - 2 ? 'dupre-clip' : k >= n - 4 ? 'dupre-hot' : 'dupre-on') : ''; + if (p > 0 && k === p - 1) c = (c ? c + ' ' : '') + 'dupre-peak'; b[k].className = c; } } @@ -2557,27 +3033,44 @@ GW.vuPair = function (host, opts = {}) { return { el, get: () => [lv, rv], set }; }; -/* 12 mini 4-bar signal — compact activity meter; set(level) */ -GW.miniSig = function (host, opts = {}) { +/* 12 mini 4-bar signal — compact activity meter; set(level). + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(level, 'N%') fires on every set (level 0-1, clamped; + readout in percent). No initial fire — the page's tick drives it + (the tick contract — the page owns the clock and the signal). + handle: el, get() (last level set), set(level). Bars light bottom-up: + dupre-on, third bar dupre-hot, top bar dupre-clip. + CSS lives in the "mini 4-bar signal" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.miniSig = function (host, opts = {}) { const onChange = opts.onChange || noop; - const el = document.createElement('span'); el.className = 'sig'; + const el = document.createElement('span'); el.className = 'dupre-sig'; el.innerHTML = '<i></i><i></i><i></i><i></i>'; host.appendChild(el); let v = 0; function set(l) { - v = l; - const b = el.children, lit = Math.round(l * 4); - for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'clip' : k >= 2 ? 'hot' : 'on') : ''; - onChange(l, Math.round(l * 100) + '%'); + v = Math.max(0, Math.min(1, l)); + const b = el.children, lit = Math.round(v * 4); + for (let k = 0; k < 4; k++) b[k].className = k < lit ? (k >= 3 ? 'dupre-clip' : k >= 2 ? 'dupre-hot' : 'dupre-on') : ''; + onChange(v, Math.round(v * 100) + '%'); } return { el, get: () => v, set }; }; -/* 13 signal ladder — stepped 0-4 strength; click cycles */ -GW.signalLadder = function (host, opts = {}) { +/* 13 signal ladder — stepped 0-4 strength; click cycles. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-4, default 3); onChange(v, 'v/4') fires on every set, + including the initial paint. + handle: el, get() (current step), set(v) — bars at or below v light + gold; click advances (v + 1) % 5. + CSS lives in the "signal ladder" block of DUPRE_CSS; the lit/unlit bar + colours are inline (--gold / --wash), no other styles involved. */ +DUPRE.signalLadder = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 3; - const el = document.createElement('span'); el.className = 'ladder'; + const el = document.createElement('span'); el.className = 'dupre-ladder'; el.innerHTML = '<i></i><i></i><i></i><i></i>'; host.appendChild(el); function set(nv) { @@ -2591,36 +3084,53 @@ GW.signalLadder = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set */ -GW.fuelBar = function (host, opts = {}) { +/* 14 linear fuel bar — one 0-100 bar, warn tint under the threshold; drag to set. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); warnAt (threshold, default 20 — below + it the fill tints dupre-warn); onChange(value, 'N%') fires on + every set, including the initial paint. + handle: el, get() (current value), set(v) — clamped to 0-100; drag + along the bar sets by pointer position. + CSS lives in the "linear progress / fuel bar" block of DUPRE_CSS; no + other styles involved. */ +DUPRE.fuelBar = function (host, opts = {}) { const onChange = opts.onChange || noop; const warnAt = opts.warnAt ?? 20; let v = opts.value ?? 50; - const el = document.createElement('div'); el.className = 'bar'; + const el = document.createElement('div'); el.className = 'dupre-bar'; el.innerHTML = '<span></span>'; host.appendChild(el); const fill = el.querySelector('span'); function set(p) { - v = p; - fill.style.width = p + '%'; - el.classList.toggle('warn', p < warnAt); - onChange(p, Math.round(p) + '%'); + v = Math.max(0, Math.min(100, p)); + fill.style.width = v + '%'; + el.classList.toggle('dupre-warn', v < warnAt); + onChange(v, Math.round(v) + '%'); } dragX(el, set); set(v); return { el, get: () => v, set }; }; -/* 15 radial ring — percentage donut; drag up/down to set */ -GW.radialRing = function (host, opts = {}) { +/* 15 radial ring — percentage donut; drag up/down to set. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 68); onChange(value, 'N%') fires on every + set, including the initial paint. + handle: el, get() (current value), set(v) — clamped to 0-100; the fill + is a conic gradient driven by the element-scoped --p property. + CSS lives in the "radial ring" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.radialRing = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 68; - const el = document.createElement('span'); el.className = 'ring'; + const el = document.createElement('span'); el.className = 'dupre-ring'; el.innerHTML = '<b></b>'; host.appendChild(el); const num = el.querySelector('b'); function set(nv) { - v = nv; + v = Math.max(0, Math.min(100, nv)); el.style.setProperty('--p', v); num.textContent = Math.round(v); onChange(v, Math.round(v) + '%'); @@ -2630,39 +3140,63 @@ GW.radialRing = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it */ -GW.sparkline = function (host, opts = {}) { +/* 16 sparkline — rolling history trace; push(v) appends a sample, fill(v) levels it. + + Contract (everything a consumer needs; no page globals touched): + opts: samples (history length, default 40, floor 2 — the trace needs + two points); value (initial level, default 0.5); onChange(last, + 'N') fires on every repaint (samples 0-1, clamped on entry; + readout 0-100). No initial fire — the page's tick drives it (the + tick contract — the page owns the clock and the signal). The + history buffer is display-side state owned in here. + handle: el, get() (newest sample), push(v) (append, oldest drops), + fill(v) (level the whole buffer). + CSS lives in the "sparkline" block of DUPRE_CSS; the trace colour is an + inline stroke, no other styles involved. */ +DUPRE.sparkline = function (host, opts = {}) { const onChange = opts.onChange || noop; - const n = opts.samples || 40; - const hist = Array.from({ length: n }, () => opts.value ?? 0.5); - const el = document.createElement('span'); el.className = 'spark'; + const n = Math.max(2, opts.samples || 40); + const clamp = x => Math.max(0, Math.min(1, x)); + const hist = Array.from({ length: n }, () => clamp(opts.value ?? 0.5)); + const el = document.createElement('span'); el.className = 'dupre-spark'; el.innerHTML = '<svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg>'; host.appendChild(el); const line = el.querySelector('polyline'); - const clamp = x => Math.max(0, Math.min(1, x)); function paint() { - line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - clamp(v) * 40 - 2}`).join(' ')); - onChange(hist[n - 1], String(Math.round(clamp(hist[n - 1]) * 100))); + line.setAttribute('points', hist.map((v, i) => `${i / (n - 1) * 170},${44 - v * 40 - 2}`).join(' ')); + onChange(hist[n - 1], String(Math.round(hist[n - 1] * 100))); } - function push(v) { hist.push(v); hist.shift(); paint(); } - function fill(v) { hist.fill(v); paint(); } + function push(v) { hist.push(clamp(v)); hist.shift(); paint(); } + function fill(v) { hist.fill(clamp(v)); paint(); } return { el, get: () => hist[n - 1], push, fill }; }; -/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1 */ -GW.waveStrip = function (host, opts = {}) { +/* 17 waveform strip — sampled trace; set(samples, amp) with samples in -1..1. + + Contract (everything a consumer needs; no page globals touched): + opts: amp (initial amplitude for the readout, default 0.6); + onChange(amp, 'amp N%') fires on every set, including the + initial paint (a flat centre line). + handle: el, get() (last amp), set(samples, amp) — samples in -1..1, + clamped per sample; fewer than two samples draws the flat + centre line. Amp is readout-only; the trace height comes from + the samples themselves. + CSS lives in the "waveform strip" block of DUPRE_CSS; the trace colour + is an inline stroke, no other styles involved. */ +DUPRE.waveStrip = function (host, opts = {}) { const onChange = opts.onChange || noop; - const el = document.createElement('span'); el.className = 'wave'; + const el = document.createElement('span'); el.className = 'dupre-wave'; el.innerHTML = '<svg viewBox="0 0 170 38" preserveAspectRatio="none"><path fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg>'; host.appendChild(el); const path = el.querySelector('path'); + const clamp = x => Math.max(-1, Math.min(1, x)); let amp = 0; function set(samples, a) { amp = a; let d = 'M0 19'; if (samples.length < 2) d += ' L170 19'; else for (let i = 0; i < samples.length; i++) - d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + samples[i] * 14).toFixed(1)}`; + d += ` L${(i / (samples.length - 1) * 170).toFixed(1)} ${(19 + clamp(samples[i]) * 14).toFixed(1)}`; path.setAttribute('d', d); onChange(amp, 'amp ' + Math.round(amp * 100) + '%'); } @@ -2670,31 +3204,54 @@ GW.waveStrip = function (host, opts = {}) { return { el, get: () => amp, set }; }; -/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1 */ -GW.scope = function (host, opts = {}) { +/* N11 oscilloscope — sampled phosphor trace; set(samples, vpp) with samples in -1..1. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(vpp, 'Vpp N') fires on every set (readout is vpp x100). + No initial fire — the page's tick drives it (the tick contract — + the page owns the clock and the signal). + handle: el, get() (last vpp), set(samples, vpp) — samples in -1..1, + clamped per sample; fewer than two samples clears the trace. + Vpp is readout-only; the trace comes from the samples. + CSS lives in the "oscilloscope" block of DUPRE_CSS; the screen-family + --scr-* vars retint it, with the original green as fallback. */ +DUPRE.scope = function (host, opts = {}) { const onChange = opts.onChange || noop; - const el = document.createElement('span'); el.className = 'scope'; - el.innerHTML = '<span class="grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>'; + const el = document.createElement('span'); el.className = 'dupre-scope'; + el.innerHTML = '<span class="dupre-grat"></span><svg viewBox="0 0 176 78" preserveAspectRatio="none"><polyline/></svg>'; host.appendChild(el); const line = el.querySelector('polyline'); + const clamp = x => Math.max(-1, Math.min(1, x)); let vpp = 0; function set(samples, v) { vpp = v; const n = samples.length; - line.setAttribute('points', samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + s * 22).toFixed(1)}`).join(' ')); + line.setAttribute('points', n < 2 ? '' : + samples.map((s, i) => `${(i / (n - 1) * 176).toFixed(1)},${(39 + clamp(s) * 22).toFixed(1)}`).join(' ')); onChange(vpp, 'Vpp ' + Math.round(vpp * 100)); } return { el, get: () => vpp, set }; }; -/* N12 spectrum / EQ bars — set(values) paints one column per band */ -GW.eqBars = function (host, opts = {}) { +/* N12 spectrum / EQ bars — set(values) paints one column per band. + + Contract (everything a consumer needs; no page globals touched): + opts: bands (columns, default 11); cells (segments per column, default + 9); onChange(values, 'peak N%') fires on every set (values 0-1 + per band, clamped; a missing band reads 0). No initial fire — + the page's tick drives it (the tick contract — the page owns the + clock and the signal). + handle: el, get() (last values array), set(values). Cells light + bottom-up: dupre-on, top three dupre-hot, topmost dupre-clip. + CSS lives in the "spectrum / EQ" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.eqBars = function (host, opts = {}) { const onChange = opts.onChange || noop; const bands = opts.bands || 11, cells = opts.cells || 9; - const el = document.createElement('span'); el.className = 'eq'; + const el = document.createElement('span'); el.className = 'dupre-eq'; host.appendChild(el); for (let b = 0; b < bands; b++) { - const band = document.createElement('span'); band.className = 'band'; + const band = document.createElement('span'); band.className = 'dupre-band'; for (let s = 0; s < cells; s++) band.appendChild(document.createElement('i')); el.appendChild(band); } @@ -2703,11 +3260,11 @@ GW.eqBars = function (host, opts = {}) { vals = values; let peak = 0; for (let b = 0; b < bands; b++) { - const col = el.children[b].children, val = values[b], lit = Math.round(val * cells); + const col = el.children[b].children, val = Math.max(0, Math.min(1, values[b] || 0)), lit = Math.round(val * cells); peak = Math.max(peak, val); for (let k = 0; k < cells; k++) { let c = ''; - if (k < lit) c = (k >= cells - 1 ? 'clip' : k >= cells - 3 ? 'hot' : 'on'); + if (k < lit) c = (k >= cells - 1 ? 'dupre-clip' : k >= cells - 3 ? 'dupre-hot' : 'dupre-on'); col[k].className = c; } } @@ -2716,17 +3273,26 @@ GW.eqBars = function (host, opts = {}) { return { el, get: () => vals, set }; }; -/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross */ -GW.crossNeedle = function (host, opts = {}) { +/* N13 crossed-needle meter — one drive value, FWD and RFL needles cross. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 55); onChange(value, 'FWD n · RFL n') fires + on every set — RFL derives from the drive value (0.68×), the way a + crossed-needle SWR face couples the two. + handle: el, get() (current value), set(v) — clamped to 0-100; drag on + the face sets by vertical delta. + CSS lives in the "crossed-needle" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.crossNeedle = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 55; - const el = document.createElement('div'); el.className = 'crossm'; - el.innerHTML = `<div class="face"><div class="arc"></div><div class="nA"></div><div class="nB"></div></div> - <div class="lbl"><span>FWD</span><span>RFL</span></div>`; + const el = document.createElement('div'); el.className = 'dupre-crossm'; + el.innerHTML = `<div class="dupre-face"><div class="dupre-crossm-arc"></div><div class="dupre-fwd"></div><div class="dupre-rfl"></div></div> + <div class="dupre-lbl"><span>FWD</span><span>RFL</span></div>`; host.appendChild(el); - const face = el.querySelector('.face'), nA = el.querySelector('.nA'), nB = el.querySelector('.nB'); + const face = el.querySelector('.dupre-face'), nA = el.querySelector('.dupre-fwd'), nB = el.querySelector('.dupre-rfl'); function set(nv) { - v = nv; + v = Math.max(0, Math.min(100, nv)); const fwd = v, rfl = v * 0.68; nA.style.transform = `rotate(${-42 + fwd / 100 * 84}deg)`; nB.style.transform = `rotate(${42 - rfl / 100 * 84}deg)`; @@ -2737,17 +3303,25 @@ GW.crossNeedle = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* N14 thermometer column — mercury rises with the value */ -GW.thermometer = function (host, opts = {}) { +/* N14 thermometer column — mercury rises with the value. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 58); onChange(value, 'N°') fires on every + set — the readout maps 0-100 onto the printed 30-90° scale. + handle: el, get() (current value), set(v) — clamped to 0-100; drag + anywhere on the column sets by vertical delta. + CSS lives in the "thermometer" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.thermometer = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 58; - const el = document.createElement('div'); el.className = 'thermo'; - el.innerHTML = `<div class="scale"><span>90</span><span>60</span><span>30</span></div> - <div class="wrapcol"><div class="tube"><div class="fill"></div></div><div class="bulb"></div></div>`; + const el = document.createElement('div'); el.className = 'dupre-thermo'; + el.innerHTML = `<div class="dupre-scale"><span>90</span><span>60</span><span>30</span></div> + <div class="dupre-wrapcol"><div class="dupre-thermo-tube"><div class="dupre-thermo-fill"></div></div><div class="dupre-bulb"></div></div>`; host.appendChild(el); - const fill = el.querySelector('.fill'); + const fill = el.querySelector('.dupre-thermo-fill'); function set(nv) { - v = nv; + v = Math.max(0, Math.min(100, nv)); fill.style.height = v + '%'; onChange(v, Math.round(30 + v / 100 * 60) + '°'); } @@ -2756,18 +3330,27 @@ GW.thermometer = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* N15 bourdon pressure gauge — needle over a printed arc with a redline */ -GW.bourdon = function (host, opts = {}) { +/* N15 bourdon pressure gauge — needle over a printed arc with a redline. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 45); onChange(value, 'N PSI') fires on every + set — the readout maps 0-100 onto a 0-160 PSI scale. + handle: el, get() (current value), set(v) — clamped to 0-100, the + needle sweeps -60°..+60°; drag anywhere on the dial sets by + vertical delta. The printed arc and redline are the builder's + own inline SVG. + CSS lives in the "bourdon" block of DUPRE_CSS; no other styles involved. */ +DUPRE.bourdon = function (host, opts = {}) { const onChange = opts.onChange || noop; let v = opts.value ?? 45; - const el = document.createElement('div'); el.className = 'bourdon'; + const el = document.createElement('div'); el.className = 'dupre-bourdon'; el.innerHTML = `<svg viewBox="0 0 82 82"><path d="M14 62 A34 34 0 0 1 68 62" fill="none" stroke="#2c2f32" stroke-width="3"/> <path d="M52 22 A34 34 0 0 1 68 62" fill="none" stroke="#cb6b4d" stroke-width="3"/></svg> - <div class="ndl"></div><div class="hub"></div><div class="cap">PSI</div>`; + <div class="dupre-bourdon-ndl"></div><div class="dupre-bourdon-hub"></div><div class="dupre-bourdon-cap">PSI</div>`; host.appendChild(el); - const ndl = el.querySelector('.ndl'); + const ndl = el.querySelector('.dupre-bourdon-ndl'); function set(nv) { - v = nv; + v = Math.max(0, Math.min(100, nv)); ndl.style.transform = `rotate(${-60 + v / 100 * 120}deg)`; onChange(v, Math.round(v / 100 * 160) + ' PSI'); } @@ -2776,16 +3359,25 @@ GW.bourdon = function (host, opts = {}) { return { el, get: () => v, set }; }; -/* N16 strip-chart recorder — scrolling history, pen rides the newest value; - push(v) appends, set(samples, current) replaces the trace (values 0..1) */ -GW.stripChart = function (host, opts = {}) { +/* N16 strip-chart recorder — scrolling history, pen rides the newest value. + + Contract (everything a consumer needs; no page globals touched): + opts: samples (history length, default 60, floor 2); value (initial + level 0-1, default 0.5); onChange(current, 'N') fires on every + paint (readout in percent). Values clamp to 0-1 at paint. + handle: el, get() (newest value), push(v) appends one sample and + scrolls, set(samples, current) replaces the whole trace (a + short or missing array back-fills 0.5). + CSS lives in the "strip-chart" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.stripChart = function (host, opts = {}) { const onChange = opts.onChange || noop; - const n = opts.samples || 60; + const n = Math.max(2, opts.samples || 60); const hist = Array.from({ length: n }, () => opts.value ?? 0.5); - const el = document.createElement('span'); el.className = 'strip'; - el.innerHTML = '<span class="rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="pen" style="top:31px"></span>'; + const el = document.createElement('span'); el.className = 'dupre-strip'; + el.innerHTML = '<span class="dupre-rule"></span><svg viewBox="0 0 176 62" preserveAspectRatio="none"><polyline/></svg><span class="dupre-pen" style="top:31px"></span>'; host.appendChild(el); - const line = el.querySelector('polyline'), pen = el.querySelector('.pen'); + const line = el.querySelector('polyline'), pen = el.querySelector('.dupre-pen'); const clamp = x => Math.max(0, Math.min(1, x)); const yPx = v => 59 - clamp(v) * 56; function paint(current) { @@ -2795,23 +3387,33 @@ GW.stripChart = function (host, opts = {}) { } function push(v) { hist.push(v); hist.shift(); paint(v); } function set(samples, current) { + if (!Array.isArray(samples)) samples = []; for (let i = 0; i < n; i++) hist[i] = samples[i] ?? 0.5; paint(current ?? hist[n - 1]); } return { el, get: () => hist[n - 1], push, set }; }; -/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right */ -GW.corrMeter = function (host, opts = {}) { +/* N17 correlation meter — needle rests at 0 and swings ±; drag left/right. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 58 — 50 is the 0 rest point); onChange(corr, + '±x.xx') fires on every set with the CORRELATION (-1..+1 mapped + from the 0-100 position), not the raw position. + handle: el, get() (current 0-100 position), set(p) — clamped to 0-100; + drag left/right sets by pointer position. + CSS lives in the "correlation" block of DUPRE_CSS; no other styles + involved. */ +DUPRE.corrMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; let p = opts.value ?? 58; - const el = document.createElement('div'); el.className = 'corr'; - el.innerHTML = `<div class="face"><div class="arc"></div><div class="zero"></div><div class="ndl"></div></div> - <div class="lbl"><span>−1</span><span>0</span><span>+1</span></div>`; + const el = document.createElement('div'); el.className = 'dupre-corr'; + el.innerHTML = `<div class="dupre-corr-face"><div class="dupre-corr-arc"></div><div class="dupre-zero"></div><div class="dupre-corr-ndl"></div></div> + <div class="dupre-corr-lbl"><span>−1</span><span>0</span><span>+1</span></div>`; host.appendChild(el); - const ndl = el.querySelector('.ndl'); + const ndl = el.querySelector('.dupre-corr-ndl'); function set(np) { - p = np; + p = Math.max(0, Math.min(100, np)); const v = (p - 50) / 50; ndl.style.transform = `rotate(${v * 38}deg)`; onChange(v, (v >= 0 ? '+' : '') + v.toFixed(2)); @@ -2821,20 +3423,28 @@ GW.corrMeter = function (host, opts = {}) { return { el, get: () => p, set }; }; -/* N18 battery-cell gauge — charge as discrete cells, the low end warns */ -GW.battCells = function (host, opts = {}) { +/* N18 battery-cell gauge — charge as discrete cells, the low end warns. + + Contract (everything a consumer needs; no page globals touched): + opts: cells (count, default 8, floor 1); warnAt (threshold, default 25 — + at or below it lit cells tint dupre-warn); value (0-100, default + 62); onChange(value, 'N%') fires on every set. + handle: el, get() (current value), set(v) — clamped to 0-100; drag + left/right sets by pointer position. Lit cells carry dupre-on. + CSS lives in the "battery" block of DUPRE_CSS; no other styles involved. */ +DUPRE.battCells = function (host, opts = {}) { const onChange = opts.onChange || noop; - const cells = opts.cells || 8, warnAt = opts.warnAt ?? 25; + const cells = Math.max(1, opts.cells || 8), warnAt = opts.warnAt ?? 25; let p = opts.value ?? 62; - const el = document.createElement('div'); el.className = 'batt'; - el.innerHTML = '<div class="cells"></div><span class="nub"></span>'; + const el = document.createElement('div'); el.className = 'dupre-batt'; + el.innerHTML = '<div class="dupre-cells"></div><span class="dupre-nub"></span>'; host.appendChild(el); - const cc = el.querySelector('.cells'); - for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'cell'; cc.appendChild(c); } + const cc = el.querySelector('.dupre-cells'); + for (let i = 0; i < cells; i++) { const c = document.createElement('span'); c.className = 'dupre-cell'; cc.appendChild(c); } function set(np) { - p = np; + p = Math.max(0, Math.min(100, np)); const lit = Math.round(p / 100 * cells); - for (let i = 0; i < cells; i++) cc.children[i].className = 'cell' + (i < lit ? ' on' : '') + ((p <= warnAt && i < lit) ? ' warn' : ''); + for (let i = 0; i < cells; i++) cc.children[i].className = 'dupre-cell' + (i < lit ? ' dupre-on' : '') + ((p <= warnAt && i < lit) ? ' dupre-warn' : ''); onChange(p, Math.round(p) + '%'); } dragX(el, set); @@ -2843,8 +3453,19 @@ GW.battCells = function (host, opts = {}) { }; /* R01 moving-coil VU — pivot below the face, authentic nonlinear dB scale; - set(t) positions instantly (0..1.02); ballistics belong to the signal owner */ -GW.mcVu = function (host, opts = {}) { + ballistics belong to the signal owner. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-1.02, default .35 — clamped, the needle paints from it + at build); onChange(t, '+x.x VU') fires on every set, no initial + fire — the readout maps t through the shared VU law (VUDB/vuDb), + the needle caps at t=1. + handle: el, get() (current t), set(t) — clamped to 0-1.02; positions + instantly, no ballistics. No drag — display-only. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the glass gradient and face clip are per-instance defs + entries. */ +DUPRE.mcVu = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 150, 96); const cx = 75, cy = 112, sweep = t => -43 + t * 86; @@ -2879,13 +3500,13 @@ GW.mcVu = function (host, opts = {}) { x: 75, y: 78, 'text-anchor': 'middle', 'font-size': 11, 'font-weight': 700, 'font-family': 'var(--mono)', fill: '#3a3128' }).textContent = 'VU'; + let t = Math.max(0, Math.min(1.02, opts.value ?? .35)); const needle = svgEl(face, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 62, stroke: '#1a1613', 'stroke-width': 1.6, - transform: `rotate(${sweep(.35)},${cx},${cy})` + transform: `rotate(${sweep(Math.min(1, t))},${cx},${cy})` }); svgEl(face, 'circle', { cx, cy: 88, r: 9, fill: '#16130f' }); svgEl(s, 'rect', { x: 8, y: 8, width: 134, height: 74, rx: 3, fill: `url(#${glassId})` }); - let t = opts.value ?? .35; function set(nt) { t = Math.max(0, Math.min(1.02, nt)); needle.setAttribute('transform', `rotate(${sweep(Math.min(1, t))},${cx},${cy})`); @@ -2895,8 +3516,17 @@ GW.mcVu = function (host, opts = {}) { return { el: s, get: () => t, set }; }; -/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down */ -GW.roundMeter = function (host, opts = {}) { +/* R07 round panel meter — porthole bezel, same VU law as R01; drag up/down. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, '+x.x dB') fires on + every set — the readout maps the linear value through the shared + VU law (VUDB/vuDb), like every VU-family meter. + handle: el, get() (current value), set(v) — the needle sweeps -40°..+40° + over the clamped 0-1 position. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the face clip is a per-instance defs entry. */ +DUPRE.roundMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 104); const cx = 55, fy = 54, py = 74, sweep = t => -40 + t * 80; @@ -2947,8 +3577,19 @@ GW.roundMeter = function (host, opts = {}) { return { el: s, get: () => v, set }; }; -/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome */ -GW.chromeMinMax = function (host, opts = {}) { +/* R08 chrome MIN/MAX indicator — dark pointer over a brushed dome. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 50); onChange(value, readout) fires on + every set — the readout says 'MIN' at ≤2, 'MAX' at ≥98, 'N%' + between. + handle: el, get() (current value), set(v) — clamped to 0-100, the + pointer sweeps -55°..+55°; drag anywhere sets by vertical + delta. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the ring and dome gradients register in the shared defs + plate. */ +DUPRE.chromeMinMax = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 104); const cx = 55, cy = 58; @@ -2990,8 +3631,17 @@ GW.chromeMinMax = function (host, opts = {}) { return { el: s, get: () => v, set }; }; -/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme */ -GW.aviationGauge = function (host, opts = {}) { +/* R09 black-face aviation gauge — zone arcs per the airspeed-indicator scheme. + + Contract (everything a consumer needs; no page globals touched): + opts: value (0-100, default 52); onChange(value, 'x.x ×100 rpm') fires + on every set — the readout maps 0-100 onto 0-8 ×100 RPM. + handle: el, get() (current value), set(v) — clamped to 0-100, the + needle sweeps -135°..+135°; drag anywhere sets by vertical + delta. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the needle-glow filter registers in the shared defs plate. */ +DUPRE.aviationGauge = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg drag', 110, 104); const cx = 55, cy = 52, sweep = t => -135 + t * 270; @@ -3044,8 +3694,17 @@ GW.aviationGauge = function (host, opts = {}) { return { el: s, get: () => v, set }; }; -/* R13 edgewise strip meter — compressed log scale, the bar rides the edge */ -GW.edgeMeter = function (host, opts = {}) { +/* R13 edgewise strip meter — compressed log scale, the bar rides the edge. + + Contract (everything a consumer needs; no page globals touched): + opts: value (fraction of the window, .08-.94, default .62); onChange( + frac, '−x.x dB') fires on every set — the readout interpolates + the compressed 0..−40 dB scale printed on the face. + handle: el, get() (current fraction), set(f) — clamped to .08-.94 (the + printed scale's span); drag up/down sets by pointer position. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; the face gradient is a per-instance defs entry. */ +DUPRE.edgeMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const SCALE = [[0, .08], [3, .26], [6, .44], [12, .62], [20, .78], [40, .94]]; const s = stageSvg(host, 'rsvg drag', 70, 130); @@ -3086,9 +3745,19 @@ GW.edgeMeter = function (host, opts = {}) { return { el: s, get: () => frac, set }; }; -/* R17 round CRT scope — pale phosphor face; the trace is the widget's own - animation, so it lives here with its reduced-motion gate */ -GW.roundCrt = function (host, opts = {}) { +/* R17 round CRT scope — pale phosphor face; the trace is the instrument's own + animation, so it lives here with its reduced-motion gate. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(amp, 'Vpp N') fires on every animation tick, including + the initial paint — the demo waveform is instrument-owned. + handle: el, tick() — one animation step (the reduced-motion path paints + once and never ticks again on its own). + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate, except the + face gradient, which reads screen-family vars from this instrument's + subtree and so lives in a local defs. */ +DUPRE.roundCrt = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 110, 104); const cx = 55, cy = 52; @@ -3098,7 +3767,7 @@ GW.roundCrt = function (host, opts = {}) { const fl = svgEl(d, 'filter', { id: 'avGlow', x: '-60%', y: '-60%', width: '220%', height: '220%' }); svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.4 }); }); - /* the face gradient reads screen-family vars from this widget's subtree, + /* the face gradient reads screen-family vars from this instrument's subtree, so it must live in the local defs, not the shared def sink */ const defs = svgEl(s, 'defs', {}); const ph = svgEl(defs, 'radialGradient', { id: faceId, cx: '50%', cy: '44%', r: '70%' }); @@ -3138,8 +3807,17 @@ GW.roundCrt = function (host, opts = {}) { return { el: s, tick }; }; -/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */ -GW.attitudeIndicator = function (host, opts = {}) { +/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag. + + Contract (everything a consumer needs; no page globals touched): + opts: bank (initial degrees, -60..60, default 0); pitch (initial + degrees, -20..20, default 0); onChange({bank, pitch}, 'BANK … + · PITCH …') fires on every set, including the initial paint. + handle: el, get() ({bank, pitch}), set(bank, pitch) — both clamp to + their ranges; the stage itself is a 2D drag surface. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.attitudeIndicator = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 130); const cx = 65, cy = 65; @@ -3194,8 +3872,17 @@ GW.attitudeIndicator = function (host, opts = {}) { }; /* R44 heading bug + servo needle — drag parks the command; the needle chases - with honest servo lag (widget-owned animation, reduced-motion gated) */ -GW.headingBug = function (host, opts = {}) { + with honest servo lag (instrument-owned animation, reduced-motion gated). + + Contract (everything a consumer needs; no page globals touched): + opts: value (initial commanded heading in degrees, default 90 — + normalized to 0..360); onChange({cmd, act}, 'CMD … · ACT …') + fires on every set and on every servo step. + handle: el, get() ({cmd, act}), set(v) — v normalizes to 0..360; + dragging the stage moves the command bug, the needle chases. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.headingBug = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 130); const cx = 65, cy = 65; @@ -3225,7 +3912,7 @@ GW.headingBug = function (host, opts = {}) { fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6 }); svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); - let cmd = opts.value ?? 90, act = cmd; + let cmd = ((opts.value ?? 90) % 360 + 360) % 360, act = cmd; const draw = () => { bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`); needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`); @@ -3245,8 +3932,16 @@ GW.headingBug = function (host, opts = {}) { }; /* R53 circular chart recorder — a day per revolution; the pen draws the cycle - (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */ -GW.chartRecorder = function (host, opts = {}) { + (instrument-owned clock, reduced-motion paints the full day once); click for fresh paper. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange(hour, 'HH:MM · N%') fires on every pen step — the demo + day-cycle signal is instrument-owned. + handle: el, get() (current hour, 0..24), reset() — fresh paper, pen to + 00:00; clicking the stage does the same. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.chartRecorder = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 130, 130); const cx = 65, cy = 65; @@ -3295,8 +3990,17 @@ GW.chartRecorder = function (host, opts = {}) { return { el: s, get: () => hour, reset }; }; -/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */ -GW.verticalTape = function (host, opts = {}) { +/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive. + + Contract (everything a consumer needs; no page globals touched): + opts: value (initial units, 5..35 on the printed RPM x100 scale, + default 24); onChange(value, 'RPM N') fires on every set, + including the initial paint. + handle: el, get() (units), set(v) — v clamps 5..35; the stage drags + vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.verticalTape = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 70, 130); const CX = 38, CY = 65; @@ -3337,8 +4041,17 @@ GW.verticalTape = function (host, opts = {}) { return { el: s, get: () => v, set }; }; -/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */ -GW.twinNeedle = function (host, opts = {}) { +/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles. + + Contract (everything a consumer needs; no page globals touched): + opts: fuel (initial, 0..4, default 2.4); oil (initial, 0..4, default + 3.1); onChange([fuel, oil], 'FUEL n.n · OIL n.n') fires on every + set, including the initial paint. + handle: el, get() ([fuel, oil]), set(fuel, oil) — both clamp 0..4; + each half of the stage drags its own needle vertically. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.twinNeedle = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 120, 110); const cx = 60, cy = 62, R = 44; @@ -3386,7 +4099,7 @@ GW.twinNeedle = function (host, opts = {}) { }; const nF = needle('#e0523a'), nO = needle('#bfc4d0'); svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); - let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1; + let vF, vO; const draw = () => { nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`); nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`); @@ -3401,12 +4114,22 @@ GW.twinNeedle = function (host, opts = {}) { }; half(0, () => vF, v => vF = v); half(60, () => vO, v => vO = v); - draw(); + set(opts.fuel ?? 2.4, opts.oil ?? 3.1); return { el: s, get: () => [vF, vO], set }; }; -/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */ -GW.comfortMeter = function (host, opts = {}) { +/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts. + + Contract (everything a consumer needs; no page globals touched): + opts: temp (initial °F, 40..100, default 72); humidity (initial %, + 0..100, default 45); onChange([temp, humidity], 'NF · N% RH · + VERDICT') fires on every set, including the initial paint. + handle: el, get() ([temp, humidity]), set(temp, humidity) — both clamp + to their scales; the left half of the stage drags temp, the + right half humidity. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.comfortMeter = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 130, 122); const cx = 65, cy = 60; @@ -3478,7 +4201,7 @@ GW.comfortMeter = function (host, opts = {}) { const nT = needle(), nH = needle(); nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`); nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`); - let vT = opts.temp ?? 72, vH = opts.humidity ?? 45; + let vT, vH; const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right'; const draw = () => { nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`); @@ -3495,16 +4218,25 @@ GW.comfortMeter = function (host, opts = {}) { }; half(0, () => vT, v => vT = v, 40, 100); half(65, () => vH, v => vH = v, 0, 100); - draw(); + set(opts.temp ?? 72, opts.humidity ?? 45); return { el: s, get: () => [vT, vH], set }; }; /* ================= indicators & readouts ================= */ -/* 18 status lamps — one lamp per health state; click any lamp to cycle it */ -GW.statusLamps = function (host, opts = {}) { +/* 18 status lamps — one lamp per health state; click any lamp to cycle it. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array of state indices, default [0,1,2,3,4] — one lamp + per entry; 0 ok · 1 engaged · 2 fault · 3 off · 4 busy); + onChange(states array copy, state name) fires per change and once + at build with a count summary. + handle: el, get() (states copy), set(i, st) — st wraps modulo 5. + CSS lives in the "shared primitives" .dupre-lamp block of DUPRE_CSS + (shared with the output well's step lamps); no other styles involved. */ +DUPRE.statusLamps = function (host, opts = {}) { const onChange = opts.onChange || noop; - const CLS = ['lamp', 'lamp gold', 'lamp red', 'lamp off', 'lamp busy']; + const CLS = ['dupre-lamp', 'dupre-lamp dupre-gold', 'dupre-lamp dupre-red', 'dupre-lamp dupre-off', 'dupre-lamp dupre-busy']; const NAMES = ['ok', 'engaged', 'fault', 'off', 'busy']; const states = (opts.states || [0, 1, 2, 3, 4]).slice(); const wrap = document.createElement('span'); @@ -3524,15 +4256,25 @@ GW.statusLamps = function (host, opts = {}) { return { el: wrap, get: () => states.slice(), set }; }; -/* 19 badges — labelled flags; click a badge to cycle its variant */ -GW.badges = function (host, opts = {}) { +/* 19 badges — labelled flags; click a badge to cycle its variant. + + Contract (everything a consumer needs; no page globals touched): + opts: items (array of [label, variant] pairs, variant 0 gold · 1 red · + 2 ghost, default a three-badge demo set); onChange(variants array + copy, label) fires per change and once at build with a count + summary. + handle: el, get() (variants copy), set(i, v) — v wraps modulo 3. + CSS lives in the .dupre-badge block of DUPRE_CSS; no other styles + involved. */ +DUPRE.badges = function (host, opts = {}) { const onChange = opts.onChange || noop; - const CLS = ['badge', 'badge red', 'badge ghost']; + const CLS = ['dupre-badge', 'dupre-badge dupre-red', 'dupre-badge dupre-ghost']; + const norm = v => (((v | 0) % CLS.length) + CLS.length) % CLS.length; const items = opts.items || [['TUNNEL', 0], ['LOW BATT', 1], ['2.4G', 2]]; - const vars = items.map(it => it[1]); + const vars = items.map(it => norm(it[1])); const wrap = document.createElement('span'); - const els = items.map(([txt, v], i) => { - const b = document.createElement('span'); b.className = CLS[v]; b.textContent = txt; b.style.cursor = 'pointer'; + const els = items.map(([txt], i) => { + const b = document.createElement('span'); b.className = CLS[vars[i]]; b.textContent = txt; b.style.cursor = 'pointer'; b.addEventListener('click', () => set(i, vars[i] + 1)); wrap.appendChild(b); if (i < items.length - 1) wrap.appendChild(document.createTextNode(' ')); @@ -3540,7 +4282,7 @@ GW.badges = function (host, opts = {}) { }); host.appendChild(wrap); function set(i, v) { - vars[i] = ((v % CLS.length) + CLS.length) % CLS.length; + vars[i] = norm(v); els[i].className = CLS[vars[i]]; onChange(vars.slice(), items[i][0]); } @@ -3549,13 +4291,24 @@ GW.badges = function (host, opts = {}) { }; /* 20 tabular readout — mm:ss countdown; builder owns the state, the page - drives tick() on its own clock (tick contract); click pauses/resumes */ -GW.tabularReadout = function (host, opts = {}) { + drives tick() on its own clock (tick contract); click pauses/resumes. + + Contract (everything a consumer needs; no page globals touched): + opts: secs (initial seconds, wraps modulo 3600, default 24:10); run + (initial running state, default true); unit (caption under the + digits, default 'timer'); onChange(secs, 'running'/'paused') + fires on every set, including the initial paint. + handle: el, get() (secs), set(v) — v wraps modulo 3600; tick() — one + second down when running (the page's clock drives it); clicking + the digits pauses/resumes. + CSS lives in the .dupre-readout block of DUPRE_CSS; no other styles + involved. */ +DUPRE.tabularReadout = function (host, opts = {}) { const onChange = opts.onChange || noop; const wrap = document.createElement('div'); wrap.style.textAlign = 'center'; - wrap.innerHTML = '<div class="readout"></div><span class="u"></span>'; - const out = wrap.querySelector('.readout'); - wrap.querySelector('.u').textContent = opts.unit || 'timer'; + wrap.innerHTML = '<div class="dupre-readout"></div><span class="dupre-unit"></span>'; + const out = wrap.querySelector('.dupre-readout'); + wrap.querySelector('.dupre-unit').textContent = opts.unit || 'timer'; host.appendChild(wrap); let secs, run = opts.run !== undefined ? !!opts.run : true; const draw = () => { @@ -3568,12 +4321,21 @@ GW.tabularReadout = function (host, opts = {}) { return { el: wrap, get: () => secs, set, tick: () => { if (run) set(secs - 1); } }; }; -/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9 */ -GW.engravedLabel = function (host, opts = {}) { +/* 21 engraved label — hairline-flanked caps label with a count; click bumps 1-9. + + Contract (everything a consumer needs; no page globals touched): + opts: label (caption text, default 'outputs'); count (initial count, + default 3); onChange(count, 'count N') fires on every set, + including the initial paint. + handle: el, get() (count), set(v); clicking the label cycles the count + 1..9. + CSS lives in the .dupre-engrave block of DUPRE_CSS; no other styles + involved. */ +DUPRE.engravedLabel = function (host, opts = {}) { const onChange = opts.onChange || noop; - const e = document.createElement('span'); e.className = 'engrave'; + const e = document.createElement('span'); e.className = 'dupre-engrave'; e.append(opts.label || 'outputs'); - const c = document.createElement('span'); c.className = 'cnt'; e.appendChild(c); + const c = document.createElement('span'); c.className = 'dupre-cnt'; e.appendChild(c); host.appendChild(e); let n; const set = v => { n = v; c.textContent = '· ' + n; onChange(n, 'count ' + n); }; @@ -3584,15 +4346,28 @@ GW.engravedLabel = function (host, opts = {}) { /* 22 output well — streaming step log, lamp per step; click streams the next demo step; push([lampCls, name, evidence]) appends programmatically */ -GW.outputWell = function (host, opts = {}) { +/* 22 output well — a scrolling log of status steps; click streams the next demo + step, oldest rows scroll off once the well is full. + + Contract (everything a consumer needs; no page globals touched): + opts: seed (initial rows, default two); steps (the click-cycle demo pool); + keep (max visible rows before the oldest scrolls off, default 5). + Each row is [tone, label, detail]; tone is '' | 'gold' | 'red' and + lights the row's dupre-lamp accent. onChange(row|null, caption) fires + on every push and once at build with a hint. + handle: el, push(row) — appends a row and trims the well to keep. Click + advances through steps and pushes the next. + CSS lives in the "output well" block of DUPRE_CSS; the row lamp is the shared + .dupre-lamp primitive tinted by the tone accent. */ +DUPRE.outputWell = function (host, opts = {}) { const onChange = opts.onChange || noop; const seed = opts.seed || [['', 'Link', 'wlp170s0 · @Hyatt'], ['gold', 'DNS', 'resolving…']]; const steps = opts.steps || [['gold', 'Probe', '8.8.8.8 …'], ['', 'Gateway', '10.0.0.1 ok'], ['', 'DNS', '1.1.1.1 ok'], ['red', 'Retry', 'timeout']]; const keep = opts.keep || 5; - const w = document.createElement('div'); w.className = 'owell'; host.appendChild(w); + const w = document.createElement('div'); w.className = 'dupre-owell'; host.appendChild(w); const add = s => { - const d = document.createElement('div'); d.className = 'ostep'; - d.innerHTML = `<span class="lamp ${s[0]}"></span><span><b>${s[1]}</b><span class="ev">${s[2]}</span></span>`; + const d = document.createElement('div'); d.className = 'dupre-ostep'; + d.innerHTML = `<span class="dupre-lamp${s[0] ? ' dupre-' + s[0] : ''}"></span><span><b>${s[1]}</b><span class="dupre-ev">${s[2]}</span></span>`; w.appendChild(d); while (w.children.length > keep) w.removeChild(w.firstChild); }; seed.forEach(add); @@ -3604,11 +4379,19 @@ GW.outputWell = function (host, opts = {}) { }; /* 23 toast — one-line transient confirmation; click fires the next demo - message; fire(msg) shows any message with the fade-in */ -GW.toast = function (host, opts = {}) { + message; fire(msg) shows any message with the fade-in. + + Contract (everything a consumer needs; no page globals touched): + opts: msgs (the demo message pool, cycled by click); text (initial + message, default the last pool entry); onChange(n, 'fired n') fires + on each click and once at build with a hint. + handle: el, fire(msg) — shows any message with the opacity fade-in; the + page can drive it with its own text. Click cycles the demo pool. + CSS lives in the "toast" block of DUPRE_CSS; no other styles involved. */ +DUPRE.toast = function (host, opts = {}) { const onChange = opts.onChange || noop; const msgs = opts.msgs || ['link up · 300 Mbps', 'bt paired — WH-1000XM4', 'profile switched', 'joined @Hyatt_WiFi — saved']; - const t = document.createElement('span'); t.className = 'toastw'; + const t = document.createElement('span'); t.className = 'dupre-toastw'; t.textContent = opts.text || msgs[msgs.length - 1]; host.appendChild(t); const fire = msg => { @@ -3621,14 +4404,22 @@ GW.toast = function (host, opts = {}) { return { el: t, fire }; }; -/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments */ -GW.nixie = function (host, opts = {}) { +/* 26 nixie tubes — one lit numeral per tube, leading zeros dark; click increments. + + Contract (everything a consumer needs; no page globals touched): + opts: digits (tube count, default 2); value (initial, default 8); + onChange(value, zero-padded string) fires on every set. + handle: el, get() (current value), set(v) — wraps modulo 10^digits; + leading-zero tubes go dark rather than showing 0, like the + hardware. + CSS lives in the "nixie tube" block of DUPRE_CSS; no other styles involved. */ +DUPRE.nixie = function (host, opts = {}) { const onChange = opts.onChange || noop; const digits = opts.digits || 2; const max = Math.pow(10, digits); - const nx = document.createElement('span'); nx.className = 'nixie'; + const nx = document.createElement('span'); nx.className = 'dupre-nixie'; for (let i = 0; i < digits; i++) { - const tu = document.createElement('span'); tu.className = 'tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu); + const tu = document.createElement('span'); tu.className = 'dupre-tube'; tu.innerHTML = '<b></b>'; nx.appendChild(tu); } host.appendChild(nx); let v; @@ -3636,7 +4427,7 @@ GW.nixie = function (host, opts = {}) { v = ((x % max) + max) % max; const s = String(v).padStart(digits, '0'); [...nx.children].forEach((tu, i) => { - tu.classList.toggle('off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); + tu.classList.toggle('dupre-off', i < digits - 1 && v < Math.pow(10, digits - 1 - i)); tu.querySelector('b').textContent = s[i]; }); onChange(v, s); @@ -3659,9 +4450,9 @@ GW.nixie = function (host, opts = {}) { Contract (everything a consumer needs; no page globals touched): opts: rows (1) x cells (4) grid; chars (flap order, default space+A-Z+0-9); - flapMs (85, per-cell rate jittered 0.8x-1.35x so letters finish + flapMs (100, per-cell rate jittered 0.8x-1.35x so letters finish at different times, base mutable via setFlapMs); animate (true); - skin ('white'|'dark'|'light'|'paper', card default white); font ('mono'|'helv'); + skin ('paper'|'white'|'dark'|'light', card default paper); font ('helv'|'mono', card default helv); words (the demo pool, at least rows+1 entries: next() sends every row to a different random word, mutually distinct; set(i) pins the top row for determinism); onChange(idx|-1, top word) fires at @@ -3671,8 +4462,8 @@ GW.nixie = function (host, opts = {}) { setStyle(axis, name), chars, flapMs()/setFlapMs(ms) (base flap rate, clamped 20-400), onSettle(cb) — cb(reading) fires once per command when every cell has arrived (dwell hook). - CSS lives in the "split-flap" block of GW_CSS; no other styles involved. */ -GW.splitFlap = function (host, opts = {}) { + CSS lives in the "split-flap" block of DUPRE_CSS; no other styles involved. */ +DUPRE.splitFlap = function (host, opts = {}) { const onChange = opts.onChange || noop; const words = opts.words || ['DNS ', 'LINK', 'SYNC', 'WIFI', 'SCAN']; const animate = opts.animate !== undefined ? !!opts.animate : true; @@ -3680,7 +4471,7 @@ GW.splitFlap = function (host, opts = {}) { const cells = opts.cells || 4; const chars = opts.chars || ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const clampMs = ms => Math.min(400, Math.max(20, ms | 0)); - let flapMs = clampMs(opts.flapMs || 85); + let flapMs = clampMs(opts.flapMs || 100); const ci = ch => Math.max(0, chars.indexOf(ch)); const fit = (str, w) => String(str).padEnd(w, ' ').slice(0, w); @@ -3802,7 +4593,7 @@ GW.splitFlap = function (host, opts = {}) { // construction axes (skin, font): swap this axis's classes per STYLES. // Polarity and typeface are construction, not accent — no colour policy. const setStyle = (axis, name) => { - const ax = GW.splitFlap.STYLES[axis]; const o = ax && ax[name]; + const ax = DUPRE.splitFlap.STYLES[axis]; const o = ax && ax[name]; if (!o) return; Object.values(ax).forEach(v => { if (v.cls) f.classList.remove(v.cls); }); if (o.cls) f.classList.add(o.cls); @@ -3818,34 +4609,43 @@ GW.splitFlap = function (host, opts = {}) { flapMs: () => flapMs, setFlapMs: ms => { flapMs = clampMs(ms); }, }; }; -GW.splitFlap.STYLES = { +DUPRE.splitFlap.STYLES = { // board polarity + ink: dark board with the kit's cream ink (default), // dark board with true-white ink, or the inverse ivory board // dot: the board colour; the gallery card letters each chip in the skin's // ink so the swatch is a miniature flap cell, not an ambiguous colour dot skin: { + paper: { cls: 'flap-paper', dot: '#ffffff' }, white: { cls: 'flap-white', dot: '#141210' }, dark: { cls: '', dot: '#141210' }, light: { cls: 'flap-light', dot: '#f3e7c5' }, - paper: { cls: 'flap-paper', dot: '#ffffff' }, }, // typeface: the kit's Berkeley Mono, or the grotesque real boards wore font: { - mono: { cls: '', dot: '#0d0c0b' }, helv: { cls: 'flap-helv', dot: '#0d0c0b' }, + mono: { cls: '', dot: '#0d0c0b' }, }, }; /* N21 seven-segment countdown — mm:ss in lit segments; the page drives tick(); - click adds a minute */ -GW.sevenSeg = function (host, opts = {}) { + click adds a minute. + + Contract (everything a consumer needs; no page globals touched): + opts: secs (initial seconds, default 24:10); onChange(secs, 'mm:ss') + fires on every set. + handle: el, get() (current seconds), set(v) — wraps modulo 3600; + tick() steps one second down (the tick contract — the page owns + the clock); click adds 60. + CSS lives in the "seven-segment" block of DUPRE_CSS; the digit glyphs come + from the shared seg7() engine helper (.seg7, shared with the DSKY). */ +DUPRE.sevenSeg = function (host, opts = {}) { const onChange = opts.onChange || noop; - const sv = document.createElement('span'); sv.className = 'seven'; host.appendChild(sv); + const sv = document.createElement('span'); sv.className = 'dupre-seven'; host.appendChild(sv); let secs; const set = v => { secs = ((v % 3600) + 3600) % 3600; const mm = String(Math.floor(secs / 60)).padStart(2, '0'), ss = String(secs % 60).padStart(2, '0'); - sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]); + sv.innerHTML = seg7(mm[0]) + seg7(mm[1]) + '<span class="dupre-colon"><i></i><i></i></span>' + seg7(ss[0]) + seg7(ss[1]); onChange(secs, mm + ':' + ss); }; sv.addEventListener('click', () => set(secs + 60)); @@ -3853,14 +4653,23 @@ GW.sevenSeg = function (host, opts = {}) { return { el: sv, get: () => secs, set, tick: () => set(secs - 1) }; }; -/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message */ -GW.vfdMarquee = function (host, opts = {}) { +/* N22 VFD marquee — teal dot-matrix scroll; the page drives tick(); click cycles the message. + + Contract (everything a consumer needs; no page globals touched): + opts: msgs (message pool, default demo set); width (window px, 176); + onChange(msg index, 'msg i/n') fires on click-cycle and once at + build. + handle: el, get() (current message index), tick() — advances the scroll + 1.1px and wraps when the text clears the window (the tick + contract — the page owns the clock). + CSS lives in the "VFD marquee" block of DUPRE_CSS; no other styles involved. */ +DUPRE.vfdMarquee = function (host, opts = {}) { const onChange = opts.onChange || noop; const msgs = opts.msgs || ['ARCHSETUP · NET OK · BT 2 · SND 62%', 'WIFI @Hyatt · 300 Mbps · VPN UP', 'BATTERY 84% · DISK 61% · TEMP 47C']; const W = opts.width || 176; - const m = document.createElement('span'); m.className = 'vfdm'; - m.innerHTML = '<span class="txt"></span><span class="mesh"></span>'; - const t = m.querySelector('.txt'); t.textContent = msgs[0]; + const m = document.createElement('span'); m.className = 'dupre-vfdm'; + m.innerHTML = '<span class="dupre-txt"></span><span class="dupre-mesh"></span>'; + const t = m.querySelector('.dupre-txt'); t.textContent = msgs[0]; host.appendChild(m); let mi = 0, x = W; m.addEventListener('click', () => { @@ -3875,28 +4684,42 @@ GW.vfdMarquee = function (host, opts = {}) { }; /* N23 annunciator — named alarm grid with the raise → MSTR CAUTION → ACK → RESET - lifecycle; TEST proves the bulbs then restores the board */ -GW.annunciator = function (host, opts = {}) { + lifecycle; TEST proves the bulbs then restores the board. + + Contract (everything a consumer needs; no page globals touched): + opts: cells (array of [label, state] where state is 0 ok · 1 warn · + 2 fault; defaults to a six-cell demo). onChange fires on every + transition: (count, summary) after a refresh, (index, 'label → name') + after a cell change, (null, 'lamp test') during TEST. + handle: el, get() (per-cell state array 0/1/2), set(i, st) — st clamps to + the 0..2 lens range at build AND at set, so an out-of-range caller + can't paint an undefined class. A new warn/fault re-arms the + MSTR CAUTION flasher; ACK stops it, RESET clears the board. + CSS lives in the "annunciator" block of DUPRE_CSS; the ACK/TEST/RESET buttons + are the shared .dupre-key primitive; the board uses the kit-wide dupre-warn + modifier and MSTR CAUTION the kit-wide dupre-on. */ +DUPRE.annunciator = function (host, opts = {}) { const onChange = opts.onChange || noop; - const CLS = ['acell', 'acell warn', 'acell fault']; + const CLS = ['dupre-acell', 'dupre-acell dupre-warn', 'dupre-acell dupre-fault']; const NAMES = ['ok', 'warn', 'fault']; + const clampSt = st => Math.max(0, Math.min(CLS.length - 1, st | 0)); const cells = opts.cells || [['SYNC', 0], ['LOW BATT', 1], ['LINK', 0], ['NO DNS', 2], ['VPN', 0], ['DISK', 0]]; - const wrap = document.createElement('div'); wrap.className = 'annwrap'; - const grid = document.createElement('div'); grid.className = 'annun'; wrap.appendChild(grid); - const bar = document.createElement('div'); bar.className = 'annbar'; - const mc = document.createElement('span'); mc.className = 'mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc); + const wrap = document.createElement('div'); wrap.className = 'dupre-annwrap'; + const grid = document.createElement('div'); grid.className = 'dupre-annun'; wrap.appendChild(grid); + const bar = document.createElement('div'); bar.className = 'dupre-annbar'; + const mc = document.createElement('span'); mc.className = 'dupre-mc'; mc.textContent = 'MSTR CAUTION'; bar.appendChild(mc); ['ACK', 'TEST', 'RESET'].forEach(a => { - const b = document.createElement('button'); b.className = 'key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b); + const b = document.createElement('button'); b.className = 'dupre-key'; b.dataset.k = a; b.textContent = a; bar.appendChild(b); }); wrap.appendChild(bar); host.appendChild(wrap); const els = cells.map(([label, st]) => { - const c = document.createElement('span'); c.className = CLS[st]; c.textContent = label; grid.appendChild(c); return c; + const c = document.createElement('span'); c.className = CLS[clampSt(st)]; c.textContent = label; grid.appendChild(c); return c; }); let acked = false; - const active = () => grid.querySelectorAll('.warn,.fault').length; + const active = () => grid.querySelectorAll('.dupre-warn,.dupre-fault').length; const refresh = () => { const n = active(); - mc.classList.toggle('on', n > 0); mc.classList.toggle('fl', n > 0 && !acked); + mc.classList.toggle('dupre-on', n > 0); mc.classList.toggle('dupre-fl', n > 0 && !acked); onChange(n, n === 0 ? 'clear' : n + ' active · ' + (acked ? 'ACK' : 'UNACK')); }; els.forEach((c, i) => c.addEventListener('click', () => { @@ -3904,56 +4727,79 @@ GW.annunciator = function (host, opts = {}) { if (j > 0) acked = false; /* a new alarm re-arms the flasher */ refresh(); onChange(j, cells[i][0] + ' → ' + NAMES[j]); })); - bar.querySelectorAll('.key').forEach(k => k.addEventListener('click', () => { + bar.querySelectorAll('.dupre-key').forEach(k => k.addEventListener('click', () => { const a = k.dataset.k; if (a === 'ACK') { if (active() > 0) acked = true; refresh(); } - else if (a === 'RESET') { els.forEach(c => c.className = 'acell'); acked = false; refresh(); } + else if (a === 'RESET') { els.forEach(c => c.className = 'dupre-acell'); acked = false; refresh(); } else if (a === 'TEST') { const prev = els.map(c => c.className); - els.forEach(c => c.className = 'acell fault'); - mc.classList.add('on', 'fl'); onChange(null, 'lamp test'); + els.forEach(c => c.className = 'dupre-acell dupre-fault'); + mc.classList.add('dupre-on', 'dupre-fl'); onChange(null, 'lamp test'); setTimeout(() => { els.forEach((c, i) => c.className = prev[i]); refresh(); }, 1100); } })); refresh(); return { el: wrap, get: () => els.map(c => Math.max(0, CLS.indexOf(c.className.trim()))), - set: (i, st) => { els[i].className = CLS[st]; if (st > 0) acked = false; refresh(); } + set: (i, st) => { els[i].className = CLS[clampSt(st)]; if (clampSt(st) > 0) acked = false; refresh(); } }; }; -/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark */ -GW.jewels = function (host, opts = {}) { +/* N24 jewel pilot lamps — faceted bezel indicators; click cycles red · amber · green · dark. + + Contract (everything a consumer needs; no page globals touched): + opts: states (array, one jewel per entry: 0 red · 1 amber · 2 green · + -1 dark; default [0,1,2,-1]); onChange(state, name) fires per + change and once at build with a hint. + handle: el, get() (states copy), set(i, st) — st clamps to the lens + range, negatives dark the jewel. State lives in here; the DOM + is a paint of it (was the reverse — the old builder kept state + only in classList/--jc, so nothing could read or drive it). + CSS lives in the "jewel" block of DUPRE_CSS; no other styles involved. */ +DUPRE.jewels = function (host, opts = {}) { const onChange = opts.onChange || noop; const cols = ['var(--jewel-r)', 'var(--jewel-a)', 'var(--jewel-g)']; const NAMES = ['red', 'amber', 'green']; - const init = opts.states || [0, 1, 2, -1]; /* index into cols; -1 = dark */ + const states = (opts.states || [0, 1, 2, -1]).slice(); /* index into cols; -1 = dark */ const wrap = document.createElement('span'); wrap.style.cssText = 'display:inline-flex;gap:10px;align-items:center'; - init.forEach(st => { - const j = document.createElement('span'); j.className = 'jewel' + (st < 0 ? ' dim' : ''); - if (st >= 0) j.style.setProperty('--jc', cols[st]); - j.addEventListener('click', () => { - if (j.classList.contains('dim')) { j.classList.remove('dim'); j.style.setProperty('--jc', cols[0]); onChange(0, 'red'); return; } - const cur = j.style.getPropertyValue('--jc').trim(); const i = cols.indexOf(cur); - if (i >= cols.length - 1 || i < 0) { j.classList.add('dim'); onChange(-1, 'dark'); } - else { j.style.setProperty('--jc', cols[i + 1]); onChange(i + 1, NAMES[i + 1]); } - }); - wrap.appendChild(j); + const paint = i => { + const st = states[i], j = els[i]; + j.className = 'dupre-jewel' + (st < 0 ? ' dupre-dim' : ''); + if (st >= 0) j.style.setProperty('--jc', cols[st]); else j.style.removeProperty('--jc'); + }; + function set(i, st) { + states[i] = st < 0 ? -1 : Math.min(st, cols.length - 1); + paint(i); + onChange(states[i], states[i] < 0 ? 'dark' : NAMES[states[i]]); + } + const els = states.map((_, i) => { + const j = document.createElement('span'); + j.addEventListener('click', () => set(i, states[i] >= cols.length - 1 ? -1 : states[i] + 1)); + wrap.appendChild(j); return j; }); host.appendChild(wrap); + states.forEach((_, i) => paint(i)); onChange(null, 'click to cycle'); - return { el: wrap }; + return { el: wrap, get: () => states.slice(), set }; }; -/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit */ -GW.tapeCounter = function (host, opts = {}) { +/* N25 tape counter — odometer wheels; set(total) rolls each wheel to its digit. + + Contract (everything a consumer needs; no page globals touched): + opts: wheels (digit count, default 6); redFrom (index from which the + high-order wheels wear the red band, default 4); value (initial, + default 471300); onChange(value, zero-padded string) fires on set. + handle: el, get() (current total), set(total) — rounds fractional input + and wraps modulo 10^wheels, then rolls each wheel to its digit. + CSS lives in the "tape counter" block of DUPRE_CSS; no other styles involved. */ +DUPRE.tapeCounter = function (host, opts = {}) { const onChange = opts.onChange || noop; const wheels = opts.wheels || 6, redFrom = opts.redFrom !== undefined ? opts.redFrom : 4; - const ct = document.createElement('span'); ct.className = 'counter'; host.appendChild(ct); + const ct = document.createElement('span'); ct.className = 'dupre-counter'; host.appendChild(ct); for (let idx = 0; idx < wheels; idx++) { - const w = document.createElement('span'); w.className = 'cwheel' + (idx >= redFrom ? ' redw' : ''); - const col = document.createElement('span'); col.className = 'col'; + const w = document.createElement('span'); w.className = 'dupre-cwheel' + (idx >= redFrom ? ' dupre-redw' : ''); + const col = document.createElement('span'); col.className = 'dupre-col'; for (let n = -1; n <= 10; n++) { const sp = document.createElement('span'); sp.textContent = ((n + 10) % 10); col.appendChild(sp); } w.appendChild(col); ct.appendChild(w); } @@ -3962,7 +4808,7 @@ GW.tapeCounter = function (host, opts = {}) { const set = x => { v = ((Math.round(x) % max) + max) % max; const s = String(v).padStart(wheels, '0'); - [...ct.children].forEach((w, i) => { w.querySelector('.col').style.top = (-(+s[i] + 1) * 34) + 'px'; }); + [...ct.children].forEach((w, i) => { w.querySelector('.dupre-col').style.top = (-(+s[i] + 1) * 34) + 'px'; }); onChange(v, s); }; set(opts.value !== undefined ? opts.value : 471300); @@ -3970,14 +4816,22 @@ GW.tapeCounter = function (host, opts = {}) { }; /* N26 analog clock — engraved ticks + three hands; the page owns the time source - and drives set(h, m, s); silent until the first set, like the live original */ -GW.analogClock = function (host, opts = {}) { + and drives set(h, m, s); silent until the first set, like the live original. + + Contract (everything a consumer needs; no page globals touched): + opts: onChange([h, m, s], 'hh:mm:ss') fires on every set. + handle: el, set(h, m, s) — the minute hand carries a seconds fraction + and the hour hand a minutes fraction, so the face reads like a + real movement. Display-only: no state to get, no clock of its + own (the tick contract — the page owns the time source). + CSS lives in the "analog clock" block of DUPRE_CSS; no other styles involved. */ +DUPRE.analogClock = function (host, opts = {}) { const onChange = opts.onChange || noop; - const cl = document.createElement('span'); cl.className = 'clock'; - for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } - cl.insertAdjacentHTML('beforeend', '<span class="hh"></span><span class="mh"></span><span class="sh"></span><span class="pin"></span>'); + const cl = document.createElement('span'); cl.className = 'dupre-clock'; + for (let i = 0; i < 12; i++) { const t = document.createElement('span'); t.className = 'dupre-tk'; t.style.transform = `rotate(${i * 30}deg)`; cl.appendChild(t); } + cl.insertAdjacentHTML('beforeend', '<span class="dupre-hh"></span><span class="dupre-mh"></span><span class="dupre-sh"></span><span class="dupre-pin"></span>'); host.appendChild(cl); - const hh = cl.querySelector('.hh'), mh = cl.querySelector('.mh'), sh = cl.querySelector('.sh'); + const hh = cl.querySelector('.dupre-hh'), mh = cl.querySelector('.dupre-mh'), sh = cl.querySelector('.dupre-sh'); const set = (h, m, s) => { sh.style.transform = `rotate(${s * 6}deg)`; mh.style.transform = `rotate(${m * 6 + s * 0.1}deg)`; @@ -3988,24 +4842,35 @@ GW.analogClock = function (host, opts = {}) { }; /* N27 frequency-dial scale — printed log axis, marks crowd low and spread high; - drag the pointer to tune */ -GW.freqScale = function (host, opts = {}) { + drag the pointer to tune. + + Contract (everything a consumer needs; no page globals touched): + opts: width (px, default 184); marks (labeled major marks, log-spaced, + default 0.5..20); unit (band label, default 'MHz'); value (initial + pointer percent 0..100, default 45); onChange(freq, 'nn unit') fires + on set and drag. + handle: el, get() (pointer percent 0..100), set(pct) — clamps to 0..100 + and maps to a frequency along the log axis. dragX drives it. + CSS lives in the "frequency-dial scale" block of DUPRE_CSS; the ticks and + marks reuse the shared scoped dupre-tick/dupre-mk names (like the tuner and + dualknob), and the unit label is dupre-fs-band (dupre-band is the EQ's). */ +DUPRE.freqScale = function (host, opts = {}) { const onChange = opts.onChange || noop; const W = opts.width || 184; const marks = opts.marks || [0.5, 1, 2, 3, 5, 7, 10, 14, 20]; const unit = opts.unit || 'MHz'; const lo = Math.log10(marks[0]), hi = Math.log10(marks[marks.length - 1]); const px = m => 6 + ((Math.log10(m) - lo) / (hi - lo)) * (W - 12); - const fs = document.createElement('span'); fs.className = 'freqscale'; - const band = document.createElement('span'); band.className = 'band'; band.textContent = unit; fs.appendChild(band); + const fs = document.createElement('span'); fs.className = 'dupre-freqscale'; + const band = document.createElement('span'); band.className = 'dupre-fs-band'; band.textContent = unit; fs.appendChild(band); marks.forEach(m => { - const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk); - const mk = document.createElement('span'); mk.className = 'mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk); + const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '11px'; fs.appendChild(tk); + const mk = document.createElement('span'); mk.className = 'dupre-mk'; mk.style.left = px(m) + 'px'; mk.textContent = m; fs.appendChild(mk); }); for (let m = marks[0]; m <= marks[marks.length - 1]; m += (m < 2 ? 0.25 : m < 10 ? 1 : 2)) { - const tk = document.createElement('span'); tk.className = 'tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk); + const tk = document.createElement('span'); tk.className = 'dupre-tick'; tk.style.left = px(m) + 'px'; tk.style.height = '6px'; tk.style.opacity = '.5'; fs.appendChild(tk); } - const ptr = document.createElement('span'); ptr.className = 'fptr'; fs.appendChild(ptr); + const ptr = document.createElement('span'); ptr.className = 'dupre-fptr'; fs.appendChild(ptr); host.appendChild(fs); let pct; const set = p => { @@ -4020,15 +4885,25 @@ GW.freqScale = function (host, opts = {}) { }; /* N28 patch bay — jack grid with SVG cables; click one jack then another to - patch or unpatch the pair; redraws itself on window resize */ -GW.patchBay = function (host, opts = {}) { + patch or unpatch the pair; redraws itself on window resize. + + Contract (everything a consumer needs; no page globals touched): + opts: rows (default 2), cols (default 4); conns (initial cables as + 'a-b' jack-index strings, default ['0-5', '2-7']); onChange(conns + array, summary) fires on every patch/unpatch and once at build. + handle: el, get() (conns copy), set(conns) — replaces the patch set and + redraws the cables. Click one jack then another to patch or + unpatch; the SVG cables re-solve on window resize. + CSS lives in the "patch-bay" block of DUPRE_CSS; a live jack wears the + kit-wide dupre-hot modifier. */ +DUPRE.patchBay = function (host, opts = {}) { const onChange = opts.onChange || noop; const rows = opts.rows || 2, cols = opts.cols || 4; - const patch = document.createElement('div'); patch.className = 'patch'; + const patch = document.createElement('div'); patch.className = 'dupre-patch'; const jacks = []; for (let r = 0; r < rows; r++) { - const rowEl = document.createElement('div'); rowEl.className = 'row'; - for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); } + const rowEl = document.createElement('div'); rowEl.className = 'dupre-row'; + for (let c = 0; c < cols; c++) { const j = document.createElement('span'); j.className = 'dupre-jack'; j.dataset.id = (r * cols + c); rowEl.appendChild(j); jacks.push(j); } patch.appendChild(rowEl); } const svg = document.createElementNS(SVGNS, 'svg'); patch.appendChild(svg); @@ -4038,7 +4913,7 @@ GW.patchBay = function (host, opts = {}) { const draw = () => { while (svg.firstChild) svg.removeChild(svg.firstChild); const pr = patch.getBoundingClientRect(); - jacks.forEach(j => j.classList.remove('hot')); + jacks.forEach(j => j.classList.remove('dupre-hot')); conns.forEach(pair => { const [a, b] = pair.split('-').map(Number); const ra = jacks[a].getBoundingClientRect(), rb = jacks[b].getBoundingClientRect(); @@ -4046,17 +4921,17 @@ GW.patchBay = function (host, opts = {}) { const x2 = rb.left - pr.left + rb.width / 2, y2 = rb.top - pr.top + rb.height / 2; const p = document.createElementNS(SVGNS, 'path'); const my = Math.max(y1, y2) + 16; p.setAttribute('d', `M${x1} ${y1} C${x1} ${my} ${x2} ${my} ${x2} ${y2}`); svg.appendChild(p); - jacks[a].classList.add('hot'); jacks[b].classList.add('hot'); + jacks[a].classList.add('dupre-hot'); jacks[b].classList.add('dupre-hot'); }); onChange(conns.slice(), conns.length ? conns.map(c => { const [a, b] = c.split('-'); return a + '↔' + b; }).join(' ') : 'no cables'); }; jacks.forEach(j => { j.addEventListener('click', () => { const id = +j.dataset.id; - if (pending == null) { pending = id; j.classList.add('sel'); return; } - if (pending === id) { pending = null; j.classList.remove('sel'); return; } + if (pending == null) { pending = id; j.classList.add('dupre-sel'); return; } + if (pending === id) { pending = null; j.classList.remove('dupre-sel'); return; } const k = key(pending, id); const i = conns.indexOf(k); if (i >= 0) conns.splice(i, 1); else conns.push(k); - jacks[pending].classList.remove('sel'); pending = null; draw(); + jacks[pending].classList.remove('dupre-sel'); pending = null; draw(); }); }); draw(); window.addEventListener('resize', draw); @@ -4064,8 +4939,21 @@ GW.patchBay = function (host, opts = {}) { }; /* R10 data matrix readout — a page of labeled amber fields; click cycles pages; - a page marked live re-renders on the builder's own 1s clock (reduced-motion gated) */ -GW.dataMatrix = function (host, opts = {}) { + a page marked live re-renders on the builder's own 1s clock (reduced-motion gated). + + Contract (everything a consumer needs; no page globals touched): + opts: pages (array of [name, rowsFn, live?] tuples, default SYS/NET/TIME; + rowsFn() returns the three text rows, and a truthy third element + marks a page that re-renders on the 1s clock); page (initial index, + default 0); onChange(index, 'page NAME') fires on set. + handle: el, get() (page index), set(i) — wraps modulo the page count. + Click cycles pages; a live page re-renders on the builder's own + 1s clock (reduced-motion gated). + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the glow filter registers in the shared defs plate, but the + screen-family background gradient lives in this instrument's local defs (it + reads --scr-* vars from its own subtree). */ +DUPRE.dataMatrix = function (host, opts = {}) { const onChange = opts.onChange || noop; const pages = opts.pages || [ ['SYS', () => ['CPU 42 MEM 61', 'DSK 58 TMP 47C', 'ARCH 6.18 LTS']], @@ -4081,7 +4969,7 @@ GW.dataMatrix = function (host, opts = {}) { const fl = svgEl(d, 'filter', { id: 'dmxGlow', x: '-30%', y: '-30%', width: '160%', height: '160%' }); svgEl(fl, 'feGaussianBlur', { in: 'SourceGraphic', stdDeviation: 1.1 }); }); - /* the background gradient reads screen-family vars from this widget's subtree, + /* the background gradient reads screen-family vars from this instrument's subtree, so it lives in the local defs, not the shared def sink */ const bgId = uid('dmxBg'); const defs = svgEl(s, 'defs', {}); @@ -4116,8 +5004,17 @@ GW.dataMatrix = function (host, opts = {}) { }; /* R11 warning flag window — the striped mechanical flag slides into the window - when the condition trips; click trips and clears it */ -GW.warningFlag = function (host, opts = {}) { + when the condition trips; click trips and clears it. + + Contract (everything a consumer needs; no page globals touched): + opts: label (window legend, default 'VIB'); on (initial tripped state, + default false); onChange(on, 'FLAG'|'clear') fires on set. + handle: el, get() (tripped bool), set(v) — slides the striped flag into + the window when truthy, retracts it when not. Click toggles. + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the barber-stripe pattern registers in the shared defs plate, the + window clip in this instrument's local defs. */ +DUPRE.warningFlag = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 120, 64); def('barber', d => { @@ -4145,8 +5042,17 @@ GW.warningFlag = function (host, opts = {}) { }; /* R25 fourteen-segment display — the starburst alphanumeric that spells words; - click cycles the word */ -GW.seg14 = function (host, opts = {}) { + click cycles the word. + + Contract (everything a consumer needs; no page globals touched): + opts: words (the four-letter word pool cycled by click, default + ZOOM/ECHO/TAPE/…; letters outside the segment MAP render blank); + index (initial, default 0); onChange(index, word) fires on set. + handle: el, get() (word index), set(i) — wraps modulo the pool and lights + the fourteen-segment cells to spell the word. Click cycles. + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the segments are filled inline (lit vs --sevoff), no shared defs. */ +DUPRE.seg14 = function (host, opts = {}) { const onChange = opts.onChange || noop; const words = opts.words || ['ZOOM', 'ECHO', 'TAPE', 'MOOD', 'HALL', 'COMP']; const MAP = { @@ -4203,8 +5109,17 @@ GW.seg14 = function (host, opts = {}) { }; /* R26 response graph — log-frequency axes with a draggable amber peak; 2D drag - places the peak in both axes at once */ -GW.responseGraph = function (host, opts = {}) { + places the peak in both axes at once. + + Contract (everything a consumer needs; no page globals touched): + opts: fc (initial center frequency Hz, default 1200); gain (initial dB, + default 6.5); onChange([fc, gain], 'f · ±g dB') fires on set and drag. + handle: el, get() ([fc, gain]), set(f, g) — fc clamps 32..16000 Hz, gain + clamps ±12 dB; draws the bell curve and moves the peak handle. A + 2D drag on the plot places the peak in both axes at once. + SVG-built: styling is inline attributes plus the shared .rsvg stage block of + DUPRE_CSS; the amber glow filter registers in the shared defs plate. */ +DUPRE.responseGraph = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg', 190, 110); const RG = { x0: 30, x1: 182, y0: 8, y1: 86, fLo: Math.log10(32), fHi: Math.log10(16000), db: 12 }; @@ -4272,7 +5187,7 @@ GW.responseGraph = function (host, opts = {}) { /* R30 telegraph indicator — the pointer names the active state on labeled sectors, engine-telegraph style; click steps the state */ -GW.telegraphIndicator = function (host, opts = {}) { +DUPRE.telegraphIndicator = function (host, opts = {}) { const onChange = opts.onChange || noop; const states = opts.states || ['OK', 'TEST', 'BUSY', 'STOP', 'ATTN', 'FAULT']; const s = stageSvg(host, 'rsvg press', 110, 110), cx = 55, cy = 55, N = states.length; @@ -4305,7 +5220,7 @@ GW.telegraphIndicator = function (host, opts = {}) { /* R31 radar sweep — the beam circles the bearing ring on the builder's own clock (reduced-motion gated), contacts bloom as it passes; click marks the bearing */ -GW.radarSweep = function (host, opts = {}) { +DUPRE.radarSweep = function (host, opts = {}) { const onChange = opts.onChange || noop; const contacts = opts.contacts || [[60, .55], [205, .7], [318, .4]]; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65; @@ -4378,8 +5293,18 @@ GW.radarSweep = function (host, opts = {}) { }; /* R35 day-date disc calendar — coaxial printed discs rotate so today reads - under the fixed hand; starts on today; click rolls midnight forward */ -GW.dayDateCal = function (host, opts = {}) { + under the fixed hand; starts on today; click rolls midnight forward. + + Contract (everything a consumer needs; no page globals touched): + opts: date (1-31) and day (0-6, SUN=0), both defaulting to today; + onChange([date, day], 'DAY d') fires on every set, including the + initial paint. + handle: el, get() ([date, day]), set(d, w) — rotates both discs so the + commanded pair reads under the fixed hand; click advances date + and weekday together like a midnight rollover. + SVG-built: styling is inline attributes plus the shared .rsvg stage block + of DUPRE_CSS; gradients register in the shared defs plate. */ +DUPRE.dayDateCal = function (host, opts = {}) { const onChange = opts.onChange || noop; const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65, HAND = 135; @@ -4435,7 +5360,7 @@ GW.dayDateCal = function (host, opts = {}) { /* R36 LED dot matrix — 8x8 paintable bitmap behind a tinted window; click dots to paint; starts on the reference K */ -GW.dotMatrix = function (host, opts = {}) { +DUPRE.dotMatrix = function (host, opts = {}) { const onChange = opts.onChange || noop; const N = 8, STEP = 11, X0 = 10.5, Y0 = 10.5; const glyph = opts.glyph || ['10000100', '10001000', '10010000', '11100000', '10100000', '10010000', '10001000', '10000100']; @@ -4464,7 +5389,7 @@ GW.dotMatrix = function (host, opts = {}) { /* R45 flip-disc tile array — bistable mechanical pixels; scaleX flip with the color swap at the midpoint; click a disc to flip it */ -GW.flipDisc = function (host, opts = {}) { +DUPRE.flipDisc = function (host, opts = {}) { const onChange = opts.onChange || noop; const COLS = 7, ROWS = 5, STEP = 14, X0 = 13, Y0 = 13; const glyph = opts.glyph || ['0000100', '0000010', '1111111', '0000010', '0000100']; /* arrow right */ @@ -4493,7 +5418,7 @@ GW.flipDisc = function (host, opts = {}) { /* R46 dekatron counting ring — each click pulses the neon glow one cathode around; the wrap flashes the carry dot */ -GW.dekatron = function (host, opts = {}) { +DUPRE.dekatron = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 120, 120), cx = 60, cy = 60; svgEl(s, 'circle', { cx, cy, r: 52, fill: '#171310', stroke: '#060505', 'stroke-width': 2 }); @@ -4538,7 +5463,7 @@ GW.dekatron = function (host, opts = {}) { /* R47 landing gear indicator — three greens or nothing; the lever cycles down → transit (amber pulse) → up */ -GW.gearIndicator = function (host, opts = {}) { +DUPRE.gearIndicator = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 140, 110); svgEl(s, 'rect', { x: 2, y: 2, width: 136, height: 106, rx: 9, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); @@ -4589,7 +5514,7 @@ GW.gearIndicator = function (host, opts = {}) { /* R52 blinkenlights front panel — address/data lamps ripple with a live pseudo-PC (builder-owned clock, reduced-motion gated) that folds in the switch register; flip the SR bits and the pattern changes */ -GW.blinkenlights = function (host, opts = {}) { +DUPRE.blinkenlights = function (host, opts = {}) { const onChange = opts.onChange || noop; const s = stageSvg(host, 'rsvg press', 170, 100); svgEl(s, 'rect', { x: 2, y: 2, width: 166, height: 96, rx: 8, fill: '#3a1420', stroke: '#060505', 'stroke-width': 1.5 }); @@ -4637,15 +5562,15 @@ GW.blinkenlights = function (host, opts = {}) { return { el: s, get: () => sr, tick }; }; -/* ---- widget CSS: injected once, grows as builders move in ---- */ -/* widget-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */ -const GW_CSS = ` +/* ---- instrument CSS: injected once, grows as builders move in ---- */ +/* instrument-internal CSS (moved from the gallery <style> block; gallery keeps page furniture) */ +const DUPRE_CSS = ` /* ---- shared primitives ---- */ -.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} -.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} -.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} -.lamp.off{background:var(--wash);box-shadow:none} -.lamp.busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite} +.dupre-lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.dupre-lamp.dupre-gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} +.dupre-lamp.dupre-red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.dupre-lamp.dupre-off{background:var(--wash);box-shadow:none} +.dupre-lamp.dupre-busy{background:var(--gold);animation:pulse var(--pulse-rate) ease-in-out infinite} /* standard pulse: 1s ease-in-out — the norm for every pulsing / flashing element */ @keyframes pulse{50%{opacity:.25}} @@ -4670,364 +5595,367 @@ const GW_CSS = ` .switch.red::before{content:"OFF";order:1;color:var(--cream)} .switch.red::after{order:2} -/* The ABC keypad takes keys, so it must show when it is the one listening: an - unlit focus state means typing vanishes into a card you thought was live. +/* keypad / index-plate focus — both take keys, so each must show when it is + the one listening: an unlit focus state means typing vanishes into a card + you thought was live. :focus, not :focus-visible — Chrome won't match :focus-visible on a mouse-driven focus of a non-text element, and clicking the plate IS how it gets focus here, so the ring would have appeared only when tabbed to. */ -.kp-pad,.ix-pad{outline:none} -.kp-pad:focus,.ix-pad:focus{outline:2px solid var(--gold-hi);outline-offset:3px;border-radius:9px} -.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; +.dupre-kp-pad,.dupre-ix-pad{outline:none} +.dupre-kp-pad:focus,.dupre-ix-pad:focus{outline:2px solid var(--gold-hi);outline-offset:3px;border-radius:9px} + +/* console keys */ +.dupre-key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} -.key:hover{color:var(--gold);border-color:var(--gold)} -.key:active{transform:translateY(1px)} -.key.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700} -.key.green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700} -.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} -.key.off{opacity:.4} +.dupre-key:hover{color:var(--gold);border-color:var(--gold)} +.dupre-key:active{transform:translateY(1px)} +.dupre-key.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi);font-weight:700} +.dupre-key.dupre-green{color:var(--cream);background:linear-gradient(180deg,#9cbf5e,var(--pass));border-color:var(--pass);font-weight:700} +.dupre-key.dupre-red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} -.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} -.chip.on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))} +/* chip toggle */ +.dupre-chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} +.dupre-chip.dupre-on{color:var(--chip-on,var(--gold));border-color:var(--chip-on,var(--gold))} -.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} -.badge.red{background:var(--fail);color:var(--cream)} -.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} +.dupre-badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.dupre-badge.dupre-red{background:var(--fail);color:var(--cream)} +.dupre-badge.dupre-ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} /* fader */ -.fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none} -.fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} -.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))} -.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; +.dupre-fader{width:150px;height:16px;position:relative;cursor:pointer;touch-action:none} +.dupre-fader .dupre-slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.dupre-fader .dupre-fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold))} +.dupre-fader .dupre-cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)} /* vertical fader */ -.vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none} -.vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} -.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))} -.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; +.dupre-vfader{width:16px;height:64px;position:relative;cursor:pointer;touch-action:none} +.dupre-vfader .dupre-slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.dupre-vfader .dupre-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,var(--amber-grad-bot),var(--gold))} +.dupre-vfader .dupre-cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; background:linear-gradient(90deg,var(--amber-grad-mid),var(--amber-grad-top));border:1px solid var(--amber-edge)} /* rotary knob */ -.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; +.dupre-knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} -.knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi); +.dupre-knob .dupre-ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi); margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)} /* needle gauge */ -.gauge{width:96px;cursor:ns-resize;touch-action:none} -.gauge .dial{position:relative;height:48px;overflow:hidden} -.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} -.gauge .tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px} -.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); +.dupre-gauge{width:96px;cursor:ns-resize;touch-action:none} +.dupre-gauge .dupre-dial{position:relative;height:48px;overflow:hidden} +.dupre-gauge .dupre-arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} +.dupre-gauge .dupre-tk{position:absolute;left:50%;top:1px;width:1.5px;height:8px;margin-left:-.75px;background:var(--steel);transform-origin:50% 47px} +.dupre-gauge .dupre-ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(var(--glow-hi),.5); transition:transform .12s cubic-bezier(.3,1.3,.5,1)} -.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} -.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} +.dupre-gauge .dupre-hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} +.dupre-gauge .dupre-gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} /* segmented VU / LED bar */ -.vu{width:170px;display:flex;flex-direction:column;gap:5px} -.vurow{display:flex;align-items:center;gap:7px} -.vurow .ch{color:var(--steel);font-size:.6rem;width:8px} -.vubar{flex:1;display:flex;gap:2px;height:9px} -.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} -.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)} -.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} +.dupre-vu{width:170px;display:flex;flex-direction:column;gap:5px} +.dupre-vurow{display:flex;align-items:center;gap:7px} +.dupre-vurow .dupre-ch{color:var(--steel);font-size:.6rem;width:8px} +.dupre-vubar{flex:1;display:flex;gap:2px;height:9px} +.dupre-vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.dupre-vubar i.dupre-on{opacity:1;background:var(--pass)}.dupre-vubar i.dupre-hot{opacity:1;background:var(--gold)} +.dupre-vubar i.dupre-clip{opacity:1;background:var(--fail)}.dupre-vubar i.dupre-peak{outline:1px solid var(--gold-hi);outline-offset:-1px} /* mini 4-bar signal */ -.sig{display:flex;align-items:flex-end;gap:2px;height:18px} -.sig i{width:4px;background:var(--wash);border-radius:1px} -.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px} -.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} +.dupre-sig{display:flex;align-items:flex-end;gap:2px;height:18px} +.dupre-sig i{width:4px;background:var(--wash);border-radius:1px} +.dupre-sig i:nth-child(1){height:5px}.dupre-sig i:nth-child(2){height:9px}.dupre-sig i:nth-child(3){height:13px}.dupre-sig i:nth-child(4){height:17px} +.dupre-sig i.dupre-on{background:var(--pass)}.dupre-sig i.dupre-hot{background:var(--gold)}.dupre-sig i.dupre-clip{background:var(--fail)} /* signal ladder (wifi bars) */ -.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer} -.ladder i{width:5px;background:var(--wash);border-radius:1px} -.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px} -.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px} +.dupre-ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px;cursor:pointer} +.dupre-ladder i{width:5px;background:var(--wash);border-radius:1px} +.dupre-ladder i:nth-child(1){height:6px}.dupre-ladder i:nth-child(2){height:10px} +.dupre-ladder i:nth-child(3){height:14px}.dupre-ladder i:nth-child(4){height:18px} /* linear progress / fuel bar */ -.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none} -.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px} -.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.dupre-bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative;cursor:pointer;touch-action:none} +.dupre-bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--amber-grad-bot),var(--gold));border-radius:6px} +.dupre-bar.dupre-warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} /* radial ring */ -.ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none; +.dupre-ring{width:60px;height:60px;border-radius:50%;cursor:ns-resize;touch-action:none; background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); display:grid;place-items:center;position:relative} -.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} -.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} +.dupre-ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.dupre-ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} /* tabular readout */ -.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer} -.readout small{color:var(--dim);font-size:12px;font-weight:400} -.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} +.dupre-readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em;cursor:pointer} +.dupre-readout small{color:var(--dim);font-size:12px;font-weight:400} +.dupre-unit{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} /* sparkline */ -.spark{width:170px;height:44px} -.spark svg{display:block;width:100%;height:100%} +.dupre-spark{width:170px;height:44px} +.dupre-spark svg{display:block;width:100%;height:100%} /* lamp row (list item) */ -.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px} -.lrow:hover{background:var(--wash)} -.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)} -.lrow .what{margin-left:auto;color:var(--dim);font-size:11px} +.dupre-lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px} +.dupre-lrow:hover{background:var(--wash)} +.dupre-lrow .dupre-who{color:var(--silver)}.dupre-lrow .dupre-who b{color:var(--cream)} +.dupre-lrow .dupre-what{margin-left:auto;color:var(--dim);font-size:11px} /* arm-to-fire */ -.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; +.dupre-arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; border-radius:8px;padding:7px 12px} -.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)} +.dupre-arm.dupre-armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)} /* stepper / segmented selector */ -.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} -.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} -.seg button:last-child{border-right:0} -.seg button.on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700} +.dupre-seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.dupre-seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} +.dupre-seg button:last-child{border-right:0} +.dupre-seg button.dupre-on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700} /* engraved section label */ -.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; +.dupre-engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; display:flex;align-items:center;gap:9px} -.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} -.engrave::before{max-width:10px} -.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} +.dupre-engrave::before,.dupre-engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.dupre-engrave::before{max-width:10px} +.dupre-engrave .dupre-cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} /* waveform strip */ -.wave{width:170px;height:38px} -.wave svg{width:100%;height:100%;display:block} +.dupre-wave{width:170px;height:38px} +.dupre-wave svg{width:100%;height:100%;display:block} /* toast */ -.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer} +.dupre-toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px;cursor:pointer} /* output well (log step) */ -.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer} -.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} -.ostep .lamp{margin-top:3px;width:7px;height:7px} -.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px} +.dupre-owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px;cursor:pointer} +.dupre-ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} +.dupre-ostep .dupre-lamp{margin-top:3px;width:7px;height:7px} +.dupre-ostep b{color:var(--cream);font-weight:700}.dupre-ostep .dupre-ev{color:var(--steel);display:block;font-size:10.5px} /* rotary selector */ -.rotsel{position:relative;width:118px;height:74px} -.rotsel>.knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer} -.rotsel .pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em} -.rotsel .pos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} +.dupre-rotsel{position:relative;width:118px;height:74px} +.dupre-rotsel>.dupre-knob{position:absolute;left:50%;top:20px;margin-left:-26px;cursor:pointer} +.dupre-rotsel .dupre-pos{position:absolute;font-size:9px;color:var(--dim);transform:translate(-50%,-50%);letter-spacing:.02em} +.dupre-rotsel .dupre-pos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} /* slide-rule tuner dial */ -.tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer; +.dupre-tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer; background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c); box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)} -.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)} -.tuner .tick.mn{height:6px;opacity:.5} -.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px} -.tuner .mk.on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))} -.tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px} -.tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px} -.tuner .ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px; +.dupre-tuner .dupre-tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)} +.dupre-tuner .dupre-tick.dupre-mn{height:6px;opacity:.5} +.dupre-tuner .dupre-mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px} +.dupre-tuner .dupre-mk.dupre-on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))} +.dupre-tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px} +.dupre-tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px} +.dupre-tuner .dupre-ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px; background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s} /* nixie tube */ -.nixie{display:inline-flex;gap:5px;cursor:pointer} -.nixie .tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden; +.dupre-nixie{display:inline-flex;gap:5px;cursor:pointer} +.dupre-nixie .dupre-tube{width:30px;height:44px;border-radius:5px;position:relative;overflow:hidden; background:radial-gradient(circle at 50% 38%,#241a12,#0b0807);border:1px solid #2c261d; display:grid;place-items:center;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} -.nixie .tube b{font-size:26px;font-weight:400;color:#ff9a3c; +.dupre-nixie .dupre-tube b{font-size:26px;font-weight:400;color:#ff9a3c; text-shadow:0 0 6px rgba(255,140,50,.85),0 0 15px rgba(255,120,40,.45)} -.nixie .tube.off b{color:#3a2a1c;text-shadow:none} -.nixie .tube::after{content:"";position:absolute;inset:0;pointer-events:none; +.dupre-nixie .dupre-tube.dupre-off b{color:#3a2a1c;text-shadow:none} +.dupre-nixie .dupre-tube::after{content:"";position:absolute;inset:0;pointer-events:none; background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 32%)} /* ===== candidate-unique CSS ===== */ /* rocker */ -.rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative; +.dupre-rocker{width:66px;height:40px;border-radius:7px;cursor:pointer;position:relative; background:linear-gradient(180deg,#25211c,#141210);border:1px solid #34302a; box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5);overflow:hidden} -.rocker .half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center; +.dupre-rocker .dupre-half{position:absolute;left:0;right:0;height:50%;display:flex;align-items:center;justify-content:center; font-size:12px;letter-spacing:.06em;color:var(--dim)} -.rocker .top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)} -.rocker .bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)} -.rocker.on .top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)} -.rocker.on .bot{color:var(--steel)} -.rocker:not(.on) .top{color:var(--steel)} -.rocker:not(.on) .bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)} +.dupre-rocker .dupre-top{top:0;border-bottom:1px solid #0c0b0a;background:linear-gradient(180deg,#211d19,#181513)} +.dupre-rocker .dupre-bot{bottom:0;background:linear-gradient(180deg,#141210,#100e0c);box-shadow:inset 0 3px 5px rgba(0,0,0,.55)} +.dupre-rocker.dupre-on .dupre-top{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700;box-shadow:0 0 10px rgba(var(--glow-hi),.35)} +.dupre-rocker.dupre-on .dupre-bot{color:var(--steel)} +.dupre-rocker:not(.dupre-on) .dupre-top{color:var(--steel)} +.dupre-rocker:not(.dupre-on) .dupre-bot{background:linear-gradient(180deg,#2a1512,#1c0f0d);color:var(--fail);box-shadow:inset 0 3px 5px rgba(0,0,0,.55),0 0 8px rgba(203,107,77,.25)} /* transport */ -.reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px} -.reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative; +.dupre-reels{display:flex;gap:16px;align-items:center;justify-content:center;margin-bottom:2px} +.dupre-reel{width:26px;height:26px;border-radius:50%;border:2px solid #34302a;position:relative; background:radial-gradient(circle at 45% 40%,#211d18,#0d0b09)} -.reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310} -.reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px} -.reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)} -.reel i:nth-child(2){transform:rotate(120deg)}.reel i:nth-child(3){transform:rotate(240deg)} -.transport{display:flex;gap:5px} -.tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center; +.dupre-reel::before{content:"";position:absolute;inset:9px;border-radius:50%;border:1px solid #4a443a;background:#161310} +.dupre-reel i{position:absolute;left:50%;top:50%;width:2px;height:11px;background:var(--steel);margin:-5.5px 0 0 -1px;transform-origin:50% 5.5px} +.dupre-reel i:nth-child(1){background:var(--gold-hi);box-shadow:0 0 4px rgba(var(--glow-hi),.55)} +.dupre-reel i:nth-child(2){transform:rotate(120deg)}.dupre-reel i:nth-child(3){transform:rotate(240deg)} +.dupre-transport{display:flex;gap:5px} +.dupre-tbtn{font:inherit;font-size:12px;color:var(--silver);cursor:pointer;width:30px;height:26px;display:grid;place-items:center; background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:6px} -.tbtn:hover{color:var(--gold);border-color:var(--gold)} -.tbtn.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)} -.tbtn.rec.on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.dupre-tbtn:hover{color:var(--gold);border-color:var(--gold)} +.dupre-tbtn.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));border-color:var(--gold-hi)} +.dupre-tbtn.dupre-rec.dupre-on{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} /* radio bank */ -.radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden; +.dupre-radiobank{display:flex;gap:0;border:1px solid #0c0b0a;border-radius:7px;overflow:hidden; background:#0c0b0a;padding:3px;box-shadow:inset 0 2px 4px rgba(0,0,0,.5)} -.preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px; +.dupre-preset{font:inherit;font-size:11px;color:var(--dim);cursor:pointer;padding:8px 9px;border:0;border-radius:4px; background:linear-gradient(180deg,#211d19,#161310);box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} -.preset+.preset{margin-left:3px} -.preset:hover{color:var(--silver)} -.preset.on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700; +.dupre-preset+.dupre-preset{margin-left:3px} +.dupre-preset:hover{color:var(--silver)} +.dupre-preset.dupre-on{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));font-weight:700; box-shadow:inset 0 2px 4px rgba(0,0,0,.4)} /* concentric dual knob */ -.dualknob{width:64px;height:64px;position:relative} -.dualknob .outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none; +.dupre-dualknob{width:64px;height:64px;position:relative} +.dupre-dualknob .dupre-outer{width:64px;height:64px;border-radius:50%;position:absolute;inset:0;cursor:ns-resize;touch-action:none; background:radial-gradient(circle at 40% 35%,#2a2622,#100e0c);border:1px solid #3a352c; box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} -.dualknob .outer .tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px} -.dualknob .inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none; +.dupre-dualknob .dupre-outer .dupre-tick{position:absolute;left:50%;top:3px;width:2px;height:9px;background:var(--steel);margin-left:-1px;transform-origin:50% 29px} +.dupre-dualknob .dupre-inner{width:34px;height:34px;border-radius:50%;position:absolute;left:15px;top:15px;cursor:ns-resize;touch-action:none; background:radial-gradient(circle at 40% 35%,#37322a,#1a1713);border:1px solid #4a443a;box-shadow:0 2px 4px rgba(0,0,0,.5)} -.dualknob .inner .ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px; +.dupre-dualknob .dupre-inner .dupre-ind{position:absolute;left:50%;top:3px;width:2px;height:11px;background:var(--gold-hi);margin-left:-1px; transform-origin:50% 14px;border-radius:1px;box-shadow:0 0 5px rgba(var(--glow-hi),.6)} /* rotary encoder + LED ring */ -.encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none} -.encoder .led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash); +.dupre-encoder{position:relative;width:66px;height:66px;display:grid;place-items:center;cursor:ns-resize;touch-action:none} +.dupre-encoder .dupre-led{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--wash); left:50%;top:50%;margin:-2.5px} -.encoder .led.on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)} -.encoder .knob{width:40px;height:40px;cursor:ns-resize} -.encoder .knob .ind{transform-origin:50% 15px;top:4px;height:12px} +.dupre-encoder .dupre-led.dupre-on{background:var(--gold);box-shadow:0 0 5px 1px rgba(var(--glow-lo),.7)} +.dupre-encoder .dupre-knob{width:40px;height:40px;cursor:ns-resize} +.dupre-encoder .dupre-knob .dupre-ind{transform-origin:50% 15px;top:4px;height:12px} /* keyed mode switch */ -.keylock{position:relative;width:110px;height:70px} -.keylock .body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%; +.dupre-keylock{position:relative;width:110px;height:70px} +.dupre-keylock .dupre-body{position:absolute;left:50%;top:30px;margin-left:-21px;width:42px;height:42px;border-radius:50%; background:radial-gradient(circle at 42% 36%,#2a2622,#0f0d0b);border:1px solid #3a352c;cursor:pointer; box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} -.keylock .barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908; +.dupre-keylock .dupre-barrel{position:absolute;left:50%;top:50%;width:5px;height:5px;margin:-2.5px;border-radius:1px;background:#0a0908; box-shadow:0 0 2px rgba(0,0,0,.8)} -.keylock .bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi); +.dupre-keylock .dupre-bit{position:absolute;left:50%;top:50%;width:3px;height:15px;margin:-15px 0 0 -1.5px;background:var(--gold-hi); border-radius:1px;transform-origin:50% 100%;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .2s} -.keylock .kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)} -.keylock .kpos.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} +.dupre-keylock .dupre-kpos{position:absolute;font-size:9px;color:var(--dim);letter-spacing:.05em;transform:translate(-50%,-50%)} +.dupre-keylock .dupre-kpos.dupre-on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.55)} /* crossfader */ -.xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none} -.xfader .slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18} -.xfader .detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7} -.xfader .end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)} -.xfader .cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s; +.dupre-xfader{width:160px;height:22px;position:relative;cursor:pointer;touch-action:none} +.dupre-xfader .dupre-slot{position:absolute;top:9px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18} +.dupre-xfader .dupre-detent{position:absolute;top:2px;left:50%;width:1px;height:18px;background:var(--steel);margin-left:-.5px;opacity:.7} +.dupre-xfader .dupre-end{position:absolute;top:11px;font-size:9px;color:var(--steel);transform:translateY(-50%)} +.dupre-xfader .dupre-cap{position:absolute;top:3px;width:9px;height:16px;border-radius:2px;margin-left:-4.5px;transition:left .05s; background:linear-gradient(180deg,var(--amber-grad-top),var(--amber-grad-mid));border:1px solid var(--amber-edge);box-shadow:0 1px 2px rgba(0,0,0,.5)} /* thumbwheel */ -.thumbw{display:flex;align-items:center;gap:10px} -.thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden; +.dupre-thumbw{display:flex;align-items:center;gap:10px} +.dupre-thumbwheel{width:34px;height:52px;border-radius:6px;cursor:ns-resize;touch-action:none;position:relative;overflow:hidden; border:1px solid #34302a;box-shadow:inset 0 0 8px rgba(0,0,0,.6); background:repeating-linear-gradient(0deg,#0e0c0a 0 2px,#221e19 2px 3px,#2f2a23 3px 5px,#221e19 5px 6px)} -.thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))} -.thumbw .win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums; +.dupre-thumbwheel::after{content:"";position:absolute;inset:0;background:linear-gradient(90deg,rgba(0,0,0,.55),transparent 30%,transparent 70%,rgba(0,0,0,.55))} +.dupre-thumbw .dupre-win{color:var(--cream);font-size:15px;font-weight:700;font-variant-numeric:tabular-nums; background:var(--well);border:1px solid #231f18;border-radius:4px;padding:3px 8px} /* DIP bank */ -.dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px; +.dupre-dip{display:flex;gap:3px;padding:6px 7px;background:#0f1a2a;border:1px solid #24344a;border-radius:5px; box-shadow:inset 0 1px 3px rgba(0,0,0,.5)} -.dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42} -.dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98); +.dupre-dipsw{width:12px;height:26px;background:#0a1220;border-radius:2px;position:relative;cursor:pointer;border:1px solid #1c2c42} +.dupre-dipsw i{position:absolute;left:1px;right:1px;height:11px;border-radius:1px;background:linear-gradient(180deg,#eae4d2,#b4ad98); bottom:1px;transition:bottom .12s,top .12s} -.dipsw.on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))} +.dupre-dipsw.dupre-on i{bottom:auto;top:1px;background:linear-gradient(180deg,var(--amber-grad-top),var(--gold))} /* jog / shuttle */ -.jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none} -.jog .shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713; +.dupre-jog{position:relative;width:74px;height:74px;cursor:ns-resize;touch-action:none} +.dupre-jog .dupre-shuttle{position:absolute;inset:0;border-radius:50%;border:6px solid #1a1713; background:conic-gradient(from -90deg,#3a352c 0 var(--sh,40deg),#141210 0 360deg); box-shadow:inset 0 0 8px rgba(0,0,0,.6)} -.jog .inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear; +.dupre-jog .dupre-inner{position:absolute;inset:12px;border-radius:50%;transition:transform .05s linear; background:radial-gradient(circle at 42% 36%,#2c2822,#100e0c);border:1px solid #3a352c;box-shadow:0 2px 5px rgba(0,0,0,.6)} -.jog .dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%; +.dupre-jog .dupre-dimple{position:absolute;left:50%;top:7px;width:8px;height:8px;margin-left:-4px;border-radius:50%; background:radial-gradient(circle at 40% 35%,#151210,#000);box-shadow:inset 0 1px 2px rgba(0,0,0,.9)} /* oscilloscope — screen-family vars with the original green as fallback */ -.scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden; +.dupre-scope{width:176px;height:78px;border-radius:6px;position:relative;overflow:hidden; background:radial-gradient(circle at 50% 45%,var(--scr-bgc,#04140a),var(--scr-bge,#020a05)); border:1px solid var(--scr-brd,#123018); box-shadow:inset 0 0 16px rgba(0,0,0,.7)} -.scope .grat{position:absolute;inset:0;opacity:.5; +.dupre-scope .dupre-grat{position:absolute;inset:0;opacity:.5; background-image:linear-gradient(var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px),linear-gradient(90deg,var(--scr-grat,rgba(111,206,51,.18)) 1px,transparent 1px); background-size:22px 19.5px} -.scope .grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))} -.scope svg{position:absolute;inset:0;width:100%;height:100%} -.scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))} +.dupre-scope .dupre-grat::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--scr-gratc,rgba(111,206,51,.35))} +.dupre-scope svg{position:absolute;inset:0;width:100%;height:100%} +.dupre-scope polyline{fill:none;stroke:var(--scr-hi,var(--phos));stroke-width:1.6;filter:drop-shadow(0 0 3px var(--scr-glow,rgba(127,224,160,.8)))} /* voice-loop keyset */ -.vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px} -.vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em; +.dupre-vloop{display:grid;grid-template-columns:repeat(4,52px);gap:4px} +.dupre-vk{display:flex;flex-direction:column;align-items:stretch;gap:4px;font-size:7px;letter-spacing:.05em; text-align:center;padding:6px 4px 4px;cursor:pointer; color:var(--dim);background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-radius:4px; font-family:var(--mono)} -.vk .bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f} -.vk.mon{color:var(--silver)} -.vk.mon .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} -.vk.mon.act .bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)} -.vk.tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold)); +.dupre-vk .dupre-vk-bar{height:3px;width:40px;align-self:center;border-radius:2px;background:#16240f} +.dupre-vk.dupre-mon{color:var(--silver)} +.dupre-vk.dupre-mon .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} +.dupre-vk.dupre-mon.dupre-act .dupre-vk-bar{background:#a8d84a;box-shadow:0 0 7px rgba(150,200,60,.9)} +.dupre-vk.dupre-tlk{color:var(--panel);background:linear-gradient(180deg,var(--amber-grad-top),var(--gold)); border-color:var(--gold-hi);font-weight:700} -.vk.tlk .bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} +.dupre-vk.dupre-tlk .dupre-vk-bar{background:var(--pass);box-shadow:0 0 4px rgba(116,147,47,.6)} /* spectrum / EQ */ -.eq{display:flex;align-items:flex-end;gap:3px;height:60px} -.eq .band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%} -.eq .band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3} -.eq .band i.on{opacity:1;background:var(--pass)} -.eq .band i.hot{opacity:1;background:var(--gold)} -.eq .band i.clip{opacity:1;background:var(--fail)} +.dupre-eq{display:flex;align-items:flex-end;gap:3px;height:60px} +.dupre-eq .dupre-band{width:9px;display:flex;flex-direction:column-reverse;gap:2px;height:100%} +.dupre-eq .dupre-band i{height:5px;border-radius:1px;background:var(--wash);opacity:.3} +.dupre-eq .dupre-band i.dupre-on{opacity:1;background:var(--pass)} +.dupre-eq .dupre-band i.dupre-hot{opacity:1;background:var(--gold)} +.dupre-eq .dupre-band i.dupre-clip{opacity:1;background:var(--fail)} /* crossed-needle */ -.crossm{width:120px} -.crossm .face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none} -.crossm .arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%} -.crossm .nA{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%; +.dupre-crossm{width:120px} +.dupre-crossm .dupre-face{position:relative;height:56px;overflow:hidden;cursor:ns-resize;touch-action:none} +.dupre-crossm .dupre-crossm-arc{position:absolute;inset:2px 4px -56px;border:2px solid var(--wash);border-radius:50%} +.dupre-crossm .dupre-fwd{position:absolute;left:14px;bottom:2px;width:2px;height:52px;background:var(--gold-hi);transform-origin:50% 100%; border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} -.crossm .nB{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%; +.dupre-crossm .dupre-rfl{position:absolute;right:14px;bottom:2px;width:2px;height:52px;background:var(--fail);transform-origin:50% 100%; border-radius:2px;box-shadow:0 0 5px rgba(203,107,77,.5);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} -.crossm .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} +.dupre-crossm .dupre-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} /* thermometer */ -.thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none} -.thermo .tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden} -.thermo .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))} -.thermo .bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px; +.dupre-thermo{display:flex;align-items:flex-end;gap:6px;height:74px;cursor:ns-resize;touch-action:none} +.dupre-thermo .dupre-thermo-tube{width:12px;height:64px;border-radius:6px 6px 0 0;position:relative;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.dupre-thermo .dupre-thermo-fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#a35a3f,var(--fail))} +.dupre-thermo .dupre-bulb{width:20px;height:20px;border-radius:50%;background:var(--fail);position:absolute;left:-4px;bottom:-9px; box-shadow:0 0 6px rgba(203,107,77,.5)} -.thermo .scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)} -.thermo .wrapcol{position:relative;padding-bottom:9px} +.dupre-thermo .dupre-scale{display:flex;flex-direction:column;justify-content:space-between;height:64px;font-size:9px;color:var(--steel)} +.dupre-thermo .dupre-wrapcol{position:relative;padding-bottom:9px} /* bourdon */ -.bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; +.dupre-bourdon{width:82px;height:82px;border-radius:50%;position:relative;cursor:ns-resize;touch-action:none; background:radial-gradient(circle at 48% 42%,#1d1a16,#0c0b09);border:2px solid #2c261d; box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} -.bourdon svg{position:absolute;inset:0} -.bourdon .ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi); +.dupre-bourdon svg{position:absolute;inset:0} +.dupre-bourdon-ndl{position:absolute;left:50%;top:50%;width:2px;height:32px;margin:-32px 0 0 -1px;background:var(--gold-hi); transform-origin:50% 100%;border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.55);transition:transform .12s cubic-bezier(.3,1.2,.5,1)} -.bourdon .hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)} -.bourdon .cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)} +.dupre-bourdon-hub{position:absolute;left:50%;top:50%;width:8px;height:8px;margin:-4px;border-radius:50%;background:var(--gold)} +.dupre-bourdon-cap{position:absolute;left:0;right:0;bottom:12px;text-align:center;font-size:8px;letter-spacing:.16em;color:var(--steel)} /* strip-chart */ -.strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden; +.dupre-strip{width:176px;height:62px;border-radius:5px;position:relative;overflow:hidden; background:#0c0e0f;border:1px solid #231f18} -.strip .rule{position:absolute;inset:0;opacity:.4; +.dupre-strip .dupre-rule{position:absolute;inset:0;opacity:.4; background-image:linear-gradient(90deg,rgba(150,147,133,.14) 1px,transparent 1px);background-size:16px 100%} -.strip svg{position:absolute;inset:0;width:100%;height:100%} -.strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4} -.strip .pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi); +.dupre-strip svg{position:absolute;inset:0;width:100%;height:100%} +.dupre-strip polyline{fill:none;stroke:var(--gold);stroke-width:1.4} +.dupre-strip .dupre-pen{position:absolute;right:2px;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold-hi); box-shadow:0 0 6px rgba(var(--glow-hi),.8);transition:top .08s linear} /* correlation */ -.corr{width:150px;cursor:pointer;touch-action:none} -.corr .face{position:relative;height:44px;overflow:hidden} -.corr .arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%} -.corr .zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px} -.corr .ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%; +.dupre-corr{width:150px;cursor:pointer;touch-action:none} +.dupre-corr-face{position:relative;height:44px;overflow:hidden} +.dupre-corr-arc{position:absolute;inset:0 0 -44px;border:2px solid var(--wash);border-radius:50%} +.dupre-corr .dupre-zero{position:absolute;left:50%;top:2px;width:1px;height:10px;background:var(--gold);margin-left:-.5px} +.dupre-corr-ndl{position:absolute;left:50%;bottom:0;width:2px;height:38px;background:var(--gold-hi);transform-origin:50% 100%; border-radius:2px;box-shadow:0 0 5px rgba(var(--glow-hi),.5);transition:transform .1s ease-out} -.corr .lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} +.dupre-corr-lbl{display:flex;justify-content:space-between;color:var(--steel);font-size:9px;margin-top:2px} /* battery */ -.batt{display:flex;align-items:center;cursor:pointer;touch-action:none} -.batt .cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10} -.batt .cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35} -.batt .cell.on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)} -.batt .cell.warn.on{background:linear-gradient(180deg,var(--fail),#a04a34)} -.batt .nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0} +.dupre-batt{display:flex;align-items:center;cursor:pointer;touch-action:none} +.dupre-batt .dupre-cells{display:flex;gap:2px;padding:3px;border:1px solid #34302a;border-radius:4px;background:#0d0f10} +.dupre-batt .dupre-cell{width:12px;height:26px;border-radius:1px;background:var(--wash);opacity:.35} +.dupre-batt .dupre-cell.dupre-on{opacity:1;background:linear-gradient(180deg,var(--pass),#5c7526)} +.dupre-batt .dupre-cell.dupre-warn.dupre-on{background:linear-gradient(180deg,var(--fail),#a04a34)} +.dupre-batt .dupre-nub{width:4px;height:12px;background:#34302a;border-radius:0 2px 2px 0} /* split-flap — four clipped half-panels per cell; the fold happens on .ftc (falls) and .fbn (lands), driven by the builder's WAAPI flips */ @@ -5059,156 +5987,156 @@ const GW_CSS = ` .flap-light .flapd::after{background:rgba(0,0,0,.3);box-shadow:0 1px 0 rgba(255,255,255,.3)} /* seven-segment */ -.seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer; +.dupre-seven{display:inline-flex;gap:6px;padding:6px 9px;border-radius:6px;background:#0a0806;border:1px solid #231f18;cursor:pointer; box-shadow:inset 0 0 10px rgba(0,0,0,.6)} .seg7{width:22px;height:40px;filter:drop-shadow(0 0 3px rgba(87,211,87,.55))} .seg7.red{filter:drop-shadow(0 0 3px rgba(226,84,63,.55))} -.seven .colon{align-self:center;display:flex;flex-direction:column;gap:9px} -.seven .colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)} +.dupre-seven .dupre-colon{align-self:center;display:flex;flex-direction:column;gap:9px} +.dupre-seven .dupre-colon i{width:4px;height:4px;border-radius:50%;background:var(--sevgrn);box-shadow:0 0 4px rgba(87,211,87,.7)} /* DSKY verb/noun panel */ -.dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px} -.dsky .lampcol{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px} -.dsky .sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center; +.dupre-dsky{display:flex;gap:9px;align-items:stretch;background:#1c1916;border:1px solid #060505;border-radius:8px;padding:8px} +.dupre-dsky-lamps{display:grid;grid-template-rows:repeat(6,1fr);gap:3px;width:52px} +.dupre-dsky-sl{font-size:6.5px;letter-spacing:.04em;display:flex;align-items:center;justify-content:center;text-align:center; background:#141210;color:#5c574c;border-radius:2px;border:1px solid #26221c} -.dsky .sl.on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700; +.dupre-dsky-sl.dupre-on{background:linear-gradient(180deg,var(--amber-warn),var(--gold));color:var(--panel);font-weight:700; box-shadow:0 0 6px rgba(var(--glow-lo),.5)} -.dsky .right{display:flex;flex-direction:column;gap:6px} -.dsky .wins{display:flex;gap:8px} -.dsky .win{display:flex;flex-direction:column;align-items:center;gap:2px} -.dsky .win .wl{font-size:6px;letter-spacing:.14em;color:var(--steel)} -.dsky .win .wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px} -.dsky .win .wd .seg7{width:13px;height:24px} -.dsky .win.hot .wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)} -.dsky .pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px} -.dsky .pad .key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em} +.dupre-dsky-right{display:flex;flex-direction:column;gap:6px} +.dupre-dsky-wins{display:flex;gap:8px} +.dupre-dsky-win{display:flex;flex-direction:column;align-items:center;gap:2px} +.dupre-dsky-wl{font-size:6px;letter-spacing:.14em;color:var(--steel)} +.dupre-dsky-wd{display:flex;gap:2px;background:#0a0806;border:1px solid #231f18;border-radius:4px;padding:3px 4px} +.dupre-dsky-wd .seg7{width:13px;height:24px} +.dupre-dsky-win.dupre-hot .dupre-dsky-wd{border-color:var(--gold);box-shadow:0 0 5px rgba(var(--glow-lo),.4)} +.dupre-dsky-pad{display:grid;grid-template-columns:repeat(5,1fr);gap:4px} +.dupre-dsky-pad .dupre-key{padding:4px 0;font-size:8.5px;border-radius:5px;text-align:center;letter-spacing:.03em} /* VFD marquee */ -.vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer; +.dupre-vfdm{width:176px;height:34px;border-radius:5px;overflow:hidden;position:relative;cursor:pointer; background:linear-gradient(180deg,#04100e,#020807);border:1px solid #123028;box-shadow:inset 0 0 12px rgba(0,0,0,.6)} -.vfdm .txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em; +.dupre-vfdm .dupre-txt{position:absolute;top:50%;transform:translateY(-50%);white-space:nowrap;font-size:15px;letter-spacing:.22em; color:var(--vfd);text-shadow:0 0 6px rgba(99,230,200,.65)} -.vfdm .mesh{position:absolute;inset:0;pointer-events:none;opacity:.35; +.dupre-vfdm .dupre-mesh{position:absolute;inset:0;pointer-events:none;opacity:.35; background-image:radial-gradient(rgba(0,0,0,.6) 40%,transparent 41%);background-size:3px 3px} /* annunciator */ -.annwrap{display:flex;flex-direction:column;gap:6px} -.annbar{display:flex;gap:5px;align-items:center} -.mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c; +.dupre-annwrap{display:flex;flex-direction:column;gap:6px} +.dupre-annbar{display:flex;gap:5px;align-items:center} +.dupre-mc{font-size:7px;letter-spacing:.08em;padding:4px 8px;border-radius:3px;background:#141210;color:#5c574c; border:1px solid #26221c;text-align:center} -.mc.on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700; +.dupre-mc.dupre-on{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream);font-weight:700; box-shadow:0 0 8px rgba(203,107,77,.5)} -.mc.on.fl{animation:pulse var(--pulse-rate) ease-in-out infinite} -.annbar .key{padding:3px 7px;font-size:7.5px;border-radius:4px} -.annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px} -.acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer; +.dupre-mc.dupre-on.dupre-fl{animation:pulse var(--pulse-rate) ease-in-out infinite} +.dupre-annbar .dupre-key{padding:3px 7px;font-size:7.5px;border-radius:4px} +.dupre-annun{display:grid;grid-template-columns:repeat(3,1fr);gap:3px} +.dupre-acell{font-size:8.5px;letter-spacing:.08em;text-align:center;color:var(--dim);padding:6px 4px;border-radius:3px;cursor:pointer; background:#141210;border:1px solid #262320;line-height:1.2} -.acell.warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)} -.acell.fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite} +.dupre-acell.dupre-warn{color:var(--panel);background:linear-gradient(180deg,var(--amber-warn),var(--gold));font-weight:700;box-shadow:0 0 8px rgba(var(--glow-lo),.4)} +.dupre-acell.dupre-fault{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));font-weight:700;box-shadow:0 0 8px rgba(203,107,77,.4);animation:pulse var(--pulse-rate) ease-in-out infinite} /* jewel */ -.jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer; +.dupre-jewel{width:24px;height:24px;border-radius:50%;position:relative;cursor:pointer; background:radial-gradient(circle at 36% 30%,rgba(255,255,255,.75),var(--jc) 42%,#3a0d08 100%); box-shadow:0 0 10px 1px var(--jc),inset 0 -2px 3px rgba(0,0,0,.5)} -.jewel::after{content:"";position:absolute;inset:0;border-radius:50%; +.dupre-jewel::after{content:"";position:absolute;inset:0;border-radius:50%; background:conic-gradient(from 0deg,rgba(255,255,255,.12) 0 22deg,transparent 22deg 45deg,rgba(0,0,0,.18) 45deg 67deg,transparent 67deg 90deg); background-repeat:repeat} -.jewel.dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)} +.dupre-jewel.dupre-dim{background:radial-gradient(circle at 36% 30%,rgba(120,120,120,.3),#241f1b 55%,#0e0c0a);box-shadow:inset 0 -2px 3px rgba(0,0,0,.5)} /* tape counter */ -.counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px; +.dupre-counter{display:inline-flex;gap:2px;padding:4px;background:#0c0b0a;border:1px solid #2c261d;border-radius:4px; box-shadow:inset 0 1px 3px rgba(0,0,0,.6)} -.cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden; +.dupre-cwheel{width:18px;height:34px;border-radius:2px;position:relative;overflow:hidden; background:linear-gradient(180deg,#efe9d6,#c7c0ac);box-shadow:inset 0 0 3px rgba(0,0,0,.3)} -.cwheel .col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px; +.dupre-cwheel .dupre-col{position:absolute;left:0;right:0;text-align:center;color:#1a1613;font-size:18px;font-weight:700;line-height:34px; transition:top .35s cubic-bezier(.4,1.4,.5,1)} -.cwheel .col span{display:block;height:34px} -.cwheel::before,.cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none} -.cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)} -.cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)} -.counter .redw{background:linear-gradient(180deg,#e7b46a,#cf9440)} +.dupre-cwheel .dupre-col span{display:block;height:34px} +.dupre-cwheel::before,.dupre-cwheel::after{content:"";position:absolute;left:0;right:0;height:9px;z-index:2;pointer-events:none} +.dupre-cwheel::before{top:0;background:linear-gradient(180deg,rgba(0,0,0,.4),transparent)} +.dupre-cwheel::after{bottom:0;background:linear-gradient(0deg,rgba(0,0,0,.4),transparent)} +.dupre-counter .dupre-redw{background:linear-gradient(180deg,#e7b46a,#cf9440)} /* analog clock */ -.clock{width:78px;height:78px;border-radius:50%;position:relative; +.dupre-clock{width:78px;height:78px;border-radius:50%;position:relative; background:radial-gradient(circle at 46% 40%,#1c1915,#0c0b09);border:2px solid #2c261d;box-shadow:inset 0 2px 5px rgba(0,0,0,.6)} -.clock .tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px} -.clock .hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream); +.dupre-clock .dupre-tk{position:absolute;left:50%;top:4px;width:1.5px;height:6px;background:var(--steel);margin-left:-.75px;transform-origin:50% 35px} +.dupre-clock .dupre-hh{position:absolute;left:50%;top:50%;width:3px;height:20px;margin:-20px 0 0 -1.5px;background:var(--cream); transform-origin:50% 100%;border-radius:2px} -.clock .mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver); +.dupre-clock .dupre-mh{position:absolute;left:50%;top:50%;width:2px;height:28px;margin:-28px 0 0 -1px;background:var(--silver); transform-origin:50% 100%;border-radius:2px} -.clock .sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi); +.dupre-clock .dupre-sh{position:absolute;left:50%;top:50%;width:1px;height:30px;margin:-30px 0 0 -.5px;background:var(--gold-hi); transform-origin:50% 100%;box-shadow:0 0 4px rgba(var(--glow-hi),.5)} -.clock .pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)} +.dupre-clock .dupre-pin{position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:var(--gold)} /* frequency-dial scale */ -.freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none; +.dupre-freqscale{width:184px;height:44px;position:relative;border-radius:5px;overflow:hidden;cursor:pointer;touch-action:none; background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c; box-shadow:inset 0 0 16px rgba(var(--glow-lo),.1)} -.freqscale .tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)} -.freqscale .mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px} -.freqscale .band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em} -.freqscale .fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px; +.dupre-freqscale .dupre-tick{position:absolute;top:5px;width:1px;background:var(--steel);transform:translateX(-50%)} +.dupre-freqscale .dupre-mk{position:absolute;bottom:6px;transform:translateX(-50%);color:var(--steel);font-size:9px} +.dupre-freqscale .dupre-fs-band{position:absolute;bottom:2px;left:6px;color:var(--gold);font-size:8px;letter-spacing:.18em} +.dupre-freqscale .dupre-fptr{position:absolute;top:3px;bottom:14px;width:2px;margin-left:-1px;border-radius:1px; background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85)} /* patch-bay */ -.patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative} -.patch .row{display:flex;gap:9px} -.patch .row+.row{margin-top:9px} -.patch .jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%); +.dupre-patch{padding:7px 9px;background:#141210;border:1px solid #2c261d;border-radius:5px;position:relative} +.dupre-patch .dupre-row{display:flex;gap:9px} +.dupre-patch .dupre-row+.dupre-row{margin-top:9px} +.dupre-patch .dupre-jack{width:13px;height:13px;border-radius:50%;cursor:pointer;background:radial-gradient(circle at 40% 35%,#3a352c,#0a0908 70%); border:1px solid #4a443a;box-shadow:inset 0 1px 2px rgba(0,0,0,.8)} -.patch .jack.hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)} -.patch .jack.sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)} -.patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%} -.patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round} +.dupre-patch .dupre-jack.dupre-hot{background:radial-gradient(circle at 40% 35%,var(--amber-edge),#1a1408 70%)} +.dupre-patch .dupre-jack.dupre-sel{border-color:var(--gold-hi);box-shadow:0 0 6px 1px rgba(var(--glow-hi),.7)} +.dupre-patch svg{position:absolute;inset:0;pointer-events:none;width:100%;height:100%} +.dupre-patch svg path{fill:none;stroke:var(--gold);stroke-width:2.4;opacity:.85;stroke-linecap:round} -/* ===== reference-batch (R) widgets — SVG-first, after period hardware ===== */ +/* ===== reference-batch (R) instruments — SVG-first, after period hardware ===== */ .rsvg{display:block} .rsvg.drag{cursor:ns-resize;touch-action:none} .rsvg.press{cursor:pointer} /* transport reels spin (was a page-side keyframe injector) */ @keyframes reelspin{to{transform:rotate(360deg)}} -.reel.spin{animation:reelspin 2.6s linear infinite} +.dupre-reel.dupre-spin{animation:reelspin 2.6s linear infinite} `; function ensureCss() { - if (document.getElementById('gw-css') || !GW_CSS) return; - const st = document.createElement('style'); st.id = 'gw-css'; st.textContent = GW_CSS; + if (document.getElementById('dupre-css') || !DUPRE_CSS) return; + const st = document.createElement('style'); st.id = 'dupre-css'; st.textContent = DUPRE_CSS; document.head.appendChild(st); } if (document.head) ensureCss(); else document.addEventListener('DOMContentLoaded', ensureCss); -/* Policy classification (see GW.POLICIES). This is the colour pass's worklist made +/* Policy classification (see DUPRE.POLICIES). This is the colour pass's worklist made explicit: every card gets a record here as we review it — the kind it's bound by, why in its own terms, and the range it may vary and stay authentic. This round covers the cards already touched; the rest are unclassified on purpose, the review still to do card by card, not a memory dump to fill now. Assigned after the builders exist, in one place, so it reads as a catalogue. */ -GW.slideToggle.POLICY = { kind: 'accent', - why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the widget’s.', +DUPRE.slideToggle.POLICY = { kind: 'accent', + why: 'On means good in one panel and a fault in the next, so the lit colour is the consumer’s claim, not the instrument’s.', authentic: 'The on-colour from the accent family (red, amber, green, white, vfd), plus the pill and thumb finishes. The switch form stays.' }; -GW.segmented.POLICY = { kind: 'accent', +DUPRE.segmented.POLICY = { kind: 'accent', why: 'The active segment signals a mode whose meaning depends on the panel it sits in.', authentic: 'The accent colour of the active segment. The segment count and labels are the deployment’s, not a colour choice.' }; -GW.chipToggle.POLICY = { kind: 'accent', +DUPRE.chipToggle.POLICY = { kind: 'accent', why: 'A filter chip’s on-state reads as good, warning or fault entirely by where it is used.', authentic: 'The lit colour from the accent family. The chip keeps its inline weight and dotted underline.' }; -GW.dataMatrix.POLICY = { kind: 'screen', +DUPRE.dataMatrix.POLICY = { kind: 'screen', why: 'LED and LCD dot-matrix modules were sold in several emitter colours, so no one colour is definitive.', authentic: 'The screen family (amber, green, red, blue, vfd, white) — each a panel that was actually built. Not an arbitrary hue.' }; -GW.roundCrt.POLICY = { kind: 'screen', +DUPRE.roundCrt.POLICY = { kind: 'screen', why: 'CRT phosphors were manufactured in more than one colour (P1 green, P3 amber, white), so the trace colour is a real choice.', authentic: 'The screen family, limited to phosphors that existed. The face tint follows the phosphor.' }; -GW.waveRegion.POLICY = { kind: 'screen', +DUPRE.waveRegion.POLICY = { kind: 'screen', why: 'A backlit editor LCD was made in several tints; the ink colour is the panel’s, not fixed.', authentic: 'The screen family. The waveform and region handles recolour with the screen, staying legible on it.' }; -GW.radarSweep.POLICY = { kind: 'screen', +DUPRE.radarSweep.POLICY = { kind: 'screen', why: 'PPI radar scopes ran amber and green phosphors both; neither is the one true colour.', authentic: 'The screen family, phosphors that shipped. The sweep and afterglow track the chosen screen.' }; -GW.abcKeypad.POLICY = { kind: 'screen', +DUPRE.abcKeypad.POLICY = { kind: 'screen', why: 'The entry window is a screen like any other; its phosphor was made in several colours. (Mixed card: the keys are fixed-function and not a colour choice.)', authentic: 'The window’s screen family. The keycap colours are functional and stay put.' }; -Object.assign(GW, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS }); -window.GW = GW; +Object.assign(DUPRE, { SVGNS, svgEl, polar, dragX, dragY, dragDelta, SEG, seg7, buildBars, VUDB, vuDb, SCREEN_FAMS }); +window.DUPRE = DUPRE; })(); diff --git a/docs/specs/2026-07-02-desktop-settings-panel-spec.org b/docs/specs/2026-07-02-desktop-settings-panel-spec.org index 50853f3..17fa0e6 100644 --- a/docs/specs/2026-07-02-desktop-settings-panel-spec.org +++ b/docs/specs/2026-07-02-desktop-settings-panel-spec.org @@ -136,7 +136,7 @@ other things" into this panel, but the extras weren't enumerated. Current control list (above): auto-dim, idle/caffeine, touchpad, mouse, airplane, screen brightness, keyboard backlight. Candidates raised or adjacent — confirm which belong here vs the audio panel vs the bar: night-light / color -temperature, a theme/dupre-vs-hudson switch (theme-studio kin), volume or a +temperature, volume or a master-mute mirror (or leave all audio to the audio panel), a notifications/do-not-disturb toggle (dunst), lock/suspend actions. Craig to name the set. diff --git a/docs/specs/2026-07-07-maintenance-console-spec.org b/docs/specs/2026-07-07-maintenance-console-spec.org index 9810fe8..1044cfc 100644 --- a/docs/specs/2026-07-07-maintenance-console-spec.org +++ b/docs/specs/2026-07-07-maintenance-console-spec.org @@ -88,7 +88,7 @@ Temps, memory, and throttle re-read every ~3 s while visible; local probes every *Refresh tiers* (all engine-side, GUI subscribes): live group ~3 s panel-open + subpanel-visible; fast local ~30 s panel-open + post-action re-probe; hydration on open (fast reads first, process probes behind, sub-second perceived); network tier from the hourly timer cache, on-demand refresh only; *slow local tier* — probes whose cost class matches the network tier despite being local (full =pacman -Qkk=, measured 47 s on ratio; the disk top-consumers scan) ride the same hourly timer cache with age shown, on-demand refresh only, never in hydration or fast-local; closed-panel glyph fed by a ~30-min systemd user timer running =maint scan --glyph= to write the state file =waybar-maint= reads. Two user units ship with the package: =maint-scan.timer= (glyph) and =maint-net-scan.timer= (hourly network-tier + slow-local cache). -*Waybar wiring.* =custom/maint= replaces =custom/sysmon= in the modules list and config; glyph state file + SIGRTMIN signal refresh like =custom/ptt=. Gotcha (from the PTT build): waybar runs a *generated runtime config* — the module lands via =waybar-active-config= + SIGUSR2, not just a canonical config edit. Theme CSS mirrored in both themes (dupre, hudson). +*Waybar wiring.* =custom/maint= replaces =custom/sysmon= in the modules list and config; glyph state file + SIGRTMIN signal refresh like =custom/ptt=. Gotcha (from the PTT build): waybar runs a *generated runtime config* — the module lands via =waybar-active-config= + SIGUSR2, not just a canonical config edit. Theme CSS lives in Dupre and mirrors the live Waybar stylesheet. *Cross-links, not duplicates.* DNS/NetworkManager repair deep-links to the net panel's doctor; OPEN JOURNAL launches a terminal running =journalctl -p err -b= (the NET DOCTOR delegation pattern). @@ -229,7 +229,7 @@ Round-1 (verified live): every other assumed tool present; =arch-audit= is not. Round-1: the hourly network-tier/slow-local cache must survive reboots (else "no data" for up to an hour after boot) — =$XDG_RUNTIME_DIR= wrong for it. Resolved: added to the open config-paths decision; proposal now puts glyph state + cache under =~/.local/state/maint/=. ** DONE Panel-CSS "both themes" ambiguity -Round-1 (code fact): sibling panels load =themes/dupre/panel.css= hardcoded (=audio/gui.py:52-61=); hudson has no panel.css. Resolved: Phase 7 follows the sibling dupre-only panel-CSS convention; Phase 11 scopes both-themes CSS to the waybar glyph stylesheets. +Round-1 (code fact): sibling panels load =themes/dupre/panel.css= hardcoded (=audio/gui.py:52-61=). Resolved: Phase 7 follows that shared Dupre panel-CSS convention; Phase 11 scopes glyph CSS to the Dupre/live Waybar stylesheet pair. * Implementation phases @@ -254,7 +254,7 @@ DNS/NM reachability, firewall state (+ public-bind exposure naming when down), l =remedies.py= (full allowlisted table with =re_probe_ids= + running-% for long ops), =priv.py= verb table, =guard.py= live-update matcher (pure), curation writes (mark/unmark/clear/keep/expected), =maint fix <id> [--dry-run]=, =maint doctor clean|review= streaming an event feed (text wall in the terminal). Topgrade wrapper with =--disable git=. KILL's four guards. Argv-construction tests for every remedy; no execution. Splits naturally into 6a (remedies/priv/guard) + 6b (curation writes + doctor + CLI) if the session runs long — 6a alone leaves a working tree. ** Phase 7 — GUI shell -GTK4 window, faceplate, category selector row (lamps + fixable/watch splits), subpanel scaffolding, hydration tiers + refresh cadence (live/fast, visibility-gated like the audio meters), =MAINT_PANEL_FIXTURE= loading, panel CSS following the sibling convention (=themes/dupre/panel.css=, the shared panel stylesheet — hudson carries waybar CSS only, no panel.css). Renders real =status= data read-only — no levers yet. +GTK4 window, faceplate, category selector row (lamps + fixable/watch splits), subpanel scaffolding, hydration tiers + refresh cadence (live/fast, visibility-gated like the audio meters), =MAINT_PANEL_FIXTURE= loading, panel CSS following the sibling convention (=themes/dupre/panel.css=, the shared panel stylesheet). Renders real =status= data read-only — no levers yet. ** Phase 8 — GUI subpanels: storage, snapshots, packages, updates strip Evidence digests, armed per-item keys (orphan REMOVE/KEEP, pacnew MERGE, DELETE STALE, scrub with running-%), the Packages rotary band selector, updates strip with state-tiered border, UPDATE/TOPGRADE behind the arming guard, armed REBOOT on completion. @@ -266,7 +266,7 @@ Failed-unit roster keys, journal digest + MARK KNOWN lifecycle UI + OPEN JOURNAL The wall widget (lamp stream, date+time, inline results, running-%, HIDE/COPY, 3.5-entry cap, dark scrollbar), CLEAN UP / REVIEW & FIX wired through =doctor.py=, every armed key streaming, curation events logged, post-action re-probes visible. ** Phase 11 — Waybar glyph + timers -=indicator.py= + =waybar-maint=, =custom/maint= replacing =custom/sysmon= (canonical config + =waybar-active-config= runtime path + SIGUSR2), signal-driven refresh, =maint-scan.timer= + =maint-net-scan.timer= user units, glyph color tracks worst diagnostic from the state file; on battery hosts the module text is the live battery level (sysfs read per waybar interval, charging indicator, low-charge threshold feeding the diagnostic state) and any existing battery module retires. Glyph CSS lands in both theme waybar stylesheets (dupre, hudson). Retirement collateral in the same commit (Craig ruled 2026-07-07): =custom/maint= right-click re-homes the btop scratchpad (=pypr toggle monitor=); the =waybar-sysmon= + =sysmon-cycle= scripts and their suites (=tests/waybar-sysmon/=, =tests/sysmon-cycle/=) retire, and the =#custom-sysmon= CSS blocks come out of =waybar/style.css= and both themes. +=indicator.py= + =waybar-maint=, =custom/maint= replacing =custom/sysmon= (canonical config + =waybar-active-config= runtime path + SIGUSR2), signal-driven refresh, =maint-scan.timer= + =maint-net-scan.timer= user units, glyph color tracks worst diagnostic from the state file; on battery hosts the module text is the live battery level (sysfs read per waybar interval, charging indicator, low-charge threshold feeding the diagnostic state) and any existing battery module retires. Glyph CSS lands in the Dupre source and live Waybar stylesheet. Retirement collateral in the same commit (Craig ruled 2026-07-07): =custom/maint= right-click re-homes the btop scratchpad (=pypr toggle monitor=); the =waybar-sysmon= + =sysmon-cycle= scripts and their suites (=tests/waybar-sysmon/=, =tests/sysmon-cycle/=) retire, and the =#custom-sysmon= CSS blocks come out of =waybar/style.css= and Dupre. ** Phase 11b — Prototype fidelity pass (added 2026-07-08) Added mid-build from Craig's live-board review: phases 7-10 delivered E5's structure and behavior but rendered subpanel metrics as one-line list rows (the sibling panels' idiom) instead of the prototype's instrument-card grid; the per-phase screenshot checks verified structure, not presentation. Scope: card-grid subpanels (4-up metric cards — big-number value, caption line, progress bars, radial gauges for scrub cadence and NVMe wear, status chips, corner lever key), two-row selector tiles with compact count chips, subpanel attention/ok/fixable/watch header line, evidence digests as full-width rosters under the grid. Engine and viewmodel contracts unchanged; =gui.py= layout + =panel.css=. Sequenced before Phase 12 so the AT-SPI smoke targets the final layout. Verification is a pixel-level comparison against the settled E5 render (headless Chrome), not structural spot checks. diff --git a/docs/specs/2026-07-12-component-generation-spec.org b/docs/specs/2026-07-12-component-generation-spec.org index 8998585..09d0fce 100644 --- a/docs/specs/2026-07-12-component-generation-spec.org +++ b/docs/specs/2026-07-12-component-generation-spec.org @@ -8,7 +8,7 @@ :PROPERTIES: :ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba :END: -- 2026-07-12 Sun @ 22:56:40 -0500 — Phase 1 complete (still DOING): all 109 builders extracted into =widgets.js=, cards are declarative records, widget CSS moved into =GW_CSS= (pixel-diff identical), slide toggle carries the first constructor-opts style axes, README consumers section written. Commits acee657 → 7b3bc47 on main; probes + 239-check suite green throughout. Remaining: Phases 2–5. +- 2026-07-12 Sun @ 22:56:40 -0500 — Phase 1 complete (still DOING): all 109 builders extracted into =widgets.js=, cards are declarative records, widget CSS moved into =DUPRE_CSS= (pixel-diff identical), slide toggle carries the first constructor-opts style axes, README consumers section written. Commits acee657 → 7b3bc47 on main; probes + 239-check suite green throughout. Remaining: Phases 2–5. - 2026-07-12 Sun @ 20:57:50 -0500 — READY → DOING: phases decomposed into todo.org tasks under "Retro widget catalogue" (=:SPEC_ID:= stamped); Phase 1 extraction starts now per the option-1 approval. - 2026-07-12 Sun @ 20:57:50 -0500 — DRAFT → READY: spec-review passed (Ready; 3 findings — the option-1 supersession, the card-record refactor, stale counts — all accepted and folded same pass). - 2026-07-12 Sun @ 10:12:06 -0500 — drafted. @@ -53,12 +53,12 @@ The groundwork is already laid. Tokens are single-sourced in [[file:../prototype ** Scope tiers - v1: full web library extraction (with the card-record refactor), demand inventory for the scripted targets, Emacs ports of the demanded widgets, one waybar pilot panel, the Level-2 generator go/no-go. -- Out of scope: new widget collection as part of this work (the catalogue keeps growing on its own track — new cards land as =GW.*= calls once extracted), shared cross-target rendering code, interaction parity beyond each target's native idiom. +- Out of scope: new widget collection as part of this work (the catalogue keeps growing on its own track — new cards land as =DUPRE.*= calls once extracted), shared cross-target rendering code, interaction parity beyond each target's native idiom. - vNext (log to todo.org at hand-off): React/framework wrappers, Level-2/3 codegen if Phase 5 says go, ports beyond the demand matrix as consumers appear. * Design ** For a consumer -Web: include =widgets.js= (classic script) after the token =:root= block; call a builder — e.g. =GW.vuMeter(el, {value: 0.6})= — and get back a handle with setters. Widget-internal CSS ships inside =widgets.js= (the =GW_CSS= block, self-injected at load), so the consumer supplies only the tokens. The gallery page itself becomes a grid of exactly these calls, so the demo and the library cannot drift. +Web: include =widgets.js= (classic script) after the token =:root= block; call a builder — e.g. =DUPRE.vuMeter(el, {value: 0.6})= — and get back a handle with setters. Widget-internal CSS ships inside =widgets.js= (the =DUPRE_CSS= block, self-injected at load), so the consumer supplies only the tokens. The gallery page itself becomes a grid of exactly these calls, so the demo and the library cannot drift. Emacs: =(require 'gallery-widgets)= (the =gallery-widget.el= pattern, one file per widget family or one shared file — Phase 3 sizes this); each widget is a function from parameters to an svg.el object, tokens resolved through =gallery-tokens.el=. Interaction (keymap, click regions) is per-widget Emacs idiom, not a translation of pointer drags. @@ -130,7 +130,7 @@ Extraction order inside Phase 1: the card builders already share =svgEl=/=polar= ** DONE Web library packaging - Context: the gallery is a single self-contained =file://= HTML page; ES modules don't load over =file://=, and a React consumer doesn't exist yet. -- Decision: =widgets.js= ships as a classic script exposing one namespace object (working name =GW=), loaded by the gallery via a relative =<script src>=; the shared helpers (=svgEl=, =polar=, =vuDb=, scale tables) move inside it. Framework wrappers are vNext, demand-gated. +- Decision: =widgets.js= ships as a classic script exposing one namespace object (working name =DUPRE=), loaded by the gallery via a relative =<script src>=; the shared helpers (=svgEl=, =polar=, =vuDb=, scale tables) move inside it. Framework wrappers are vNext, demand-gated. - Consequences: easier — gallery keeps working from =file://=, zero build tooling; harder — no tree-shaking or import isolation (acceptable at this scale). - Approved by Craig 2026-07-12 (componentization go-ahead, option 1). The extraction itself is ungated — a lossless transform verified by the CDP probes; Craig's per-card validation pass (the status lamps) gates only the per-widget Emacs ports and the final blessing. @@ -139,7 +139,7 @@ Extraction order inside Phase 1: the card builders already share =svgEl=/=polar= The spec's Phase 1 → Phase 2 order gates the web extraction on the demand matrix, and the Web-library-packaging decision says the build is "gated on his per-card validation pass." Craig's componentization go-ahead (option 1, 2026-07-12 ~21:30) superseded both: the web extraction covers the *full* catalogue as a lossless transform and starts immediately; the demand matrix and the validation lamps gate only the per-widget Emacs ports and the final blessing. An implementer reading the spec as written would follow the retired order. Recommended change: re-scope the Demand-driven-porting decision to the Emacs/waybar targets, drop the extraction gate from the packaging decision, and reorder/reword Phases 1-2. Disposition: accepted — pre-agreed by Craig (recorded in todo.org and the 2026-07-12 session log). Folded into Decisions 4 and 8 and Phases 1-2. ** DONE Card-as-component refactor absent from Phase 2 -The approved build includes restructuring each gallery card into a declarative record (no/title/stage builder/options/readout/note/spec-sheet incl. reference link/validation lamp) so the page becomes data plus =GW.*= calls, but Phase 2 as written only covers lifting builders into =widgets.js=. Without it the "gallery becomes calls into the library" claim leaves the card chrome hand-rolled per card. Recommended change: name the card-record refactor as part of Phase 2's deliverable. +The approved build includes restructuring each gallery card into a declarative record (no/title/stage builder/options/readout/note/spec-sheet incl. reference link/validation lamp) so the page becomes data plus =DUPRE.*= calls, but Phase 2 as written only covers lifting builders into =widgets.js=. Without it the "gallery becomes calls into the library" claim leaves the card chrome hand-rolled per card. Recommended change: name the card-record refactor as part of Phase 2's deliverable. Disposition: accepted — folded into the Design section and Phase 2. ** DONE Stale gallery facts The spec states 84 cards, a 2258-line file, the R-series closed at R31, and 52a43ec as the final SHA. The gallery has since grown to 109 cards (R-series through R56, ~4300 lines) across the gallery-upgrades merge and the takuzu-survey builds. Not a design problem, but stale numbers in the contract mislead the implementer sizing the extraction. Recommended change: refresh the counts and phrase them as of the review date, since the catalogue still grows. @@ -147,7 +147,7 @@ Disposition: accepted — counts refreshed and dated throughout. * Implementation phases ** Phase 1 — Web library extraction -Lift *all* card builders into =docs/prototypes/widgets.js= behind parameter objects (classic script, =GW= namespace, shared helpers inside), and restructure each gallery card as a declarative record (no / title / stage builder / options / readout / note / spec sheet incl. reference link / validation lamp) so the page becomes data plus =GW.*= calls. Batched (roughly 10-15 widgets per batch); a green re-run of the CDP behavioral probes (=tests/gallery-probes/=) is the per-batch no-regression gate, and each batch leaves the gallery fully working from =file://=. Ungated by the validation lamps (option-1 approval — lossless transform). +Lift *all* card builders into =docs/prototypes/widgets.js= behind parameter objects (classic script, =DUPRE= namespace, shared helpers inside), and restructure each gallery card as a declarative record (no / title / stage builder / options / readout / note / spec sheet incl. reference link / validation lamp) so the page becomes data plus =DUPRE.*= calls. Batched (roughly 10-15 widgets per batch); a green re-run of the CDP behavioral probes (=tests/gallery-probes/=) is the per-batch no-regression gate, and each batch leaves the gallery fully working from =file://=. Ungated by the validation lamps (option-1 approval — lossless transform). ** Phase 2 — Demand inventory (Emacs / waybar) Build the widget-to-target matrix for the *scripted-port* targets: walk the live waybar panels (net/bt/audio/maint) and the Emacs surfaces Craig names (games, dashboards), and record which gallery cards each actually wants. Deliverable: a short matrix table in this spec's appendix, approved by Craig. Tree untouched. Runs in parallel with or after Phase 1 — the extraction does not wait on it. @@ -163,7 +163,7 @@ After ~5 hand ports, revisit Level-2 codegen with evidence: how much of each por * Acceptance criteria - [ ] Demand matrix exists in the appendix and Craig has approved it. -- [X] Every gallery card is a declarative record calling into =widgets.js=; CDP probes green; gallery renders unchanged from =file://=. (Done 2026-07-12: 109/109 builders, widget CSS in =GW_CSS=, pixel-diff verified under forced reduced motion.) +- [X] Every gallery card is a declarative record calling into =widgets.js=; CDP probes green; gallery renders unchanged from =file://=. (Done 2026-07-12: 109/109 builders, widget CSS in =DUPRE_CSS=, pixel-diff verified under forced reduced motion.) - [ ] Every demanded Emacs widget has ERT coverage and an rsvg side-by-side render matching its card; =make test-unit= green. - [ ] The audio panel reads its palette from =tokens-waybar.css=; panel suite green; manual-testing checklist entry filed. - [ ] Phase 5 go/no-go recorded as a dated decision. diff --git a/scripts/testing/tests/test_boot.py b/scripts/testing/tests/test_boot.py index e442682..66b78e4 100644 --- a/scripts/testing/tests/test_boot.py +++ b/scripts/testing/tests/test_boot.py @@ -76,8 +76,8 @@ def test_zfs_pre_pacman_snapshot_hook(host): script = host.file("/usr/local/bin/zfs-pre-snapshot") assert script.exists and script.is_file, "pre-pacman snapshot script missing" assert script.mode & 0o111, "pre-pacman snapshot script is not executable" - hook = host.file("/etc/pacman.d/hooks/zfs-snapshot.hook") - assert hook.exists and hook.is_file, "zfs-snapshot.hook missing" + hook = host.file("/etc/pacman.d/hooks/05-zfs-snapshot.hook") + assert hook.exists and hook.is_file, "05-zfs-snapshot.hook missing" assert "PreTransaction" in hook.content_string, "hook not PreTransaction" assert "/usr/local/bin/zfs-pre-snapshot" in hook.content_string, \ "hook does not exec the snapshot script" diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py index 6f79bfd..468bc75 100644 --- a/scripts/testing/tests/test_desktop.py +++ b/scripts/testing/tests/test_desktop.py @@ -56,7 +56,7 @@ def test_live_update_guard_installed(host, hyprland_installed): guard = host.file("/usr/local/bin/hypr-live-update-guard") assert guard.exists, "live-update guard script missing" assert guard.mode & 0o111, "live-update guard not executable" - hook = host.file("/etc/pacman.d/hooks/hypr-live-update-guard.hook") + hook = host.file("/etc/pacman.d/hooks/10-hypr-live-update-guard.hook") assert hook.exists, "live-update guard pacman hook missing" assert "hypr-live-update-guard" in hook.content_string, \ "hook does not invoke the guard script" diff --git a/tests/gallery-probes/audit-extraction.mjs b/tests/gallery-probes/audit-extraction.mjs new file mode 100644 index 0000000..3a69f2b --- /dev/null +++ b/tests/gallery-probes/audit-extraction.mjs @@ -0,0 +1,77 @@ +// audit-extraction.mjs — report-only grader for the extraction-readiness bar. +// +// Every DUPRE builder should be liftable out of the gallery without archaeology: +// a contract comment documenting opts + handle, its CSS in DUPRE_CSS rather than +// the page stylesheet, and no page code reaching past a handle into instrument +// DOM. This audit is static (no browser): it reads the sources and reports a +// worklist. It gates nothing — the gate flips on only when the sweep reaches +// 100% (the VSTATUS/policy ratchet pattern). Exit 0 always, unless the audit +// itself cannot parse the sources. +// +// Checks: +// C1 contract comment — the block comment directly above DUPRE.<name> = +// function mentions both opts and the handle (the split-flap shape). +// C2 page-styled internals — class selectors in the page <style> that also +// appear in DUPRE_CSS (the page overriding component internals). +// C3 page reach-ins — page script querySelectors descending into a card's +// stage (past the handle) instead of using the .dupre surface. +// +// Usage: node audit-extraction.mjs [--verbose] + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const kitSrc = readFileSync(join(root, 'docs/prototypes/widgets.js'), 'utf8'); +const page = readFileSync(join(root, 'docs/prototypes/panel-widget-gallery.html'), 'utf8'); +const verbose = process.argv.includes('--verbose'); + +// ---- C1: contract comments ------------------------------------------------- +// Builders are `DUPRE.<name> = function`; the comment block that ends directly +// above (allowing blank lines) is its documentation. +const builders = []; +const re = /DUPRE\.(\w+)\s*=\s*function/g; +for (let m; (m = re.exec(kitSrc)); ) { + const name = m[1]; + const head = kitSrc.slice(0, m.index); + // Only the LAST comment block directly above counts — a lazy match from the + // file start would hand every builder the whole file's text and grade + // everything compliant (it did, on the audit's first run). + const at = head.lastIndexOf('/*'); + const tail = at >= 0 ? head.slice(at) : ''; + const comment = /^\/\*[\s\S]*\*\/\s*$/.test(tail) ? tail : ''; + const contract = /\bopts\b/i.test(comment) && /\bhandle\b/i.test(comment); + builders.push({ name, contract, commentLen: comment.length }); +} + +// ---- C2: page-styled instrument internals ------------------------------------- +const classSels = src => new Set([...src.matchAll(/\.([a-zA-Z][\w-]*)/g)].map(x => x[1])); +const cssBlock = kitSrc.match(/DUPRE_CSS\s*=\s*\[([\s\S]*?)\]\.join|DUPRE_CSS\s*=\s*`([\s\S]*?)`/); +const dupreCss = cssBlock ? (cssBlock[1] || cssBlock[2]) : ''; +if (!dupreCss) { console.error('AUDIT ERROR: DUPRE_CSS not found'); process.exit(1); } +const pageStyle = [...page.matchAll(/<style>([\s\S]*?)<\/style>/g)].map(x => x[1]).join('\n'); +const dupreClasses = classSels(dupreCss); +// Rig and page chrome legitimately live in the page; only instrument-internal +// classes (defined in DUPRE_CSS) styled by the page are violations. +const pageOverrides = [...classSels(pageStyle)].filter(c => dupreClasses.has(c)); + +// ---- C3: page reach-ins ---------------------------------------------------- +// Page script (inline <script> bodies only; widgets.js is external). +const pageScript = [...page.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(x => x[1]).join('\n'); +const RIG = /\.(famchips|fc|fgroup|lab|opts|wrd|stagew|winfo|wnote|wname|no)\b/; +const reachIns = [...pageScript.matchAll(/querySelector(?:All)?\(\s*['"](#card-[\w-]+\s+[^'"]+)['"]/g)] + .map(x => x[1]).filter(sel => !RIG.test(sel)); + +// ---- report ---------------------------------------------------------------- +const good = builders.filter(b => b.contract); +console.log(`extraction audit — ${builders.length} builders`); +console.log(`C1 contract comments: ${good.length}/${builders.length} compliant`); +if (verbose || true) { + const bad = builders.filter(b => !b.contract).map(b => b.name); + if (bad.length) console.log(` missing (${bad.length}): ${bad.join(' ')}`); +} +console.log(`C2 page-styled instrument internals: ${pageOverrides.length}${pageOverrides.length ? ' — ' + [...new Set(pageOverrides)].join(' ') : ' (clean)'}`); +console.log(`C3 page reach-ins past handles: ${reachIns.length}${reachIns.length ? '' : ' (clean)'}`); +reachIns.forEach(r => console.log(' ' + r)); +process.exit(0); diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs index 7aa2c4b..a2333fb 100644 --- a/tests/gallery-probes/probe-fams.mjs +++ b/tests/gallery-probes/probe-fams.mjs @@ -42,15 +42,15 @@ try { const line1 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`); ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`); - // scope (CSS widget): trace stroke changes on amber chip - const tr0 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`); - await evl(`document.querySelector('.scope').closest('.card').querySelector('.fc[title="amber"]').click()`); - const tr1 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`); + // scope (CSS instrument): trace stroke changes on amber chip + const tr0 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`); + await evl(`document.querySelector('.dupre-scope').closest('.card').querySelector('.fc[title="amber"]').click()`); + const tr1 = await evl(`getComputedStyle(document.querySelector('.dupre-scope polyline')).stroke`); ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`); // R19: drag still works after family switch (click vfd, then check the handle's set() writes var-based fills) await evl(`document.querySelector('#card-R19 .fc[title="vfd"]').click()`); - await evl(`document.getElementById('card-R19').gw.set(30,70)`); + await evl(`document.getElementById('card-R19').dupre.set(30,70)`); const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 svg rect')[10];return b.getAttribute('fill');})()`); ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill); @@ -71,17 +71,17 @@ try { // Default is the shipped gold, unchanged until a chip is clicked. It reads the // same as the amber family's --scr-hi because --gold-hi IS #ffbe54 — which is // what makes the amber default pixel-identical rather than a near-miss. - const kpInk0 = await evl(`getComputedStyle(document.querySelector('#card-R57 .kp-pad text')).fill`); + const kpInk0 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`); ok('R57 default ink = gold-hi', kpInk0 === 'rgb(255, 190, 84)', kpInk0); // vfd recolors both the ink and the window behind it — a screen is not just its text await evl(`document.querySelector('#card-R57 .fc[title="vfd"]').click()`); - const kpInk1 = await evl(`getComputedStyle(document.querySelector('#card-R57 .kp-pad text')).fill`); + const kpInk1 = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-pad text')).fill`); ok('R57 vfd chip recolors the ink to marquee cyan', kpInk1 === 'rgb(99, 230, 200)', kpInk1); - /* .kp-win, not the first rect on the pad — that one is the faceplate, and + /* .dupre-kp-win, not the first rect on the pad — that one is the faceplate, and reading it made this check report the plate's colour and fail for the wrong reason. */ - const kpBg = await evl(`getComputedStyle(document.querySelector('#card-R57 .kp-win')).fill`); + const kpBg = await evl(`getComputedStyle(document.querySelector('#card-R57 .dupre-kp-win')).fill`); ok('R57 vfd chip recolors the window too', kpBg === 'rgb(6, 16, 13)', kpBg); ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0); diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs index 051d575..3db09a5 100644 --- a/tests/gallery-probes/probe.mjs +++ b/tests/gallery-probes/probe.mjs @@ -1,4 +1,4 @@ -// CDP probe for the widget gallery — no playwright, Node global WebSocket/fetch. +// CDP probe for the instrument gallery — no playwright, Node global WebSocket/fetch. // Usage: node probe.mjs [--shot out.png] [--size N] import { spawn } from 'node:child_process'; import { writeFileSync } from 'node:fs'; @@ -65,7 +65,7 @@ try { const errs = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200)); - // 1. card count (the widgets/row default is checked in 1h) + // 1. card count (the instruments/row default is checked in 1h) const cards = await evl(`document.querySelectorAll('.card').length`); ok('111 cards', cards === 111, `got ${cards}`); @@ -89,7 +89,7 @@ try { const chips = [...document.querySelectorAll('#card-07 .fc')].map(c => c.title); const missing = want.filter(w => !chips.includes(w)); if (missing.length) return 'card 07 missing accents: ' + missing.join(' '); - const built = Object.keys(GW.accentStyles('--x')); + const built = Object.keys(DUPRE.accentStyles('--x')); return JSON.stringify(built) === JSON.stringify(want) ? 'ok' : 'family is ' + JSON.stringify(built); })()`); ok('card 07 offers the shared accent family', accents === 'ok', accents); @@ -97,7 +97,7 @@ try { // 1d. The chip actually recolours, and its default is untouched until asked — // the same fallback discipline the screen families use. const chipInk = await evl(`(()=>{ - const chip = document.querySelector('#card-07 .chip'); + const chip = document.querySelector('#card-07 .dupre-chip'); const lit = () => getComputedStyle(chip).color; const before = lit(); document.querySelector('#card-07 .fc[title="vfd"]').click(); @@ -107,7 +107,7 @@ try { })()`); ok('card 07 defaults to gold and recolours on click', chipInk === 'ok', chipInk); - // 1e. The colour-policy backbone. Every widget's colour is either free (the + // 1e. The colour-policy backbone. Every instrument's colour is either free (the // consumer picks) or locked for a stated reason, and the colour pass kept // finding cards where "is this one a standard?" was answered from memory and // wrong. So policy is declared on the builder and checked here. The invariant @@ -116,10 +116,10 @@ try { // an accent card can't forget to say so. Coverage grows card by card; this // round classifies the touched set, and the count is reported not gated. const policy = await evl(`(()=>{ - const KINDS = GW.POLICIES; + const KINDS = DUPRE.POLICIES; if (!KINDS) return 'no POLICIES vocabulary'; const valid = new Set(Object.keys(KINDS)); - const fns = Object.entries(GW).filter(([,v]) => typeof v === 'function'); + const fns = Object.entries(DUPRE).filter(([,v]) => typeof v === 'function'); const declared = fns.filter(([,v]) => v.POLICY); // each POLICY record is complete: a valid kind, a why, an authentic range const shape = declared.filter(([,v]) => !valid.has(v.POLICY.kind) || !v.POLICY.why || !v.POLICY.authentic) @@ -177,27 +177,27 @@ try { })()`); ok('colour policy tally sums to the card total', ptally.startsWith('ok'), ptally); - // 1h. widgets/row: the control sets an explicit column count and the cards + // 1h. instruments/row: the control sets an explicit column count and the cards // resize to match. One-per-row cards must be much wider than four-per-row, // and the count actually reaches the grid. - ok('default is 3 widgets/row', await evl(`document.body.dataset.cols`) === '3'); - await evl(`document.querySelector('.szbar .key[data-cols="1"]').click()`); + ok('default is 3 instruments/row', await evl(`document.body.dataset.cols`) === '3'); + await evl(`document.querySelector('.szbar .dupre-key[data-cols="1"]').click()`); const w1col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); const cols1 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); - await evl(`document.querySelector('.szbar .key[data-cols="4"]').click()`); + await evl(`document.querySelector('.szbar .dupre-key[data-cols="4"]').click()`); const w4col = await evl(`document.querySelector('.card').getBoundingClientRect().width`); const cols4 = await evl(`getComputedStyle(document.querySelector('.grid')).gridTemplateColumns.split(' ').length`); ok('1/row is much wider than 4/row', w1col > w4col * 2.5, `w1=${Math.round(w1col)} w4=${Math.round(w4col)}`); ok('the grid renders the chosen column count', cols1 === 1 && cols4 === 4, `1->${cols1} 4->${cols4}`); - ok('widgets/row chip flips state', await evl(`document.body.dataset.cols`) === '4'); + ok('instruments/row chip flips state', await evl(`document.body.dataset.cols`) === '4'); // 3. behavioral, zoomed: fader drag on card 03 changes readout. Run at 2/row, // not 1/row: at one-per-row the card is wide enough that its controls fall // outside the 1600px probe window and the dispatched mouse events miss. - await evl(`document.querySelector('.szbar .key[data-cols="2"]').click()`); + await evl(`document.querySelector('.szbar .dupre-key[data-cols="2"]').click()`); await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`); await sleep(200); - const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`); + const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.dupre-fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`); const before = await evl(`document.getElementById('rd-03').textContent`); await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2); await sleep(150); @@ -218,21 +218,21 @@ try { // 5. card 02 console keys: reading order and per-key tone. LIVE is green // because --pass is what the kit means by live everywhere else; gold stays // the generic engaged look. Craig's call, 2026-07-16. - const order = await evl(`[...document.querySelectorAll('#card-02 .key')].map(b => b.textContent)`); + const order = await evl(`[...document.querySelectorAll('#card-02 .dupre-key')].map(b => b.textContent)`); ok('card 02 keys read SCAN, LIVE, MUTED', JSON.stringify(order) === JSON.stringify(['SCAN', 'LIVE', 'MUTED']), JSON.stringify(order)); const engaged = await evl(`(()=>{ - const lit = [...document.querySelectorAll('#card-02 .key')].filter(b => /\\b(on|green|red)\\b/.test(b.className)); - return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length; + const lit = [...document.querySelectorAll('#card-02 .dupre-key')].filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className)); + return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length; })()`); ok('card 02 engages LIVE in green by default', engaged === 'LIVE:green', engaged); const muted = await evl(`(()=>{ - const keys = [...document.querySelectorAll('#card-02 .key')]; + const keys = [...document.querySelectorAll('#card-02 .dupre-key')]; keys.find(b => b.textContent === 'MUTED').click(); - const lit = keys.filter(b => /\\b(on|green|red)\\b/.test(b.className)); - return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length; + const lit = keys.filter(b => /\\bdupre-(on|green|red)\\b/.test(b.className)); + return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('dupre-key dupre-','') : 'lit=' + lit.length; })()`); ok('card 02 MUTED engages red and releases LIVE', muted === 'MUTED:red', muted); @@ -245,7 +245,7 @@ try { // disengaged state instead. dim offText lets an off toggle recede in a dense // panel. Craig's call, 2026-07-16. const atoms = await evl(`(()=>{ - const S = GW.slideToggle.STYLES; + const S = DUPRE.slideToggle.STYLES; const want = { on: ['amber','green','dark','red','warn'], onText: ['panel','cream','green'], off: ['dark','red'], offText: ['white','red','black','dim'], thumb: ['light','dark','chrome','brass'] }; @@ -260,9 +260,9 @@ try { // must resolve in STYLES — a typo here would silently fall through to the // stylesheet default and look "nearly right", which is the worst outcome. const presets = await evl(`(()=>{ - const P = GW.slideToggle.PRESETS, S = GW.slideToggle.STYLES; + const P = DUPRE.slideToggle.PRESETS, S = DUPRE.slideToggle.STYLES; if (!P) return 'no PRESETS'; - const AX = GW.slideToggle.AXIS_ORDER; + const AX = DUPRE.slideToggle.AXIS_ORDER; const bad = []; for (const [name, p] of Object.entries(P)) { for (const ax of AX) { @@ -279,7 +279,7 @@ try { // the only thing carrying state. Both backgrounds must stay dark while the // inks diverge — if either pill lights, the preset has lost its point. const dark = await evl(`(()=>{ - const P = GW.slideToggle.PRESETS.dark, S = GW.slideToggle.STYLES; + const P = DUPRE.slideToggle.PRESETS.dark, S = DUPRE.slideToggle.STYLES; const onBg = S.on[P.on].vars['--sw-on-bg'], offBg = S.off[P.off].vars['--sw-off-bg']; const onInk = S.onText[P.onText].vars['--sw-on-ink'], offInk = S.offText[P.offText].vars['--sw-off-ink']; if (onBg !== offBg) return 'pills differ: on=' + onBg + ' off=' + offBg; @@ -288,7 +288,7 @@ try { })()`); ok('dark preset keeps both pills dark and the inks distinct', dark === 'ok', dark); - // 7b. the card claims the preset it is actually in. The widget defaults to + // 7b. the card claims the preset it is actually in. The instrument defaults to // `panel`, so a preset group with nothing lit would assert "no preset // active" — false, and exactly the kind of quiet mislabel this walk exists // to catch. Nothing above this point touches card 01's chips (checks 2-4 @@ -304,8 +304,8 @@ try { })()`); ok('card 01 defaults to the panel preset', defaultPreset === 'panel', defaultPreset); - // 8. a preset chip drives the widget AND re-syncs the axis chips, so the card - // never shows a combination the widget isn't in. + // 8. a preset chip drives the instrument AND re-syncs the axis chips, so the card + // never shows a combination the instrument isn't in. const applied = await evl(`(()=>{ const card = document.getElementById('card-01'); const groups = [...card.querySelectorAll('.fgroup')]; @@ -319,7 +319,7 @@ try { const brd = card.querySelector('.switch').style.getPropertyValue('--sw-on-brd'); return (lit.length === 1 ? lit[0].title : 'lit=' + lit.length) + '|' + brd; })()`); - ok('armed preset drives widget and syncs the on chip', applied === 'red|var(--fail)', applied); + ok('armed preset drives instrument and syncs the on chip', applied === 'red|var(--fail)', applied); // 8b. axes are not independent: onText overrides the ink `on` sets, so changing // `on` after picking an onText must not silently revert the legend while its @@ -360,7 +360,7 @@ try { // the operator can't be assumed to touch-type, and it is what Craig's // reference photos show. A QWERTY drift here would silently lose the idiom. const abcOrder = await evl(`(()=>{ - const letters = [...document.querySelectorAll('#card-R57 .kp-key')] + const letters = [...document.querySelectorAll('#card-R57 .dupre-kp-key')] .map(k => k.dataset.k).filter(k => /^[A-Z]$/.test(k)); const want = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ']; return JSON.stringify(letters) === JSON.stringify(want) @@ -369,7 +369,7 @@ try { ok('R57 carries A-Z in alphabetical order', abcOrder === 'ok', abcOrder); const abcDigits = await evl(`(()=>{ - const d = [...document.querySelectorAll('#card-R57 .kp-key')] + const d = [...document.querySelectorAll('#card-R57 .dupre-kp-key')] .map(k => k.dataset.k).filter(k => /^[0-9]$/.test(k)); return d.length === 10 ? 'ok' : 'got ' + d.length + ': ' + d.join(''); })()`); @@ -383,7 +383,7 @@ try { // start at column 3 while M-X started at column 0). const layout = await evl(`(()=>{ const x = k => { - const g = [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); return g ? Math.round(g.querySelector('rect').getBBox().x) : null; }; const colStarts = ['A','D','G','J','M','S','Y'].map(x); @@ -400,7 +400,7 @@ try { // deliberate inversion of where they started, easy to "tidy" back. const reach = await evl(`(()=>{ const box = k => { - const g = [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); return g ? g.querySelector('rect').getBBox() : null; }; const del = box('DEL'), clr = box('CLR'), ent = box('ENT'); @@ -418,7 +418,7 @@ try { // the palette may be retuned, the distinction may not collapse. const ladder = await evl(`(()=>{ const fill = k => { - const g = [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const g = [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); return g ? g.querySelector('rect').getAttribute('fill') : null; }; const f = { DEL: fill('DEL'), CLR: fill('CLR'), ENT: fill('ENT'), plain: fill('A'), digit: fill('1') }; @@ -432,7 +432,7 @@ try { // 11. typing accumulates, in order. A keypad that registers presses but drops // or reorders them is the failure that matters for a password field. const typed = await evl(`(()=>{ - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); for (const c of ['W','I','F','I','7']) key(c).dispatchEvent(new MouseEvent('click', {bubbles:true})); return document.getElementById('rd-R57').textContent; @@ -443,7 +443,7 @@ try { // wiping the whole entry, which on a 20-character passphrase means // starting over — so the check that matters is that DEL is not CLR. const del = await evl(`(()=>{ - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); click('CLR'); for (const c of ['C','A','B','S']) click(c); @@ -466,7 +466,7 @@ try { // passphrase they can't read back. Checked past the 13-char truncation // boundary, where there are no pad dots left for a space to displace. const space = await evl(`(()=>{ - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); const win = () => document.querySelector('#card-R57 text[font-size="14"]').textContent; click('CLR'); @@ -486,7 +486,7 @@ try { // the checks above — they mutate it, so a check that assumed their leftovers // would pass or fail on their behaviour instead of its own. const committed = await evl(`(()=>{ - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})); click('CLR'); for (const c of ['N','E','T','5']) click(c); @@ -505,10 +505,10 @@ try { // 13. KEYS is a declarative TABLE, not a function over a DOM event. The Emacs // port installs this same table into a keymap — it never sees a keydown — - // so a function here would force it to re-derive the widget's intent and + // so a function here would force it to re-derive the instrument's intent and // the two bindings would drift. (README, keyboard contract.) const keysTable = await evl(`(()=>{ - const K = GW.abcKeypad.KEYS; + const K = DUPRE.abcKeypad.KEYS; if (!K) return 'no KEYS'; if (typeof K !== 'object' || Array.isArray(K)) return 'KEYS is not a plain table: ' + typeof K; const missing = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'].filter(c => K[c] !== c); @@ -519,11 +519,11 @@ try { })()`); ok('R57 KEYS is a declarative table covering the plate', keysTable === 'ok', keysTable); - // 13b. THE contract's first rule: no document-level listener. A widget that + // 13b. THE contract's first rule: no document-level listener. An instrument that // binds globally types into itself from anywhere on a 110-card page. // Typing at the body with the card unfocused must do nothing at all. const unfocused = await evl(`(()=>{ - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); document.activeElement.blur(); for (const c of ['A','B','C']) document.body.dispatchEvent( @@ -535,8 +535,8 @@ try { // 13c. Focused, the same keys land — and land through press(), so click and key // cannot drift apart. const typedByKey = await evl(`(()=>{ - const pad = document.querySelector('#card-R57 .kp-pad'); - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); pad.focus(); const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); @@ -554,18 +554,18 @@ try { // identical buffer and readout. Duplicated logic in the handler would // pass every check above this one and fail here. const drift = await evl(`(()=>{ - const pad = document.querySelector('#card-R57 .kp-pad'); - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); const rd = () => document.getElementById('rd-R57').textContent; const seq = ['A','B','SPC','7']; key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); seq.forEach(k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}))); - const byClick = rd() + '/' + document.getElementById('card-R57').gw.get(); + const byClick = rd() + '/' + document.getElementById('card-R57').dupre.get(); key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); pad.focus(); ['A','B',' ','7'].forEach(k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))); - const byKey = rd() + '/' + document.getElementById('card-R57').gw.get(); + const byKey = rd() + '/' + document.getElementById('card-R57').dupre.get(); return byClick === byKey ? 'ok' : 'drift: click=' + byClick + ' key=' + byKey; })()`); ok('R57 click and key land in the same place', drift === 'ok', drift); @@ -576,9 +576,9 @@ try { // caller is a hole in exactly the target the table exists for. const pressGuard = await evl(`(()=>{ const card = document.getElementById('card-R57'); - const h = card.gw; + const h = card.dupre; if (!h || !h.press) return 'no handle'; - const key = k => [...card.querySelectorAll('.kp-key')].find(e => e.dataset.k === k); + const key = k => [...card.querySelectorAll('.dupre-kp-key')].find(e => e.dataset.k === k); key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); h.press('F1'); h.press('ArrowLeft'); h.press(''); const junk = h.get(); @@ -590,7 +590,7 @@ try { // 13c-4. Every key the table maps must be a real plate action, or the Emacs // port installs a binding that silently does nothing. const keysSubset = await evl(`(()=>{ - const bad = Object.entries(GW.abcKeypad.KEYS).filter(([,v]) => !GW.abcKeypad.ACTIONS.has(v)); + const bad = Object.entries(DUPRE.abcKeypad.KEYS).filter(([,v]) => !DUPRE.abcKeypad.ACTIONS.has(v)); return bad.length ? 'KEYS maps to non-actions: ' + JSON.stringify(bad) : 'ok'; })()`); ok('R57 every KEYS value is a real plate action', keysSubset === 'ok', keysSubset); @@ -598,9 +598,9 @@ try { // 13d. preventDefault is spent only where there is a default worth killing. // Space scrolls and Backspace navigates back, so those are claimed; Tab is // how the page is navigable and Escape belongs to the audit stepper, so a - // widget that swallows either breaks something it cannot see. + // instrument that swallows either breaks something it cannot see. const defaults = await evl(`(()=>{ - const pad = document.querySelector('#card-R57 .kp-pad'); + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); pad.focus(); const fired = k => { const e = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); @@ -621,8 +621,8 @@ try { // 13e. press() is an allowlist, not a mailbox: an unmapped key must not append. const unmapped = await evl(`(()=>{ - const pad = document.querySelector('#card-R57 .kp-pad'); - const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k); + const pad = document.querySelector('#card-R57 .dupre-kp-pad'); + const key = k => [...document.querySelectorAll('#card-R57 .dupre-kp-key')].find(e => e.dataset.k === k); key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true})); pad.focus(); ['F1','ArrowLeft','Home','é','!'].forEach(k => @@ -637,12 +637,12 @@ try { // click ever prints, the card has silently become a keypad with extra steps. // Reads the BUFFER, not the card readout: the readout is supposed to change // while hunting ("stylus over g"), and asserting on it would fail a correct - // widget for showing the operator where the pointer is. + // instrument for showing the operator where the pointer is. const grammar = await evl(`(()=>{ - const cell = c => document.querySelector('#card-R58 .ix-cell[data-c="' + c + '"]'); - const lever = document.querySelector('#card-R58 .ix-lever'); - const buf = () => document.getElementById('card-R58').gw.get(); - document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + const buf = () => document.getElementById('card-R58').dupre.get(); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); const empty = buf(); ['M','i','g'].forEach(c => cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true}))); const afterSelecting = buf(); @@ -654,9 +654,9 @@ try { // 14b. The lever prints whatever the stylus is resting on, once per pull — the // operator's two hands are two separate acts. const spelled = await evl(`(()=>{ - const cell = c => document.querySelector('#card-R58 .ix-cell[data-c="' + c + '"]'); - const lever = document.querySelector('#card-R58 .ix-lever'); - document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); for (const c of ['M','i','g','n','o','n']) { cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true})); lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); @@ -668,13 +668,13 @@ try { // 14c. Pulling the lever twice prints the character twice: the selection stays // put, which is what lets you type 'ss' without re-aiming. const repeat = await evl(`(()=>{ - const cell = c => document.querySelector('#card-R58 .ix-cell[data-c="' + c + '"]'); - const lever = document.querySelector('#card-R58 .ix-lever'); - document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + const cell = c => document.querySelector('#card-R58 .dupre-ix-cell[data-c="' + c + '"]'); + const lever = document.querySelector('#card-R58 .dupre-ix-lever'); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); cell('s').dispatchEvent(new MouseEvent('click', {bubbles:true})); lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); lever.dispatchEvent(new MouseEvent('click', {bubbles:true})); - return document.getElementById('card-R58').gw.get(); + return document.getElementById('card-R58').dupre.get(); })()`); /* Exact, on the buffer. /ss$/ on the readout also matched 'sss', so it passed even if selecting printed — unable to fail on the one bug this card is about. */ @@ -690,7 +690,7 @@ try { // That economy is an old value worth keeping, so it's asserted rather than // tolerated: adding a 1 key would be a silent departure from the reference. const charset = await evl(`(()=>{ - const have = new Set([...document.querySelectorAll('#card-R58 .ix-cell')].map(c => c.dataset.c)); + const have = new Set([...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.dataset.c)); const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz', ...'23456789', 'ä','ö','ü','§','½','¼','¾']; const missing = need.filter(c => !have.has(c)); @@ -707,7 +707,7 @@ try { // are dark" is wrong, and only position decides. const zones = await evl(`(()=>{ const disc = c => { - const g = [...document.querySelectorAll('#card-R58 .ix-cell')].find(e => e.dataset.c === c); + const g = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].find(e => e.dataset.c === c); return g ? g.querySelector('circle').getAttribute('fill') : null; }; const caps = disc('P'), lower = disc('p'), ringCap = disc('J'), ringPunct = disc('&'); @@ -722,10 +722,10 @@ try { // redraw. Craig has already said the keys will change; a layout welded into // the drawing is one that never does. const layoutData = await evl(`(()=>{ - const L = GW.indexPlate && GW.indexPlate.LAYOUT; + const L = DUPRE.indexPlate && DUPRE.indexPlate.LAYOUT; if (!L) return 'no LAYOUT'; if (!Array.isArray(L) || !L.every(Array.isArray)) return 'LAYOUT is not a grid'; - const cells = document.querySelectorAll('#card-R58 .ix-cell').length; + const cells = document.querySelectorAll('#card-R58 .dupre-ix-cell').length; const declared = L.flat().filter(Boolean).length; return cells === declared ? 'ok' : 'drawn ' + cells + ' but declared ' + declared; })()`); @@ -738,10 +738,10 @@ try { // controls back onto the characters. const clear = await evl(`(()=>{ const box = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; }; - const cells = [...document.querySelectorAll('#card-R58 .ix-cell')].map(c => c.getBBox()); + const cells = [...document.querySelectorAll('#card-R58 .dupre-ix-cell')].map(c => c.getBBox()); const right = Math.max(...cells.map(b => b.x + b.width)); const hits = []; - for (const sel of ['.ix-lever', '.ix-clear']) { + for (const sel of ['.dupre-ix-lever', '.dupre-ix-clear']) { const b = box(sel); if (!b) { hits.push(sel + ' missing'); continue; } if (b.x < right) hits.push(sel + ' starts at ' + Math.round(b.x) + ', left of the plate edge ' + Math.round(right)); @@ -757,7 +757,7 @@ try { // shrink was the trap, which is why VH now takes a floor. const stack = await evl(`(()=>{ const bb = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; }; - const lever = bb('.ix-lever'), clr = bb('.ix-clear'); + const lever = bb('.dupre-ix-lever'), clr = bb('.dupre-ix-clear'); const legend = [...document.querySelectorAll('#card-R58 text')].find(t => t.textContent === 'PRINT'); if (!lever || !clr || !legend) return 'missing gutter part'; const lg = legend.getBBox(); @@ -766,7 +766,7 @@ try { if (overlaps(lever, clr)) return 'lever overlaps CLR'; if (overlaps(lg, clr)) return 'PRINT legend overlaps CLR'; if (overlaps(lg, lever)) return 'PRINT legend overlaps the lever'; - const vb = document.querySelector('#card-R58 .ix-pad').viewBox.baseVal; + const vb = document.querySelector('#card-R58 .dupre-ix-pad').viewBox.baseVal; if (clr.y + clr.height > vb.height) return 'CLR falls outside the viewBox'; return 'ok'; })()`); @@ -778,7 +778,7 @@ try { // jump the stylus across the plate to the second. 14e can't see it — a // duplicate inflates the drawn count and the declared count equally. const dupes = await evl(`(()=>{ - const flat = GW.indexPlate.LAYOUT.flat().filter(Boolean); + const flat = DUPRE.indexPlate.LAYOUT.flat().filter(Boolean); const seen = new Set(), dup = new Set(); for (const c of flat) (seen.has(c) ? dup : seen).add(c); return dup.size ? 'duplicated on the plate: ' + [...dup].join(' ') : 'ok'; @@ -788,7 +788,7 @@ try { // 14i. press() is the allowlist for R58 too. R57 has this check; without it, // press('F1') reaching select() unfiltered would ship green. const ixPress = await evl(`(()=>{ - const h = document.getElementById('card-R58').gw; + const h = document.getElementById('card-R58').dupre; h.press('CLR'); ['F1', 'PRINT ', '', 'constructor', 'ZZ'].forEach(k => h.press(k)); return JSON.stringify([h.get(), h.selected()]); @@ -799,7 +799,7 @@ try { // relaying the plate can't leave a keybinding pointing at a character the // plate no longer carries. Enter is the lever. const ixKeys = await evl(`(()=>{ - const K = GW.indexPlate.KEYS, L = GW.indexPlate.LAYOUT; + const K = DUPRE.indexPlate.KEYS, L = DUPRE.indexPlate.LAYOUT; if (!K) return 'no KEYS'; const chars = L.flat().filter(Boolean); const missing = chars.filter(c => K[c] !== c); @@ -815,10 +815,10 @@ try { // quietly become R57 with a nicer plate, and the one thing it exists to // demonstrate is gone. const ixType = await evl(`(()=>{ - const pad = document.querySelector('#card-R58 .ix-pad'); - const h = document.getElementById('card-R58').gw; + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + const h = document.getElementById('card-R58').dupre; const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); - document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); pad.focus(); ['M','i','g'].forEach(send); const afterTyping = h.get(); @@ -833,10 +833,10 @@ try { // uppercase; this one must not, and that difference is the plate's whole // argument for having no shift key. const ixCase = await evl(`(()=>{ - const pad = document.querySelector('#card-R58 .ix-pad'); - const h = document.getElementById('card-R58').gw; + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); + const h = document.getElementById('card-R58').dupre; const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); - document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); pad.focus(); send('a'); send('Enter'); send('A'); send('Enter'); @@ -848,7 +848,7 @@ try { // always. (The missing space cell is a known gap — the real Mignon has a // separate space key.) const ixSpace = await evl(`(()=>{ - const pad = document.querySelector('#card-R58 .ix-pad'); + const pad = document.querySelector('#card-R58 .dupre-ix-pad'); pad.focus(); const e = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); pad.dispatchEvent(e); @@ -857,13 +857,13 @@ try { ok('R58 leaves Space alone (not on the plate)', ixSpace === 'ok', ixSpace); // 15e. Unfocused, it hears nothing — the contract's first rule, on the second - // widget to take keys. + // instrument to take keys. // Asserts nothing CHANGED, rather than expecting a cleared selection: CLR // is fresh paper, and fresh paper doesn't move the operator's hand, so the // stylus legitimately stays where the previous check left it. const ixBlur = await evl(`(()=>{ - const h = document.getElementById('card-R58').gw; - document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); + const h = document.getElementById('card-R58').dupre; + document.querySelector('#card-R58 .dupre-ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true})); document.activeElement.blur(); const before = JSON.stringify([h.get(), h.selected()]); ['Q','Z'].forEach(k => document.body.dispatchEvent( @@ -877,7 +877,7 @@ try { // through the declared charset one flap at a time, retarget cleanly, and // animate:false jumps straight to the target. const flapApi = await evl(`(() => { - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; return { reading: typeof h.reading, chars: typeof h.chars, set: typeof h.set }; })()`); ok('N20 handle exposes reading() and chars', flapApi.reading === 'function' && flapApi.chars === 'string', @@ -889,12 +889,12 @@ try { // Let the board settle on a known grid first — the demo ticker left it // anywhere, and every command cascades (there is deliberately no teleport // path). setText is the deterministic seam; next() is random by design. - await evl(`document.querySelector('#card-N20').gw.setText('SYSTEM\\nVOLUME\\nSIGNAL')`); - await sleep(5400); + await evl(`document.querySelector('#card-N20').dupre.setText('SYSTEM\\nVOLUME\\nSIGNAL')`); + await sleep(6500); // Start a cascade and sample the displayed reading every 15ms until stable. await evl(`(() => { - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; window.__n20 = { samples: [], anims: 0 }; h.setText('VOLUME\\nSIGNAL\\nRECORD'); // multi-step distances on several cells window.__n20.n = 0; @@ -908,10 +908,10 @@ try { window.__n20.anims++; }, 15); })()`); - await sleep(5400); + await sleep(6500); const n20 = await evl(`(() => { clearInterval(window.__n20.timer); - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; return { samples: [...new Set(window.__n20.samples)], final: h.reading(), anims: window.__n20.anims, chars: h.chars }; })()`); @@ -933,15 +933,15 @@ try { // Retarget mid-cascade: aim at word 2, then word 3 while still spinning; must land on 3. await evl(`(() => { - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; window.__n20r = { samples: [] }; window.__n20r.timer = setInterval(() => window.__n20r.samples.push(h.reading()), 15); h.setText('SIGNAL\\nRECORD\\nONLINE'); setTimeout(() => h.setText('RECORD\\nONLINE\\nSTEREO'), 180); })()`); - await sleep(5800); + await sleep(7000); const n20b = await evl(`(() => { clearInterval(window.__n20r.timer); - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; return { final: h.reading().split('\\n')[0], target: 'RECORD', sawSync: window.__n20r.samples.some(s => s.split('\\n')[0] === 'SIGNAL'), anims: document.querySelector('#card-N20').getAnimations({ subtree: true }).length }; @@ -955,7 +955,7 @@ try { const n20s = await evl(`(() => { const el = document.createElement('div'); document.body.appendChild(el); const pool = ['SYSTEM','VOLUME','SIGNAL','RECORD','ONLINE','STEREO','FILTER','OUTPUT']; - const h = GW.splitFlap(el, { rows: 3, cells: 6, words: pool, animate: false }); + const h = DUPRE.splitFlap(el, { rows: 3, cells: 6, words: pool, animate: false }); const grids = [h.reading().split('\\n')]; h.next(); grids.push(h.reading().split('\\n')); h.next(); grids.push(h.reading().split('\\n')); @@ -975,7 +975,7 @@ try { const n20c = await evl(`(() => { const el = document.createElement('div'); document.body.appendChild(el); const settles = []; - const h = GW.splitFlap(el, { animate: false, onSettle: r => settles.push(r) }); + const h = DUPRE.splitFlap(el, { animate: false, onSettle: r => settles.push(r) }); h.set(1); const r = h.reading(); el.remove(); return { r, settles }; @@ -986,7 +986,7 @@ try { // The card is a three-row six-letter board; the demo draws random pool words. const n20d = await evl(`(() => { - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; return { cells: document.querySelectorAll('#card-N20 .flapd').length, lines: document.querySelectorAll('#card-N20 .flapline').length, readLines: h.reading().split('\\n').length }; @@ -997,47 +997,48 @@ try { // Skin axis: dark (cream on black) and the inverse light (black on cream), // switched by the chip rig via setStyle, computed ink actually inverting. const n20e = await evl(`(() => { - const h = document.querySelector('#card-N20').gw; - if (typeof h.setStyle !== 'function' || !GW.splitFlap.STYLES || !GW.splitFlap.STYLES.skin) return 'no skin axis'; - const skins = Object.keys(GW.splitFlap.STYLES.skin); - if (skins[0] !== 'white') return 'white must list first, got: ' + skins.join(','); + const h = document.querySelector('#card-N20').dupre; + if (typeof h.setStyle !== 'function' || !DUPRE.splitFlap.STYLES || !DUPRE.splitFlap.STYLES.skin) return 'no skin axis'; + const skins = Object.keys(DUPRE.splitFlap.STYLES.skin); + if (skins[0] !== 'paper') return 'paper must list first, got: ' + skins.join(','); if (!['dark','white','light','paper'].every(k => skins.includes(k))) return 'skins are: ' + skins.join(','); const ink = () => getComputedStyle(document.querySelector('#card-N20 .fh')).color; const lum = c => { const m = c.match(/[0-9]+/g).map(Number); return m[0] + m[1] + m[2]; }; - const white = ink(); // the card's default skin is white ink - if (white !== 'rgb(255, 255, 255)') return 'default ink is not white: ' + white; + const paperDef = ink(); // the card's default skin is the white card, dark letters + if (lum(paperDef) >= 300) return 'default ink is not dark-on-paper: ' + paperDef; + if (!document.querySelector('#card-N20 .flap').classList.contains('flap-paper')) return 'default skin class is not paper'; + h.setStyle('skin', 'white'); + const white = ink(); + if (white !== 'rgb(255, 255, 255)') return 'white skin ink is ' + white; h.setStyle('skin', 'dark'); const cream = ink(); h.setStyle('skin', 'light'); const light = ink(); const gotClass = document.querySelector('#card-N20 .flap').classList.contains('flap-light'); h.setStyle('skin', 'paper'); - const paper = ink(); - const paperClass = document.querySelector('#card-N20 .flap').classList.contains('flap-paper'); - h.setStyle('skin', 'white'); const back = ink(); if (!gotClass) return 'light skin class missing'; - if (!paperClass) return 'paper skin class missing'; - if (!(lum(cream) > 400 && lum(cream) < 760 && lum(light) < 300 && lum(paper) < 300)) return 'inks wrong: ' + cream + ' / ' + light + ' / ' + paper; - if (back !== white) return 'did not restore the white default: ' + back; + if (!(lum(cream) > 400 && lum(cream) < 760 && lum(light) < 300)) return 'inks wrong: ' + cream + ' / ' + light; + if (back !== paperDef) return 'did not restore the paper default: ' + back; return 'ok'; })()`); - ok('N20 skins: white default first, cream, ivory board, white board — all switch and restore', n20e === 'ok', n20e); + ok('N20 skins: paper default first, white ink, cream, ivory — all switch and restore', n20e === 'ok', n20e); const n20fnt = await evl(`(() => { - const h = document.querySelector('#card-N20').gw; - if (!GW.splitFlap.STYLES.font) return 'no font axis'; + const h = document.querySelector('#card-N20').dupre; + if (!DUPRE.splitFlap.STYLES.font) return 'no font axis'; const fam = () => getComputedStyle(document.querySelector('#card-N20 .fh')).fontFamily; + const helv = fam(); // the card's default face is Helvetica + if (!/helvetica/i.test(helv)) return 'default face is not helvetica: ' + helv; + h.setStyle('font', 'mono'); const mono = fam(); h.setStyle('font', 'helv'); - const helv = fam(); - h.setStyle('font', 'mono'); const back = fam(); - if (!/helvetica/i.test(helv)) return 'helv did not apply: ' + helv; - if (!/berkeley|mono/i.test(mono) || back !== mono) return 'mono wrong or not restored: ' + mono + ' / ' + back; + if (!/berkeley|mono/i.test(mono) || /helvetica/i.test(mono)) return 'mono did not apply: ' + mono; + if (back !== helv) return 'did not restore the helv default: ' + back; return 'ok'; })()`); - ok('N20 font switches between Berkeley Mono and Helvetica', n20fnt === 'ok', n20fnt); + ok('N20 face: Helvetica default, Berkeley Mono one click away', n20fnt === 'ok', n20fnt); const n20f = await evl(`(() => { const chips = [...document.querySelectorAll('#card-N20 .famchips .fc, #card-N20 .famchips [title]')].map(c => c.title || c.textContent); return chips.length ? chips.join(',') : 'no chips'; @@ -1055,10 +1056,10 @@ try { // Flap speed: the handle takes setFlapMs and the card's slider drives it. const n20v = await evl(`(() => { - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; if (typeof h.setFlapMs !== 'function' || typeof h.flapMs !== 'function') return 'no speed api'; const before = h.flapMs(); - if (before !== 85) return 'default flap rate is ' + before + ', want 85'; + if (before !== 100) return 'default flap rate is ' + before + ', want 100'; h.setFlapMs(120); if (h.flapMs() !== 120) return 'setFlapMs did not take: ' + h.flapMs(); const slider = document.querySelector('#card-N20 input[type=range]'); @@ -1074,7 +1075,7 @@ try { // board must settle, hold the reading ~2s, and only then take the next update. await evl(`(() => { IH.flap = window.__realFlap; - const h = document.querySelector('#card-N20').gw; + const h = document.querySelector('#card-N20').dupre; window.__n20dwell = { settleAt: 0, changeAt: 0, settled: '' }; h.onSettle(r => { if (!window.__n20dwell.settleAt) { window.__n20dwell.settleAt = performance.now(); window.__n20dwell.settled = r; } }); window.__n20dwell.timer = setInterval(() => { @@ -1083,7 +1084,7 @@ try { }, 50); h.set(0); })()`); - await sleep(9000); + await sleep(10500); const dwell = await evl(`(() => { const d = window.__n20dwell; clearInterval(d.timer); return { settleAt: d.settleAt, delta: d.changeAt ? d.changeAt - d.settleAt : -1 }; @@ -1102,20 +1103,39 @@ try { if (new Set(skins).size !== 4) return 'skin previews not distinct: ' + skins.join(' '); if (chips.slice(0, 4).some(c => getComputedStyle(c, '::after').content === 'none')) return 'skin chip has no ink letter'; const fams = chips.slice(4).map(c => getComputedStyle(c, '::after').fontFamily); - if (!/helvetica/i.test(fams[1]) || fams[0] === fams[1]) return 'font previews wrong: ' + fams.join(' vs '); + if (!fams.some(f => /helvetica/i.test(f)) || fams[0] === fams[1]) return 'font previews wrong: ' + fams.join(' vs '); const size = parseFloat(getComputedStyle(chips[0]).width); return size >= 15 ? 'ok' : 'chips too small to read: ' + size; })()`); ok('N20 chips are miniature flap cells: board colour, ink letter, real face', n20g === 'ok', n20g); + // N24 jewels — the handle reads and drives lamp state. Waybar-bound: a + // consumer must be able to set a jewel without synthesizing clicks. + const jw = await evl(`(() => { + const h = document.getElementById('card-N24').dupre; + if (!h.get || !h.set) return 'no get/set on handle'; + const init = h.get().join(','); + if (init !== '0,1,2,-1') return 'initial ' + init; + h.set(3, 1); + if (h.get()[3] !== 1) return 'set(3,1) not reflected: ' + h.get().join(','); + const el = document.querySelectorAll('#card-N24 .dupre-jewel')[3]; + if (!el) return 'no .dupre-jewel elements'; + if (el.classList.contains('dupre-dim')) return 'jewel 3 still dark after set(3,1)'; + h.set(3, -1); + if (!el.classList.contains('dupre-dim')) return 'set(3,-1) did not dark'; + if (h.get()[3] !== -1) return 'get after dark: ' + h.get().join(','); + return 'ok'; + })()`); + ok('N24 jewels handle exposes get/set', jw === 'ok', jw); + // Under prefers-reduced-motion the board paints once and RESTS — the dwell // chain must stay gated off, or the panel self-advances every 2s forever. await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); await send('Page.reload'); await sleep(2500); - const rm1 = await evl(`document.querySelector('#card-N20').gw.reading()`); + const rm1 = await evl(`document.querySelector('#card-N20').dupre.reading()`); await sleep(5200); - const rm2 = await evl(`(() => ({ r: document.querySelector('#card-N20').gw.reading(), anims: document.getAnimations().length }))()`); + const rm2 = await evl(`(() => ({ r: document.querySelector('#card-N20').dupre.reading(), anims: document.getAnimations().length }))()`); ok('N20 rests under reduced motion (no self-advancing dwell chain)', rm1 === rm2.r && rm2.anims === 0, JSON.stringify({ before: rm1, after: rm2.r, anims: rm2.anims })); @@ -1131,6 +1151,398 @@ try { })()`); ok('every card ID badge links to its own anchor', anchors === 'ok', anchors); + // Batch-3 set() hardening: out-of-range input cannot wedge an instrument. + // presetBank clamps; dualKnob clamps both spindles. Built on detached hosts + // so the gallery's own cards stay untouched. + const clamps = await evl(`(() => { + const pb = DUPRE.presetBank(document.createElement('div')); + pb.set(99); const hi = pb.get(); + pb.set(-5); const lo = pb.get(); + const dk = DUPRE.dualKnob(document.createElement('div')); + dk.set(500, -500); const [o, i] = dk.get(); + return JSON.stringify({ hi, lo, o, i }); + })()`); + ok('presetBank and dualKnob set() clamp out-of-range input', + clamps === JSON.stringify({ hi: 3, lo: 0, o: 100, i: 0 }), clamps); + + // Batch-4 set() hardening, same detached-host shape: vernierDial and + // flutedKnob clamp 0-100; filterBank clamps the band index (an out-of-range + // band lands on the last, it doesn't throw); multiBandDial clamps the band + // to its rings and keeps the current band when set() omits it. + const clamps4 = await evl(`(() => { + const vd = DUPRE.vernierDial(document.createElement('div')); + vd.set(500); const vHi = vd.get(); + vd.set(-5); const vLo = vd.get(); + const fk = DUPRE.flutedKnob(document.createElement('div')); + fk.set(500); const fHi = fk.get(); + const fb = DUPRE.filterBank(document.createElement('div')); + fb.set(99, 30); const fbLast = fb.get()[11]; + const mb = DUPRE.multiBandDial(document.createElement('div')); + mb.set(50, 99); const [, bHi] = mb.get(); + mb.set(70); const [v2, bKeep] = mb.get(); + return JSON.stringify({ vHi, vLo, fHi, fbLast, bHi, v2, bKeep }); + })()`); + ok('batch-4 set() clamps: vernier/fluted 0-100, filterBank + multiBand band index', + clamps4 === JSON.stringify({ vHi: 100, vLo: 0, fHi: 100, fbLast: 30, bHi: 3, v2: 70, bKeep: 3 }), clamps4); + + // The batch-4 rename holds end to end: a detached dipBank round-trips its + // word through the dupre- prefixed switch classes, and jogWheel's cached + // inner disc actually rotates on set. + const b4dom = await evl(`(() => { + const db = DUPRE.dipBank(document.createElement('div')); + db.set('101'); + const jw = DUPRE.jogWheel(document.createElement('div')); + jw.set(10); + const inner = jw.el.querySelector('.dupre-inner'); + return JSON.stringify({ word: db.get(), sw: db.el.querySelectorAll('.dupre-dipsw').length, + rot: inner ? inner.style.transform : 'missing' }); + })()`); + ok('dipBank word round-trips and jogWheel rotates via dupre- classes', + b4dom === JSON.stringify({ word: '101000', sw: 6, rot: 'rotate(40deg)' }), b4dom); + + // Batch-5 set() hardening, same detached-host shape: ledRow, pillSlide and + // discSwitch clamp their index (discSwitch used to throw); waveRegion keeps + // E at least 4 above S however set() is called, not only on the drag path. + // ledRow's default index is 5 only for the default program list — a caller's + // own items start at 0 (the consoleKeys default-active rule). + const clamps5 = await evl(`(() => { + const lr = DUPRE.ledRow(document.createElement('div')); + lr.set(99); const lrHi = lr.get(); + lr.set(-3); const lrLo = lr.get(); + const lrDef = DUPRE.ledRow(document.createElement('div')).get(); + const lrOwn = DUPRE.ledRow(document.createElement('div'), { items: ['A', 'B', 'C'] }).get(); + const ps = DUPRE.pillSlide(document.createElement('div')); + ps.set(9); const psHi = ps.get(); + ps.set(-1); const psLo = ps.get(); + let dsHi; + try { const ds = DUPRE.discSwitch(document.createElement('div')); ds.set(9); dsHi = ds.get(); } + catch (e) { dsHi = 'threw'; } + const wr = DUPRE.waveRegion(document.createElement('div')); + wr.set(80, 20); const { s: wrS, e: wrE } = wr.get(); + wr.set(-10, 200); const { s: wrS2, e: wrE2 } = wr.get(); + return JSON.stringify({ lrHi, lrLo, lrDef, lrOwn, psHi, psLo, dsHi, wrS, wrE, wrS2, wrE2 }); + })()`); + ok('batch-5 set() clamps: ledRow/pillSlide/discSwitch index, waveRegion S/E order', + clamps5 === JSON.stringify({ lrHi: 7, lrLo: 0, lrDef: 5, lrOwn: 0, psHi: 2, psLo: 0, dsHi: 2, wrS: 80, wrE: 84, wrS2: 0, wrE2: 100 }), clamps5); + + // entryKeypad buffer round-trip on a detached host: digits accumulate, the + // buffer caps at 6, keys outside the contract's set are ignored, ✗ clears, + // ✓ commits and clears. + const b5dom = await evl(`(() => { + const kp = DUPRE.entryKeypad(document.createElement('div')); + ['1', '2', '3'].forEach(k => kp.press(k)); const buf3 = kp.get(); + kp.press('X'); const stray = kp.get(); + ['4', '5', '6', '7'].forEach(k => kp.press(k)); const cap = kp.get(); + kp.press('✗'); const clr = kp.get(); + kp.press('1'); kp.press('✓'); const commit = kp.get(); + return JSON.stringify({ buf3, stray, cap, clr, commit }); + })()`); + ok('entryKeypad buffers digits only, caps at 6, clears and commits', + b5dom === JSON.stringify({ buf3: '123', stray: '123', cap: '123456', clr: '', commit: '' }), b5dom); + + // batch-6 domain gates on detached hosts: fourWayToggle falls back to C on a + // bad initial position and ignores invalid quadrants in set(), camTimer + // clamps set() to the ring (12 steps → 0..11, fractions floor), pinMatrix + // drops initial pins that name no intersection. + const clamps6 = await evl(`(() => { + const fw = DUPRE.fourWayToggle(document.createElement('div'), { position: 'Z' }); + const fwDef = fw.get(); fw.set('B'); const fwB = fw.get(); fw.set('Q'); const fwQ = fw.get(); + fw.set('toString'); const fwProto = fw.get(); + const ct = DUPRE.camTimer(document.createElement('div'), { position: 99 }); + const ctHi = ct.get(); ct.set(-5); const ctLo = ct.get(); ct.set(2.7); const ctInt = ct.get(); + const pm = DUPRE.pinMatrix(document.createElement('div'), { pins: ['OSC1>VCF', 'BAD>KEY', 'LFO>NOPE', 'OSC2>VCA>X'] }); + return JSON.stringify({ fwDef, fwB, fwQ, fwProto, ctHi, ctLo, ctInt, pmN: pm.get().length }); + })()`); + ok('batch-6 domain gates: fourWayToggle quadrant, camTimer step clamp, pinMatrix pin filter', + clamps6 === JSON.stringify({ fwDef: 'C', fwB: 'B', fwQ: 'B', fwProto: 'B', ctHi: 11, ctLo: 0, ctInt: 2, pmN: 1 }), clamps6); + + // dsky verb/noun grammar through the prefixed DOM (catches rename stragglers): + // VERB arms exactly one hot window, V16 N36 commits PROG 16 and disarms, + // V35 lights all six status lamps. + const b6dom = await evl(`(() => { + const d = DUPRE.dsky(document.createElement('div')); + const K = {}; + d.el.querySelectorAll('.dupre-dsky-pad .dupre-key').forEach(b => K[b.textContent] = b); + K.VERB.click(); + const hot1 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K['1'].click(); K['6'].click(); K.NOUN.click(); K['3'].click(); K['6'].click(); K.ENTR.click(); + const prog = d.get().prog; + const hot0 = d.el.querySelectorAll('.dupre-dsky-win.dupre-hot').length; + K.VERB.click(); K['3'].click(); K['5'].click(); K.ENTR.click(); + const lit = d.el.querySelectorAll('.dupre-dsky-sl.dupre-on').length; + return JSON.stringify({ hot1, prog, hot0, lit }); + })()`); + ok('dsky grammar drives prefixed DOM: hot window, V16 N36 sets PROG, V35 lights all lamps', + b6dom === JSON.stringify({ hot1: 1, prog: '16', hot0: 0, lit: 6 }), b6dom); + + // batch-7 domain gates on detached hosts: decadeBox coerces and clamps its + // digits, twoHandSafety ignores sides outside L/R, the meters clamp their + // set()/push() domains, sparkline floors its history at two points, scope + // clears the trace below two samples, eqBars reads missing bands as 0. + const clamps7 = await evl(`(() => { + const db = DUPRE.decadeBox(document.createElement('div'), { digits: [12, -3, undefined, 7] }); + const dbTot = db.get(); + const thsLog = []; + const ths = DUPRE.twoHandSafety(document.createElement('div'), { onChange: st => thsLog.push(st) }); + ths.press('X'); ths.press('L'); ths.press('R'); + const ms = DUPRE.miniSig(document.createElement('div')); + ms.set(2); const msHi = ms.get(); ms.set(-1); const msLo = ms.get(); + const fb = DUPRE.fuelBar(document.createElement('div')); + fb.set(150); const fbHi = fb.get(); + const rr = DUPRE.radialRing(document.createElement('div')); + rr.set(150); const rrHi = rr.get(); + const sp = DUPRE.sparkline(document.createElement('div'), { samples: 1 }); + sp.push(5); const spHi = sp.get(); + const spPts = sp.el.querySelector('polyline').getAttribute('points'); + const ws = DUPRE.waveStrip(document.createElement('div')); + ws.set([0, 5, -5], 0.5); + const wsD = ws.el.querySelector('path').getAttribute('d'); + const sc = DUPRE.scope(document.createElement('div')); + sc.set([0.5], 1); + const scEmpty = sc.el.querySelector('polyline').getAttribute('points'); + sc.set([5, -5], 1); + const scPts = sc.el.querySelector('polyline').getAttribute('points'); + let eqTxt; const eq = DUPRE.eqBars(document.createElement('div'), { bands: 3, cells: 4, onChange: (v, t) => eqTxt = t }); + eq.set([2, -1]); + const eqB0 = eq.el.children[0].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length; + const eqRest = eq.el.children[1].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length + + eq.el.children[2].querySelectorAll('.dupre-on,.dupre-hot,.dupre-clip').length; + return JSON.stringify({ dbTot, ths: thsLog.join('|'), msHi, msLo, fbHi, rrHi, spHi, + spOk: !spPts.includes('NaN'), wsOk: !wsD.includes('NaN') && wsD.includes(' 33.0') && wsD.includes(' 5.0'), + scEmpty, scPts, eqTxt, eqB0, eqRest }); + })()`); + ok('batch-7 domain gates: decadeBox digits, twoHandSafety side, meter clamps, trace guards', + clamps7 === JSON.stringify({ dbTot: 9007, ths: 'ready|armed|running', msHi: 1, msLo: 0, fbHi: 100, + rrHi: 100, spHi: 1, spOk: true, wsOk: true, scEmpty: '', scPts: '0.0,61.0 176.0,17.0', + eqTxt: 'peak 100%', eqB0: 4, eqRest: 0 }), clamps7); + + // voiceLoop grammar through the prefixed DOM (catches rename stragglers): + // the default set opens FD/GNC/A-G engaged with GNC talking, click cycles + // idle → monitored → talking → idle with talk exclusive, and a caller's own + // loop set starts all-idle (the demo default is the stock set's alone). + const b7dom = await evl(`(() => { + const vl = DUPRE.voiceLoop(document.createElement('div')); + const ks = vl.el.querySelectorAll('.dupre-vk'); + const nKeys = ks.length; + const mon0 = vl.el.querySelectorAll('.dupre-mon').length; + const tlk0 = vl.el.querySelectorAll('.dupre-tlk').length; + ks[2].click(); + const mon1 = vl.el.querySelectorAll('.dupre-mon').length; + ks[0].click(); + const tlk1 = vl.el.querySelectorAll('.dupre-tlk').length; + const fdTalks = ks[0].classList.contains('dupre-tlk') && !ks[1].classList.contains('dupre-tlk'); + ks[0].click(); + const fdIdle = vl.get()[0] === '0' && !ks[0].classList.contains('dupre-mon'); + const own = DUPRE.voiceLoop(document.createElement('div'), { loops: ['A', 'B', 'C'] }); + const ownIdle = own.get().join('') === '000' && own.el.querySelectorAll('.dupre-mon,.dupre-tlk').length === 0; + return JSON.stringify({ nKeys, mon0, tlk0, mon1, tlk1, fdTalks, fdIdle, ownIdle }); + })()`); + ok('voiceLoop cycles through prefixed DOM: demo default, exclusive talk, own set idle', + b7dom === JSON.stringify({ nKeys: 8, mon0: 3, tlk0: 1, mon1: 4, tlk1: 1, fdTalks: true, fdIdle: true, ownIdle: true }), b7dom); + + // batch-8 domain gates on detached hosts: the needle meters clamp set() to + // their documented 0-100, stripChart floors its history at two samples and + // survives a non-array set(), battCells floors its cell count at one. + const clamps8 = await evl(`(() => { + const xn = DUPRE.crossNeedle(document.createElement('div')); + xn.set(150); const xnHi = xn.get(); xn.set(-10); const xnLo = xn.get(); + const th = DUPRE.thermometer(document.createElement('div')); + th.set(-5); const thLo = th.get(); + const bd = DUPRE.bourdon(document.createElement('div')); + bd.set(200); const bdHi = bd.get(); + const st = DUPRE.stripChart(document.createElement('div'), { samples: 1 }); + st.set(null, 0.5); + const stPts = st.el.querySelector('polyline').getAttribute('points'); + let cmTxt; const cm = DUPRE.corrMeter(document.createElement('div'), { onChange: (v, t) => cmTxt = t }); + cm.set(200); const cmHi = cm.get(); + const bc = DUPRE.battCells(document.createElement('div'), { cells: -3 }); + const bcCells = bc.el.querySelectorAll('.dupre-cell').length; + bc.set(-10); const bcLo = bc.get(); + const vu = DUPRE.mcVu(document.createElement('div'), { value: 5 }); + const vuInit = vu.get(); + const vuNdl = vu.el.querySelector('line[stroke-width="1.6"]').getAttribute('transform'); + return JSON.stringify({ xnHi, xnLo, thLo, bdHi, stOk: !stPts.includes('NaN'), cmHi, cmTxt, bcCells, bcLo, vuInit, vuNdl }); + })()`); + ok('batch-8 domain gates: needle-meter clamps, stripChart array/floor guards, battCells cell floor, mcVu init', + clamps8 === JSON.stringify({ xnHi: 100, xnLo: 0, thLo: 0, bdHi: 100, stOk: true, + cmHi: 100, cmTxt: '+1.00', bcCells: 1, bcLo: 0, vuInit: 1.02, vuNdl: 'rotate(43,75,112)' }), clamps8); + + // batch-8 state through the prefixed DOM (catches rename stragglers): the + // battery lights dupre-on cells and tints dupre-warn under the threshold, + // the crossed needles and mercury column track set() through their renamed + // internals. + const b8dom = await evl(`(() => { + const bc = DUPRE.battCells(document.createElement('div')); + const nCells = bc.el.querySelectorAll('.dupre-cell').length; + const on62 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length; + const warn62 = bc.el.querySelectorAll('.dupre-warn').length; + bc.set(20); + const on20 = bc.el.querySelectorAll('.dupre-cell.dupre-on').length; + const warn20 = bc.el.querySelectorAll('.dupre-cell.dupre-warn.dupre-on').length; + const xn = DUPRE.crossNeedle(document.createElement('div')); + xn.set(0); + const fwdT = xn.el.querySelector('.dupre-fwd').style.transform; + const rflT = xn.el.querySelector('.dupre-rfl').style.transform; + const th = DUPRE.thermometer(document.createElement('div')); + th.set(75); + const thH = th.el.querySelector('.dupre-thermo-fill').style.height; + const bd = DUPRE.bourdon(document.createElement('div')); + bd.set(50); + const bdT = bd.el.querySelector('.dupre-bourdon-ndl').style.transform; + const cm = DUPRE.corrMeter(document.createElement('div')); + cm.set(50); + const cmT = cm.el.querySelector('.dupre-corr-ndl').style.transform; + const stc = DUPRE.stripChart(document.createElement('div')); + stc.push(1); + const penT = stc.el.querySelector('.dupre-pen').style.top; + return JSON.stringify({ nCells, on62, warn62, on20, warn20, fwdT, rflT, thH, bdT, cmT, penT }); + })()`); + ok('batch-8 renamed DOM tracks state: battery on/warn cells, needles and pen move', + b8dom === JSON.stringify({ nCells: 8, on62: 5, warn62: 0, on20: 2, warn20: 2, + fwdT: 'rotate(-42deg)', rflT: 'rotate(42deg)', thH: '75%', bdT: 'rotate(0deg)', + cmT: 'rotate(0deg)', penT: '3px' }), b8dom); + + // batch-9 domain gates on detached hosts: the flight instruments clamp (or + // normalize) both set() and their build opts, badges coerces junk variants, + // the readout wraps its clock. + const clamps9 = await evl(`(() => { + const ai = DUPRE.attitudeIndicator(document.createElement('div'), { bank: -999, pitch: 5 }); + const aiB = ai.get().bank; + ai.set(999, 999); const aiHi = ai.get(); + const hb = DUPRE.headingBug(document.createElement('div'), { value: 450 }); + const hbB = hb.get().cmd; + const hbNdl = hb.el.querySelector('g polygon').parentNode.getAttribute('transform'); + hb.set(-30); const hbSet = hb.get().cmd; + const vt = DUPRE.verticalTape(document.createElement('div'), { value: 99 }); + const vtB = vt.get(); vt.set(0); const vtLo = vt.get(); + const vtT = vt.el.querySelector('g g').getAttribute('transform'); + const tn = DUPRE.twinNeedle(document.createElement('div'), { fuel: 99, oil: -5 }); + const tnB = tn.get(); + let cmTxt; const cm = DUPRE.comfortMeter(document.createElement('div'), + { temp: 999, humidity: -40, onChange: (v, t) => cmTxt = t }); + const cmB = cm.get(); + const bg = DUPRE.badges(document.createElement('div'), { items: [['X', 7], ['Y', 'junk']] }); + const bgVars = bg.get(); + const tr = DUPRE.tabularReadout(document.createElement('div'), { secs: -10 }); + const trB = tr.get(); + return JSON.stringify({ aiB, aiHi, hbB, hbNdl, hbSet, vtB, vtLo, vtT, tnB, cmB, cmTxt, bgVars, trB }); + })()`); + ok('batch-9 domain gates: flight-instrument clamps at build and set, badges variant coercion, readout wrap', + clamps9 === JSON.stringify({ aiB: -60, aiHi: { bank: 60, pitch: 20 }, hbB: 90, + hbNdl: 'rotate(90.0,65,65)', hbSet: 330, vtB: 35, vtLo: 5, vtT: 'translate(0,92.5)', + tnB: [4, 0], cmB: [100, 0], cmTxt: '100F · 0% RH · TOO WARM', bgVars: [1, 0], trB: 3590 }), clamps9); + + // batch-9 state through the renamed DOM (catches rename stragglers): badges + // swap variant classes, the readout counts down through dupre-readout and + // carries a styled dupre-unit (the old .readout .u rule was dead — .u is a + // sibling, so the caption rendered unstyled), the engraved label counts + // through dupre-cnt, and the SVG flight instruments track set(). + const b9dom = await evl(`(() => { + const bg = DUPRE.badges(document.createElement('div')); + const nBadge = bg.el.querySelectorAll('.dupre-badge').length; + const red0 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const ghost0 = bg.el.querySelectorAll('.dupre-badge.dupre-ghost').length; + bg.set(0, 1); + const red1 = bg.el.querySelectorAll('.dupre-badge.dupre-red').length; + const trHost = document.createElement('div'); document.body.appendChild(trHost); + const tr = DUPRE.tabularReadout(trHost); + const trTxt = tr.el.querySelector('.dupre-readout').textContent; + const unit = tr.el.querySelector('.dupre-unit'); + const unitTxt = unit.textContent; + const unitDisp = getComputedStyle(unit).display; + tr.tick(); const trTick = tr.el.querySelector('.dupre-readout').textContent; + tr.el.querySelector('.dupre-readout').click(); tr.tick(); + const trPaused = tr.el.querySelector('.dupre-readout').textContent; + trHost.remove(); + const el = DUPRE.engravedLabel(document.createElement('div')); + const cnt0 = el.el.querySelector('.dupre-cnt').textContent; + el.set(7); const cnt7 = el.el.querySelector('.dupre-cnt').textContent; + el.el.click(); const cnt8 = el.el.querySelector('.dupre-cnt').textContent; + const tn = DUPRE.twinNeedle(document.createElement('div')); + tn.set(0, 4); + const tnF = tn.el.querySelector('polygon[fill="#e0523a"]').parentNode.getAttribute('transform'); + const tnO = tn.el.querySelector('polygon[fill="#bfc4d0"]').parentNode.getAttribute('transform'); + const cm = DUPRE.comfortMeter(document.createElement('div')); + cm.set(70, 80); + const humid = [...cm.el.querySelectorAll('text')].find(t => t.textContent === 'TOO HUMID').getAttribute('fill'); + const ai = DUPRE.attitudeIndicator(document.createElement('div')); + ai.set(30, -10); + const aiT = ai.el.querySelector('g g').getAttribute('transform'); + const rc = DUPRE.roundCrt(document.createElement('div')); + const p1 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + rc.tick(); + const p2 = rc.el.querySelector('polyline[stroke="#f4fcf4"]').getAttribute('points'); + const cr = DUPRE.chartRecorder(document.createElement('div')); + cr.reset(); + const crH = cr.get(); + const crD = cr.el.querySelector('path[stroke="#8f2416"]').getAttribute('d'); + return JSON.stringify({ nBadge, red0, ghost0, red1, trTxt, unitTxt, unitDisp, trTick, trPaused, + cnt0, cnt7, cnt8, tnF, tnO, humid, aiT, crtMoves: p1 !== p2 && !p2.includes('NaN'), crH, crD }); + })()`); + ok('batch-9 renamed DOM tracks state: badge variants, readout clock + styled unit, engraved count, instruments follow set()', + b9dom === JSON.stringify({ nBadge: 3, red0: 1, ghost0: 1, red1: 2, trTxt: '24:10', + unitTxt: 'timer', unitDisp: 'block', trTick: '24:09', trPaused: '24:09', + cnt0: '· 3', cnt7: '· 7', cnt8: '· 8', tnF: 'rotate(-170.0,60,62)', tnO: 'rotate(10.0,60,62)', + humid: '#c23a28', aiT: 'rotate(-30.0,65,65) translate(0,-16.0)', + crtMoves: true, crH: 0, crD: '' }), b9dom); + + // batch-10 domain gate: the annunciator documents a 0..2 state per cell, so + // an out-of-range initial state or set() must clamp rather than paint an + // undefined class (the mcVu build-enforcement precedent). Without the clamp, + // CLS[9] is undefined and get() reads back 0 — this fails RED on that. + const clamps10 = await evl(`(() => { + const an = DUPRE.annunciator(document.createElement('div'), { cells: [['A', 9], ['B', -3], ['C', 1]] }); + const anB = an.get(); + an.set(0, 9); an.set(1, -5); + const anSet = an.get(); + return JSON.stringify({ anB, anSet }); + })()`); + ok('batch-10 domain gate: annunciator state clamps to 0..2 at build and set', + clamps10 === JSON.stringify({ anB: [2, 0, 1], anSet: [2, 0, 1] }), clamps10); + + // batch-10 state through the renamed DOM (catches rename stragglers): the six + // extracted DOM builders each expose their handle and paint the dupre- classes + // — a JS/CSS name mismatch would render unstyled without throwing, so assert + // the class names directly. patchBay lights hot jacks and freqScale carries + // the compound dupre-fs-band unit label (dupre-band is the EQ's). + const b10dom = await evl(`(() => { + const ow = DUPRE.outputWell(document.createElement('div')); + const owRoot = ow.el.className, owSeed = ow.el.querySelectorAll('.dupre-ostep').length; + ow.push(['red', 'Err', 'boom']); + const owN = ow.el.querySelectorAll('.dupre-ostep').length; + const owEv = ow.el.querySelector('.dupre-ostep:last-child .dupre-ev').textContent; + const owRed = ow.el.querySelector('.dupre-ostep:last-child .dupre-lamp').classList.contains('dupre-red'); + const to = DUPRE.toast(document.createElement('div')); + const toRoot = to.el.className; to.fire('ping'); const toTxt = to.el.textContent; + const tc = DUPRE.tapeCounter(document.createElement('div')); + const tcRoot = tc.el.className; + const tcWheels = tc.el.querySelectorAll('.dupre-cwheel').length; + const tcRed = tc.el.querySelectorAll('.dupre-redw').length; + tc.set(120); const tcV = tc.get(); + const fq = DUPRE.freqScale(document.createElement('div')); + const fqBand = fq.el.querySelector('.dupre-fs-band').textContent; + const fqTicks = fq.el.querySelectorAll('.dupre-tick').length > 0; + const fqPtr = !!fq.el.querySelector('.dupre-fptr'); + fq.set(50); const fqV = fq.get(); + const pb = DUPRE.patchBay(document.createElement('div')); + const pbJacks = pb.el.querySelectorAll('.dupre-jack').length; + const pbHot = pb.el.querySelectorAll('.dupre-jack.dupre-hot').length; + const pbConns = pb.get(); + const an = DUPRE.annunciator(document.createElement('div')); + const anCells = an.el.querySelectorAll('.dupre-acell').length; + const anWarn = an.el.querySelectorAll('.dupre-warn').length; + const anFault = an.el.querySelectorAll('.dupre-fault').length; + const anMcOn = an.el.querySelector('.dupre-mc').classList.contains('dupre-on'); + return JSON.stringify({ owRoot, owSeed, owN, owEv, owRed, toRoot, toTxt, + tcRoot, tcWheels, tcRed, tcV, fqBand, fqTicks, fqPtr, fqV, + pbJacks, pbHot, pbConns, anCells, anWarn, anFault, anMcOn }); + })()`); + ok('batch-10 renamed DOM tracks state: well/toast/counter/freqscale/patchbay/annunciator handles + dupre- classes', + b10dom === JSON.stringify({ owRoot: 'dupre-owell', owSeed: 2, owN: 3, owEv: 'boom', owRed: true, + toRoot: 'dupre-toastw', toTxt: 'ping', tcRoot: 'dupre-counter', tcWheels: 6, tcRed: 2, tcV: 120, + fqBand: 'MHz', fqTicks: true, fqPtr: true, fqV: 50, pbJacks: 8, pbHot: 4, pbConns: ['0-5', '2-7'], + anCells: 6, anWarn: 1, anFault: 1, anMcOn: true }), b10dom); + // late exceptions from interactions const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions after interaction', errs2.length === 0); diff --git a/tests/installer-steps/test_pacman_hook_order.py b/tests/installer-steps/test_pacman_hook_order.py new file mode 100644 index 0000000..49acdbe --- /dev/null +++ b/tests/installer-steps/test_pacman_hook_order.py @@ -0,0 +1,27 @@ +"""Regression tests for the pacman hook ordering safety invariant.""" + +import os +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +class PacmanHookOrderTests(unittest.TestCase): + def test_safety_hooks_precede_mkinitcpio_removal(self): + with open(ARCHSETUP, encoding="utf-8") as source: + text = source.read() + + self.assertIn( + "cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook", text) + self.assertIn( + "cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook", text) + self.assertLess("05-zfs-snapshot.hook", "60-mkinitcpio-remove.hook") + self.assertLess("10-hypr-live-update-guard.hook", "60-mkinitcpio-remove.hook") + self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", text) + self.assertIn("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", text) + + +if __name__ == "__main__": + unittest.main() @@ -69,20 +69,21 @@ Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/ ** TODO [#C] Add inetutils to install base :feature:solo:quick:network: TRAMP's /ftp: method (ange-ftp) shells out to a command-line ftp client; Arch ships none by default. GNU inetutils provides =/usr/bin/ftp=. Craig's dirvish config has an FTP quick-access entry (phone FTP server), so it's a config dependency. Installed manually on ratio 2026-07-14; velox needs it once it boots. Add to the install base so future machines get it for free; verify via VM test. From .emacs.d handoff 2026-07-14-1751. -** DONE [#B] Absorb the clock-panel project into the dotfiles :feature:waybar:dotfiles: -CLOSED: [2026-07-18 Sat] -Absorbed into =~/.dotfiles= (commit 3fab11d): package =clock/src/clock/= (renamed from clock_panel), the six PNG watchface layers packaged inside the module at =clock/src/clock/assets/=, a stowed =clock-panel= shell shim (LD_PRELOADs gtk4-layer-shell), waybar left-click now =clock-panel toggle= with the absolute path dropped, tests converted pytest→unittest into =tests/clock/= plus an asset-load guard. Kept the layer-shell overlay and the socket toggle. The standalone repo is archived (ARCHIVED.md), kept for its design history. Verified live: the bar click renders the polished watchface. - ** TODO [#C] Restore date-format scrolling on the waybar date module :feature:waybar:dotfiles:quick: Scrolling the date module used to cycle its text format (=date-cycle= over full/ordinal/longfull/utc); that scroll binding was removed when format cycling moved to the time module (the date now shows a fixed ordinal "Fri Jul 17th"). Re-add the =on-scroll-up/down= bindings on =custom/date= so the date cycles its own formats independently of the time. The =waybar-date= script and =date-cycle= are intact and already support the ring — this is re-adding two config lines. One small design call: whether the date ring should still carry =utc= and =longfull= now that the time module owns UTC and the combined date+time readout (likely trim to date-only formats). Update =tests/waybar-clock-layout= (it currently asserts the date does not scroll) and =tests/date-cycle= accordingly. +** TODO [#C] Notification sound loudness :chore:audio:quick:solo: +Reduce notification-sound playback loudness by 40% (0.6 gain, approximately +-4.4 dB). Change the =NOTIFY_VOLUME= playback control rather than re-encoding +the normalized sound files; verify each notification type still plays clearly. + ** DOING [#B] Widget gallery upgrades :feature:design: :PROPERTIES: :LAST_REVIEWED: 2026-07-13 :END: Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work, so it runs on the =gallery-upgrades= branch (squash merge to main after Craig's UI confirmation + tweaks). Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm after the merge. *** TODO Extraction-readiness bar for every gallery component :refactor:design: -Craig's standing directive (2026-07-18, set while finishing the split-flap): every =GW.*= builder should meet the bar the split-flap now sets, since these become regular components. The bar: a contract comment documenting every opt and the full handle surface; no page globals touched (page owns cadence via handles/callbacks, e.g. =onSettle=); all component CSS in one named =GW_CSS= block; refactored until no opportunity worth doing remains (small named helpers, no duplication); construction axes declared via =STYLES= where the component has them. Sweep the existing builders against that list, fix the gaps, and make the bar a stated convention in the widgets.js header or README so new builders inherit it. Overlaps the component-generation spec's extraction phase — reconcile there rather than doing the work twice. +Craig's standing directive (2026-07-18, set while finishing the split-flap): every =DUPRE.*= builder should meet the bar the split-flap now sets, since these become regular components. The bar: a contract comment documenting every opt and the full handle surface; no page globals touched (page owns cadence via handles/callbacks, e.g. =onSettle=); all component CSS in one named =DUPRE_CSS= block; refactored until no opportunity worth doing remains (small named helpers, no duplication); construction axes declared via =STYLES= where the component has them. Sweep the existing builders against that list, fix the gaps, and make the bar a stated convention in the widgets.js header or README so new builders inherit it. Overlaps the component-generation spec's extraction phase — reconcile there rather than doing the work twice. *** 2026-07-18 Sat @ 04:32:20 -0500 Made the N20 split-flap an honest Solari mechanism =GW.splitFlap= rebuilt from the drop-fade fake: charset-as-drum stepping (=opts.chars= is the flap order, one flip at a time through intermediates, staggered arrival), re-aim-not-queue retargeting, and the real two-half-panel fold (WAAPI, backfaces hidden), with =animate:false= collapsing to instant jump for reduced motion. Handle grew =setText=/=chars=/=reading()=; default width 3 → 4 cells. Nine probe checks written red-first (arrival, intermediates, one-flap stepping, re-aim discriminator, instant path); technique studied from HotFX and re-derived — no license on their repo, nothing copied (reference filed in =working/retro-stereo-widgets/references/=). Review: sound; its two test-strength notes addressed in the same change. @@ -178,9 +179,9 @@ Review (subagent) cleared the grammar — no path where selecting prints, none w - *No allowlist coverage on press().* R57 had it, R58 didn't. Known gap, Craig's to weigh when he revisits the keys: no space cell, so Space isn't in KEYS and still scrolls the page (correct per the contract). You can type Mignon-4 but not "Mignon 4". The real machine has a separate space key. -4. *Chorded keyset* — six keys, no key per letter; the chord IS the character (Microwriter 1978, Perkins Brailler 1951). The most compact honest password field. The kit names the chorded grammar already (R50 is its safety form) but holds no chorded text control. Third, if a compact field is wanted. -5. *Swatch picker* — model the signal-lamp lens turret or theatrical gel wheel; a rotary whose detents are coloured lenses. Undecided whether it earns a card or stays page furniture. -6. *Legend switch* — the lit pushbutton whose cap legend and colour ARE the state, press to step (MIL-STD-1472 catalogs it; the doc already cites the standard). The validation lamps are one. +4. [@4] *Chorded keyset* — six keys, no key per letter; the chord IS the character (Microwriter 1978, Perkins Brailler 1951). The most compact honest password field. The kit names the chorded grammar already (R50 is its safety form) but holds no chorded text control. Third, if a compact field is wanted. +5. [@5] *Swatch picker* — model the signal-lamp lens turret or theatrical gel wheel; a rotary whose detents are coloured lenses. Undecided whether it earns a card or stays page furniture. +6. [@6] *Legend switch* — the lit pushbutton whose cap legend and colour ARE the state, press to step (MIL-STD-1472 catalogs it; the doc already cites the standard). The validation lamps are one. Items 1 and 2 fill the two empty cells and are wanted by live panels today; 3 and 4 are the survey's genuinely-new grammars; 5 and 6 come from the gallery-can't-build-itself finding and are lower stakes. @@ -276,6 +277,8 @@ Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, wait Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression. +Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns). Both were the session's first probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). + ** TODO [#C] Maint live-refresh hairline replacement :feature:maint: :PROPERTIES: :LAST_REVIEWED: 2026-07-14 @@ -814,6 +817,18 @@ Some entries are libraries likely pulled in as dependencies (blas-openblas, open - [ ] webkit2gtk - [ ] whisper.cpp +** TODO [#B] Installed-package drift audit :chore:packages: +Compare the packages explicitly declared by =pacman_install= / =aur_install= +against the installed system, separately from the existing inventory's +unexpected-package review. Report missing declared packages and substitutions +where a provider is installed instead of the named package (the ratio +=emacs= versus =emacs-wayland= case). Decide whether the audit is on-demand, +timer-driven, or surfaced at startup; it must not make package changes. + +From the rulesets Emacs/package-audit handoffs, 2026-07-16. The existing +=scripts/audit-packages.sh= validates repository availability, not installed +machine state. + ** TODO [#B] Security hardening + audit :security: :PROPERTIES: :LAST_REVIEWED: 2026-07-14 @@ -1578,144 +1593,6 @@ The maintenance console's coredump metric flagged telega-server on ratio (8 core * Archsetup Resolved -** DONE [#B] Maintenance console build :feature: -CLOSED: [2026-07-08 Wed] -:PROPERTIES: -:SPEC_ID: 9d9df833-c592-4aec-a7df-50d588e943ce -:END: -Build the maintenance console per [[file:docs/specs/2026-07-07-maintenance-console-spec.org][maintenance-console-spec.org]] (DOING; review incorporated 2026-07-07 — two rounds, all 13 decisions DONE, all 10 findings DONE, verdict Ready; Craig approved config paths + sysmon right-click re-homing). Design source of truth: [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]]; pixel reference: [[file:docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html][E5 prototype]]. CLI-first =maint= package in dotfiles (archsetup-owns-dotfiles applies: edit, test, commit, push, note dotfiles inbox per phase); thresholds TOML + VM harness in archsetup. Each phase gates on green tests before its commit. - -*** 2026-07-07 Tue @ 20:11:23 -0500 Built Phase 1 — package skeleton, thresholds, contract (dotfiles 43a39ac) -maint/ package + shim + tests/maint/ (48 tests, fake-tool harness); thresholds two-layer merge with disable flags + env overrides; capability probe (battery ⇔ type==Battery, verified against ratio's Mains+USB tree); =maint status --json= with disk-usage/pacman-cache/failed-units pilots. Seeded configs/maintenance-thresholds.toml in archsetup from the workflow's values and installed a copy to ~/.config/archsetup/ on ratio (installer wiring stays Phase 13). Full make test green (50 suites). Live on ratio: worst=warn from the real 10.75 GB cache; du grades on its printed total, not its exit code (root-owned partial-download dirs make it exit 1) — pinned in a test. - -*** 2026-07-07 Tue @ 20:49:05 -0500 Built Phase 2 — storage & snapshot collectors (dotfiles 94e8371) -All Phase 2 collectors TDD'd against the fake-tool harness (argv-matched cases added): btrfs unallocated/scrub-age/device-errors (SMART cross-check), per-disk SMART health + last self-test, fstrim posture (discard mount option = continuous trim, ratio's real setup), disk top-consumers on a new slow-local cache (=maint scan --slow=, ~/.local/state/maint), snapper counts split timeline·single·pre-post (split keys on cleanup, not type), full ZFS family. =priv.py= pulled forward from Phase 6 (read-only verbs =smart_json= + =btrfs_scrub_status=, validated args, MAINT_SUDO) so probes stay elevation-free. maint suites 48→118; make test green (51). Live read-only verified on BOTH hosts: ratio worst=warn (10.75 GB cache; scrub 0d, SMART ok), velox over tailscale shows the ZFS family live (ONLINE/autotrim-on/scrub 1d/231 snaps) with a genuine fstrim warn (timer off — Phase 6 Confirm remedy). TOML gained storage keys (btrfs_unalloc_warn_gb, smart_*, fstrim_stale_days, hog_*); installed copies refreshed on ratio + velox. - -*** 2026-07-07 Tue @ 21:30:59 -0500 Built Phase 3 — packages, security, systemd collectors (dotfiles 3eaab6e) -All Phase 3 collectors TDD'd: orphans name+size with [curation.kept_orphans], pacnew safe-delete vs needs-merge, keyring freshness, reboot-required (uname -r vs modules dir), -Qkk on =maint scan --slow= with a noise split (unverifiable-as-user reads, mtime-only, [curation.qkk_known] paths tallied but never counted — an unfiltered live run sat permanently red at 68 "altered" files; classified, ratio shows 10 real findings); =maint scan --net= writes checkupdates/yay/arch-audit/fwupd caches (yay rc-1-silent = AUR-clean zero; failures never clobber good cache; malformed payloads degrade to unprobed), readers grade with age + stale bump; is-system-running with state-aware cause, failed-unit roster since/exit/journalctl hint, maintenance-timers meta-metric (capability-derived expected set, @-prefix instance match), taint letters decoded. Prereq done: arch-audit added to archsetup deps + installed both hosts. Review subagent (live-verified) found 2 Important (yay AUR-clean rc quirk, cache-shape envelope kill) + 2 minor — all fixed red-first; archsetup now enables btrfs-scrub@-.timer on btrfs installs (was ratio-only out-of-band, the meta-metric expects it). maint suites 118→169; full make test green. Live on ratio: 23-metric envelope, worst=warn from real findings (10.75GB cache, 8 orphans, mirrorlist.pacnew, 713 repo updates, 32 CVEs, 1 firmware). - -*** 2026-07-07 Tue @ 21:59:27 -0500 Built Phase 4 — logs + mem·pwr collectors (dotfiles 40c448b) -All Phase 4 collectors TDD'd: journal error digest (identifier groups w/ first/last seen + journalctl hint, known-noise curation tallied but never counted, byte-array MESSAGE decoded), coredumps by binary over a TOML window, kernel/hw event classes (MCE/EDAC/IO/thermal/GPU — generic driver noise excluded), journald disk usage, app-log staleness mirroring the log-cleanup cron's filename-date rules; memory pressure + top-5 RSS, OOM kills (kill-verdict lines only — one event spans several matching lines), swap/zram, temps via sensors -j (k10temp Tctl / coretemp package / amdgpu edge), Intel thermal-throttle count, EPP read, battery capacity/health/charge-cap, unclean-shutdown rate from wtmp (last -x pairing). Throttle/EPP/battery self-gate on sysfs and drop out of the envelope. Review subagent (live-verified) found 1 Blocking — windowed-empty coredumpctl exits 1 (my earlier --since-now pin was wrong), which left the metric permanently unprobed on healthy hosts — plus OOM ~3× overcount and a malformed-curation-entry envelope crash; all fixed red-first, re-review Approve. maint suites 169→227; full make test green. Live read-only both hosts: ratio 34 metrics (telega-server crashing 3× in window — real find), velox 38 (genuine 75% battery-health warn; charge_control_end_threshold ABSENT on velox's cros_ec hardware → Phase 9's SET 80% lever must gate on the file). TOML +journal_disk_warn_gb/coredump_window_days, new [memory], [power] temp/health/unclean keys; installed both hosts. - -*** 2026-07-07 Tue @ 22:35:28 -0500 Built Phase 5 — network posture + services collectors (dotfiles 2e92a9f) -All Phase 5 collectors TDD'd after live-pinning every tool format on both hosts (velox presence sweep over tailscale: full tool set present): nm_state (passive — NM's own connectivity check covers DNS+HTTP, so status never generates traffic), chronyc offset vs [network].ntp_offset_warn_ms, tailscale Health + peer tally, fail2ban per-jail bans, listeners digest via priv ss (expected-entry curation tallied-not-counted, unexpected+wildcard=crit, v4/v6 dedup, multi-holder sockets match any name), firewall (priv ufw status; fallback requires unit-active AND ufw.conf ENABLED=yes; names "ufw down — N public binds exposed" from the digest), rsyncshot freshness (1MB log-tail parse; hourly graded only where the log shows hourly runs — velox is daily-only; daily ≥48h/missing = crit), docker df JSONL + stopped containers w/ [curation.expected_containers], libvirt roster (queried only when libvirtd already active — a read must not socket-activate), cron drift (root rsyncshot expectations gate on backup-source). priv.py +4 read-only verbs; thresholds.curation_entries() tolerant helper. Review subagent (live-verified): Request Changes — 1 Important (malformed fail2ban counts raise ValueError into the envelope) + ufw-disable unit-state false positive, docker unknown-unit sizes reading 0, unfloatable chrony offset; all fixed red-first, re-review Approve. maint suites 227→297; full make test green (54 suites, unpiped exit 0 — an earlier tail-piped run masked make's exit code, rerun properly). Live both hosts: ratio 45 metrics, velox 49. REAL FINDS: listeners crit on both (ratio postgres/8080 wildcard binds, velox syncthing *:8384/*:22000) — curation material for Craig; backups stale on both since 2026-07-06 (truenas down for repairs, Craig confirmed — clears when it returns). TOML +[network]/hourly-backup/cron keys, installed both hosts. - -*** 2026-07-07 Tue @ 23:14:56 -0500 Built Phase 6 — remedy layer, priv verbs, guard, doctor CLI (dotfiles e171c08) -The full remedy layer TDD'd in one pass (no 6a/6b split needed): remedies.py allowlisted table (~30 remedies — exact argv, tiers per the design tables as amended by the dated decisions, re_probe_ids, long flags, RECLAIM SPACE macro resolving steps against the envelope incl. per-config snapper expansion); priv.py +28 write verbs (closed builders, type-validated args, per-verb timeouts; =build()= returns the exact sudo-prefixed argv so dry-run and the GUI arm-press share one string); guard.py pure live-update matcher over new TOML =[updates] guard_patterns= (fnmatch, case-sensitive, pairwise-tested; cold pending-cache is surfaced as a note, never silent); curation.py user-layer writes (mark/unmark/clear + KEEP/MARK KNOWN/EXPECTED wrappers, disable-flags for shipped defaults, self-verifying TOML emitter); doctor.py clean|review + iter_fix streaming the event feed (begin/ok/fail/guard/note; failures red without aborting; re-probed truth after each action); cli =maint fix <id> [--dry-run] [--force]= + =maint doctor clean|review= wall renderer; status envelope now stamps =levers= (always-on for free controls: epp_set, charge_limit, update, topgrade). KILL four guards: pid:name victim, TERM-only verb, session-critical denylist ([curation.session_critical], new TOML table) at preview, comm revalidation + denylist re-check at fire. Live-pinned before coding: coredumpctl HAS NO clean verb (find -mtime +N -delete via priv), log-cleanup at ~/.local/bin/cron/, rsyncshot takes <mode> <keep>, ufw enable needs --force, EPP value enum. Review subagent (live-verified): Request Changes — 1 Blocking (json.dumps astral-char surrogate pairs = invalid TOML: one emoji in a MARK KNOWN example would brick thresholds.load() for the whole console → ensure_ascii=False + write self-verifies by re-parsing before replace) + 1 Important (KeyError from sparse installed TOML escapes the event stream → caught, degrades to a red wall entry) + 2 Minor (cold-cache guard note, comm-vs-denylist re-check); all fixed red-first (6 new tests), re-review Approve. maint suites 297→393 (96 in the new suite); full make test green ×2 (unpiped, exit 0). VERIFIED LIVE on ratio: doctor clean executed the Auto tier for real — paccache -ruk0 freed 223.91 MiB and the re-probe measured the cache drop exactly; the live-update guard trips on ratio's genuinely-pending mesa/vulkan-radeon set. TOML +guard_patterns/+session_critical/+timeline_quarterly installed both hosts. - -*** 2026-07-07 Tue @ 23:52:40 -0500 Built Phase 7 — GUI shell (dotfiles d28435e) -The GTK4 window lands as the fourth panel sibling (net.cjennings.maintpanel, humble-object layering: GTK-free PanelModel presenter + pure viewmodel formatters + thin gui.py view). Read-only shell: faceplate (worst lamp, CVE/ATTN badges, MNT·01, close), inert doctor/update keys, updates strip (pending/CVE/AUR/firmware from the strip ids), eight-band selector (lamp + fixable/watch split from the envelope's levers; updates/security categories fold into PACKAGES), metric-row subpanel per band with per-id value formatting (bytes/percent/days/°C/ms/bool). Hydration off-thread; 3s live tier (build_live: cheap mem·pwr reads) + 30s full rebuild, both visibility-gated; MAINT_PANEL_FIXTURE=good|bad renders shipped envelope fixtures (reshaped from the E5 prototype by tests/maint/gen_fixtures.py, deterministic, real remedy ids) and freezes both tiers so the degraded board stays put on a healthy machine. Review subagent: Request Changes — 1 Blocking (build_live returned bare metrics, so the 3s merge stripped the levers the full envelope established and the band splits oscillated between tiers → attach_levers in build_live) + 2 Minor (synthetic error envelope missing worst read as healthy green → panel.error_envelope with worst=unprobed; smoke leaked the panel process on mid-run assertion errors → try/finally reap); all fixed red-first, re-review Approve (+1 hardening: merge into an error envelope is a no-op). maint suites 393→447 (54 in test_panel.py); full make test green (56 suites, unpiped exit 0). AT-SPI smoke PASS on live Hyprland (faceplate, badges, strip, all 8 tiles, band switch, clean close; window parked on a special workspace, never the active one). Screenshots off-workspace via headless output: bad fixture + live board vs the E5 reference. Live board on ratio honest: 32 CVE / 13 ATTN, NETWORK red from the real listeners crit, SERVICES fixable from the truenas-down backup staleness. GOTCHAS: windowrules need hyprctl reload before first launch (panel opens tiled otherwise); pkill -f "maint panel" self-matches the invoking shell — anchor with "maint panel$". - -*** 2026-07-08 Wed @ 00:39:23 -0500 Built Phase 8 — GUI levers: digests, armed keys, rotary band, live strip (dotfiles 388521e) -The storage/snapshots/packages subpanels grew their evidence digests (disk hogs, per-drive SMART, device errors, snapper by-type, orphans KEEP/REMOVE, pacnew DELETE/MERGE, CVE advisories), packages got the E5 rotary band selector (ORPHANS/PACNEW/ADVISORIES), and the updates strip went live: state-tiered border, UPDATE/TOPGRADE behind the arm-to-override live-update guard, REBOOT offered when required or after an update lands. Every lever is arm-then-fire — first press shows the exact argv on the new act line (identical to =maint fix --dry-run=, the parity criterion), second press fires through =doctor.iter_fix= with the re-probe landing back on the act line; one key armed at a time, band/deck switch disarms; fixture boards always dry-run and never write curation, which is what lets the AT-SPI smoke exercise the full press-press path safely. Engine support: snapper probe emits =stale_singles= (DELETE STALE's candidates, newest kept), both scrub probes parse running-%, =doctor.pending_updates()= public for the arm-press guard read, fixtures regenerated with deterministic evidence. Review subagent (live-verified): Request Changes — 2 Important (refresh tiers raced a firing remedy: a 30s tick could probe the package DB mid-transaction and land stale data after the fire's own re-probe → tiers pause on =firing= + post-fire rebuild retries past an in-flight tick; digest builders KeyError'd on identity-less evidence rows replayed from cache files → skip-and-degrade, 8 new red-first tests) + 4 Minor (all taken: broadened strip-key except, scrub key offers first idle target instead of vanishing while any runs, busy-refused press no longer consumes the arm, fire-time guard refusal names the tripped set in GUI wording). Re-review: Approve. maint suites 447→516; full =make test= green (exit 0, unpiped); AT-SPI smoke PASS end-to-end on the bad fixture; headless-output screenshots vs E5; live board honest on ratio (real orphans w/ KEEP/REMOVE, 10.5 GB cache CLEAN, arm-press names the genuinely pending mesa set). Velox pulled to 388521e (no restow/TOML/windowrule changes). Scroll-position-on-arm check added to Manual testing. - -*** 2026-07-08 Wed @ 01:51:26 -0500 Built Phase 9 — GUI: systemd, logs, mem·pwr, network, services (dotfiles 9a2985f + a3da942) -Shipped in two gated halves. 9a (9a2985f): failed-unit + timer rosters (armed RESTART/RESET per unit; ENABLE only on present-but-inactive timers), logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, row levers VACUUM/CLEAR/RUN. 9b (a3da942): CPU-mode segmented control (current + "default" excluded — priv's closed verb refuses "default"), top-memory guarded KILL (session-critical disabled pre-press, arm refusal behind it, fire-time comm revalidation behind both), battery SET 80% gated on the sysfs knob (velox: unsupported → withheld, verified live), listeners curation (probe emits pid + owning unit from the cgroup v2 line; STOP for system.slice services, KILL otherwise, "?" watch-only), firewall ENABLE / tailscale UP / chrony STEP row levers, services deck (containers MARK EXPECTED + START, docker df, cron roster + backups w/ worst-of chip lamp). RECLAIM SPACE deferred to Phase 10's wall by design — the act line can't honestly render a 7-step stream. Reviews: 9a Request Changes (curation done-callbacks bypassed the busy-retry rebuild → routed through _refresh_after_fire) → Approve; 9b Request Changes (DEFAULT EPP dead control + cgroup hybrid-hierarchy, "?"-curation, cron chip lamp, unprobed ✓ minors — all fixed red-first) → Approve. maint 516→612 tests; make test 58 suites exit 0; AT-SPI smoke PASS across all 8 bands; headless screenshots vs E5; live envelopes verified both hosts. -**** 2026-07-08 Wed @ 01:13:48 -0500 Built 9a — systemd + logs subpanels (dotfiles 9a2985f) -Failed-unit roster (armed RESTART/RESET per unit, since/exit context, journalctl hint) + expected-timer roster (ENABLE only on present-but-inactive timers); logs rotary deck (SIGNAL / KNOWN NOISE / COREDUMPS / KERNEL·HW) with the journal MARK KNOWN lifecycle (arm-then-fire mark bound to identifier + sample, UNMARK, armed CLEAR MARKS, empty-sample withhold), OPEN JOURNAL terminal delegation, and row levers VACUUM / CLEAR / RUN. Review subagent (live-verified): Request Changes — 1 Important (curation done-callbacks called refresh_full, which silently no-ops while a tick refresh holds busy → a mid-tick write left a pre-write envelope on the board up to 30s while the act line claimed success; routed all four incl. Phase 8's keep through _refresh_after_fire) + minors taken (single _mark_token definition, three test gaps closed, docstring truthfulness, stale arm wording cleared on band/deck switch — my own find during screenshot eyeballing). Re-review: Approve. maint 516→562 tests; make test 58 suites exit 0 unpiped; AT-SPI smoke PASS incl. the new rosters/deck/mark path; headless screenshots vs E5. velox pulled (no restow). Accepted gap: KNOWN NOISE subsec lists the live curation table on a fixture board — fixture-sourced entries list is later polish. - -*** 2026-07-08 Wed @ 02:36:54 -0500 Built Phase 10 — results wall + doctor keys (dotfiles cc2fb5d) -Results wall shipped as the session action log: doctor events stream per-event (begin opens a running entry with the exact argv, ok/fail resolves in place, re-probe notes follow), 3.5-visible-row scroll cap with autoscroll, HIDE/COPY (COPY emits CLI-wall text), curation writes logged (fixture-suppressed as dim notes). CLEAN UP streams the auto tier with no arm press; REVIEW & FIX swaps the subpanel to the confirm-tier roster (item-less remedies get armed FIX keys, per-item ones point at their subpanel; review events carry item + worst severity). RECLAIM SPACE macro landed on the disk-usage row (deferred from 9b); macro arm line shows first step + remaining count; zero-step macros refuse by name. Review (live-verified): Request Changes — 1 Important: the roster's FIX for update/topgrade could never override the live-update guard (fire-time re-arm used the bare rid while the FIX key arms on "rid:", and _on_lever never passed force — an infinite arm→guard loop); fixed by threading the pressed token through _fire and sharing the strip's guard arming (_guard_arm_line + panel.guarded). Minors all taken: wall_abort resolves stranded running entries red when a stream dies, empty-macro IndexError → named RemedyError, test predicate tightened. Re-review: Approve, no residuals. maint 612→645; make test 59 suites exit 0 ×2; AT-SPI smoke extended (wall, HIDE/SHOW, CLEAN UP stream, review roster, 14-line RECLAIM stream) PASS; screenshots vs E5; LIVE verify on ratio: real CLEAN UP on the live board streamed "3 done" with real re-probes (coredumps warn 5, disk_usage ok 60). Velox pulled cc2fb5d. - -*** 2026-07-08 Wed @ 03:24:44 -0500 Built Phase 11 — waybar glyph + timers (dotfiles 10033be) -custom/maint replaced custom/sysmon: waybar-maint renders from the state file maint-scan.timer writes every 30 min; color tracks the worst diagnostic (lever-less) metric only, actionable findings never color the bar; missing/stale scan data dims to an honest unprobed state naming the timer. Battery hosts show live battery % + charging icon from sysfs on waybar's interval; low charge forces red. Left-click toggles the console (new maint-panel wrapper); right-click keeps the btop scratchpad. Bare =maint scan= became the glyph scan (envelope → state → RTMIN+12); maint-net-scan.timer drives =--net --slow= hourly. Retired same commit: waybar-sysmon, sysmon-cycle, both suites, #custom-sysmon CSS ×3 — plus unnamed collateral the grep caught: waybar-collapse's collapsed base set (maint glyph stays on battery hosts — it IS the battery display now) and a stale dotfiles todo. Review (live-verified): Approve with 4 Minor, all taken red-first — scope!=Device gate so a wireless mouse's battery can't masquerade as the machine's (shared capability.device_scope), unprobed diagnostics named in the tooltip instead of "all clear" under a dimmed glyph, comment truth, dead state field dropped; re-confirm clean (677 maint tests). Full make test exit 0; smoke PASS (first run's pipe masked a transient AT-SPI race — rerun unpiped clean). LIVE both hosts: ratio timer fired for real (glyph amber from genuine diagnostics: firmware update + journal errors; 11 actionable NOT coloring), velox pulled + one-time stow + battery 100% text w/ warning class. velox's user systemd manager found wedged (pre-existing) — timers enabled via manual symlinks + state seeded; Craig rebooting to clear. - -*** 2026-07-08 Wed @ 04:15:45 -0500 Built Phase 11b — prototype fidelity pass (dotfiles 3ee22a8) -Rebuilt the GUI presentation to the E5 instrument-card idiom. Each band renders a 4-column card grid: big-number readouts (viewmodel.card_spec registry — one builder per bespoke metric id, count-card fallback for the rest), cairo progress bars w/ threshold ticks (cache scaled to 1.5× the warn line), radial gauges (scrub cadence, keyring/topgrade age, temperatures — sweep scaled to the metric's own threshold), status chips (PASSED/FAILING/INACTIVE/YES; firewall chips the state word, exposure detail moves to the caption), and the CPU-mode segmented control promoted from digest section to card (current mode lit, alternatives as joined armed keys — same DIGEST_ONLY epp_set arm-then-fire). Evidence digests stay full-width below the grid. Selector became a two-row 4-col grid of wide tiles w/ count chips (3! · 7✓, crit reddens) + severity left borders; subpanel header carries the attention/ok/fixable/watch summary right-aligned beside a gold band title. The scoped NVMe wear dial was skipped honestly — no standalone wear metric exists (wear rides SMART evidence rows). Presentation only: engine/remedies/doctor/curation and the lever contracts unchanged. New 42-test suite (card kinds, threshold scales, running-scrub override, unprobed degrade incl. tick drop, seg exclusions, chips/subhead/band_counts, both-fixture sweep). Review (live-verified): Approve w/ 1 Important (pre-existing: chrony "not running" string crashed format_value's ms branch — fixed w/ card guard) + 4 Minor all taken red-first (disk None guard, _pct negative clamp, unprobed tick, dead row_detail/split_label/.maint-val removed); re-review Approve. make test 61 suites exit 0 ×2 unpiped; AT-SPI smoke 92 checks PASS (new gauge/chip/subhead assertions — DrawingAreas carry accessible_role=IMG + names so the smoke can see them); all 8 bands + review roster screenshotted on both fixtures + the live board against the settled headless-Chrome prototype render. Velox pulled (no restow). Gotcha pinned: a card body's vexpand propagates up a Gtk.Grid — a one-card band ballooned until grid.set_vexpand(False). - -*** 2026-07-08 Wed @ 05:05:20 -0500 Ran the granted /refactor sweep over the maint package (dotfiles a178470..73e9d94) -Four parallel read-only scans (complexity, duplication, dead-code, simplification) converged on the same hotspots; ten behavior-preserving commits applied and pushed. Headlines: six curation handlers folded into one _curation helper (~150 lines of repeated fixture/done plumbing, act/wall strings byte-identical), _on_lever/_on_strip_key merged into _press_lever, _on_fired split (_rearm_after_guard + _fire_summary), the "tool: not found / exit N" wording single-sited in cmd.why() across 20 probe sites, exact clones deduped (packages curation_entries, _parse_asctime, double VERSION), ten uniform digest sections folded into _evidence_digest, _card_body split per kind, listener key rule named, plus a small-simplifications batch. Deliberately skipped: format_value's flat chain (readable as-is), _journal_lines generalization (rc-semantics risk), test-panel-maint Makefile target (Phase 12 scope). Verification: suite parity exact pre/post (59 suites / 2490 tests, exit 0 unpiped), AT-SPI smoke PASS end-to-end, live ratio envelope 45 metrics zero unprobed, review subagent walked the full diff against 3ee22a8 and approved (no behavior change; accessible names untouched; its one latent-robustness minor pinned as an invariant comment). gui.py 1680→1605; net −103 lines. Velox pulled. - -*** 2026-07-08 Wed @ 05:46:02 -0500 Built Phase 12 — VM remedy scenarios + AT-SPI target (archsetup d6993d3, dotfiles 0636554) -Scenario orchestration landed as =run-maint-scenarios.sh= over the existing vm-utils.sh snapshot primitives: nine break → =maint fix= → assert scenarios in three groups (logs, packages, systemd), batched per Decision 11 (one boot per group, maint-ready snapshot restored between groups, clean-install restored at the end). =run-maint-nspawn.sh= is the fast lane — the packages group against a cached pacstrap rootfs in seconds. Plan layer is pure (=--list=, no KVM) with a 19-test unit suite; =make test-maint= wires it in. Dotfiles side: panel smoke now runs GOOD + BAD fixture passes (healthy render, badge tallies from the fixture, no-REBOOT, leak check) and =make test-panel-maint= is enumerated. Verified: full VM run 9/9 green from pristine snapshot ×2 (test-results/maint-20260708-054029), nspawn 4/4, smoke 100 checks exit 0, unit suite green under both FS_PROFILE values. Review subagent: Request Changes (FS_PROFILE env leak in the unit suite, journal-vacuum assert vacuous both directions) — all six findings fixed red-first, re-review Approve. FS_PROFILE=zfs scenarios: filtered but unexercised — the zfs base image fails to build (see the zfs DKMS task). - -*** 2026-07-08 Wed @ 06:18:58 -0500 Built Phase 13 — install wiring, workflow move, docs (archsetup bef7053 + 18c081f, dotfiles 9a3f0c7) -Installer: install_maintenance_config in user_customizations installs the shipped TOML to ~/.config/archsetup/ (re-run refreshes it, ~/.config/maint/ user layer never touched) and enables maint-scan/maint-net-scan timers via wants-symlinks (hyprland-only — the units live in that stow tier; no session bus during install, syncthing idiom); orchestrator + dispatch-branch tests pin the wiring. Deps swept from the probes' argv: expac, lm_sensors, fwupd added (arch-audit was already in from Phase 3; the rest ride base/archinstall or existing sections). maint/README.md in dotfiles covers user (CLI, panel, glyph, config layers, guard, privilege) + developer (module map, env seams, four test layers, add-a-metric recipe). system-health-check.org moved home → archsetup docs/workflows/ and rewired: TOML-authoritative severity section, threshold lines cite TOML keys (TOML wins on disagreement), inventory paths → docs/homelab-inventory/, maint-CLI fast-path note; only the four #+HOSTNAME: host inventories moved (+ the truenas specs asset they link) — the personal gear records stay in home per the domain split; discoverability via .ai/project-workflows symlink + notes.org entry; handoff note in home's inbox (2026-07-08-0607) lists home-side removals and flags strix-soak-watch as a candidate to move later. Verified: fresh-shell =maint status= green from installed paths on ratio; make test-unit exit 0; dotfiles make test exit 0; shellcheck count unchanged; review subagent Approve (2 Minor doc nits, both taken). - -*** 2026-07-08 Wed @ 06:18:58 -0500 Flipped the spec to IMPLEMENTED -Spec keyword DOING → IMPLEMENTED with a dated history line naming the closing commits (phases 1-13, dotfiles 43a39ac..9a3f0c7, archsetup d6993d3/bef7053/18c081f) and the Status mirror updated. Residuals tracked as their own tasks: manual-testing checklist, zfs base-image DKMS bug [#C], vNext [#D]. -** DONE [#B] Signal and known-noise counts disagree with the journal :bug:maint:solo: -CLOSED: [2026-07-09 Thu] -Not a counting bug — a semantics bug (dotfiles eded0c0). journal_errors grouped by identifier and summed lines, but displayed one sample message, and logs.py overwrote that sample on every iteration so it named the *last* error rather than the counted one. Live on ratio: systemd had 10 error lines this boot — 6 "Failed to start Emacs text editor." and 4 org-roam auto-sync — which is exactly the 10-vs-6. Now grouped by identifier + normalized message (pids, hex addresses, uuids, long integer runs blanked), so a row's count is that message's count, and each row's hint carries a =--grep= reproducing it (verified: 6 and 4). MARK KNOWN binds the volatile-free literal instead of the pid-bearing sample, so marking suppresses exactly what the row counted. The KNOWN NOISE headline counts matched lines while the deck lists patterns; the caption now names both units. -Logs > SIGNAL reports 10 journal errors and the signal item claims 10, but the group holds one item and the open journal shows it 6 times. KNOWN NOISE reports 19 against 12 visible entries. The counts are the point of the band, so a wrong number undermines the whole diagnostic. Find where the tally diverges from the rendered set — likely counting matched journal lines rather than distinct grouped entries, and counting curated entries against a different window than the one displayed. -** DONE [#B] Persist the CPU pill setting across reboot :feature:maint: -CLOSED: [2026-07-09 Thu] -The kernel resets energy_performance_preference to the driver default every boot. A successful epp_set now records the preference in the maint user config; maint-epp-restore.service replays it at login. No-op when nothing is remembered, the host lacks EPP, or the mode already matches. A hand-edited bogus value is refused by priv's closed verb. Installer enables the unit via the existing wants-symlink idiom. Verified live on ratio: unit enabled, ran clean, EPP untouched. (dotfiles a7f34bd, archsetup 430ef1a) - -The power/perf/balance pill sets EPP live, but the value reverts to its original setting after a reboot. Add a way to make a selection permanent. Needs a call on where persistence lives — a systemd unit, a udev rule, or a config file the maint package owns. -** DONE [#B] Preview the pacman + AUR update queue before upgrade :feature:maint:solo: -CLOSED: [2026-07-09 Thu] -A QUEUE key on the updates strip streams the pending pacman + AUR set onto the results wall, each package with its version move; `maint queue` prints the same in a terminal. The cap reports how many it dropped rather than truncating silently, and a cold cache says it has no pending set instead of rendering "nothing pending". Verified live against ratio's 105 pending packages. (dotfiles 8837a47) - -Show which pacman and AUR packages are queued before UPDATE or topgrade runs, so the armed live-update guard isn't the first place the package set becomes visible. -** DONE [#B] Audio PTT state desync across panel, waybar icon, and actual state :bug:audio:solo: -CLOSED: [2026-07-09 Thu] -Two state stores and two copies of the logic. The panel flipped an in-memory ptt_armed flag with its own enter/exit rules; the CLI toggle and the bar tag read a runtime state file. The file is now the only state and ptt.toggle_plan the only decision. The panel adopts the persisted state on every refresh, routes its toggle through the shared plan, and persists before signalling waybar (writing after would race the bar into re-rendering the pre-toggle class). Tests drive both entry points against one state across armed/disarmed x muted/live. (dotfiles 9524a44) - -The audio panel's push-to-talk, the waybar PTT icon, and the real PTT state disagree. All three must agree at all times. Cover every state transition with tests, not just the happy path — this is the second PTT bug after the pre-talk mute restore landed 2026-07-05 (dotfiles 1443b9e). -** DONE [#B] Publish the homelab inventory into the agent knowledge base :chore:docs: -CLOSED: [2026-07-09 Thu] -The four host inventories, the TrueNAS specs asset, and the ratio USB/xHCI record now live as roam nodes in =~/org/roam/hardware/=, linked from the "Homelab Hardware Inventory" index node. =docs/homelab-inventory/= is gone; roam is canonical. - -Carried rather than pointed, per home's argument, which beat mine. Pointing keeps one canonical copy but breaks the write path: a third project that discovers a durable hardware fact would have to write into archsetup's repo to record it, which the cross-project rules forbid, so the fact would sit in an inbox instead of on the device's page. Moving answers the canonical-copy worry without that cost. - -The =* Automated Capabilities= drawer moved with the pages. =system-health-check.org= now resolves a host's node by its =#+HOSTNAME:= keyword under =${ROAM_DIR:-$HOME/org/roam}/hardware/= — never by filename, since the timestamp prefix isn't stable. Verified: all four hosts resolve, an unknown host degrades to "NO INVENTORY FILE", and ratio's capability drawer still parses. A host without the roam clone (mybitch, truenas) takes the same no-inventory path it always did. - -The strix kernel watch stayed at =docs/workflows/strix-soak-watch.org= — it's a workflow, not an inventory page. -** DONE [#C] Optional ticking sound for the pomodoro :feature:timer: -CLOSED: [2026-07-09 Thu] -WTIMER_TICK=1 plays a quiet tick each second through a pomodoro's work phase; off by default, silent through rest (a tick there would undo the break), while paused, and while awaiting a manual advance. Rides the bar's existing once-a-second `wtimer render` call. Playback is fire-and-forget so it can never stall or break the bar. Sound is an 18ms 1.4kHz sine at a tenth of full scale (notify/tick.ogg); WTIMER_TICK_SOUND overrides. Craig still has to judge whether the tick sounds right. (dotfiles 886dafd) - -Timers module. Off by default; needs a sound choice and a volume decision. -** DONE [#C] Refresh indicator for the top-like items :feature:maint: -CLOSED: [2026-07-09 Thu] -A hairline under the top-memory section drains toward the next live refresh, so a slow board reads differently from a frozen one. Withheld on a fixture board, which never live-ticks — a countdown that never counts is worse than none. The fraction is a pure viewmodel function; only the cairo stroke lives in the GUI. (dotfiles 5d384d9) - -Show when the process/memory rows are about to refresh, so a stale-looking board is distinguishable from a frozen one. Shape is a design call — a countdown, a pulse, or a progress hairline. -** DONE [#C] CPU pill should not reposition on selection :bug:maint:quick:solo: -CLOSED: [2026-07-09 Thu] -epp_rows dropped the current preference from the key list, so the remaining segments slid sideways on every selection. Every preference now renders in a fixed order with the current one lit and unpressable in place, at full contrast rather than GTK's disabled dimming. The separate lit label is gone — the active segment is the lit one. Three separate tests encoded the old contract (unit, AT-SPI smoke, phase11b card); all moved with the behavior. The smoke caught a real gap the unit tests missed: the EPP row renders through the card path, which ignored the disabled flag. (dotfiles 5d384d9) - -The perf/balance/power pill moves position depending on which option is selected. The segmented control should hold a fixed layout and mark the active segment in place. -** DONE [#C] Session identifier needs more width :bug:waybar:quick: -CLOSED: [2026-07-09 Thu] -Not waybar — the tmux status line. status-left-length was never set, so tmux clipped the drawn status-left to its default of 10 columns: "[aiv-archsetup] " came out "[aiv-archs", losing the closing bracket and the separating space, which is why the window list ran into it. Set to 40. Applied to the live tmux server too. (dotfiles dc3c27f) - -The session section truncates; give it more characters, and keep the window sections that follow from running into it. Screenshot: [[file:/home/cjennings/pictures/screenshots/2026-07-07_145236.png][2026-07-07_145236.png]] -** DONE [#C] minimal tier zsh login PATH gap :bug:dotfiles:quick:solo: -CLOSED: [2026-07-09 Thu] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-09 -:END: -Fixed (dotfiles =c6a7878=). =minimal/.zprofile= added, mirroring =common/='s PATH-only prepend and its rationale comment. - -Tested the invariant rather than the file, because the bug *was* a per-file fix missing a tier: any stow tier shipping a =.zshrc= must ship a =.zprofile= that prepends =~/.local/bin=. Red first, and it named =minimal=. Verified behaviorally as the task asked — stowed the tier into a throwaway HOME and read a real =zsh -lc 'echo $PATH'=, which now leads with =~/.local/bin=. Full =make test= green. - -The 2026-07-08 fix gave common/ a .zprofile (zsh logins never read .profile, so ~/.local/bin was missing from TTY/ssh PATH — dotfiles 39cef40). The minimal/ tier has the same gap: it carries .zshrc and .profile but no .zprofile, so headless installs (DESKTOP_ENV=none stows minimal/ instead of common/) still get zsh logins without ~/.local/bin. Add a matching minimal/.zprofile (PATH-only prepend, same rationale comment). Verify: fresh minimal stow, zsh -lc 'echo $PATH' shows ~/.local/bin first. The installer itself is clean — it writes no shell profiles; the stow layer owns this. -** DONE [#B] Doctor button for the audio panel :feature:audio: -CLOSED: [2026-07-10 Fri] -:PROPERTIES: -:LAST_REVIEWED: 2026-07-09 -:END: -What happens when pipewire or pulseaudio is broken — would we know? Spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] — *READY*, all eight decisions closed 2026-07-09, Phase 0 shipped. Buildable: Phase 1 diag, Phase 2 classify, Phase 3 repair + doctor, Phase 4 the DOCTOR key and wall. Grounded in a live survey of ratio's stack: there is no PulseAudio (pipewire + wireplumber + pipewire-pulse, all user-scope, so no sudo anywhere), =pactl= hangs against a server that accepts and never answers, and the panel can't diagnose itself because =pactl= is the layer most likely to be down. Mirrors the net panel's diag/classify/repair/doctor split, not the maint console's priv-verb table. - -Re-graded =[#C]= → =[#B]= by the 2026-07-09 audit: the spec is written, Phase 0 shipped, and six decisions are all that stand between this and a build. Active backlog, not parking lot. - -*** 2026-07-09 Thu @ 14:12:00 -0500 Fixed: waybar's audio module died against a half-wedged server -My first read of this was wrong. =pactl.run()= already carries =timeout=8= and raises =PactlTimeout=, so the panel degrades and does not freeze. Correcting the record rather than leaving the claim standing. - -The real bug, found by driving it: =build_status()= guarded its two device-list reads but called =default_sink()= and =default_source()= outside the guard. A server that answered the lists and then stopped answering raised =PactlTimeout= straight out of a function whose whole contract is to degrade to =ok: False=. =waybar-audio= calls it with nothing catching it, so the bar's audio module died instead of dimming; the GTK panel survived only because its background worker catches everything. - -A server that hangs on the *first* call never exposes this — =list_sinks= raises inside the guard and masks the unguarded reads behind it — so the obvious regression test passes against broken code. =fake-pactl= grew =FAKE_PACTL_SLEEP_DEFAULTS= to express "answers the lists, hangs on the defaults". Verified end to end: =waybar-audio= now emits =class: degraded= / "Audio graph unavailable" and exits 0. dotfiles =4d42eb3=; full =make test= green. - -*** 2026-07-10 Fri @ 06:41:00 -0500 Shipped: the audio doctor, phases 1 through 4 -=audio diag=, =audio doctor=, =audio doctor --fix=, and the panel's DOCTOR section header with DIAGNOSE / FIX keys streaming into a results wall (copy + close). Spec is IMPLEMENTED. dotfiles =01a1d80=, =76857e3=, =7223c51=, =1550ac8=, =4320255=, =bd33440=. - -Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, and found two bugs no fake could surface: =pactl get-default-sink= prints the literal =@DEFAULT_SINK@= placeholder when no default is set, and remedy 1 re-probed before WirePlumber had re-created the devices, calling a repaired stack broken. Both fixed. - -Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs. - -Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy. ** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network: CLOSED: [2026-07-12 Sun] Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=. @@ -1772,3 +1649,6 @@ Retrofit the net doctor (=~/.dotfiles/net/src/net/doctor.py=) and bluetooth doct Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance console's wider results-wall layout (date+time stamp column, COPY, persistent history) does NOT backport — the net/bt panels are ~400px wide and lack the horizontal real estate. Their existing output wells keep their compact layout; this task just makes them stream live. Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over. +** DONE [#B] Absorb the clock-panel project into the dotfiles :feature:waybar:dotfiles: +CLOSED: [2026-07-18 Sat] +Absorbed into =~/.dotfiles= (commit 3fab11d): package =clock/src/clock/= (renamed from clock_panel), the six PNG watchface layers packaged inside the module at =clock/src/clock/assets/=, a stowed =clock-panel= shell shim (LD_PRELOADs gtk4-layer-shell), waybar left-click now =clock-panel toggle= with the absolute path dropped, tests converted pytest→unittest into =tests/clock/= plus an asset-load guard. Kept the layer-shell overlay and the socket toggle. The standalone repo is archived (ARCHIVED.md), kept for its design history. Verified live: the bar click renders the polished watchface. |
