theme+style: follow prefers-color-scheme for a dark palette
commit 496af3b480ef83b4aa7a2c26479abfea95bfcaf6 · parent a4fa43a129
· browse this tree
Ximianth, 2026-08-09 23:58:28 UTC
Adds a @media (prefers-color-scheme: dark) override block to the base
palette (--ink, --rule, --paper, --well, --well-edge) and to the
per-type callout/diagram-error colors, which move from hardcoded hex
into --tip/--success/--todo/--warning/--danger vars so the dark block
can override them without touching the selectors that consume them.
theme.go's newTheme now emits two :root { --accent: ... } blocks (light
and dark) instead of one; themeLightnessDark is a flat 70% lightness,
since unlike the light background a single value clears WCAG AA across
the whole hue wheel with margin (no yellow/green/cyan band to special-
case, unlike themeLightness).
There is no in-page toggle: vitrine ships no JavaScript, so there is
nowhere to store one. The browser/OS preference is the only input.
Contrast measurements (sRGB relative luminance, same method as the
hostname-theming work) are recorded in the vitrine.meta worklog.
diff --git a/README.md b/README.md index 4303631..74a3f05 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ on every build and every host. The `-theme-seed` flag substitutes any string for the hostname segment -- useful for testing, or if you dislike the color your hostname landed on. +Pages follow the browser's `prefers-color-scheme`, light or dark, +with no in-page toggle (vitrine ships no JavaScript, so there is nowhere to store one). +Both palettes are picked to clear WCAG AA contrast (4.5:1) against their background, +and server-rendered diagrams (see Diagrams below) invert to match. + Callouts -------- diff --git a/style.css b/style.css index 381d21d..bc9dbea 100644 --- a/style.css +++ b/style.css @@ -8,6 +8,31 @@ --paper: #fdfdfc; --well: #f3f5f7; --well-edge: #c9d1d9; + --tip: #c99000; + --success: #1a7f37; + --todo: #8250df; + --warning: #ef7c00; + --danger: #cf222e; +} + +/* Dark mode follows the OS/browser preference; there is no in-page toggle + (vitrine ships no JavaScript, so there is nowhere to store one). + Every color below is picked to clear WCAG AA (4.5:1) against --paper; + see the vitrine.meta worklog on dark mode for the measurements. */ +@media (prefers-color-scheme: dark) { + :root { + --ink: #e6edf3; + --ink-soft: #9198a1; + --rule: #30363d; + --paper: #0d1117; + --well: #161b22; + --well-edge: #30363d; + --tip: #d29922; + --success: #3fb950; + --todo: #a371f7; + --warning: #f0883e; + --danger: #f85149; + } } body { @@ -153,12 +178,12 @@ article .callout-title::before { these and falls back to the neutral base box above, still readable. */ article .callout-info, article .callout-note { --callout-icon: "\2139\FE0F"; } /* information source, theme color */ -article .callout-tip { --callout: #c99000; --callout-icon: "\1F4A1"; } /* light bulb, sunflower gold */ -article .callout-success { --callout: #1a7f37; --callout-icon: "\2705"; } /* check mark, green */ -article .callout-todo { --callout: #8250df; --callout-icon: "\1F6A7"; } /* construction sign, purple */ -article .callout-warning { --callout: #ef7c00; --callout-fill: 18%; --callout-icon: "\26A0\FE0F"; } /* warning sign, orange (weaker hue, so tinted harder) */ -article .callout-danger { --callout: #cf222e; --callout-icon: "\1F9E8"; } /* firecracker, red */ -article .callout-reject { --callout: #cf222e; --callout-icon: "\274C"; } /* cross mark, red */ +article .callout-tip { --callout: var(--tip); --callout-icon: "\1F4A1"; } /* light bulb, sunflower gold */ +article .callout-success { --callout: var(--success); --callout-icon: "\2705"; } /* check mark, green */ +article .callout-todo { --callout: var(--todo); --callout-icon: "\1F6A7"; } /* construction sign, purple */ +article .callout-warning { --callout: var(--warning); --callout-fill: 18%; --callout-icon: "\26A0\FE0F"; } /* warning sign, orange (weaker hue, so tinted harder) */ +article .callout-danger { --callout: var(--danger); --callout-icon: "\1F9E8"; } /* firecracker, red */ +article .callout-reject { --callout: var(--danger); --callout-icon: "\274C"; } /* cross mark, red */ /* Diagrams: server-rendered inline SVG from ```dot fenced blocks. @@ -172,19 +197,29 @@ article .diagram svg { max-width: 100%; height: auto; } +/* Graphviz always draws on an opaque white canvas with black ink, regardless + of the page theme (DOT has no notion of a page dark mode). + Inverting lightness while rotating hue back into place turns that white + canvas dark and keeps colored edges/nodes close to their original hue, + without needing to parse or rewrite the SVG's own fill/stroke attributes. */ +@media (prefers-color-scheme: dark) { + article .diagram svg { + filter: invert(0.9) hue-rotate(180deg); + } +} /* A diagram that failed to render echoes its source in a bordered well, flagged in the danger hue so the failure is visible at a glance. */ article .diagram-error { text-align: left; - border: 1px solid #cf222e; + border: 1px solid var(--danger); border-radius: .25rem; - background: color-mix(in srgb, #cf222e 8%, var(--paper)); + background: color-mix(in srgb, var(--danger) 8%, var(--paper)); padding: .4rem .8rem; } article .diagram-error-note { margin: .2rem 0; font-weight: bold; - color: #cf222e; + color: var(--danger); } article .diagram-error pre { margin-left: 0; } diff --git a/theme.go b/theme.go index ccf8209..3e5f92a 100644 --- a/theme.go +++ b/theme.go @@ -15,14 +15,17 @@ import ( // theme is one host's visual identity, computed once at startup. type theme struct { Name string // the seed itself, shown in the nav next to the site name - CSS []byte // ":root" accent override, appended after the stylesheet by the /-/style.css handler + CSS []byte // ":root" accent override (light and dark), appended after the stylesheet by the /-/style.css handler } func newTheme(seed string) theme { hue := themeHue(seed) return theme{ Name: seed, - CSS: []byte(fmt.Sprintf(":root { --accent: hsl(%d 70%% %d%%); }\n", hue, themeLightness(hue))), + CSS: []byte(fmt.Sprintf( + ":root { --accent: hsl(%d 70%% %d%%); }\n"+ + "@media (prefers-color-scheme: dark) { :root { --accent: hsl(%d 70%% %d%%); } }\n", + hue, themeLightness(hue), hue, themeLightnessDark)), } } @@ -57,3 +60,10 @@ func themeLightness(hue int) int { } return 35 } + +// themeLightnessDark is the accent lightness (percent) used against the dark +// --paper background (see the dark-mode block in style.css). +// Unlike the light background, a single flat lightness clears WCAG AA across +// the whole wheel with margin: measured worst case ~5.38:1 at hue 240, +// so there is no yellow/green/cyan band to special-case here. +const themeLightnessDark = 70 diff --git a/theme_test.go b/theme_test.go index 5a9fc7a..4de0452 100644 --- a/theme_test.go +++ b/theme_test.go @@ -58,7 +58,7 @@ func TestHostLabel(t *testing.T) { } // TestStylesheetCarriesThemeAccent checks that /-/style.css ends with the -// computed :root override, after the embedded stylesheet: +// computed :root override (light and dark), after the embedded stylesheet: // at equal specificity the later declaration wins, // so appending (not prepending) is what makes the theme accent take effect. func TestStylesheetCarriesThemeAccent(t *testing.T) { @@ -67,10 +67,11 @@ func TestStylesheetCarriesThemeAccent(t *testing.T) { t.Fatalf("status = %d", rec.Code) } body := rec.Body.String() - // "testhost" is the seed newTestServer uses; it hashes to hue 351, lightness 35%. - want := ":root { --accent: hsl(351 70% 35%); }\n" + // "testhost" is the seed newTestServer uses; it hashes to hue 351, lightness 35%/70%. + want := ":root { --accent: hsl(351 70% 35%); }\n" + + "@media (prefers-color-scheme: dark) { :root { --accent: hsl(351 70% 70%); } }\n" if !strings.HasSuffix(body, want) { - t.Errorf("stylesheet does not end with theme override %q; tail: %q", want, body[max(0, len(body)-120):]) + t.Errorf("stylesheet does not end with theme override %q; tail: %q", want, body[max(0, len(body)-200):]) } if !strings.Contains(body, "--paper") { t.Errorf("embedded stylesheet missing before theme override")