Skip to content

Dark theme: full token migration + data-theme switch, contrast as a test (#548) - #549

Merged
richard-devbot merged 5 commits into
mainfrom
claude/dark-theme-501
Aug 2, 2026
Merged

Dark theme: full token migration + data-theme switch, contrast as a test (#548)#549
richard-devbot merged 5 commits into
mainfrom
claude/dark-theme-501

Conversation

@richard-devbot

@richard-devbot richard-devbot commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Closes #548 — epic #501's largest child. Three bisected commits.

1. The load-bearing half: a full token migration

A dark theme can only flip what routes through tokens, and ui/styles.js carried 77 distinct hardcoded colors across 269 occurrences. All migrated: the recurring families became semantic tokens (surfaces, hover, per-status -soft/-line/-deep tints, --on-strong for text on strong fills, --chart-dim, the topbar veils), exact-value matches reuse existing tokens, and near-identical one-off whites/tints are collapsed onto their family representative (channel deltas of a step or two — the light theme renders visibly unchanged, screenshot-verified). Zero literal colors remain outside :root.

color: #fff was deliberately split from background #fff: text on a strong fill is not a surface, and dark proves why (below).

2. Dark palette, with contrast as a test instead of an eyeball

One [data-theme="dark"] block overrides every color token — Digital-Twin-adjacent: deep navy surfaces, cool light text, brighter status hues. Two tests carry the weight, and both earned their keep before the PR opened:

  • Contrast is computed. WCAG ratios for 22 load-bearing pairs asserted in both themes. The dark palette passed first run; the light theme failed one pair — the Data-viz: event-density timeline + Run Workspace swimlane (#500 final children) #546 density bars sat at 1.76:1 on white, under the 3:1 graphical-object floor. Darkened. The new theme's test fixed a defect in the old one.
  • Completeness is enforced. A color token the dark block forgets keeps its light value — a white panel in a dark page. This failed its first run too: I had forgotten --band-very-high.

color-scheme flips with the theme, so selects and scrollbars follow natively.

3. The switch

Explicit choice in localStorage wins; with no choice the OS decides and keeps deciding — a mid-session OS flip is honoured until the human picks. Topbar toggle with a constant label and aria-pressed carrying state (the #432 lesson: a next-action label contradicts the attribute). A pre-paint head snippet applies the theme before the stylesheet renders — no white flash for a dark reader, asserted at domcontentloaded.

What the dark screenshot caught that the tests did not

The Approve button rendered near-white text on pastel green. In dark, every strong fill becomes a light pastel and --ink flips light — so text on them must flip dark (the Material dark-surface convention). Fixed, and the pair class pinned in the contrast test (--on-strong/--green, --on-strong/--ink) so it holds in both themes. That's the fourth time this wave a screenshot found what green assertions walked past.

Verification

Out of scope

The Studio's own palette (already themed, twin/classic). The remaining #501 children (toasts, mobile action bar, portal card).

Summary by CodeRabbit

  • New Features

    • Added a light/dark theme toggle for the dashboard.
    • Automatically follows the operating system’s theme preference when no saved choice exists.
    • Remembers the selected theme and applies it consistently across dashboard pages, charts, reports, and workflows.
    • Improved accessibility with theme-aware controls and state indicators.
  • Tests

    • Added coverage for theme persistence, preference detection, visual contrast, chart visibility, and accessibility.

richardsongunde and others added 3 commits August 2, 2026 10:44
77 distinct literals across 269 occurrences outside :root — a dark theme can
only flip what routes through tokens, so this is the load-bearing half of
the child. Pure refactor: light rendering is unchanged except that
near-identical one-off whites and tints are collapsed onto their family
representative (channel deltas of a step or two).

New tokens: surfaces (--surface-2/--hover/--line-soft), text
(--text-secondary/--on-strong — color:#fff on strong fills is deliberately
NOT a surface), per-status -soft/-line/-deep families for green/red/amber/
blue, --purple (previously fallback-only), --chart-dim, and the white
topbar veils. Exact-value matches reuse existing tokens (--panel,
--line-strong, --green, --amber, the --band-* ramp).

color-scheme: light declared so the dark block can flip native form
controls with it.

Zero literal colors remain outside :root; 491/491 dashboard/hub tests
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One [data-theme="dark"] block overrides every color token. Digital-Twin-
adjacent palette: deep navy surfaces, cool light text, brighter status hues
so strokes and pills clear their floors on dark panels. color-scheme flips
with it, so selects and scrollbars follow.

Two tests carry the weight:

Contrast is computed, not eyeballed — WCAG ratios for twenty load-bearing
token pairs (text on surfaces, muted copy, status text on its own tint,
strokes and fills as UI components) are asserted in BOTH themes. The dark
palette passed on the first run; the LIGHT theme failed one pair — the #546
density bars sat at 1.76:1 on white, below the 3:1 graphical-object floor.
Darkened. The new theme's test fixed a defect in the old one.

Completeness is enforced — a color token the dark block forgets silently
keeps its light value, which is how a white panel ends up in a dark page.
The test failed on its first run too: I had forgotten --band-very-high.

The switch: explicit choice in localStorage wins; with no choice the OS
decides and KEEPS deciding (a mid-session OS flip is honoured until the
human picks). Topbar toggle with a constant label and aria-pressed carrying
state — the #432 lesson, where a next-action label contradicted the
attribute. A pre-paint head snippet applies the theme before the stylesheet
renders, so a dark reader never gets a white flash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six journeys: OS-dark renders dark untouched; the toggle flips, persists and
survives reload; an explicit light choice beats an OS-dark preference; the
theme attribute is set before first paint (the head snippet, not the bundle);
the #546 charts and #544 glyph stay visible against dark panels; axe
serious/critical clean on command/approvals/live-feed in dark.

The dark screenshot caught what the first pair list missed: the Approve
button rendered near-white on pastel green. In dark, every strong fill is a
light pastel and --ink flips light, so text ON them flips dark — the
Material dark-surface convention. Two pairs added to the contrast test
(on-strong/green, on-strong/ink) so the class is pinned in both themes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Aug 2, 2026

Copy link
Copy Markdown

Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@richard-devbot, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f25e6c9-f15b-4a85-9db8-3c8562a11564

📥 Commits

Reviewing files that changed from the base of the PR and between cad7e9a and 5ab885c.

📒 Files selected for processing (5)
  • src/observability/dashboard/ui/index.js
  • src/observability/dashboard/ui/lib.js
  • src/observability/dashboard/ui/styles.js
  • tests/browser/dashboard-548-dark.test.js
  • tests/hub-dark-theme-548.test.js
📝 Walkthrough

Walkthrough

The dashboard adds persisted light and dark themes, system preference detection, an accessible theme toggle, dark-theme CSS tokens across dashboard surfaces, and unit and browser coverage for contrast, persistence, initialization, visibility, and accessibility.

Changes

Hub dark theme

Layer / File(s) Summary
Theme tokens and surface migration
src/observability/dashboard/ui/styles.js
Semantic light and dark tokens now control dashboard surfaces, states, charts, overlays, and status treatments.
Theme initialization and toggling
src/observability/dashboard/ui/lib.js, src/observability/dashboard/ui/index.js, src/observability/dashboard/ui/client.js
Dashboard startup and navigation initialize the theme. The toggle updates data-theme, aria-pressed, and stored preferences while tolerating storage errors.
Theme behavior and accessibility validation
tests/hub-dark-theme-548.test.js, tests/browser/dashboard-548-dark.test.js
Tests validate tokens, contrast, system preference handling, persistence, early initialization, visual states, markup, and accessibility.
Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: richardsongunde

Poem

A rabbit toggles dark at night,
Then stores the choice just right.
Soft panels glow, charts remain clear,
Accessible states are always near.
The hub now hops from shade to light.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: dark-theme token migration, theme switching, and contrast testing.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/dark-theme-501

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Hub dark theme via full token migration, data-theme switch, and contrast tests

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Migrate Hub UI colors to semantic CSS tokens to enable theme flipping.
• Add full dark-token override block and color-scheme switching for native controls.
• Implement persisted theme toggle with OS-following default and no-flash prepaint apply.
• Add CI tests for token completeness, WCAG contrast floors, and browser-level switch behavior.
Diagram

graph TD
  A(["Reader / OS preference"]) --> B["index.js: prepaint theme script"] --> C["<html data-theme>"] --> D["styles.js: tokens + dark overrides"]
  C --> E["lib.js: initHubTheme/toggleHubTheme"] --> F[("localStorage: rstack-hub-theme")]
  G["client.js: boot"] --> E
  H["tests: unit + browser"] --> D
  H --> E

  subgraph Legend
    direction LR
    _usr(["User/OS"]) ~~~ _file["JS/CSS module"] ~~~ _store[("Storage")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Pure CSS `@media (prefers-color-scheme: dark)` (no JS toggle)
  • ➕ Simpler implementation; no persistence logic
  • ➕ Less JS executed on load
  • ➖ No explicit user override (common product requirement)
  • ➖ Harder to guarantee 'no flash' when combined with persisted choice
2. Toggle by swapping a theme stylesheet (light.css/dark.css)
  • ➕ Clear separation of themes; potentially smaller per-theme CSS
  • ➕ Can reduce risk of missing overrides if files are complete
  • ➖ Harder to keep semantics aligned across files (token drift)
  • ➖ More moving parts for bundling/caching and prepaint selection
3. CSS variables only, but allow partial overrides (no completeness test)
  • ➕ Less test maintenance; easier to add one-off tokens quickly
  • ➖ High risk of 'white panel in dark page' regressions
  • ➖ Theme quality becomes manual/screenshot-dependent over time

Recommendation: Keep the PR’s approach: a single data-theme switch with full tokenization and a dark override block, backed by automated contrast + completeness tests. Compared to media-query-only or stylesheet swapping, this design best supports an explicit user choice, prevents partial-theme regressions, and makes future palette tweaks safer by failing in CI with actionable messages.

Files changed (6) +648 / -182

Enhancement (3) +38 / -0
client.jsInitialize Hub theme during dashboard boot +1/-0

Initialize Hub theme during dashboard boot

• Adds a call to 'initHubTheme()' during client initialization so the persisted/OS-driven theme is applied when the app starts.

src/observability/dashboard/ui/client.js

index.jsAdd topbar theme toggle and prepaint theme attribute script +2/-0

Add topbar theme toggle and prepaint theme attribute script

• Adds a theme toggle button with 'aria-pressed' semantics to the topbar. Injects a small inline head script that sets 'data-theme="dark"' based on stored choice or OS preference before CSS paints to avoid a light flash.

src/observability/dashboard/ui/index.js

lib.jsImplement persisted theme switch that follows OS until user chooses +35/-0

Implement persisted theme switch that follows OS until user chooses

• Introduces theme management helpers ('hubThemeChoice', 'applyHubTheme', 'initHubTheme', 'toggleHubTheme'). Explicit localStorage choice wins; otherwise the app tracks OS changes via 'matchMedia' listeners, and keeps the toggle’s constant label while updating 'aria-pressed'.

src/observability/dashboard/ui/lib.js

Refactor (1) +271 / -182
styles.jsTokenize all UI colors and add complete dark-theme token overrides +271/-182

Tokenize all UI colors and add complete dark-theme token overrides

• Moves remaining hardcoded colors to semantic CSS tokens and replaces component literals with token references. Adds a '[data-theme="dark"]' block overriding every color token, flips 'color-scheme', and introduces/adjusts tokens such as '--on-strong' and '--chart-dim' to preserve readability across themes.

src/observability/dashboard/ui/styles.js

Tests (2) +339 / -0
dashboard-548-dark.test.jsAdd browser tests for dark theme switch, persistence, and no-flash load +146/-0

Add browser tests for dark theme switch, persistence, and no-flash load

• Creates end-to-end tests covering OS-driven dark default, toggle persistence across reloads, explicit choice precedence over OS preference, and early application of 'data-theme' at 'domcontentloaded'. Also asserts key chart/glyph visibility on dark panels and optionally runs axe checks (excluding known contrast debt).

tests/browser/dashboard-548-dark.test.js

hub-dark-theme-548.test.jsAdd unit tests for token override completeness and WCAG contrast floors +193/-0

Add unit tests for token override completeness and WCAG contrast floors

• Parses token definitions from 'styles.js' and computes WCAG contrast for a curated set of load-bearing token pairs in both light and dark themes. Enforces that every color-valued ':root' token is overridden in the dark block and verifies 'color-scheme' flips; includes a harness to unit-test switch behavior against OS changes and persisted choice.

tests/hub-dark-theme-548.test.js

@qodo-code-review

qodo-code-review Bot commented Aug 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (4)

Context used
✅ Compliance rules (platform): 300 rules
✅ Skills: 17 invoked
  code-review-pr
  claude-api
  documentation-writing
  pptx
  docx
  performance-monitoring
  security-compliance
  cso
  plan-eng-review
  design-review
  prompt-engineering
  mcp-builder
  qa-testing
  code-patterns
  xlsx
  security-owasp
  testing-qa

Grey Divider


Action required

1. Dark --line-strong contrast too low 📜 Skill insight ☑ Accessibility
Description
In the dark theme, --line-strong (#3d4e70) against --panel (#111a2d) is ~2.09:1, below the
3:1 WCAG AA requirement for UI components/boundaries. This reduces the perceptibility of component
borders and violates the contrast compliance rule.
Code

src/observability/dashboard/ui/styles.js[R99-103]

+  --panel: #111a2d;
+  --soft: #0d1626;
+  --line: #243250;
+  --line-strong: #3d4e70;
+  --line-soft: #1e2a42;
Relevance

●● Moderate

No historical evidence found that border contrast is enforced at 3:1; contrast-related review
history not found.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1401077 requires UI components to meet at least 3:1 contrast. The dark theme sets
--panel to #111a2d and --line-strong to #3d4e70, and --line-strong is used as a border
color on elements with --panel backgrounds (e.g., .policy-action), giving a computed contrast of
~2.09:1 (<3:1). The added contrast test also encodes a lower threshold (1.3) for this pair,
indicating the PR currently permits sub-3:1 border contrast.

src/observability/dashboard/ui/styles.js[96-103]
src/observability/dashboard/ui/styles.js[1882-1903]
tests/hub-dark-theme-548.test.js[64-91]
Skill: design-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The dark theme defines `--line-strong: #3d4e70` while `--panel` is `#111a2d`, which yields an estimated WCAG contrast ratio of ~2.09:1—below the required 3:1 for UI components/boundaries.

## Issue Context
`--line-strong` is used for visible borders on `--panel` backgrounds (e.g., `.policy-action` uses `border: 1px solid var(--line-strong)` with `background: var(--panel)`), so insufficient contrast makes UI boundaries harder to perceive.

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[96-104]
- src/observability/dashboard/ui/styles.js[1882-1903]
- tests/hub-dark-theme-548.test.js[64-91]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Theme not guaranteed prepaint ✓ Resolved 🐞 Bug ☼ Reliability
Description
The theme-setting snippet runs near the end of the document body, so it cannot guarantee
data-theme="dark" is applied before first paint (and therefore cannot reliably prevent an initial
light render under load). This contradicts the intended “pre-paint head snippet” behavior asserted
by the new browser test description.
Code

src/observability/dashboard/ui/index.js[81]

+<script>try { var hubTheme = localStorage.getItem('rstack-hub-theme'); if (hubTheme === 'dark' || (!hubTheme && matchMedia('(prefers-color-scheme: dark)').matches)) document.documentElement.setAttribute('data-theme', 'dark'); } catch (err) {}</script>
Relevance

●● Moderate

No historical suggestions found about moving theme init snippet to <head> for prepaint/flash
prevention.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
dashboardHtml() emits <style>${styles}</style> in the head, but the theme-setting script is
injected after the body markup, so it executes later in parsing order than the head/style. The
accompanying browser test comments explicitly attribute early theme application to a “pre-paint head
snippet,” which the HTML currently does not do.

src/observability/dashboard/ui/index.js[10-18]
src/observability/dashboard/ui/index.js[68-85]
tests/browser/dashboard-548-dark.test.js[80-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The “pre-paint” theme application script is currently emitted late in the HTML (after the body markup), which cannot guarantee that the `data-theme` attribute is set before the browser’s first paint.

### Issue Context
To reliably prevent a first-paint flash, the attribute must be set as early as possible—ideally in `<head>` before the stylesheet is applied.

### Fix Focus Areas
- src/observability/dashboard/ui/index.js[10-18]
- src/observability/dashboard/ui/index.js[81-85]

### Suggested change
1. Move the inline theme script from the end of the body into the `<head>` (before `<style>${styles}</style>`).
2. Keep the script defensive (try/catch) as it is now.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. btn-theme touch target too small ✓ Resolved 📜 Skill insight ☑ Accessibility
Description
The newly added theme toggle button uses .tb-chip, which does not provide a 44px minimum touch
target. This can make the new control difficult to use on touch devices and fails the minimum
target-size requirement.
Code

src/observability/dashboard/ui/index.js[57]

+        <button class="tb-chip" id="btn-theme" type="button" aria-pressed="false" title="Toggle dark theme" onclick="toggleHubTheme()">Dark</button>
Relevance

●● Moderate

44px touch-target feedback only partially accepted previously (path-copy, density buckets). Unclear
if they’ll fix this button.

PR-#539
PR-#547

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399597 requires interactive touch targets to be at least 44px in both dimensions.
The PR adds the new #btn-theme button, and the .tb-chip styling it uses has no 44px minimum
sizing (only padding/font), so the minimum is not assured.

src/observability/dashboard/ui/index.js[57-57]
src/observability/dashboard/ui/styles.js[318-326]
Skill: design-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The newly added `#btn-theme` button is styled with `.tb-chip`, which lacks a `min-height`/`min-width` (or equivalent padding) to guarantee a 44px touch target.

## Issue Context
This PR introduces a new interactive control (`#btn-theme`). Ensuring its minimum target size improves accessibility and mobile usability.

## Fix Focus Areas
- src/observability/dashboard/ui/index.js[57-57]
- src/observability/dashboard/ui/styles.js[318-326]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Theme choice not robust ✓ Resolved 🐞 Bug ≡ Correctness
Description
initHubTheme() treats any truthy localStorage value as an explicit choice and uses
localStorage as the only indicator of “explicit choice,” so invalid stored values (e.g. corrupted)
disable OS-following and storage access failures allow later OS flips to override an in-session
toggle. This can cause the theme to unexpectedly stick to light or change mid-session even after the
user toggles.
Code

src/observability/dashboard/ui/lib.js[R148-172]

+function hubThemeChoice() {
+  try { return localStorage.getItem(HUB_THEME_KEY); } catch (err) { return null; }
+}
+
+function applyHubTheme(dark) {
+  if (dark) document.documentElement.setAttribute('data-theme', 'dark');
+  else document.documentElement.removeAttribute('data-theme');
+  var button = document.getElementById('btn-theme');
+  if (button) button.setAttribute('aria-pressed', dark ? 'true' : 'false');
+}
+
+function initHubTheme() {
+  var choice = hubThemeChoice();
+  applyHubTheme(choice ? choice === 'dark' : !!(HUB_THEME_MEDIA && HUB_THEME_MEDIA.matches));
+  if (HUB_THEME_MEDIA && HUB_THEME_MEDIA.addEventListener) {
+    HUB_THEME_MEDIA.addEventListener('change', function(event) {
+      if (!hubThemeChoice()) applyHubTheme(event.matches);
+    });
+  }
+}
+
+function toggleHubTheme() {
+  var dark = document.documentElement.getAttribute('data-theme') !== 'dark';
+  try { localStorage.setItem(HUB_THEME_KEY, dark ? 'dark' : 'light'); } catch (err) { /* private mode: unpersisted but applied */ }
+  applyHubTheme(dark);
Relevance

●● Moderate

No close historical precedent for hardening stored theme values/localStorage failure semantics; team
sometimes hardens inputs but inconsistently.

PR-#528

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code path choice ? choice === 'dark' : osPref treats any non-empty stored string as an
explicit choice, and the OS-change handler only runs when hubThemeChoice() is falsy. If storage
access throws, hubThemeChoice() always returns null, so the OS-change handler can override a user
toggle that failed to persist; if storage contains an unexpected string, it is treated as an
explicit non-dark choice (light) and blocks OS-following.

src/observability/dashboard/ui/lib.js[145-173]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Theme selection uses `localStorage.getItem()` as the sole source of explicit choice and treats any non-empty string as an explicit choice. Additionally, when `localStorage` is unavailable (throws), a user toggle is not tracked in memory, so OS `prefers-color-scheme` changes can override the user’s in-session selection.

### Issue Context
- `hubThemeChoice()` returns the raw stored value (or null on exception).
- `initHubTheme()` interprets any truthy value as an explicit choice.
- The media-query change handler only applies OS flips when `!hubThemeChoice()`.
- `toggleHubTheme()` catches `localStorage.setItem()` failures but does not persist an in-memory explicit choice.

### Fix Focus Areas
- src/observability/dashboard/ui/lib.js[145-173]

### Suggested change
1. Validate stored values: treat only `'dark'` and `'light'` as valid; otherwise treat as `null` (optionally clear the key).
2. Add an in-memory fallback for explicit choice when storage is unavailable:
  - Maintain a `HUB_THEME_CHOICE_MEM` variable.
  - `hubThemeChoice()` should return the validated localStorage value when accessible, otherwise return `HUB_THEME_CHOICE_MEM`.
  - `toggleHubTheme()` should set `HUB_THEME_CHOICE_MEM` before/after attempting `setItem()`.
3. Ensure the OS change handler gates on the validated choice (so a real explicit choice—stored or in-memory—prevents OS overrides).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

5. No addListener fallback ✓ Resolved 🐞 Bug ☼ Reliability
Description
OS mid-session theme flips are only subscribed via MediaQueryList.addEventListener('change', ...),
so environments that expose only the legacy addListener API will never react to OS scheme changes
while unchosen. This breaks the stated “OS decides and keeps deciding” behavior on those browsers.
Code

src/observability/dashboard/ui/lib.js[R162-166]

+  if (HUB_THEME_MEDIA && HUB_THEME_MEDIA.addEventListener) {
+    HUB_THEME_MEDIA.addEventListener('change', function(event) {
+      if (!hubThemeChoice()) applyHubTheme(event.matches);
+    });
+  }
Relevance

●● Moderate

No prior review history found requiring MediaQueryList addListener fallback; compatibility
expectations unclear.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new listener registration is guarded only by HUB_THEME_MEDIA.addEventListener, with no
fallback to addListener, so OS flips will be ignored in legacy implementations.

src/observability/dashboard/ui/lib.js[159-167]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`initHubTheme()` only registers the OS preference change handler using `MediaQueryList.addEventListener`. Some browsers expose only the legacy `addListener` API, so OS flips won’t be observed.

### Issue Context
`HUB_THEME_MEDIA` is a MediaQueryList from `matchMedia('(prefers-color-scheme: dark)')`.

### Fix Focus Areas
- src/observability/dashboard/ui/lib.js[159-167]

### Suggested change
Use:
- `HUB_THEME_MEDIA.addEventListener('change', handler)` when available, else
- `HUB_THEME_MEDIA.addListener(handler)` when available.

(Optionally store the handler reference if you ever plan to remove it.)

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Single-letter m variable 📜 Skill insight ⚙ Maintainability
Description
The new test introduces a single-letter variable m, which is not descriptive and reduces
readability/maintainability. This violates the descriptive naming requirement for JS variables.
Code

tests/hub-dark-theme-548.test.js[24]

+  for (const m of block.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) out[m[1]] = m[2].trim();
Relevance

● Weak

Reviewer suggestions to rename non-descriptive test variables/helpers were commonly rejected;
single-letter iterator likely ignored.

PR-#511
PR-#524
PR-#534

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399635 requires descriptive variable names and flags single-letter/ambiguous
identifiers. The new test uses m as an iterator variable for matches, which is ambiguous.

tests/hub-dark-theme-548.test.js[22-26]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A single-letter variable name (`m`) is used in new JavaScript test code, which is not descriptive.

## Issue Context
Descriptive variable names improve maintainability and make test intent clearer.

## Fix Focus Areas
- tests/hub-dark-theme-548.test.js[22-26]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Non-scale padding: 13px 📜 Skill insight ⚙ Maintainability
Description
A modified CSS rule keeps padding: 13px, which does not conform to the required 4px/8px spacing
scale and can lead to inconsistent UI spacing. This should be normalized to the project spacing
scale (e.g., 12px or 16px).
Code

src/observability/dashboard/ui/styles.js[R1204-1209]

.project-card, .agent-group, .approval-card, .alert-card, .layer-card {
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 13px;
-  background: #fff;
+  background: var(--panel);
}
Relevance

● Weak

Spacing-scale normalization requests were repeatedly rejected (e.g., 14px/10px/13px-style values
kept).

PR-#520
PR-#531
PR-#547

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400413 requires spacing values to follow a consistent 4px/8px scale; the modified
rule sets padding: 13px, which is not on that scale.

src/observability/dashboard/ui/styles.js[1204-1209]
Skill: design-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The modified CSS rule uses `padding: 13px`, which violates the spacing scale requirement (4px/8px base).

## Issue Context
This rule was touched in the PR (token migration), so it is an opportunity to normalize spacing values to the project scale.

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[1204-1209]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
8. .density-fill transitions height 📜 Skill insight ➹ Performance
Description
The updated .density-fill rule transitions height, which violates the requirement to only
animate transform and opacity. Animating layout-affecting properties can cause avoidable reflow
and jank.
Code

src/observability/dashboard/ui/styles.js[1280]

+.density-fill { width: 100%; border-radius: 3px 3px 0 0; background: var(--chart-dim); transition: background var(--motion-fast) var(--motion-ease), height var(--motion-base) var(--motion-ease); }
Relevance

● Weak

“Only transform/opacity transitions” compliance suggestions were repeatedly rejected; team keeps
non-compliant transitions.

PR-#514
PR-#542

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1399699 restricts CSS animations/transitions to transform and opacity. The
changed .density-fill rule explicitly transitions height, which is outside the allowed set.

src/observability/dashboard/ui/styles.js[1280-1280]
Skill: design-review

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/observability/dashboard/ui/styles.js` animates `height` in `.density-fill`, but compliance requires animations/transitions to be limited to `transform` and `opacity`.

## Issue Context
The density bars currently use `transition: ... height ...`, which can trigger layout/reflow on updates.

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[1280-1280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/observability/dashboard/ui/index.js
Comment on lines +99 to +103
--panel: #111a2d;
--soft: #0d1626;
--line: #243250;
--line-strong: #3d4e70;
--line-soft: #1e2a42;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

5. Dark --line-strong contrast too low 📜 Skill insight ☑ Accessibility

In the dark theme, --line-strong (#3d4e70) against --panel (#111a2d) is ~2.09:1, below the
3:1 WCAG AA requirement for UI components/boundaries. This reduces the perceptibility of component
borders and violates the contrast compliance rule.
Agent Prompt
## Issue description
The dark theme defines `--line-strong: #3d4e70` while `--panel` is `#111a2d`, which yields an estimated WCAG contrast ratio of ~2.09:1—below the required 3:1 for UI components/boundaries.

## Issue Context
`--line-strong` is used for visible borders on `--panel` backgrounds (e.g., `.policy-action` uses `border: 1px solid var(--line-strong)` with `background: var(--panel)`), so insufficient contrast makes UI boundaries harder to perceive.

## Fix Focus Areas
- src/observability/dashboard/ui/styles.js[96-104]
- src/observability/dashboard/ui/styles.js[1882-1903]
- tests/hub-dark-theme-548.test.js[64-91]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/observability/dashboard/ui/index.js Outdated
Comment thread src/observability/dashboard/ui/lib.js
Comment thread src/observability/dashboard/ui/lib.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/observability/dashboard/ui/index.js`:
- Line 81: Move the inline theme initialization script from the end of the body
into the document head, placing it immediately after the
<style>${styles}</style> block. Preserve its existing localStorage,
system-preference, and error-handling behavior so the data-theme attribute is
set before any body markup can be parsed or painted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ed270664-22df-4db1-ae06-0f964de44fcf

📥 Commits

Reviewing files that changed from the base of the PR and between 347ecc9 and cad7e9a.

📒 Files selected for processing (6)
  • src/observability/dashboard/ui/client.js
  • src/observability/dashboard/ui/index.js
  • src/observability/dashboard/ui/lib.js
  • src/observability/dashboard/ui/styles.js
  • tests/browser/dashboard-548-dark.test.js
  • tests/hub-dark-theme-548.test.js

Comment thread src/observability/dashboard/ui/index.js Outdated
CodeRabbit (Major), Qodo, and a CI failure all converged on the same thing:
the "pre-paint" theme snippet sat at the END of body — after the entire
shell markup — where it prevents exactly nothing. Moved into <head> after
the stylesheet, which is what "before first paint" actually requires. The
no-flash journey is rewritten (its reload-inside-evaluate contraption died
on context destruction in CI) to assert on the SERVED HTML that the snippet
precedes </head>, plus the attribute at domcontentloaded; a unit pins the
placement structurally.

Qodo, all real:
- Any truthy localStorage garbage counted as an explicit choice, silently
  disabling OS-following forever. Only the literal 'dark'/'light' are
  choices now, in both the snippet and hubThemeChoice().
- A toggle that failed to persist (private mode) was overridden by the next
  OS flip. An in-memory session choice now backs the stored one. The first
  draft of the test for this forgot to break storage and passed through
  localStorage — rewritten to genuinely throw, and mutation-checked
  (removing the in-memory choice fails it).
- Legacy engines exposing only MediaQueryList.addListener never followed OS
  flips. Fallback wired and unit-tested against an addEventListener-less
  media object.
- The topbar chips are ~28px tall: hit areas expand via the #539 ::after
  pattern (zero layout change), for all three chips, not just the toggle.

Declined with measurement (reply on the PR): raising dark --line-strong to
3:1 — dark's border contrast (2.09:1) already exceeds what light ships
(1.46:1), and control identification does not rest on the border alone.
A both-themes border redesign belongs with the standing contrast debt.

2010/2010 core, 73/73 browser, lint 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@richard-devbot

Copy link
Copy Markdown
Owner Author

Fixed in 22defcb — the placement finding was dead right, and CI agreed with you

CodeRabbit (Major) + Qodo: the "pre-paint" snippet was not pre-paint

Correct, and embarrassing in the useful way: I emitted it before artifactRenderScript believing that block sat in <head> — it sits at the end of body, after the entire shell markup, where the snippet prevents exactly nothing. The browser-regression job failed on the very journey that claimed to prove it (the test's reload-inside-evaluate contraption died on context destruction — a bad test guarding a bad implementation).

Moved into <head> directly after the stylesheet. The journey is rewritten to assert on the served HTML that the snippet precedes </head> plus the attribute at domcontentloaded, and a unit pins the placement structurally — because placement is the feature.

Qodo: corrupted stored values disable OS-following

Real. Any truthy garbage counted as a choice and pinned the theme to light forever. Only the literal 'dark'/'light' are choices now — in hubThemeChoice() and in the head snippet, which had the same bug.

Qodo: a failed persist let the OS override a fresh toggle

Real (private mode). An in-memory session choice now backs storage. Worth confessing: my first test for this was vacuous — it forgot to break storage and passed straight through localStorage. Rewritten so storage genuinely throws, and mutation-checked: removing the in-memory choice fails it.

Qodo: no addListener fallback

Wired, and unit-tested against a media object that has no addEventListener at all.

Qodo: #btn-theme touch target

Fixed with the #539 ::after hit-area pattern — applied to all three topbar chips, since Alerts and Approvals had the same ~28px height all along.

Declined, with the measurement: dark --line-strong to 3:1

Dark's border contrast is 2.09:1 — which already exceeds the light theme's shipped 1.46:1 (#d0d5dd on white). Holding the dark theme to a bar the light theme has never met would make dark the only theme with near-black borders, and control identification here doesn't rest on the border alone (selects have labels and chevrons, buttons have text on filled backgrounds). A both-themes border redesign is real work that belongs with the standing contrast debt, not a regression introduced by this PR. If you want, I'll file it there.


Verification: 2010/2010 core, 73/73 browser (including the rewritten no-flash journey), lint 0 errors.

The logo <img> replaces the R brand mark, so its ink-fill/on-strong rules go
with it — the badge keeps that pairing and the contrast pair stays, comment
updated. Everything else auto-merged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@richard-devbot
richard-devbot merged commit 3fdf753 into main Aug 2, 2026
10 checks passed
richard-devbot pushed a commit that referenced this pull request Aug 2, 2026
… counts

While preparing 2.3.1, PR #549 (dark theme, epic #501 now 5/8) landed on
origin/main from another session — merged cleanly (no conflicts; the
brand-mark logo change from the previous session's work survived intact).
Updates the 2.3.1 changelog entry to describe it honestly rather than
calling this a behavior-free patch, and refreshes README's test-count
badge and CLI example output (2010 tests, up from a stale 1454 reference
that had drifted for several releases).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dark theme for the Hub: token migration + data-theme switch (#501 child 5)

2 participants