Files
ruvnet--RuView/docs/adr/ADR-169-adam-mode-light-theme.md
T
rUv 42dcf49f4d fix(adr): resolve duplicate ADR numbers + close ADR-080 security + ADR-154 M1 signal backlog (#1051)
* fix(signal): circular phase variance for ghost-tap guard (ADR-154 §7.4 #1)

`phase_variance` computed a LINEAR sample variance over phase angles that
wrap at ±π, so a tightly-clustered set straddling the branch cut reported
spuriously HIGH dispersion — false-tripping the `> TAU` ghost-tap guard on
real, tightly-clustered CIR taps.

Replace with Mardia's circular variance V = 1 − R̄, bounded [0,1] and
invariant to where the cluster sits on the circle. Re-derive the guard
against the bounded metric via a named const
`GHOST_TAP_CIRCULAR_VARIANCE_MAX` (the old TAU-scaled threshold is
meaningless on [0,1]).

Grade: metric fix MEASURED; threshold value DATA-GATED — a clean single-path
ramp also sweeps the circle, so V alone cannot separate clean from
unsanitized without labelled frames. Conservative default (0.99) errs toward
never false-rejecting, strictly more permissive at the wrap boundary than the
buggy linear guard.

Fails-on-old test: `phase_variance_circular_not_fooled_by_branch_cut` —
inlines the old linear variance to show it exceeds TAU on wrap-straddling
phases while circular V≈0 and the guard no longer trips. Plus
`phase_variance_circular_is_bounded_and_extremal` (V∈[0,1], V≈0 identical,
V≈1 uniform).

cargo test -p wifi-densepose-signal --no-default-features --features cir --lib
→ 432 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(signal): pin Welford n=0/n=1 finiteness guard (ADR-154 §7.4 #10)

The shared `WelfordStats` (field_model.rs, used by longitudinal.rs and others)
relies on `count < 2` guards in `variance`/`sample_variance`/`std_dev`/
`z_score` to stay finite at the boundaries. The guards existed but the n=0
boundary was UNTESTED — exactly the §4 divide-by-(n−1) family the ADR groups
this with.

Add `welford_finite_at_n0_and_n1` asserting every statistic is finite and
returns the documented sentinel (0.0) at n=0 and n=1, plus load-bearing doc
comments on the two guards.

Fails-on-old proof: with the `sample_variance` guard removed, the test FAILS
with "attempt to subtract with overflow" at the `(self.count - 1)` underflow
(0usize − 1); `variance` would similarly yield 0.0/0.0 = NaN. The guard is
restored; the test pins it so a future regression is caught.

Grade: MEASURED (boundary finiteness is asserted; the guard is the §4-family
fix made testable).

cargo test -p wifi-densepose-signal --no-default-features --lib field_model
→ 22 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic adversarial thresholds + boundary tests (ADR-154 §7.4 #13)

Lift the bare numeric literals buried in `check`/`check_consistency` into
named, documented module consts (FIELD_MODEL_GINI_VIOLATION=0.8,
ENERGY_RATIO_HIGH_VIOLATION=2.0, ENERGY_RATIO_LOW_VIOLATION=0.1,
CONSISTENCY_ACTIVE_FRACTION_OF_MEAN=0.1, SCORE_W_* weights). VALUES UNCHANGED —
each const equals the original literal; only names + pinning tests are new.

Grade: DATA-GATED. The operating values stay empirical (defensible values need
labelled spoofed/clean CSI — Wi-Spoof, §6.2/§7.3). The de-magicking +
characterization tests are MEASURED: `tuning_consts_unchanged_from_literals`,
`energy_ratio_high_boundary`, `energy_ratio_low_boundary`,
`field_model_gini_boundary`, `consistency_active_fraction_boundary` pin the
decision boundaries at/just-below/just-above each threshold, so a future
data-driven retune is a visible, tested change.

Fails-on-change proof: bumping ENERGY_RATIO_HIGH_VIOLATION 2.0→3.0 makes
`energy_ratio_high_boundary` FAIL (restored). Operating values explicitly
NOT changed.

cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::adversarial
→ 20 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* refactor(signal): de-magic coherence drift/gate thresholds (ADR-154 §7.4 #9)

Lift the bare detection literals in `coherence.rs::classify_drift`
(DRIFT_STABLE_SCORE=0.85, DRIFT_STEP_CHANGE_MAX_STALE=10) and the
`coherence_gate.rs` Default impl (DEFAULT_ACCEPT_THRESHOLD=0.85,
DEFAULT_REJECT_THRESHOLD=0.5, DEFAULT_MAX_STALE_FRAMES=200,
DEFAULT_PREDICT_ONLY_NOISE=3.0) into named, documented consts. VALUES
UNCHANGED. The gate already exposed these via GatePolicyConfig (config seam);
this names + pins the defaults.

Grade: DATA-GATED. Operating values stay empirical (defensible Z-score
thresholds need labelled stable/drifting coherence traces). De-magicking +
boundary tests are MEASURED: `classify_drift_stable_score_boundary`,
`classify_drift_stale_count_boundary` pin the at/just-below/just-above
decisions; `drift_consts_unchanged_from_literals` /
`gate_default_consts_unchanged_from_literals` pin the values. Operating values
explicitly NOT changed.

cargo test -p wifi-densepose-signal --no-default-features --lib ruvsense::coherence
→ 40 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-154): mark §7.4 P1 backlog cleared — Milestone-1 (#1,#10 RESOLVED; #9,#13 DATA-GATED)

Update ADR-154 §7.4 backlog rows #1, #9, #10, #13 with commit refs + grades,
the §7.4 intro count (four P1 items cleared, ~41 P2/P3 remain), the
Horizon-ledger one-liner (Milestone-1 DONE), and the §8 honest-limits #1 line
(metric now correct; threshold still DATA-GATED). Add CHANGELOG [Unreleased]
entry.

Grades: #1 RESOLVED (MEASURED metric / DATA-GATED threshold), #10 RESOLVED
(MEASURED), #9 & #13 RESOLVED-PARTIAL (DATA-GATED — de-magicked + boundary
tested, operating values unchanged).

Validation: cargo test --workspace --no-default-features → 2057 passed, 0
failed; wifi-densepose-signal lib → 442 passed (no-default + --features cir);
python archive/v1/data/proof/verify.py → VERDICT: PASS, hash f8e76f21…46f7a
UNCHANGED (CIR ghost-tap guard is not on the deterministic proof path).

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(sensing-server): stop leaking internal errors in HTTP responses (ADR-080 #2)

Six handlers in `main.rs` serialized the internal error `Display` straight
into the JSON response body, leaking server internals to any client (ADR-080
finding #2, CWE-209; reframed onto the Rust boundary by ADR-164 G11):

  - edge_registry_endpoint: a panicked spawn_blocking `JoinError`
    ("task … panicked") in a 500, and the raw upstream error in a 503
  - delete_model / delete_recording / start_recording: std::io::Error
    strings carrying OS detail / filesystem paths
  - calibration_start / calibration_stop: the FieldModel error chain

New `error_response` module: `internal_error` / `internal_error_json` /
`upstream_unavailable` log the full detail server-side only (tagged with a
correlation id) and return a generic body
(`{"error":"internal_error","correlation_id":…}`) — no `panicked`, no file
paths, no Debug chain. The correlation id lets an operator join a client
report to the exact server log line without ever shipping the detail.

Pinned by 5 error_response tests, incl. a leak-substring guard
(internal_error_body_does_not_leak_detail) verified to FAIL on the reverted
old body (returns the panic message / path / "os error"). The HOMECORE sweep
(ADR-161) covered homecore-server, not this crate.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(sensing-server): pin XFF-immunity + no-query-token (ADR-080 #1, #3)

Findings #1 (XFF-spoofing bypass) and #3 (JWT-in-URL, CWE-598) were logged
against the Python v1 API but are VERIFIED ABSENT on the current Rust
sensing-server, so they get regression tests rather than redundant fixes:

  - #1 XFF: there is no IP-based rate-limiter or IP-allowlist to bypass, and
    neither security middleware reads a forwarded header. Added
    bearer_auth::xff_header_never_affects_auth_decision (spoofed
    X-Forwarded-For never flips a 401<->200 decision) and
    host_validation::forwarded_headers_never_bypass_host_allowlist (spoofed
    X-Forwarded-Host: localhost never lets Host: evil.com past the allowlist).

  - #3 JWT-in-URL: require_bearer reads the token only from the Authorization
    header; WS handlers take no query token; the sole Query extractor
    (EdgeRegistryParams) is a non-secret refresh flag. Added
    bearer_auth::query_string_token_is_never_accepted — ?token= / ?access_token=
    in the URL never authenticates (stays 401) while the header path still 200s.
    Verified to FAIL when a query-token path is injected into require_bearer.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-080): mark P0 security findings #1-#3 RESOLVED; close ADR-164 G11

- ADR-080: Status note + per-finding closure (#1 XFF and #3 JWT-in-URL
  verified absent + regression-pinned; #2 leaked errors fixed via the
  error_response module). Records the v1-vs-Rust boundary distinction
  explicitly: v1 paths remain archived; this closure governs the shipped
  Rust sensing-server.
- ADR-164: Gap Register G11 and the Open/Gated Backlog entry marked
  RESOLVED with the fix + branch reference.
- CHANGELOG: [Unreleased] -> ### Security entry covering all three findings.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): renumber 6 displaced ADRs to resolve duplicate-number collisions (ADR-164 G1)

Resolves the 5 duplicate ADR numbers (6 displaced files) flagged by ADR-164
Gap Register item G1. Canonical keeper per number = first file committed at
that number (date tie-broken by inbound cross-reference count / parent-appendix
relationship). Displaced files renumbered to the next free numbers (166-171):

  050 keeps provisioning-tool-enhancements (5 refs vs 1)
    -> ADR-166-quality-engineering-security-hardening
  052 keeps tauri-desktop-frontend (parent ADR)
    -> ADR-167-ddd-bounded-contexts (its appendix)
  147 keeps nvidia-cosmos/OccWorld (the actual ADR, has Status header)
    -> ADR-168-benchmark-proof (proof companion, no Status)
    -> ADR-169-adam-mode-light-theme (was untracked)
  148 keeps drone-swarm-control-system (committed #862)
    -> ADR-170-yoga-mode-pose-system (was untracked)
  149 keeps public-community-leaderboard-huggingface (committed 16:47 vs 17:38)
    -> ADR-171-swarm-benchmarking-evaluation-methodology

Updates in-file `# ADR-NNN` headers and intra-file self-references (yoga-modes

* docs(adr): repoint inbound cross-references to renumbered ADRs (166-171)

Follow-up to the ADR renumbering (ADR-164 G1). Updates every inbound reference
that pointed at a displaced ADR, disambiguating shared numbers by title/slug so
only references to the DISPLACED topic move and keeper references stay put.

ADR-168 (was 147 benchmark-proof): README, CHANGELOG, user-guide,
  proof-of-capabilities, research docs 00/03 — all path/label refs updated.
ADR-169 (was 147 adam-mode) / ADR-170 (was 148 yoga-mode): docs/adr/README index.
ADR-171 (was 149 swarm-benchmarking): all ruview-swarm eval code+docs
  (Cargo.toml, evals/, eval_swarm.rs, metrics/mod/report/runner.rs), research
  doc 03 (every §-ref matched ADR-171 sections, not AetherArena), 00-system-review,
  series README, CHANGELOG, and ADR-148's forward/"open issues" pointers.
ADR-166 (was 050 quality-engineering / security-hardening): disambiguated from the
  ADR-050 provisioning KEEPER by topic. The HMAC/secure_tdm, directory-traversal,
  bind-address, and OTA-PSK-auth references in code comments
  (wifi-densepose-hardware Cargo.toml + secure_tdm.rs, sensing-server main.rs) and
  in ADR-052-tauri / ADR-167 all describe the security-hardening ADR -> ADR-166.
ADR-167 (was 052 ddd-appendix): inbound appendix references.

Index/registry updates: docs/adr/README.md, gap-analysis/census.md (rows +
header count), gap-analysis/lens-findings.md (collision table marked RESOLVED),
and ADR-164 Gap Register G1 marked RESOLVED with the full renumber map.

Keeper references deliberately untouched: all ADR-147 OccWorld code, all ADR-148
drone-swarm code/docs, all ADR-149 AetherArena refs (incl. ADR-150's SSL/resampling
refs, which ADR-150 explicitly binds to the AetherArena benchmark), ADR-050
provisioning refs, ADR-052 tauri refs. The frozen GitHub blob URLs in
docs/adr/.issue-177-body.md (pinned to an old branch) are left as historical.

Comment-only code edits; no behavior change. wifi-densepose-hardware compiles
clean; the sensing-server build's sole blocker is the pre-existing upstream
midstreamer-temporal-compare@0.2.1 registry crate, unrelated to these edits.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-13 14:31:38 -04:00

12 KiB
Raw Blame History

ADR-169: adam-mode — light theme toggle for the three.js realtime demo

Field Value
Status Proposed
Date 2026-06-02
Deciders ruv
Codename adam-mode
Scope examples/three.js/demos/05-skinned-realtime.html (primary), demos 0104 (follow-on)
Relates to ADR-019 (sensing-only UI), ADR-035 (live sensing UI accuracy)
Tracking issue none yet

1. Context

examples/three.js/demos/05-skinned-realtime.html (build stamp 2026-05-15-fps-tune) is the live MediaPipe → Mixamo retargeting + ESP32 CSI overlay demo. It currently ships a single, opinionated dark theme:

  • Body --bg: #050507 (near-black), --text: #d8c69a (warm beige).
  • Amber accents (--amber: #ffb840, --amber-hot: #ffe09f) on panels and controls.
  • Two full-screen overlays: a radial-vignette .overlay-frame and a 50%-opacity CRT-style .scanlines layer.
  • Three.js scene matches: scene.background = new THREE.Color(0x050507) and scene.fog = new THREE.FogExp2(0x050507, 0.06) (lines 269270).

The dark/amber CRT aesthetic is intentional for screen-recording and "command-centre" feel, but it has real failure modes:

  1. Daylight visibility — Demoing the live capture on a laptop in a sunlit room is unreadable; the dark background absorbs ambient glare and the amber-on-dark contrast disappears.
  2. Recording for embedded/print contexts — When the demo's screen is captured for documentation, blog posts, or HA blueprints, the dark theme bleeds into surrounding white content and looks heavy.
  3. Accessibility — A subset of users with light-sensitive retinas (the inverse of typical photophobia) report the high amber-on-near-black combination strains them; high-contrast light themes are easier.
  4. Operator pairing with a light-mode IDE — Many operators run a light-mode browser alongside a dark-mode IDE and want the demo to match the browser, not the IDE.

A toggle is the right answer because none of these reasons are universal — some sessions and some users want each mode.

1.1 What this ADR is not

  • Not a redesign. The amber accent stays; only the surface colours and overlays swap. The information density, panel layout, and three.js scene geometry are unchanged.
  • Not a multi-theme system. We add exactly two themes: the existing dark (default, unnamed) and adam-mode (light). Future themes would need a new ADR.
  • Not a backend / data-model change. Pure presentation.
  • Not yet propagated to demos 0104. Those follow-on after adam-mode lands on demo 05 and is validated.

2. Decision

Add a client-side theme toggle to 05-skinned-realtime.html that switches between the existing dark theme and a new light theme called adam-mode, driven by a data-theme="adam" attribute on <body> plus a sibling :root[data-theme="adam"] CSS block that re-defines the existing custom properties. A new toggle button in the existing #helpers panel switches between modes and persists the choice in localStorage under the key ruview.theme.

2.1 CSS — the colour swap

Add immediately after the existing :root { ... } block in <style>:

:root[data-theme="adam"] {
    --bg: #f6f2ea;
    --bg-panel: rgba(252, 250, 246, 0.92);
    --amber: #b8741a;        /* deeper amber, readable on cream */
    --amber-hot: #8a5612;    /* deepest amber for emphasis text */
    --cyan: #1a6f8a;         /* slate cyan */
    --magenta: #a8348a;      /* slate magenta */
    --text: #2a241c;         /* near-black warm */
    --text-mute: #7a6f5d;    /* warm grey */
    --green: #1f7a32;        /* forest green */
    --red: #b03a1a;          /* burnt sienna */
    --border: rgba(184, 116, 26, 0.28);
}

Every existing element already reads from these custom properties, so the swap is automatic for panels, text, borders, and bar fills. No per-element CSS rewrites required.

2.2 Overlay handling

The vignette and scanlines are dark-theme aesthetics. In adam-mode they would muddy the cream background. Two new rules:

:root[data-theme="adam"] .overlay-frame {
    background:
        radial-gradient(ellipse at center, transparent 70%, rgba(184,116,26,0.10) 100%),
        linear-gradient(180deg, rgba(184,116,26,0.06) 0%, transparent 18%, transparent 82%, rgba(184,116,26,0.08) 100%);
}
:root[data-theme="adam"] .scanlines {
    opacity: 0.15;
    mix-blend-mode: multiply;
}

The vignette is preserved but inverted in colour and lightened; scanlines drop to 15 % opacity and switch from overlay to multiply blend so they read as faint paper texture rather than CRT lines.

2.3 Three.js scene reactivity

Two scene colours are hard-coded at construction (lines 269270). Replace them with a function call that reads the current theme:

function themeSceneColors(theme) {
    return theme === 'adam'
        ? { bg: 0xf6f2ea, fogDensity: 0.025 }
        : { bg: 0x050507, fogDensity: 0.06 };
}
function applySceneTheme(theme) {
    const c = themeSceneColors(theme);
    scene.background = new THREE.Color(c.bg);
    scene.fog = new THREE.FogExp2(c.bg, c.fogDensity);
    renderer.setClearColor(c.bg, 1.0);
}

Called once after renderer is constructed, then again from the toggle handler.

scene.fog density drops in adam-mode because exponential fog on a light background reads as "haze" much more strongly than on dark — 0.06 → 0.025 keeps the falloff visible without losing the figure into the background.

2.4 UI toggle

Add to the #helpers panel (top of its labels list):

<label class="theme-toggle">
    <input type="checkbox" id="adam-mode-toggle">
    <span>adam-mode (light)</span>
    <span class="swatch" style="background: var(--amber)"></span>
</label>

Handler:

const THEME_KEY = 'ruview.theme';
const root = document.documentElement;
const toggle = document.getElementById('adam-mode-toggle');

function applyTheme(theme) {
    if (theme === 'adam') {
        root.setAttribute('data-theme', 'adam');
        toggle.checked = true;
    } else {
        root.removeAttribute('data-theme');
        toggle.checked = false;
    }
    applySceneTheme(theme);
    try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
}

const initialTheme = (() => {
    try { return localStorage.getItem(THEME_KEY) || 'dark'; }
    catch (_) { return 'dark'; }
})();
applyTheme(initialTheme);

toggle.addEventListener('change', e => {
    applyTheme(e.target.checked ? 'adam' : 'dark');
});

2.5 Why "adam-mode" as the codename

The user picked the name. It is a project-specific brand — distinct from the generic "light mode" terminology that other modes (--theme=high-contrast, --theme=print) may eventually need. Keeping a codename makes the toggle searchable in the codebase, the localStorage key portable across the demo set, and avoids ambiguity if dark itself is later renamed.

The string "adam" is the only literal value the data-theme attribute and the localStorage key ever take. "dark" is the implicit default (no attribute, no stored value).

2.6 Rejected alternatives

Alternative Rejected because
Use prefers-color-scheme: light only, no toggle Operators frequently want the opposite of their OS preference for screen-recording or daylight desk use. Auto-only frustrates the actual use case.
Ship two separate HTML files (05-…-dark.html, 05-…-light.html) Doubles maintenance for every future demo edit. No path to per-session toggle.
Build a full multi-theme system with a runtime registry Premature. Two themes don't need a registry; the data-theme="adam" attribute is the registry.
Use Tailwind / DaisyUI / a CSS framework Demos are intentionally stand-alone single-file HTML for portability. No build step exists; adding one for theming is wrong shape.
Adopt the cognitum-v0 / HOMECORE design tokens (--hc-* from examples/frontend/) That design system is dark-only by intent (ADR-131). adam-mode is the light counterpart needed in demo contexts, not HA dashboard contexts.
Make adam-mode the default Breaks the dark-aesthetic recording context this demo was originally built for. Default stays dark; toggle stays opt-in.

3. Consequences

3.1 Positive

  • Demo is usable in daylight, in printed documentation, on light-mode browsers, and by users who find the dark-amber combination fatiguing.
  • Toggle persists across reloads via localStorage — set once, sticks.
  • No structural change to information density, panel layout, or three.js scene geometry. Operators familiar with the dark theme can switch and still find every readout in the same place.
  • Implementation is contained — a single <style> block addition, a single button, a ~25-line JS handler, and a swap of two scene-construction lines.

3.2 Negative

  • Two themes to maintain. Any future colour change requires updating both :root blocks. Mitigated by keeping the existing custom-property names — adam-mode's values are the only edits.
  • The vignette + scanlines lose some of the CRT charm in adam-mode. Tradeoff accepted by design.
  • One additional localStorage slot consumed per origin (ruview.theme).
  • The amber accent in adam-mode (#b8741a) is visibly different from the dark-mode amber (#ffb840) — they share the same CSS variable name but a screenshot from each mode is not pixel-comparable. This is the correct call for accessibility (the bright amber is unreadable on cream) but does mean side-by-side comparisons need both screenshots labelled.

3.3 Risks

Risk Likelihood Mitigation
Future demo edits update one :root block and forget the other Medium A lint script in scripts/ could grep both blocks for matching key sets; documented as P2 follow-up.
localStorage blocked by privacy settings Low All accesses are wrapped in try/catch; falls back to dark.
Three.js fog density of 0.025 still washes out the model on adam-mode Low Empirically tuned during implementation; if it does, drop to 0.015 or remove fog entirely in adam-mode.
User on a high-DPI display sees scanlines as visible paper texture even at 15 % opacity Low If reported, drop to 8 % or hide scanlines entirely in adam-mode.

4. Implementation plan

Tiny scope — single file. No swarm needed.

  1. Add :root[data-theme="adam"] CSS block and the two overlay overrides.
  2. Refactor scene background + fog into the two helper functions themeSceneColors() and applySceneTheme().
  3. Add <label> markup and handler script.
  4. Verify in a browser at http://127.0.0.1:8765/examples/three.js/demos/05-skinned-realtime.html — toggle on, reload, confirm adam-mode persists; toggle off, reload, confirm dark persists.
  5. Smoke-screenshot both modes; commit.

Acceptance criteria:

  • Toggle checkbox visible in #helpers panel.
  • Clicking the toggle swaps colours within one frame.
  • Reload preserves last choice.
  • Three.js scene background follows the toggle (no dark frame visible behind a light HUD or vice-versa).
  • Existing dark-theme appearance is byte-identical when toggle is off.

5. Test plan

  • Manual visual check in two themes (no automated visual regression — demos aren't in the CI test loop today).
  • view-source confirms the new CSS block, the toggle markup, and the handler are present.
  • DevTools localStorage shows ruview.theme after a toggle.
  • Three.js inspector (or a console.log(scene.background.getHexString())) confirms scene colour swap.

6. Follow-on work (out of scope for this ADR)

  • Roll adam-mode into demos 0104. Each demo has its own <style> block; the same data-theme="adam" selector and the same JS handler can be copied.
  • Honor prefers-color-scheme: light on first load if localStorage has no stored choice. Trivial three-line addition.
  • Add a high-contrast theme for accessibility (separate ADR).
  • Lint script that asserts both :root blocks declare the same custom-property names.
  • ADR-019 — sensing-only UI mode (Gaussian splats viewer)
  • ADR-035 — live sensing UI accuracy norms (which this demo follows)
  • ADR-131 — HOMECORE / cognitum-v0 design tokens (dark-only, separate context)