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 "fmt"
5 "math"
6 "regexp"
7 "strconv"
8 "strings"
9 "testing"
10 "unicode"
11)
12
13// day derives its accent-dependent tokens from --rst-accent:
14// the four fills and the soft ground take the accent's hue at day's own
15// lightness, with the accent's chroma scaled and capped, as
16// oklch(from var(--rst-accent) L C h) inside an @supports block for
17// relative colour; the tints, the halo, the plain sheen and the rim
18// fold the accent into a surface or into transparent with
19// color-mix(in srgb, …); and the tokens that repeat another are a bare
20// var(). The contrast gate and the composed-face test read declared
21// values, so without an evaluator every pair that names a derived token
22// would report "cannot parse" and the gate would be reduced to the
23// neutrals. This file is that evaluator: a second implementation of the
24// three shapes, in Go, held to Chromium's own pixels by
25// TestDaysDerivedTokensReproduceTheirDocumentedColours.
26//
27// Only the shapes day writes are read. A new shape in a theme fails
28// loudly here ("cannot evaluate"), which is the point: a value this
29// file cannot evaluate is a value the gate is not checking.
30//
31// An out-of-gamut relative colour is clipped per channel, as Chromium
32// paints it, not chroma-reduced as colour.go's oklchRGB does for the
33// colour engine: the drives run in Chromium, and the sweep in
34// accent_browser_test.go reads what it paints. The clip itself
35// (oklchClip) and canonColours, which the browser drives read computed
36// styles through, are in colourhelp_test.go.
37
38// rgba is a resolved colour: eight-bit channels as floats (so a mix is
39// not rounded until it is printed) and an alpha in 0..1.
40type rgba struct {
41 r, g, b, a float64
42}
43
44// String spells the colour the way the rest of the tests read one: a
45// hex when opaque, rgba() otherwise, so ContrastRatio, channelsOf and
46// alphaOf all accept it.
47func (c rgba) String() string {
48 r, g, b := uint8(math.Round(c.r)), uint8(math.Round(c.g)), uint8(math.Round(c.b))
49 if c.a >= 1 {
50 return hexOf([3]uint8{r, g, b})
51 }
52 return fmt.Sprintf("rgba(%d, %d, %d, %s)", r, g, b, strconv.FormatFloat(c.a, 'f', -1, 64))
53}
54
55// accentSupports is the condition of the one block a theme may add
56// beyond its :root, the toggles and the dark-number blocks: day's
57// redeclaration of its hue-derived tokens where the engine has relative
58// colour syntax. It is the exact condition day.css writes, as a rule's
59// context names it; a block with another condition is not this block
60// and the structure gates refuse it.
61const accentSupports = "@supports (color: oklch(from red l c h))"
62
63// resolvedTokens is themeTokens with every derived colour evaluated for
64// its scheme. A value that is not a colour expression (a font stack, a
65// radius, a number, a shadow) is carried through unchanged, so a caller
66// that reads numbers from it sees what themeTokens shows.
67func resolvedTokens(t *testing.T, theme string) map[string]map[string]string {
68 t.Helper()
69 out := map[string]map[string]string{}
70 for scheme, table := range themeTokens(t, theme) {
71 res := make(map[string]string, len(table))
72 for name, v := range table {
73 res[name] = resolveValue(v, table)
74 }
75 out[scheme] = res
76 }
77 return out
78}
79
80// resolveWith evaluates one scheme's table with some tokens overridden,
81// which is what an app stylesheet does to :root. The override test
82// computes its expectation this way rather than from a second copy of
83// the formulas.
84func resolveWith(t *testing.T, theme, scheme string, override map[string]string) map[string]string {
85 t.Helper()
86 table := map[string]string{}
87 for k, v := range themeTokens(t, theme)[scheme] {
88 table[k] = v
89 }
90 for k, v := range override {
91 table[k] = v
92 }
93 res := make(map[string]string, len(table))
94 for name, v := range table {
95 res[name] = resolveValue(v, table)
96 }
97 return res
98}
99
100// resolveValue evaluates a value only when it is a derivation; a
101// declared literal (a hex, "transparent", a number, a shadow) is handed
102// back as written, so the tests that compare one by string still see
103// what the theme says rather than this file's spelling of it.
104func resolveValue(v string, table map[string]string) string {
105 if !strings.HasPrefix(v, "var(") && !strings.HasPrefix(v, "color-mix(") && !strings.HasPrefix(v, "oklch(from ") {
106 return v
107 }
108 c, err := evalColour(v, table, 0)
109 if err != nil {
110 return v
111 }
112 return c.String()
113}
114
115var rgbFn = regexp.MustCompile(`^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*(?:[,/]\s*([0-9.]+%?)\s*)?\)$`)
116
117// evalColour evaluates one declared value against its scheme's table.
118func evalColour(v string, table map[string]string, depth int) (rgba, error) {
119 v = strings.TrimSpace(v)
120 if depth > 16 {
121 return rgba{}, fmt.Errorf("%q: var() chain deeper than 16; a cycle", v)
122 }
123 switch {
124 case v == "transparent":
125 return rgba{}, nil
126 case v == "white" || v == "#fff" || v == "#ffffff":
127 return rgba{255, 255, 255, 1}, nil
128 case v == "black" || v == "#000" || v == "#000000":
129 return rgba{0, 0, 0, 1}, nil
130 case strings.HasPrefix(v, "#"):
131 r, g, b, err := parseHex(v)
132 if err != nil {
133 return rgba{}, err
134 }
135 return rgba{float64(r), float64(g), float64(b), 1}, nil
136 case strings.HasPrefix(v, "rgb"):
137 m := rgbFn.FindStringSubmatch(v)
138 if m == nil {
139 return rgba{}, fmt.Errorf("cannot evaluate %q", v)
140 }
141 var n [4]float64
142 n[3] = 1
143 for i := 0; i < 4 && i < len(m)-1; i++ {
144 if m[i+1] == "" {
145 continue
146 }
147 f, err := strconv.ParseFloat(strings.TrimSuffix(m[i+1], "%"), 64)
148 if err != nil {
149 return rgba{}, err
150 }
151 if i == 3 && strings.HasSuffix(m[i+1], "%") {
152 f /= 100
153 }
154 n[i] = f
155 }
156 return rgba{n[0], n[1], n[2], n[3]}, nil
157 case strings.HasPrefix(v, "var("):
158 name, fallback, ok := splitVar(v)
159 if !ok {
160 return rgba{}, fmt.Errorf("cannot evaluate %q", v)
161 }
162 if ref, declared := table[name]; declared {
163 return evalColour(ref, table, depth+1)
164 }
165 if fallback == "" {
166 return rgba{}, fmt.Errorf("%s is not declared and %q has no fallback", name, v)
167 }
168 return evalColour(fallback, table, depth+1)
169 case strings.HasPrefix(v, "light-dark("):
170 // Only reachable inside a fallback the scheme split never saw;
171 // the table is already one scheme's, so there is no right half
172 // to pick and the value is refused rather than guessed.
173 return rgba{}, fmt.Errorf("%q: light-dark() inside a derived value is not evaluated per scheme", v)
174 case strings.HasPrefix(v, "color-mix(in srgb, "):
175 args := splitArgs(strings.TrimSuffix(strings.TrimPrefix(v, "color-mix(in srgb, "), ")"))
176 if len(args) != 2 {
177 return rgba{}, fmt.Errorf("cannot evaluate %q: want two colours", v)
178 }
179 a, pa, err := colourAndShare(args[0], table, depth)
180 if err != nil {
181 return rgba{}, err
182 }
183 b, pb, err := colourAndShare(args[1], table, depth)
184 if err != nil {
185 return rgba{}, err
186 }
187 return mixSRGBPremultiplied(a, pa, b, pb), nil
188 case strings.HasPrefix(v, "oklch(from "):
189 parts := splitSpaces(strings.TrimSuffix(strings.TrimPrefix(v, "oklch("), ")"))
190 if len(parts) != 5 || parts[0] != "from" {
191 return rgba{}, fmt.Errorf("cannot evaluate %q: want oklch(from <colour> <L> <C> <H>)", v)
192 }
193 from, err := evalColour(parts[1], table, depth+1)
194 if err != nil {
195 return rgba{}, err
196 }
197 lab := oklabOf(uint8(math.Round(from.r)), uint8(math.Round(from.g)), uint8(math.Round(from.b)))
198 l0, c0 := lab[0], math.Hypot(lab[1], lab[2])
199 h0 := math.Atan2(lab[2], lab[1]) * 180 / math.Pi
200 if h0 < 0 {
201 h0 += 360
202 }
203 vars := map[string]float64{"l": l0, "c": c0, "h": h0}
204 var ch [3]float64
205 for i, expr := range parts[2:5] {
206 f, err := evalChannel(expr, vars)
207 if err != nil {
208 return rgba{}, fmt.Errorf("%q: %v", v, err)
209 }
210 ch[i] = f
211 }
212 rgb := oklchClip(ch[0], ch[1], ch[2])
213 return rgba{float64(rgb[0]), float64(rgb[1]), float64(rgb[2]), from.a}, nil
214 }
215 return rgba{}, fmt.Errorf("cannot evaluate %q", v)
216}
217
218// splitSpaces splits on top-level spaces, so a channel written as a
219// function full of spaces stays one part.
220func splitSpaces(s string) []string {
221 var out []string
222 depth, start := 0, 0
223 for i := 0; i < len(s); i++ {
224 switch s[i] {
225 case '(':
226 depth++
227 case ')':
228 depth--
229 case ' ':
230 if depth == 0 {
231 if p := strings.TrimSpace(s[start:i]); p != "" {
232 out = append(out, p)
233 }
234 start = i + 1
235 }
236 }
237 }
238 if p := strings.TrimSpace(s[start:]); p != "" {
239 out = append(out, p)
240 }
241 return out
242}
243
244// A channel of a relative colour: a number, the origin's l, c or h, or
245// CSS math over those, which day writes as calc(), min(), max() and
246// cos() with a deg unit. A quantity carries whether it is an angle, so
247// cos() reads degrees as degrees and a bare number as radians, as CSS
248// does.
249type quantity struct {
250 v float64
251 deg bool
252}
253
254func evalChannel(expr string, vars map[string]float64) (float64, error) {
255 p := &channelParser{src: expr, vars: vars}
256 q, err := p.sum()
257 if err != nil {
258 return 0, err
259 }
260 p.skip()
261 if p.pos != len(p.src) {
262 return 0, fmt.Errorf("unexpected %q at %d in %q", p.src[p.pos:], p.pos, expr)
263 }
264 return q.v, nil
265}
266
267type channelParser struct {
268 src string
269 pos int
270 vars map[string]float64
271}
272
273func (p *channelParser) skip() {
274 for p.pos < len(p.src) && p.src[p.pos] == ' ' {
275 p.pos++
276 }
277}
278
279func (p *channelParser) peek() byte {
280 p.skip()
281 if p.pos >= len(p.src) {
282 return 0
283 }
284 return p.src[p.pos]
285}
286
287func (p *channelParser) sum() (quantity, error) {
288 left, err := p.product()
289 if err != nil {
290 return left, err
291 }
292 for {
293 switch p.peek() {
294 case '+':
295 p.pos++
296 right, err := p.product()
297 if err != nil {
298 return left, err
299 }
300 left = quantity{left.v + right.v, left.deg || right.deg}
301 case '-':
302 p.pos++
303 right, err := p.product()
304 if err != nil {
305 return left, err
306 }
307 left = quantity{left.v - right.v, left.deg || right.deg}
308 default:
309 return left, nil
310 }
311 }
312}
313
314func (p *channelParser) product() (quantity, error) {
315 left, err := p.factor()
316 if err != nil {
317 return left, err
318 }
319 for {
320 switch p.peek() {
321 case '*':
322 p.pos++
323 right, err := p.factor()
324 if err != nil {
325 return left, err
326 }
327 left = quantity{left.v * right.v, left.deg || right.deg}
328 case '/':
329 p.pos++
330 right, err := p.factor()
331 if err != nil {
332 return left, err
333 }
334 if right.v == 0 {
335 return left, fmt.Errorf("division by zero in %q", p.src)
336 }
337 left = quantity{left.v / right.v, left.deg}
338 default:
339 return left, nil
340 }
341 }
342}
343
344func (p *channelParser) factor() (quantity, error) {
345 switch c := p.peek(); {
346 case c == '(':
347 p.pos++
348 q, err := p.sum()
349 if err != nil {
350 return q, err
351 }
352 if p.peek() != ')' {
353 return q, fmt.Errorf("missing ) in %q", p.src)
354 }
355 p.pos++
356 return q, nil
357 case c == '-':
358 p.pos++
359 q, err := p.factor()
360 return quantity{-q.v, q.deg}, err
361 case c == '.' || unicode.IsDigit(rune(c)):
362 start := p.pos
363 for p.pos < len(p.src) && (p.src[p.pos] == '.' || unicode.IsDigit(rune(p.src[p.pos]))) {
364 p.pos++
365 }
366 f, err := strconv.ParseFloat(p.src[start:p.pos], 64)
367 if err != nil {
368 return quantity{}, err
369 }
370 if strings.HasPrefix(p.src[p.pos:], "deg") {
371 p.pos += 3
372 return quantity{f, true}, nil
373 }
374 return quantity{f, false}, nil
375 case unicode.IsLetter(rune(c)):
376 start := p.pos
377 for p.pos < len(p.src) && unicode.IsLetter(rune(p.src[p.pos])) {
378 p.pos++
379 }
380 name := p.src[start:p.pos]
381 if p.peek() != '(' {
382 v, ok := p.vars[name]
383 if !ok {
384 return quantity{}, fmt.Errorf("unknown channel %q in %q", name, p.src)
385 }
386 return quantity{v, false}, nil
387 }
388 p.pos++
389 var args []quantity
390 for {
391 q, err := p.sum()
392 if err != nil {
393 return q, err
394 }
395 args = append(args, q)
396 if p.peek() == ',' {
397 p.pos++
398 continue
399 }
400 break
401 }
402 if p.peek() != ')' {
403 return quantity{}, fmt.Errorf("missing ) after %s( in %q", name, p.src)
404 }
405 p.pos++
406 switch name {
407 case "calc":
408 if len(args) == 1 {
409 return args[0], nil
410 }
411 case "min", "max":
412 out := args[0]
413 for _, a := range args[1:] {
414 if (name == "min") == (a.v < out.v) {
415 out = a
416 }
417 }
418 return out, nil
419 case "cos", "sin":
420 if len(args) == 1 {
421 x := args[0].v
422 if args[0].deg {
423 x = x * math.Pi / 180
424 }
425 if name == "cos" {
426 return quantity{math.Cos(x), false}, nil
427 }
428 return quantity{math.Sin(x), false}, nil
429 }
430 case "abs":
431 if len(args) == 1 {
432 return quantity{math.Abs(args[0].v), args[0].deg}, nil
433 }
434 }
435 return quantity{}, fmt.Errorf("cannot evaluate %s() with %d arguments in %q", name, len(args), p.src)
436 }
437 return quantity{}, fmt.Errorf("unexpected %q at %d in %q", p.src[p.pos:], p.pos, p.src)
438}
439
440// splitVar reads var(--name) or var(--name, fallback); the fallback is
441// everything after the first top-level comma, commas included.
442func splitVar(v string) (name, fallback string, ok bool) {
443 if !strings.HasPrefix(v, "var(") || !strings.HasSuffix(v, ")") {
444 return "", "", false
445 }
446 inner := v[len("var(") : len(v)-1]
447 parts := splitArgs(inner)
448 if len(parts) == 0 || !strings.HasPrefix(parts[0], "--") {
449 return "", "", false
450 }
451 if len(parts) == 1 {
452 return parts[0], "", true
453 }
454 return parts[0], strings.TrimSpace(inner[len(parts[0])+1:]), true
455}
456
457// splitArgs splits on top-level commas, so an argument that is itself a
458// call full of commas stays whole.
459func splitArgs(s string) []string {
460 var out []string
461 depth, start := 0, 0
462 for i := 0; i < len(s); i++ {
463 switch s[i] {
464 case '(':
465 depth++
466 case ')':
467 depth--
468 case ',':
469 if depth == 0 {
470 out = append(out, strings.TrimSpace(s[start:i]))
471 start = i + 1
472 }
473 }
474 }
475 return append(out, strings.TrimSpace(s[start:]))
476}
477
478// colourAndShare reads one color-mix() argument, "<colour> [<p>%]"; a
479// share of -1 means "whatever is left", which CSS fills in.
480func colourAndShare(arg string, table map[string]string, depth int) (rgba, float64, error) {
481 share := -1.0
482 if i := strings.LastIndex(arg, " "); i > 0 && strings.HasSuffix(arg, "%") {
483 p, err := strconv.ParseFloat(strings.TrimSuffix(arg[i+1:], "%"), 64)
484 if err != nil {
485 return rgba{}, 0, err
486 }
487 share, arg = p/100, arg[:i]
488 }
489 c, err := evalColour(arg, table, depth+1)
490 return c, share, err
491}
492
493// mixSRGBPremultiplied is color-mix(in srgb, a pa, b pb) as CSS Color 5
494// defines it: the shares normalised, the colours premultiplied by their
495// alpha, interpolated, and divided back out, so a mix with transparent
496// keeps the opaque colour's channels at the mixed alpha rather than
497// sliding them toward black.
498func mixSRGBPremultiplied(a rgba, pa float64, b rgba, pb float64) rgba {
499 switch {
500 case pa < 0 && pb < 0:
501 pa, pb = 0.5, 0.5
502 case pa < 0:
503 pa = 1 - pb
504 case pb < 0:
505 pb = 1 - pa
506 }
507 if sum := pa + pb; sum != 1 && sum > 0 {
508 pa, pb = pa/sum, pb/sum
509 }
510 alpha := a.a*pa + b.a*pb
511 if alpha == 0 {
512 return rgba{}
513 }
514 mix := func(x, y float64) float64 { return (x*a.a*pa + y*b.a*pb) / alpha }
515 return rgba{mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b), alpha}
516}
517
518// TestDerivedColourEvaluatorReadsDaysShapes pins the evaluator at the
519// level a later tidy-up would touch it: each of the three shapes, the
520// channel math day writes, the fallback, the premultiplied mix with
521// transparent, and the refusal of a shape it does not read. The numbers
522// are the ones Chromium painted for the same expressions
523// (TestDaysDerivedTokensReproduceTheirDocumentedColours checks the live
524// theme against the same pixels).
525func TestDerivedColourEvaluatorReadsDaysShapes(t *testing.T) {
526 table := map[string]string{
527 "--rst-accent": "#1f56d6",
528 "--rst-surface": "#ffffff",
529 "--rst-mixed": "color-mix(in srgb, var(--rst-accent, #ff0000) 4.5%, var(--rst-surface))",
530 "--rst-alias": "var(--rst-mixed)",
531 }
532 const darkFill = "calc(0.543 - 0.06 * max(0, cos((h - 185) * 1deg) - 0.3)) min(calc(c * 1.5045), 0.215) h"
533 for _, c := range []struct{ in, want string }{
534 {"color-mix(in srgb, var(--rst-accent) 80%, #000000)", "#1945ab"},
535 {"color-mix(in srgb, var(--rst-accent) 4.5%, var(--rst-surface))", "#f5f7fd"},
536 {"color-mix(in srgb, var(--rst-accent) 22%, transparent)", "rgba(31, 86, 214, 0.22)"},
537 {"oklch(from var(--rst-accent) 0.513 0.21 h)", "#2059dd"},
538 {"oklch(from var(--rst-accent) 0.513 min(calc(c * 1.0244), 0.21) h)", "#2059dd"},
539 {"oklch(from #7aa2ff " + darkFill + ")", "#3160ea"},
540 {"oklch(from #14b8a6 " + darkFill + ")", "#00816d"},
541 {"oklch(from #14b8a6 0.513 min(calc(c * 1.0244), 0.21) h)", "#007d6d"},
542 {"oklch(from #808080 0.513 min(calc(c * 1.0244), 0.21) h)", "#676767"},
543 {"var(--rst-alias)", "#f5f7fd"},
544 {"var(--rst-missing, #123456)", "#123456"},
545 {"var(--rst-missing, color-mix(in srgb, #000000 50%, #ffffff))", "#808080"},
546 } {
547 got, err := evalColour(c.in, table, 0)
548 if err != nil {
549 t.Errorf("%s: %v", c.in, err)
550 continue
551 }
552 if got.String() != c.want {
553 t.Errorf("%s = %s, want %s", c.in, got, c.want)
554 }
555 }
556 for _, bad := range []string{"color-mix(in oklab, var(--rst-accent) 50%, #000)", "oklch(0.5 0.2 260)", "var(--rst-missing)", "hsl(0 0% 0%)", "oklch(from #000 0.5 pow(c, 2) h)", "oklch(from #000 0.5 c)"} {
557 if _, err := evalColour(bad, table, 0); err == nil {
558 t.Errorf("%s evaluated; a shape the gate cannot read must be refused, not guessed", bad)
559 }
560 }
561 // A theme's tokens are what a browser applies: a declaration in a
562 // comment is prose, and day's relative-colour block wins where the
563 // engine reads it.
564 tok := themeTokensOf(t, "t.css", ":root { --rst-x: 1; /* --rst-x: 2; */ --rst-y: light-dark(#fff, #000); --rst-z: light-dark(\"a, b\"); }\n"+accentSupports+" {\n :root {\n --rst-y: light-dark(#eee, #111);\n }\n}\n")
565 for scheme, want := range map[string]map[string]string{
566 "light": {"--rst-x": "1", "--rst-y": "#eee", "--rst-z": `light-dark("a, b")`},
567 "dark": {"--rst-x": "1", "--rst-y": "#111", "--rst-z": `light-dark("a, b")`},
568 } {
569 for name, v := range want {
570 if got := tok[scheme][name]; got != v {
571 t.Errorf("themeTokensOf %s %s = %q, want %q", scheme, name, got, v)
572 }
573 }
574 }
575 if got := canonColours("radial-gradient(90px, color(srgb 0.121569 0.337255 0.839216 / 0.09), rgba(0, 0, 0, 0) 70%) oklch(0.513 0.21 263.298)"); got != "radial-gradient(90px, rgba(31, 86, 214, 0.09), rgba(0, 0, 0, 0) 70%) rgb(32, 89, 221)" {
576 t.Errorf("canonColours = %s", got)
577 }
578}
579