Co-Study4Grid / docs /features /dark-mode.md
github-actions[bot]
Deploy 7688ef1
13d4e44
|
Raw
History Blame Contribute Delete
7.92 kB

Dark Mode

Co-Study4Grid ships a light/dark theme toggle. The whole UI re-themes from a single source of truth — the design-token CSS custom properties in frontend/src/styles/tokens.css — so a theme switch is one DOM attribute flip, not a per-component restyle.

This document is the contract: where the theme lives, how it propagates, what the diagram / overflow-viewer special-cases are, and which tests guard each piece.


1. How the theme is selected

Layer What it does
Pre-mount script (frontend/index.html) Reads localStorage['cs4g-theme'] (or the OS prefers-color-scheme) before React mounts and sets <html data-theme="…"> + colorScheme. Avoids a flash of light theme on a dark reload.
useTheme hook (frontend/src/hooks/useTheme.ts) Owns the React-side theme state. Applies data-theme + color-scheme to <html>, persists to localStorage, exposes theme / toggleTheme / setTheme.
resolveInitialTheme() Pure resolver shared in spirit with the inline script: persisted value → OS preference → light. Tolerates a missing localStorage / matchMedia.
Header toggle (components/Header.tsx) Sun/moon button (data-testid="header-theme-toggle") calling toggleTheme. in light mode, in dark.

The selected theme is recorded as the interaction event theme_toggled { theme } (declared in the InteractionType union, mirrored in specConformance.test.ts and scripts/check_standalone_parity.py).

Note on multiple useTheme() instances. The hook keeps local useState, so two components calling useTheme() do not share a re-render. The single source of truth across the app is the <html data-theme> attribute. Consumers that need to react to a theme change without owning the toggle (e.g. useOverflowIframe) observe that attribute with a MutationObserver rather than reading React state.


2. Tokens are the single source of truth

frontend/src/styles/tokens.css defines every colour as a CSS custom property under :root (light) with a [data-theme="dark"] override block. tokens.ts exposes typed var(--…) accessors for inline style objects. The code-quality gate enforces zero hex literals outside tokens.css / tokens.ts — so adding a dark variant means editing only the token files, and the whole UI follows.

Dark mode flips the chrome tokens (surfaces, borders, text, brand, state colours, accent). It deliberately does not flip the domain-signal tokens (--signal-*, the action-pin palette) because those encode grid semantics and are rendered onto a diagram backdrop.

Tokens added for dark mode

Token Why
--color-diagram-surface Backdrop behind the pypowsybl NAD/SLD (white in light, near-black #0c0f13 in dark). The SVG is transparent, so the container is the diagram background.
--color-diagram-veil Semi-opaque veil painted over a diagram while a new one loads (translucent white → translucent near-black).
--color-text-on-bright Dark ink that stays dark in both themes — for text sitting on a solid bright fill (warning-yellow badge, the load-shedding "Re-simulate" button). The *-text tokens are tuned for the matching *-soft background and flip light in dark mode, so they can't be used on a bright fill.

3. The "soft-background" trap

The recurring dark-mode bug class: a control styled background: <X>Soft

  • color: <X>Text reads fine in light mode (pale bg, dark text) but in dark mode <X>Soft becomes dark while <X>Text becomes light — so a control with a solid bright fill (colors.warning, colors.brand) ends up with low-contrast text. Two fixes are used:
  • Solid bright fill (always bright in both themes) → text = colors.textOnBright (badge) or colors.textOnBrand (active toggle segments: Flows/Impacts, Hierarchical/Geo, the VL-names button, tabs).
  • Soft fill (pale in light, dark in dark) → the *-text token is correct, no change needed.

When adding a toggle/badge: if the active background is a solid brand / state colour, set the text to textOnBrand / textOnBright, never to colors.surface or a *-text token.


4. Diagram (NAD/SLD) legibility

pypowsybl bakes voltage-coded line colours into the SVG via an inline <style> and renders on a transparent canvas. Dark-mode handling lives in frontend/src/App.css under [data-theme="dark"]:

  • Canvas.svg-container { background: var(--color-diagram-surface) } goes near-black. Lines keep their semantic colours (they read on dark).
  • NAD flow values (.nad-edge-infos text) — recoloured light. The white "halo" pypowsybl strokes under each value (via paint-order) is repainted in the backdrop colour so light text isn't fringed/fuzzy.
  • NAD VL labels — HTML <div>s inside <foreignObject> (not <text>). Given light text + a dark chip.
  • Action-overview dim rect — the .nad-overview-dim-rect is set to fill="white" inline for light mode; a CSS fill: var(--color-diagram-surface) rule (a CSS property beats the inline presentation attribute) makes it dim toward near-black, so it no longer leaves a grey square over the dark canvas.
  • SLD labels ([data-testid="sld-overlay"] text) — recoloured light, excluding sld-delta-text-* so the flow-delta positive/negative/neutral colouring is preserved.

Pin labels are deliberately untouched. Action-overview pins are <text> with their own dark-on-white glyph fills written via setAttribute. The NAD text rules are class-scoped (.nad-edge-infos, not a blanket text) precisely so pin labels stay readable.


5. Overflow Analysis viewer (iframe)

The Overflow Analysis tab embeds a third-party HTML graph viewer in an iframe served from the backend. Theming it requires both sides:

  • Frontend (hooks/useOverflowIframe.ts) — posts a cs4g:theme { theme } message to the iframe on overlay-ready and whenever <html data-theme> flips (watched via MutationObserver).
  • Backend (expert_backend/services/overflow_overlay.py) — the inject_overlay() injector (run at serve time on every /results/pdf/*.html request) adds:
    • a cs4g:theme message handler that sets <html data-cs4g-theme>,
    • a dark stylesheet keyed off html[data-cs4g-theme="dark"]: body / #sidebar / #stage surfaces, the white graphviz canvas polygon repainted dark, and edge fixes scoped to g.edge: flow-value labels lightened, grey "null redispatch" edges → near-white, black "overload" edges → red (#ef4444). Node ellipses and their labels are never touched.

Because the dark CSS + handler are injected server-side, a change to overflow_overlay.py requires a backend restart to take effect — the long-running uvicorn process serves the old injection until then.


6. Tests

Spec Test
Theme resolution / persistence / <html> apply / toggle / theme_toggled log frontend/src/hooks/useTheme.test.ts
Header toggle glyph + aria-label + document flip frontend/src/components/Header.test.tsx (dark-mode toggle)
Overflow viewer: cs4g:theme handler, dark chrome surfaces, canvas repaint, edge label/colour retargets, g.edge scoping expert_backend/tests/test_overflow_overlay.py (TestDarkTheme)

7. Adding a dark variant for a new colour

  1. Add the token to :root in tokens.css, and its dark value in the [data-theme="dark"] block.
  2. Add the typed accessor to tokens.ts.
  3. Use colors.<name> (inline styles) or var(--…) (CSS). Never inline a hex — the gate (scripts/check_code_quality.py) fails on it.
  4. If it's text on a solid bright fill, use textOnBright / textOnBrand instead of a *-text token (see §3).