Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Retina Dashboard</title>
<!-- Before the stylesheet and without defer: it has to stamp the theme
before the first paint. Not inline, because this vhost's CSP is
`script-src 'self'` — see the file. -->
<script src="/theme-boot.js"></script>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
Expand Down
18 changes: 18 additions & 0 deletions dashboard/public/theme-boot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* Stamps the theme attribute before the first paint, so someone who chose dark
is not shown a white page while the bundle loads.

Its own file rather than an inline <script> 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 */
}
214 changes: 208 additions & 6 deletions dashboard/src/App.css
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 ───────────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -430,7 +533,7 @@ td {
}

tr:hover td {
background: rgba(0, 0, 0, 0.02);
background: var(--bg-card-hover);
}

/* ── Status Badge ────────────────────────────────────────────────────────── */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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 <link> 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;
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<div style={{ padding: "2rem", textAlign: "center", color: "#94a3b8" }}>
<div style={{ padding: "2rem", textAlign: "center", color: "var(--text-muted)" }}>
<h2>Something went wrong</h2>
<p>Please refresh the page. If the problem persists, contact support.</p>
<button
Expand Down
Loading
Loading