| 1 | package check |
| 2 | |
| 3 | import ( |
| 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. |
| 40 | type 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. |
| 47 | func (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. |
| 61 | const 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. |
| 67 | func 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. |
| 84 | func 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. |
| 104 | func 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 | |
| 115 | var 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. |
| 118 | func 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. |
| 220 | func 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. |
| 249 | type quantity struct { |
| 250 | v float64 |
| 251 | deg bool |
| 252 | } |
| 253 | |
| 254 | func 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 | |
| 267 | type channelParser struct { |
| 268 | src string |
| 269 | pos int |
| 270 | vars map[string]float64 |
| 271 | } |
| 272 | |
| 273 | func (p *channelParser) skip() { |
| 274 | for p.pos < len(p.src) && p.src[p.pos] == ' ' { |
| 275 | p.pos++ |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | func (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 | |
| 287 | func (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 | |
| 314 | func (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 | |
| 344 | func (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. |
| 442 | func 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. |
| 459 | func 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. |
| 480 | func 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. |
| 498 | func 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). |
| 525 | func 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 | |