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

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
}