From 0af853c7a834a569763f9826035d43bf7e3e4ffd Mon Sep 17 00:00:00 2001 From: Babissimo Date: Mon, 14 Sep 2026 17:51:51 +0100 Subject: [PATCH] feat(dashboard): offer a dark theme on dash and admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console has only ever had the light palette, which makes it the odd one out next to the map — the map has themed since it borrowed dash's whole vocabulary, and the data explorer has answered the OS preference all along. Someone working across the three at night meets a white page on two of them. The mechanism is the map's, with the two themes the other way round. The map carries dark on its base selector and light behind the attribute, because dark is its default and the default must never be the theme that flashes on first paint; light is the default here, so light stays on the bare selector and dark is what the attribute buys. The palette is the map's dark column wholesale rather than a new one: the brand guide already records the two as counterparts, and the estate's own §10 says drift is its main failure mode. The control has three states rather than the map's two. `system` stamps no attribute and lets a prefers-color-scheme block answer, so the OS preference is honoured with no JavaScript and keeps working when the OS changes its mind mid-session; light and dark override it. That also means the data explorer, which has behaved that way since it was written, can gain a switch without regressing anyone. Three things the diff does not explain on its own: - Recharts paints from props, not from the cascade, so `var()` cannot reach it. The chart chrome moves into a second, hand-kept copy of the tokens, and a test asserts the two agree. AnomalyPage was already passing `stroke="var(--border)"` into SVG presentation attributes, where CSS does not resolve var() at all — those axes were drawing at the initial value, and now draw. - Dark's accent is a bright sky blue, so the white that inks the primary button and the sidebar mark drops to about 1.8:1 there. Both now take `--accent-ink`, near-black on dark. The map still inks `#fff` on the same fill; that is its bug to fix, not one to copy. - The pre-paint script is a file, not an inline tag. Every page vhost sends `script-src 'self'`, which does not cover inline code, while the dev server sends no CSP — an inline version works in every local check and silently never runs once deployed. A colour written into a page is pinned to one theme, and no token block covers a chart's props or an inline style, so the pages get their own sweep. It catches both shapes this change had: an Area whose stroke follows the palette while its fill does not, and a `color: "white"` sitting on a .btn-primary, where an inline style outranks the class rule and quietly undoes --accent-ink. Leaflet needs three rules rather than one. Its tiles take a filter, since OSM does not theme; its container takes the sunk tier; and its own attribution and zoom controls are hardcoded white and #333 in leaflet.css, so on a dark card they read as two bright chips stuck to the corners until they are told otherwise. The switch is three icons rather than three words: Feather's sun, monitor and moon, ordered light to dark with the neutral between them, hand-inlined in the same house style as the sidebar's set (24-unit box, no fill, 2-unit round-capped stroke in currentColor) since no icon package is installed and three glyphs do not justify adding one. Drawing in currentColor is what lets the active segment tint itself from the same rule that sets its ink. A glyph names nothing on its own, so each button carries an aria-label and a matching title, and the svg is aria-hidden so the name is not read twice. Choosing role=radiogroup over three independent toggles is what obliges the keyboard half: the set is announced as one control with three options, so it is one tab stop rather than three (a roving tabIndex on whichever is checked) and the arrows move within it, selecting as they go. Home and End reach the ends. Everything else passes through — without preventDefault the arrows scroll the dropdown while the selection moves underneath. Verified in a browser across all three settings: system follows the OS and follows it changing, an explicit choice overrides it and survives a reload, and the tooltip, grid and axes take their themed values. Co-Authored-By: Claude Opus 5 --- dashboard/index.html | 4 + dashboard/public/theme-boot.js | 18 ++ dashboard/src/App.css | 214 +++++++++++++++++- dashboard/src/components/ErrorBoundary.tsx | 2 +- dashboard/src/components/Header.tsx | 127 +++++++++++ dashboard/src/context/ThemeContext.tsx | 118 ++++++++++ dashboard/src/main.tsx | 20 +- dashboard/src/pages/admin/AnalyticsPage.tsx | 49 ++-- dashboard/src/pages/admin/ConfigPage.tsx | 2 +- dashboard/src/pages/admin/InvitesPage.tsx | 4 +- .../src/pages/admin/NetworkHealthPage.tsx | 23 +- dashboard/src/pages/admin/StoragePage.tsx | 8 +- dashboard/src/pages/user/AnomalyPage.tsx | 88 +++++-- dashboard/src/pages/user/ContributionPage.tsx | 18 +- .../src/pages/user/KnowledgeBasePage.tsx | 2 +- dashboard/src/pages/user/LeaderboardPage.tsx | 8 +- dashboard/src/pages/user/NodeDetailPage.tsx | 18 +- dashboard/src/pages/user/OnboardingPage.tsx | 4 +- dashboard/src/pages/user/OverviewPage.tsx | 21 +- .../src/pages/user/RFEnvironmentPage.tsx | 22 +- dashboard/src/raw-import.d.ts | 21 ++ dashboard/src/test/appearanceSwitch.test.tsx | 167 ++++++++++++++ dashboard/src/test/chartTheme.test.ts | 123 ++++++++++ dashboard/src/test/theme.test.tsx | 214 ++++++++++++++++++ dashboard/src/test/themeTokens.test.ts | 83 +++++++ dashboard/src/utils/chartTheme.ts | 65 ++++++ dashboard/vite.config.js | 3 + 27 files changed, 1305 insertions(+), 141 deletions(-) create mode 100644 dashboard/public/theme-boot.js create mode 100644 dashboard/src/context/ThemeContext.tsx create mode 100644 dashboard/src/raw-import.d.ts create mode 100644 dashboard/src/test/appearanceSwitch.test.tsx create mode 100644 dashboard/src/test/chartTheme.test.ts create mode 100644 dashboard/src/test/theme.test.tsx create mode 100644 dashboard/src/test/themeTokens.test.ts create mode 100644 dashboard/src/utils/chartTheme.ts diff --git a/dashboard/index.html b/dashboard/index.html index 2d1cb7a9..5b39a583 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -4,6 +4,10 @@ Retina Dashboard + + because every page vhost sends + `script-src 'self'` (deploy/nginx/snippets/security-headers-page.conf), which + does not cover inline code — and the dev server sends no CSP at all, so an + inline version works in every local check and silently never runs once + deployed. That is the same trap data-explorer/ vendors its libraries to avoid. + + Only an explicit choice is stamped: `system` is the default, and App.css's own + prefers-color-scheme block answers it with no flash to avoid. The key and the + attribute are ThemeContext.tsx's; this file has to agree with it. */ +try { + var t = localStorage.getItem("retina.theme"); + if (t === "dark" || t === "light") document.documentElement.setAttribute("data-theme", t); +} catch (e) { + /* private browsing — fall through to the OS preference */ +} diff --git a/dashboard/src/App.css b/dashboard/src/App.css index 72abeefe..1590c26c 100644 --- a/dashboard/src/App.css +++ b/dashboard/src/App.css @@ -1,10 +1,20 @@ /* ── CSS Variables ────────────────────────────────────────────────────────── */ + +/* Light is the base, and stays on the bare selector: the guide makes light the + norm for anything that explains or configures, and the theme attribute is + stamped from JavaScript, so whichever theme depends on it is the one that can + flash the other on first paint. The map inverts this for the same reason — + dark is its default, so dark is what its base selector carries. */ :root { --bg-primary: #f1f5f9; --bg-secondary: #ffffff; --bg-card: #ffffff; --bg-card-hover: #f8fafc; --bg-input: #f8fafc; + /* The guide's third surface tier. A light canvas is already darker than a + card, so a recessed region is made by letting it show through and this is + the canvas value; dark inverts the ramp and needs a colour of its own. */ + --bg-sunk: #f1f5f9; --text-primary: #0f172a; --text-secondary: #475569; --text-muted: #94a3b8; @@ -19,10 +29,101 @@ --warning-light: rgba(245, 158, 11, 0.10); --error: #ef4444; --error-light: rgba(239, 68, 68, 0.10); + /* The wash for a badge that carries no status — a private node is a normal + node with its location withheld. Named to sit in the same family as the + semantic washes above, because it is spent in the same place. */ + --neutral-light: rgba(15, 23, 42, 0.06); + /* Ink for the things that sit ON the accent rather than beside it. It is a + token and not a literal white because the dark accent is a bright sky blue: + white on it is about 1.8:1, which is why the sidebar mark and the primary + button take a near-black there instead. */ + --accent-ink: #ffffff; + --shadow: rgba(15, 23, 42, 0.14); + /* How far the basemap is pushed back. OSM tiles do not theme, so on dark they + are filtered rather than swapped — the same treatment the map surface gives + its own tiles. */ + --tile-filter: none; + /* Leaflet draws its own attribution and zoom chrome, and hardcodes both + light. Same token name and values as the map surface. */ + --attribution-bg: rgba(255, 255, 255, 0.85); --sidebar-width: 250px; --header-height: 56px; --radius: 8px; --radius-sm: 4px; + color-scheme: light; +} + +/* ── Dark ───────────────────────────────────────────────────────────────── + The map's dark palette (frontend/src/map-surface.css), which the guide + already records as this ramp's counterpart rather than a sixth palette. + + Two selectors carry it because the control has three states. The media query + answers `system`, which stamps no attribute at all, so the OS preference is + honoured with no JavaScript and keeps working when the OS changes its mind + mid-session; `:not([data-theme="light"])` is what lets an explicit light + choice override a dark OS. The attribute rule answers an explicit dark. + + The two blocks must stay identical, which themeTokens.test.ts asserts: CSS has no + way to share a declaration block across a media query boundary, so the guard + against them drifting is a test rather than the stylesheet. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg-primary: #0d1b2a; + --bg-secondary: #132240; + --bg-card: #132240; + --bg-card-hover: #1a2b4d; + --bg-input: rgba(15, 30, 55, 0.9); + --bg-sunk: #0f2035; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --border: rgba(100, 180, 255, 0.14); + --border-light: rgba(100, 180, 255, 0.28); + --accent: #38bdf8; + --accent-hover: #7dd3fc; + --accent-light: rgba(56, 189, 248, 0.16); + --success: #4ade80; + --success-light: rgba(74, 222, 128, 0.15); + --warning: #fbbf24; + --warning-light: rgba(251, 191, 36, 0.15); + --error: #f43f5e; + --error-light: rgba(244, 63, 94, 0.15); + --neutral-light: rgba(226, 232, 240, 0.10); + --accent-ink: #082f49; + --shadow: rgba(0, 0, 0, 0.5); + --tile-filter: saturate(0.85) brightness(0.72); + --attribution-bg: rgba(13, 27, 42, 0.85); + color-scheme: dark; + } +} + +:root[data-theme="dark"] { + --bg-primary: #0d1b2a; + --bg-secondary: #132240; + --bg-card: #132240; + --bg-card-hover: #1a2b4d; + --bg-input: rgba(15, 30, 55, 0.9); + --bg-sunk: #0f2035; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --border: rgba(100, 180, 255, 0.14); + --border-light: rgba(100, 180, 255, 0.28); + --accent: #38bdf8; + --accent-hover: #7dd3fc; + --accent-light: rgba(56, 189, 248, 0.16); + --success: #4ade80; + --success-light: rgba(74, 222, 128, 0.15); + --warning: #fbbf24; + --warning-light: rgba(251, 191, 36, 0.15); + --error: #f43f5e; + --error-light: rgba(244, 63, 94, 0.15); + --neutral-light: rgba(226, 232, 240, 0.10); + --accent-ink: #082f49; + --shadow: rgba(0, 0, 0, 0.5); + --tile-filter: saturate(0.85) brightness(0.72); + --attribution-bg: rgba(13, 27, 42, 0.85); + color-scheme: dark; } /* ── Reset ───────────────────────────────────────────────────────────────── */ @@ -103,6 +204,7 @@ a:hover { width: 64px; height: 64px; background: var(--accent); + color: var(--accent-ink); border-radius: 16px; display: inline-flex; align-items: center; @@ -174,6 +276,7 @@ a:hover { width: 32px; height: 32px; background: var(--accent); + color: var(--accent-ink); border-radius: 8px; display: flex; align-items: center; @@ -430,7 +533,7 @@ td { } tr:hover td { - background: rgba(0, 0, 0, 0.02); + background: var(--bg-card-hover); } /* ── Status Badge ────────────────────────────────────────────────────────── */ @@ -478,7 +581,7 @@ tr:hover td { /* Neutral on purpose: a private node is a normal node with its location withheld, not a warning state. */ .badge.private { - background: rgba(15, 23, 42, 0.06); + background: var(--neutral-light); color: var(--text-secondary); } .badge.private::before { @@ -600,7 +703,7 @@ tr:hover td { .btn-primary { background: var(--accent); - color: white; + color: var(--accent-ink); } .btn-primary:hover { background: var(--accent-hover); @@ -756,7 +859,7 @@ tr:hover td { padding: 4px; min-width: 160px; z-index: 100; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 16px var(--shadow); } .user-dropdown button { @@ -777,16 +880,115 @@ tr:hover td { color: var(--text-primary); } +/* ── Leaflet ─────────────────────────────────────────────────────────────── */ +.leaflet-tile { + filter: var(--tile-filter); +} + +/* The gap between tiles and the panes Leaflet paints itself, which would + otherwise stay the library's own near-white. */ +.leaflet-container { + background: var(--bg-sunk); +} + +/* Leaflet's own controls, which its stylesheet pins to white and #333 — on a + dark card they read as two bright chips stuck to the corners. Qualified by + .leaflet-container to outrank leaflet.css rather than to fight it with + !important: that stylesheet is a in index.html, and this file's load + order relative to it differs between the dev server and the built bundle. */ +.leaflet-container .leaflet-control-attribution { + background: var(--attribution-bg); + color: var(--text-muted); +} + +.leaflet-container .leaflet-control-attribution a { + color: var(--accent); +} + +.leaflet-container .leaflet-bar { + border-color: var(--border); +} + +.leaflet-container .leaflet-bar a { + background: var(--bg-card); + color: var(--text-primary); + border-bottom-color: var(--border); +} + +.leaflet-container .leaflet-bar a:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +/* ── Appearance switch ───────────────────────────────────────────────────── */ +.dropdown-group { + padding: 8px 12px 10px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + margin: 4px 0; +} + +/* The guide's micro-label: small, uppercase, letter-spaced, muted. */ +.dropdown-label { + display: block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 6px; +} + +.theme-switch { + display: flex; + gap: 2px; + padding: 2px; + background: var(--bg-sunk); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +/* Outranks the block layout .user-dropdown button sets, since these three sit + side by side and hold a glyph rather than a line of text. */ +.theme-switch button { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + width: auto; + padding: 6px; + border-radius: 3px; +} + +.theme-switch button svg { + width: 15px; + height: 15px; + display: block; +} + +/* The glyph is drawn in currentColor, so setting the ink is what tints it. */ +.theme-switch button.active { + background: var(--accent-light); + color: var(--accent); +} + +/* The hover rule above is a plain descendant selector of equal weight, so + without this an active segment loses its wash on the way to being clicked. */ +.theme-switch button.active:hover { + background: var(--accent-light); + color: var(--accent); +} + /* ── Code / Config blocks ────────────────────────────────────────────────── */ .config-block { - background: #f1f5f9; + background: var(--bg-sunk); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; - color: #334155; + color: var(--text-secondary); overflow-x: auto; white-space: pre-wrap; word-break: break-all; diff --git a/dashboard/src/components/ErrorBoundary.tsx b/dashboard/src/components/ErrorBoundary.tsx index bc732efc..6fb8c9ea 100644 --- a/dashboard/src/components/ErrorBoundary.tsx +++ b/dashboard/src/components/ErrorBoundary.tsx @@ -25,7 +25,7 @@ export default class ErrorBoundary extends Component { render() { if (this.state.hasError) { return ( -
+

Something went wrong

Please refresh the page. If the problem persists, contact support.

+ {/* Clicks are stopped here because the whole .header-user toggles + the menu: without it the menu shuts on the first press, and + comparing the three settings means reopening it each time. */} +
e.stopPropagation()}> + Appearance +
+ {APPEARANCE.map(({ value, label, icon }, i) => ( + + ))} +
+
)} diff --git a/dashboard/src/context/ThemeContext.tsx b/dashboard/src/context/ThemeContext.tsx new file mode 100644 index 00000000..7aae3c65 --- /dev/null +++ b/dashboard/src/context/ThemeContext.tsx @@ -0,0 +1,118 @@ +import { createContext, useContext, useEffect, useMemo, useState } from "react"; + +/** + * Which palette the console is drawn with, and the switch for it. + * + * The mechanism is the map's (frontend/src/components/map/useMapTheme.tsx), + * with the two themes the other way round. The map carries dark on its base + * selector and light behind the attribute, because dark is its default and the + * default must never be the theme that flashes. Here light is the default, so + * light is what the bare selector holds and dark is what the attribute buys. + * + * The third state is the reason this is not simply the map's boolean. `system` + * stamps no attribute at all and lets the `prefers-color-scheme` block in + * App.css answer, which means the OS preference is honoured with no JavaScript + * and keeps working when the OS changes its mind mid-session. Stamping a + * resolved value instead would pin the console to whatever the OS happened to + * be at load. + * + * `resolved` exists for the one thing CSS cannot reach: Recharts paints to a + * canvas from props, so useChartTheme needs to be told which palette is on + * screen rather than inheriting it. + */ + +export type ThemePreference = "system" | "light" | "dark"; +export type Theme = "light" | "dark"; + +export const THEME_KEY = "retina.theme"; +export const DARK_QUERY = "(prefers-color-scheme: dark)"; + +const PREFERENCES: readonly ThemePreference[] = ["system", "light", "dark"]; + +function isPreference(v: unknown): v is ThemePreference { + return typeof v === "string" && (PREFERENCES as readonly string[]).includes(v); +} + +/** The stored preference, or `system` for anything this version cannot read — + * a value from a future one, a hand-edited key, or no storage at all. */ +function storedPreference(): ThemePreference { + try { + const raw = window.localStorage.getItem(THEME_KEY); + return isPreference(raw) ? raw : "system"; + } catch { + return "system"; + } +} + +/** Absent in jsdom and in any non-browser render, so every caller has to cope + * with there being no media query to ask. */ +function darkQuery(): MediaQueryList | null { + return typeof window.matchMedia === "function" ? window.matchMedia(DARK_QUERY) : null; +} + +interface ThemeValue { + preference: ThemePreference; + /** The theme actually on screen, with `system` already resolved. */ + resolved: Theme; + setPreference: (p: ThemePreference) => void; +} + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [preference, setPreferenceState] = useState(storedPreference); + const [systemDark, setSystemDark] = useState(() => darkQuery()?.matches ?? false); + + useEffect(() => { + const mql = darkQuery(); + if (!mql) return; + const onChange = (e: MediaQueryListEvent) => setSystemDark(e.matches); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + + const resolved: Theme = preference === "system" ? (systemDark ? "dark" : "light") : preference; + + useEffect(() => { + const root = document.documentElement; + if (preference === "system") root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", preference); + }, [preference]); + + const setPreference = (p: ThemePreference) => { + setPreferenceState(p); + try { + window.localStorage.setItem(THEME_KEY, p); + } catch { + /* quota exceeded / private mode — the choice still holds for this tab */ + } + }; + + const value = useMemo( + () => ({ preference, resolved, setPreference }), + [preference, resolved], + ); + + return {children}; +} + +/** Throws without a provider, because there is no sensible default for + * "change the theme". Read-only consumers want useResolvedTheme instead. */ +export function useTheme(): ThemeValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used inside ThemeProvider"); + return ctx; +} + +/** The theme alone, which is what the charts want. Unlike useTheme this does + * not require a provider: asking which palette is drawn has an obvious answer + * without one, and demanding it would mean every test rendering a chart in + * isolation had to wrap it. */ +export function useResolvedTheme(): Theme { + const ctx = useContext(ThemeContext); + if (ctx) return ctx.resolved; + return document.documentElement.getAttribute("data-theme") === "dark" || + (!document.documentElement.hasAttribute("data-theme") && (darkQuery()?.matches ?? false)) + ? "dark" + : "light"; +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index dbfe1c75..0920f875 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -3,17 +3,23 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import App from "./App"; import { AuthProvider } from "./context/AuthContext"; +import { ThemeProvider } from "./context/ThemeContext"; import ErrorBoundary from "./components/ErrorBoundary"; import "./App.css"; +// Outermost, so the theme outlives a crash in the tree below: the boundary's +// fallback is drawn with the same tokens as the app, and a dark console must +// not turn white to tell you something went wrong. ReactDOM.createRoot(document.getElementById("root")).render( - - - - - - - + + + + + + + + + ); diff --git a/dashboard/src/pages/admin/AnalyticsPage.tsx b/dashboard/src/pages/admin/AnalyticsPage.tsx index 6eae1180..fa8ede54 100644 --- a/dashboard/src/pages/admin/AnalyticsPage.tsx +++ b/dashboard/src/pages/admin/AnalyticsPage.tsx @@ -4,12 +4,13 @@ import { PieChart, Pie, Cell, LineChart, Line, Legend, } from "recharts"; import { api } from "../../api/client"; +import { useChartTheme, seriesColour } from "../../utils/chartTheme"; -const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16", "#f97316", "#14b8a6"]; const TOP_N_CHART = 15; const PAGE_SIZE = 25; export default function AnalyticsPage() { + const chart = useChartTheme(); const [analytics, setAnalytics] = useState(null); const [overlaps, setOverlaps] = useState([]); const [loading, setLoading] = useState(true); @@ -69,8 +70,8 @@ export default function AnalyticsPage() { const topDet = allDetections.slice(0, 10); const othersValue = allDetections.slice(10).reduce((s, d) => s + d.value, 0); const detectionShare = [ - ...topDet.map((d, i) => ({ ...d, fill: COLORS[i % COLORS.length] })), - ...(othersValue > 0 ? [{ name: `Others (${allDetections.length - 10})`, value: othersValue, fill: "#94a3b8" }] : []), + ...topDet.map((d, i) => ({ ...d, fill: seriesColour(chart, i) })), + ...(othersValue > 0 ? [{ name: `Others (${allDetections.length - 10})`, value: othersValue, fill: chart.others }] : []), ]; const totalDetections = summaries.reduce((s, n) => s + (n.metrics?.total_detections || n.detection_area?.n_detections || 0), 0); @@ -117,19 +118,13 @@ export default function AnalyticsPage() {
- - - + + + - +
@@ -148,21 +143,15 @@ export default function AnalyticsPage() {
- - - + + + - - + +
@@ -193,13 +182,7 @@ export default function AnalyticsPage() { ))} [value.toLocaleString(), name]} /> {error && ( -
-
{error}
+
+
{error}
)} diff --git a/dashboard/src/pages/admin/NetworkHealthPage.tsx b/dashboard/src/pages/admin/NetworkHealthPage.tsx index dceedf5b..94d20550 100644 --- a/dashboard/src/pages/admin/NetworkHealthPage.tsx +++ b/dashboard/src/pages/admin/NetworkHealthPage.tsx @@ -4,12 +4,14 @@ import { } from "recharts"; import { MapContainer, TileLayer, CircleMarker, Popup } from "react-leaflet"; import { api } from "../../api/client"; +import { useChartTheme } from "../../utils/chartTheme"; import { RetnodeLink } from "../../components/RetnodeLink"; import { useNodeIds } from "../../components/useNodeIds"; const PAGE_SIZE = 25; export default function NetworkHealthPage() { + const chart = useChartTheme(); const [dashboard, setDashboard] = useState(null); const [aircraft, setAircraft] = useState([]); const [loading, setLoading] = useState(true); @@ -113,20 +115,14 @@ export default function NetworkHealthPage() {
- - - + + + - - + +
@@ -164,6 +160,9 @@ export default function NetworkHealthPage() { key={ref} center={[node.location.rx_lat, node.location.rx_lon]} radius={7} + // Literals, not the status tokens: these are painted onto + // the OSM basemap, which stays light in both themes, so the + // dark ramp would read worse here rather than better. fillColor={online ? "#10b981" : "#ef4444"} color={online ? "#059669" : "#dc2626"} weight={2} diff --git a/dashboard/src/pages/admin/StoragePage.tsx b/dashboard/src/pages/admin/StoragePage.tsx index 3ed76659..1e132fef 100644 --- a/dashboard/src/pages/admin/StoragePage.tsx +++ b/dashboard/src/pages/admin/StoragePage.tsx @@ -89,8 +89,8 @@ export default function StoragePage() {
90 ? "#ef4444" - : (storage.disk.used_pct || 0) > 75 ? "#f59e0b" : "#10b981", + background: (storage.disk.used_pct || 0) > 90 ? "var(--error)" + : (storage.disk.used_pct || 0) > 75 ? "var(--warning)" : "var(--success)", }} />
@@ -133,8 +133,8 @@ export default function StoragePage() { Est. Days Until Full {storage.write_rate.days_until_full > 0 ? storage.write_rate.days_until_full > 365 diff --git a/dashboard/src/pages/user/AnomalyPage.tsx b/dashboard/src/pages/user/AnomalyPage.tsx index 51c42e4e..949ac27f 100644 --- a/dashboard/src/pages/user/AnomalyPage.tsx +++ b/dashboard/src/pages/user/AnomalyPage.tsx @@ -3,6 +3,8 @@ import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, } from "recharts"; import { api } from "../../api/client"; +import { useChartTheme } from "../../utils/chartTheme"; +import { useResolvedTheme, type Theme } from "../../context/ThemeContext"; interface AnomalyEvent { hex: string; @@ -27,16 +29,51 @@ interface AnomalyData { recent_events: AnomalyEvent[]; } -const TYPE_COLORS: Record = { - supersonic: "#ef4444", - instant_acceleration: "#f97316", - instant_direction_change: "#eab308", - sustained_orbit: "#8b5cf6", - position_mismatch: "#3b82f6", - identity_swap: "#ec4899", - altitude_jump: "#14b8a6", - anomalous_behavior: "#6b7280", -}; +/** + * A domain palette, not the status ramp: these hues say which kind of anomaly, + * not how bad it is. Dark keeps every hue and takes it one step lighter, the + * same move the chart series makes, so a type is recognisably itself in either + * theme. + */ +const TYPE_COLOURS = { + light: { + supersonic: "#ef4444", + instant_acceleration: "#f97316", + instant_direction_change: "#eab308", + sustained_orbit: "#8b5cf6", + position_mismatch: "#3b82f6", + identity_swap: "#ec4899", + altitude_jump: "#14b8a6", + anomalous_behavior: "#6b7280", + }, + dark: { + supersonic: "#f87171", + instant_acceleration: "#fb923c", + instant_direction_change: "#facc15", + sustained_orbit: "#a78bfa", + position_mismatch: "#60a5fa", + identity_swap: "#f472b6", + altitude_jump: "#2dd4bf", + anomalous_behavior: "#9ca3af", + }, +} as const satisfies Record>; + +/** For a reason the backend added before this map did. */ +const TYPE_FALLBACK: Record = { light: "#6b7280", dark: "#9ca3af" }; + +/** Ink for a badge whose fill is one of the colours above. White is unreadable + * on the lighter dark variants. */ +const BADGE_INK: Record = { light: "#ffffff", dark: "#0b1220" }; + +function typeColour(theme: Theme, type: string | undefined): string { + const palette: Record = TYPE_COLOURS[theme]; + // hasOwnProperty, not a plain lookup: these keys come from the backend, and + // `constructor` or `toString` would otherwise resolve up the prototype chain + // and hand Recharts a function as a colour. + return type && Object.prototype.hasOwnProperty.call(palette, type) + ? palette[type] + : TYPE_FALLBACK[theme]; +} function formatTime(ts: number) { return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); @@ -49,6 +86,8 @@ function formatDateTime(iso: string) { } export default function AnomalyPage() { + const chart = useChartTheme(); + const theme = useResolvedTheme(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [stale, setStale] = useState(false); @@ -125,23 +164,24 @@ export default function AnomalyPage() { {timeline && timeline.length > 0 ? ( - + - + new Date((v as number) * 1000).toLocaleString()} - contentStyle={{ background: "var(--bg-card)", border: "1px solid var(--border)" }} + contentStyle={chart.tooltip} /> @@ -159,20 +199,20 @@ export default function AnomalyPage() { {typeData.length > 0 ? ( - - + + v.replace(/_/g, " ")} /> - + {typeData.map((entry, i) => ( - + ))} @@ -212,7 +252,7 @@ export default function AnomalyPage() { {c.dominant_type.replace(/_/g, " ")} @@ -230,7 +270,7 @@ export default function AnomalyPage() {

Recent Anomaly Events

- {stale && ⚠ Stale data} + {stale && ⚠ Stale data} {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : "Auto-refreshes every 10s"}
@@ -256,7 +296,7 @@ export default function AnomalyPage() { {(ev.reason || "unknown").replace(/_/g, " ")} diff --git a/dashboard/src/pages/user/ContributionPage.tsx b/dashboard/src/pages/user/ContributionPage.tsx index 4a8b55ae..aa0aa22e 100644 --- a/dashboard/src/pages/user/ContributionPage.tsx +++ b/dashboard/src/pages/user/ContributionPage.tsx @@ -3,10 +3,12 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { api } from "../../api/client"; +import { useChartTheme } from "../../utils/chartTheme"; const PAGE_SIZE = 25; export default function ContributionPage() { + const chart = useChartTheme(); const [analytics, setAnalytics] = useState(null); const [overlaps, setOverlaps] = useState([]); const [leaderboard, setLeaderboard] = useState([]); @@ -91,19 +93,13 @@ export default function ContributionPage() {
- - - + + + - +
diff --git a/dashboard/src/pages/user/KnowledgeBasePage.tsx b/dashboard/src/pages/user/KnowledgeBasePage.tsx index feab0648..4c605f45 100644 --- a/dashboard/src/pages/user/KnowledgeBasePage.tsx +++ b/dashboard/src/pages/user/KnowledgeBasePage.tsx @@ -100,7 +100,7 @@ export default function KnowledgeBasePage() { target="_blank" rel="noopener noreferrer" className="btn btn-primary" - style={{ textDecoration: "none", color: "white" }} + style={{ textDecoration: "none" }} > Community Discord diff --git a/dashboard/src/pages/user/LeaderboardPage.tsx b/dashboard/src/pages/user/LeaderboardPage.tsx index 60a09cc1..25303897 100644 --- a/dashboard/src/pages/user/LeaderboardPage.tsx +++ b/dashboard/src/pages/user/LeaderboardPage.tsx @@ -166,13 +166,13 @@ export default function LeaderboardPage() { {entry.detections.toLocaleString()} {entry.tracks} {entry.in_range || 0} - 0 ? "#f59e0b" : undefined }}> + 0 ? "var(--warning)" : undefined }}> {entry.missed || 0} 0.5 ? "#ef4444" - : (entry.miss_rate || 0) > 0.2 ? "#f59e0b" : "#10b981", + color: (entry.miss_rate || 0) > 0.5 ? "var(--error)" + : (entry.miss_rate || 0) > 0.2 ? "var(--warning)" : "var(--success)", }}> {(entry.in_range || 0) > 0 ? ((entry.miss_rate || 0) * 100).toFixed(1) + "%" : "—"} @@ -211,7 +211,7 @@ export default function LeaderboardPage() { target="_blank" rel="noopener noreferrer" className="btn btn-primary" - style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none", color: "white" }} + style={{ display: "inline-flex", alignItems: "center", gap: 8, textDecoration: "none" }} > diff --git a/dashboard/src/pages/user/NodeDetailPage.tsx b/dashboard/src/pages/user/NodeDetailPage.tsx index 675560b6..1c5d83e7 100644 --- a/dashboard/src/pages/user/NodeDetailPage.tsx +++ b/dashboard/src/pages/user/NodeDetailPage.tsx @@ -4,6 +4,7 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { api } from "../../api/client"; +import { useChartTheme } from "../../utils/chartTheme"; import { RetnodeLink } from "../../components/RetnodeLink"; import { POSITION_STATUS_EXPLANATION } from "../../components/PositionStatusBadge"; import { @@ -19,6 +20,7 @@ const POSITION_FIX_HINT: Record, string> = }; export default function NodeDetailPage() { + const chart = useChartTheme(); const { nodeId } = useParams(); const navigate = useNavigate(); const [data, setData] = useState(null); @@ -123,19 +125,13 @@ export default function NodeDetailPage() {
- - - + + + - +
diff --git a/dashboard/src/pages/user/OnboardingPage.tsx b/dashboard/src/pages/user/OnboardingPage.tsx index 39c075df..9eafccaf 100644 --- a/dashboard/src/pages/user/OnboardingPage.tsx +++ b/dashboard/src/pages/user/OnboardingPage.tsx @@ -92,8 +92,8 @@ export default function OnboardingPage() {
{error && ( -
-
{error}
+
+
{error}
)} diff --git a/dashboard/src/pages/user/OverviewPage.tsx b/dashboard/src/pages/user/OverviewPage.tsx index 6f8099d6..d87a96a6 100644 --- a/dashboard/src/pages/user/OverviewPage.tsx +++ b/dashboard/src/pages/user/OverviewPage.tsx @@ -4,10 +4,12 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { api } from "../../api/client"; +import { useChartTheme } from "../../utils/chartTheme"; import { PositionStatusBadge, POSITION_STATUS_EXPLANATION } from "../../components/PositionStatusBadge"; import { LocationPrivacyBadge } from "../../components/LocationPrivacyControl"; export default function OverviewPage() { + const chart = useChartTheme(); const [nodes, setNodes] = useState([]); const [myNodes, setMyNodes] = useState([]); const [analytics, setAnalytics] = useState(null); @@ -162,23 +164,18 @@ export default function OverviewPage() {
- - - + + + diff --git a/dashboard/src/pages/user/RFEnvironmentPage.tsx b/dashboard/src/pages/user/RFEnvironmentPage.tsx index d5d89480..a3ca6a23 100644 --- a/dashboard/src/pages/user/RFEnvironmentPage.tsx +++ b/dashboard/src/pages/user/RFEnvironmentPage.tsx @@ -4,8 +4,10 @@ import { LineChart, Line, } from "recharts"; import { api } from "../../api/client"; +import { useChartTheme } from "../../utils/chartTheme"; export default function RFEnvironmentPage() { + const chart = useChartTheme(); const [nodes, setNodes] = useState([]); const [selectedNode, setSelectedNode] = useState(""); const [loading, setLoading] = useState(true); @@ -123,11 +125,11 @@ export default function RFEnvironmentPage() {
- - - - - + + + + +
@@ -143,11 +145,11 @@ export default function RFEnvironmentPage() {
- - - - - + + + + +
diff --git a/dashboard/src/raw-import.d.ts b/dashboard/src/raw-import.d.ts new file mode 100644 index 00000000..b1f03ff9 --- /dev/null +++ b/dashboard/src/raw-import.d.ts @@ -0,0 +1,21 @@ +/* Vite's raw import, which the theme tests use to read App.css and + public/theme-boot.js as text rather than through fs — the dashboard carries + no @types/node. + + Its own file because env.d.ts has a top-level import, which makes every + `declare module` in it an augmentation of an existing module rather than an + ambient declaration of a new one. */ +declare module "*?raw" { + const contents: string; + export default contents; +} + +/* Just the one member of vite/client the tests use. Declared rather than + referencing the whole type package, which redeclares the asset modules + env.d.ts already owns. */ +interface ImportMeta { + glob( + pattern: string, + options?: { query?: string; import?: string; eager?: boolean }, + ): Record; +} diff --git a/dashboard/src/test/appearanceSwitch.test.tsx b/dashboard/src/test/appearanceSwitch.test.tsx new file mode 100644 index 00000000..a5f41c76 --- /dev/null +++ b/dashboard/src/test/appearanceSwitch.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import Header from "../components/Header"; +import { ThemeProvider } from "../context/ThemeContext"; + +vi.mock("../context/AuthContext", () => ({ + useAuth: () => ({ user: { name: "Ada", email: "ada@example.com" }, loading: false, logout: vi.fn() }), +})); + +/** As in theme.test.tsx: stubbed rather than borrowed, because jsdom has no + * matchMedia and Node 20 and 26 disagree about window.localStorage. */ +function stubBrowser() { + const store = new Map(); + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: () => null, + length: 0, + }, + }); + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: "(prefers-color-scheme: dark)", + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as typeof window.matchMedia; +} + +/** Open the avatar menu the switch lives inside. */ +function openMenu() { + render( + + +
+ + , + ); + act(() => screen.getByText("Ada").click()); +} + +beforeEach(() => { + stubBrowser(); + document.documentElement.removeAttribute("data-theme"); +}); + +describe("the appearance switch", () => { + // Two things at once, both easy to lose. The buttons hold a glyph and no + // text, so without aria-label a screen reader reads three unlabelled radios + // and the control becomes unusable rather than merely ugly. And the order is + // light → system → dark, a run from one extreme to the other with the neutral + // between them, which is a decision rather than an accident of the array. + it("names all three settings in order, though none of them carries text", () => { + openMenu(); + const radios = screen.getAllByRole("radio"); + expect(radios.map((r) => r.getAttribute("aria-label"))).toEqual(["Light", "System", "Dark"]); + expect(radios.every((r) => r.textContent === "")).toBe(true); + }); + + it("offers the same words as a tooltip", () => { + openMenu(); + expect(screen.getAllByRole("radio").map((r) => r.getAttribute("title"))).toEqual(["Light", "System", "Dark"]); + }); + + it("hides the glyph itself from assistive tech, so the name is not read twice", () => { + openMenu(); + for (const r of screen.getAllByRole("radio")) { + expect(r.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true"); + } + }); + + it("marks the current setting, and moves the mark when another is picked", () => { + openMenu(); + const checked = () => + screen.getAllByRole("radio").find((r) => r.getAttribute("aria-checked") === "true"); + + expect(checked()).toHaveAttribute("aria-label", "System"); + act(() => screen.getByRole("radio", { name: "Dark" }).click()); + expect(checked()).toHaveAttribute("aria-label", "Dark"); + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + }); + + // The whole .header-user toggles the menu, so a click that bubbled would shut + // it — and comparing the three settings means reopening it every time. + it("leaves the menu open so the settings can be compared", () => { + openMenu(); + act(() => screen.getByRole("radio", { name: "Light" }).click()); + expect(screen.getAllByRole("radio")).toHaveLength(3); + }); +}); + +/** + * The keyboard contract that comes with role="radiogroup". Choosing the radio + * role over three independent toggle buttons is what obliges this: the set is + * announced as one control with three options, and a keyboard user expects one + * tab stop with the arrows moving inside it. + */ +describe("the appearance switch by keyboard", () => { + const group = () => screen.getByRole("radiogroup"); + const labels = () => screen.getAllByRole("radio").map((r) => r.getAttribute("aria-label")); + const focused = () => document.activeElement?.getAttribute("aria-label"); + const checked = () => + screen + .getAllByRole("radio") + .find((r) => r.getAttribute("aria-checked") === "true") + ?.getAttribute("aria-label"); + + it("is one tab stop, on whichever option is checked", () => { + openMenu(); + const tabbable = screen.getAllByRole("radio").filter((r) => r.getAttribute("tabindex") === "0"); + expect(tabbable).toHaveLength(1); + expect(tabbable[0]).toHaveAttribute("aria-label", checked()!); + }); + + it("moves the selection right, and takes focus with it", () => { + openMenu(); + expect(labels()).toEqual(["Light", "System", "Dark"]); + expect(checked()).toBe("System"); + + act(() => void fireEvent.keyDown(group(), { key: "ArrowRight" })); + expect(checked()).toBe("Dark"); + expect(focused()).toBe("Dark"); + }); + + it("moves the selection left", () => { + openMenu(); + act(() => void fireEvent.keyDown(group(), { key: "ArrowLeft" })); + expect(checked()).toBe("Light"); + expect(focused()).toBe("Light"); + }); + + it("wraps at both ends rather than stopping", () => { + openMenu(); + act(() => void fireEvent.keyDown(group(), { key: "ArrowLeft" })); // to Light + act(() => void fireEvent.keyDown(group(), { key: "ArrowLeft" })); // wraps to Dark + expect(checked()).toBe("Dark"); + + act(() => void fireEvent.keyDown(group(), { key: "ArrowRight" })); // wraps to Light + expect(checked()).toBe("Light"); + }); + + it("takes Home and End to the ends", () => { + openMenu(); + act(() => void fireEvent.keyDown(group(), { key: "End" })); + expect(checked()).toBe("Dark"); + act(() => void fireEvent.keyDown(group(), { key: "Home" })); + expect(checked()).toBe("Light"); + }); + + // Without preventDefault the arrows scroll the dropdown and Home/End jump the + // page, while the selection moves underneath. Everything else must pass. + it("swallows only the keys it handles", () => { + openMenu(); + expect(fireEvent.keyDown(group(), { key: "ArrowRight" })).toBe(false); + expect(fireEvent.keyDown(group(), { key: "a" })).toBe(true); + }); + + it("applies an arrow selection to the document, not just the markup", () => { + openMenu(); + act(() => void fireEvent.keyDown(group(), { key: "ArrowRight" })); + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + }); +}); diff --git a/dashboard/src/test/chartTheme.test.ts b/dashboard/src/test/chartTheme.test.ts new file mode 100644 index 00000000..ae24b8ee --- /dev/null +++ b/dashboard/src/test/chartTheme.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import rawCss from "../App.css?raw"; +import { CHART_THEMES, seriesColour } from "../utils/chartTheme"; + +const css = rawCss.replace(/\/\*[\s\S]*?\*\//g, ""); + +/** One token's value in a theme, read out of the stylesheet. */ +function token(selector: string, name: string): string { + const open = css.indexOf("{", css.indexOf(selector)); + const close = css.indexOf("}", open); + const match = css.slice(open + 1, close).match(new RegExp(`${name}\\s*:\\s*([^;]+);`)); + expect(match, `${name} not declared under ${selector}`).not.toBeNull(); + return match![1].trim(); +} + +const TOKENS = { + light: (name: string) => token(":root {", name), + dark: (name: string) => token(':root[data-theme="dark"]', name), +} as const; + +describe.each(["light", "dark"] as const)("the %s chart chrome", (theme) => { + const chart = CHART_THEMES[theme]; + const tokenFor = TOKENS[theme]; + + // Recharts cannot read the cascade, so these values are a hand-kept copy of + // the stylesheet's. Nothing but this test stops the two drifting. + it("draws its grid in the border colour", () => { + expect(chart.grid).toBe(tokenFor("--border")); + }); + + it("draws its axes in the muted ink", () => { + expect(chart.axis).toBe(tokenFor("--text-muted")); + }); + + it("draws its tooltip as a card", () => { + expect(chart.tooltip.background).toBe(tokenFor("--bg-card")); + expect(chart.tooltip.color).toBe(tokenFor("--text-primary")); + }); +}); + +// A tooltip floats over a chart that is itself on a card, so its border is the +// only thing separating the two — and the themes need different tokens to get +// the same separation. Light's hairline is a solid grey that reads against +// white; dark's is a 14%-alpha white that does not read against the navy it is +// drawn on, so dark takes the stronger one. +describe("the tooltip border", () => { + it("is the hairline on light", () => { + expect(CHART_THEMES.light.tooltip.border).toBe(`1px solid ${TOKENS.light("--border")}`); + }); + + it("is the stronger hairline on dark", () => { + expect(CHART_THEMES.dark.tooltip.border).toBe(`1px solid ${TOKENS.dark("--border-light")}`); + }); +}); + +/** + * The stylesheet has themeTokens.test.ts sweeping it for stray colours; the + * pages need their own sweep, because a chart's colours are props rather than + * CSS and no token block covers them. A literal here is pinned to one theme and + * mismatches its own neighbours in the other — an `Area` whose stroke follows + * the palette and whose fill does not is the shape this catches. + */ +describe("the chart pages", () => { + const ALLOWED = [ + // Google's brand mark on the sign-in button, which does not theme. + "src/pages/LoginPage.tsx", + // Painted onto OSM tiles, which stay light in both themes. + "src/pages/admin/NetworkHealthPage.tsx", + // Its own documented domain palette, with a value per theme. + "src/pages/user/AnomalyPage.tsx", + ]; + + const pages = import.meta.glob("../pages/**/*.tsx", { query: "?raw", import: "default", eager: true }) as Record; + + it("finds pages to check, so a glob that matched nothing cannot pass", () => { + expect(Object.keys(pages).length).toBeGreaterThan(10); + }); + + it.each(Object.keys(pages).filter((p) => !ALLOWED.some((a) => p.endsWith(a.replace("src/", "")))))( + "%s hardcodes no colour", + (path) => { + const source = pages[path].replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ""); + expect(source.match(/#[0-9a-f]{3,8}\b|\brgba?\([\d\s,.]+\)/gi) ?? []).toEqual([]); + }, + ); + + // A named colour is as pinned to one theme as a hex is, and an inline style + // outranks the class rule it sits on — `color: "white"` on a .btn-primary + // quietly defeats --accent-ink. `transparent` and `currentColor` are fine. + it.each(Object.keys(pages))("%s names no colour in a style object", (path) => { + const source = pages[path].replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, ""); + const named = /\b(?:color|background|backgroundColor|borderColor|fill|stroke)\s*:\s*"(white|black|red|green|blue|grey|gray|silver|navy|teal|orange)"/gi; + expect(source.match(named) ?? []).toEqual([]); + }); +}); + +describe("the series palette", () => { + // The guide fixes this ordering for dash's charts; dark keeps it and takes + // each hue a step lighter, so the same category is the same position and + // recognisably the same hue in either theme. + it("is the guide's order in light", () => { + expect(CHART_THEMES.light.series).toEqual([ + "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", + "#ec4899", "#06b6d4", "#84cc16", "#f97316", "#14b8a6", + ]); + }); + + it("holds the same number of categories in both themes", () => { + expect(CHART_THEMES.dark.series).toHaveLength(CHART_THEMES.light.series.length); + }); + + it("shares no colour between the two themes, so neither is half-ported", () => { + const shared = CHART_THEMES.dark.series.filter((c) => CHART_THEMES.light.series.includes(c)); + expect(shared).toEqual([]); + }); + + it("wraps past the last category rather than running out", () => { + const light = CHART_THEMES.light; + expect(seriesColour(light, 0)).toBe(light.series[0]); + expect(seriesColour(light, 10)).toBe(light.series[0]); + expect(seriesColour(light, 13)).toBe(light.series[3]); + }); +}); diff --git a/dashboard/src/test/theme.test.tsx b/dashboard/src/test/theme.test.tsx new file mode 100644 index 00000000..2efade54 --- /dev/null +++ b/dashboard/src/test/theme.test.tsx @@ -0,0 +1,214 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { ThemeProvider, useTheme, THEME_KEY } from "../context/ThemeContext"; +import themeBoot from "../../public/theme-boot.js?raw"; + +/** + * Both browser APIs under test are stubbed rather than borrowed from the + * environment. matchMedia because jsdom has none; localStorage because Node 20 + * and Node 26 disagree about whose implementation `window.localStorage` reaches + * (26 shadows jsdom's with its own, which throws without `--localstorage-file`), + * and a suite that passes on CI's Node and fails on a developer's is worse than + * no suite. + */ +function stubStorage(seed: Record = {}) { + const store = new Map(Object.entries(seed)); + const storage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, String(v)), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: (i: number) => [...store.keys()][i] ?? null, + get length() { + return store.size; + }, + }; + Object.defineProperty(window, "localStorage", { value: storage, configurable: true, writable: true }); + return storage; +} + +/** Replace it with one that throws on every access, as private browsing and a + * full quota both do. */ +function stubBrokenStorage() { + const boom = () => { + throw new Error("storage unavailable"); + }; + Object.defineProperty(window, "localStorage", { + value: { getItem: boom, setItem: boom, removeItem: boom, clear: boom, key: boom, length: 0 }, + configurable: true, + writable: true, + }); +} + +/** Install a matchMedia whose match state the test controls, keeping the + * listeners so a test can fire an OS-level theme change. */ +function stubMatchMedia(prefersDark: boolean) { + const listeners = new Set<(e: MediaQueryListEvent) => void>(); + const mql = { + matches: prefersDark, + media: "(prefers-color-scheme: dark)", + addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn), + removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn), + }; + window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia; + return { + /** Flip the OS preference the way a real media query would. */ + set(matches: boolean) { + mql.matches = matches; + act(() => { + for (const fn of listeners) fn({ matches } as MediaQueryListEvent); + }); + }, + listenerCount: () => listeners.size, + }; +} + +function Probe() { + const { preference, resolved, setPreference } = useTheme(); + return ( + <> + {preference} + {resolved} + + + + ); +} + +const renderProbe = () => render(); +const attr = () => document.documentElement.getAttribute("data-theme"); + +let storage: ReturnType; + +beforeEach(() => { + storage = stubStorage(); + document.documentElement.removeAttribute("data-theme"); + stubMatchMedia(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("theme preference", () => { + it("defaults to following the OS", () => { + renderProbe(); + expect(screen.getByTestId("preference")).toHaveTextContent("system"); + }); + + it("restores a stored preference", () => { + storage.setItem(THEME_KEY, "dark"); + renderProbe(); + expect(screen.getByTestId("preference")).toHaveTextContent("dark"); + expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); + }); + + it("persists a change", () => { + renderProbe(); + act(() => screen.getByText("dark").click()); + expect(storage.getItem(THEME_KEY)).toBe("dark"); + }); + + // A value from a future version, a hand-edited one, or another app sharing + // the origin must not leave the console rendering a theme that has no tokens. + it("falls back to system on a value it does not recognise", () => { + storage.setItem(THEME_KEY, "solarized"); + renderProbe(); + expect(screen.getByTestId("preference")).toHaveTextContent("system"); + }); + + it("survives storage being unavailable", () => { + stubBrokenStorage(); + renderProbe(); + expect(screen.getByTestId("preference")).toHaveTextContent("system"); + expect(() => act(() => screen.getByText("dark").click())).not.toThrow(); + expect(screen.getByTestId("preference")).toHaveTextContent("dark"); + }); +}); + +describe("the data-theme attribute", () => { + // The CSS carries light on the bare selector and dark behind a media query, + // so "system" must leave no attribute at all rather than stamp a resolved + // value: stamping one would pin the console to whichever theme the OS + // happened to be in at load and stop it following a later change. + it("is absent while the preference is system", () => { + renderProbe(); + expect(attr()).toBeNull(); + + stubMatchMedia(true); + renderProbe(); + expect(attr()).toBeNull(); + }); + + it("is stamped by an explicit preference, and cleared by going back to system", () => { + renderProbe(); + act(() => screen.getByText("dark").click()); + expect(attr()).toBe("dark"); + + act(() => screen.getByText("system").click()); + expect(attr()).toBeNull(); + }); +}); + +describe("the resolved theme", () => { + it("reports what the OS asks for while the preference is system", () => { + stubMatchMedia(true); + renderProbe(); + expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); + }); + + it("follows the OS changing its mind", () => { + const media = stubMatchMedia(false); + renderProbe(); + expect(screen.getByTestId("resolved")).toHaveTextContent("light"); + + media.set(true); + expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); + }); + + // Charts read `resolved` to pick their chrome, so an explicit choice has to + // win there as decisively as it does in the CSS. + it("ignores the OS once the preference is explicit", () => { + const media = stubMatchMedia(false); + renderProbe(); + act(() => screen.getByText("dark").click()); + expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); + + media.set(true); + expect(screen.getByTestId("resolved")).toHaveTextContent("dark"); + }); + + it("stops listening to the OS when unmounted", () => { + const media = stubMatchMedia(false); + const { unmount } = renderProbe(); + expect(media.listenerCount()).toBe(1); + unmount(); + expect(media.listenerCount()).toBe(0); + }); +}); + +/** + * public/theme-boot.js runs before the bundle and stamps the same attribute + * from the same key, so that a dark console is dark before the first paint + * rather than after. It cannot import any of this — it is a plain script loaded + * by a tag, deliberately outside the module graph — so the agreement between + * the two is asserted here instead. + */ +describe("the pre-paint boot script", () => { + it("reads the key ThemeContext writes", () => { + expect(themeBoot).toContain(`"${THEME_KEY}"`); + }); + + it("stamps only the two explicit themes, leaving system to the stylesheet", () => { + expect(themeBoot).toContain('t === "dark" || t === "light"'); + expect(themeBoot).toContain('setAttribute("data-theme", t)'); + expect(themeBoot).not.toContain('"system"'); + }); + + // Reading storage throws outright in some privacy modes, and this runs before + // anything else on the page — an exception here would take the document with + // it rather than merely losing the theme. + it("survives storage throwing", () => { + expect(themeBoot).toMatch(/try\s*\{[\s\S]*\}\s*catch/); + }); +}); diff --git a/dashboard/src/test/themeTokens.test.ts b/dashboard/src/test/themeTokens.test.ts new file mode 100644 index 00000000..1ad88a41 --- /dev/null +++ b/dashboard/src/test/themeTokens.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import rawCss from "../App.css?raw"; + +const css = rawCss; + +// Comments go first and everything below reads the remainder: prose is free to +// contain a semicolon, a brace or a hex code, and every one of those confuses a +// parser this small. +const bare = css.replace(/\/\*[\s\S]*?\*\//g, ""); + +/** The declarations of the first rule whose selector matches. Good enough for a + * stylesheet of flat, hand-authored blocks, and it fails loudly rather than + * silently matching nothing. */ +function block(selector: string): Record { + const at = bare.indexOf(selector); + expect(at, `no rule for ${selector}`).toBeGreaterThan(-1); + const open = bare.indexOf("{", at); + const close = bare.indexOf("}", open); + const declarations: Record = {}; + for (const line of bare.slice(open + 1, close).split(";")) { + const [prop, ...rest] = line.split(":"); + const value = rest.join(":").trim(); + const name = prop.trim(); + if (name && value) declarations[name] = value; + } + return declarations; +} + +const light = block(":root {"); +const systemDark = block(':root:not([data-theme="light"])'); +const explicitDark = block(':root[data-theme="dark"]'); + +describe("the two dark blocks", () => { + // CSS cannot share a declaration block across a media query boundary, so the + // OS-preference copy and the explicit-choice copy are written twice. Nothing + // in the stylesheet stops them drifting; this does. + it("are identical", () => { + expect(systemDark).toEqual(explicitDark); + }); + + it("are not empty, so a parse that matched nothing cannot pass", () => { + expect(Object.keys(explicitDark).length).toBeGreaterThan(15); + }); +}); + +describe("the dark palette", () => { + // A token declared in one theme and not the other resolves to nothing in the + // theme that lacks it, which is a blank fill rather than a wrong colour. + it("answers every colour the light palette declares", () => { + const colours = (b: Record) => + Object.keys(b).filter((k) => k.startsWith("--") && !/^--(sidebar|header|radius)/.test(k)); + expect(colours(explicitDark).sort()).toEqual(colours(light).sort()); + }); + + it("is the map's, not a new one", () => { + expect(explicitDark["--bg-primary"]).toBe("#0d1b2a"); + expect(explicitDark["--bg-card"]).toBe("#132240"); + expect(explicitDark["--accent"]).toBe("#38bdf8"); + expect(explicitDark["--text-primary"]).toBe("#e2e8f0"); + }); + + it("tells the browser to theme its own furniture too", () => { + expect(light["color-scheme"]).toBe("light"); + expect(explicitDark["color-scheme"]).toBe("dark"); + }); +}); + +describe("the stylesheet", () => { + // Every one of these is invisible or wrong in the other theme. + it("hardcodes no colour outside the token blocks", () => { + const lastToken = bare.indexOf(':root[data-theme="dark"]'); + const afterTokens = bare.slice(bare.indexOf("}", bare.indexOf("{", lastToken))); + const hardcoded = afterTokens.match(/#[0-9a-f]{3,8}\b|\brgba?\([^)]*\)/gi) ?? []; + expect(hardcoded).toEqual([]); + }); + + // `color: white` is as invisible on navy as `color: #fff` is, and the hex + // sweep above does not see it. `transparent` and `currentColor` are fine. + it("hardcodes no named colour either", () => { + const named = /:\s*(white|black|red|green|blue|grey|gray|silver|navy|teal|orange)\b/gi; + expect(bare.match(named) ?? []).toEqual([]); + }); +}); diff --git a/dashboard/src/utils/chartTheme.ts b/dashboard/src/utils/chartTheme.ts new file mode 100644 index 00000000..7ddf871a --- /dev/null +++ b/dashboard/src/utils/chartTheme.ts @@ -0,0 +1,65 @@ +import { useResolvedTheme, type Theme } from "../context/ThemeContext"; + +/** + * Chart chrome and series colours, per theme. + * + * Recharts paints from props rather than from the cascade, so it is the one + * part of the console a `var()` cannot reach: every axis, grid and tooltip has + * to be told its colour. These values are therefore a second copy of what + * App.css already holds, and chartTheme.test.ts asserts the two agree — the + * same arrangement the stylesheet's two dark blocks are under. + * + * The series palette is the guide's categorical ordering for dash. Dark keeps + * the order and takes each hue one step lighter, because a navy ground admits + * the light end of a hue where the near-white ground does not. + */ + +export interface ChartTheme { + /** CartesianGrid stroke. */ + grid: string; + /** XAxis / YAxis stroke, and their tick ink. */ + axis: string; + /** Recharts wants the tooltip as one style object. */ + tooltip: { background: string; border: string; borderRadius: number; fontSize: number; color: string }; + /** Categorical series, in the guide's order. */ + series: readonly string[]; + /** The "others" slice of a top-N breakdown. Neutral in both themes: it is an + * absence of category rather than one more of them. */ + others: string; +} + +const TOOLTIP_SHAPE = { borderRadius: 6, fontSize: 12 } as const; + +export const CHART_THEMES: Record = { + light: { + grid: "#e2e8f0", + axis: "#94a3b8", + tooltip: { ...TOOLTIP_SHAPE, background: "#ffffff", border: "1px solid #e2e8f0", color: "#0f172a" }, + series: ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16", "#f97316", "#14b8a6"], + others: "#94a3b8", + }, + dark: { + grid: "rgba(100, 180, 255, 0.14)", + axis: "#64748b", + tooltip: { + ...TOOLTIP_SHAPE, + background: "#132240", + border: "1px solid rgba(100, 180, 255, 0.28)", + color: "#e2e8f0", + }, + series: ["#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#f472b6", "#22d3ee", "#a3e635", "#fb923c", "#2dd4bf"], + others: "#94a3b8", + }, +}; + +/** The chrome for whichever theme is drawn. Re-reads on a theme change, so a + * chart already on screen repaints rather than keeping the old palette. */ +export function useChartTheme(): ChartTheme { + return CHART_THEMES[useResolvedTheme()]; +} + +/** The series colour for the nth category, wrapping past the tenth rather than + * running out and painting SVG's default black. */ +export function seriesColour(theme: ChartTheme, index: number): string { + return theme.series[index % theme.series.length]; +} diff --git a/dashboard/vite.config.js b/dashboard/vite.config.js index 23f2f2d3..d7a2cdcd 100644 --- a/dashboard/vite.config.js +++ b/dashboard/vite.config.js @@ -6,6 +6,9 @@ export default defineConfig({ test: { environment: "jsdom", globals: true, + // So `App.css?raw` reaches the theme tests as its text. Vitest stubs every + // CSS import with an empty string by default, the raw query included. + css: true, setupFiles: ["./src/test/setup.ts"], }, build: {