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
+}