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", "#605c0b", "#f3ecda"},
{"light diff del", "#9d0006", "#f3ecda"},
{"dark diff add", "#b8bb26", "#1d2021"},
{"dark diff del", "#fc8c7f", "#1d2021"},
// Diff row and mark tints are grounds under body text — the
// whole line stays readable inside a tinted region. Syntax
// colours on these grounds are TestSyntaxReadsOnEveryCodeGround.
{"light text on add row", "#282828", "#e4e6c3"},
{"light text on del row", "#282828", "#f3dccd"},
{"light text on add mark", "#282828", "#d8dcb0"},
{"light text on del mark", "#282828", "#efcdbb"},
{"dark text on add row", "#f0ece4", "#2c3122"},
{"dark text on del row", "#f0ece4", "#382724"},
{"dark text on add mark", "#f0ece4", "#384226"},
{"dark text on del mark", "#f0ece4", "#4a2d27"},
// --warn, the one severity colour that is not the diff palette
// under another name. It reads as TEXT on both grounds, so it
// is checked against the page and the secondary surface, the
// same pair links get. Its predecessor was not a token at all:
// nine rules read var(--warn, …) for a property declared
// nowhere, and #b60 — the fallback six of them fell through to
// — is 3.55:1 on cream, which is why this row exists.
{"light warn on page", "#7a4a00", "#f3ecda"},
{"light warn on code", "#7a4a00", "#e6dcc0"},
{"dark warn on page", "#e3b341", "#1d2021"},
{"dark warn on code", "#e3b341", "#32302f"},
} {
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, "#605c0b": true, "#9d0006": true,
"#f0ece4": true, "#1d2021": true, "#a89984": true, "#32302f": true,
"#504945": true, "#d99058": true, "#bdb6ab": true,
"#665418": true, "#b8bb26": true, "#fc8c7f": true,
// Diff row/mark grounds, both themes — paired with body text in
// the contrast table above.
"#e4e6c3": true, "#f3dccd": true, "#d8dcb0": true, "#efcdbb": true,
"#2c3122": true, "#382724": true, "#384226": true, "#4a2d27": true,
// Syntax, both themes — TestSyntaxReadsOnEveryCodeGround.
"#8d3e70": true, "#7c510e": true, "#356246": true, "#635850": true,
"#a23603": true, "#076273": true,
"#da99aa": true, "#fabd2f": true, "#8ec07c": true, "#b6a997": true,
"#fe8e32": true, "#95b2a7": true,
// --slice-empty: a cell with no work in it. Like --rule it is
// a shape, never a ground under text, so it has no pair in the
// table above; it only has to read as darker than the page and
// lighter than anyone's colour.
"#e2d8bf": true, "#3a3735": true,
// --warn, both themes. --ok and --danger declare no hex of
// their own: they are var(--add) and var(--del), already
// checked above.
"#7a4a00": true, "#e3b341": 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)
}
}
// SYNTAX READS ON EVERY GROUND IT CAN SIT ON.
//
// Code used chroma's stock github/github-dark themes, whose colours the
// palette never saw: a near-black slab of saturated red and green in a
// Gruvbox page. The syntax colours are palette tokens now, and each is
// text on the code ground, the page, both diff rows and both word
// marks, in both themes.
func TestSyntaxReadsOnEveryCodeGround(t *testing.T) {
const aa = 4.5
themes := []struct {
name string
text map[string]string
grounds map[string]string
}{
{"light",
map[string]string{"kw": "#9d0006", "str": "#605c0b", "num": "#8d3e70", "fn": "#7c510e", "type": "#356246", "com": "#635850", "builtin": "#a23603", "attr": "#076273", "add": "#605c0b", "del": "#9d0006", "fg": "#282828"},
map[string]string{"code": "#e6dcc0", "page": "#f3ecda", "add-row": "#e4e6c3", "del-row": "#f3dccd", "add-mark": "#d8dcb0", "del-mark": "#efcdbb"}},
{"dark",
map[string]string{"kw": "#fc8c7f", "str": "#b8bb26", "num": "#da99aa", "fn": "#fabd2f", "type": "#8ec07c", "com": "#b6a997", "builtin": "#fe8e32", "attr": "#95b2a7", "add": "#b8bb26", "del": "#fc8c7f", "fg": "#f0ece4"},
map[string]string{"code": "#32302f", "page": "#1d2021", "add-row": "#2c3122", "del-row": "#382724", "add-mark": "#384226", "del-mark": "#4a2d27"}},
}
for _, th := range themes {
for tn, fg := range th.text {
if tn != "fg" && !strings.Contains(paletteCSS, "--syn-"+tn+": "+fg) && tn != "add" && tn != "del" {
t.Errorf("FAIL: %s --syn-%s is not %s in the palette; this table is stale", th.name, tn, fg)
}
for gn, bg := range th.grounds {
if got := contrast(fg, bg); got < aa {
t.Errorf("FAIL: %s %s %s on %s %s is %.2f:1, below AA", th.name, tn, fg, gn, bg, got)
}
}
}
}
}