@@ -160,6 +160,86 @@ Instead, place `HeaderSection` and `BodySection` in their own files and import t
160160- Use ` useCallback ` for functions referenced in ` useEffect ` dependency arrays
161161- ** Never suppress** ` react-hooks/exhaustive-deps ` — fix the dependency array properly
162162
163+ ## Theming (light + dark)
164+
165+ The app ships a light and a dark theme. ` <html data-theme> ` is the ** single source of
166+ truth** : it is always present and always ` light ` or ` dark ` .
167+
168+ ### The two systems, and why tokens are split
169+
170+ Colour reaches the DOM two ways, and both must be satisfied:
171+
172+ 1 . ** daisyUI semantic classes** (` bg-base-100 ` , ` text-base-content ` , ` btn-primary ` ).
173+ These follow ` data-theme ` for free.
174+ 2 . ** ` src/theme/colors.ts ` ** , consumed by ** inline ` style ` props** in ~ 35 files (the
175+ ReactFlow visualiser, timeline, explore rail, namespace cards). ` data-theme ` cannot
176+ reach these, because JavaScript bakes the value into the DOM.
177+
178+ So ` colors.ts ` leaves come in two flavours, and which one a token gets is ** not** an
179+ aesthetic choice:
180+
181+ - ** Neutrals** (surfaces, text, borders, washes, the ` ink ` ramp) are ` var(--calm-…) `
182+ strings, defined per theme in ` src/theme/theme.css ` . Inline styles resolve them
183+ against the active ` data-theme ` . No React state, no provider.
184+ - ** Chromatic** values (node-type hues, risk/status, brand accents) stay ** hex** ,
185+ because they are alpha-concatenated (`` `${color}20` `` ) — ` var(--x)20 ` is not a
186+ colour — and because ReactFlow serialises some into SVG presentation attributes.
187+
188+ ### Adding a colour
189+
190+ - Neutral? Add ` --calm-* ` to ** both** blocks in ` theme.css ` and point ` colors.ts ` at it.
191+ The ` [data-theme='light'] ` value must equal the hex it replaces — light mode must not
192+ move.
193+ - Chromatic and used as ** text or an icon** ? It needs a separate ` …Text ` token
194+ (` brand.accentText ` , ` redesign.primaryText ` , ` resourceTypes.*.accentText ` ). ` #007dff `
195+ and ` #2563EB ` cannot reach 4.5:1 on * any* dark background, not even black, so the
196+ text role has to lighten while the fill/border/stripe role does not. Check every new
197+ dark tint against its accent with a contrast checker.
198+
199+ ### Three traps that have already bitten
200+
201+ 1 . ** SVG presentation attributes ignore ` var() ` .** ReactFlow writes ` <Background color> `
202+ and ` <MiniMap nodeColor/maskColor> ` straight into ` fill ` /` stroke ` attributes. Don't
203+ pass theme colours through those props — omit them and style
204+ ` .react-flow__background circle ` , ` .react-flow__minimap-mask ` etc. in ` index.css ` ,
205+ where a CSS * property* beats the attribute.
206+ 2 . ** ` reactflow/dist/style.css ` is bundled after ` index.css ` .** It defines
207+ ` .react-flow__controls-button ` , ` .react-flow__minimap ` and ` .react-flow__attribution a ` ,
208+ so an unqualified override here silently loses the source-order tie. Scope ours under
209+ ` .react-flow ` to win on specificity.
210+ 3 . ** Components with their own theme system need telling.** Monaco (` JsonRenderer ` )
211+ paints its own colours. Use ` useResolvedTheme() ` from ` src/theme/useTheme.ts ` , which
212+ observes the ` data-theme ` attribute — never call ` useTheme() ` a second time, as each
213+ call holds its own state and the copies drift apart when the toggle is pressed.
214+ Monaco's theme API takes literal hex and cannot read ` --calm-* ` , so the dark editor
215+ chrome is duplicated in ` src/theme/monaco-theme.ts ` ; ` monaco-theme.test.ts ` parses
216+ theme.css and fails if the copies drift. It registers the theme from ` beforeMount `
217+ unconditionally — an editor first mounted in light must still be able to switch.
218+
219+ ### Toggle and persistence
220+
221+ ` useTheme(storage?) ` owns the attribute. It reads ` localStorage['calm-theme'] `
222+ (` light ` | ` dark ` | absent → follow the OS via ` prefers-color-scheme ` , tracked live).
223+ An inline script in ` index.html ` applies the same resolution before the bundle loads,
224+ so there is no flash; do ** not** rely on daisyUI's ` --prefersdark ` , which emits a media
225+ query for daisyUI's own variables only and would leave every ` --calm-* ` token unresolved.
226+
227+ Pass a ` createMemoryStorage() ` from ` src/test-support/memory-storage.ts ` in tests.
228+
229+ ### Testing colour
230+
231+ jsdom's inline-style resolver keeps ` var(...) ` and ` color-mix(...) ` verbatim but
232+ normalises hex to ` rgb(...) ` . So assert ** by reference**
233+ (` toHaveStyle({ backgroundColor: colors.redesign.tintBg }) ` ), never against a literal
234+ hex — a literal will break the moment the token becomes a var.
235+
236+ Beware the ` mockViewport ` /` mockMobileViewport ` helpers: they return ` matches: <isMobile> `
237+ for ** every** query, not just the width one. Since the Navbar also asks for
238+ ` (prefers-color-scheme: dark) ` , a ` mockMobileViewport(true) ` test silently resolves the
239+ theme to dark and stamps ` data-theme="dark" ` on the document. Harmless for the
240+ assertions that exist today, but if you write a theme-sensitive assertion inside a
241+ mobile test, match on the query string in the mock.
242+
163243## Responsive Design
164244
165245The CALM Hub UI is ** mobile responsive** — it must be usable on phones as well as
0 commit comments