Files
kjol/go/cmd/aria-check/theme.go

378 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}