Add aria check program to go/cmd
This commit is contained in:
308
go/cmd/aria-check/check.go
Normal file
308
go/cmd/aria-check/check.go
Normal 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)})
|
||||||
|
}
|
||||||
175
go/cmd/aria-check/check_test.go
Normal file
175
go/cmd/aria-check/check_test.go
Normal 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
436
go/cmd/aria-check/color.go
Normal 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},
|
||||||
|
}
|
||||||
94
go/cmd/aria-check/color_test.go
Normal file
94
go/cmd/aria-check/color_test.go
Normal 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
228
go/cmd/aria-check/main.go
Normal 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
253
go/cmd/aria-check/scan.go
Normal 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
377
go/cmd/aria-check/theme.go
Normal 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: 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 <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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user