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]>
214 lines
8.6 KiB
JavaScript
214 lines
8.6 KiB
JavaScript
/*
|
|
The seven Query Logs row colours, measured where they are really used.
|
|
|
|
It exists because `dev/palette-distance.mjs` measures the SERIES palette (lines
|
|
and chips at full colour) and the row colour code is something else: the same
|
|
token laid as a translucent background behind text. Measuring both proved it:
|
|
with the seven series tokens, the chip separates (0 of 21 pairs below the
|
|
threshold) and **the row does not** (5 of 21). Measuring one surface and deciding
|
|
about the other is the mistake this file prevents.
|
|
|
|
Since 2026-10-15 that background is no longer the whole row: it is the pill
|
|
around the Response Type (`.tone` in screens/logs/Logs.module.css), at the same
|
|
30 % over the same panel, so the three checks below measure it unchanged.
|
|
|
|
The three checks, and all three have to pass at once:
|
|
|
|
· ΔE00 between pairs as a BACKGROUND, at the real opacity, over the panel.
|
|
· Contrast of the cell text over the lightest background: AA asks for 4.5:1.
|
|
· Contrast of the LINE over the panel: WCAG 1.4.11 asks for 3:1 for a non-text
|
|
graphical object, and it is the one that sank the first proposed tweak:
|
|
darkening the cyan fixed deuteranopia and left the line at 2.35:1.
|
|
|
|
node dev/row-colours.mjs
|
|
|
|
Measured on 2026-09-03. The value that passes all three is `tok('ch-refuse')`
|
|
(teal-600): the HUE has to move as well as the lightness, because no cyan passes
|
|
all three.
|
|
*/
|
|
import { readFileSync } from 'node:fs'
|
|
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)]
|
|
}
|
|
|
|
const luminance = (hex) => {
|
|
const [r, g, b] = srgb(hex).map(toLinear)
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
}
|
|
|
|
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. */
|
|
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],
|
|
],
|
|
}
|
|
|
|
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
|
|
|
|
|
|
/*
|
|
The seven are READ from `tokens.css`, not written here.
|
|
|
|
Written by hand, this tool would say one thing and the product would paint another
|
|
as soon as someone touched a token, and that is exactly what happened during the
|
|
`--ch-refuse` tweak: the first version carried the value by hand and kept
|
|
reporting the old one after it changed. A drift tool that can drift is no use.
|
|
*/
|
|
const CSS = readFileSync(new URL('../src/theme/tokens.css', import.meta.url), 'utf8')
|
|
const tok = (n) => {
|
|
const m = new RegExp(`--${n}:\\s*(#[0-9a-fA-F]{6})`).exec(CSS)
|
|
if (m == null) throw new Error(`token --${n} is missing from tokens.css`)
|
|
return m[1].toLowerCase()
|
|
}
|
|
|
|
const PAN = tok('pan')
|
|
const INK = tok('ink')
|
|
const mix = (h, a) => '#' + [1, 3, 5].map((i) =>
|
|
Math.round(parseInt(h.slice(i, i + 2), 16) * a + parseInt(PAN.slice(i, i + 2), 16) * (1 - a))
|
|
.toString(16).padStart(2, '0')).join('')
|
|
const lum = (h) => {
|
|
const c = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
|
|
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
|
}
|
|
const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05) }
|
|
|
|
/* The seven of the Query Logs colour code, and the four series `Refused` shares
|
|
the Dashboard line chart with. */
|
|
const ROW = {
|
|
'Server Failure': tok('ch-fail'), 'Blocked': tok('ch-block'), 'NX Domain': tok('ch-nx'),
|
|
'Refused': tok('ch-refuse'), 'Authoritative': tok('ch-auth'), 'Recursive': tok('ch-rec'),
|
|
'Cached': tok('ch-cache'),
|
|
}
|
|
const SERIES = {
|
|
...ROW, 'Total': tok('ch-total'), 'No Error': tok('ch-ok'),
|
|
'Dropped': tok('ch-drop'), 'Clients': tok('ch-clients'),
|
|
}
|
|
const ALPHA = 0.30
|
|
const ks = Object.keys(ROW)
|
|
|
|
console.log('tokens read from tokens.css:', JSON.stringify(ROW))
|
|
|
|
console.log(`\n== ROW BACKGROUND at ${ALPHA * 100} % over ${PAN} ==`)
|
|
const pairs = []
|
|
for (let i = 0; i < ks.length; i++) for (let j = i + 1; j < ks.length; j++)
|
|
pairs.push([deltaE00(mix(ROW[ks[i]], ALPHA), mix(ROW[ks[j]], ALPHA)), ks[i], ks[j]])
|
|
pairs.sort((a, b) => a[0] - b[0])
|
|
const bad = pairs.filter((p) => p[0] < 10)
|
|
for (const [d, a, b] of pairs.slice(0, 6))
|
|
console.log(` ${d.toFixed(1).padStart(5)} ${d < 10 ? 'COLLISION' : 'at risk '} ${a} / ${b}`)
|
|
console.log(` --> ${bad.length} of ${pairs.length} below the collision threshold (10)`)
|
|
|
|
const worstText = Math.min(...ks.map((k) => ratio(INK, mix(ROW[k], ALPHA))))
|
|
console.log(`\n== Cell TEXT over the lightest background ==`)
|
|
console.log(` ${worstText.toFixed(1)}:1 ${worstText >= 4.5 ? 'ok (AA)' : 'BELOW 4.5'}`)
|
|
|
|
console.log(`\n== LINE over the panel: WCAG 1.4.11 asks for 3:1 ==`)
|
|
for (const [n, h] of Object.entries(ROW)) {
|
|
const c = ratio(h, PAN)
|
|
console.log(` ${n.padEnd(15)} ${c.toFixed(2).padStart(5)}:1 ${c >= 3 ? 'ok' : 'BELOW'}`)
|
|
}
|
|
|
|
console.log(`\n== THE ELEVEN SERIES, by vision, worst pair of each with Refused ==`)
|
|
for (const v of ['normal', 'protanopia', 'deuteranopia']) {
|
|
const r = Object.entries(SERIES).filter(([n]) => n !== 'Refused').map(([n, h]) => {
|
|
const A = v === 'normal' ? ROW['Refused'] : simulate(ROW['Refused'], v)
|
|
const B = v === 'normal' ? h : simulate(h, v)
|
|
return [deltaE00(A, B), n]
|
|
}).sort((a, b) => a[0] - b[0])
|
|
const [d, n] = r[0]
|
|
console.log(` ${v.padEnd(13)} worst: ${n} ${d.toFixed(1)} ${d < 10 ? 'COLLISION' : d < 15 ? 'at risk' : 'separates'}`)
|
|
}
|