oxedyne/daimond/dev/verify_theme.mjs
37.5 KiB, 1 run
created by r2519314175:737, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // Verify the palettes: every one declares every token, every text rung clears |
| 2 | // its contrast floor on every surface it is drawn on, and the pre-paint table in |
| 3 | // index.html says the same thing as the registry in daimond.js. |
| 4 | // |
| 5 | // A palette is offered from a list, so nobody reads it before choosing it. That |
| 6 | // makes the floor a property of the LIST and not of any one palette: a picker |
| 7 | // with ten entries is ten chances to hand someone grey-on-grey. The ratios here |
| 8 | // are computed from the declared hex, by the WCAG formula, not eyeballed off a |
| 9 | // screenshot -- a screenshot proves one palette on one machine. |
| 10 | // |
| 11 | // The floors are the ones the first three palettes were tuned to, and the |
| 12 | // comments in variables.css record why the muted rung has its own: it carries |
| 13 | // whole explanatory paragraphs, and it was found sitting at 2.75 when nobody was |
| 14 | // measuring it. |
| 15 | // |
| 16 | // ── Beyond the lettering ───────────────────────────────────────────── |
| 17 | // Text was only ever half of it. WCAG 2.2 SC 1.4.11 asks 3:1 of the parts of a |
| 18 | // control that are needed to IDENTIFY it -- the box round an input, the ring |
| 19 | // that says where the keyboard is, the fill that says a filter is on -- and SC |
| 20 | // 1.4.3 asks 4.5 of any state colour that is drawn as words rather than as a |
| 21 | // dot. None of that was measured, so the second half of this file measures it. |
| 22 | // |
| 23 | // A colour that falls short today is not failed, it is RECORDED. `KNOWN` below |
| 24 | // carries the ratio each shortfall stands at now; a check that is short but no |
| 25 | // worse than its record prints SHORT and the suite stays green, while a check |
| 26 | // that drops below its record, or that is short and has no record at all, is a |
| 27 | // hard FAIL. Palette debt can therefore be paid off but never quietly added to, |
| 28 | // which is the only property that matters for a picker with eleven entries. |
| 29 | // `node dev/verify_theme.mjs --emit-baseline` prints the table to paste back. |
| 30 | |
| 31 | import fs from 'node:fs'; |
| 32 | import path from 'node:path'; |
| 33 | import { fileURLToPath } from 'node:url'; |
| 34 | |
| 35 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 36 | const WWW = path.join(HERE, '..', 'www'); |
| 37 | |
| 38 | const out = []; |
| 39 | let bad = 0; |
| 40 | const say = (ok, what) => { out.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); return ok; }; |
| 41 | const check = (ok, what) => { if (!say(ok, what)) bad++; }; |
| 42 | |
| 43 | // Shortfalls that stand today, so a pre-existing debt does not fail the suite |
| 44 | // while still being gated against getting worse. See the header. |
| 45 | const shorts = []; |
| 46 | const EMIT = process.argv.includes('--emit-baseline'); |
| 47 | /// How far a recorded shortfall may drift before it counts as a regression. |
| 48 | /// Big enough to absorb the last digit of a hex nudge, small enough that any |
| 49 | /// real darkening trips it. |
| 50 | const DRIFT = 0.02; |
| 51 | /// A contrast check that may be short, provided it is no shorter than recorded. |
| 52 | /// |
| 53 | /// `id` is the stable key into KNOWN; keep it free of measured numbers so the |
| 54 | /// record survives a colour being improved. |
| 55 | function soft(id, r, floor, what) { |
| 56 | const line = `${what} = ${r.toFixed(2)} (floor ${floor})`; |
| 57 | if (r >= floor) { say(true, line); return true; } |
| 58 | const rec = KNOWN[id]; |
| 59 | if (rec === undefined) { |
| 60 | check(false, `${line} -- a NEW shortfall, absent from the baseline`); |
| 61 | shorts.push({ id, r, floor, what, fresh: true }); |
| 62 | return false; |
| 63 | } |
| 64 | if (r < rec - DRIFT) { |
| 65 | check(false, `${line} -- WORSE than the recorded ${rec.toFixed(2)}`); |
| 66 | shorts.push({ id, r, floor, what, worse: true }); |
| 67 | return false; |
| 68 | } |
| 69 | out.push(`SHORT ${line}`); |
| 70 | shorts.push({ id, r, floor, what }); |
| 71 | return false; |
| 72 | } |
| 73 | |
| 74 | // ── The record of what is short today ─────────────────────────── |
| 75 | // Every entry is a real defect, written up in dev/contrast_report.md with a |
| 76 | // suggested colour. The number is what the ratio stands at now; `soft` above |
| 77 | // refuses to let it fall further, and refuses a shortfall that is not here at |
| 78 | // all, so the list can only be shortened. |
| 79 | const KNOWN = { |
| 80 | 'amber/accent-vs-border2': 2.99, |
| 81 | 'amber/border-2-vs-surface': 1.23, |
| 82 | 'amber/border-vs-surface': 1.04, |
| 83 | 'amber/border2-vs-border': 1.18, |
| 84 | 'amber/chip-edge': 1.82, |
| 85 | 'amber/chip-no-text': 4.48, |
| 86 | 'amber/tag-on-vs-surface': 1.70, |
| 87 | 'amber/white-on-accent-hover': 3.05, |
| 88 | 'amber/white-on-danger': 2.99, |
| 89 | 'amber/white-on-ok': 2.25, |
| 90 | 'dark/border-2-vs-surface': 1.29, |
| 91 | 'dark/border-vs-surface': 1.10, |
| 92 | 'dark/border2-vs-border': 1.17, |
| 93 | 'dark/chip-edge': 1.77, |
| 94 | 'dark/chip-no-text': 4.48, |
| 95 | 'dark/tag-on-vs-surface': 1.70, |
| 96 | 'dark/white-on-accent-hover': 2.93, |
| 97 | 'dark/white-on-danger': 2.93, |
| 98 | 'dark/white-on-ok': 2.36, |
| 99 | 'dusk/accent-ring-vs-surface': 1.91, |
| 100 | 'dusk/accent-vs-border': 1.52, |
| 101 | 'dusk/accent-vs-border2': 1.08, |
| 102 | 'dusk/bgprimary-on-accent': 2.76, |
| 103 | 'dusk/border-2-vs-surface': 1.76, |
| 104 | 'dusk/border-vs-surface': 1.25, |
| 105 | 'dusk/border2-vs-border': 1.40, |
| 106 | 'dusk/chip-edge': 1.27, |
| 107 | 'dusk/chip-no-text': 2.96, |
| 108 | 'dusk/tag-on-vs-surface': 1.13, |
| 109 | 'dusk/white-on-accent-hover': 3.88, |
| 110 | 'dusk/white-on-danger': 2.56, |
| 111 | 'dusk/white-on-ok': 2.15, |
| 112 | 'forest/accent-ring-vs-surface': 2.85, |
| 113 | 'forest/accent-vs-border': 2.77, |
| 114 | 'forest/accent-vs-border2': 2.14, |
| 115 | 'forest/bgprimary-on-accent': 3.84, |
| 116 | 'forest/border-2-vs-surface': 1.32, |
| 117 | 'forest/border-vs-surface': 1.03, |
| 118 | 'forest/border2-vs-border': 1.29, |
| 119 | 'forest/chip-edge': 1.71, |
| 120 | 'forest/chip-no-text': 4.06, |
| 121 | 'forest/tag-on-vs-surface': 1.54, |
| 122 | 'forest/white-on-accent-hover': 3.46, |
| 123 | 'forest/white-on-danger': 2.69, |
| 124 | 'forest/white-on-ok': 2.28, |
| 125 | 'ink-light/tag-on-vs-chip': 1.19, |
| 126 | 'light/border-2-vs-surface': 1.28, |
| 127 | 'light/border-vs-surface': 1.07, |
| 128 | 'light/border2-vs-border': 1.20, |
| 129 | 'light/chip-edge': 1.19, |
| 130 | 'light/chip-no-text': 3.14, |
| 131 | 'light/chip-text-hover': 4.47, |
| 132 | 'light/ok-on-ok-bg': 4.47, |
| 133 | 'light/warn-on-warn-bg': 4.26, |
| 134 | 'linen/border-2-vs-surface': 1.35, |
| 135 | 'linen/border-vs-surface': 1.04, |
| 136 | 'linen/border2-vs-border': 1.29, |
| 137 | 'linen/chip-edge': 1.14, |
| 138 | 'linen/chip-no-text': 2.88, |
| 139 | 'linen/chip-text-hover': 4.47, |
| 140 | 'linen/white-on-accent-hover': 4.48, |
| 141 | 'lollypop/accent-ring-vs-surface': 2.99, |
| 142 | 'lollypop/accent-vs-border': 2.85, |
| 143 | 'lollypop/accent-vs-border2': 2.22, |
| 144 | 'lollypop/bgprimary-on-accent': 3.53, |
| 145 | 'lollypop/border-2-vs-surface': 1.34, |
| 146 | 'lollypop/border-vs-surface': 1.04, |
| 147 | 'lollypop/border2-vs-border': 1.28, |
| 148 | 'lollypop/chip-edge': 1.13, |
| 149 | 'lollypop/chip-no-text': 2.90, |
| 150 | 'lollypop/chip-text-hover': 4.47, |
| 151 | 'lollypop/danger-on-warn-bg': 3.15, |
| 152 | 'lollypop/danger-vs-surface': 2.77, |
| 153 | 'lollypop/success-vs-surface': 2.09, |
| 154 | 'lollypop/warn-on-warn-bg': 2.21, |
| 155 | 'lollypop/warn-vs-surface': 1.95, |
| 156 | 'lollypop/white-on-accent-hover': 2.96, |
| 157 | 'lollypop/white-on-danger': 3.64, |
| 158 | 'midnight/accent-vs-border2': 2.42, |
| 159 | 'midnight/bgprimary-on-accent': 4.19, |
| 160 | 'midnight/border-2-vs-surface': 1.29, |
| 161 | 'midnight/border-vs-surface': 1.04, |
| 162 | 'midnight/border2-vs-border': 1.24, |
| 163 | 'midnight/chip-edge': 1.74, |
| 164 | 'midnight/chip-no-text': 4.17, |
| 165 | 'midnight/tag-on-vs-surface': 1.59, |
| 166 | 'midnight/white-on-accent-hover': 3.33, |
| 167 | 'midnight/white-on-danger': 2.88, |
| 168 | 'midnight/white-on-ok': 2.29, |
| 169 | 'mist/border-2-vs-surface': 1.30, |
| 170 | 'mist/border-vs-surface': 1.04, |
| 171 | 'mist/border2-vs-border': 1.24, |
| 172 | 'mist/chip-edge': 1.19, |
| 173 | 'mist/chip-no-text': 3.08, |
| 174 | 'mist/chip-text-hover': 4.47, |
| 175 | 'mist/ok-on-ok-bg': 4.44, |
| 176 | 'mist/white-on-accent-hover': 3.92, |
| 177 | 'motion/infinite-uncovered': 8.00, |
| 178 | 'plum/border-2-vs-surface': 1.26, |
| 179 | 'plum/border-vs-surface': 1.03, |
| 180 | 'plum/border2-vs-border': 1.22, |
| 181 | 'plum/chip-edge': 1.77, |
| 182 | 'plum/chip-no-text': 4.27, |
| 183 | 'plum/tag-on-vs-surface': 1.63, |
| 184 | 'plum/white-on-accent-hover': 2.64, |
| 185 | 'plum/white-on-danger': 2.65, |
| 186 | 'plum/white-on-ok': 2.25, |
| 187 | 'sage/border-2-vs-surface': 1.32, |
| 188 | 'sage/border-vs-surface': 1.02, |
| 189 | 'sage/border2-vs-border': 1.29, |
| 190 | 'sage/chip-edge': 1.05, |
| 191 | 'sage/chip-no-text': 2.65, |
| 192 | 'sage/chip-text-hover': 4.47, |
| 193 | 'sage/white-on-accent-hover': 4.09, |
| 194 | }; |
| 195 | |
| 196 | // ── The declared palettes ──────────────────────────────────────── |
| 197 | const css = fs.readFileSync(path.join(WWW, 'css', 'variables.css'), 'utf8'); |
| 198 | |
| 199 | /// Every `:root[data-theme="x"] { ... }` block, plus the bare `:root` block, |
| 200 | /// which is the dark palette's home and the source of every default. |
| 201 | function blocks(src) { |
| 202 | const found = {}; |
| 203 | const re = /:root(\[data-theme="([a-z]+)"\])?\s*\{([^}]*)\}/g; |
| 204 | let m; |
| 205 | while ((m = re.exec(src))) { |
| 206 | const name = m[2] || (m[1] ? null : 'dark'); |
| 207 | if (!name) continue; |
| 208 | const decls = {}; |
| 209 | for (const line of m[3].split(';')) { |
| 210 | const d = line.match(/(--[a-z0-9-]+)\s*:\s*([^;]+)/i); |
| 211 | if (d) decls[d[1]] = d[2].trim(); |
| 212 | } |
| 213 | found[name] = Object.assign(found[name] || {}, decls); |
| 214 | } |
| 215 | return found; |
| 216 | } |
| 217 | const declared = blocks(css); |
| 218 | |
| 219 | // A palette inherits everything it does not restate from the bare :root block. |
| 220 | const base = declared.dark || {}; |
| 221 | const NAMES = ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum']; |
| 222 | const palette = (n) => Object.assign({}, base, declared[n] || {}); |
| 223 | |
| 224 | /// Each palette's band and ink, read from the registry in daimond.js. Needed |
| 225 | /// before the contrast checks, because whether a filled button letters in white |
| 226 | /// or in the palette's own dark surface depends on the ink. |
| 227 | const THEME_SPEC = {}; |
| 228 | { |
| 229 | const js0 = fs.readFileSync(path.join(WWW, 'js', 'daimond.js'), 'utf8'); |
| 230 | const tbl = js0.match(/var THEMES = \{([\s\S]*?)\n\t\};/); |
| 231 | if (tbl) { |
| 232 | const re = /([a-z]+)\s*:\s*\{\s*tone:\s*'([a-z]+)'\s*,\s*ink:\s*'([a-z]+)'\s*\}/g; |
| 233 | let m; |
| 234 | while ((m = re.exec(tbl[1]))) THEME_SPEC[m[1]] = { tone: m[2], ink: m[3] }; |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | // ── Colour ────────────────────────────────────────────────────── |
| 239 | function rgb(hex) { |
| 240 | const h = hex.trim().replace('#', ''); |
| 241 | if (!/^[0-9a-f]{6}$/i.test(h)) return null; |
| 242 | return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); |
| 243 | } |
| 244 | /// Relative luminance, sRGB, exactly as WCAG defines it. |
| 245 | function lum(c) { |
| 246 | const f = c.map(v => { |
| 247 | const s = v / 255; |
| 248 | return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); |
| 249 | }); |
| 250 | return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; |
| 251 | } |
| 252 | function ratio(a, b) { |
| 253 | const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); |
| 254 | return (x + 0.05) / (y + 0.05); |
| 255 | } |
| 256 | |
| 257 | // ── Every palette declares every token ────────────────────────── |
| 258 | const REQUIRED = [ |
| 259 | '--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-hover', |
| 260 | '--border', '--border-2', |
| 261 | '--text-primary', '--text-secondary', '--text-muted', |
| 262 | '--accent', '--accent-hover', '--accent-soft', '--accent-text', |
| 263 | '--ok', '--ok-bg', '--warn', '--warn-bg', '--danger', '--success', |
| 264 | ]; |
| 265 | for (const n of NAMES) { |
| 266 | const p = palette(n); |
| 267 | const missing = REQUIRED.filter(k => !p[k]); |
| 268 | check(missing.length === 0, `${n}: declares every token${missing.length ? ` (missing ${missing.join(', ')})` : ''}`); |
| 269 | const notHex = REQUIRED.filter(k => p[k] && !rgb(p[k])); |
| 270 | check(notHex.length === 0, `${n}: every token is a plain hex colour${notHex.length ? ` (bad: ${notHex.map(k => `${k}=${p[k]}`).join(', ')})` : ''}`); |
| 271 | } |
| 272 | |
| 273 | // ── The floors ────────────────────────────────────────────────── |
| 274 | // The three surfaces text is READ on, and --bg-hover, which it is only passed |
| 275 | // over. They do not get the same floor, and the reason is not convenience: the |
| 276 | // three palettes that predate the picker were tuned against the stable three |
| 277 | // and sit at 3.75-4.02 on hover, so holding new palettes to a bar the shipped |
| 278 | // ones never met would be an arbitrary line, while dropping the surface |
| 279 | // entirely would let a palette put unreadable text under the pointer. Hover is |
| 280 | // checked, one rung lower. |
| 281 | const STABLE = ['--bg-primary', '--bg-secondary', '--bg-tertiary']; |
| 282 | const HOVER = '--bg-hover'; |
| 283 | const RUNGS = [ |
| 284 | ['--text-primary', 4.5], |
| 285 | ['--text-secondary', 4.5], |
| 286 | // The quiet rung, held to 4.0: it is meant to be lighter than secondary and |
| 287 | // still carries prose, so it gets its own floor rather than an exemption. |
| 288 | ['--text-muted', 4.0], |
| 289 | ]; |
| 290 | /// How far a floor is relaxed on the transient surface. |
| 291 | const HOVER_DROP = 0.5; |
| 292 | |
| 293 | for (const n of NAMES) { |
| 294 | const p = palette(n); |
| 295 | let worst = { r: Infinity }; |
| 296 | for (const [rung, floor] of RUNGS) { |
| 297 | for (const surf of STABLE.concat([HOVER])) { |
| 298 | const a = rgb(p[rung] || ''), b = rgb(p[surf] || ''); |
| 299 | if (!a || !b) continue; |
| 300 | const r = ratio(a, b); |
| 301 | const f = surf === HOVER ? floor - HOVER_DROP : floor; |
| 302 | if (r < worst.r) worst = { r, rung, surf, floor: f }; |
| 303 | check(r >= f, |
| 304 | `${n}: ${rung.replace('--text-', '')} on ${surf.replace('--bg-', '')} = ${r.toFixed(2)} (floor ${f})`); |
| 305 | } |
| 306 | } |
| 307 | if (worst.r < Infinity) { |
| 308 | out.push(` ${n}: worst rung ${worst.rung.replace('--text-', '')} on ${worst.surf.replace('--bg-', '')} at ${worst.r.toFixed(2)}`); |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | /// What a filled control letters in, on this palette. |
| 313 | /// |
| 314 | /// Not always white. A filled button on a light palette is a deep accent and |
| 315 | /// takes white; on a dark palette it is a light mint or salmon and takes the |
| 316 | /// palette's own darkest surface, which is what `--on-fill` resolves to there. |
| 317 | /// Measuring white everywhere would measure a colour the app does not paint. |
| 318 | const onFill = (n) => { |
| 319 | const p = palette(n); |
| 320 | const spec = THEME_SPEC[n]; |
| 321 | return (spec && spec.ink === 'light') ? rgb(p['--bg-primary'] || '') : [255, 255, 255]; |
| 322 | }; |
| 323 | |
| 324 | // The accent carries button labels, and its own text rung on a surface. |
| 325 | for (const n of NAMES) { |
| 326 | const p = palette(n); |
| 327 | const acc = rgb(p['--accent'] || ''), lettering = onFill(n); |
| 328 | if (acc && lettering) { |
| 329 | // 4.5, not 3.5: .tile-start is 12px and .admin-cta is 13px, so these are |
| 330 | // ordinary words and not large text. |
| 331 | check(ratio(acc, lettering) >= 4.5, |
| 332 | `${n}: the label on a filled accent button = ${ratio(acc, lettering).toFixed(2)} (floor 4.5)`); |
| 333 | } |
| 334 | const at = rgb(p['--accent-text'] || ''), bg3 = rgb(p['--bg-tertiary'] || ''); |
| 335 | if (at && bg3) { |
| 336 | check(ratio(at, bg3) >= 4.0, |
| 337 | `${n}: accent-text on tertiary = ${ratio(at, bg3).toFixed(2)} (floor 4.0)`); |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | // ── The selected-chip pair is uniform ─────────────────────────── |
| 342 | // The whole point of the neutral chip is that it does not vary, so no palette |
| 343 | // may quietly restate it. |
| 344 | for (const n of NAMES) { |
| 345 | const own = declared[n] || {}; |
| 346 | const restated = ['--tag-on-bg', '--tag-on-fg', '--tag-on-hover'].filter(k => own[k]); |
| 347 | check(n === 'dark' || restated.length === 0, |
| 348 | `${n}: does not restate the selected-chip colours${restated.length ? ` (${restated.join(', ')})` : ''}`); |
| 349 | } |
| 350 | { |
| 351 | const p = palette('dark'); |
| 352 | const on = rgb(p['--tag-on-bg'] || ''), fg = rgb(p['--tag-on-fg'] || ''); |
| 353 | check(!!on && !!fg && ratio(on, fg) >= 4.5, |
| 354 | `the selected chip's own lettering = ${on && fg ? ratio(on, fg).toFixed(2) : '?'} (floor 4.5)`); |
| 355 | } |
| 356 | |
| 357 | // ── The two registries agree ──────────────────────────────────── |
| 358 | // index.html carries a copy of the palette table so the first paint is right; |
| 359 | // a copy that drifts shows a returning user the wrong band for a frame, or |
| 360 | // leaves data-ink saying the opposite of the palette actually loaded. |
| 361 | const html = fs.readFileSync(path.join(WWW, 'index.html'), 'utf8'); |
| 362 | const js = fs.readFileSync(path.join(WWW, 'js', 'daimond.js'), 'utf8'); |
| 363 | |
| 364 | const fromHtml = {}; |
| 365 | { |
| 366 | const tbl = html.match(/var P = \{([\s\S]*?)\};/); |
| 367 | if (tbl) { |
| 368 | const re = /([a-z]+)\s*:\s*\[\s*'([a-z]+)'\s*,\s*'([a-z]+)'\s*\]/g; |
| 369 | let m; |
| 370 | while ((m = re.exec(tbl[1]))) fromHtml[m[1]] = { tone: m[2], ink: m[3] }; |
| 371 | } |
| 372 | } |
| 373 | const fromJs = {}; |
| 374 | { |
| 375 | const tbl = js.match(/var THEMES = \{([\s\S]*?)\n\t\};/); |
| 376 | if (tbl) { |
| 377 | const re = /([a-z]+)\s*:\s*\{\s*tone:\s*'([a-z]+)'\s*,\s*ink:\s*'([a-z]+)'\s*\}/g; |
| 378 | let m; |
| 379 | while ((m = re.exec(tbl[1]))) fromJs[m[1]] = { tone: m[2], ink: m[3] }; |
| 380 | } |
| 381 | } |
| 382 | // Amber exists to keep short-wavelength light down, so that is a property and |
| 383 | // not a matter of taste: every colour it paints must be blue-poor, or a later |
| 384 | // edit "brightening it up" would quietly undo the only reason it is offered. |
| 385 | { |
| 386 | const p = palette('amber'); |
| 387 | const loud = []; |
| 388 | for (const k of REQUIRED) { |
| 389 | const c = rgb(p[k] || ''); |
| 390 | if (!c) continue; |
| 391 | // Blue may not lead. Allowing it to equal the smaller of red and green |
| 392 | // keeps neutral greys legal and rules out anything that reads as cool. |
| 393 | if (c[2] > Math.min(c[0], c[1])) loud.push(`${k}=${p[k]} (b=${c[2]} > min(r,g)=${Math.min(c[0], c[1])})`); |
| 394 | } |
| 395 | check(loud.length === 0, `amber: no colour lets blue lead${loud.length ? `: ${loud.join(', ')}` : ''}`); |
| 396 | } |
| 397 | |
| 398 | check(Object.keys(fromJs).length === NAMES.length, |
| 399 | `the registry in daimond.js holds all ${NAMES.length} palettes (found ${Object.keys(fromJs).length})`); |
| 400 | check(Object.keys(fromHtml).length === NAMES.length, |
| 401 | `the pre-paint table in index.html holds all ${NAMES.length} palettes (found ${Object.keys(fromHtml).length})`); |
| 402 | for (const n of NAMES) { |
| 403 | const a = fromJs[n], b = fromHtml[n]; |
| 404 | check(!!a && !!b && a.tone === b.tone && a.ink === b.ink, |
| 405 | `${n}: the two tables agree (js=${a ? a.tone + '/' + a.ink : '-'} html=${b ? b.tone + '/' + b.ink : '-'})`); |
| 406 | } |
| 407 | // Every declared palette must actually exist in the stylesheet, and vice versa. |
| 408 | for (const n of NAMES) check(!!declared[n], `${n}: has a palette block in variables.css`); |
| 409 | for (const n of Object.keys(declared)) { |
| 410 | check(NAMES.includes(n), `variables.css declares no palette the picker cannot offer (${n})`); |
| 411 | } |
| 412 | |
| 413 | // ── The ink axis is what the rules key on ─────────────────────── |
| 414 | // A rule naming a palette is a rule that a new palette silently misses, which |
| 415 | // is exactly how ten palettes would ship half-styled. |
| 416 | for (const file of ['app.css', 'render.css', 'guide.css']) { |
| 417 | const src = fs.readFileSync(path.join(WWW, 'css', file), 'utf8'); |
| 418 | const named = [...src.matchAll(/\[data-theme="([a-z]+)"\]/g)].map(m => m[1]); |
| 419 | check(named.length === 0, |
| 420 | `${file}: no rule keys on a palette NAME${named.length ? ` (${[...new Set(named)].join(', ')})` : ''}`); |
| 421 | } |
| 422 | |
| 423 | // ════════════════════════════════════════════════════════════════ |
| 424 | // Everything that is not lettering |
| 425 | // ════════════════════════════════════════════════════════════════ |
| 426 | // Two floors, and which one applies is a question about what the colour is |
| 427 | // DOING, not about what kind of token it is: |
| 428 | // |
| 429 | // CTRL 3.0 WCAG 2.2 SC 1.4.11, Non-text Contrast. The parts of a control |
| 430 | // needed to identify it or its state -- the box round an input, the |
| 431 | // ring that says where the keyboard is, the fill that says a filter |
| 432 | // is on -- and any graphical object that carries meaning, such as a |
| 433 | // status dot. Measured against the surface ADJACENT to it, which for |
| 434 | // a border is the panel on one side and the control's own fill on |
| 435 | // the other, so the worst of the surfaces is the one that counts. |
| 436 | // |
| 437 | // WORD 4.5 SC 1.4.3, Contrast (Minimum). A state colour stops being a dot |
| 438 | // and becomes text the moment a rule says `color:`. --warn is words |
| 439 | // in .spend-governor.amber and .ti-label; --ok is words in .pill.ok; |
| 440 | // #fff is words on every filled button. Those are text, and text |
| 441 | // does not get the non-text floor. |
| 442 | // |
| 443 | // Nothing here relaxes on --bg-hover the way the text rungs do. A text rung is |
| 444 | // only PASSED OVER on hover; a chip, a border and a focus ring all sit on the |
| 445 | // hovered surface for as long as the pointer does, and a control that vanishes |
| 446 | // under the pointer is worse than one that never showed. |
| 447 | const SURF = ['--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-hover']; |
| 448 | const CTRL = 3.0; |
| 449 | const WORD = 4.5; |
| 450 | const WHITE = [255, 255, 255]; |
| 451 | const nm = (s) => s.replace('--bg-', '').replace(/^--/, ''); |
| 452 | |
| 453 | /// The worst ratio a colour reaches against any of the given surfaces. |
| 454 | function worstOn(col, p, surfaces = SURF) { |
| 455 | let w = { r: Infinity, surf: null }; |
| 456 | if (!col) return w; |
| 457 | for (const s of surfaces) { |
| 458 | const b = rgb(p[s] || ''); |
| 459 | if (!b) continue; |
| 460 | const r = ratio(col, b); |
| 461 | if (r < w.r) w = { r, surf: s }; |
| 462 | } |
| 463 | return w; |
| 464 | } |
| 465 | |
| 466 | // ── Borders ───────────────────────────────────────────────────── |
| 467 | // There are two border tokens doing two different jobs, and only one of them is |
| 468 | // a control's boundary. |
| 469 | // |
| 470 | // --border-strong IS the boundary. Every text field, select and outlined button |
| 471 | // in the app now reads `border: 1px solid var(--border-strong)`, and the fill |
| 472 | // inside a field is within 1.2:1 of the panel outside it on every palette, so |
| 473 | // that line is the only thing saying the control is there. Squarely SC 1.4.11, |
| 474 | // and therefore a HARD check: it may not be short on any palette, on any |
| 475 | // surface, ever. There is no baseline entry to fall back on and there must |
| 476 | // never be one -- the whole token exists because the debt was allowed to sit. |
| 477 | // |
| 478 | // --border and --border-2 are dividers: they separate two things that are both |
| 479 | // visible without them, and they are drawn quietly on purpose. They stay |
| 480 | // recorded rather than failed. --border-2 is still checked against --border |
| 481 | // because where it survives as a hover promotion, a hover that changes a |
| 482 | // boundary by less than 3:1 has not shown a state change. |
| 483 | for (const n of NAMES) { |
| 484 | const p = palette(n); |
| 485 | const strong = rgb(p['--border-strong'] || ''); |
| 486 | check(!!strong, `${n}: declares --border-strong`); |
| 487 | if (strong) { |
| 488 | const w = worstOn(strong, p); |
| 489 | check(w.r >= CTRL, |
| 490 | `${n}: --border-strong against its worst surface (${nm(w.surf)}) = ${w.r.toFixed(2)} (floor ${CTRL})`); |
| 491 | } |
| 492 | for (const tok of ['--border', '--border-2']) { |
| 493 | const w = worstOn(rgb(p[tok] || ''), p); |
| 494 | if (w.surf) soft(`${n}/${nm(tok)}-vs-surface`, w.r, CTRL, |
| 495 | `${n}: ${nm(tok)} against its worst surface (${nm(w.surf)})`); |
| 496 | } |
| 497 | const b1 = rgb(p['--border'] || ''), b2 = rgb(p['--border-2'] || ''); |
| 498 | if (b1 && b2) soft(`${n}/border2-vs-border`, ratio(b1, b2), CTRL, |
| 499 | `${n}: border-2 against border (the hover promotion of a boundary)`); |
| 500 | // The field's own fill against the panel it is let into. Not a floor of its |
| 501 | // own -- the border is allowed to be the boundary -- but recorded, because a |
| 502 | // fill that is invisible is why the boundary has to carry the whole job. |
| 503 | const t = rgb(p['--bg-tertiary'] || ''), s = rgb(p['--bg-secondary'] || ''); |
| 504 | if (t && s) out.push(` ${n}: an input's fill against its panel = ${ratio(t, s).toFixed(2)} (no floor; it is why --border-strong matters)`); |
| 505 | } |
| 506 | |
| 507 | // No control may quietly go back to a divider for its boundary. The tokens are |
| 508 | // only as good as the rules that use them, and the failure this catches is a new |
| 509 | // input written by copying an old one. |
| 510 | { |
| 511 | // Comments are stripped FIRST. Without that, the prose above a rule is part of |
| 512 | // what the selector pattern sees, and a paragraph explaining why an input sits |
| 513 | // where it does named a box that is not a control at all. |
| 514 | const css = ['app.css', 'files.css', 'mail.css', 'models.css', 'workspace.css', |
| 515 | 'autoreload.css', 'mobile.css', 'render.css', 'spend.css'] |
| 516 | .map((f) => fs.readFileSync(path.join(WWW, 'css', f), 'utf8')) |
| 517 | .join('\n').replace(/\/\*[\s\S]*?\*\//g, ' '); |
| 518 | const CONTROL = /\b(input|select|textarea)\b|(btn|button)\b/i; |
| 519 | const stragglers = []; |
| 520 | for (const m of css.matchAll(/([^{}]*)\{([^{}]*)\}/g)) { |
| 521 | if (!/border:\s*1px (solid|dashed) var\(--border(-2)?\)/.test(m[2])) continue; |
| 522 | const sel = m[1].trim().split('\n').pop().trim().replace(/\s+/g, ' '); |
| 523 | if (CONTROL.test(sel)) stragglers.push(sel); |
| 524 | } |
| 525 | check(stragglers.length === 0, |
| 526 | `no control takes a divider for its boundary${stragglers.length ? ' -- ' + JSON.stringify(stragglers) : ''}`); |
| 527 | } |
| 528 | |
| 529 | // ── The accent, as the focus indicator ────────────────────────── |
| 530 | // Two shapes, both of them the accent. `outline: 2px solid var(--accent)` draws |
| 531 | // a ring on whatever surface the control sits on (app.css:258, workspace.css:166 |
| 532 | // and :218, files.css:39, models.css:134), so the ring is measured against the |
| 533 | // surfaces. Every text field instead does `outline: none; border-color: |
| 534 | // var(--accent)` -- the focus indicator is the SAME PIXELS as the resting |
| 535 | // border, recoloured -- so what has to clear 3:1 there is accent against |
| 536 | // --border, which is WCAG 2.2 SC 2.4.11 Focus Appearance: the indicator has to |
| 537 | // contrast with the unfocused state of the pixels it replaces. A palette can |
| 538 | // pass one of these and fail the other, so both are checked. |
| 539 | for (const n of NAMES) { |
| 540 | const p = palette(n); |
| 541 | const acc = rgb(p['--accent'] || ''); |
| 542 | if (!acc) continue; |
| 543 | const w = worstOn(acc, p); |
| 544 | soft(`${n}/accent-ring-vs-surface`, w.r, CTRL, |
| 545 | `${n}: the focus ring on its worst surface (${nm(w.surf)})`); |
| 546 | const b1 = rgb(p['--border'] || ''); |
| 547 | if (b1) soft(`${n}/accent-vs-border`, ratio(acc, b1), CTRL, |
| 548 | `${n}: a focused field's border against its resting border`); |
| 549 | const b2 = rgb(p['--border-2'] || ''); |
| 550 | if (b2) soft(`${n}/accent-vs-border2`, ratio(acc, b2), CTRL, |
| 551 | `${n}: a focused button's border against its resting border-2`); |
| 552 | } |
| 553 | |
| 554 | // ── The selected chip, on every palette ───────────────────────── |
| 555 | // One neutral pair shared by all eleven, which is the point of it -- so it has |
| 556 | // to be checked ELEVEN times, once per palette, and the two questions are |
| 557 | // different. Against the surface: the chip is a filled control, SC 1.4.11. |
| 558 | // Against the chip it replaces: this is the state that says a filter is on, and |
| 559 | // a state nobody can see is not a state. The unselected fill is generated per |
| 560 | // hue, so the second question is asked at every hue the app can hash to. |
| 561 | { |
| 562 | const p = palette('dark'); |
| 563 | const on = rgb(p['--tag-on-bg'] || ''); |
| 564 | if (on) for (const n of NAMES) { |
| 565 | const w = worstOn(on, palette(n)); |
| 566 | soft(`${n}/tag-on-vs-surface`, w.r, CTRL, |
| 567 | `${n}: the selected chip against its worst surface (${nm(w.surf)})`); |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | // ── The state colours ─────────────────────────────────────────── |
| 572 | // Their paired *-bg first, because that pairing is only ever used one way: the |
| 573 | // colour is the LETTERING and the -bg is the pill behind it (.pill.ok, |
| 574 | // .spend-governor.amber, .ti-label over .turn-interrupted's --warn-bg). 4.5. |
| 575 | // --danger over --warn-bg is the tripped governor, same rule. |
| 576 | for (const n of NAMES) { |
| 577 | const p = palette(n); |
| 578 | const pairs = [ |
| 579 | ['--ok', '--ok-bg', 'ok on its pill (.pill.ok)'], |
| 580 | ['--warn', '--warn-bg', 'warn on its pill (.spend-governor.amber)'], |
| 581 | ['--danger', '--warn-bg', 'danger on the warn pill (.spend-governor.tripped)'], |
| 582 | ]; |
| 583 | for (const [fg, bg, what] of pairs) { |
| 584 | const a = rgb(p[fg] || ''), b = rgb(p[bg] || ''); |
| 585 | if (a && b) soft(`${n}/${nm(fg)}-on-${nm(bg)}`, ratio(a, b), WORD, `${n}: ${what}`); |
| 586 | } |
| 587 | // The same colours as dots and rules on the main surfaces -- .astat-dot.ok, |
| 588 | // .astat-dot.warn, .ctx-fill.high, .turn-interrupted's dashed edge. Graphical |
| 589 | // objects that carry the whole meaning, so 3:1. |
| 590 | for (const tok of ['--ok', '--warn', '--danger', '--success']) { |
| 591 | const w = worstOn(rgb(p[tok] || ''), p); |
| 592 | if (w.surf) soft(`${n}/${nm(tok)}-vs-surface`, w.r, CTRL, |
| 593 | `${n}: ${nm(tok)} as a dot on its worst surface (${nm(w.surf)})`); |
| 594 | } |
| 595 | // White lettering on a filled state button. .diff-accept fills with --ok, |
| 596 | // .dlg-ok.danger and .admin-item.danger:hover and .abtn.stop:hover fill with |
| 597 | // --danger, and every primary button's HOVER fills with --accent-hover while |
| 598 | // keeping the same white label. All words, all 4.5. |
| 599 | const fills = [ |
| 600 | ['--ok', 'the label on the Accept button (.diff-accept)'], |
| 601 | ['--danger', 'the label on a filled danger button (.dlg-ok.danger)'], |
| 602 | ['--accent-hover', 'the label on a primary button, hovered (.admin-cta:hover)'], |
| 603 | ]; |
| 604 | const lettering = onFill(n); |
| 605 | for (const [tok, what] of fills) { |
| 606 | const c = rgb(p[tok] || ''); |
| 607 | if (c && lettering) soft(`${n}/white-on-${nm(tok)}`, ratio(c, lettering), WORD, `${n}: ${what}`); |
| 608 | } |
| 609 | // The one filled control that letters in the surface colour rather than white. |
| 610 | const acc = rgb(p['--accent'] || ''), bg1 = rgb(p['--bg-primary'] || ''); |
| 611 | if (acc && bg1) soft(`${n}/bgprimary-on-accent`, ratio(acc, bg1), WORD, |
| 612 | `${n}: the lit All/Any label (.tag-mode-btn.on)`); |
| 613 | } |
| 614 | |
| 615 | // ── The generated tag chips ───────────────────────────────────── |
| 616 | // These colours are in no palette. A tag's hue is hashed from its name in |
| 617 | // daimond.js and handed to app.css as --tag-h; saturation and lightness come |
| 618 | // from the ink axis. So the chip is a colour NOBODY DECLARED, and it is text on |
| 619 | // a fill, which is 4.5 -- and it varies with the hue, because HSL lightness is |
| 620 | // not luminance: at 94% lightness a yellow is far brighter than a blue. |
| 621 | // |
| 622 | // The hues and the formulas are read out of the source rather than restated, so |
| 623 | // that adding a hue to TAG_HUES or retuning a chip is measured on the next run |
| 624 | // instead of drifting away from a copy kept here. |
| 625 | const appCss = fs.readFileSync(path.join(WWW, 'css', 'app.css'), 'utf8'); |
| 626 | const TAG_HUES = (() => { |
| 627 | const m = js.match(/var TAG_HUES\s*=\s*\[([^\]]*)\]/); |
| 628 | return m ? m[1].split(',').map(s => parseInt(s.trim(), 10)).filter(v => !Number.isNaN(v)) : []; |
| 629 | })(); |
| 630 | check(TAG_HUES.length > 0, `the tag hues are readable from daimond.js (found ${TAG_HUES.length})`); |
| 631 | |
| 632 | /// sRGB for an `hsl(h s% l%)`, the way a browser resolves it. |
| 633 | function hsl(h, s, l) { |
| 634 | h = ((h % 360) + 360) % 360; s /= 100; l /= 100; |
| 635 | const c = (1 - Math.abs(2 * l - 1)) * s; |
| 636 | const x = c * (1 - Math.abs((h / 60) % 2 - 1)); |
| 637 | const m = l - c / 2; |
| 638 | let v; |
| 639 | if (h < 60) v = [c, x, 0]; else if (h < 120) v = [x, c, 0]; |
| 640 | else if (h < 180) v = [0, c, x]; else if (h < 240) v = [0, x, c]; |
| 641 | else if (h < 300) v = [x, 0, c]; else v = [c, 0, x]; |
| 642 | return v.map(q => Math.round((q + m) * 255)); |
| 643 | } |
| 644 | /// The `hsl(var(--tag-h, 0) S% L%)` triples declared by one rule in app.css. |
| 645 | /// |
| 646 | /// THE SELECTOR MUST START ITS OWN LINE, and that is the whole of this comment. |
| 647 | /// This was a bare `indexOf(selector + ' {')` until 2026-08-21, so ANY more |
| 648 | /// specific rule written earlier in the file shadowed it: `.session-box-tags |
| 649 | /// .tag-chip { flex: none; }` arrived at line 700 and the search stopped there, |
| 650 | /// on a rule with no colour in it at all. Every formula then read null and the |
| 651 | /// whole tag-chip section of this file went dark -- reported as "the formulas are |
| 652 | /// not readable", which sounds like a stylesheet fault and was a parser fault. |
| 653 | /// Anchoring to a line start makes a shadowing rule impossible rather than |
| 654 | /// unlikely: a nested selector cannot begin a line with the bare one. |
| 655 | function chipRule(selector) { |
| 656 | const at = appCss.indexOf('\n' + selector + ' {'); |
| 657 | const i = at < 0 ? -1 : at + 1; |
| 658 | if (i < 0) return null; |
| 659 | const body = appCss.slice(i, appCss.indexOf('}', i)); |
| 660 | const grab = (prop) => { |
| 661 | const m = body.match(new RegExp(prop + ':[^;]*?hsl\\(var\\(--tag-h[^)]*\\)\\s*(\\d+)%\\s*(\\d+)%')); |
| 662 | return m ? [Number(m[1]), Number(m[2])] : null; |
| 663 | }; |
| 664 | return { bg: grab('background'), fg: grab('color'), bd: grab('border(?:-color)?') }; |
| 665 | } |
| 666 | const CHIP = { |
| 667 | light: { rest: chipRule('.tag-chip'), hover: chipRule('button.tag-chip:hover'), no: chipRule('.tag-no') }, |
| 668 | dark: { rest: chipRule(':root[data-ink="dark"] .tag-chip'), hover: chipRule(':root[data-ink="dark"] button.tag-chip:hover'), no: chipRule(':root[data-ink="dark"] .tag-no') }, |
| 669 | }; |
| 670 | check(!!(CHIP.light.rest && CHIP.light.rest.bg && CHIP.light.rest.fg && CHIP.dark.rest && CHIP.dark.rest.bg && CHIP.dark.rest.fg), |
| 671 | 'the tag chip formulas are readable from app.css'); |
| 672 | |
| 673 | // The ink each palette takes, from the registry already parsed above -- not a |
| 674 | // second copy of the table, which is the thing this file exists to prevent. |
| 675 | const inkOf = (n) => (fromJs[n] || {}).ink; |
| 676 | for (const n of NAMES) { |
| 677 | const p = palette(n), ink = inkOf(n), c = CHIP[ink]; |
| 678 | if (!c || !c.rest || !c.rest.bg || !c.rest.fg) continue; |
| 679 | // The chip's own lettering, at every hue. 4.5: it is the tag's name. |
| 680 | let wt = { r: Infinity }, wh = { r: Infinity }, wb = { r: Infinity }, wn = { r: Infinity }; |
| 681 | for (const h of TAG_HUES) { |
| 682 | const bg = hsl(h, ...c.rest.bg), fg = hsl(h, ...c.rest.fg); |
| 683 | const r = ratio(fg, bg); |
| 684 | if (r < wt.r) wt = { r, h }; |
| 685 | if (c.hover && c.hover.bg) { |
| 686 | const rh = ratio(fg, hsl(h, ...c.hover.bg)); |
| 687 | if (rh < wh.r) wh = { r: rh, h }; |
| 688 | } |
| 689 | // The chip against the panel. A chip is identifiable if EITHER its fill or |
| 690 | // its edge is far enough from the surface -- it does not need both -- so |
| 691 | // the better of the two is what has to clear 3:1, and a chip that clears |
| 692 | // it on neither is a control nobody can see the extent of. |
| 693 | { |
| 694 | const surfs = ['--bg-secondary', '--bg-tertiary']; |
| 695 | const fw = worstOn(bg, p, surfs); |
| 696 | const bw = c.rest.bd ? worstOn(hsl(h, ...c.rest.bd), p, surfs) : { r: 0, surf: fw.surf }; |
| 697 | const best = fw.r >= bw.r ? fw : bw; |
| 698 | if (best.r < wb.r) wb = { r: best.r, h, surf: best.surf }; |
| 699 | } |
| 700 | // The REFUSED chip has no fill at all -- `background: transparent` -- so |
| 701 | // its label is drawn straight onto the surface. Text, 4.5. |
| 702 | if (c.no && c.no.fg) { |
| 703 | const w = worstOn(hsl(h, ...c.no.fg), p); |
| 704 | if (w.r < wn.r) wn = { r: w.r, h, surf: w.surf }; |
| 705 | } |
| 706 | } |
| 707 | if (wt.r < Infinity) soft(`${n}/chip-text`, wt.r, WORD, `${n}: a tag chip's name, worst hue (${wt.h})`); |
| 708 | if (wh.r < Infinity) soft(`${n}/chip-text-hover`, wh.r, WORD, `${n}: a tag chip's name while hovered, worst hue (${wh.h})`); |
| 709 | if (wb.r < Infinity) soft(`${n}/chip-edge`, wb.r, CTRL, `${n}: a tag chip's extent against the panel, worst hue (${wb.h})`); |
| 710 | if (wn.r < Infinity) soft(`${n}/chip-no-text`, wn.r, WORD, `${n}: a REFUSED tag's name on ${nm(wn.surf)}, worst hue (${wn.h})`); |
| 711 | } |
| 712 | // The selected chip against the chip it replaces. The fill is the only thing |
| 713 | // that changes, so this ratio IS the state change, and it is asked per hue and |
| 714 | // per ink because the fill it replaces is generated. |
| 715 | { |
| 716 | const on = rgb(palette('dark')['--tag-on-bg'] || ''); |
| 717 | for (const ink of ['light', 'dark']) { |
| 718 | const c = CHIP[ink]; |
| 719 | if (!on || !c || !c.rest || !c.rest.bg) continue; |
| 720 | let w = { r: Infinity }; |
| 721 | for (const h of TAG_HUES) { |
| 722 | const r = ratio(on, hsl(h, ...c.rest.bg)); |
| 723 | if (r < w.r) w = { r, h }; |
| 724 | } |
| 725 | soft(`ink-${ink}/tag-on-vs-chip`, w.r, CTRL, |
| 726 | `ink=${ink}: a selected chip against the unselected one, worst hue (${w.h})`); |
| 727 | } |
| 728 | } |
| 729 | |
| 730 | // ── Reduced motion ────────────────────────────────────────────── |
| 731 | // Not a contrast question, but the same kind of question: something the |
| 732 | // stylesheets do that nobody was checking. Every rule that moves has to have a |
| 733 | // `@media (prefers-reduced-motion: reduce)` answer somewhere in the same file, |
| 734 | // and an INFINITE animation is the one that cannot be argued away -- a 0.15s |
| 735 | // transition ends, a `2.2s infinite` pulse never does, and SC 2.2.2 asks that |
| 736 | // anything moving for more than five seconds can be stopped. |
| 737 | { |
| 738 | const MOTION = fs.readdirSync(path.join(WWW, 'css')).filter(f => f.endsWith('.css')); |
| 739 | const infinite = []; |
| 740 | for (const f of MOTION) { |
| 741 | const src = fs.readFileSync(path.join(WWW, 'css', f), 'utf8'); |
| 742 | const rm = src.includes('prefers-reduced-motion'); |
| 743 | // Which animations does that file's reduce block actually turn off? |
| 744 | const stopped = new Set(); |
| 745 | for (const m of src.matchAll(/@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{([\s\S]*?)\n\t*\}/g)) { |
| 746 | for (const s of m[1].matchAll(/([^{}]+)\{[^{}]*(?:animation|transition)\s*:\s*none/g)) { |
| 747 | for (const sel of s[1].split(',')) stopped.add(sel.trim()); |
| 748 | } |
| 749 | } |
| 750 | const lines = src.split('\n'); |
| 751 | lines.forEach((line, i) => { |
| 752 | if (!/animation[^:]*:\s*[^;]*infinite/.test(line)) return; |
| 753 | // The selector is what is left of the brace on the same line for a |
| 754 | // one-line rule, and otherwise the nearest line above that opens a |
| 755 | // block -- stopping at the previous rule's close, so a declaration |
| 756 | // buried in a multi-line rule still finds its own selector. |
| 757 | let sel = ''; |
| 758 | if (line.includes('{')) sel = line.split('{')[0].trim(); |
| 759 | else for (let k = i - 1; k >= 0; k--) { |
| 760 | if (lines[k].includes('}')) break; |
| 761 | if (lines[k].includes('{')) { sel = lines[k].split('{')[0].trim(); break; } |
| 762 | } |
| 763 | const covered = [...stopped].some(s => s === sel || sel.startsWith(s) || s.startsWith(sel)); |
| 764 | if (!covered) infinite.push(`${f}:${i + 1} ${sel || '(?)'}`); |
| 765 | }); |
| 766 | if (!rm) { |
| 767 | const moves = (src.match(/transition\s*:/g) || []).length + (src.match(/animation\s*:/g) || []).length; |
| 768 | if (moves) out.push(` ${f}: ${moves} moving rule(s), no prefers-reduced-motion block`); |
| 769 | } |
| 770 | } |
| 771 | // Scored as a countdown from ten rather than a ratio, so the same gate does |
| 772 | // the same job: covering one more animation raises the score and covering |
| 773 | // them all clears the floor, while ADDING an uncovered one drops below the |
| 774 | // record and fails outright. |
| 775 | soft('motion/infinite-uncovered', 10 - infinite.length, 10, |
| 776 | `every infinite animation has a prefers-reduced-motion answer${infinite.length ? ` (uncovered: ${infinite.join('; ')})` : ''}`); |
| 777 | } |
| 778 | |
| 779 | |
| 780 | console.log(out.join('\n')); |
| 781 | if (shorts.length) { |
| 782 | console.log(`\n── short of the floor, and recorded (${shorts.length}) ──`); |
| 783 | for (const s of shorts) console.log(` ${s.what} = ${s.r.toFixed(2)} < ${s.floor}${s.fresh ? ' [NEW]' : ''}${s.worse ? ' [WORSE]' : ''}`); |
| 784 | console.log(' See dev/contrast_report.md for the suggested colours.'); |
| 785 | } |
| 786 | if (EMIT) { |
| 787 | console.log('\n── baseline to paste into KNOWN ──'); |
| 788 | for (const s of shorts.slice().sort((a, b) => a.id.localeCompare(b.id))) { |
| 789 | console.log(`\t'${s.id}': ${(Math.floor(s.r * 100) / 100).toFixed(2)},`); |
| 790 | } |
| 791 | } |
| 792 | console.log(bad ? `\n${bad} FAILED` : `\nALL PASS (${out.filter(l => l.startsWith('PASS')).length} checks, ${shorts.length} short and recorded)`); |
| 793 | process.exit(bad ? 1 : 0); |