Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
191 lines
11 KiB
JavaScript
191 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
/*
|
||
Every text colour against every surface it is drawn on, per theme.
|
||
|
||
A palette is not legible in general: a token is legible on a surface. `--faint`
|
||
can be 5:1 on the panel and 4.2:1 on the same panel under a hovered row, and the
|
||
second is where it is read the moment the pointer is on it. So what is measured
|
||
here is not a list of tokens but a list of PAIRS, each one a place the console
|
||
really puts that colour on that ground. The pairs were read from the stylesheets
|
||
(`grep var(--token)` in `src/`), and each says where.
|
||
|
||
A surface may be a token, or a token under a translucent one (the hover veils,
|
||
the Query Logs row colours), composed the way the browser composes them: in sRGB,
|
||
source over. `color-mix(in srgb, X 30%, transparent)` is X at alpha 0.3.
|
||
|
||
Thresholds, WCAG 2.2:
|
||
|
||
4.5 text (1.4.3). Every text here is under 18.66 px bold, so none is "large".
|
||
3 a non-text object a user needs to see (1.4.11): a field's border, the focus
|
||
ring, a checkbox's box, a switch's knob, a tick on its fill.
|
||
|
||
What it does not measure: the chart series (`dev/palette-distance.mjs` does, as
|
||
pairs that share a chart) and decorative dividers (`--line`, `--line2`), which
|
||
1.4.11 does not ask anything of: a panel is still a panel without its border.
|
||
|
||
Exit code: 1 when a pair in the LIGHT theme is under its threshold. Dark is
|
||
measured and printed with the same list, and it is not gated, on purpose: its
|
||
values are the ones shipped since 2026-08-25 and the light theme was designed
|
||
under a "dark stays pixel-identical" rule (2026-10-01). What it fails is listed
|
||
as a fact about dark, not excused.
|
||
|
||
Run: node dev/theme-contrast.mjs
|
||
*/
|
||
import { readFileSync } from 'node:fs'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { dirname, join } from 'node:path'
|
||
import { themeTokens } from './check-colour-tokens.mjs'
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||
|
||
// ── Colour ───────────────────────────────────────────────────────────────────
|
||
|
||
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
||
|
||
/** `#rrggbb` or `rgb[a](r, g, b[, a])` → [r, g, b, a], channels 0-255. */
|
||
export function parse(value) {
|
||
const hex = /^#([0-9a-f]{6})$/i.exec(value)
|
||
if (hex) return [0, 2, 4].map((i) => parseInt(hex[1].slice(i, i + 2), 16)).concat(1)
|
||
const fn = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(value)
|
||
if (fn) return [Number(fn[1]), Number(fn[2]), Number(fn[3]), fn[4] == null ? 1 : Number(fn[4])]
|
||
return null
|
||
}
|
||
|
||
/** `top` over an opaque `ground`, in sRGB: what the browser paints. */
|
||
export const over = (top, ground) => [0, 1, 2].map((i) => top[i] * top[3] + ground[i] * (1 - top[3])).concat(1)
|
||
|
||
const luminance = ([r, g, b]) => {
|
||
const [R, G, B] = [r, g, b].map((c) => toLinear(c / 255))
|
||
return 0.2126 * R + 0.7152 * G + 0.0722 * B
|
||
}
|
||
|
||
export const contrast = (a, b) => {
|
||
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m)
|
||
return (x + 0.05) / (y + 0.05)
|
||
}
|
||
|
||
const hex = (c) => '#' + c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')
|
||
|
||
// ── What is measured ─────────────────────────────────────────────────────────
|
||
|
||
/*
|
||
The surfaces. A name is a token; `a + b` is token `b` painted over surface `a`;
|
||
`a + b@0.3` is `b` at that alpha, which is how `color-mix(..., transparent)` and
|
||
the row colours are written.
|
||
*/
|
||
const PLAIN = ['bg', 'pan', 'pan2', 'field']
|
||
const HOVERED = ['pan + hover', 'pan + hover-strong', 'pan2 + hover-strong']
|
||
const EVERYWHERE = [...PLAIN, ...HOVERED]
|
||
|
||
/*
|
||
The `stroke` written inside a `--glyph-*` data URI, which is the only place that
|
||
colour exists. `%23` is `#`.
|
||
*/
|
||
const GLYPH = { 'glyph-chevron': 'glyph-chevron', 'glyph-tick': 'glyph-tick', 'glyph-tick-ok': 'glyph-tick-ok', 'glyph-dash': 'glyph-dash' }
|
||
|
||
export const PAIRS = [
|
||
// Text: the three steps of ink, on every ground of the console.
|
||
{ fg: 'ink', on: [...EVERYWHERE, 'field-focus', 'acc-bg', 'dan-fill'], min: 4.5, where: 'all body text; the own-session row (`acc-bg`); Confirm armed (`dan-fill`)' },
|
||
{ fg: 'mute', on: [...EVERYWHERE, 'acc-bg'], min: 4.5, where: 'secondary text, labels, the small button, row data' },
|
||
{ fg: 'faint', on: [...EVERYWHERE, 'acc-bg'], min: 4.5, where: 'placeholders, TTL suffixes, timestamps, disabled row actions' },
|
||
// Amber.
|
||
{ fg: 'on-acc', on: ['acc'], min: 4.5, where: 'primary button, active rail entry, current page, Segmented' },
|
||
{ fg: 'acc-ink', on: [...EVERYWHERE, 'acc-bg', 'pan + acc@0.14'], min: 4.5, where: 'links, current sub-tab, sorted arrow, `acc` pill, Versions mark' },
|
||
// State tones, as pill or notice (`ui/tones`) and as loose text.
|
||
{ fg: 'ok', on: ['ok-bg', 'pan', 'pan2'], min: 4.5, where: '`ok` pill and notice; Blocking status mark' },
|
||
{ fg: 'warn', on: ['warn-bg', 'pan', 'pan + hover'], min: 4.5, where: '`warn` pill and notice; the Dashboard\'s limited rows' },
|
||
{ fg: 'dan', on: ['dan-bg', 'pan', 'pan2', 'pan + hover-strong'], min: 4.5, where: '`dan` pill and notice, validation text, Delete in a row or a menu' },
|
||
{ fg: 'info', on: ['info-bg', 'pan'], min: 4.5, where: '`info` pill and notice' },
|
||
{ fg: 'on-dan', on: ['dan-fill'], min: 4.5, where: 'the destructive button, filled' },
|
||
{ fg: 'chip-fg', on: ['chip-bg'], min: 4.5, where: 'record type and app class chips' },
|
||
// The rule kind in Blocking › Rules and Lists: `Allow` takes `--ok`, `Block` a series colour.
|
||
{ fg: 'ok', on: ['pan + hover'], min: 4.5, where: '`Allow` in Blocking › Rules and Block Lists, under the row hover' },
|
||
{ fg: 'ch-block', on: ['pan', 'pan + hover'], min: 4.5, where: '`Block` in Blocking › Rules and Block Lists' },
|
||
// Query Logs: the cell text on its row colour, 30 % over the panel.
|
||
...['ch-fail', 'ch-block', 'ch-nx', 'ch-refuse', 'ch-auth', 'ch-rec', 'ch-cache'].map((k) => ({
|
||
fg: 'ink', on: [`pan + ${k}@0.3`], min: 4.5, where: `Query Logs row (${k})`,
|
||
})),
|
||
...['ch-fail', 'ch-block', 'ch-nx', 'ch-refuse', 'ch-auth', 'ch-rec', 'ch-cache'].map((k) => ({
|
||
fg: 'faint-on-tint', on: [`pan + ${k}@0.3`], min: 4.5, where: `Query Logs row, its response time (${k})`,
|
||
})),
|
||
// The login cover (`screens/LoginCover`) draws all its text on `field`, the deepest surface.
|
||
...['ink', 'mute', 'faint', 'acc-ink'].map((fg) => ({
|
||
fg, on: ['field'], min: 4.5, where: `the login cover (${fg})`,
|
||
})),
|
||
// Non-text: what a user has to see to operate a control.
|
||
{ fg: 'faint-on-tint', on: ['pan', 'pan + hover'], min: 4.5, where: 'Query Logs, the response time on an uncoloured or hovered row' },
|
||
{ fg: 'line-field', on: ['bg', 'pan', 'field', 'pan2'], min: 3, where: 'a field\'s border at rest: it is what tells a field from the panel' },
|
||
{ fg: 'line-ctrl', on: ['pan', 'field', 'pan2'], min: 3, where: 'a checkbox\'s and a radio\'s box at rest; a field under the pointer' },
|
||
{ fg: 'acc-ink', on: ['bg', 'pan', 'pan2', 'field'], min: 3, where: 'the focus ring; a focused field\'s border; a ticked box\'s border' },
|
||
{ fg: 'dan', on: ['pan', 'field'], min: 3, where: 'the border of a field that failed validation' },
|
||
{ fg: 'mute', on: ['field'], min: 3, where: 'the switch\'s knob, off' },
|
||
{ fg: 'on-acc', on: ['acc'], min: 3, where: 'the switch\'s knob, on; the tick and the dash' },
|
||
{ fg: 'glyph-tick-ok', on: ['ok'], min: 3, where: 'a granted permission\'s tick, on its green' },
|
||
{ fg: 'ok', on: ['pan'], min: 3, where: 'a granted permission\'s box and pip' },
|
||
{ fg: 'glyph-chevron', on: ['field'], min: 3, where: 'the dropdown\'s chevron' },
|
||
{ fg: 'ch-1', on: ['pan', 'pan2'], min: 3, where: 'the Dashboard\'s week by hour: its busiest cell on the panel and beside an empty cell (Heatmap.module.css)' },
|
||
]
|
||
|
||
// ── Resolution ───────────────────────────────────────────────────────────────
|
||
|
||
function resolver(decls) {
|
||
const value = (name) => {
|
||
let v = decls.get(`--${name}`)
|
||
if (v == null) throw new Error(`--${name} is not declared`)
|
||
if (GLYPH[name]) {
|
||
const m = /stroke='%23([0-9a-f]{6})'/i.exec(v)
|
||
if (!m) throw new Error(`--${name} has no stroke colour`)
|
||
v = `#${m[1]}`
|
||
}
|
||
const ref = /^var\(--([\w-]+)\)$/.exec(v)
|
||
if (ref) return value(ref[1])
|
||
const c = parse(v)
|
||
if (!c) throw new Error(`--${name}: cannot read ${v}`)
|
||
return c
|
||
}
|
||
const surface = (spec) => {
|
||
const [base, ...layers] = spec.split('+').map((s) => s.trim())
|
||
let ground = value(base)
|
||
if (ground[3] !== 1) throw new Error(`${base} is not opaque`)
|
||
for (const layer of layers) {
|
||
const [name, alpha] = layer.split('@')
|
||
const top = value(name)
|
||
ground = over(alpha == null ? top : [...top.slice(0, 3), Number(alpha)], ground)
|
||
}
|
||
return ground
|
||
}
|
||
return { value, surface }
|
||
}
|
||
|
||
/** Every pair of `PAIRS` measured in one theme. */
|
||
export function measure(decls) {
|
||
const { value, surface } = resolver(decls)
|
||
return PAIRS.flatMap((p) => p.on.map((on) => {
|
||
const ratio = contrast(value(p.fg), surface(on))
|
||
return { ...p, on, ratio, pass: ratio >= p.min, fgHex: hex(value(p.fg)), onHex: hex(surface(on)) }
|
||
}))
|
||
}
|
||
|
||
// ── The report ───────────────────────────────────────────────────────────────
|
||
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||
const themes = themeTokens(readFileSync(join(ROOT, 'src/theme/tokens.css'), 'utf8'))
|
||
let lightFails = 0
|
||
for (const theme of ['light', 'dark']) {
|
||
const rows = measure(themes[theme])
|
||
console.log(`\n${'='.repeat(78)}\n${theme.toUpperCase()}${theme === 'dark' ? ': measured, not gated' : ''}\n${'='.repeat(78)}`)
|
||
for (const p of PAIRS) {
|
||
const mine = rows.filter((r) => r.fg === p.fg && r.where === p.where)
|
||
const worst = mine.reduce((a, b) => (b.ratio < a.ratio ? b : a))
|
||
console.log(`\n --${p.fg} ${mine[0].fgHex} ≥ ${p.min} · ${p.where}`)
|
||
console.log(' ' + mine.map((r) => `${r.on} ${r.ratio.toFixed(2)}${r.pass ? '' : ' ✗'}`).join(' · '))
|
||
if (!worst.pass) console.log(` UNDER ${p.min}: ${worst.on} ${worst.onHex} gives ${worst.ratio.toFixed(2)}:1`)
|
||
}
|
||
const fails = rows.filter((r) => !r.pass)
|
||
console.log(`\n ${theme}: ${rows.length - fails.length} of ${rows.length} pairs meet their threshold`)
|
||
if (theme === 'light') lightFails = fails.length
|
||
}
|
||
console.log('')
|
||
if (lightFails) process.exitCode = 1
|
||
}
|