a73x

3e860222

site: the reader picks the theme, not just the browser

a73x   2026-08-18 19:02

Commit message
site: the reader picks the theme, not just the browser

The page followed prefers-color-scheme alone, and Firefox answers that
from its own appearance setting rather than the system's — so the same
page arrived dark in one browser and light in another with nothing the
reader could do about it. The nav now carries a control: sign-in and the
theme sit right, the sections sit left, and a choice is remembered.

The choice is applied before first paint, so a stored theme never flashes
the other one first, and the control starts hidden — with no script the
browser's preference still decides, and a dead button would be a lie.
The label reserves the width of the longer word so flipping it does not
shove the nav around.

internal/site/site_test.go
Old New
@@ -244,3 +244,30 @@ func TestBuildDescribesEachPageFromItsProse(t *testing.T) {
244 t.Errorf("faq page not described from its prose:\n%s", html) 244 t.Errorf("faq page not described from its prose:\n%s", html)
245 } 245 }
246 } 246 }
247
248 // The theme control lives in the shipped template, which every page is
249 // rendered through — so this reads that file rather than a fixture standing in
250 // for it. Firefox answers prefers-color-scheme from its own appearance
251 // setting, so a reader whose browser disagrees with them needs the override;
252 // the pre-paint script is what keeps a stored choice from flashing the other
253 // theme first, and the hidden attribute is deliberate — with no script the
254 // browser's preference decides and a dead button would be a lie.
255 func TestShippedTemplateCarriesTheThemeControl(t *testing.T) {
256 tmpl := read(t, filepath.Join("..", "..", "site", "template.html"))
257 for _, want := range []string{
258 `localStorage.getItem("eitri-theme")`, // applied before first paint
259 `localStorage.setItem("eitri-theme"`, // and remembered
260 `<button type="button" class="theme" hidden>`,
261 `root.dataset.theme = dark() ? "light" : "dark"`,
262 } {
263 if !strings.Contains(tmpl, want) {
264 t.Errorf("site/template.html no longer carries %q", want)
265 }
266 }
267 css := read(t, filepath.Join("..", "..", "site", "style.css"))
268 for _, want := range []string{`:root[data-theme="dark"]`, `:root:not([data-theme="light"])`} {
269 if !strings.Contains(css, want) {
270 t.Errorf("site/style.css no longer honours %q — the toggle would set an attribute nothing reads", want)
271 }
272 }
273 }
site/style.css
Old New
@@ -1,11 +1,24 @@
1 * { margin: 0; padding: 0; box-sizing: border-box; } 1 * { margin: 0; padding: 0; box-sizing: border-box; }
2 2
3 /* The tab icon inverts with the reader's theme; the page follows it. */ 3 /* The tab icon inverts with the reader's theme; the page follows it — unless
4 :root { color-scheme: light dark; } 4 the reader has said otherwise, in which case data-theme wins over the
5 browser. Firefox reports a preference from its own appearance setting
6 rather than the system's, so "the browser knows" is not enough on its own. */
7 :root {
8 color-scheme: light dark;
9 --bg: #ffffff;
10 --fg: #1a1a1a;
11 }
12
13 @media (prefers-color-scheme: dark) {
14 :root:not([data-theme="light"]) { --bg: #1a1a1a; --fg: #ececec; }
15 }
16
17 :root[data-theme="dark"] { --bg: #1a1a1a; --fg: #ececec; }
5 18
6 body { 19 body {
7 background: #ffffff; 20 background: var(--bg);
8 color: #1a1a1a; 21 color: var(--fg);
9 font-family: monospace; 22 font-family: monospace;
10 max-width: 72ch; 23 max-width: 72ch;
11 margin: 0 auto; 24 margin: 0 auto;
@@ -13,15 +26,28 @@ body {
13 line-height: 1.5; 26 line-height: 1.5;
14 } 27 }
15 28
16 @media (prefers-color-scheme: dark) {
17 body { background: #1a1a1a; color: #ececec; }
18 }
19
20 a { color: inherit; } 29 a { color: inherit; }
21 30
22 nav { margin-bottom: 2em; } 31 /* The theme control is a nav word that happens to be a button. */
23 nav a { margin-right: 1em; } 32 .theme {
33 font: inherit;
34 color: inherit;
35 background: none;
36 border: 0;
37 padding: 0;
38 text-decoration: underline;
39 cursor: pointer;
40 }
41
42 /* Where you are goes left; what you are signed in as, and how the page
43 looks, go right. */
44 nav { display: flex; flex-wrap: wrap; align-items: baseline; gap: 1em; margin-bottom: 2em; }
24 nav a.active { font-weight: bold; text-decoration: none; } 45 nav a.active { font-weight: bold; text-decoration: none; }
46 nav .right { margin-left: auto; }
47 nav .sep { opacity: 0.5; }
48 /* "light" is a character wider than "dark": reserve the wider word so the
49 nav does not shift when the theme flips. */
50 nav .theme { display: inline-block; min-width: 5ch; text-align: right; }
25 51
26 h1 { margin-bottom: 0.5em; } 52 h1 { margin-bottom: 0.5em; }
27 h2 { margin-top: 1.5em; margin-bottom: 0.5em; } 53 h2 { margin-top: 1.5em; margin-bottom: 0.5em; }
site/template.html
Old New
@@ -6,6 +6,13 @@
6 <title>{{.Title}}</title> 6 <title>{{.Title}}</title>
7 <meta name="description" content="{{.Description}}"> 7 <meta name="description" content="{{.Description}}">
8 <link rel="canonical" href="{{.URL}}"> 8 <link rel="canonical" href="{{.URL}}">
9 <script>
10 // Before first paint: a stored choice must not flash the other theme first.
11 try {
12 var t = localStorage.getItem("eitri-theme");
13 if (t) document.documentElement.dataset.theme = t;
14 } catch (e) {}
15 </script>
9 <link rel="stylesheet" href="/style.css"> 16 <link rel="stylesheet" href="/style.css">
10 <link rel="icon" href="/favicon.svg"> 17 <link rel="icon" href="/favicon.svg">
11 18
@@ -27,9 +34,34 @@
27 <a href="/"{{if eq .Section "home"}} class="active"{{end}}>home</a> 34 <a href="/"{{if eq .Section "home"}} class="active"{{end}}>home</a>
28 <a href="/docs/"{{if eq .Section "docs"}} class="active"{{end}}>docs</a> 35 <a href="/docs/"{{if eq .Section "docs"}} class="active"{{end}}>docs</a>
29 <a href="/dl/"{{if eq .Section "dl"}} class="active"{{end}}>downloads</a> 36 <a href="/dl/"{{if eq .Section "dl"}} class="active"{{end}}>downloads</a>
30 <a href="https://console.eitri.sh">sign in</a> 37 <a class="right" href="https://console.eitri.sh">sign in</a>
38 <span class="sep" hidden>|</span>
39 <button type="button" class="theme" hidden>theme</button>
31 </nav> 40 </nav>
32 41
42 <script>
43 // The control only appears where it can work: with no script, the browser's
44 // own preference still decides and a dead button would be a lie.
45 (function () {
46 var root = document.documentElement, btn = document.querySelector(".theme");
47 var sep = document.querySelector(".sep");
48 var dark = function () {
49 return root.dataset.theme
50 ? root.dataset.theme === "dark"
51 : matchMedia("(prefers-color-scheme: dark)").matches;
52 };
53 var label = function () { btn.textContent = dark() ? "light" : "dark"; };
54 btn.hidden = false;
55 sep.hidden = false;
56 label();
57 btn.addEventListener("click", function () {
58 root.dataset.theme = dark() ? "light" : "dark";
59 try { localStorage.setItem("eitri-theme", root.dataset.theme); } catch (e) {}
60 label();
61 });
62 })();
63 </script>
64
33 {{.Content}} 65 {{.Content}}
34 </body> 66 </body>
35 </html> 67 </html>