From 61e9221b3a5f84c155785e832dcabdd57c741464 Mon Sep 17 00:00:00 2001 From: Michael Pilosov Date: Tue, 21 Apr 2026 21:51:16 -0600 Subject: [PATCH] dark/light theme toggle --- app/web/static/metrics.js | 28 ++++++------ app/web/static/style.css | 82 ++++++++++++++++++++++++++++++++++ app/web/static/theme.js | 29 ++++++++++++ app/web/templates/index.html | 13 +++++- app/web/templates/metrics.html | 15 +++++-- 5 files changed, 149 insertions(+), 18 deletions(-) create mode 100644 app/web/static/theme.js diff --git a/app/web/static/metrics.js b/app/web/static/metrics.js index 4a79e65..c30d51e 100644 --- a/app/web/static/metrics.js +++ b/app/web/static/metrics.js @@ -5,18 +5,18 @@ const SVG_NS = "http://www.w3.org/2000/svg"; -// Qualitative palette — muted, matches the notebook aesthetic. Cycles if -// there are more runs than colours. -const PALETTE = [ - "#1f4e5f", // accent teal - "#8a3a2a", // rust - "#a77a2c", // warm amber - "#3a6f3f", // olive - "#5d4a7b", // slate purple - "#7a5c4b", // brown - "#2b5d7a", // deeper blue - "#6b6b3e", // moss -]; +// Qualitative palette — defined as CSS custom properties on :root (and +// overridden under [data-theme="dark"]) so the theme toggle automatically +// swaps plot colours. Read fresh on every render. +function getPalette() { + const cs = getComputedStyle(document.documentElement); + const out = []; + for (let i = 1; i <= 8; i++) { + const v = cs.getPropertyValue(`--plot-${i}`).trim(); + if (v) out.push(v); + } + return out.length ? out : ["#1f4e5f"]; +} const state = { raw: [], @@ -50,6 +50,7 @@ async function init() { populateFilters(); wireEvents(); + document.addEventListener("themechange", render); render(); } @@ -163,9 +164,10 @@ function render() { // Stable colour assignment per legend row (across the full filtered set), // so toggling selection doesn't shuffle colours. + const palette = getPalette(); const colored = runs.map((r, i) => ({ run: r, - color: PALETTE[i % PALETTE.length], + color: palette[i % palette.length], selected: state.selected.has(runKey(r)), })); const forPlot = state.selected.size === 0 ? colored : colored.filter((c) => c.selected); diff --git a/app/web/static/style.css b/app/web/static/style.css index 5f48823..fcaf921 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -25,6 +25,17 @@ --warm: #a77a2c; --good: #3a6f3f; + /* Qualitative palette for plot lines. JS reads these via getComputedStyle + so the palette flips with the theme. */ + --plot-1: #1f4e5f; + --plot-2: #8a3a2a; + --plot-3: #a77a2c; + --plot-4: #3a6f3f; + --plot-5: #5d4a7b; + --plot-6: #7a5c4b; + --plot-7: #2b5d7a; + --plot-8: #6b6b3e; + --serif: Georgia, "Iowan Old Style", "Palatino Linotype", serif; --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", "Inter", "Arial", sans-serif; @@ -36,6 +47,57 @@ --space: 1rem; } +/* Dark mode — warm near-black, preserving the scientific-notebook feel. + Toggled by setting [data-theme="dark"] on . */ +[data-theme="dark"] { + --page: #1a1917; + --panel: #1f1e1c; + --ink: #e8e4da; + --mute: #9a968c; + --faint: #5f5b52; + --rule: #2b2925; + --rule-2: #3a3834; + --accent: #84bcc9; + --accent-tint: #1c2930; + --alarm: #d18774; + --warm: #cba368; + --good: #8fb695; + + --plot-1: #84bcc9; + --plot-2: #d18774; + --plot-3: #cba368; + --plot-4: #8fb695; + --plot-5: #a695c4; + --plot-6: #bc9f8a; + --plot-7: #8fa7c5; + --plot-8: #adad80; +} + +html { color-scheme: light dark; } +[data-theme="light"] { color-scheme: light; } +[data-theme="dark"] { color-scheme: dark; } + +/* Hardcoded light-mode colour patches. These predate the theme system; + overriding them here keeps the original selectors untouched. */ +[data-theme="dark"] button.submit:hover, +[data-theme="dark"] .picker-footer .continue:not(:disabled):hover { + background: #a5d0da; +} +[data-theme="dark"] .flash.err, +[data-theme="dark"] .badge.cancelled { + background: #3a2520; +} +[data-theme="dark"] .badge.completed { + background: #1f2e21; +} +[data-theme="dark"] .dataset-picker { + --picker-panel: #252420; + --picker-hair: #3a3834; +} +[data-theme="dark"] .dataset-picker .card-desc { + color: var(--ink); +} + * { box-sizing: border-box; } html, body { @@ -880,6 +942,26 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c } .masthead-link:hover { border-bottom-color: var(--accent); } +.theme-toggle { + background: transparent; + border: 0; + color: var(--mute); + font-size: 1.05rem; + line-height: 1; + cursor: pointer; + padding: 0 0 0 0.55rem; + vertical-align: middle; + transition: color 120ms ease, transform 240ms ease; + user-select: none; +} +.theme-toggle:hover { color: var(--accent); } +.theme-toggle:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; +} +[data-theme="dark"] .theme-toggle { transform: rotate(180deg); } + .masthead .nav-link { font-family: var(--mono); font-size: 0.78rem; diff --git a/app/web/static/theme.js b/app/web/static/theme.js new file mode 100644 index 0000000..130371c --- /dev/null +++ b/app/web/static/theme.js @@ -0,0 +1,29 @@ +// theme.js — toggle UI wire-up. The initial theme is set by a tiny inline +// script in the so there's no flash of the wrong palette on load; +// this module only handles click-to-toggle + broadcasting the change so +// anything that cached a CSS-var value (e.g. the metrics page's plot +// palette) can re-render. + +(function () { + function current() { + return document.documentElement.getAttribute("data-theme") || "light"; + } + function apply(theme) { + document.documentElement.setAttribute("data-theme", theme); + try { localStorage.setItem("theme", theme); } catch (e) {} + document.dispatchEvent(new CustomEvent("themechange", { detail: { theme } })); + const btn = document.getElementById("theme-toggle"); + if (btn) btn.setAttribute("aria-label", theme === "dark" ? "switch to light mode" : "switch to dark mode"); + } + function wire() { + const btn = document.getElementById("theme-toggle"); + if (!btn) return; + btn.addEventListener("click", () => apply(current() === "dark" ? "light" : "dark")); + apply(current()); + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", wire); + } else { + wire(); + } +})(); diff --git a/app/web/templates/index.html b/app/web/templates/index.html index 78f435d..32da13f 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -4,7 +4,7 @@ embedding notebook — web1 - + + @@ -25,7 +32,8 @@
{% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %} - metrics →
+ metrics → +
{{ prefect_api }}
@@ -185,6 +193,7 @@ web · scientific instrument · port 8001 + diff --git a/app/web/templates/metrics.html b/app/web/templates/metrics.html index fa4042a..93f6864 100644 --- a/app/web/templates/metrics.html +++ b/app/web/templates/metrics.html @@ -4,8 +4,15 @@ metrics — embedding notebook - + + @@ -14,7 +21,8 @@

embedding notebook — stability metrics

- ← runs
+ ← runs +
{{ prefect_api }}
@@ -85,7 +93,8 @@ web · metrics · port 8001 - + +