vitrine

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")