Skip to content

Commit e7d692b

Browse files
authored
[CALM Hub UI] Dark mode (#2797)
* feat(calm-hub-ui): add a dark theme * feat(calm-hub-ui): add a white navbar lockup for the dark theme * feat(calm-hub-ui): match the Monaco editor to the dark theme
1 parent bccd882 commit e7d692b

62 files changed

Lines changed: 1497 additions & 249 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Loading

calm-hub-ui/AGENTS.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

165245
The CALM Hub UI is **mobile responsive** — it must be usable on phones as well as

calm-hub-ui/index.html

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,30 @@
11
<!doctype html>
2-
<html lang="en" data-theme="light">
2+
<html lang="en">
33
<head>
44
<meta charset="UTF-8" />
5+
<script>
6+
// Resolve the theme before first paint, so the page never flashes light
7+
// before the bundle mounts. `data-theme` must always be present and
8+
// always be light or dark: the --calm-* tokens in theme.css are keyed
9+
// solely off this attribute, and would resolve to nothing without it.
10+
// Kept in sync with src/theme/useTheme.ts.
11+
(function () {
12+
var theme;
13+
try {
14+
theme = localStorage.getItem('calm-theme');
15+
} catch (e) {
16+
theme = null;
17+
}
18+
if (theme !== 'light' && theme !== 'dark') {
19+
theme =
20+
window.matchMedia &&
21+
window.matchMedia('(prefers-color-scheme: dark)').matches
22+
? 'dark'
23+
: 'light';
24+
}
25+
document.documentElement.setAttribute('data-theme', theme);
26+
})();
27+
</script>
528
<link rel="icon" href="/brand/Icon/2025_CALM_Icon.svg" />
629
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
730
<link rel="preconnect" href="https://fonts.googleapis.com" />

calm-hub-ui/src/admin/AdminPage.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ describe('AdminPage (active-state consistency, problem #8)', () => {
9898
it('uses the redesign blue (not navy/neutral) on the active desktop sidebar link', () => {
9999
renderLayout(adminState, '/admin/namespaces');
100100
const activeLink = screen.getByRole('link', { name: /namespaces/i });
101-
expect(activeLink).toHaveStyle({ color: REDESIGN_BLUE_RGB });
101+
// The label is text, so it takes the text-role token, which lightens on dark.
102+
expect(activeLink).toHaveStyle({ color: colors.redesign.primaryText });
102103
// Guard against regressing to the global navy brand or a neutral pill.
103104
expect(colors.redesign.primary).toBe('#2563EB');
104105
expect(activeLink).not.toHaveClass('menu-active');
@@ -107,14 +108,15 @@ describe('AdminPage (active-state consistency, problem #8)', () => {
107108
it('does not apply the active blue to a resting desktop sidebar link', () => {
108109
renderLayout(adminState, '/admin/namespaces');
109110
const restingLink = screen.getByRole('link', { name: /entitlements/i });
110-
expect(restingLink).not.toHaveStyle({ color: REDESIGN_BLUE_RGB });
111+
expect(restingLink).not.toHaveStyle({ color: colors.redesign.primaryText });
111112
});
112113

113114
it('uses the redesign blue on the active mobile tab', () => {
114115
restore = mockMobileViewport(true);
115116
renderLayout(adminState, '/admin/namespaces');
116117
const activeTab = screen.getByRole('link', { name: /namespaces/i });
117-
expect(activeTab).toHaveStyle({ color: REDESIGN_BLUE_RGB });
118+
expect(activeTab).toHaveStyle({ color: colors.redesign.primaryText });
119+
// The underline is a border, not text, so it keeps the chromatic blue.
118120
expect(activeTab).toHaveStyle({ borderBottomColor: REDESIGN_BLUE_RGB });
119121
});
120122
});

calm-hub-ui/src/admin/AdminPage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,24 @@ import { colors } from '../theme/colors.js';
1010
// global navy brand (`--color-primary` = #000063) / a neutral pill, so they are
1111
// NOT used for the active accent here.
1212
const ACTIVE = colors.redesign.primary;
13+
/** The same blue in a text role; it has to lighten on dark to stay legible. */
14+
const ACTIVE_TEXT = colors.redesign.primaryText;
1315
const ACTIVE_TINT = colors.redesign.tintBg;
1416

1517
function sidebarNavClass({ isActive }: { isActive: boolean }) {
1618
return isActive ? 'font-semibold' : '';
1719
}
1820

1921
function sidebarNavStyle({ isActive }: { isActive: boolean }) {
20-
return isActive ? { backgroundColor: ACTIVE_TINT, color: ACTIVE } : undefined;
22+
return isActive ? { backgroundColor: ACTIVE_TINT, color: ACTIVE_TEXT } : undefined;
2123
}
2224

2325
function mobileTabClass({ isActive }: { isActive: boolean }) {
2426
return `flex-1 text-center py-2 text-sm border-b-2 transition-colors ${isActive ? 'font-semibold' : 'border-transparent hover:border-base-content/20'}`;
2527
}
2628

2729
function mobileTabStyle({ isActive }: { isActive: boolean }) {
28-
return isActive ? { borderBottomColor: ACTIVE, color: ACTIVE } : undefined;
30+
return isActive ? { borderBottomColor: ACTIVE, color: ACTIVE_TEXT } : undefined;
2931
}
3032

3133
export function AdminPage() {

calm-hub-ui/src/components/navbar/Navbar.test.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { describe, it, expect } from 'vitest';
55
import { Navbar } from './Navbar.js';
66
import { UserAccessContext } from '../../admin/context/UserAccessContext.js';
77
import { CurrentUserAccessState } from '../../admin/hooks/useCurrentUserAccess.js';
8+
import { createMemoryStorage } from '../../test-support/memory-storage.js';
9+
import { THEME_STORAGE_KEY } from '../../theme/useTheme.js';
810

911
function makeState(overrides: Partial<CurrentUserAccessState>): CurrentUserAccessState {
1012
return {
@@ -17,11 +19,11 @@ function makeState(overrides: Partial<CurrentUserAccessState>): CurrentUserAcces
1719
};
1820
}
1921

20-
function renderNavbar(state: CurrentUserAccessState) {
22+
function renderNavbar(state: CurrentUserAccessState, storage: Storage = createMemoryStorage()) {
2123
return render(
2224
<UserAccessContext.Provider value={state}>
2325
<MemoryRouter>
24-
<Navbar />
26+
<Navbar storage={storage} />
2527
</MemoryRouter>
2628
</UserAccessContext.Provider>
2729
);
@@ -91,3 +93,52 @@ describe('Navbar Admin link visibility', () => {
9193
expect(screen.queryByRole('link', { name: /^admin$/i })).not.toBeInTheDocument();
9294
});
9395
});
96+
97+
describe('Navbar theme', () => {
98+
it('renders the theme toggle', () => {
99+
renderNavbar(makeState({}));
100+
expect(screen.getByRole('button', { name: /switch to .* theme/i })).toBeInTheDocument();
101+
});
102+
103+
it('shows the two-tone lockup on light', () => {
104+
renderNavbar(makeState({}));
105+
expect(screen.getByAltText('CALM Logo')).toHaveAttribute(
106+
'src',
107+
'/brand/Horizontal/2025_CALM_Horizontal_Navbar_Logo.svg'
108+
);
109+
});
110+
111+
it('swaps to the white lockup on dark, which the navy one would not survive', () => {
112+
const storage = createMemoryStorage();
113+
storage.setItem(THEME_STORAGE_KEY, 'dark');
114+
115+
renderNavbar(makeState({}), storage);
116+
117+
expect(screen.getByAltText('CALM Logo')).toHaveAttribute(
118+
'src',
119+
'/brand/Horizontal/2025_CALM_Horizontal_Navbar_Logo_WHT.svg'
120+
);
121+
});
122+
123+
it('uses a navbar lockup — never the full one with the tagline — in either theme', () => {
124+
const dark = createMemoryStorage();
125+
dark.setItem(THEME_STORAGE_KEY, 'dark');
126+
127+
for (const storage of [createMemoryStorage(), dark]) {
128+
const { unmount } = renderNavbar(makeState({}), storage);
129+
expect(screen.getByAltText('CALM Logo').getAttribute('src')).toContain('Navbar_Logo');
130+
unmount();
131+
}
132+
});
133+
134+
it('swaps the logo when the toggle is pressed, keeping it in step with the theme', async () => {
135+
renderNavbar(makeState({}));
136+
expect(screen.getByAltText('CALM Logo').getAttribute('src')).not.toContain('WHT');
137+
138+
await userEvent.click(screen.getByRole('button', { name: /switch to dark theme/i }));
139+
140+
// Guards against Navbar and ThemeToggle each holding their own useTheme state.
141+
expect(screen.getByAltText('CALM Logo').getAttribute('src')).toContain('WHT');
142+
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
143+
});
144+
});

calm-hub-ui/src/components/navbar/Navbar.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,34 @@ import { useContext, useState } from 'react';
33
import { Link, NavLink } from 'react-router-dom';
44
import { IoMenuOutline } from 'react-icons/io5';
55
import { GlobalSearchBar } from './GlobalSearchBar.js';
6+
import { ThemeToggle } from './ThemeToggle.js';
7+
import { useTheme } from '../../theme/useTheme.js';
68
import { UserAccessContext } from '../../admin/context/UserAccessContext.js';
79

10+
/**
11+
* The navy-and-blue lockup is unreadable on a dark base, so dark gets a white one.
12+
* Both are navbar lockups — icon + CALM wordmark, no tagline — and share an aspect
13+
* ratio, so the logo does not resize when the theme is toggled.
14+
*/
15+
const LOGO_SRC = {
16+
light: '/brand/Horizontal/2025_CALM_Horizontal_Navbar_Logo.svg',
17+
dark: '/brand/Horizontal/2025_CALM_Horizontal_Navbar_Logo_WHT.svg',
18+
} as const;
19+
820
function menuNavClass({ isActive }: { isActive: boolean }) {
921
return `w-full flex items-center px-4 py-3 text-left hover:bg-base-200 active:bg-base-200${isActive ? ' bg-base-200 font-semibold' : ''}`;
1022
}
1123

12-
export function Navbar() {
24+
interface NavbarProps {
25+
/** Injected for tests; see the storage note in calm-hub-ui/AGENTS.md. */
26+
storage?: Storage;
27+
}
28+
29+
export function Navbar({ storage }: NavbarProps = {}) {
1330
const { loading, isGlobalAdmin, grants } = useContext(UserAccessContext);
1431
const showAdminLink = !loading && (isGlobalAdmin || grants.some((g) => g.permission === 'admin'));
1532
const [isMenuOpen, setIsMenuOpen] = useState(false);
33+
const { theme, toggleTheme } = useTheme(storage);
1634

1735
const closeMenu = () => setIsMenuOpen(false);
1836

@@ -29,17 +47,14 @@ export function Navbar() {
2947
</div>
3048
<div className="navbar-center absolute left-1/2 -translate-x-1/2">
3149
<Link to="/" className="btn btn-ghost min-w-0 px-1" aria-label="CALM Hub home">
32-
<img
33-
src="/brand/Horizontal/2025_CALM_Horizontal_Navbar_Logo.svg"
34-
alt="CALM Logo"
35-
className="h-10 logo"
36-
/>
50+
<img src={LOGO_SRC[theme]} alt="CALM Logo" className="h-10 logo" />
3751
</Link>
3852
</div>
3953
<div className="navbar-end flex items-center gap-1 min-w-0 shrink-0">
4054
{/* Portal target for page-level actions (e.g. the diagram's
4155
view-options menu), always visible across breakpoints. */}
4256
<div id="navbar-actions" className="flex items-center" />
57+
<ThemeToggle theme={theme} onToggle={toggleTheme} />
4358
<div className="hidden lg:flex items-center">
4459
<GlobalSearchBar />
4560
</div>

0 commit comments

Comments
 (0)