Artifact d37c469e5876da9a6de7d5b436e2c0f990a1f747a35904c967cba054dbeec33c:
- File
internal/web/palette_test.go
— part of check-in
[eb4793ee8e]
at
2026-08-31 15:49:44
on branch trunk
— serve the stylesheet instead of inlining it in every page
112,118 bytes of <style> rode on every response — 40% of a 309,584-byte timeline page, byte-identical each time and uncacheable because it had no URL, while the JS assets already carried Cache-Control. Now one hashed asset served immutable: a rule change mints a new URL, so a stale cache is impossible without anyone revalidating. (user: quarry, size: 7293) [annotate] [blame] [check-ins using]
package web import ( "craftdesign.group/quarry/internal/highlight" "math" "regexp" "strconv" "strings" "testing" ) // relLuminance is WCAG 2.x relative luminance for an sRGB hex colour. func relLuminance(hex string) float64 { hex = strings.TrimPrefix(hex, "#") chan_ := func(i int) float64 { v, _ := strconv.ParseUint(hex[i:i+2], 16, 8) c := float64(v) / 255 if c <= 0.04045 { return c / 12.92 } return math.Pow((c+0.055)/1.055, 2.4) } return 0.2126*chan_(0) + 0.7152*chan_(2) + 0.0722*chan_(4) } func contrast(a, b string) float64 { la, lb := relLuminance(a), relLuminance(b) hi, lo := math.Max(la, lb), math.Min(la, lb) return (hi + 0.05) / (lo + 0.05) } // The palette is tuned by eye, which is exactly why it needs a floor // under it: "a shade whiter" and "a bit more muted" are adjustments that // drift toward lower contrast, and nothing else in the build would // notice. Links are checked against --code-bg too, because that is the // secondary surface pills and code spans put them on — a pair that // passes on the page ground can still fail there. func TestPaletteContrastMeetsAA(t *testing.T) { const aa = 4.5 for _, tc := range []struct{ name, fg, bg string }{ {"light body on page", "#282828", "#f3ecda"}, {"light body on code", "#282828", "#e6dcc0"}, {"light muted on page", "#665c54", "#f3ecda"}, {"light link on page", "#a04412", "#f3ecda"}, {"light link on code", "#a04412", "#e6dcc0"}, {"dark body on page", "#f0ece4", "#1d2021"}, {"dark body on code", "#f0ece4", "#32302f"}, {"dark muted on page", "#bdb6ab", "#1d2021"}, {"dark muted on code", "#bdb6ab", "#32302f"}, {"dark link on page", "#d99058", "#1d2021"}, {"dark link on code", "#d99058", "#32302f"}, // The search highlight and the diff/danger pair are tokens too, // and each is read as text sitting on its own ground. {"light text on highlight", "#282828", "#f7dd9a"}, {"dark text on highlight", "#f0ece4", "#665418"}, {"light diff add", "#116329", "#f3ecda"}, {"light diff del", "#a40e26", "#f3ecda"}, {"dark diff add", "#56d364", "#1d2021"}, {"dark diff del", "#ff7b72", "#1d2021"}, // Diff row and mark tints are grounds under body text — the // whole line stays readable inside a tinted region. {"light text on add row", "#282828", "#dafbe1"}, {"light text on del row", "#282828", "#ffebe9"}, {"light text on add mark", "#282828", "#aceebb"}, {"light text on del mark", "#282828", "#ffcecb"}, {"dark text on add row", "#f0ece4", "#203326"}, {"dark text on del row", "#f0ece4", "#3e2727"}, {"dark text on add mark", "#f0ece4", "#22492c"}, {"dark text on del mark", "#f0ece4", "#5f2f2d"}, } { if got := contrast(tc.fg, tc.bg); got < aa { t.Errorf("%s: %s on %s is %.2f:1, below AA %.1f:1", tc.name, tc.fg, tc.bg, got, aa) } } } // Every colour the stylesheet defines must be one the test above knows // about, or a new token could ship unchecked. func TestEveryPaletteTokenIsContrastChecked(t *testing.T) { known := map[string]bool{ "#282828": true, "#f3ecda": true, "#665c54": true, "#e6dcc0": true, "#d5c4a1": true, "#a04412": true, "#f7dd9a": true, "#116329": true, "#a40e26": true, "#f0ece4": true, "#1d2021": true, "#a89984": true, "#32302f": true, "#504945": true, "#d99058": true, "#bdb6ab": true, "#665418": true, "#56d364": true, "#ff7b72": true, // Diff row/mark grounds, both themes — paired with body text in // the contrast table above. "#dafbe1": true, "#ffebe9": true, "#aceebb": true, "#ffcecb": true, "#203326": true, "#3e2727": true, "#22492c": true, "#5f2f2d": true, } // Match 8-digit colours too, and skip them: a colour with an alpha // channel is a wash over an unknown ground, so there is no pair to // check. Matching only 6 digits would silently capture the first // six of an 8-digit value and treat a 10% tint as an opaque colour. decl := regexp.MustCompile(`--[a-z-]+:\s*(#[0-9a-fA-F]{8}|#[0-9a-fA-F]{6})\b`) found := decl.FindAllStringSubmatch(paletteCSS, -1) if len(found) < 27 { t.Fatalf("found only %d palette declarations; the palette moved", len(found)) } for _, m := range found { if len(m[1]) == 9 { continue // translucent; see above } if !known[strings.ToLower(m[1])] { t.Errorf("palette colour %s is not contrast-checked in TestPaletteContrastMeetsAA", m[1]) } } } // A highlight has to be visible against the ground it sits on, which is // not the same requirement as its text being readable. #fff3b8 was // picked for a white page and differs from our cream by a luminance of // 0.047 — legible, but barely distinguishable from the page it marks. func TestHighlightIsVisibleAgainstItsPage(t *testing.T) { const minDelta = 0.06 for _, tc := range []struct{ name, mark, page string }{ {"light", "#f7dd9a", "#f3ecda"}, {"dark", "#665418", "#1d2021"}, } { if d := math.Abs(relLuminance(tc.mark) - relLuminance(tc.page)); d < minDelta { t.Errorf("%s highlight %s differs from page %s by only %.3f luminance; it will not read as a highlight", tc.name, tc.mark, tc.page, d) } } } // Every theme-dependent colour must live in paletteCSS. A media query // anywhere else is almost always missing the :root:not([data-theme=...]) // guard, which shows up only for a reader who picks light on a // dark-mode device — the case nobody tests by hand. func TestNoColourSchemeQueriesOutsideThePalette(t *testing.T) { // Two stylesheets legitimately theme themselves: paletteCSS and the // vendored syntax highlighter, which guards its own three states // (see highlight.TestDarkRulesAreThemeGuarded). Everything else in // the page must take its colours from tokens — a media query // anywhere else is almost always missing the // :root:not([data-theme="light"]) guard, which shows up only for a // reader who picks light on a dark-mode device. rest := strings.Replace(styleSource, paletteCSS, "", 1) rest = strings.Replace(rest, highlight.CSS(), "", 1) if n := strings.Count(rest, "prefers-color-scheme"); n != 0 { t.Errorf("%d hand-written prefers-color-scheme queries outside the palette; each is a theme state that can disagree with the others", n) } } // A stray brace anywhere in the stylesheet makes the parser silently // drop the NEXT rule — no error, no console line, just one rule gone. // An orphaned close-brace left by an edit dropped .colhead's flex rule // and broke the fleet header's spacing invisibly. Brace balance is the // whole class of that bug, and it is checkable. func TestStylesheetBracesBalance(t *testing.T) { // The sheet itself, not the page: the stylesheet is served as a // hashed asset now, so the page holds a <link> and no braces. css := sheet // Comments may hold example braces; strip them first. for { i := strings.Index(css, "/*") if i < 0 { break } j := strings.Index(css[i:], "*/") if j < 0 { t.Fatal("unterminated CSS comment") } css = css[:i] + css[i+j+2:] } depth, line := 0, 1 for _, r := range css { switch r { case '\n': line++ case '{': depth++ case '}': depth-- if depth < 0 { t.Fatalf("stray '}' near stylesheet line %d: the next rule is silently dropped", line) } } } if depth != 0 { t.Fatalf("stylesheet ends %d braces deep: trailing rules are silently dropped", depth) } }