Files
technitium-console/dev/theme-contrast.mjs
T
DenozordecandClaude Sonnet 5.5 f3531c64f9 Move the console onto ReUI PRO
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 <[email protected]>
2026-10-27 00:06:06 +07:00

191 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 + [email protected]` 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 + [email protected]'], 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
}