Files
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 <noreply@anthropic.com>
2026-10-27 00:06:06 +07:00

352 lines
14 KiB
JavaScript

#!/usr/bin/env node
/*
How far apart the chart colours actually are.
A series palette is not a matter of taste at the point where two series land in
the same doughnut: either the eye separates them or it does not. This measures
it, so the decision between the design drawing's palette and the code's is made
on numbers and not on which one was written last.
What it measures, for each palette:
· CIEDE2000 between every pair of series that SHARE A CHART. Pairs that never
appear together do not compete and are not counted.
· The same, through protanopia and deuteranopia (Machado 2009, severity 1.0).
A DNS console is read by whoever administers the server, and a palette that
only works for trichromats is a palette that works for most people.
· Every series against the interface's own text and line tokens. A series the
exact colour of `--mute` is a series that reads as a label.
· WCAG 1.4.11 contrast against the panel it is drawn on: a 2 px line and a 9 px
legend chip are non-text graphical objects and want 3:1.
Thresholds, stated so they can be argued with:
ΔE00 < 10 collision: the two read as the same colour at line and chip size
ΔE00 < 15 at risk: separable side by side, not separable across a chart
Every theme is measured, not only the dark one: the code's palette is read from
each theme block of `tokens.css` and reported against that theme's own panel and
text tokens. Light is held to two more rules, because dark is the palette these
thresholds were argued on and light has to answer to it pair by pair:
· no series under ΔE00 10 from a text or line token of its theme;
· no pair that shares a chart separates LESS in light than in dark, under any
of the three visions, unless it is still at 15 or more. A pair that was at
risk in dark may stay at risk in light, never sink further.
Exit code: 1 when a theme of the code fails its rules (every theme: each series
at 3:1 on its panel; light: also the two above), 0 otherwise. The drawn palette
and the proposal are history, printed for comparison and never gated.
Run: node dev/palette-distance.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)), '..')
// ── The two palettes under comparison ────────────────────────────────────────
/*
The code's, read from the file so this cannot go stale: one palette per theme
block, with the six-digit hex tokens of that block and nothing else.
*/
export function codePalettes(css = readFileSync(join(ROOT, 'src/theme/tokens.css'), 'utf8')) {
const out = {}
for (const [theme, decls] of Object.entries(themeTokens(css))) {
out[theme] = {}
for (const [name, value] of decls)
if (/^#[0-9a-f]{6}$/i.test(value)) out[theme][name.slice(2)] = value.toLowerCase()
}
return out
}
/*
The palette of the Dashboard design drawing, transcribed by hand on 2026-09-02.
Transcribed and not read: the drawing is not a file in this repository.
*/
const DRAWN = {
'ch-total': '#60a5fa',
'ch-ok': '#34d399',
'ch-fail': '#f87171',
'ch-nx': '#a3e635',
'ch-refuse': '#22d3ee',
'ch-auth': '#38bdf8',
'ch-rec': '#fb923c',
'ch-cache': '#f472b6',
'ch-block': '#c084fc',
'ch-drop': '#868e96',
'ch-clients': '#f5a524',
/* The open cycle, taken from the two doughnuts the drawing paints with it. */
'ch-1': '#60a5fa',
'ch-2': '#38bdf8',
'ch-3': '#2dd4bf',
'ch-4': '#f472b6',
'ch-5': '#9aa1a8',
}
/*
The proposal this measurement argues for: the drawing's hues, which measure better
where it counts, with the two exact identities corrected back to the code's.
*/
const PROPOSED = {
...DRAWN,
/* `#868e96` IS `--faint`, to the digit: a series cannot be the text colour. */
'ch-drop': '#94a3b8',
/* Eight and not five: the cycle feeds an unbounded set (StatsManager.cs:2544
truncates nothing), so it runs out sooner than the doughnut does. */
'ch-1': '#38bdf8', 'ch-2': '#34d399', 'ch-3': '#a78bfa', 'ch-4': '#fb923c',
'ch-5': '#2dd4bf', 'ch-6': '#f472b6', 'ch-7': '#f87171', 'ch-8': '#facc15',
}
/*
Which series share a chart. Read from the server, not guessed:
WebServiceDashboardApi.cs:465-535 for the line, StatsManager.cs:2467 for the
response doughnut. The two remaining doughnuts are open sets and get the cycle.
*/
export const CHARTS = {
'main line chart': ['ch-total', 'ch-ok', 'ch-fail', 'ch-nx', 'ch-refuse', 'ch-auth', 'ch-rec', 'ch-cache', 'ch-block', 'ch-drop', 'ch-clients'],
'Query Response Types': ['ch-auth', 'ch-rec', 'ch-cache', 'ch-block', 'ch-drop'],
'Blocking stacked bars': ['ch-ok', 'ch-block'],
'open cycle (Query / Protocol Types)': ['ch-1', 'ch-2', 'ch-3', 'ch-4', 'ch-5', 'ch-6', 'ch-7', 'ch-8'],
}
/* The interface tokens a series must not be mistaken for. */
export const UI = ['ink', 'mute', 'faint', 'line']
// ── Colour ───────────────────────────────────────────────────────────────────
const srgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
const fromLinear = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)
function lab(hex) {
const [r, g, b] = srgb(hex).map(toLinear)
const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047
const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b
const Z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27) * t / 116 + 16 / 116)
const [fx, fy, fz] = [f(X), f(Y), f(Z)]
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
}
export const luminance = (hex) => {
const [r, g, b] = srgb(hex).map(toLinear)
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)
}
/* CIEDE2000. Sharma, Wu & Dalal's formulation. */
export function deltaE00(hexA, hexB) {
const [L1, a1, b1] = lab(hexA)
const [L2, a2, b2] = lab(hexB)
const rad = Math.PI / 180
const C1 = Math.hypot(a1, b1)
const C2 = Math.hypot(a2, b2)
const Cb = (C1 + C2) / 2
const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)))
const ap1 = (1 + G) * a1
const ap2 = (1 + G) * a2
const Cp1 = Math.hypot(ap1, b1)
const Cp2 = Math.hypot(ap2, b2)
const hp = (b, ap) => {
if (b === 0 && ap === 0) return 0
const h = Math.atan2(b, ap) / rad
return h < 0 ? h + 360 : h
}
const hp1 = hp(b1, ap1)
const hp2 = hp(b2, ap2)
const dL = L2 - L1
const dC = Cp2 - Cp1
let dh = 0
if (Cp1 * Cp2 !== 0) {
dh = hp2 - hp1
if (dh > 180) dh -= 360
else if (dh < -180) dh += 360
}
const dH = 2 * Math.sqrt(Cp1 * Cp2) * Math.sin((dh * rad) / 2)
const Lb = (L1 + L2) / 2
const Cpb = (Cp1 + Cp2) / 2
let hpb
if (Cp1 * Cp2 === 0) hpb = hp1 + hp2
else if (Math.abs(hp1 - hp2) <= 180) hpb = (hp1 + hp2) / 2
else hpb = hp1 + hp2 < 360 ? (hp1 + hp2 + 360) / 2 : (hp1 + hp2 - 360) / 2
const T =
1 -
0.17 * Math.cos((hpb - 30) * rad) +
0.24 * Math.cos(2 * hpb * rad) +
0.32 * Math.cos((3 * hpb + 6) * rad) -
0.2 * Math.cos((4 * hpb - 63) * rad)
const dTheta = 30 * Math.exp(-(((hpb - 275) / 25) ** 2))
const Rc = 2 * Math.sqrt(Cpb ** 7 / (Cpb ** 7 + 25 ** 7))
const Sl = 1 + (0.015 * (Lb - 50) ** 2) / Math.sqrt(20 + (Lb - 50) ** 2)
const Sc = 1 + 0.045 * Cpb
const Sh = 1 + 0.015 * Cpb * T
const Rt = -Math.sin(2 * dTheta * rad) * Rc
return Math.sqrt((dL / Sl) ** 2 + (dC / Sc) ** 2 + (dH / Sh) ** 2 + Rt * (dC / Sc) * (dH / Sh))
}
/* Machado, Oliveira & Fernandes 2009, severity 1.0, on linear RGB. */
const CVD = {
protanopia: [
[0.152286, 1.052583, -0.204868],
[0.114503, 0.786281, 0.099216],
[-0.003882, -0.048116, 1.051998],
],
deuteranopia: [
[0.367322, 0.860646, -0.227968],
[0.280085, 0.672501, 0.047413],
[-0.01182, 0.04294, 0.968881],
],
}
export function simulate(hex, kind) {
if (!kind) return hex
const m = CVD[kind]
const lin = srgb(hex).map(toLinear)
const out = m.map((row) => row.reduce((s, k, i) => s + k * lin[i], 0))
return (
'#' +
out
.map((c) => Math.round(Math.min(1, Math.max(0, fromLinear(c))) * 255).toString(16).padStart(2, '0'))
.join('')
)
}
// ── The report ───────────────────────────────────────────────────────────────
const NAME = {
'ch-total': 'Total', 'ch-ok': 'No Error', 'ch-fail': 'Server Failure',
'ch-nx': 'NX Domain', 'ch-refuse': 'Refused', 'ch-auth': 'Authoritative',
'ch-rec': 'Recursive', 'ch-cache': 'Cached', 'ch-block': 'Blocked',
'ch-drop': 'Dropped', 'ch-clients': 'Clients',
}
const label = (k) => NAME[k] ?? k
export function pairs(palette, keys, vision) {
const present = keys.filter((k) => palette[k])
const out = []
for (let i = 0; i < present.length; i++)
for (let j = i + 1; j < present.length; j++) {
const [a, b] = [present[i], present[j]]
out.push({ a, b, d: deltaE00(simulate(palette[a], vision), simulate(palette[b], vision)) })
}
return out.sort((x, y) => x.d - y.d)
}
export const VISIONS = [null, 'deuteranopia', 'protanopia']
function report(title, palette, ui) {
console.log(`\n${'='.repeat(72)}\n${title}\n${'='.repeat(72)}`)
for (const [chart, keys] of Object.entries(CHARTS)) {
console.log(`\n── ${chart} ──`)
for (const vision of VISIONS) {
const p = pairs(palette, keys, vision)
if (!p.length) continue
const bad = p.filter((x) => x.d < 15)
const head = vision ?? 'normal vision'
if (!bad.length) {
console.log(` ${head.padEnd(14)} worst pair ΔE00 ${p[0].d.toFixed(1)} ${label(p[0].a)} / ${label(p[0].b)}: clear`)
} else {
console.log(` ${head}`)
for (const x of bad)
console.log(
` ΔE00 ${x.d.toFixed(1).padStart(5)} ${x.d < 10 ? 'COLLISION' : 'at risk '} ${label(x.a)} ${palette[x.a]} / ${label(x.b)} ${palette[x.b]}`,
)
}
}
}
console.log('\n── a series the colour of the interface ──')
const clashes = uiClashes(palette, ui)
for (const c of clashes)
console.log(` ΔE00 ${c.d.toFixed(1).padStart(5)} ${c.d < 1 ? 'IDENTICAL' : 'COLLISION'} ${label(c.k)} ${palette[c.k]} / --${c.t} ${ui[c.t]}`)
if (!clashes.length) console.log(' none under ΔE00 10')
console.log(`\n── contrast on the panel (WCAG 1.4.11 wants 3:1) ──`)
const thin = thinSeries(palette, ui.pan)
if (!thin.length) console.log(` every series ≥ 3:1 on --pan ${ui.pan}`)
else for (const k of thin) console.log(` ${contrast(palette[k], ui.pan).toFixed(2)}:1 ${label(k)} ${palette[k]}`)
}
const seriesOf = (palette) => [...new Set(Object.values(CHARTS).flat())].filter((k) => palette[k])
/** The series under ΔE00 10 from a text or line token of the same theme. */
export function uiClashes(palette, ui) {
const out = []
for (const k of seriesOf(palette))
for (const t of UI) {
if (!ui[t]) continue
const d = deltaE00(palette[k], ui[t])
if (d < 10) out.push({ k, t, d })
}
return out
}
/** The series under 3:1 against the panel they are drawn on. */
export function thinSeries(palette, pan) {
return seriesOf(palette).filter((k) => contrast(palette[k], pan) < 3)
}
/*
Every pair, chart and vision where `light` separates less than `dark` AND is
under 15. Pairs that do not share a chart are not compared, as everywhere else.
*/
export function regressions(dark, light) {
const out = []
for (const [chart, keys] of Object.entries(CHARTS))
for (const vision of VISIONS) {
const before = new Map(pairs(dark, keys, vision).map((x) => [`${x.a}|${x.b}`, x.d]))
for (const x of pairs(light, keys, vision)) {
const was = before.get(`${x.a}|${x.b}`)
if (x.d < 15 && was != null && x.d < was) out.push({ chart, vision, a: x.a, b: x.b, dark: was, light: x.d })
}
}
return out
}
const uiOf = (p) => ({ ink: p.ink, mute: p.mute, faint: p.faint, line: p.line, pan: p.pan })
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const { dark, light } = codePalettes()
report('CODE, dark: src/theme/tokens.css', dark, uiOf(dark))
report('CODE, light: src/theme/tokens.css', light, uiOf(light))
report('DRAWN: the Dashboard design drawing', DRAWN, uiOf(dark))
report('PROPOSED: the drawing with the two identities corrected', PROPOSED, uiOf(dark))
console.log(`\n${'='.repeat(72)}\nWhere the two disagree\n${'='.repeat(72)}`)
for (const k of Object.keys(NAME)) {
if (dark[k] !== DRAWN[k]) console.log(` ${label(k).padEnd(15)} code ${dark[k]} drawn ${DRAWN[k]}`)
}
console.log(`\n${'='.repeat(72)}\nLight against dark, pair by pair\n${'='.repeat(72)}`)
const worse = regressions(dark, light)
if (!worse.length) console.log('\n no pair under 15 separates less in light than in dark')
for (const r of worse)
console.log(` ${r.chart} · ${r.vision ?? 'normal'}: ${label(r.a)} / ${label(r.b)} dark ${r.dark.toFixed(1)} → light ${r.light.toFixed(1)}`)
const failures = [
...seriesOf(dark).filter((k) => !light[k]).map((k) => `light: ${label(k)} is not declared`),
...thinSeries(dark, dark.pan).map((k) => `dark: ${label(k)} under 3:1 on its panel`),
...thinSeries(light, light.pan).map((k) => `light: ${label(k)} under 3:1 on its panel`),
...uiClashes(light, uiOf(light)).map((c) => `light: ${label(c.k)} is ΔE00 ${c.d.toFixed(1)} from --${c.t}`),
...worse.map((r) => `light: ${label(r.a)} / ${label(r.b)} separates less than in dark (${r.chart}, ${r.vision ?? 'normal'})`),
]
console.log(`\n${'='.repeat(72)}\nVerdict\n${'='.repeat(72)}`)
if (!failures.length) console.log('\n dark and light both pass\n')
else {
for (const f of failures) console.log(` FAIL ${f}`)
console.log('')
process.exitCode = 1
}
}