Files
technitium-console/dev/row-colours.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 <noreply@anthropic.com>
2026-10-27 00:06:06 +07:00

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'}`)
}