3e860222
site: the reader picks the theme, not just the browser
a73x 2026-08-18 19:02
Commit message
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> |