# 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 `` + `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 ``, 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 > `` 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: Soft` + `color: Text` reads fine in light mode (pale bg, dark text) but in dark mode `Soft` becomes dark while `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 `