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.
)}
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() {