Add aria check program to go/cmd

This commit is contained in:
2026-07-20 12:05:55 -04:00
parent 03b5bea72d
commit c0ddef5923
7 changed files with 1871 additions and 0 deletions

308
go/cmd/aria-check/check.go Normal file
View File

@@ -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)})
}

View File

@@ -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("<p>don't click</p>\n<a class='bg-red-500 text-white'>x</a>\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)
}
}
}

436
go/cmd/aria-check/color.go Normal file
View File

@@ -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, <color> 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},
}

View File

@@ -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")
}
}

228
go/cmd/aria-check/main.go Normal file
View File

@@ -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)
}

253
go/cmd/aria-check/scan.go Normal file
View File

@@ -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:]
}

377
go/cmd/aria-check/theme.go Normal file
View File

@@ -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: 16 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 <space>, <c1> [p1%], <c2> [p2%])`. The
// mixing space in Tailwind's output is always oklab; we evaluate there. This covers
// the opacity form (`… <color> 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 "<color> [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
}