Skip to content
quarry Artifact [ab49ef5687]
Login

Artifact [ab49ef5687]

Artifact ab49ef56873219ccbd332b09019f715a4b8402114bbc0ace6dcbf52db89ffbd0:


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,
		// --slice-ghost: a cell the chosen person was not on. 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,
	}
	// 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)
	}
}