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(baseHTML, 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) {
src := baseHTML
start := strings.Index(src, "<style>")
end := strings.Index(src, "</style>")
if start < 0 || end < 0 {
t.Fatal("no style block")
}
css := src[start:end]
// 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)
}
}