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).