| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "encoding/json" |
| 9 | "fmt" |
| 10 | "math" |
| 11 | "net/http" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/chromedp/chromedp" |
| 17 | |
| 18 | "amadan.net/rastrillo/fiesta" |
| 19 | "amadan.net/rastrillo/fiesta/internal/harness" |
| 20 | ) |
| 21 | |
| 22 | // day derives its accent-dependent tokens from --rst-accent. |
| 23 | // Two drives hold the two halves of that promise. The first reads every |
| 24 | // derived token as Chromium paints it, in both schemes, and holds it to |
| 25 | // the hex the design was measured with (the values day.css declared |
| 26 | // before the derivation) within two units per channel, and to the Go |
| 27 | // evaluator the contrast gate relies on within one, so the gate's |
| 28 | // numbers are the browser's. The second puts an app stylesheet on the |
| 29 | // page that sets --rst-accent alone, the way the design system's guide |
| 30 | // says to, and reads the primary button, a hovered row, a focused |
| 31 | // field, the current menu item and the current nav item: the first four |
| 32 | // follow the override, in both schemes, and the nav item stays the |
| 33 | // surface thumb day made it (C15), which is not an accent colour and so |
| 34 | // has nothing to follow. |
| 35 | |
| 36 | // documentedDayColours is what day.css declared for each derived token |
| 37 | // before the derivation, the colours the contrast table and the mockups were |
| 38 | // measured with. The derivations have to land on these, not the other |
| 39 | // way round: a formula that drifted would move the measured ratios. |
| 40 | var documentedDayColours = map[string]map[string]string{ |
| 41 | "light": { |
| 42 | "--rst-accent-strong": "#1844ac", "--rst-accent-soft": "#ecf1ff", |
| 43 | "--rst-accent-fill": "#2257df", "--rst-accent-fill-hover": "#1d4dc8", "--rst-accent-fill-edge": "#153ca0", "--rst-accent-fill-edge-hover": "#12358f", |
| 44 | "--rst-tint-hover": "#f3f7ff", "--rst-tint-press": "#eaf1fe", "--rst-tint-tap": "#eaf1fe", |
| 45 | "--rst-tint-card-hover": "#f3f7ff", "--rst-tint-tile-hover": "#f3f7ff", "--rst-tint-tile-press": "#eaf1fe", "--rst-tint-group-hover": "#f3f7ff", |
| 46 | "--rst-row-tint-hover": "#f3f7ff", "--rst-row-tint-press": "#eaf1fe", |
| 47 | "--rst-focus-edge": "#1f56d6", "--rst-focus-halo": "rgba(31, 86, 214, 0.22)", |
| 48 | "--rst-sheen-plain": "rgba(34, 87, 223, 0.09)", "--rst-light-rim": "#b9c6e4", |
| 49 | }, |
| 50 | "dark": { |
| 51 | "--rst-accent-strong": "#a3c0ff", "--rst-accent-soft": "#16234a", |
| 52 | "--rst-accent-fill": "#2f61e8", "--rst-accent-fill-hover": "#2956d0", "--rst-accent-fill-edge": "#1f43a6", "--rst-accent-fill-edge-hover": "#1a3b95", |
| 53 | "--rst-tint-hover": "#171c28", "--rst-tint-press": "#182031", "--rst-tint-tap": "#182031", |
| 54 | "--rst-tint-card-hover": "#171c28", "--rst-tint-tile-hover": "#171c28", "--rst-tint-tile-press": "#182031", "--rst-tint-group-hover": "#171c28", |
| 55 | "--rst-row-tint-hover": "#171c28", "--rst-row-tint-press": "#182031", |
| 56 | "--rst-focus-edge": "#7aa2ff", "--rst-focus-halo": "rgba(122, 162, 255, 0.22)", |
| 57 | "--rst-sheen-plain": "rgba(255, 255, 255, 0.04)", "--rst-light-rim": "#5d6a86", |
| 58 | }, |
| 59 | } |
| 60 | |
| 61 | // paintedToken reads one token as the page resolves it: a probe span |
| 62 | // painted with var(<token>), its computed background colour, and that |
| 63 | // colour painted into a 1x1 canvas and read back as eight-bit sRGB, so |
| 64 | // a relative colour (oklch) is checked as the engine converts it rather |
| 65 | // than as a serialisation this suite parses. For a translucent token |
| 66 | // the canvas read is useless (an un-premultiply at 9% alpha loses the |
| 67 | // channels), so the serialisation is returned too and the caller reads |
| 68 | // that instead. |
| 69 | func paintedToken(t *testing.T, ctx context.Context, token string) (serialised string, pixel [4]int) { |
| 70 | t.Helper() |
| 71 | var raw string |
| 72 | mustRun(t, ctx, chromedp.Evaluate(`(() => { |
| 73 | const e = document.createElement("span"); e.style.background = "var(`+token+`)"; document.body.append(e); |
| 74 | const css = getComputedStyle(e).backgroundColor; e.remove(); |
| 75 | const c = document.createElement("canvas"); c.width = c.height = 1; |
| 76 | const g = c.getContext("2d", { willReadFrequently: true }); |
| 77 | g.clearRect(0, 0, 1, 1); g.fillStyle = "#000000"; g.fillStyle = css; g.fillRect(0, 0, 1, 1); |
| 78 | const d = g.getImageData(0, 0, 1, 1).data; |
| 79 | return JSON.stringify({Css: css, Px: [d[0], d[1], d[2], d[3]]}); |
| 80 | })()`, &raw)) |
| 81 | var out struct { |
| 82 | Css string |
| 83 | Px [4]int |
| 84 | } |
| 85 | if err := json.Unmarshal([]byte(raw), &out); err != nil { |
| 86 | t.Fatalf("%s: %v (%s)", token, err, raw) |
| 87 | } |
| 88 | return canonColours(out.Css), out.Px |
| 89 | } |
| 90 | |
| 91 | // nearColour reports whether two colours (hex or rgba()) agree on every |
| 92 | // channel within tol units and on alpha within tol/255. |
| 93 | func nearColour(t *testing.T, got, want string, tol float64) bool { |
| 94 | t.Helper() |
| 95 | g, w := channelsOf(t, got), channelsOf(t, want) |
| 96 | for i := range g { |
| 97 | if math.Abs(g[i]-w[i]) > tol { |
| 98 | return false |
| 99 | } |
| 100 | } |
| 101 | // alphaOf reads the last number of any rgb() as the alpha, which |
| 102 | // is right for rgba() and wrong for Chromium's opaque rgb(r, g, b). |
| 103 | alpha := func(v string) float64 { |
| 104 | if strings.HasPrefix(v, "rgb(") { |
| 105 | return 1 |
| 106 | } |
| 107 | return alphaOf(t, v) |
| 108 | } |
| 109 | return math.Abs(alpha(got)-alpha(want))*255 <= tol |
| 110 | } |
| 111 | |
| 112 | func TestDaysDerivedTokensReproduceTheirDocumentedColours(t *testing.T) { |
| 113 | for _, scheme := range []string{"light", "dark"} { |
| 114 | t.Run(scheme, func(t *testing.T) { |
| 115 | rig := sizingRig(t, false, depthDoc(t)) |
| 116 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 117 | defer cancel() |
| 118 | open(t, ctx, rig, "/", false) |
| 119 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil)) |
| 120 | resolved := resolvedTokens(t, "day")[scheme] |
| 121 | for token, want := range documentedDayColours[scheme] { |
| 122 | serialised, px := paintedToken(t, ctx, token) |
| 123 | got := serialised |
| 124 | if alphaOf(t, want) >= 1 { |
| 125 | got = fmt.Sprintf("#%02x%02x%02x", px[0], px[1], px[2]) |
| 126 | if px[3] != 255 { |
| 127 | t.Errorf("%s painted with alpha %d, want opaque", token, px[3]) |
| 128 | } |
| 129 | } |
| 130 | if !nearColour(t, got, want, 2) { |
| 131 | t.Errorf("%s paints as %s, want %s within 2 per channel (the hex the design was measured with)", token, got, want) |
| 132 | } |
| 133 | if !nearColour(t, got, resolved[token], 1) { |
| 134 | t.Errorf("%s paints as %s but the Go evaluator resolves it to %s; the contrast gate is reading a colour the browser does not paint", token, got, resolved[token]) |
| 135 | } |
| 136 | t.Logf("%-30s %-26s documented %-26s evaluator %s", token, got, want, resolved[token]) |
| 137 | } |
| 138 | }) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // overrideAccent is the one line the design system's guide shows an |
| 143 | // app writing: the docs' own violet, not a blue, so a token that kept |
| 144 | // day's cobalt is caught. |
| 145 | const overrideAccent = `<style>:root { --rst-accent: light-dark(#7c3aed, #a78bfa); }</style>` |
| 146 | |
| 147 | var overrideAccentHex = map[string]string{"light": "#7c3aed", "dark": "#a78bfa"} |
| 148 | |
| 149 | func TestOneAccentOverrideRecoloursDay(t *testing.T) { |
| 150 | doc := depthDoc(t) |
| 151 | doc["/"] = strings.Replace(doc["/"], `<link rel="stylesheet" href="/theme.css">`, `<link rel="stylesheet" href="/theme.css">`+overrideAccent, 1) |
| 152 | for _, scheme := range []string{"light", "dark"} { |
| 153 | t.Run(scheme, func(t *testing.T) { |
| 154 | rig := sizingRig(t, false, doc, harness.WithHover()) |
| 155 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 156 | defer cancel() |
| 157 | open(t, ctx, rig, "/", false) |
| 158 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil)) |
| 159 | still(t, ctx) |
| 160 | // The expectation is the theme's own formulas evaluated with |
| 161 | // the override in place (derived_test.go), not a second copy |
| 162 | // of them; and it must differ from the default, or the drive |
| 163 | // would pass on a page the override never reached. |
| 164 | want := resolveWith(t, "day", scheme, map[string]string{"--rst-accent": overrideAccentHex[scheme]}) |
| 165 | def := resolvedTokens(t, "day")[scheme] |
| 166 | for _, tok := range []string{"--rst-accent-fill", "--rst-accent-fill-hover", "--rst-accent-fill-edge-hover", "--rst-tint-hover", "--rst-focus-edge", "--rst-focus-halo", "--rst-accent-soft"} { |
| 167 | if nearColour(t, want[tok], def[tok], 2) { |
| 168 | t.Fatalf("%s resolves to %s with the override and %s without; the override does not reach it, so this drive could not tell", tok, want[tok], def[tok]) |
| 169 | } |
| 170 | } |
| 171 | pixel := func(css string) string { return paintCSS(t, ctx, css) } |
| 172 | check := func(what, got, tok string) { |
| 173 | t.Helper() |
| 174 | if !nearColour(t, got, want[tok], 2) { |
| 175 | t.Errorf("%s is %s, want %s (%s derived from the overridden accent)", what, got, want[tok], tok) |
| 176 | } |
| 177 | } |
| 178 | check("primary button at rest", pixel(computed(t, ctx, "#b-primary", "background-color")), "--rst-accent-fill") |
| 179 | forceState(t, ctx, "#b-primary", "hover") |
| 180 | check("primary button hovered", pixel(computed(t, ctx, "#b-primary", "background-color")), "--rst-accent-fill-hover") |
| 181 | check("primary button's hovered edge", pixel(computed(t, ctx, "#b-primary", "border-top-color")), "--rst-accent-fill-edge-hover") |
| 182 | forceState(t, ctx, "#b-primary") |
| 183 | forceState(t, ctx, "#row-a", "hover") |
| 184 | check("hovered row", computed(t, ctx, "#row-a", "background-color"), "--rst-tint-hover") |
| 185 | forceState(t, ctx, "#row-a") |
| 186 | forceState(t, ctx, "#field", "focus", "focus-visible") |
| 187 | check("focused field's edge", computed(t, ctx, "#field", "border-top-color"), "--rst-focus-edge") |
| 188 | if sh := computed(t, ctx, "#field", "box-shadow"); !strings.Contains(sh, cssColour(t, want["--rst-focus-halo"])) { |
| 189 | t.Errorf("focused field's shadow is %s, want the halo %s in it", sh, cssColour(t, want["--rst-focus-halo"])) |
| 190 | } |
| 191 | forceState(t, ctx, "#field") |
| 192 | check("current menu item", computed(t, ctx, "#menu-cur", "background-color"), "--rst-accent-soft") |
| 193 | // Not accent-dependent, by design: the current nav item is a |
| 194 | // surface thumb in day (C15), so the override leaves it. |
| 195 | if got := computed(t, ctx, "#nav-y", "background-color"); got != cssColour(t, def["--rst-nav-current-bg"]) { |
| 196 | t.Errorf("current nav item is %s, want the surface %s: day's nav thumb is not an accent colour", got, cssColour(t, def["--rst-nav-current-bg"])) |
| 197 | } |
| 198 | }) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // paintCSS paints a computed colour through a 1x1 canvas and returns |
| 203 | // the eight-bit hex, as paintedToken does: a fill is a relative colour, |
| 204 | // and the engine's own conversion is the thing under test. |
| 205 | func paintCSS(t *testing.T, ctx context.Context, css string) string { |
| 206 | t.Helper() |
| 207 | var raw string |
| 208 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const c = document.createElement("canvas"); c.width = c.height = 1; const g = c.getContext("2d", { willReadFrequently: true }); g.fillStyle = "#000000"; g.fillStyle = `+jsStr(css)+`; g.fillRect(0, 0, 1, 1); const d = g.getImageData(0, 0, 1, 1).data; return "#" + [d[0], d[1], d[2]].map(n => n.toString(16).padStart(2, "0")).join(""); })()`, &raw)) |
| 209 | return raw |
| 210 | } |
| 211 | |
| 212 | // accentSweep is the set of single-colour overrides the label check |
| 213 | // walks: twelve hues at 30 degree steps, at a chroma a brand would use |
| 214 | // (oklch 0.6 0.15), three achromatic accents, and the three sRGB |
| 215 | // primaries' edges (cyan, green, yellow), the accents whose fills clip |
| 216 | // hardest and gain the most luminance (rastrillo's review computed cyan's dark |
| 217 | // hover at 4.43:1 under the sheen). A single colour, not a light-dark() |
| 218 | // pair, so the same accent serves both schemes, which is what an app |
| 219 | // that writes one hex gets. |
| 220 | func accentSweep() []string { |
| 221 | out := []string{} |
| 222 | for h := 0; h < 360; h += 30 { |
| 223 | rgb, _ := oklchRGB(0.6, 0.15, float64(h)) |
| 224 | out = append(out, hexOf(rgb)) |
| 225 | } |
| 226 | return append(out, "#808080", "#111111", "#000000", "#00ffff", "#00ff00", "#ffff00") |
| 227 | } |
| 228 | |
| 229 | // brightestInside photographs one element in the given states and |
| 230 | // returns the brightest pixel of its interior as hex: the face as it is |
| 231 | // painted, fill, shade and sheen composed by the engine. The interior |
| 232 | // is the box inset by 3px, which leaves out the 1px border, the 1px |
| 233 | // inset highlight under it (offset sideways by the light), and the |
| 234 | // antialiased corner of a 4px radius, none of which sits under the |
| 235 | // label; the label itself is at least 7px in. The caller hides the |
| 236 | // label first, or its white glyphs would be the brightest pixels. |
| 237 | func brightestInside(t *testing.T, ctx context.Context, selector string, states ...string) string { |
| 238 | t.Helper() |
| 239 | img := decodePNG(t, snap(t, ctx, selector, states...)) |
| 240 | b := img.Bounds() |
| 241 | const inset = 3 |
| 242 | if b.Dx() <= 2*inset || b.Dy() <= 2*inset { |
| 243 | t.Fatalf("%s photographs as %v, too small to have an interior", selector, b) |
| 244 | } |
| 245 | best, bestLum := "", -1.0 |
| 246 | for y := b.Min.Y + inset; y < b.Max.Y-inset; y++ { |
| 247 | for x := b.Min.X + inset; x < b.Max.X-inset; x++ { |
| 248 | r, g, bl, _ := img.At(x, y).RGBA() |
| 249 | px := [3]uint8{uint8(r >> 8), uint8(g >> 8), uint8(bl >> 8)} |
| 250 | if lum := relLuminance(px[0], px[1], px[2]); lum > bestLum { |
| 251 | best, bestLum = hexOf(px), lum |
| 252 | } |
| 253 | } |
| 254 | } |
| 255 | return best |
| 256 | } |
| 257 | |
| 258 | // TestAnyAccentKeepsTheFillLabelReadable holds three findings of |
| 259 | // rastrillo's reviews: a green-to-cyan accent at day's fill lightness clips to |
| 260 | // sRGB and gains luminance, and a grey accent used to take a fixed |
| 261 | // chroma at a powerless hue and come out crimson. For every accent in |
| 262 | // the sweep, in both schemes, the primary button's label clears 4.5:1 |
| 263 | // on the fill at rest and hovered, measured from the colours Chromium |
| 264 | // paints, and on the composed face, the brightest pixel of the button |
| 265 | // as photographed with the sheen pinned under its top edge: the fill |
| 266 | // under the face shade's white at rest, and under the shade and the |
| 267 | // full sheen hovered. The bare fill cleared for every hue while the |
| 268 | // composed face did not (cyan in dark: 4.74:1 bare, 4.15:1 composed), |
| 269 | // which is what the review found. An achromatic accent gives an achromatic |
| 270 | // fill. |
| 271 | func TestAnyAccentKeepsTheFillLabelReadable(t *testing.T) { |
| 272 | for _, scheme := range []string{"light", "dark"} { |
| 273 | t.Run(scheme, func(t *testing.T) { |
| 274 | rig := sizingRig(t, false, depthDoc(t), harness.WithHover()) |
| 275 | ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second) |
| 276 | defer cancel() |
| 277 | open(t, ctx, rig, "/", false) |
| 278 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil)) |
| 279 | still(t, ctx) |
| 280 | // The sheen's centre is where light.js puts the pointer; 4px |
| 281 | // under the top edge is a pointer resting there, and puts the |
| 282 | // sheen's full strength on the shade's lightest rows. |
| 283 | mustRun(t, ctx, chromedp.Evaluate(`(() => { const s = document.createElement("style"); s.id = "override"; document.head.append(s); const b = document.getElementById("b-primary"); b.style.setProperty("--rst-mx", "50%"); b.style.setProperty("--rst-my", "4px"); return true; })()`, nil)) |
| 284 | hideLabel := func(hide bool) { |
| 285 | v := `""` |
| 286 | if hide { |
| 287 | v = `"transparent"` |
| 288 | } |
| 289 | mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("b-primary").style.color = `+v+`, true`, nil)) |
| 290 | } |
| 291 | worst, worstComposed := 99.0, 99.0 |
| 292 | for _, accent := range accentSweep() { |
| 293 | mustRun(t, ctx, chromedp.Evaluate(`document.getElementById("override").textContent = `+jsStr(":root { --rst-accent: "+accent+"; }")+`, true`, nil)) |
| 294 | label := paintCSS(t, ctx, computed(t, ctx, "#b-primary", "color")) |
| 295 | fill := paintCSS(t, ctx, computed(t, ctx, "#b-primary", "background-color")) |
| 296 | forceState(t, ctx, "#b-primary", "hover") |
| 297 | hover := paintCSS(t, ctx, computed(t, ctx, "#b-primary", "background-color")) |
| 298 | forceState(t, ctx, "#b-primary") |
| 299 | hideLabel(true) |
| 300 | faceRest := brightestInside(t, ctx, "#b-primary") |
| 301 | faceHover := brightestInside(t, ctx, "#b-primary", "hover") |
| 302 | hideLabel(false) |
| 303 | for _, c := range []struct { |
| 304 | state, bg, face string |
| 305 | }{{"rest", fill, faceRest}, {"hover", hover, faceHover}} { |
| 306 | ratio, err := ContrastRatio(label, c.bg) |
| 307 | if err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | composed, err := ContrastRatio(label, c.face) |
| 311 | if err != nil { |
| 312 | t.Fatal(err) |
| 313 | } |
| 314 | worst, worstComposed = min(worst, ratio), min(worstComposed, composed) |
| 315 | if ratio < 4.5 { |
| 316 | t.Errorf("accent %s: label %s on the %s fill %s is %.2f:1, under 4.5:1", accent, label, c.state, c.bg, ratio) |
| 317 | } |
| 318 | if composed < 4.5 { |
| 319 | t.Errorf("accent %s: label %s on the composed %s face %s (fill %s under the shade and sheen) is %.2f:1, under 4.5:1", accent, label, c.state, c.face, c.bg, composed) |
| 320 | } |
| 321 | if lf, lb := relLuminance(channels8(t, c.face)), relLuminance(channels8(t, c.bg)); lf < lb { |
| 322 | t.Errorf("accent %s: the composed %s face %s is darker than its fill %s; the photograph missed the shade and sheen, so its ratio proves nothing", accent, c.state, c.face, c.bg) |
| 323 | } |
| 324 | t.Logf("accent %s %-5s %s on %s = %.2f, on the composed face %s = %.2f", accent, c.state, label, c.bg, ratio, c.face, composed) |
| 325 | } |
| 326 | if accent == "#808080" || accent == "#111111" || accent == "#000000" { |
| 327 | r, g, b, _ := parseHex(fill) |
| 328 | if int(r)-int(g) > 2 || int(g)-int(r) > 2 || int(g)-int(b) > 2 || int(b)-int(g) > 2 { |
| 329 | t.Errorf("accent %s: the fill is %s, want a grey; the chroma must come from the accent, not from a fixed number at a powerless hue", accent, fill) |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | t.Logf("tightest pair across the sweep: %.2f:1 on the bare fill, %.2f:1 on the composed face", worst, worstComposed) |
| 334 | }) |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | // channels8 is a hex colour's three channels, for relLuminance. |
| 339 | func channels8(t *testing.T, hex string) (r, g, b uint8) { |
| 340 | t.Helper() |
| 341 | r, g, b, err := parseHex(hex) |
| 342 | if err != nil { |
| 343 | t.Fatal(err) |
| 344 | } |
| 345 | return r, g, b |
| 346 | } |
| 347 | |
| 348 | // TestWithoutRelativeColourDayKeepsItsCobalt is review I1: the derived |
| 349 | // fills live in an @supports block for oklch(from …), which is above |
| 350 | // the stated Firefox floor. Served with that block's test string made |
| 351 | // false, the way an engine without relative colour sees the file, the |
| 352 | // primary button is day's cobalt with its white label, not a fill that |
| 353 | // resolved to nothing. The control is the same page with the real |
| 354 | // string, which paints the derived fill, so the rewrite is known to |
| 355 | // take effect. |
| 356 | func TestWithoutRelativeColourDayKeepsItsCobalt(t *testing.T) { |
| 357 | const real, forced = "oklch(from red l c h)", "oklch(from red l c zz)" |
| 358 | for _, c := range []struct { |
| 359 | name, test string |
| 360 | fill map[string]string |
| 361 | }{ |
| 362 | {"forced false", forced, map[string]string{"light": "rgb(34, 87, 223)", "dark": "rgb(47, 97, 232)"}}, |
| 363 | {"control", real, map[string]string{"light": "rgb(32, 89, 221)", "dark": "rgb(49, 96, 234)"}}, |
| 364 | } { |
| 365 | t.Run(c.name, func(t *testing.T) { |
| 366 | theme, _ := fiesta.ThemeCSS("day") |
| 367 | if !bytes.Contains(theme, []byte(real)) { |
| 368 | t.Fatalf("day.css no longer gates its derived fills on %q; this drive has nothing to force", real) |
| 369 | } |
| 370 | theme = bytes.ReplaceAll(theme, []byte(real), []byte(c.test)) |
| 371 | pages := depthDoc(t) |
| 372 | rig := harness.New(t, func(string) http.Handler { |
| 373 | mux := sizingMux(t, pages) |
| 374 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 375 | if r.URL.Path == "/theme.css" { |
| 376 | w.Header().Set("Content-Type", "text/css") |
| 377 | w.Write(theme) |
| 378 | return |
| 379 | } |
| 380 | mux.ServeHTTP(w, r) |
| 381 | }) |
| 382 | }) |
| 383 | ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| 384 | defer cancel() |
| 385 | open(t, ctx, rig, "/", false) |
| 386 | for scheme, want := range c.fill { |
| 387 | mustRun(t, ctx, chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), true`, nil)) |
| 388 | fill, label := computed(t, ctx, "#b-primary", "background-color"), computed(t, ctx, "#b-primary", "color") |
| 389 | if fill != want || label != "rgb(255, 255, 255)" { |
| 390 | t.Errorf("%s: primary button is %s on %s, want rgb(255, 255, 255) on %s", scheme, label, fill, want) |
| 391 | } |
| 392 | } |
| 393 | }) |
| 394 | } |
| 395 | } |
| 396 | |