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: {