Skip to content
quarry Artifact [f1714ee015]
Login

Artifact [f1714ee015]

Artifact f1714ee0156411fa834f54be1490cf0caebb55fb1ed605176c8f66c958742818:


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", "#e8f0d2"},
		{"light text on del row", "#282828", "#fae4dc"},
		{"light text on add mark", "#282828", "#d3e4ae"},
		{"light text on del mark", "#282828", "#f4cbbd"},
		{"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.
		"#e8f0d2": true, "#fae4dc": true, "#d3e4ae": true, "#f4cbbd": true,
		// --code-block, light (dark reuses #32302f): text and links on it
		// are TestSyntaxReadsOnEveryCodeGround's "block" ground.
		"#fbf8ef": true,
		"#2c3122": true, "#382724": true, "#384226": true, "#4a2d27": true,
		// Syntax, both themes — TestSyntaxReadsOnEveryCodeGround.
		"#8d3e70": true, "#7c510e": true, "#356246": true, "#62584d": 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": "#62584d", "builtin": "#a23603", "attr": "#076273", "add": "#605c0b", "del": "#9d0006", "fg": "#282828"},
			map[string]string{"block": "#fbf8ef", "page": "#f3ecda", "add-row": "#e8f0d2", "del-row": "#fae4dc", "add-mark": "#d3e4ae", "del-mark": "#f4cbbd"}},
		{"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{"block": "#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)
				}
			}
		}
	}
}