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 }