rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1package check
2
3import (
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.
59var 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.
74func 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.
87func 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.
98func 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.
117func 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.
157func 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.
169func 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.
210func 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.
244func 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.
277func 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.
428func 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.
468func 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.
493func 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.
515func 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