Spaces:
Sleeping
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 localuseState, so two components callinguseTheme()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 aMutationObserverrather 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>Textreads fine in light mode (pale bg, dark text) but in dark mode<X>Softbecomes dark while<X>Textbecomes 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) orcolors.textOnBrand(active toggle segments: Flows/Impacts, Hierarchical/Geo, the VL-names button, tabs). - Soft fill (pale in light, dark in dark) → the
*-texttoken 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 (viapaint-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-rectis set tofill="white"inline for light mode; a CSSfill: 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, excludingsld-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 viasetAttribute. The NAD text rules are class-scoped (.nad-edge-infos, not a blankettext) 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 acs4g:theme { theme }message to the iframe on overlay-ready and whenever<html data-theme>flips (watched viaMutationObserver). - Backend (
expert_backend/services/overflow_overlay.py) — theinject_overlay()injector (run at serve time on every/results/pdf/*.htmlrequest) adds:- a
cs4g:thememessage handler that sets<html data-cs4g-theme>, - a dark stylesheet keyed off
html[data-cs4g-theme="dark"]: body /#sidebar/#stagesurfaces, the white graphviz canvas polygon repainted dark, and edge fixes scoped tog.edge: flow-value labels lightened, grey "null redispatch" edges → near-white, black "overload" edges → red (#ef4444). Node ellipses and their labels are never touched.
- a
Because the dark CSS + handler are injected server-side, a change to
overflow_overlay.pyrequires 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
- Add the token to
:rootintokens.css, and its dark value in the[data-theme="dark"]block. - Add the typed accessor to
tokens.ts. - Use
colors.<name>(inline styles) orvar(--…)(CSS). Never inline a hex — the gate (scripts/check_code_quality.py) fails on it. - If it's text on a solid bright fill, use
textOnBright/textOnBrandinstead of a*-texttoken (see §3).