package check import ( "math" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "testing" "amadan.net/rastrillo/fiesta" ) // This file is a WCAG 2.2 AA contrast gate over every shipped theme's // *declared* custom-property values — the pairs a component's markup // names directly (e.g. "color: var(--rst-text); background: // var(--rst-bg)"). tokens.css declares no colour of its own; the values // under test all live in themes/.css. // // Since the v2 themes each collapsed to one :root block, every token is // declared once as light-dark(, ) and this file splits that // call back into two per-scheme tables, so a theme is gated in light and // in dark from a single set of declarations. A token whose value is not // a light-dark() call (the font stack, the radii, a shadow whose two // schemes are identical) is carried into both tables unchanged. // // The WCAG arithmetic itself is not here: contrast ratios come from // ContrastRatio in colour.go, the same arithmetic rastrillo's colour // engine is built on, so this gate and the engine cannot drift apart. // // LIMITATION, stated explicitly because it is easy to forget: this checks // token pairs, not the resolved cascade. It parses each theme's :root // block and computes contrast between the hex values two custom // properties are *declared* as — it does not parse selectors, does not // know which element pairs which background in the actual DOM, and does // not see specificity, :hover, or any other cascade effect. A token can // pass every check here and still render at the wrong contrast once a // browser resolves the real cascade: tokens.css's own comment on // [rst-btn~="danger"]:hover documents exactly that happening — an earlier, // higher-specificity [rst-btn]:hover rule silently won the label colour // back to the accent (~1.05:1 on the red fill) at the exact moment a // user commits to a destructive action, and no token-level check would // ever have caught it, because both --rst-tone-negative-fg and // --rst-on-accent were, individually, still declared at a passing ratio // the whole time. That bug was only caught by reading the rendered // cascade by hand. Treat a green run of this file as "the palette is // internally consistent," never as "every rendered pixel passes." // colorMixSkip lists custom properties whose *value* uses a CSS function // this test cannot evaluate (color-mix(), a relative-colour syntax) — // computing those is out of scope, so they are named here, explicitly, // rather than silently skipped by a parse failure. light-dark() is no // longer such a function: splitLightDark below evaluates it, which is // the whole point of the v2 format. Empty today. If a theme grows a // value this parser cannot read, name it here with a comment instead of // weakening the parser to accept it silently. var colorMixSkip = map[string]bool{ // (none yet) // // --rst-header-rule is NOT listed here, and its absence is the // point: skipping a token implies it belongs in the pair table and // could not be evaluated. The header rule does not belong in the // table at all. day and signal derive it with color-mix(), which // this parser cannot read — and it is never asked to, because no // pair below names it. See the note above the pair table. } // declaredAt is what decls set at selector in context, property by // property, as the cascade reads rules of one selector: the last // !important declaration, else the last. A value keeps a trailing // " !important", so a gate comparing values sees it. func declaredAt(decls []declaration, context, selector string) map[string]string { out := map[string]string{} important := map[string]bool{} for _, d := range decls { if d.Context != context || d.Selector != selector || (important[d.Property] && !d.Important) { continue } out[d.Property], important[d.Property] = fullValue(d), d.Important } return out } // fullValue is a declaration's value with its !important, if any. func fullValue(d declaration) string { if d.Important { return d.Value + " !important" } return d.Value } // themeDeclarations reads a theme file with the declaration reader, // failing the test on one it refuses. A theme was read with patterns // over its text, which counted a declaration in a comment or a string // as one the browser applies. func themeDeclarations(t testing.TB, where, css string) []declaration { t.Helper() decls, err := declarationsOf(where, css) if err != nil { t.Fatal(err) } return decls } // splitLightDark evaluates a whole-value light-dark(, ) // call, returning the two halves. It reports false for anything else — // including a value that merely *contains* a light-dark() somewhere // inside it, which is exactly what the shadow tokens look like // ("0 8px 24px light-dark(rgba(…), rgba(…))"): those are not colours in // their own right, are not in the pair table, and are left to both // schemes verbatim rather than half-parsed. // // The split is paren-aware because both halves are usually rgba() calls // full of commas of their own. func splitLightDark(v string) (light, dark string, ok bool) { const prefix = "light-dark(" if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") { return "", "", false } inner := v[len(prefix) : len(v)-1] depth, comma := 0, -1 // Read with cssSpan, so a comma or a bracket inside a string is // text: light-dark("a, b") is one argument, not two. for i := 0; i < len(inner); { end, comment, err := cssSpan(inner, i) if err != nil || comment { return "", "", false } switch inner[i] { case '(': depth++ case ')': depth-- if depth < 0 { return "", "", false // the closing ")" we trimmed was not ours } case ',': if depth == 0 { if comma >= 0 { return "", "", false // three arguments is not light-dark() } comma = i } } i = end } if depth != 0 || comma < 0 { return "", "", false } return strings.TrimSpace(inner[:comma]), strings.TrimSpace(inner[comma+1:]), true } // themeTokens reads the :root a v2 theme declares and returns it // twice: once resolved for the light scheme, once for dark. func themeTokens(t *testing.T, theme string) map[string]map[string]string { t.Helper() raw, ok := fiesta.ThemeCSS(theme) if !ok { t.Fatalf("fiesta.ThemeCSS(%q) missing", theme) } return themeTokensOf(t, "themes/"+theme+".css", string(raw)) } // themeTokensOf is themeTokens over a theme's text: the --rst- // properties its top-level :root rules declare, read with the // declaration reader, so one in a comment or a string is not one. func themeTokensOf(t testing.TB, where, css string) map[string]map[string]string { t.Helper() decls := themeDeclarations(t, where, css) declared := map[string]string{} for name, v := range declaredAt(decls, "", ":root") { if strings.HasPrefix(name, "--rst-") { declared[name] = v } } if len(declared) == 0 { t.Fatalf("%s declares no --rst- properties in its :root block", where) } // day redeclares its hue-derived tokens inside an @supports block // for relative colour (derived_test.go). Those are what a current // engine paints, so they win here, as they do in the cascade. for name, v := range declaredAt(decls, accentSupports, ":root") { if strings.HasPrefix(name, "--rst-") { declared[name] = v } } light := make(map[string]string, len(declared)) dark := make(map[string]string, len(declared)) for name, v := range declared { if l, d, ok := splitLightDark(v); ok { light[name], dark[name] = l, d continue } light[name], dark[name] = v, v } return map[string]map[string]string{"light": light, "dark": dark} } // TestSplitLightDarkReadsTheV2Format pins the one piece of parsing the // whole gate rests on. A splitter that quietly stopped recognising // light-dark() would not fail anything — it would hand both schemes the // same unsplit string, every hex parse would fail, and the pair table // would report "add to colorMixSkip" instead of a contrast number. These // cases say what the format is, including the two shapes that must NOT // split: a shadow that merely contains a light-dark(), and a plain // single value. func TestSplitLightDarkReadsTheV2Format(t *testing.T) { for _, tt := range []struct { in string wantL, wantD string wantOK bool }{ {"light-dark(#ffffff, #111418)", "#ffffff", "#111418", true}, {"light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6))", "rgba(0, 0, 0, 0.45)", "rgba(0, 0, 0, 0.6)", true}, {"0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))", "", "", false}, {"0 1px 2px rgba(0, 0, 0, 0.3)", "", "", false}, {"8px", "", "", false}, {"light-dark(#fff)", "", "", false}, {"light-dark(#fff, #000, #111)", "", "", false}, } { gotL, gotD, ok := splitLightDark(tt.in) if ok != tt.wantOK || gotL != tt.wantL || gotD != tt.wantD { t.Errorf("splitLightDark(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.in, gotL, gotD, ok, tt.wantL, tt.wantD, tt.wantOK) } } } // TestContrastMathMatchesDocumentedDangerFillRatios calibrates the WCAG // arithmetic against numbers tokens.css's own comment already published // and hand-verified (the [rst-btn~="danger"] comment — the one colour // commentary that stayed with the component rule): --rst-on-accent on // --rst-tone-negative-fg, both schemes of the default theme. If this test // ever fails, suspect the formula before suspecting the published ratios. // // The arithmetic it calibrates is ui.ContrastRatio in colour.go — shipped // code, not a test helper. There is one implementation of the WCAG // formula in this package: this gate, the colour engine and any app // gating its own colours all measure with the same one, so these two // hand-verified ratios calibrate all three. func TestContrastMathMatchesDocumentedDangerFillRatios(t *testing.T) { for _, tt := range []struct { name string fg, bg string wantLow float64 // the comment's published value, rounded to 2dp; allow ±0.02 for rounding wantHigh float64 }{ {"light: on-accent on tone-negative-fg", "#ffffff", "#b91c1c", 6.45, 6.49}, {"dark: on-accent on tone-negative-fg", "#0b1220", "#f79aa0", 8.97, 9.01}, } { got, err := ContrastRatio(tt.fg, tt.bg) if err != nil { t.Fatalf("%s: %v", tt.name, err) } if got < tt.wantLow || got > tt.wantHigh { t.Errorf("%s: ContrastRatio(%s, %s) = %.2f, want in [%.2f, %.2f] (tokens.css's published ratio)", tt.name, tt.fg, tt.bg, got, tt.wantLow, tt.wantHigh) } } } // TestThemeTokenContrastMeetsWCAG is the real gate: the full pair table // each theme's own "WCAG 2.2 AA, measured" header comment documents, // enforced at each row's documented floor, in every shipped theme and in // BOTH schemes. See the file doc comment above for exactly what this does // and does not verify. // // --rst-text-faint is held to the same 4.5:1 body-text floor as // --rst-text/--rst-text-muted, not AA's lower 3:1 large-text/graphic // floor: this branch puts it on normal-size, normal-weight text // ([rst-count-line], [rst-field-hint]) and on [rst-lrow~="head"] (11.5px, // well under WCAG's ~18pt/14pt-bold "large text" threshold even with its // letter-spacing and uppercase transform), so 3:1 would be the wrong bar // for what this token is actually used for. func TestThemeTokenContrastMeetsWCAG(t *testing.T) { type pair struct { fg, bg string min float64 why string } // Transcribed from each theme's own "WCAG 2.2 AA, measured" header // table plus the [rst-btn~="danger"] comment's documented pair (not in // the main table, since it reuses --rst-tone-negative-fg as a solid // fill rather than declaring a dedicated --rst-danger-* token) — every // row those tables publish, at its documented floor. If a header // table grows a row, add it here too; the two are meant to stay in // lockstep. // // --rst-header-rule is deliberately absent. The page header's rule // is decorative and carries no contrast floor (design doc §6-v2.2): // the heading's size, weight and spacing carry the structure, the // rule does not, so 1.4.11's 3:1 was never its bar. signal tints it // with the accent at 45%, which on a light surface is under 3:1 by // construction; day and plain make it the line, which is a hairline // divider and carries no floor either. That is not a defect and // must not be "fixed" by adding a row here — the design doc says so in // writing precisely so this argument has already been answered. pairs := []pair{ {"--rst-text", "--rst-surface", 4.5, "body text on a card"}, {"--rst-text", "--rst-bg", 4.5, "body text"}, {"--rst-text", "--rst-surface-2", 4.5, "body text on a card"}, {"--rst-text", "--rst-accent-soft", 4.5, "body text on an accent-tinted surface"}, {"--rst-text-muted", "--rst-surface", 4.5, "muted text on a card"}, {"--rst-text-muted", "--rst-bg", 4.5, "muted text"}, {"--rst-text-muted", "--rst-surface-2", 4.5, "muted text on a card"}, {"--rst-text-muted", "--rst-accent-soft", 4.5, "muted text on an accent-tinted surface"}, {"--rst-text-faint", "--rst-surface", 4.5, "faint text on a card ([rst-field-hint])"}, {"--rst-text-faint", "--rst-bg", 4.5, "faint text"}, {"--rst-text-faint", "--rst-surface-2", 4.5, "faint text on a card"}, {"--rst-text-faint", "--rst-accent-soft", 4.5, "faint text on an accent-tinted surface"}, {"--rst-accent", "--rst-surface", 4.5, "text + focus ring"}, {"--rst-accent", "--rst-bg", 4.5, "text + focus ring"}, {"--rst-accent", "--rst-surface-2", 4.5, "text"}, {"--rst-accent", "--rst-accent-soft", 4.5, "text"}, {"--rst-on-accent", "--rst-accent", 4.5, "primary button label"}, {"--rst-on-accent", "--rst-accent-strong", 4.5, "primary button label, hover"}, {"--rst-line-strong", "--rst-surface", 3.0, "control border (1.4.11 boundary, not text)"}, {"--rst-line-strong", "--rst-bg", 3.0, "control border"}, {"--rst-line-strong", "--rst-surface-2", 3.0, "control border"}, {"--rst-tone-neutral-fg", "--rst-tone-neutral-bg", 4.5, "status pill text"}, {"--rst-tone-positive-fg", "--rst-tone-positive-bg", 4.5, "status pill text"}, {"--rst-tone-warning-fg", "--rst-tone-warning-bg", 4.5, "status pill text"}, {"--rst-tone-negative-fg", "--rst-tone-negative-bg", 4.5, "status pill text"}, // The stat band's delta, which is the first place in this // system where a tone colours TEXT on a plain card rather than // filling a pill behind it. The pill pairs above say nothing // about this one: --rst-tone-positive-fg was chosen to clear // 4.5:1 on --rst-tone-positive-bg, a tinted ground, and // --rst-surface is not that ground. // // It owes the full 4.5:1 even though the delta's sign already // carries the meaning and the colour is the second signal. // 1.4.1 is about meaning; 1.4.3 is about text, and this is // text. A reader who cannot resolve the hue still has to be // able to READ the number. {"--rst-tone-positive-fg", "--rst-surface", 4.5, "a stat's rising delta"}, {"--rst-tone-negative-fg", "--rst-surface", 4.5, "a stat's falling delta"}, // Not in the main header tables — documented separately in // tokens.css's [rst-btn~="danger"] comment, which reuses // --rst-tone-negative-fg as a solid fill rather than declaring a // dedicated --rst-danger-* pair. This is the real pair the danger // button's label renders against, so it belongs in the gate anyway. {"--rst-on-accent", "--rst-tone-negative-fg", 4.5, "danger button label"}, // The depth axis's pairs. Filled controls read // their own fill now, so the label pairs are these; the old // --rst-on-accent on --rst-accent rows stay for the places still // painted with the accent itself. {"--rst-on-accent-fill", "--rst-accent-fill", 4.5, "primary button label"}, {"--rst-on-accent-fill", "--rst-accent-fill-hover", 4.5, "primary button label, hover"}, {"--rst-on-danger-fill", "--rst-danger-fill", 4.5, "danger button label"}, {"--rst-on-danger-fill-hover", "--rst-danger-fill-hover", 4.5, "danger button label, hover"}, {"--rst-text", "--rst-row-tint-hover", 4.5, "a list-row-action's text, hovered"}, {"--rst-text", "--rst-row-tint-press", 4.5, "a list-row-action's text, pressed"}, {"--rst-control-text-hover", "--rst-control-face-hover", 4.5, "plain button label, hover"}, {"--rst-text-muted", "--rst-control-face-hover", 4.5, "a muted label on a hovered plain face"}, {"--rst-text", "--rst-tint-hover", 4.5, "row text on the hover tint"}, {"--rst-text", "--rst-tint-press", 4.5, "row text on the press tint"}, {"--rst-text-muted", "--rst-tint-hover", 4.5, "a row's muted cell on the hover tint"}, {"--rst-text-muted", "--rst-tint-press", 4.5, "a row's muted cell on the press tint"}, {"--rst-text-faint", "--rst-tint-hover", 4.5, "a row's faint cell on the hover tint"}, {"--rst-text-faint", "--rst-tint-press", 4.5, "a row's faint cell on the press tint"}, {"--rst-accent", "--rst-tint-hover", 4.5, "a link on a hovered row"}, {"--rst-nav-current-fg", "--rst-nav-current-bg", 4.5, "the current nav item"}, {"--rst-track-off", "--rst-surface", 3.0, "switch off track (1.4.11 boundary)"}, {"--rst-track-off", "--rst-bg", 3.0, "switch off track on the page"}, {"--rst-knob-shade", "--rst-track-off", 3.0, "switch knob on the off track"}, {"--rst-knob-shade", "--rst-accent-fill", 3.0, "switch knob on the on track"}, } for _, theme := range fiesta.ThemeNames() { for _, scheme := range []string{"light", "dark"} { // Resolved, not declared: day derives its fills, tints and // soft ground from the accent (derived_test.go), and a pair // that names one of those is gated on what the derivation // lands on, held to Chromium's pixels by the browser suite. tokens := resolvedTokens(t, theme)[scheme] t.Run(theme+"/"+scheme, func(t *testing.T) { for _, p := range pairs { if colorMixSkip[p.fg] || colorMixSkip[p.bg] { t.Logf("skipping %s on %s: listed in colorMixSkip", p.fg, p.bg) continue } fgVal, ok := tokens[p.fg] if !ok { t.Errorf("token %s is not declared in this theme", p.fg) continue } bgVal, ok := tokens[p.bg] if !ok { t.Errorf("token %s is not declared in this theme", p.bg) continue } ratio, err := ContrastRatio(fgVal, bgVal) if err != nil { t.Errorf("%s (%s) on %s (%s): %v — add to colorMixSkip if this is a value the parser cannot evaluate", p.fg, fgVal, p.bg, bgVal, err) continue } // Logged in every run, so `go test -v -run // TestThemeTokenContrastMeetsWCAG` prints the whole // table a theme's header comment copies: the rows // there are transcribed from here, never computed // by hand. t.Logf("%s on %s = %.2f", p.fg, p.bg, ratio) if ratio < p.min { t.Errorf("%s (%s) on %s (%s) = %.2f:1, want >= %.1f:1 (%s)", p.fg, fgVal, p.bg, bgVal, ratio, p.min, p.why) } } }) } } } // Every highlight colour of the gallery's code view reaches 4.5:1 // against the code block's --rst-surface, in every theme and both // schemes, resolved from the themes' own light-dark() pairs. The axe // scan checks the rendered cascade; this checks every pair, including // the ones a page with no such token on it would never show axe. The // five rules are read from the gallery's view.css, the widget's half // of its stylesheet, where the code-block rules live. // // It lived beside the gallery and read each theme's :root with a // pattern, so a declaration in a comment there was measured in place // of the one a browser applies. Here it reads both stylesheets with // the declaration reader: view.css with leafRules, the themes with // themeTokens. func TestHighlightColoursPassAgainstTheCodeBackground(t *testing.T) { view, err := os.ReadFile(filepath.Join("..", "internal", "gallery", "view.css")) if err != nil { t.Fatal(err) } element := regexp.MustCompile(`^ds-[tavxs]$`) token := regexp.MustCompile(`^var\((--rst-[a-z0-9-]+)\)$`) pairs := map[string]string{} for _, r := range leafRules(t, string(view)) { if r.context != "" || !element.MatchString(r.selector) || len(r.decls) != 1 { continue } if m := token.FindStringSubmatch(r.decls[0][1]); r.decls[0][0] == "color" && r.decls[0][2] == "" && m != nil { pairs[r.selector] = m[1] } } if len(pairs) != 5 { t.Fatalf("view.css colours %d highlight elements, want 5: %v", len(pairs), pairs) } for _, theme := range fiesta.ThemeNames() { for scheme, tok := range themeTokens(t, theme) { bg := tok["--rst-surface"] for el, name := range pairs { fg := tok[name] r, err := ContrastRatio(fg, bg) if err != nil { t.Errorf("%s %s %s (%s on %s): %v", theme, scheme, el, fg, bg, err) continue } if r < 4.5 { t.Errorf("%s %s: %s is %s (%s) on %s, %.2f:1, under 4.5:1", theme, scheme, el, name, fg, bg, r) } } } } } // stageArtMix reads the colour tokens.css gives one part of the stage // art — color-mix(in srgb, var(A) N%, var(B)) — so the check below // measures what the stylesheet actually says rather than a copy of it. func stageArtMix(t *testing.T, part string) (a string, p float64, b string) { t.Helper() // Read off the rule's declarations, so a mix in a comment or a // string is not the one measured. mix := regexp.MustCompile(`^color-mix\(in srgb, var\((--rst-[a-z-]+)\) (\d+)%, var\((--rst-[a-z-]+)\)\)$`) var m []string for _, r := range leafRules(t, string(fiesta.TokensCSS())) { if m != nil || !slices.Contains(splitSelectorList(r.selector), "[rst-stage-art-"+part+"]") { continue } for _, prop := range []string{"fill", "stroke"} { if v, ok := r.value(prop); ok && m == nil { m = mix.FindStringSubmatch(v) } } } if m == nil { t.Fatalf("tokens.css paints [rst-stage-art-%s] with no color-mix(in srgb, var(--rst-…) N%%, var(--rst-…))", part) } n, _ := strconv.Atoi(m[2]) return m[1], float64(n) / 100, m[3] } // mixSRGB is CSS color-mix(in srgb, a p, b): a per-channel linear blend // of the two sRGB values. func mixSRGB(t *testing.T, a, b string, p float64) string { t.Helper() ar, ag, ab, err := parseHex(a) if err != nil { t.Fatal(err) } br, bg, bb, err := parseHex(b) if err != nil { t.Fatal(err) } mix := func(x, y uint8) uint8 { return uint8(math.Round(float64(x)*p + float64(y)*(1-p))) } return hexOf([3]uint8{mix(ar, br), mix(ag, bg), mix(ab, bb)}) } // The sign-in card's border is its boundary against the stage art (WCAG // 1.4.11). The art is drawn by mixing accent and line into the page // background, so the border must clear 3:1 against each colour the art // paints, in every theme and scheme — not only against --rst-bg, which // is all the token-pair gate above can see. Each colour is measured at // full strength, which the glow only reaches where its translucent // circles pile up: the worst case, never better than it. If this fails, // lower the art's percentage in tokens.css; never the floor. func TestTheSigninCardStandsOutFromTheStageArt(t *testing.T) { // What follows measures --rst-line-strong. Hold the card to it, or a // later edit to the card's border leaves this passing about a colour // nothing paints. if !slices.ContainsFunc(leafRules(t, string(fiesta.TokensCSS())), func(r leafRule) bool { return slices.Contains(splitSelectorList(r.selector), "[rst-signin]") && r.sets("border", "1px solid var(--rst-line-strong)") }) { t.Fatal("tokens.css no longer borders [rst-signin] with 1px solid var(--rst-line-strong), the colour this test measures") } for _, theme := range fiesta.ThemeNames() { for _, scheme := range []string{"light", "dark"} { tok := themeTokens(t, theme)[scheme] for _, part := range []string{"lines", "glow"} { a, p, b := stageArtMix(t, part) behind := mixSRGB(t, tok[a], tok[b], p) ratio, err := ContrastRatio(tok["--rst-line-strong"], behind) if err != nil { t.Fatal(err) } if ratio < 3.0 { t.Errorf("%s/%s: the card border on the art's %s is %.2f:1, want >= 3:1", theme, scheme, part, ratio) } } } } }