Skip to content
Open
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
2 changes: 1 addition & 1 deletion data-explorer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ and the page talks to the same-origin API.
```
data-explorer/
index.html markup + the CommonJS shim that loads the vendored timeline
app.css tokens (light palette verbatim from dashboard/src/App.css) + layout
app.css tokens (light from dashboard/src/App.css, dark from the map) + layout
app.js all behaviour, one classic script, plain ES2020
vendor/ react, react-dom, lodash, classnames, @edsc/timeline — see NOTICE.md
```
Expand Down
53 changes: 44 additions & 9 deletions data-explorer/app.css
Original file line number Diff line number Diff line change
@@ -1,23 +1,49 @@
/* Retina Data Explorer — no build step, no framework.
Tokens: the light palette is verbatim from dashboard/src/App.css so the two
sites read as one product; dark is its counterpart, applied from the OS
preference only (there is no theme switch here). */

Tokens: the light palette is verbatim from dashboard/src/App.css and the dark
one is the map's (frontend/src/map-surface.css), so a surface of the estate is
recognisable in either theme. Light stays on the bare selector because it is
the default; dark arrives either from the OS preference or from the switch in
the topbar, and the two dark blocks are identical by hand — CSS cannot share a
declaration block across a media query boundary, and there is no build step
here to do it for us. */
:root{
--bg-primary:#f1f5f9;--bg-secondary:#ffffff;--bg-card:#ffffff;--bg-card-hover:#f8fafc;--bg-input:#f8fafc;
/* A light canvas is already darker than a card, so a recessed region is made
by letting it show through; dark inverts the ramp and needs its own. */
--bg-sunk:#f1f5f9;
--text-primary:#0f172a;--text-secondary:#475569;--text-muted:#94a3b8;--border:#e2e8f0;--border-light:#cbd5e1;
--accent:#3b82f6;--accent-hover:#2563eb;--accent-light:rgba(59,130,246,.10);
/* Ink for what sits ON the accent. A token and not a literal white because
the dark accent is a bright sky blue, where white is about 1.8:1. */
--accent-ink:#ffffff;
--success:#10b981;--success-light:rgba(16,185,129,.10);--warning:#f59e0b;--warning-light:rgba(245,158,11,.10);
--error:#ef4444;--error-light:rgba(239,68,68,.10);
--shadow:rgba(15,23,42,.14);--scrim:rgba(15,23,42,.35);
--header-height:56px;--radius:8px;--radius-sm:4px;
--mono:"SF Mono","Fira Code",ui-monospace,Menlo,Consolas,monospace;
color-scheme:light;
}
@media (prefers-color-scheme:dark){:root{
--bg-primary:#0f172a;--bg-secondary:#131c2e;--bg-card:#131c2e;--bg-card-hover:#1b2740;--bg-input:#0b1220;
--text-primary:#e2e8f0;--text-secondary:#94a3b8;--text-muted:#64748b;--border:#1e293b;--border-light:#334155;
--accent-light:rgba(59,130,246,.14);--success-light:rgba(16,185,129,.12);--warning-light:rgba(245,158,11,.12);--error-light:rgba(239,68,68,.12);
/* `:not([data-theme="light"])` is what lets an explicit light choice win over a
dark OS. */
@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,.9);
--bg-sunk:#0f2035;
--text-primary:#e2e8f0;--text-secondary:#94a3b8;--text-muted:#64748b;
--border:rgba(100,180,255,.14);--border-light:rgba(100,180,255,.28);
--accent:#38bdf8;--accent-hover:#7dd3fc;--accent-light:rgba(56,189,248,.16);--accent-ink:#082f49;
--success:#4ade80;--success-light:rgba(74,222,128,.15);--warning:#fbbf24;--warning-light:rgba(251,191,36,.15);
--error:#f43f5e;--error-light:rgba(244,63,94,.15);
--shadow:rgba(0,0,0,.5);--scrim:rgba(2,6,23,.55);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,.9);
--bg-sunk:#0f2035;
--text-primary:#e2e8f0;--text-secondary:#94a3b8;--text-muted:#64748b;
--border:rgba(100,180,255,.14);--border-light:rgba(100,180,255,.28);
--accent:#38bdf8;--accent-hover:#7dd3fc;--accent-light:rgba(56,189,248,.16);--accent-ink:#082f49;
--success:#4ade80;--success-light:rgba(74,222,128,.15);--warning:#fbbf24;--warning-light:rgba(251,191,36,.15);
--error:#f43f5e;--error-light:rgba(244,63,94,.15);
--shadow:rgba(0,0,0,.5);--scrim:rgba(2,6,23,.55);color-scheme:dark;}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
/* Must outrank the display values set on .notice, .swatches and .btn below —
the UA's own `[hidden]{display:none}` does not, so `hidden` on any of them
Expand All @@ -34,12 +60,21 @@ a:hover{text-decoration:underline}
.topbar{height:var(--header-height);border-bottom:1px solid var(--border);background:var(--bg-secondary);
display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 24px;position:sticky;top:0;z-index:40}
.brand{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600}
.brand-mark{width:28px;height:28px;border-radius:7px;background:var(--accent);color:#fff;
.brand-mark{width:28px;height:28px;border-radius:7px;background:var(--accent);color:var(--accent-ink);
display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px}
.brand .sep{color:var(--text-muted);font-weight:400}
.topnav{display:flex;gap:6px;align-items:center;font-size:13px}
.topnav a{padding:6px 10px;border-radius:var(--radius-sm);color:var(--text-secondary)}
.topnav a:hover{background:var(--bg-card-hover);color:var(--text-primary);text-decoration:none}
.topbar-end{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}

/* ── Appearance switch ── */
.theme-switch{display:flex;gap:2px;padding:2px;background:var(--bg-sunk);
border:1px solid var(--border);border-radius:var(--radius-sm)}
.theme-switch button{padding:4px 9px;font-size:11px;font-family:inherit;line-height:1.4;
background:none;border:none;border-radius:3px;color:var(--text-secondary);cursor:pointer}
.theme-switch button:hover{background:var(--bg-card-hover);color:var(--text-primary)}
.theme-switch button[aria-checked="true"]{background:var(--accent-light);color:var(--accent);font-weight:600}

.content{max-width:1400px;margin:0 auto;padding:24px 24px 140px}
.page-header{margin-bottom:20px}
Expand All @@ -64,7 +99,7 @@ a:hover{text-decoration:underline}
/* ── Controls ── */
.btn{padding:7px 14px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;cursor:pointer;border:none;font-family:inherit;
display:inline-flex;align-items:center;gap:6px;text-decoration:none;white-space:nowrap}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover);text-decoration:none;color:#fff}
.btn-primary{background:var(--accent);color:var(--accent-ink)}.btn-primary:hover{background:var(--accent-hover);text-decoration:none;color:var(--accent-ink)}
.btn-outline,.btn-secondary{background:transparent;border:1px solid var(--border-light);color:var(--text-secondary)}
.btn-outline:hover,.btn-secondary:hover{border-color:var(--accent);color:var(--text-primary);text-decoration:none}
.btn:disabled,.btn[aria-disabled="true"]{opacity:.4;cursor:not-allowed;pointer-events:none}
Expand Down
50 changes: 50 additions & 0 deletions data-explorer/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,56 @@ $("#topnav").innerHTML =
'<a href="' + esc(siblingUrl("dash")) + '">Dashboard</a>' +
'<a href="' + esc(siblingUrl("map")) + '">Live map</a>';

/* ══ Appearance ════════════════════════════════════════════════════════════
* Three states. The `retina.theme` key and the `data-theme` attribute are the
* names the dashboard console uses for the same job, deliberately: they are
* separate origins, so a stored choice cannot travel between them, and matching
* names are the only thing making the two one idiom rather than two.
*
* `system` stamps nothing and lets app.css's own media query answer, which is
* why the OS preference keeps working when it changes mid-session. An explicit
* choice is stamped here and, for the next load, by theme-boot.js before the
* first paint. */
const THEME_KEY = "retina.theme";

function storedTheme() {
try {
const t = localStorage.getItem(THEME_KEY);
return t === "light" || t === "dark" ? t : "system";
} catch (e) {
return "system";
}
}

/* Stamp the attribute and mark the switch. Persisting is the caller's job, so
* that merely opening the page does not write storage for a visitor who never
* touched the control — this one is public and unauthenticated. */
function showTheme(choice) {
if (choice === "system") document.documentElement.removeAttribute("data-theme");
else document.documentElement.setAttribute("data-theme", choice);
$$("#theme-switch button").forEach((b) => {
b.setAttribute("aria-checked", String(b.dataset.themeChoice === choice));
});
}

function chooseTheme(choice) {
showTheme(choice);
try {
localStorage.setItem(THEME_KEY, choice);
} catch (e) {
/* private browsing — the choice still holds for this page */
}
}

$("#theme-switch").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-theme-choice]");
if (btn) chooseTheme(btn.dataset.themeChoice);
});

// theme-boot.js has already stamped the attribute; this is what stops the
// switch claiming "System" when storage says otherwise.
showTheme(storedTheme());

/* ══ State ═════════════════════════════════════════════════════════════════ */
const TODAY = todayUTC();
const S = {
Expand Down
12 changes: 11 additions & 1 deletion data-explorer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@
<title>Retina · Data Explorer</title>
<meta name="description" content="Browse, preview and bulk-fetch archived detection files from the Retina passive radar network.">
<link rel="stylesheet" href="app.css">
<!-- 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>
</head>
<body>

<header class="topbar">
<div class="brand"><span class="brand-mark">R</span><span class="brand-text">Retina <span class="sep">·</span> Data Explorer</span></div>
<nav class="topnav" id="topnav"><!-- sibling-host links, filled by app.js --></nav>
<div class="topbar-end">
<nav class="topnav" id="topnav"><!-- sibling-host links, filled by app.js --></nav>
<div class="theme-switch" id="theme-switch" role="radiogroup" aria-label="Appearance">
<button type="button" role="radio" data-theme-choice="system" aria-checked="true">System</button>
<button type="button" role="radio" data-theme-choice="light" aria-checked="false">Light</button>
<button type="button" role="radio" data-theme-choice="dark" aria-checked="false">Dark</button>
</div>
</div>
</header>

<main class="content">
Expand Down
19 changes: 19 additions & 0 deletions data-explorer/theme-boot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* Stamps the theme attribute before the first paint, so someone who chose dark
is not shown a white page while the deferred app.js loads.

Its own file rather than an inline <script> for the same reason the libraries
under vendor/ are vendored: this vhost sends `script-src 'self'`, which does
not cover inline code, and a laptop serving these files sends no CSP at all —
so an inline version works in every local check and silently never runs once
deployed.

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 the names the dashboard console uses too; app.js has the other
half of this, and the reasoning. */
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 */
}
Loading