Add a theme toggle to the nav, and centralise the palette

Three states rather than two: system (follow the OS), light, dark. A two-state
toggle cannot express "just do what my laptop does", which is the state most
people actually want.

Two details that are easy to get wrong and were both handled deliberately:

- The dark rules are guarded with :root:not([data-theme="light"]). Without that
  guard, choosing light does nothing on a machine whose OS is set to dark,
  because the media query keeps winning -- the toggle appears to work in one
  direction only.
- The stored theme is applied by an inline script placed ahead of any rendered
  content, so there is no flash of the wrong palette on load. A deferred script
  would repaint after first paint, visibly.

localStorage access is wrapped in try/catch: it throws outright in some
private-browsing modes, and a theme preference is not worth a broken page.

The palette moved out of all four templates into _theme.html, which also removes
four near-identical copies of the nav CSS that had already started to drift.
Tests assert the toggle, the guard, the pre-paint ordering, and that no page
redeclares the palette locally.
This commit is contained in:
connor committed 2026-09-08 18:00:01 -04:00
1 parent 7f402f22fe
commit 8ed007a75f
7 files changed
+97 -47

No files matched your search

+25
View File
@@ -4,4 +4,29 @@
<a href="/digest" class="{{ 'here' if current == 'digest' }}">Digest</a> <a href="/digest" class="{{ 'here' if current == 'digest' }}">Digest</a>
<a href="/tune" class="{{ 'here' if current == 'tune' }}">Tune</a> <a href="/tune" class="{{ 'here' if current == 'tune' }}">Tune</a>
<a href="/config" class="{{ 'here' if current == 'config' }}">Config</a> <a href="/config" class="{{ 'here' if current == 'config' }}">Config</a>
<button id="theme" type="button" title="Theme: system / light / dark"
aria-label="Switch theme">◐</button>
</nav> </nav>
<script>
(function () {
const btn = document.getElementById('theme');
if (!btn) return;
const ICON = {system: '\u25D0', light: '\u2600', dark: '\u263E'};
const read = () => {
try { return localStorage.getItem('theme') || 'system'; } catch (e) { return 'system'; }
};
const paint = m => { btn.textContent = ICON[m]; btn.title = 'Theme: ' + m; };
const apply = m => {
if (m === 'system') delete document.documentElement.dataset.theme;
else document.documentElement.dataset.theme = m;
try { m === 'system' ? localStorage.removeItem('theme') : localStorage.setItem('theme', m); }
catch (e) { /* storage blocked; the choice just will not persist */ }
paint(m);
};
paint(read());
btn.addEventListener('click', () => {
const order = ['system', 'light', 'dark'];
apply(order[(order.indexOf(read()) + 1) % order.length]);
});
})();
</script>
+44
View File
@@ -0,0 +1,44 @@
{#- Shared palette, theme toggle and nav styling.
Three states, not two: system (follow the OS), light, dark. The dark rules
are guarded with :not([data-theme="light"]) so an explicit light choice wins
over a dark OS setting -- without that guard the toggle only works one way.
-#}
<script>
// Runs before first paint. Applying the stored theme from a deferred script
// would show a flash of the wrong palette on every page load.
try {
const t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light') document.documentElement.dataset.theme = t;
} catch (e) { /* private mode, blocked storage: fall back to the OS setting */ }
</script>
<style>
:root {
color-scheme: light dark;
--fg:#16181d; --bg:#fdfdfc; --muted:#6b7280; --rule:#e4e4e7;
--accent:#7c3aed; --card:#fff; --sel:#f3f0ff;
--pos:#15803d; --neg:#b91c1c; --warn:#b45309;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--fg:#e8e8ea; --bg:#141416; --muted:#9ca3af; --rule:#2a2a2e;
--accent:#a78bfa; --card:#1b1b1f; --sel:#241f36;
--pos:#4ade80; --neg:#f87171; --warn:#fbbf24;
}
}
:root[data-theme="dark"] {
--fg:#e8e8ea; --bg:#141416; --muted:#9ca3af; --rule:#2a2a2e;
--accent:#a78bfa; --card:#1b1b1f; --sel:#241f36;
--pos:#4ade80; --neg:#f87171; --warn:#fbbf24;
}
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; align-items:center; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
#theme { margin-left:.35rem; font:inherit; font-size:.95rem; line-height:1;
background:transparent; color:var(--muted); cursor:pointer;
border:1px solid var(--rule); border-radius:6px; padding:.22rem .45rem; }
#theme:hover { color:var(--fg); }
</style>
+1 -13
View File
@@ -2,15 +2,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Config</title> <title>Config</title>
{% include "_theme.html" %}
<style> <style>
:root { color-scheme: light dark; --fg:#16181d; --bg:#fdfdfc; --muted:#6b7280;
--rule:#e4e4e7; --accent:#7c3aed; --card:#fff; --neg:#b91c1c;
--warn:#b45309; --pos:#15803d; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e8e8ea; --bg:#141416; --muted:#9ca3af; --rule:#2a2a2e;
--accent:#a78bfa; --card:#1b1b1f; --neg:#f87171; --warn:#fbbf24;
--pos:#4ade80; }
}
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); body { margin:0; background:var(--bg); color:var(--fg);
font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
@@ -47,11 +40,6 @@
.nearmiss { border-left:2px solid var(--warn); padding-left:.7rem; margin-top:.6rem; } .nearmiss { border-left:2px solid var(--warn); padding-left:.7rem; margin-top:.6rem; }
.ok { color:var(--pos); } .err { color:var(--neg); } .ok { color:var(--pos); } .err { color:var(--neg); }
.gate { color:var(--warn); } .gate { color:var(--warn); }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
</style> </style>
<main> <main>
+1 -10
View File
@@ -2,12 +2,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Briefing — {{ day }}</title> <title>Briefing — {{ day }}</title>
{% include "_theme.html" %}
<style> <style>
:root { color-scheme: light dark; --fg:#16181d; --bg:#fdfdfc; --muted:#6b7280;
--rule:#e4e4e7; --accent:#7c3aed; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e8e8ea; --bg:#141416; --muted:#9ca3af; --rule:#2a2a2e; --accent:#a78bfa; }
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.6 -apple-system, body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.6 -apple-system,
BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
@@ -32,11 +28,6 @@
nav { display:flex; justify-content:space-between; margin-top:2rem; font-size:.9rem; } nav { display:flex; justify-content:space-between; margin-top:2rem; font-size:.9rem; }
nav a { color:var(--accent); text-decoration:none; } nav a { color:var(--accent); text-decoration:none; }
.empty { color:var(--muted); font-style:italic; } .empty { color:var(--muted); font-style:italic; }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
</style> </style>
<main> <main>
+1 -13
View File
@@ -2,15 +2,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Triage</title> <title>Triage</title>
{% include "_theme.html" %}
<style> <style>
:root { color-scheme: light dark; --fg:#16181d; --bg:#fdfdfc; --muted:#6b7280;
--rule:#e4e4e7; --accent:#7c3aed; --card:#fff; --sel:#f3f0ff;
--pos:#15803d; --neg:#b91c1c; --warn:#b45309; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e8e8ea; --bg:#141416; --muted:#9ca3af; --rule:#2a2a2e;
--accent:#a78bfa; --card:#1b1b1f; --sel:#241f36;
--pos:#4ade80; --neg:#f87171; --warn:#fbbf24; }
}
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); body { margin:0; background:var(--bg); color:var(--fg);
font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
@@ -56,11 +49,6 @@
border-top:1px solid var(--rule); padding:.45rem 1rem; font-size:.78rem; border-top:1px solid var(--rule); padding:.45rem 1rem; font-size:.78rem;
color:var(--muted); display:flex; gap:1rem; justify-content:center; } color:var(--muted); display:flex; gap:1rem; justify-content:center; }
.empty { color:var(--muted); font-style:italic; padding:2rem 0; } .empty { color:var(--muted); font-style:italic; padding:2rem 0; }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
@media (max-width:34rem) { .help { display:none; } } @media (max-width:34rem) { .help { display:none; } }
</style> </style>
+1 -11
View File
@@ -2,13 +2,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tune</title> <title>Tune</title>
{% include "_theme.html" %}
<style> <style>
:root { color-scheme: light dark; --fg:#16181d; --bg:#fdfdfc; --muted:#6b7280;
--rule:#e4e4e7; --accent:#7c3aed; --card:#fff; --neg:#b91c1c; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e8e8ea; --bg:#141416; --muted:#9ca3af; --rule:#2a2a2e;
--accent:#a78bfa; --card:#1b1b1f; --neg:#f87171; }
}
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); body { margin:0; background:var(--bg); color:var(--fg);
font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
@@ -39,11 +34,6 @@
letter-spacing:.04em; min-width:5.5rem; } letter-spacing:.04em; min-width:5.5rem; }
.lane.imp { color:var(--neg); } .lane.imp { color:var(--neg); }
.note { color:var(--muted); font-size:.82rem; } .note { color:var(--muted); font-size:.82rem; }
.sitenav { display:flex; gap:.15rem; flex-wrap:wrap; }
.sitenav a { color:var(--muted); text-decoration:none; font-size:.85rem;
padding:.2rem .6rem; border-radius:6px; border:1px solid transparent; }
.sitenav a:hover { color:var(--fg); background:var(--rule); }
.sitenav a.here { color:var(--accent); border-color:var(--rule); font-weight:600; }
</style> </style>
<main> <main>
+24
View File
@@ -243,3 +243,27 @@ def test_every_page_carries_the_nav(client, path, active):
for target in ("/tune", "/config", "/digest"): for target in ("/tune", "/config", "/digest"):
assert f'href="{target}"' in html, f"{path} is missing a link to {target}" assert f'href="{target}"' in html, f"{path} is missing a link to {target}"
assert f'class="here">{active}<' in html, f"{path} does not mark itself active" assert f'class="here">{active}<' in html, f"{path} does not mark itself active"
@pytest.mark.parametrize("path", ["/", "/tune", "/config", "/digest"])
def test_every_page_has_a_working_theme_toggle(client, path):
"""Three states (system/light/dark), no flash, and the guard that makes the
light choice win over a dark OS setting."""
c, _ = client
html = c.get(path).text
assert 'id="theme"' in html, "theme button missing"
assert '[data-theme="dark"]' in html, "explicit dark rules missing"
assert ':root:not([data-theme="light"])' in html, (
"without this guard, choosing light does nothing under a dark OS theme")
# The theme must be applied before first paint, so its script has to appear
# ahead of any rendered content.
assert html.index("localStorage.getItem('theme')") < html.index("<main")
@pytest.mark.parametrize("path", ["/", "/tune", "/config", "/digest"])
def test_palette_is_defined_once_from_the_shared_partial(client, path):
"""A page that redeclares the palette locally would drift from the others."""
c, _ = client
html = c.get(path).text
assert html.count(".sitenav {") == 1
assert html.count("--accent:#7c3aed") == 1, "light palette declared more than once"