| 1 | package check |
| 2 | |
| 3 | import ( |
| 4 | "math" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "slices" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "amadan.net/rastrillo/fiesta" |
| 14 | ) |
| 15 | |
| 16 | // This file is a WCAG 2.2 AA contrast gate over every shipped theme's |
| 17 | // *declared* custom-property values — the pairs a component's markup |
| 18 | // names directly (e.g. "color: var(--rst-text); background: |
| 19 | // var(--rst-bg)"). tokens.css declares no colour of its own; the values |
| 20 | // under test all live in themes/<name>.css. |
| 21 | // |
| 22 | // Since the v2 themes each collapsed to one :root block, every token is |
| 23 | // declared once as light-dark(<light>, <dark>) and this file splits that |
| 24 | // call back into two per-scheme tables, so a theme is gated in light and |
| 25 | // in dark from a single set of declarations. A token whose value is not |
| 26 | // a light-dark() call (the font stack, the radii, a shadow whose two |
| 27 | // schemes are identical) is carried into both tables unchanged. |
| 28 | // |
| 29 | // The WCAG arithmetic itself is not here: contrast ratios come from |
| 30 | // ContrastRatio in colour.go, the same arithmetic rastrillo's colour |
| 31 | // engine is built on, so this gate and the engine cannot drift apart. |
| 32 | // |
| 33 | // LIMITATION, stated explicitly because it is easy to forget: this checks |
| 34 | // token pairs, not the resolved cascade. It parses each theme's :root |
| 35 | // block and computes contrast between the hex values two custom |
| 36 | // properties are *declared* as — it does not parse selectors, does not |
| 37 | // know which element pairs which background in the actual DOM, and does |
| 38 | // not see specificity, :hover, or any other cascade effect. A token can |
| 39 | // pass every check here and still render at the wrong contrast once a |
| 40 | // browser resolves the real cascade: tokens.css's own comment on |
| 41 | // [rst-btn~="danger"]:hover documents exactly that happening — an earlier, |
| 42 | // higher-specificity [rst-btn]:hover rule silently won the label colour |
| 43 | // back to the accent (~1.05:1 on the red fill) at the exact moment a |
| 44 | // user commits to a destructive action, and no token-level check would |
| 45 | // ever have caught it, because both --rst-tone-negative-fg and |
| 46 | // --rst-on-accent were, individually, still declared at a passing ratio |
| 47 | // the whole time. That bug was only caught by reading the rendered |
| 48 | // cascade by hand. Treat a green run of this file as "the palette is |
| 49 | // internally consistent," never as "every rendered pixel passes." |
| 50 | |
| 51 | // colorMixSkip lists custom properties whose *value* uses a CSS function |
| 52 | // this test cannot evaluate (color-mix(), a relative-colour syntax) — |
| 53 | // computing those is out of scope, so they are named here, explicitly, |
| 54 | // rather than silently skipped by a parse failure. light-dark() is no |
| 55 | // longer such a function: splitLightDark below evaluates it, which is |
| 56 | // the whole point of the v2 format. Empty today. If a theme grows a |
| 57 | // value this parser cannot read, name it here with a comment instead of |
| 58 | // weakening the parser to accept it silently. |
| 59 | var colorMixSkip = map[string]bool{ |
| 60 | // (none yet) |
| 61 | // |
| 62 | // --rst-header-rule is NOT listed here, and its absence is the |
| 63 | // point: skipping a token implies it belongs in the pair table and |
| 64 | // could not be evaluated. The header rule does not belong in the |
| 65 | // table at all. day and signal derive it with color-mix(), which |
| 66 | // this parser cannot read — and it is never asked to, because no |
| 67 | // pair below names it. See the note above the pair table. |
| 68 | } |
| 69 | |
| 70 | // declaredAt is what decls set at selector in context, property by |
| 71 | // property, as the cascade reads rules of one selector: the last |
| 72 | // !important declaration, else the last. A value keeps a trailing |
| 73 | // " !important", so a gate comparing values sees it. |
| 74 | func declaredAt(decls []declaration, context, selector string) map[string]string { |
| 75 | out := map[string]string{} |
| 76 | important := map[string]bool{} |
| 77 | for _, d := range decls { |
| 78 | if d.Context != context || d.Selector != selector || (important[d.Property] && !d.Important) { |
| 79 | continue |
| 80 | } |
| 81 | out[d.Property], important[d.Property] = fullValue(d), d.Important |
| 82 | } |
| 83 | return out |
| 84 | } |
| 85 | |
| 86 | // fullValue is a declaration's value with its !important, if any. |
| 87 | func fullValue(d declaration) string { |
| 88 | if d.Important { |
| 89 | return d.Value + " !important" |
| 90 | } |
| 91 | return d.Value |
| 92 | } |
| 93 | |
| 94 | // themeDeclarations reads a theme file with the declaration reader, |
| 95 | // failing the test on one it refuses. A theme was read with patterns |
| 96 | // over its text, which counted a declaration in a comment or a string |
| 97 | // as one the browser applies. |
| 98 | func themeDeclarations(t testing.TB, where, css string) []declaration { |
| 99 | t.Helper() |
| 100 | decls, err := declarationsOf(where, css) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | return decls |
| 105 | } |
| 106 | |
| 107 | // splitLightDark evaluates a whole-value light-dark(<light>, <dark>) |
| 108 | // call, returning the two halves. It reports false for anything else — |
| 109 | // including a value that merely *contains* a light-dark() somewhere |
| 110 | // inside it, which is exactly what the shadow tokens look like |
| 111 | // ("0 8px 24px light-dark(rgba(…), rgba(…))"): those are not colours in |
| 112 | // their own right, are not in the pair table, and are left to both |
| 113 | // schemes verbatim rather than half-parsed. |
| 114 | // |
| 115 | // The split is paren-aware because both halves are usually rgba() calls |
| 116 | // full of commas of their own. |
| 117 | func splitLightDark(v string) (light, dark string, ok bool) { |
| 118 | const prefix = "light-dark(" |
| 119 | if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") { |
| 120 | return "", "", false |
| 121 | } |
| 122 | inner := v[len(prefix) : len(v)-1] |
| 123 | depth, comma := 0, -1 |
| 124 | // Read with cssSpan, so a comma or a bracket inside a string is |
| 125 | // text: light-dark("a, b") is one argument, not two. |
| 126 | for i := 0; i < len(inner); { |
| 127 | end, comment, err := cssSpan(inner, i) |
| 128 | if err != nil || comment { |
| 129 | return "", "", false |
| 130 | } |
| 131 | switch inner[i] { |
| 132 | case '(': |
| 133 | depth++ |
| 134 | case ')': |
| 135 | depth-- |
| 136 | if depth < 0 { |
| 137 | return "", "", false // the closing ")" we trimmed was not ours |
| 138 | } |
| 139 | case ',': |
| 140 | if depth == 0 { |
| 141 | if comma >= 0 { |
| 142 | return "", "", false // three arguments is not light-dark() |
| 143 | } |
| 144 | comma = i |
| 145 | } |
| 146 | } |
| 147 | i = end |
| 148 | } |
| 149 | if depth != 0 || comma < 0 { |
| 150 | return "", "", false |
| 151 | } |
| 152 | return strings.TrimSpace(inner[:comma]), strings.TrimSpace(inner[comma+1:]), true |
| 153 | } |
| 154 | |
| 155 | // themeTokens reads the :root a v2 theme declares and returns it |
| 156 | // twice: once resolved for the light scheme, once for dark. |
| 157 | func themeTokens(t *testing.T, theme string) map[string]map[string]string { |
| 158 | t.Helper() |
| 159 | raw, ok := fiesta.ThemeCSS(theme) |
| 160 | if !ok { |
| 161 | t.Fatalf("fiesta.ThemeCSS(%q) missing", theme) |
| 162 | } |
| 163 | return themeTokensOf(t, "themes/"+theme+".css", string(raw)) |
| 164 | } |
| 165 | |
| 166 | // themeTokensOf is themeTokens over a theme's text: the --rst- |
| 167 | // properties its top-level :root rules declare, read with the |
| 168 | // declaration reader, so one in a comment or a string is not one. |
| 169 | func themeTokensOf(t testing.TB, where, css string) map[string]map[string]string { |
| 170 | t.Helper() |
| 171 | decls := themeDeclarations(t, where, css) |
| 172 | declared := map[string]string{} |
| 173 | for name, v := range declaredAt(decls, "", ":root") { |
| 174 | if strings.HasPrefix(name, "--rst-") { |
| 175 | declared[name] = v |
| 176 | } |
| 177 | } |
| 178 | if len(declared) == 0 { |
| 179 | t.Fatalf("%s declares no --rst- properties in its :root block", where) |
| 180 | } |
| 181 | // day redeclares its hue-derived tokens inside an @supports block |
| 182 | // for relative colour (derived_test.go). Those are what a current |
| 183 | // engine paints, so they win here, as they do in the cascade. |
| 184 | for name, v := range declaredAt(decls, accentSupports, ":root") { |
| 185 | if strings.HasPrefix(name, "--rst-") { |
| 186 | declared[name] = v |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | light := make(map[string]string, len(declared)) |
| 191 | dark := make(map[string]string, len(declared)) |
| 192 | for name, v := range declared { |
| 193 | if l, d, ok := splitLightDark(v); ok { |
| 194 | light[name], dark[name] = l, d |
| 195 | continue |
| 196 | } |
| 197 | light[name], dark[name] = v, v |
| 198 | } |
| 199 | return map[string]map[string]string{"light": light, "dark": dark} |
| 200 | } |
| 201 | |
| 202 | // TestSplitLightDarkReadsTheV2Format pins the one piece of parsing the |
| 203 | // whole gate rests on. A splitter that quietly stopped recognising |
| 204 | // light-dark() would not fail anything — it would hand both schemes the |
| 205 | // same unsplit string, every hex parse would fail, and the pair table |
| 206 | // would report "add to colorMixSkip" instead of a contrast number. These |
| 207 | // cases say what the format is, including the two shapes that must NOT |
| 208 | // split: a shadow that merely contains a light-dark(), and a plain |
| 209 | // single value. |
| 210 | func TestSplitLightDarkReadsTheV2Format(t *testing.T) { |
| 211 | for _, tt := range []struct { |
| 212 | in string |
| 213 | wantL, wantD string |
| 214 | wantOK bool |
| 215 | }{ |
| 216 | {"light-dark(#ffffff, #111418)", "#ffffff", "#111418", true}, |
| 217 | {"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}, |
| 218 | {"0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))", "", "", false}, |
| 219 | {"0 1px 2px rgba(0, 0, 0, 0.3)", "", "", false}, |
| 220 | {"8px", "", "", false}, |
| 221 | {"light-dark(#fff)", "", "", false}, |
| 222 | {"light-dark(#fff, #000, #111)", "", "", false}, |
| 223 | } { |
| 224 | gotL, gotD, ok := splitLightDark(tt.in) |
| 225 | if ok != tt.wantOK || gotL != tt.wantL || gotD != tt.wantD { |
| 226 | t.Errorf("splitLightDark(%q) = (%q, %q, %v), want (%q, %q, %v)", |
| 227 | tt.in, gotL, gotD, ok, tt.wantL, tt.wantD, tt.wantOK) |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | // TestContrastMathMatchesDocumentedDangerFillRatios calibrates the WCAG |
| 233 | // arithmetic against numbers tokens.css's own comment already published |
| 234 | // and hand-verified (the [rst-btn~="danger"] comment — the one colour |
| 235 | // commentary that stayed with the component rule): --rst-on-accent on |
| 236 | // --rst-tone-negative-fg, both schemes of the default theme. If this test |
| 237 | // ever fails, suspect the formula before suspecting the published ratios. |
| 238 | // |
| 239 | // The arithmetic it calibrates is ui.ContrastRatio in colour.go — shipped |
| 240 | // code, not a test helper. There is one implementation of the WCAG |
| 241 | // formula in this package: this gate, the colour engine and any app |
| 242 | // gating its own colours all measure with the same one, so these two |
| 243 | // hand-verified ratios calibrate all three. |
| 244 | func TestContrastMathMatchesDocumentedDangerFillRatios(t *testing.T) { |
| 245 | for _, tt := range []struct { |
| 246 | name string |
| 247 | fg, bg string |
| 248 | wantLow float64 // the comment's published value, rounded to 2dp; allow ±0.02 for rounding |
| 249 | wantHigh float64 |
| 250 | }{ |
| 251 | {"light: on-accent on tone-negative-fg", "#ffffff", "#b91c1c", 6.45, 6.49}, |
| 252 | {"dark: on-accent on tone-negative-fg", "#0b1220", "#f79aa0", 8.97, 9.01}, |
| 253 | } { |
| 254 | got, err := ContrastRatio(tt.fg, tt.bg) |
| 255 | if err != nil { |
| 256 | t.Fatalf("%s: %v", tt.name, err) |
| 257 | } |
| 258 | if got < tt.wantLow || got > tt.wantHigh { |
| 259 | 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) |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | // TestThemeTokenContrastMeetsWCAG is the real gate: the full pair table |
| 265 | // each theme's own "WCAG 2.2 AA, measured" header comment documents, |
| 266 | // enforced at each row's documented floor, in every shipped theme and in |
| 267 | // BOTH schemes. See the file doc comment above for exactly what this does |
| 268 | // and does not verify. |
| 269 | // |
| 270 | // --rst-text-faint is held to the same 4.5:1 body-text floor as |
| 271 | // --rst-text/--rst-text-muted, not AA's lower 3:1 large-text/graphic |
| 272 | // floor: this branch puts it on normal-size, normal-weight text |
| 273 | // ([rst-count-line], [rst-field-hint]) and on [rst-lrow~="head"] (11.5px, |
| 274 | // well under WCAG's ~18pt/14pt-bold "large text" threshold even with its |
| 275 | // letter-spacing and uppercase transform), so 3:1 would be the wrong bar |
| 276 | // for what this token is actually used for. |
| 277 | func TestThemeTokenContrastMeetsWCAG(t *testing.T) { |
| 278 | type pair struct { |
| 279 | fg, bg string |
| 280 | min float64 |
| 281 | why string |
| 282 | } |
| 283 | // Transcribed from each theme's own "WCAG 2.2 AA, measured" header |
| 284 | // table plus the [rst-btn~="danger"] comment's documented pair (not in |
| 285 | // the main table, since it reuses --rst-tone-negative-fg as a solid |
| 286 | // fill rather than declaring a dedicated --rst-danger-* token) — every |
| 287 | // row those tables publish, at its documented floor. If a header |
| 288 | // table grows a row, add it here too; the two are meant to stay in |
| 289 | // lockstep. |
| 290 | // |
| 291 | // --rst-header-rule is deliberately absent. The page header's rule |
| 292 | // is decorative and carries no contrast floor (design doc §6-v2.2): |
| 293 | // the heading's size, weight and spacing carry the structure, the |
| 294 | // rule does not, so 1.4.11's 3:1 was never its bar. signal tints it |
| 295 | // with the accent at 45%, which on a light surface is under 3:1 by |
| 296 | // construction; day and plain make it the line, which is a hairline |
| 297 | // divider and carries no floor either. That is not a defect and |
| 298 | // must not be "fixed" by adding a row here — the design doc says so in |
| 299 | // writing precisely so this argument has already been answered. |
| 300 | pairs := []pair{ |
| 301 | {"--rst-text", "--rst-surface", 4.5, "body text on a card"}, |
| 302 | {"--rst-text", "--rst-bg", 4.5, "body text"}, |
| 303 | {"--rst-text", "--rst-surface-2", 4.5, "body text on a card"}, |
| 304 | {"--rst-text", "--rst-accent-soft", 4.5, "body text on an accent-tinted surface"}, |
| 305 | {"--rst-text-muted", "--rst-surface", 4.5, "muted text on a card"}, |
| 306 | {"--rst-text-muted", "--rst-bg", 4.5, "muted text"}, |
| 307 | {"--rst-text-muted", "--rst-surface-2", 4.5, "muted text on a card"}, |
| 308 | {"--rst-text-muted", "--rst-accent-soft", 4.5, "muted text on an accent-tinted surface"}, |
| 309 | {"--rst-text-faint", "--rst-surface", 4.5, "faint text on a card ([rst-field-hint])"}, |
| 310 | {"--rst-text-faint", "--rst-bg", 4.5, "faint text"}, |
| 311 | {"--rst-text-faint", "--rst-surface-2", 4.5, "faint text on a card"}, |
| 312 | {"--rst-text-faint", "--rst-accent-soft", 4.5, "faint text on an accent-tinted surface"}, |
| 313 | {"--rst-accent", "--rst-surface", 4.5, "text + focus ring"}, |
| 314 | {"--rst-accent", "--rst-bg", 4.5, "text + focus ring"}, |
| 315 | {"--rst-accent", "--rst-surface-2", 4.5, "text"}, |
| 316 | {"--rst-accent", "--rst-accent-soft", 4.5, "text"}, |
| 317 | {"--rst-on-accent", "--rst-accent", 4.5, "primary button label"}, |
| 318 | {"--rst-on-accent", "--rst-accent-strong", 4.5, "primary button label, hover"}, |
| 319 | {"--rst-line-strong", "--rst-surface", 3.0, "control border (1.4.11 boundary, not text)"}, |
| 320 | {"--rst-line-strong", "--rst-bg", 3.0, "control border"}, |
| 321 | {"--rst-line-strong", "--rst-surface-2", 3.0, "control border"}, |
| 322 | {"--rst-tone-neutral-fg", "--rst-tone-neutral-bg", 4.5, "status pill text"}, |
| 323 | {"--rst-tone-positive-fg", "--rst-tone-positive-bg", 4.5, "status pill text"}, |
| 324 | {"--rst-tone-warning-fg", "--rst-tone-warning-bg", 4.5, "status pill text"}, |
| 325 | {"--rst-tone-negative-fg", "--rst-tone-negative-bg", 4.5, "status pill text"}, |
| 326 | // The stat band's delta, which is the first place in this |
| 327 | // system where a tone colours TEXT on a plain card rather than |
| 328 | // filling a pill behind it. The pill pairs above say nothing |
| 329 | // about this one: --rst-tone-positive-fg was chosen to clear |
| 330 | // 4.5:1 on --rst-tone-positive-bg, a tinted ground, and |
| 331 | // --rst-surface is not that ground. |
| 332 | // |
| 333 | // It owes the full 4.5:1 even though the delta's sign already |
| 334 | // carries the meaning and the colour is the second signal. |
| 335 | // 1.4.1 is about meaning; 1.4.3 is about text, and this is |
| 336 | // text. A reader who cannot resolve the hue still has to be |
| 337 | // able to READ the number. |
| 338 | {"--rst-tone-positive-fg", "--rst-surface", 4.5, "a stat's rising delta"}, |
| 339 | {"--rst-tone-negative-fg", "--rst-surface", 4.5, "a stat's falling delta"}, |
| 340 | // Not in the main header tables — documented separately in |
| 341 | // tokens.css's [rst-btn~="danger"] comment, which reuses |
| 342 | // --rst-tone-negative-fg as a solid fill rather than declaring a |
| 343 | // dedicated --rst-danger-* pair. This is the real pair the danger |
| 344 | // button's label renders against, so it belongs in the gate anyway. |
| 345 | {"--rst-on-accent", "--rst-tone-negative-fg", 4.5, "danger button label"}, |
| 346 | // The depth axis's pairs. Filled controls read |
| 347 | // their own fill now, so the label pairs are these; the old |
| 348 | // --rst-on-accent on --rst-accent rows stay for the places still |
| 349 | // painted with the accent itself. |
| 350 | {"--rst-on-accent-fill", "--rst-accent-fill", 4.5, "primary button label"}, |
| 351 | {"--rst-on-accent-fill", "--rst-accent-fill-hover", 4.5, "primary button label, hover"}, |
| 352 | {"--rst-on-danger-fill", "--rst-danger-fill", 4.5, "danger button label"}, |
| 353 | {"--rst-on-danger-fill-hover", "--rst-danger-fill-hover", 4.5, "danger button label, hover"}, |
| 354 | {"--rst-text", "--rst-row-tint-hover", 4.5, "a list-row-action's text, hovered"}, |
| 355 | {"--rst-text", "--rst-row-tint-press", 4.5, "a list-row-action's text, pressed"}, |
| 356 | {"--rst-control-text-hover", "--rst-control-face-hover", 4.5, "plain button label, hover"}, |
| 357 | {"--rst-text-muted", "--rst-control-face-hover", 4.5, "a muted label on a hovered plain face"}, |
| 358 | {"--rst-text", "--rst-tint-hover", 4.5, "row text on the hover tint"}, |
| 359 | {"--rst-text", "--rst-tint-press", 4.5, "row text on the press tint"}, |
| 360 | {"--rst-text-muted", "--rst-tint-hover", 4.5, "a row's muted cell on the hover tint"}, |
| 361 | {"--rst-text-muted", "--rst-tint-press", 4.5, "a row's muted cell on the press tint"}, |
| 362 | {"--rst-text-faint", "--rst-tint-hover", 4.5, "a row's faint cell on the hover tint"}, |
| 363 | {"--rst-text-faint", "--rst-tint-press", 4.5, "a row's faint cell on the press tint"}, |
| 364 | {"--rst-accent", "--rst-tint-hover", 4.5, "a link on a hovered row"}, |
| 365 | {"--rst-nav-current-fg", "--rst-nav-current-bg", 4.5, "the current nav item"}, |
| 366 | {"--rst-track-off", "--rst-surface", 3.0, "switch off track (1.4.11 boundary)"}, |
| 367 | {"--rst-track-off", "--rst-bg", 3.0, "switch off track on the page"}, |
| 368 | {"--rst-knob-shade", "--rst-track-off", 3.0, "switch knob on the off track"}, |
| 369 | {"--rst-knob-shade", "--rst-accent-fill", 3.0, "switch knob on the on track"}, |
| 370 | } |
| 371 | |
| 372 | for _, theme := range fiesta.ThemeNames() { |
| 373 | for _, scheme := range []string{"light", "dark"} { |
| 374 | // Resolved, not declared: day derives its fills, tints and |
| 375 | // soft ground from the accent (derived_test.go), and a pair |
| 376 | // that names one of those is gated on what the derivation |
| 377 | // lands on, held to Chromium's pixels by the browser suite. |
| 378 | tokens := resolvedTokens(t, theme)[scheme] |
| 379 | t.Run(theme+"/"+scheme, func(t *testing.T) { |
| 380 | for _, p := range pairs { |
| 381 | if colorMixSkip[p.fg] || colorMixSkip[p.bg] { |
| 382 | t.Logf("skipping %s on %s: listed in colorMixSkip", p.fg, p.bg) |
| 383 | continue |
| 384 | } |
| 385 | fgVal, ok := tokens[p.fg] |
| 386 | if !ok { |
| 387 | t.Errorf("token %s is not declared in this theme", p.fg) |
| 388 | continue |
| 389 | } |
| 390 | bgVal, ok := tokens[p.bg] |
| 391 | if !ok { |
| 392 | t.Errorf("token %s is not declared in this theme", p.bg) |
| 393 | continue |
| 394 | } |
| 395 | ratio, err := ContrastRatio(fgVal, bgVal) |
| 396 | if err != nil { |
| 397 | 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) |
| 398 | continue |
| 399 | } |
| 400 | // Logged in every run, so `go test -v -run |
| 401 | // TestThemeTokenContrastMeetsWCAG` prints the whole |
| 402 | // table a theme's header comment copies: the rows |
| 403 | // there are transcribed from here, never computed |
| 404 | // by hand. |
| 405 | t.Logf("%s on %s = %.2f", p.fg, p.bg, ratio) |
| 406 | if ratio < p.min { |
| 407 | t.Errorf("%s (%s) on %s (%s) = %.2f:1, want >= %.1f:1 (%s)", p.fg, fgVal, p.bg, bgVal, ratio, p.min, p.why) |
| 408 | } |
| 409 | } |
| 410 | }) |
| 411 | } |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | // Every highlight colour of the gallery's code view reaches 4.5:1 |
| 416 | // against the code block's --rst-surface, in every theme and both |
| 417 | // schemes, resolved from the themes' own light-dark() pairs. The axe |
| 418 | // scan checks the rendered cascade; this checks every pair, including |
| 419 | // the ones a page with no such token on it would never show axe. The |
| 420 | // five rules are read from the gallery's view.css, the widget's half |
| 421 | // of its stylesheet, where the code-block rules live. |
| 422 | // |
| 423 | // It lived beside the gallery and read each theme's :root with a |
| 424 | // pattern, so a declaration in a comment there was measured in place |
| 425 | // of the one a browser applies. Here it reads both stylesheets with |
| 426 | // the declaration reader: view.css with leafRules, the themes with |
| 427 | // themeTokens. |
| 428 | func TestHighlightColoursPassAgainstTheCodeBackground(t *testing.T) { |
| 429 | view, err := os.ReadFile(filepath.Join("..", "internal", "gallery", "view.css")) |
| 430 | if err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | element := regexp.MustCompile(`^ds-[tavxs]$`) |
| 434 | token := regexp.MustCompile(`^var\((--rst-[a-z0-9-]+)\)$`) |
| 435 | pairs := map[string]string{} |
| 436 | for _, r := range leafRules(t, string(view)) { |
| 437 | if r.context != "" || !element.MatchString(r.selector) || len(r.decls) != 1 { |
| 438 | continue |
| 439 | } |
| 440 | if m := token.FindStringSubmatch(r.decls[0][1]); r.decls[0][0] == "color" && r.decls[0][2] == "" && m != nil { |
| 441 | pairs[r.selector] = m[1] |
| 442 | } |
| 443 | } |
| 444 | if len(pairs) != 5 { |
| 445 | t.Fatalf("view.css colours %d highlight elements, want 5: %v", len(pairs), pairs) |
| 446 | } |
| 447 | for _, theme := range fiesta.ThemeNames() { |
| 448 | for scheme, tok := range themeTokens(t, theme) { |
| 449 | bg := tok["--rst-surface"] |
| 450 | for el, name := range pairs { |
| 451 | fg := tok[name] |
| 452 | r, err := ContrastRatio(fg, bg) |
| 453 | if err != nil { |
| 454 | t.Errorf("%s %s %s (%s on %s): %v", theme, scheme, el, fg, bg, err) |
| 455 | continue |
| 456 | } |
| 457 | if r < 4.5 { |
| 458 | t.Errorf("%s %s: %s is %s (%s) on %s, %.2f:1, under 4.5:1", theme, scheme, el, name, fg, bg, r) |
| 459 | } |
| 460 | } |
| 461 | } |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | // stageArtMix reads the colour tokens.css gives one part of the stage |
| 466 | // art — color-mix(in srgb, var(A) N%, var(B)) — so the check below |
| 467 | // measures what the stylesheet actually says rather than a copy of it. |
| 468 | func stageArtMix(t *testing.T, part string) (a string, p float64, b string) { |
| 469 | t.Helper() |
| 470 | // Read off the rule's declarations, so a mix in a comment or a |
| 471 | // string is not the one measured. |
| 472 | mix := regexp.MustCompile(`^color-mix\(in srgb, var\((--rst-[a-z-]+)\) (\d+)%, var\((--rst-[a-z-]+)\)\)$`) |
| 473 | var m []string |
| 474 | for _, r := range leafRules(t, string(fiesta.TokensCSS())) { |
| 475 | if m != nil || !slices.Contains(splitSelectorList(r.selector), "[rst-stage-art-"+part+"]") { |
| 476 | continue |
| 477 | } |
| 478 | for _, prop := range []string{"fill", "stroke"} { |
| 479 | if v, ok := r.value(prop); ok && m == nil { |
| 480 | m = mix.FindStringSubmatch(v) |
| 481 | } |
| 482 | } |
| 483 | } |
| 484 | if m == nil { |
| 485 | t.Fatalf("tokens.css paints [rst-stage-art-%s] with no color-mix(in srgb, var(--rst-…) N%%, var(--rst-…))", part) |
| 486 | } |
| 487 | n, _ := strconv.Atoi(m[2]) |
| 488 | return m[1], float64(n) / 100, m[3] |
| 489 | } |
| 490 | |
| 491 | // mixSRGB is CSS color-mix(in srgb, a p, b): a per-channel linear blend |
| 492 | // of the two sRGB values. |
| 493 | func mixSRGB(t *testing.T, a, b string, p float64) string { |
| 494 | t.Helper() |
| 495 | ar, ag, ab, err := parseHex(a) |
| 496 | if err != nil { |
| 497 | t.Fatal(err) |
| 498 | } |
| 499 | br, bg, bb, err := parseHex(b) |
| 500 | if err != nil { |
| 501 | t.Fatal(err) |
| 502 | } |
| 503 | mix := func(x, y uint8) uint8 { return uint8(math.Round(float64(x)*p + float64(y)*(1-p))) } |
| 504 | return hexOf([3]uint8{mix(ar, br), mix(ag, bg), mix(ab, bb)}) |
| 505 | } |
| 506 | |
| 507 | // The sign-in card's border is its boundary against the stage art (WCAG |
| 508 | // 1.4.11). The art is drawn by mixing accent and line into the page |
| 509 | // background, so the border must clear 3:1 against each colour the art |
| 510 | // paints, in every theme and scheme — not only against --rst-bg, which |
| 511 | // is all the token-pair gate above can see. Each colour is measured at |
| 512 | // full strength, which the glow only reaches where its translucent |
| 513 | // circles pile up: the worst case, never better than it. If this fails, |
| 514 | // lower the art's percentage in tokens.css; never the floor. |
| 515 | func TestTheSigninCardStandsOutFromTheStageArt(t *testing.T) { |
| 516 | // What follows measures --rst-line-strong. Hold the card to it, or a |
| 517 | // later edit to the card's border leaves this passing about a colour |
| 518 | // nothing paints. |
| 519 | if !slices.ContainsFunc(leafRules(t, string(fiesta.TokensCSS())), func(r leafRule) bool { |
| 520 | return slices.Contains(splitSelectorList(r.selector), "[rst-signin]") && r.sets("border", "1px solid var(--rst-line-strong)") |
| 521 | }) { |
| 522 | t.Fatal("tokens.css no longer borders [rst-signin] with 1px solid var(--rst-line-strong), the colour this test measures") |
| 523 | } |
| 524 | for _, theme := range fiesta.ThemeNames() { |
| 525 | for _, scheme := range []string{"light", "dark"} { |
| 526 | tok := themeTokens(t, theme)[scheme] |
| 527 | for _, part := range []string{"lines", "glow"} { |
| 528 | a, p, b := stageArtMix(t, part) |
| 529 | behind := mixSRGB(t, tok[a], tok[b], p) |
| 530 | ratio, err := ContrastRatio(tok["--rst-line-strong"], behind) |
| 531 | if err != nil { |
| 532 | t.Fatal(err) |
| 533 | } |
| 534 | if ratio < 3.0 { |
| 535 | t.Errorf("%s/%s: the card border on the art's %s is %.2f:1, want >= 3:1", theme, scheme, part, ratio) |
| 536 | } |
| 537 | } |
| 538 | } |
| 539 | } |
| 540 | } |
| 541 | |