diff --git a/go/cmd/aria-check/check.go b/go/cmd/aria-check/check.go new file mode 100644 index 00000000..2f28170b --- /dev/null +++ b/go/cmd/aria-check/check.go @@ -0,0 +1,308 @@ +package main + +// check.go is the contrast checker proper: it takes the scanned class groups and the +// resolver, works out which foreground/background pairs actually co-occur (per theme +// and per variant state), resolves and composites their colours, and measures each +// pair against the WCAG threshold. +// +// The variant model is deliberately conservative. Only two contexts are evaluated: +// the resting light appearance and the resting dark appearance. `dark:` re-points a +// token (and re-points the whole variable environment), so it is a real second +// appearance worth checking. Interaction and pseudo states (hover:, focus:, group-*, +// data-*, …) are transient and are skipped rather than guessed at — pairing a +// hover-only background with a resting text colour invents an element that never +// renders. Responsive prefixes (sm:, md:, …) are stripped, since they change *when* a +// utility applies, not its colour. + +import "strings" + +// Options tunes the check. +type Options struct { + Level string // "AA" or "AAA" + MinOverride float64 // if > 0, the required ratio for normal-size text + AssumeSurface bool // check foreground-only groups against the page surface +} + +// Finding is one evaluated foreground/background pair. +type Finding struct { + File string + Line int + Snip string + Theme theme + FG, BG string // the tokens ("" BG means the assumed page surface) + FGColor RGBA + BGColor RGBA + Ratio float64 + Required float64 + Large bool + Pass bool +} + +func (f Finding) ThemeName() string { + if f.Theme == dark { + return "dark" + } + return "light" +} + +// Check evaluates every group and returns all pairs (passing and failing); callers +// filter by Pass for reporting. +func Check(groups []ClassGroup, r *Resolver, opt Options) []Finding { + var out []Finding + for _, g := range groups { + out = append(out, checkGroup(g, r, opt)...) + } + return out +} + +func checkGroup(g ClassGroup, r *Resolver, opt Options) []Finding { + var baseBG, baseFG, darkBG, darkFG []string + for _, tok := range g.Tokens { + cat := variantCategory(tok) + if cat == ctxSkip { + continue + } + switch r.side(tok) { + case "bg": + if cat == ctxDark { + darkBG = append(darkBG, tok) + } else { + baseBG = append(baseBG, tok) + darkBG = append(darkBG, tok) // a base bg also applies in dark unless overridden + } + case "fg": + if cat == ctxDark { + darkFG = append(darkFG, tok) + } else { + baseFG = append(baseFG, tok) + darkFG = append(darkFG, tok) + } + } + } + // A dark: override replaces the base for that property: if the group names any + // dark: background, only those apply in dark (and likewise for text). + if hasDarkOverride(g.Tokens, "bg") { + darkBG = onlyDark(g.Tokens, "bg", r) + } + if hasDarkOverride(g.Tokens, "fg") { + darkFG = onlyDark(g.Tokens, "fg", r) + } + + large := isLargeText(g.Tokens) + required := requiredRatio(opt, large) + + var out []Finding + seen := map[string]bool{} // dedupe identical (theme-independent) pairs + + eval := func(t theme, bgs, fgs []string) { + if len(fgs) == 0 { + return + } + // Resolve the backgrounds to concrete backdrops. A background that resolves + // fully transparent (bg-transparent, or a token that is transparent in this + // theme) carries no contrast information — the real backdrop is an ancestor + // we cannot see — so it drops out. If nothing usable is left, this is the + // foreground-only case, checked against the page surface only under + // -assume-surface. + type backdropColor struct { + tok string + color RGBA + } + var bds []backdropColor + for _, bg := range bgs { + if c, ok := backdrop(r, bg, t); ok { + bds = append(bds, backdropColor{bg, c}) + } + } + if len(bds) == 0 { + if !opt.AssumeSurface { + return + } + if s, ok := r.surface(t); ok { + bds = append(bds, backdropColor{"", s}) + } + } + for _, bd := range bds { + bg, bgColor := bd.tok, bd.color + for _, fg := range fgs { + fgColor, ok := r.resolveToken(fg, t) + if !ok || fgColor.A == 0 { + continue // transparent / currentcolor / inherit: nothing to measure + } + ratio := contrastRatio(fgColor.over(bgColor), bgColor) + + // Suppress a duplicate dark finding when the pair is a pure static + // colour (identical resolution in both themes) — it is already + // reported for light. + key := fg + "|" + bg + "|" + rgbaKey(fgColor) + "|" + rgbaKey(bgColor) + if seen[key] { + continue + } + seen[key] = true + + out = append(out, Finding{ + File: g.File, Line: g.Line, Snip: g.Snip, Theme: t, + FG: fg, BG: bg, FGColor: fgColor, BGColor: bgColor, + Ratio: ratio, Required: required, Large: large, + Pass: ratio+1e-9 >= required, + }) + } + } + } + + eval(light, baseBG, baseFG) + eval(dark, darkBG, darkFG) + return out +} + +// backdrop resolves the background colour a foreground sits on. A fully transparent +// background is not a usable backdrop (ok=false) — its real colour comes from an +// ancestor we cannot resolve statically. A partially translucent background (e.g. +// bg-black/30) does tint what is behind it, so it is flattened onto the page surface, +// the best available assumption for the ancestor. +func backdrop(r *Resolver, bg string, t theme) (RGBA, bool) { + if bg == "" { + return r.surface(t) + } + c, ok := r.resolveToken(bg, t) + if !ok || c.A == 0 { + return RGBA{}, false + } + if c.Opaque() { + return c, true + } + surf, ok := r.surface(t) + if !ok { + return RGBA{}, false + } + return c.over(surf), true +} + +// variant classification -------------------------------------------------- + +type ctxKind int + +const ( + ctxBase ctxKind = iota // resting appearance, applies light + dark + ctxDark // dark: only + ctxSkip // an interaction/pseudo state — not a resting appearance +) + +// responsiveVariants change *when* a utility applies, not its colour, so they are +// transparent to pairing. +var responsiveVariants = map[string]bool{ + "sm": true, "md": true, "lg": true, "xl": true, "2xl": true, + "xs": true, "ultrawide": true, "portrait": true, "landscape": true, + "motion-safe": true, "motion-reduce": true, "print": true, "rtl": true, "ltr": true, +} + +// variantCategory decides which resting context (if any) a token belongs to. +func variantCategory(token string) ctxKind { + variants, _ := splitVariants(token) + hasDark := false + for _, v := range variants { + v = strings.TrimPrefix(v, "max-") // max-md: etc. are still responsive + if responsiveVariants[v] { + continue + } + if v == "dark" { + hasDark = true + continue + } + return ctxSkip // hover:, focus:, group-*, data-*, and anything unrecognised + } + if hasDark { + return ctxDark + } + return ctxBase +} + +func hasDarkOverride(tokens []string, side string) bool { + for _, tok := range tokens { + if variantCategory(tok) != ctxDark { + continue + } + if sideOf(tok) == side { + return true + } + } + return false +} + +func onlyDark(tokens []string, side string, r *Resolver) []string { + var out []string + for _, tok := range tokens { + if variantCategory(tok) == ctxDark && r.side(tok) == side { + out = append(out, tok) + } + } + return out +} + +// sideOf classifies a token by its base utility prefix alone (no engine lookup), +// used where we only need bg-vs-fg intent. +func sideOf(token string) string { + _, base := splitVariants(token) + switch { + case strings.HasPrefix(base, "bg-"): + return "bg" + case strings.HasPrefix(base, "text-"): + return "fg" + } + return "" +} + +// thresholds -------------------------------------------------------------- + +func requiredRatio(opt Options, large bool) float64 { + if strings.EqualFold(opt.Level, "AAA") { + if large { + return 4.5 + } + return 7.0 + } + // AA + if large { + return 3.0 + } + if opt.MinOverride > 0 { + return opt.MinOverride + } + return 4.5 +} + +// isLargeText applies the WCAG large-text rule (≥24px, or ≥18.66px when bold) using +// Tailwind's default font-size scale. Sizes an app has overridden in its theme are +// not reflected here, so this is a best-effort classification. +func isLargeText(tokens []string) bool { + px := 16.0 // default body size if no size utility is present + bold := false + for _, tok := range tokens { + if variantCategory(tok) == ctxSkip { + continue + } + _, base := splitVariants(tok) + if sz, ok := fontSizePx[strings.TrimPrefix(base, "text-")]; ok && strings.HasPrefix(base, "text-") { + if sz > px { + px = sz + } + } + switch base { + case "font-bold", "font-extrabold", "font-black": + bold = true + } + } + return px >= 24 || (px >= 18.66 && bold) +} + +// fontSizePx is Tailwind's default type scale (rem × 16), plus kjol's --text-ss. +var fontSizePx = map[string]float64{ + "ss": 12.8, "xs": 12, "sm": 14, "base": 16, "lg": 18, "xl": 20, + "2xl": 24, "3xl": 30, "4xl": 36, "5xl": 48, "6xl": 60, + "7xl": 72, "8xl": 96, "9xl": 128, +} + +func rgbaKey(c RGBA) string { + q := func(v float64) byte { return byte(clamp01(v) * 255) } + return string([]byte{q(c.R), q(c.G), q(c.B), q(c.A)}) +} diff --git a/go/cmd/aria-check/check_test.go b/go/cmd/aria-check/check_test.go new file mode 100644 index 00000000..c1a22739 --- /dev/null +++ b/go/cmd/aria-check/check_test.go @@ -0,0 +1,175 @@ +package main + +import "testing" + +// newTestResolver compiles the kjol theme layer (no app brand) against a fixed token +// set. baseDir "." is fine — nothing in the entry @imports a local file. +func newTestResolver(t *testing.T, tokens ...string) *Resolver { + t.Helper() + r, err := NewResolver("", ".", tokens) + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + return r +} + +func TestResolverSemanticTokens(t *testing.T) { + r := newTestResolver(t, "bg-surface", "text-ink", "text-white", "bg-red-500") + + // Semantic surface: white in light, near-black in dark. + if c, ok := r.resolveToken("bg-surface", light); !ok || hexOf(c) != "#ffffff" { + t.Errorf("bg-surface light = %v (%s), want #ffffff", ok, hexOf(c)) + } + if c, ok := r.resolveToken("bg-surface", dark); !ok || hexOf(c) != "#101013" { + t.Errorf("bg-surface dark = %v (%s), want #101013", ok, hexOf(c)) + } + // text-white chases var(--color-white) → #fff. + if c, ok := r.resolveToken("text-white", light); !ok || hexOf(c) != "#ffffff" { + t.Errorf("text-white = %v (%s), want #ffffff", ok, hexOf(c)) + } + // The palette OKLCH resolves to Tailwind's published hex. + if c, ok := r.resolveToken("bg-red-500", light); !ok || hexOf(c) != "#fb2c36" { + t.Errorf("bg-red-500 = %v (%s), want #fb2c36", ok, hexOf(c)) + } +} + +func TestResolverSideClassification(t *testing.T) { + r := newTestResolver(t, "bg-red-500", "text-ink", "flex", "text-sm", "p-4") + cases := map[string]string{ + "bg-red-500": "bg", + "text-ink": "fg", + "flex": "", // not a colour utility + "text-sm": "", // font-size, not a colour + "p-4": "", + } + for tok, want := range cases { + if got := r.side(tok); got != want { + t.Errorf("side(%q) = %q, want %q", tok, got, want) + } + } +} + +func TestResolverOpacityModifier(t *testing.T) { + r := newTestResolver(t, "bg-white/50") + c, ok := r.resolveToken("bg-white/50", light) + if !ok { + t.Fatal("bg-white/50 did not resolve") + } + approx(t, "bg-white/50 alpha", c.A, 0.5, 0.02) +} + +func TestResolverArbitraryValue(t *testing.T) { + r := newTestResolver(t, "bg-[#123456]") + c, ok := r.resolveToken("bg-[#123456]", light) + if !ok || hexOf(c) != "#123456" { + t.Errorf("bg-[#123456] = %v (%s), want #123456", ok, hexOf(c)) + } +} + +func TestResolverDarkVariantToken(t *testing.T) { + r := newTestResolver(t, "dark:bg-surface") + // A dark: token resolves against the dark environment. + if c, ok := r.resolveToken("dark:bg-surface", dark); !ok || hexOf(c) != "#101013" { + t.Errorf("dark:bg-surface (dark) = %v (%s), want #101013", ok, hexOf(c)) + } +} + +// End to end: white-on-white fails; the semantic surface/ink pair passes in both +// themes (the two tokens move together, so dark mode stays legible). +func TestCheckGroupContrast(t *testing.T) { + r := newTestResolver(t, "bg-white", "text-white", "bg-surface", "text-ink") + + // White on white: identical in both themes, so it collapses to one finding. + fail := checkGroup(ClassGroup{ + File: "x.tsx", Line: 1, Tokens: []string{"bg-white", "text-white"}, + }, r, Options{Level: "AA"}) + if len(fail) != 1 || fail[0].Pass { + t.Fatalf("white-on-white should fail once, got %+v", fail) + } + + // Surface/ink: light and dark are both checked (colours differ per theme) and + // both must pass. + pass := checkGroup(ClassGroup{ + File: "x.tsx", Line: 2, Tokens: []string{"bg-surface", "text-ink"}, + }, r, Options{Level: "AA"}) + if len(pass) == 0 { + t.Fatal("surface/ink produced no findings") + } + for _, f := range pass { + if !f.Pass { + t.Errorf("surface/ink should pass in %s, got %.2f:1", f.ThemeName(), f.Ratio) + } + } +} + +// A fixed palette background paired with an inverting semantic text token is a real +// dark-mode trap: bg-white stays white while text-ink climbs to near-white. +func TestCheckGroupFixedVsSemanticDarkTrap(t *testing.T) { + r := newTestResolver(t, "bg-white", "text-ink") + got := checkGroup(ClassGroup{ + File: "x.tsx", Line: 1, Tokens: []string{"bg-white", "text-ink"}, + }, r, Options{Level: "AA"}) + + var lightPass, darkFail bool + for _, f := range got { + if f.Theme == light && f.Pass { + lightPass = true + } + if f.Theme == dark && !f.Pass { + darkFail = true + } + } + if !lightPass || !darkFail { + t.Errorf("expected light pass + dark fail, got %+v", got) + } +} + +// Scanner: a comment apostrophe ("panel's") must not open a string literal and +// swallow the class constants below it — the desync that produced dozens of bogus +// cross-paired findings. +func TestLexerSkipsCommentApostrophe(t *testing.T) { + src := []byte("// ModalSize selects the panel's max width.\n" + + "const a = \"text-white\"\n" + + "const b = \"bg-surface\"\n") + lits := extractLiterals(src) + if len(lits) != 2 { + t.Fatalf("expected 2 literals, got %d: %+v", len(lits), lits) + } + if lits[0].content != "text-white" || lits[1].content != "bg-surface" { + t.Errorf("unexpected literal contents: %+v", lits) + } +} + +func TestLexerSingleQuoteExpressionPosition(t *testing.T) { + // Apostrophe in JSX text is not a string; a single-quoted attribute value is. + src := []byte("

don't click

\nx\n") + lits := extractLiterals(src) + found := false + for _, l := range lits { + if l.content == "bg-red-500 text-white" { + found = true + } + } + if !found { + t.Errorf("single-quoted class attribute not extracted: %+v", lits) + } +} + +func TestSplitVariants(t *testing.T) { + cases := []struct { + token string + base string + nvar int + }{ + {"bg-red-500", "bg-red-500", 0}, + {"dark:bg-surface", "bg-surface", 1}, + {"dark:hover:text-ink", "text-ink", 2}, + {"text-[color:red]", "text-[color:red]", 0}, // ':' inside [] is not a variant sep + } + for _, c := range cases { + v, base := splitVariants(c.token) + if base != c.base || len(v) != c.nvar { + t.Errorf("splitVariants(%q) = %v,%q; want %d variants, base %q", c.token, v, base, c.nvar, c.base) + } + } +} diff --git a/go/cmd/aria-check/color.go b/go/cmd/aria-check/color.go new file mode 100644 index 00000000..f076f727 --- /dev/null +++ b/go/cmd/aria-check/color.go @@ -0,0 +1,436 @@ +package main + +// color.go is the colour engine: it parses every colour syntax Tailwind can emit +// into one target space — gamma-encoded sRGB with an alpha channel — and from there +// computes the WCAG 2.x relative luminance and contrast ratio exactly as WebAIM's +// checker does (https://webaim.org/resources/contrastchecker/). +// +// sRGB is the target space on purpose. WCAG defines luminance in terms of sRGB, so +// converting there once means the contrast maths is a single well-specified formula +// and never depends on which syntax a colour was written in. The palette is authored +// in OKLCH, the semantic tokens in hex, and an app can drop an oklab()/rgb()/hsl() +// literal into an arbitrary value — all of them land here as an RGBA before any +// contrast is computed. + +import ( + "math" + "strconv" + "strings" +) + +// RGBA is a colour in gamma-encoded sRGB. Channels and alpha are all in [0,1]. +// This is aria-check's single internal colour representation — the "target +// colorspace" every parser converts into. +type RGBA struct { + R, G, B, A float64 +} + +// Opaque reports whether the colour needs no compositing. +func (c RGBA) Opaque() bool { return c.A >= 1 } + +// over composites c (the source) onto an opaque backdrop using the standard +// source-over rule, in gamma space. WCAG contrast is only defined for opaque +// colours, so a translucent foreground or a translucent surface must be flattened +// against what sits behind it before its luminance means anything. Compositing in +// gamma-encoded sRGB (rather than linear) is the approximation browsers and the +// WebAIM checker effectively use. +func (c RGBA) over(bg RGBA) RGBA { + if c.Opaque() { + return c + } + a := c.A + return RGBA{ + R: c.R*a + bg.R*(1-a), + G: c.G*a + bg.G*(1-a), + B: c.B*a + bg.B*(1-a), + A: 1, + } +} + +// luminance is the WCAG relative luminance of an (assumed opaque) colour: linearise +// each sRGB channel, then weight. This is byte-for-byte the WebAIM formula, including +// its 0.03928 threshold. +func (c RGBA) luminance() float64 { + lin := func(ch float64) float64 { + if ch <= 0.03928 { + return ch / 12.92 + } + return math.Pow((ch+0.055)/1.055, 2.4) + } + return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B) +} + +// contrastRatio returns the WCAG contrast ratio between two opaque colours, in +// [1, 21]. Order does not matter. Callers must composite any translucency away first +// (see over) — this treats both colours as fully opaque. +func contrastRatio(a, b RGBA) float64 { + la, lb := a.luminance(), b.luminance() + if la < lb { + la, lb = lb, la + } + return (la + 0.05) / (lb + 0.05) +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +// parseLiteralColor parses a self-contained colour literal — one that names no CSS +// variable and is not a color-mix() (those are resolved in theme.go, which has the +// variable environment). It returns ok=false for anything it cannot turn into a +// concrete colour, including the deliberately-unresolvable keywords `currentcolor`, +// `inherit`, `transparent` (transparent is a real colour but alpha 0, handled here). +func parseLiteralColor(s string) (RGBA, bool) { + s = strings.TrimSpace(s) + if s == "" { + return RGBA{}, false + } + lower := strings.ToLower(s) + + switch { + case strings.HasPrefix(s, "#"): + return parseHex(s) + case strings.HasPrefix(lower, "rgb"): + return parseRGBFunc(s) + case strings.HasPrefix(lower, "hsl"): + return parseHSLFunc(s) + case strings.HasPrefix(lower, "oklch("): + return parseOKLCH(s) + case strings.HasPrefix(lower, "oklab("): + return parseOKLab(s) + } + if c, ok := namedColors[lower]; ok { + return c, true + } + return RGBA{}, false +} + +func parseHex(s string) (RGBA, bool) { + h := strings.TrimPrefix(s, "#") + // Expand shorthand #rgb / #rgba to full byte pairs. + switch len(h) { + case 3, 4: + var sb strings.Builder + for _, r := range h { + sb.WriteRune(r) + sb.WriteRune(r) + } + h = sb.String() + case 6, 8: + default: + return RGBA{}, false + } + val, err := strconv.ParseUint(h, 16, 64) + if err != nil { + return RGBA{}, false + } + c := RGBA{A: 1} + if len(h) == 8 { + c.R = float64((val>>24)&0xff) / 255 + c.G = float64((val>>16)&0xff) / 255 + c.B = float64((val>>8)&0xff) / 255 + c.A = float64(val&0xff) / 255 + } else { + c.R = float64((val>>16)&0xff) / 255 + c.G = float64((val>>8)&0xff) / 255 + c.B = float64(val&0xff) / 255 + } + return c, true +} + +// funcArgs splits the inside of a colour function into its space/comma-separated +// components and an optional trailing alpha introduced by `/`. Both the legacy +// comma syntax and the modern space syntax are accepted. +func funcArgs(s string) (parts []string, alpha string) { + open := strings.IndexByte(s, '(') + close := strings.LastIndexByte(s, ')') + if open < 0 || close < 0 || close < open { + return nil, "" + } + body := s[open+1 : close] + body = strings.ReplaceAll(body, ",", " ") + if i := strings.IndexByte(body, '/'); i >= 0 { + alpha = strings.TrimSpace(body[i+1:]) + body = body[:i] + } + return strings.Fields(body), alpha +} + +// numOrPct parses a number that may be a percentage. A percentage is scaled by +// pctBase (255 for rgb channels, 1 for alpha, 0.4 for oklab/oklch a/b/chroma). +func numOrPct(s string, pctBase float64) (float64, bool) { + s = strings.TrimSpace(s) + if s == "" || s == "none" { + return 0, true + } + if pct, ok := strings.CutSuffix(s, "%"); ok { + v, err := strconv.ParseFloat(pct, 64) + if err != nil { + return 0, false + } + return v / 100 * pctBase, true + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + return v, true +} + +func parseAlpha(s string) float64 { + if s == "" { + return 1 + } + if v, ok := numOrPct(s, 1); ok { + return clamp01(v) + } + return 1 +} + +func parseRGBFunc(s string) (RGBA, bool) { + parts, alpha := funcArgs(s) + if len(parts) < 3 { + return RGBA{}, false + } + r, ok1 := numOrPct(parts[0], 255) + g, ok2 := numOrPct(parts[1], 255) + b, ok3 := numOrPct(parts[2], 255) + if !ok1 || !ok2 || !ok3 { + return RGBA{}, false + } + a := 1.0 + if len(parts) >= 4 { + a = parseAlpha(parts[3]) + } else if alpha != "" { + a = parseAlpha(alpha) + } + return RGBA{clamp01(r / 255), clamp01(g / 255), clamp01(b / 255), a}, true +} + +func parseHSLFunc(s string) (RGBA, bool) { + parts, alpha := funcArgs(s) + if len(parts) < 3 { + return RGBA{}, false + } + h, ok1 := parseAngle(parts[0]) + sat, ok2 := numOrPct(parts[1], 1) // percentage → [0,1] + l, ok3 := numOrPct(parts[2], 1) + if !ok1 || !ok2 || !ok3 { + return RGBA{}, false + } + a := 1.0 + if len(parts) >= 4 { + a = parseAlpha(parts[3]) + } else if alpha != "" { + a = parseAlpha(alpha) + } + r, g, b := hslToRGB(h, clamp01(sat), clamp01(l)) + return RGBA{r, g, b, a}, true +} + +func parseAngle(s string) (float64, bool) { + s = strings.TrimSpace(strings.ToLower(s)) + s = strings.TrimSuffix(s, "deg") + if s == "none" { + return 0, true + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + return v, true +} + +func hslToRGB(h, s, l float64) (float64, float64, float64) { + h = math.Mod(math.Mod(h, 360)+360, 360) / 360 + if s == 0 { + return l, l, l + } + var q float64 + if l < 0.5 { + q = l * (1 + s) + } else { + q = l + s - l*s + } + p := 2*l - q + hue := func(t float64) float64 { + if t < 0 { + t++ + } + if t > 1 { + t-- + } + switch { + case t < 1.0/6: + return p + (q-p)*6*t + case t < 1.0/2: + return q + case t < 2.0/3: + return p + (q-p)*(2.0/3-t)*6 + default: + return p + } + } + return hue(h + 1.0/3), hue(h), hue(h - 1.0/3) +} + +func parseOKLCH(s string) (RGBA, bool) { + parts, alpha := funcArgs(s) + if len(parts) < 3 { + return RGBA{}, false + } + l, ok1 := numOrPct(parts[0], 1) // L: % → [0,1], or already 0..1 + c, ok2 := numOrPct(parts[1], 0.4) + h, ok3 := parseAngle(parts[2]) + if !ok1 || !ok2 || !ok3 { + return RGBA{}, false + } + a := 1.0 + if len(parts) >= 4 { + a = parseAlpha(parts[3]) + } else if alpha != "" { + a = parseAlpha(alpha) + } + rad := h * math.Pi / 180 + return oklabToRGBA(l, c*math.Cos(rad), c*math.Sin(rad), a), true +} + +func parseOKLab(s string) (RGBA, bool) { + parts, alpha := funcArgs(s) + if len(parts) < 3 { + return RGBA{}, false + } + l, ok1 := numOrPct(parts[0], 1) + aa, ok2 := numOrPct(parts[1], 0.4) + bb, ok3 := numOrPct(parts[2], 0.4) + if !ok1 || !ok2 || !ok3 { + return RGBA{}, false + } + alp := 1.0 + if len(parts) >= 4 { + alp = parseAlpha(parts[3]) + } else if alpha != "" { + alp = parseAlpha(alpha) + } + return oklabToRGBA(l, aa, bb, alp), true +} + +// oklabToRGBA is Björn Ottosson's OKLab → linear sRGB transform, followed by the +// sRGB transfer function and a gamut clamp. Out-of-gamut OKLCH colours (the palette +// has a few) clamp per channel, which is what a browser paints too. +func oklabToRGBA(L, a, b, alpha float64) RGBA { + l_ := L + 0.3963377774*a + 0.2158037573*b + m_ := L - 0.1055613458*a - 0.0638541728*b + s_ := L - 0.0894841775*a - 1.2914855480*b + + l := l_ * l_ * l_ + m := m_ * m_ * m_ + s := s_ * s_ * s_ + + lr := +4.0767416621*l - 3.3077115913*m + 0.2309699292*s + lg := -1.2684380046*l + 2.6097574011*m - 0.3413193965*s + lb := -0.0041960863*l - 0.7034186147*m + 1.7076147010*s + + return RGBA{ + R: clamp01(linearToSRGB(lr)), + G: clamp01(linearToSRGB(lg)), + B: clamp01(linearToSRGB(lb)), + A: alpha, + } +} + +func linearToSRGB(c float64) float64 { + if c <= 0.0031308 { + return 12.92 * c + } + return 1.055*math.Pow(c, 1/2.4) - 0.055 +} + +// mixOKLab evaluates the two-colour case of CSS color-mix() in the oklab space, +// which is the form Tailwind emits for an opacity modifier +// (`color-mix(in oklab, P%, transparent)`). Weights are normalised and the +// interpolation is alpha-premultiplied, matching the CSS spec closely enough for a +// contrast estimate. +func mixOKLab(c1 RGBA, w1 float64, c2 RGBA, w2 float64) RGBA { + if w1+w2 == 0 { + return c1 + } + total := w1 + w2 + w1 /= total + w2 /= total + + l1, a1, b1 := rgbaToOKLab(c1) + l2, a2, b2 := rgbaToOKLab(c2) + + // Premultiply the lab coordinates by alpha, interpolate, then un-premultiply. + pa := c1.A*w1 + c2.A*w2 + L := (l1*c1.A*w1 + l2*c2.A*w2) + A := (a1*c1.A*w1 + a2*c2.A*w2) + B := (b1*c1.A*w1 + b2*c2.A*w2) + if pa > 0 { + L /= pa + A /= pa + B /= pa + } + return oklabToRGBA(L, A, B, pa) +} + +// rgbaToOKLab inverts oklabToRGBA (sRGB → linear → OKLab), needed by mixOKLab. +func rgbaToOKLab(c RGBA) (L, a, b float64) { + lr := srgbToLinear(c.R) + lg := srgbToLinear(c.G) + lb := srgbToLinear(c.B) + + l := 0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb + m := 0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb + s := 0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb + + l_ := math.Cbrt(l) + m_ := math.Cbrt(m) + s_ := math.Cbrt(s) + + return 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_, + 1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_, + 0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_ +} + +func srgbToLinear(c float64) float64 { + if c <= 0.04045 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) +} + +// namedColors covers the CSS keywords likely to appear in an arbitrary value or a +// hand-written token. It is deliberately not the full 148-name list; extend as real +// usage demands. `transparent` is a real value (alpha 0); `currentcolor` and +// `inherit` are intentionally absent — they cannot be resolved statically. +var namedColors = map[string]RGBA{ + "transparent": {0, 0, 0, 0}, + "white": {1, 1, 1, 1}, + "black": {0, 0, 0, 1}, + "red": {1, 0, 0, 1}, + "green": {0, 128.0 / 255, 0, 1}, + "blue": {0, 0, 1, 1}, + "yellow": {1, 1, 0, 1}, + "cyan": {0, 1, 1, 1}, + "aqua": {0, 1, 1, 1}, + "magenta": {1, 0, 1, 1}, + "fuchsia": {1, 0, 1, 1}, + "gray": {128.0 / 255, 128.0 / 255, 128.0 / 255, 1}, + "grey": {128.0 / 255, 128.0 / 255, 128.0 / 255, 1}, + "silver": {192.0 / 255, 192.0 / 255, 192.0 / 255, 1}, + "maroon": {128.0 / 255, 0, 0, 1}, + "olive": {128.0 / 255, 128.0 / 255, 0, 1}, + "lime": {0, 1, 0, 1}, + "teal": {0, 128.0 / 255, 128.0 / 255, 1}, + "navy": {0, 0, 128.0 / 255, 1}, + "purple": {128.0 / 255, 0, 128.0 / 255, 1}, + "orange": {1, 165.0 / 255, 0, 1}, +} diff --git a/go/cmd/aria-check/color_test.go b/go/cmd/aria-check/color_test.go new file mode 100644 index 00000000..15497a69 --- /dev/null +++ b/go/cmd/aria-check/color_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "math" + "testing" +) + +func approx(t *testing.T, name string, got, want, tol float64) { + t.Helper() + if math.Abs(got-want) > tol { + t.Errorf("%s: got %.4f, want %.4f (±%.4f)", name, got, want, tol) + } +} + +// The contrast ratios below are the values WebAIM's checker reports for the same +// colour pairs — the reference this tool is meant to match. +func TestContrastRatioKnownValues(t *testing.T) { + white := RGBA{1, 1, 1, 1} + black := RGBA{0, 0, 0, 1} + approx(t, "white/black", contrastRatio(white, black), 21.0, 0.01) + + gray767676, _ := parseLiteralColor("#767676") // WebAIM's canonical AA-passing grey on white + approx(t, "#767676/white", contrastRatio(gray767676, white), 4.54, 0.03) + + red, _ := parseLiteralColor("#ff0000") + approx(t, "red/white", contrastRatio(red, white), 4.0, 0.03) + + blue, _ := parseLiteralColor("#0000ff") + approx(t, "blue/white", contrastRatio(blue, white), 8.59, 0.03) +} + +// OKLCH is the palette's authored space; the whole checker depends on converting it +// to sRGB correctly. red-500 is oklch(63.7% 0.237 25.331) and Tailwind publishes it +// as #fb2c36. +func TestParseOKLCHMatchesTailwindHex(t *testing.T) { + c, ok := parseLiteralColor("oklch(63.7% 0.237 25.331)") + if !ok { + t.Fatal("failed to parse oklch red-500") + } + want, _ := parseHex("#fb2c36") + approx(t, "R", c.R, want.R, 2.0/255) + approx(t, "G", c.G, want.G, 2.0/255) + approx(t, "B", c.B, want.B, 2.0/255) +} + +func TestParseLiteralColorForms(t *testing.T) { + cases := []struct { + in string + r, g, b, a float64 + }{ + {"#fff", 1, 1, 1, 1}, + {"#ffffff", 1, 1, 1, 1}, + {"#ff000080", 1, 0, 0, 128.0 / 255}, + {"rgb(255, 0, 0)", 1, 0, 0, 1}, + {"rgb(255 0 0 / 50%)", 1, 0, 0, 0.5}, + {"rgba(0, 0, 255, 0.25)", 0, 0, 1, 0.25}, + {"hsl(0 100% 50%)", 1, 0, 0, 1}, + {"hsl(120, 100%, 50%)", 0, 1, 0, 1}, + {"oklab(0 0 0)", 0, 0, 0, 1}, + {"white", 1, 1, 1, 1}, + {"transparent", 0, 0, 0, 0}, + } + for _, c := range cases { + got, ok := parseLiteralColor(c.in) + if !ok { + t.Errorf("%q: failed to parse", c.in) + continue + } + approx(t, c.in+" R", got.R, c.r, 0.01) + approx(t, c.in+" G", got.G, c.g, 0.01) + approx(t, c.in+" B", got.B, c.b, 0.01) + approx(t, c.in+" A", got.A, c.a, 0.01) + } +} + +func TestUnresolvableKeywords(t *testing.T) { + for _, kw := range []string{"currentcolor", "inherit", "unset", "var(--x)"} { + if _, ok := parseLiteralColor(kw); ok { + t.Errorf("%q should not resolve as a literal colour", kw) + } + } +} + +// A translucent foreground must be flattened onto its backdrop before its contrast +// means anything. +func TestCompositeOver(t *testing.T) { + fg := RGBA{0, 0, 0, 0.5} // 50% black + bg := RGBA{1, 1, 1, 1} // white + got := fg.over(bg) + approx(t, "composited grey", got.R, 0.5, 0.001) + if !got.Opaque() { + t.Error("composited colour should be opaque") + } +} diff --git a/go/cmd/aria-check/main.go b/go/cmd/aria-check/main.go new file mode 100644 index 00000000..7372390a --- /dev/null +++ b/go/cmd/aria-check/main.go @@ -0,0 +1,228 @@ +// Command aria-check is a static accessibility linter for kjol front-ends. It reads +// source (Solid .tsx and gowasm .go alike — anything that authors Tailwind classes as +// string literals) and reports problems that can be caught without a browser. +// +// The first and only check today is COLOUR CONTRAST, implemented against the same +// algorithm as WebAIM's contrast checker (https://webaim.org/resources/contrastchecker/): +// WCAG 2.x relative luminance in sRGB, ratio (L1+0.05)/(L2+0.05). +// +// How it works: +// +// 1. Scan sources for string literals that hold class lists, keeping the utilities +// that co-occur in one literal together (contrast is about a foreground and a +// background on the SAME element — see scan.go). +// 2. Compile every bg-/text- token through kjol's own Tailwind engine (package tw) +// and read back the colours it resolves — palette OKLCH, hex tokens, semantic +// tokens, opacity modifiers and arbitrary values all included (theme.go). +// 3. For each co-occurring foreground/background pair, in both the light and dark +// appearances, composite away any translucency and measure the ratio against the +// WCAG threshold (check.go, color.go). +// +// Because the tokens are resolved by the real engine, aria-check stays correct as the +// design system changes: it never hard-codes a colour. +// +// Usage (globs/roots are relative to -base; omit them to scan the whole base): +// +// aria-check -base . -entry app/style.css frontend +// aria-check -base kjol/go/webui -level AAA +// aria-check -assume-surface -json . +// +// Exit status is non-zero when any contrast failure is found (unless -warn), so it +// drops straight into CI or a pre-commit hook. +// +// Extending it: contrast is one Checker; the scan + resolve scaffolding is meant to +// carry more. Natural next checks that are equally static-friendly — missing alt text +// on images, controls with no accessible label, heading-order jumps, redundant/absent +// ARIA roles — would each add a pass over the same file walk. See the closing notes in +// the repository discussion for the full list. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +func main() { + var ( + base = flag.String("base", ".", "root directory to scan and resolve -entry against") + entry = flag.String("entry", "", "app brand Tailwind entry stylesheet (optional; kjol's theme layer is always included)") + level = flag.String("level", "AA", "WCAG conformance level: AA or AAA") + min = flag.Float64("min", 0, "override the required ratio for normal-size text (AA only)") + exts = flag.String("ext", strings.Join(DefaultExts, ","), "comma-separated source file extensions to scan") + assumeSurface = flag.Bool("assume-surface", false, "also check text-only elements against the page surface colour") + asJSON = flag.Bool("json", false, "emit findings as JSON") + verbose = flag.Bool("v", false, "also report passing pairs") + warn = flag.Bool("warn", false, "always exit 0, even when failures are found") + ) + flag.Parse() + + opt := Options{Level: *level, MinOverride: *min, AssumeSurface: *assumeSurface} + + groups, err := ScanTree(*base, flag.Args(), splitExts(*exts)) + if err != nil { + fatal(err) + } + + var entryCSS string + if *entry != "" { + b, err := os.ReadFile(*entry) + if err != nil { + fatal(err) + } + entryCSS = string(b) + } + + resolver, err := NewResolver(entryCSS, *base, ColorCandidates(groups)) + if err != nil { + fatal(fmt.Errorf("compiling theme: %w", err)) + } + + findings := Check(groups, resolver, opt) + sort.Slice(findings, func(i, j int) bool { + if findings[i].File != findings[j].File { + return findings[i].File < findings[j].File + } + if findings[i].Line != findings[j].Line { + return findings[i].Line < findings[j].Line + } + return findings[i].Ratio < findings[j].Ratio + }) + + failures := 0 + for _, f := range findings { + if !f.Pass { + failures++ + } + } + + if *asJSON { + reportJSON(findings, *verbose) + } else { + reportText(findings, failures, len(groups), *verbose) + } + + if failures > 0 && !*warn { + os.Exit(1) + } +} + +func reportText(findings []Finding, failures, groups int, verbose bool) { + filesWithFail := map[string]bool{} + for _, f := range findings { + if f.Pass && !verbose { + continue + } + if !f.Pass { + filesWithFail[f.File] = true + } + fmt.Println(formatFinding(f)) + } + + fmt.Println() + if failures == 0 { + fmt.Printf("aria-check: no contrast failures (%d class groups scanned)\n", groups) + return + } + fmt.Printf("aria-check: %d contrast failure(s) across %d file(s) — %d class groups scanned\n", + failures, len(filesWithFail), groups) +} + +func formatFinding(f Finding) string { + verdict := "FAIL" + if f.Pass { + verdict = "ok " + } + size := "normal" + if f.Large { + size = "large" + } + bg := f.BG + if bg == "" { + bg = "surface" + } + rel, err := filepath.Rel(".", f.File) + if err != nil { + rel = f.File + } + return fmt.Sprintf("%s:%d: %s %s %.2f:1 (need %.1f:1) %s on %s [%s %s]\n %s → %s %q", + rel, f.Line, verdict, f.ThemeName(), f.Ratio, f.Required, + f.FG, bg, f.ThemeName(), size, + hexOf(f.FGColor), hexOf(f.BGColor), truncate(f.Snip, 90)) +} + +type jsonFinding struct { + File string `json:"file"` + Line int `json:"line"` + Theme string `json:"theme"` + FG string `json:"fg"` + BG string `json:"bg"` + FGColor string `json:"fgColor"` + BGColor string `json:"bgColor"` + Ratio float64 `json:"ratio"` + Required float64 `json:"required"` + Large bool `json:"large"` + Pass bool `json:"pass"` +} + +func reportJSON(findings []Finding, verbose bool) { + out := make([]jsonFinding, 0, len(findings)) + for _, f := range findings { + if f.Pass && !verbose { + continue + } + bg := f.BG + if bg == "" { + bg = "surface" + } + out = append(out, jsonFinding{ + File: f.File, Line: f.Line, Theme: f.ThemeName(), + FG: f.FG, BG: bg, FGColor: hexOf(f.FGColor), BGColor: hexOf(f.BGColor), + Ratio: round2(f.Ratio), Required: f.Required, Large: f.Large, Pass: f.Pass, + }) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(out) +} + +func hexOf(c RGBA) string { + to := func(v float64) int { return int(clamp01(v)*255 + 0.5) } + if c.A < 1 { + return fmt.Sprintf("#%02x%02x%02x%02x", to(c.R), to(c.G), to(c.B), to(c.A)) + } + return fmt.Sprintf("#%02x%02x%02x", to(c.R), to(c.G), to(c.B)) +} + +func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} + +func splitExts(s string) []string { + var out []string + for _, e := range strings.Split(s, ",") { + e = strings.TrimSpace(e) + if e == "" { + continue + } + if !strings.HasPrefix(e, ".") { + e = "." + e + } + out = append(out, strings.ToLower(e)) + } + return out +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "aria-check:", err) + os.Exit(2) +} diff --git a/go/cmd/aria-check/scan.go b/go/cmd/aria-check/scan.go new file mode 100644 index 00000000..4c4cc5a2 --- /dev/null +++ b/go/cmd/aria-check/scan.go @@ -0,0 +1,253 @@ +package main + +// scan.go finds where colours are paired. The engine's own scanner (tw.Scan) +// flattens a source tree into a flat set of candidate classes, which is right for +// compiling CSS but wrong for contrast: contrast is a property of a foreground and a +// background that appear *together* on one element, and flattening throws the +// pairing away. +// +// So we do our own pass. The grouping unit is a single string literal: a class list +// is written as one string — `class="… bg-primary text-white …"`, or in Go +// `vdom.Attr("class", "…")` — and the utilities inside one literal are the ones that +// land on the same element. We pull each literal out with its line number, and hand +// its tokens on for classification. Colours split across two literals (a `clsx`-style +// merge) are not paired; that is the known limitation of a static, per-literal view. + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +// ClassGroup is one string literal that plausibly holds a class list, with the +// tokens found inside it and where it lives. +type ClassGroup struct { + File string + Line int + Snip string // the literal's content, trimmed for display + Tokens []string +} + +// looksLikeUtility is a cheap pre-filter: a literal is only interesting if it holds a +// token that could be a bg-/text- colour utility (optionally behind variants). +var looksLikeUtility = regexp.MustCompile(`(^|\s)([a-z0-9-]+:)*(bg|text)-`) + +// DefaultExts are the source kinds a kjol/Tailwind project authors classes in. +var DefaultExts = []string{".tsx", ".ts", ".jsx", ".js", ".html", ".go"} + +// ignoredDirs are never worth scanning and are often huge. +var ignoredDirs = map[string]bool{ + ".git": true, "node_modules": true, "vendor": true, "dist": true, + "build": true, "wwwroot": true, ".cache": true, "testdata": true, +} + +// ScanTree walks roots (each relative to base, or base itself if none) and returns +// every class-list literal found in files whose extension is in exts. +func ScanTree(base string, roots []string, exts []string) ([]ClassGroup, error) { + extSet := map[string]bool{} + for _, e := range exts { + extSet[e] = true + } + if len(roots) == 0 { + roots = []string{"."} + } + + var groups []ClassGroup + seenFile := map[string]bool{} + for _, root := range roots { + start := filepath.Join(base, root) + err := filepath.WalkDir(start, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if ignoredDirs[d.Name()] { + return fs.SkipDir + } + return nil + } + if !extSet[strings.ToLower(filepath.Ext(path))] { + return nil + } + if seenFile[path] { + return nil + } + seenFile[path] = true + g, err := scanFile(path) + if err != nil { + return err + } + groups = append(groups, g...) + return nil + }) + if err != nil { + return nil, err + } + } + return groups, nil +} + +func scanFile(path string) ([]ClassGroup, error) { + src, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var groups []ClassGroup + for _, lit := range extractLiterals(src) { + if !looksLikeUtility.MatchString(lit.content) { + continue + } + groups = append(groups, ClassGroup{ + File: path, + Line: lit.line, + Snip: collapse(lit.content), + Tokens: strings.Fields(lit.content), + }) + } + return groups, nil +} + +// literal is one string literal's content and the line it started on. +type literal struct { + content string + line int +} + +// extractLiterals pulls string literals out of Go/TS/JS/HTML source with a small +// state machine. It skips `//` and `/* */` comments — the source of the nastiest +// desync, where an apostrophe in a comment ("panel's") or an unbalanced quote pairs +// with a delimiter far below and swallows unrelated code — and honours backslash +// escapes inside strings. +// +// Single-quoted strings are only opened in expression position (after an operator or +// opener, or an attribute `=`). That keeps apostrophes in JSX/HTML text (`don't`) and +// Go rune-in-prose from being read as string starts, while still catching real +// single-quoted class lists (`class='…'`, `cond ? 'a' : 'b'`). +func extractLiterals(src []byte) []literal { + var out []literal + n := len(src) + line := 1 + var prev byte // most recent non-whitespace byte, for the '-in-expression test + + for i := 0; i < n; { + c := src[i] + // Comments. + if c == '/' && i+1 < n && src[i+1] == '/' { + for i < n && src[i] != '\n' { + i++ + } + continue + } + if c == '/' && i+1 < n && src[i+1] == '*' { + i += 2 + for i+1 < n && !(src[i] == '*' && src[i+1] == '/') { + if src[i] == '\n' { + line++ + } + i++ + } + i += 2 + continue + } + // String literals. + if c == '"' || c == '`' || (c == '\'' && exprPosition(prev)) { + quote := c + startLine := line + i++ + var b strings.Builder + for i < n { + ch := src[i] + if ch == '\\' && i+1 < n { + b.WriteByte(ch) + b.WriteByte(src[i+1]) + if src[i+1] == '\n' { + line++ + } + i += 2 + continue + } + if ch == quote { + i++ + break + } + if ch == '\n' { + line++ + } + b.WriteByte(ch) + i++ + } + out = append(out, literal{content: b.String(), line: startLine}) + prev = quote + continue + } + if c == '\n' { + line++ + } + if c != ' ' && c != '\t' && c != '\r' && c != '\n' { + prev = c + } + i++ + } + return out +} + +// exprPosition reports whether a `'` following prev begins a string literal (rather +// than being an apostrophe in text or a Go rune after a value). prev is the previous +// non-whitespace byte; 0 means start of file. +func exprPosition(prev byte) bool { + switch prev { + case 0, '(', '[', '{', ',', ':', ';', '=', '?', '>', '<', '&', '|', '!', '+', '-', '*', '/', '~', '^', '%', '\\': + return true + } + return false +} + +// collapse squeezes whitespace (including the newlines a multi-line class string may +// contain) to single spaces for a compact one-line display. +func collapse(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// ColorCandidates returns the de-duplicated set of tokens across all groups that are +// shaped like a bg-/text- colour utility (optionally variant-prefixed). This is the +// candidate list handed to the Tailwind engine for compilation. +func ColorCandidates(groups []ClassGroup) []string { + set := map[string]bool{} + for _, g := range groups { + for _, tok := range g.Tokens { + if _, base := splitVariants(tok); strings.HasPrefix(base, "bg-") || strings.HasPrefix(base, "text-") { + set[tok] = true + } + } + } + out := make([]string, 0, len(set)) + for tok := range set { + out = append(out, tok) + } + return out +} + +// splitVariants separates a token's variant prefixes from its base utility. The base +// is the segment after the last top-level `:` — but a `:` inside an arbitrary value +// (`text-[color:red]`) or an escaped bracket must not be treated as a variant +// separator, so we split at bracket depth zero only. +func splitVariants(token string) (variants []string, base string) { + depth := 0 + start := 0 + for i := 0; i < len(token); i++ { + switch token[i] { + case '[', '(': + depth++ + case ']', ')': + depth-- + case ':': + if depth == 0 { + variants = append(variants, token[start:i]) + start = i + 1 + } + } + } + return variants, token[start:] +} diff --git a/go/cmd/aria-check/theme.go b/go/cmd/aria-check/theme.go new file mode 100644 index 00000000..dc42d520 --- /dev/null +++ b/go/cmd/aria-check/theme.go @@ -0,0 +1,377 @@ +package main + +// theme.go turns a set of scanned Tailwind tokens into resolved colours by driving +// kjol's own Tailwind engine (package tw) and reading back what it emits. We do NOT +// re-implement utility parsing: we hand the engine every candidate, let it compile, +// and then read the CSS it produced. That keeps aria-check faithful to whatever the +// real build does — opacity modifiers, arbitrary values, semantic tokens, the lot — +// and correct-by-construction as the engine evolves. +// +// The engine gives us three things in its output: +// +// - the `:root` custom-property block → the LIGHT variable environment +// - the `.dark { … }` override block → the DARK variable environment (overlay) +// - the `@layer utilities` rules → token → colour-valued declaration +// +// A token's colour is then just: look up its declaration's value expression, and +// resolve it (var() chains and color-mix()) against the chosen environment. + +import ( + "regexp" + "strconv" + "strings" + + "kjol/tw" +) + +// Resolver holds everything needed to turn a token into a concrete colour in either +// theme. +type Resolver struct { + light map[string]string // --var → value expression, light theme + dark map[string]string // --var → value expression, dark theme (light overlaid) + + // token → the colour declaration the engine emitted for it. prop is "color" + // (a text-* utility) or "background-color" (a bg-* utility); expr is the raw + // value, e.g. "var(--color-ink)" or "color-mix(in oklab, var(--color-red-500) 50%, transparent)". + tokens map[string]tokenDecl +} + +type tokenDecl struct { + prop string + expr string +} + +// surfaceExpr is the page background token; a translucent background composites onto +// it (see check.go). It is a plain --var lookup in whichever environment. +const surfaceVar = "--color-surface" + +// NewResolver compiles candidates through the kjol Tailwind engine and indexes the +// result. entryCSS is the app's brand stylesheet (may be empty — kjol's own theme +// layer is always included via tw.CompileApp); baseDir is what any @import/@source in +// the entry resolves against. +func NewResolver(entryCSS, baseDir string, candidates []string) (*Resolver, error) { + if strings.TrimSpace(entryCSS) == "" { + entryCSS = "@theme {}" + } + css, _, err := tw.CompileApp(entryCSS, baseDir, candidates) + if err != nil { + return nil, err + } + r := &Resolver{ + light: map[string]string{}, + dark: map[string]string{}, + tokens: map[string]tokenDecl{}, + } + r.indexVars(css) + r.indexUtilities(css) + return r, nil +} + +// reVarDecl matches a `--name: value;` custom-property declaration. +var reVarDecl = regexp.MustCompile(`(--[A-Za-z0-9-]+)\s*:\s*([^;]+);`) + +// indexVars reads the theme `:root`/`:host` block into the light environment and the +// top-level `.dark { … }` rule into the dark overlay (which starts as a copy of +// light). The `.dark` rule we want is the design system's token override — selector +// exactly `.dark`, not the escaped utility selectors like `.dark\:bg-surface`. +func (r *Resolver) indexVars(css string) { + // Light: every custom property declared under the theme layer's :root/:host. + // The engine emits all theme variables there (it does not prune unused ones), + // so a single pass over the block captures the whole palette + tokens. + if root := blockBody(css, `:root, :host {`); root != "" { + for _, m := range reVarDecl.FindAllStringSubmatch(root, -1) { + r.light[m[1]] = strings.TrimSpace(m[2]) + } + } + // Some variables (e.g. the FA style flags) sit in a plain `:root {` the engine + // passes through; fold those in too so nothing referenced dangles. + if root := blockBody(css, "\n:root {"); root != "" { + for _, m := range reVarDecl.FindAllStringSubmatch(root, -1) { + if _, ok := r.light[m[1]]; !ok { + r.light[m[1]] = strings.TrimSpace(m[2]) + } + } + } + + // Dark starts as a copy of light, then every top-level `.dark { … }` rule + // re-points a subset — the kjol design-system layer defines one, and an app's + // brand stylesheet may add more, so all of them are folded in, in order. + for k, v := range r.light { + r.dark[k] = v + } + for _, darkBody := range eachBlock(css, "\n.dark {") { + for _, m := range reVarDecl.FindAllStringSubmatch(darkBody, -1) { + r.dark[m[1]] = strings.TrimSpace(m[2]) + } + } +} + +// reColorDecl finds the first color / background-color declaration in a rule body, +// even when it is nested inside a variant wrapper (`&:where(.dark, …) { … }`). +var reColorDecl = regexp.MustCompile(`(?:^|[{\s])(background-color|color)\s*:\s*([^;]+);`) + +// reUtilitySelector matches the start of one top-level utility rule and captures its +// (still CSS-escaped) selector, e.g. `.dark\:bg-surface {`. +var reUtilitySelector = regexp.MustCompile(`(?m)^\s{2}\.([^\s{]+)\s*\{`) + +// indexUtilities walks the @layer utilities block and records, per token, the first +// colour declaration the engine produced for it. Tokens with no colour declaration +// (layout utilities, font sizes, …) are simply absent from the map — which is +// exactly how we tell a colour utility from a non-colour one. +func (r *Resolver) indexUtilities(css string) { + body := blockBody(css, "@layer utilities {") + if body == "" { + return + } + locs := reUtilitySelector.FindAllStringSubmatchIndex(body, -1) + for i, loc := range locs { + escSel := body[loc[2]:loc[3]] + // The rule body runs from this selector's opening brace to the next + // top-level rule (or the end of the layer). That span may contain nested + // braces; we only need the first colour declaration within it. + start := loc[1] + end := len(body) + if i+1 < len(locs) { + end = locs[i+1][0] + } + rule := body[start:end] + m := reColorDecl.FindStringSubmatch(rule) + if m == nil { + continue + } + token := unescapeIdent(escSel) + r.tokens[token] = tokenDecl{prop: m[1], expr: strings.TrimSpace(m[2])} + } +} + +// blockBody returns the text between the braces of the first block whose header +// (including its opening `{`) matches marker. It is brace-aware, so nested rules are +// returned intact. +func blockBody(css, marker string) string { + idx := strings.Index(css, marker) + if idx < 0 { + return "" + } + open := idx + len(marker) - 1 // position of the '{' in the marker + depth := 0 + for i := open; i < len(css); i++ { + switch css[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return css[open+1 : i] + } + } + } + return "" +} + +// eachBlock returns the bodies of every block whose header matches marker, in order. +func eachBlock(css, marker string) []string { + var out []string + for { + idx := strings.Index(css, marker) + if idx < 0 { + return out + } + body := blockBody(css[idx:], marker) + out = append(out, body) + // Advance past this block's opening brace to find the next match. + css = css[idx+len(marker):] + } +} + +// unescapeIdent reverses CSS identifier escaping so a compiled selector maps back to +// the token the scanner saw. It handles both backslash-escaped punctuation +// (`bg-\[\#fff\]` → `bg-[#fff]`) and numeric escapes (`\32 xl` → `2xl`). +func unescapeIdent(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+1 >= len(s) { + b.WriteByte(s[i]) + continue + } + i++ + // Numeric escape: 1–6 hex digits, optional single trailing space. + if isHex(s[i]) { + j := i + for j < len(s) && j-i < 6 && isHex(s[j]) { + j++ + } + var code int + for k := i; k < j; k++ { + code = code*16 + hexVal(s[k]) + } + if j < len(s) && s[j] == ' ' { + j++ + } + b.WriteRune(rune(code)) + i = j - 1 + continue + } + b.WriteByte(s[i]) + } + return b.String() +} + +func isHex(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +func hexVal(c byte) int { + switch { + case c >= '0' && c <= '9': + return int(c - '0') + case c >= 'a' && c <= 'f': + return int(c-'a') + 10 + default: + return int(c-'A') + 10 + } +} + +// Colour resolution --------------------------------------------------------- + +// theme selects which variable environment a resolution runs against. +type theme int + +const ( + light theme = iota + dark +) + +func (r *Resolver) env(t theme) map[string]string { + if t == dark { + return r.dark + } + return r.light +} + +// isColorToken reports whether a token compiled to a colour-valued text-*/bg-* +// utility, and which side it lands on. side is "fg" for a text colour, "bg" for a +// background colour, "" if the token is not a foreground/background colour utility. +func (r *Resolver) side(token string) string { + d, ok := r.tokens[token] + if !ok { + return "" + } + switch d.prop { + case "color": + return "fg" + case "background-color": + return "bg" + } + return "" +} + +// resolveToken resolves a scanned token to a colour in the given theme. ok=false +// means the token is not a resolvable colour (unknown, or currentcolor/inherit). +func (r *Resolver) resolveToken(token string, t theme) (RGBA, bool) { + d, ok := r.tokens[token] + if !ok { + return RGBA{}, false + } + return r.resolveExpr(d.expr, t, 0) +} + +// surface returns the page background colour for a theme — the backdrop a +// translucent background is flattened against. +func (r *Resolver) surface(t theme) (RGBA, bool) { + if v, ok := r.env(t)[surfaceVar]; ok { + return r.resolveExpr(v, t, 0) + } + return RGBA{}, false +} + +var reVarFn = regexp.MustCompile(`^var\(\s*(--[A-Za-z0-9-]+)\s*(?:,\s*([^)]*))?\)$`) + +// resolveExpr resolves a CSS colour value expression to an RGBA. It follows var() +// chains through the environment and evaluates the color-mix() form the engine emits +// for opacity; anything else is handed to the literal parser. depth guards against a +// pathological variable cycle. +func (r *Resolver) resolveExpr(expr string, t theme, depth int) (RGBA, bool) { + expr = strings.TrimSpace(expr) + if depth > 32 || expr == "" { + return RGBA{}, false + } + if strings.HasPrefix(expr, "var(") { + m := reVarFn.FindStringSubmatch(expr) + if m == nil { + return RGBA{}, false + } + if v, ok := r.env(t)[m[1]]; ok { + return r.resolveExpr(v, t, depth+1) + } + if m[2] != "" { // var() fallback + return r.resolveExpr(m[2], t, depth+1) + } + return RGBA{}, false + } + if strings.HasPrefix(expr, "color-mix(") { + return r.resolveColorMix(expr, t, depth) + } + return parseLiteralColor(expr) +} + +// resolveColorMix evaluates `color-mix(in , [p1%], [p2%])`. The +// mixing space in Tailwind's output is always oklab; we evaluate there. This covers +// the opacity form (`… P%, transparent`) and hand-written arbitrary mixes. +func (r *Resolver) resolveColorMix(expr string, t theme, depth int) (RGBA, bool) { + inner := expr[strings.IndexByte(expr, '(')+1 : strings.LastIndexByte(expr, ')')] + parts := splitTopLevel(inner, ',') + if len(parts) != 3 { + return RGBA{}, false + } + // parts[0] is "in oklab" (or another space) — we always mix in oklab. + c1, w1, ok1 := r.mixComponent(parts[1], t, depth) + c2, w2, ok2 := r.mixComponent(parts[2], t, depth) + if !ok1 || !ok2 { + return RGBA{}, false + } + // If only one side gave a percentage, the other takes the remainder. + if w1 < 0 && w2 < 0 { + w1, w2 = 0.5, 0.5 + } else if w1 < 0 { + w1 = clamp01(1 - w2) + } else if w2 < 0 { + w2 = clamp01(1 - w1) + } + return mixOKLab(c1, w1, c2, w2), true +} + +// mixComponent parses one " [P%]" argument of a color-mix(). A negative +// weight means no percentage was given. +func (r *Resolver) mixComponent(s string, t theme, depth int) (RGBA, float64, bool) { + s = strings.TrimSpace(s) + weight := -1.0 + if i := strings.LastIndexByte(s, ' '); i >= 0 && strings.HasSuffix(s, "%") { + if v, err := strconv.ParseFloat(strings.TrimSuffix(s[i+1:], "%"), 64); err == nil { + weight = v / 100 + s = strings.TrimSpace(s[:i]) + } + } + c, ok := r.resolveExpr(s, t, depth+1) + return c, weight, ok +} + +// splitTopLevel splits s on sep, ignoring separators nested inside parentheses. +func splitTopLevel(s string, sep byte) []string { + var out []string + depth, start := 0, 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '(': + depth++ + case ')': + depth-- + case sep: + if depth == 0 { + out = append(out, strings.TrimSpace(s[start:i])) + start = i + 1 + } + } + } + out = append(out, strings.TrimSpace(s[start:])) + return out +}