// SPDX-FileCopyrightText: 2025-2026 Kat Suricata
// SPDX-License-Identifier: (CC-BY-4.0+ OR Apache-2.0+)
/*
* Checks the declared colour pairs in both modes against the WCAG floors.
*
* Run it with `pnpm contrast`. A pair states a foreground role, a background
* role, and the floor the pair must clear. Text needs 4.5:1. A component
* boundary, an icon, or a focus ring needs 3:1.
*
* A role value that holds alpha is composited over the surface being tested,
* which is what the browser does.
*/
import { color, roleValue, type ModeId } from '../src/lib/palette.ts';
import { readFileSync } from 'node:fs';
const PORT_DIR = new URL('../static/ports/', import.meta.url);
type Mode = ModeId;
const MODES: Mode[] = ['dark', 'safe-mode'];
const hexToRgb = (hex: string): [number, number, number] => {
const value = parseInt(hex.slice(1), 16);
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
};
const rgbToHex = (rgb: [number, number, number]): string =>
`#${rgb.map((channel) => channel.toString(16).toUpperCase().padStart(2, '0')).join('')}`;
/** Resolves a role value to a literal colour. `over` composites an alpha value. */
function resolve(value: string, over: string): string {
const reference = /^var\(--([a-z0-9-]+)\)$/.exec(value);
if (reference) {
const name = reference[1];
try {
return color(name).hex;
} catch {
return resolve(roleValue(name, currentMode), over);
}
}
const alpha = /^rgba?\(([^)]+)\)$/.exec(value);
if (alpha) {
const parts = alpha[1].split(',').map((part) => Number(part.trim()));
const [r, g, b] = parts;
const a = parts[3] ?? 1;
const base = hexToRgb(over);
return rgbToHex([
Math.round(r * a + base[0] * (1 - a)),
Math.round(g * a + base[1] * (1 - a)),
Math.round(b * a + base[2] * (1 - a))
]);
}
return value;
}
// The resolver needs the mode for a role-to-role reference. It is set per pass.
let currentMode: Mode = 'dark';
function luminance(hex: string): number {
return hexToRgb(hex)
.map((channel) => channel / 255)
.map((channel) =>
channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
)
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
}
function contrast(a: string, b: string): number {
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (high + 0.05) / (low + 0.05);
}
interface Pair {
fg: string;
bg: string;
need: number;
note?: string;
}
const surfaces = ['dr-surface-base', 'dr-surface-secondary', 'dr-surface-elevated'];
const textRoles = [
'dr-text-primary',
'dr-text-secondary',
'dr-text-quiet',
'dr-text-muted',
'dr-text-link',
'dr-text-link-hover',
'dr-text-link-visited',
'dr-heading',
'dr-heading-sub',
'dr-success',
'dr-warning',
'dr-danger-text',
'dr-info'
];
const syntaxRoles = [
'dr-syntax-keyword',
'dr-syntax-function',
'dr-syntax-constant',
'dr-syntax-builtin',
'dr-syntax-string',
'dr-syntax-number',
'dr-syntax-type',
'dr-syntax-comment',
'dr-syntax-operator',
'dr-syntax-punctuation',
'dr-syntax-blue',
'dr-syntax-magenta',
'dr-syntax-error'
];
const termRoles = [
'dr-term-red',
'dr-term-green',
'dr-term-yellow',
'dr-term-blue',
'dr-term-magenta',
'dr-term-cyan',
'dr-term-bright-red',
'dr-term-bright-green',
'dr-term-bright-yellow',
'dr-term-bright-blue',
'dr-term-bright-magenta',
'dr-term-bright-cyan',
'dr-term-bright-black'
];
const onHotRoles = [
'dr-on-hot-success',
'dr-on-hot-warning',
'dr-on-hot-danger',
'dr-on-hot-info',
'dr-on-hot-text',
'dr-on-hot-text-muted'
];
const tints: [string, string][] = [
['dr-success', 'memory-leek-15'],
['dr-info', 'dead-thread-15'],
['dr-danger-text', 'amber-alert-10'],
['dr-warning', 'stack-overglow-15']
];
const pairs: Pair[] = [
...textRoles.flatMap((fg) => surfaces.map((bg) => ({ fg, bg, need: 4.5 }))),
...syntaxRoles.flatMap((fg) => [
{ fg, bg: 'dr-surface-base', need: 4.5, note: 'syntax lives in a well' },
{ fg, bg: 'dr-surface-secondary', need: 4.5, note: 'and on a secondary panel' }
]),
...termRoles.map((fg) => ({ fg, bg: 'dr-term-bg', need: 4.5 })),
// The paper slot is the page colour itself, so it is measured like the
// background it sits on rather than as ink.
{ fg: 'dr-term-bright-white', bg: 'dr-term-bg', need: 1, note: 'the paper slot' },
...onHotRoles.map((fg) => ({ fg, bg: 'dr-surface-hot', need: 4.5 })),
...tints.map(([fg, bg]) => ({ fg, bg, need: 4.5, note: 'badge wash' })),
{ fg: 'dr-text-on-accent', bg: 'dr-accent', need: 4.5 },
{
fg: 'dr-term-bg',
bg: 'dr-cursor',
need: 4.5,
note: 'the cursor carries the terminal background colour'
},
...['dr-surface-base', 'dr-surface-secondary', 'dr-surface-elevated'].flatMap((bg) => [
{ fg: 'dr-border-color', bg, need: 3, note: 'control wall' },
{ fg: 'dr-border-color-hover', bg, need: 3, note: 'control wall on hover' },
{ fg: 'dr-border-color-invalid', bg, need: 3, note: 'invalid control' }
]),
{ fg: 'dr-scrollbar-thumb', bg: 'dr-surface-base', need: 3 },
{ fg: 'dr-scrollbar-thumb-hover', bg: 'dr-surface-base', need: 3 },
{ fg: 'dr-machined-fill', bg: 'dr-machined-track', need: 3, note: 'toggle thumb on the track' },
{ fg: 'dr-machined-fill', bg: 'dr-machined-rail', need: 3, note: 'slider thumb on the rail' },
{ fg: 'dr-editor-find-match-border', bg: 'dr-surface-base', need: 3 }
];
const focusRing = ['dr-surface-base', 'dr-surface-secondary', 'dr-surface-elevated'].map((bg) => ({
fg: 'dr-focus-color',
bg,
need: 3,
note: 'focus ring'
}));
/*
* Pairs that sit below a floor on purpose, with the reason. Safe Mode is the
* accessibility option, so a failure here fails the check. The dark theme has
* deviations that are recorded in the syntax specification, so a failure there
* is reported and does not fail the check.
*/
const known: Pair[] = [
...['dr-surface-base', 'dr-surface-secondary', 'dr-surface-elevated'].flatMap((bg) => [
{
fg: 'dr-border-color-container',
bg,
need: 3,
note: 'container wall: content identifies a container, so the wall stays recessed'
},
{ fg: 'dr-border-color-strong', bg, need: 3, note: 'container wall on hover' }
]),
{
fg: 'dr-syntax-magenta',
bg: 'dr-surface-secondary',
need: 4.5,
note: 'rainbow brackets are an optional feature and render in a well, where the pair passes'
}
];
const knownKeys = new Set(known.map((pair) => `${pair.fg}|${pair.bg}`));
let failures = 0;
for (const mode of MODES) {
currentMode = mode;
console.log(`\n${mode}`);
const check = (pair: Pair) => {
const bg = resolve(roleValue(pair.bg, mode), 'transparent');
const fg = resolve(roleValue(pair.fg, mode), bg);
const ratio = contrast(fg, bg);
const ok = ratio >= pair.need;
return { ...pair, fg, bg, ratio, ok };
};
// Safe Mode checks every pair. The dark theme has recorded deviations, and
// those are reported apart from the result.
const rows = [...pairs, ...focusRing]
.filter((pair) => mode === 'safe-mode' || !knownKeys.has(`${pair.fg}|${pair.bg}`))
.map(check);
const bad = rows.filter((row) => !row.ok);
failures += bad.length;
for (const row of bad) {
console.log(` FAIL ${row.ratio.toFixed(2)}:1 ${row.fg} on ${row.bg} (need ${row.need})`);
}
console.log(` ${rows.length - bad.length}/${rows.length} pairs pass, floor 3:1 or 4.5:1`);
const deviations = known.map(check).filter((row) => !row.ok);
if (deviations.length > 0) {
console.log(' recorded deviations from earlier versions:');
for (const row of deviations) {
console.log(` ${row.ratio.toFixed(2)}:1 ${row.fg} on ${row.bg} ${row.note ?? ''}`);
}
}
}
/*
* The ports hardcode the same hexes in their own formats, so this reads the
* pairs straight out of each file and measures them against the same floors.
* The ports have not had their compliance pass yet: a failure here is the
* backlog, not a regression.
*/
interface PortPair {
where: string;
label: string;
fg: string;
bg: string;
need: number;
}
type Slots = Map;
const readPort = (path: string): string => readFileSync(new URL(path, PORT_DIR), 'utf8');
/** A colour is either `#RRGGBB` or the `r,g,b` triple the KDE formats use. */
function portColor(value: string): string | null {
const trimmed = value.trim();
const hex = /^#?([0-9a-f]{6})$/i.exec(trimmed);
if (hex) return `#${hex[1].toUpperCase()}`;
const triple = trimmed.split(',').map((part) => Number(part.trim()));
if (triple.length === 3 && triple.every((n) => Number.isInteger(n) && n >= 0 && n <= 255)) {
return rgbToHex(triple as [number, number, number]);
}
return null;
}
/** Sections of an INI-shaped file, with `r,g,b` values normalised to hex. */
function iniSections(text: string): Map> {
const sections = new Map>();
let current = '';
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const header = /^\[(.+)\]$/.exec(trimmed);
if (header) {
current = header[1];
sections.set(current, new Map());
continue;
}
const entry = /^([^=]+)=(.*)$/.exec(trimmed);
if (entry && current) {
sections.get(current)?.set(entry[1].trim(), portColor(entry[2]) ?? entry[2].trim());
}
}
return sections;
}
/**
* Reads `ColorN` keys out of one section. A slot that carries the paper colour
* of its mode — slot 0 on a dark page, slots 7 and 15 on a light one — is the
* background rather than ink, so it is skipped. Every other mode finds that slot
* readable, which is the point of the pairing.
*/
function slotsFrom(section: Map | undefined, prefix = 'Color'): Slots {
const slots: Slots = new Map();
for (const [key, value] of section ?? []) {
const match = new RegExp(`^${prefix}(\\d+)$`).exec(key);
if (match && portColor(value)) slots.set(Number(match[1]), value);
}
return slots;
}
function againstSlots(where: string, slots: Slots, bg: string): PortPair[] {
const pairs: PortPair[] = [];
const paper = hexToRgb(bg).reduce((sum, channel) => sum + channel, 0) / 3 > 127 ? [7, 15] : [0];
for (const [slot, hex] of [...slots.entries()].sort((a, b) => a[0] - b[0])) {
if (paper.includes(slot)) continue;
pairs.push({ where, label: `slot ${slot}`, fg: hex, bg, need: 4.5 });
}
return pairs;
}
function terminalPorts(): PortPair[] {
const pairs: PortPair[] = [];
// Konsole and Ptyxis share the INI shape. Konsole names the variants Faint
// and Intense; both are text and both are measured.
for (const [file, where] of [
['DigitalRustKonsole.colorscheme', 'konsole'],
['DigitalRustKonsole-SafeMode.colorscheme', 'konsole safe mode']
]) {
const konsole = iniSections(readPort(file));
const konsoleBg = portColor(konsole.get('Background')?.get('Color') ?? '');
if (!konsoleBg) continue;
const foreground = portColor(konsole.get('Foreground')?.get('Color') ?? '');
if (foreground) {
pairs.push({ where, label: 'foreground', fg: foreground, bg: konsoleBg, need: 4.5 });
}
// Konsole carries slots 0 to 7 plus Faint and Intense variants, so the
// Intense variant is the bright half. Every variant is text.
for (const variant of ['', 'Faint', 'Intense']) {
const merged: Slots = new Map();
for (const [name, section] of konsole) {
const match = new RegExp(`^Color(\\d+)${variant}$`).exec(name);
if (!match) continue;
const hex = portColor(section.get('Color') ?? '');
if (hex) merged.set(Number(match[1]), hex);
}
const label = variant === '' ? 'slot' : `slot, ${variant.toLowerCase()}`;
pairs.push(...againstSlots(`${where} ${label}`, merged, konsoleBg));
}
}
const ptyxis = iniSections(readPort('DigitalRustPtyxis.palette'));
for (const [section, values] of ptyxis) {
const bg = portColor(values.get('Background') ?? '');
if (!bg) continue;
const foreground = portColor(values.get('Foreground') ?? '');
const where = `ptyxis ${section.toLowerCase()}`;
if (foreground) pairs.push({ where, label: 'foreground', fg: foreground, bg, need: 4.5 });
pairs.push(...againstSlots(where, slotsFrom(values), bg));
}
for (const [file, where] of [
['digital-rust-ghostty', 'ghostty'],
['digital-rust-ghostty-safe-mode', 'ghostty safe mode']
]) {
const ghostty = new Map();
const ghosttySlots: Slots = new Map();
for (const line of readPort(file).split('\n')) {
const slot = /^\s*palette\s*=\s*(\d+)\s*=\s*(#?[0-9a-f]{6})/i.exec(line);
if (slot) {
const hex = portColor(slot[2]);
if (hex && Number(slot[1]) !== 0) ghosttySlots.set(Number(slot[1]), hex);
continue;
}
const entry = /^\s*([a-z-]+)\s*=\s*(#?[0-9a-f]{6})/i.exec(line);
if (entry) {
const hex = portColor(entry[2]);
if (hex) ghostty.set(entry[1], hex);
}
}
if (ghostty.has('background')) {
const bg = ghostty.get('background') as string;
if (ghostty.has('foreground')) {
pairs.push({
where,
label: 'foreground',
fg: ghostty.get('foreground') as string,
bg,
need: 4.5
});
}
pairs.push(...againstSlots(where, ghosttySlots, bg));
}
}
// fish names its colours instead of numbering them. Every one of them is
// text, and the preferred background sits in a comment.
const fish = readPort('DigitalRustfish.theme');
const fishBg = portColor(/#\s*preferred_background:\s*([0-9a-f]{6})/i.exec(fish)?.[1] ?? '');
if (fishBg) {
for (const line of fish.split('\n')) {
const entry = /^fish_color_(\w+)\s+([0-9a-f]{6})/i.exec(line.trim());
const hex = entry ? portColor(entry[2]) : null;
if (entry && hex) {
pairs.push({ where: 'fish', label: entry[1], fg: hex, bg: fishBg, need: 4.5 });
}
}
}
return pairs;
}
function kdePort(): PortPair[] {
const pairs: PortPair[] = [];
const sections = iniSections(readPort('DigitalRustKDE.colors'));
for (const [section, values] of sections) {
if (!section.startsWith('Colors:')) continue;
const bg = portColor(values.get('BackgroundNormal') ?? '');
if (!bg) continue;
for (const key of [
'ForegroundNormal',
'ForegroundActive',
'ForegroundLink',
'ForegroundVisited',
'ForegroundNegative',
'ForegroundNeutral',
'ForegroundPositive'
]) {
const hex = portColor(values.get(key) ?? '');
if (hex) {
pairs.push({
where: `kde ${section.replace('Colors:', '').toLowerCase()}`,
label: key.replace('Foreground', ''),
fg: hex,
bg,
need: 4.5
});
}
}
}
return pairs;
}
/** Kate pairs a text colour with a background colour inside each style. */
function katePort(): PortPair[] {
const pairs: PortPair[] = [];
const theme = JSON.parse(readPort('DigitalRustKate.theme')) as Record;
const walk = (node: unknown, path: string): void => {
if (!node || typeof node !== 'object') return;
const record = node as Record;
const fg = portColor(String(record['text-color'] ?? ''));
const bg = portColor(String(record['background-color'] ?? ''));
if (fg && bg) pairs.push({ where: 'kate', label: path, fg, bg, need: 4.5 });
for (const [key, value] of Object.entries(record)) {
if (value && typeof value === 'object')
walk(value, path === '' ? key : `${path} · ${key}`);
}
};
for (const [key, value] of Object.entries(theme)) walk(value, key);
return pairs;
}
function vscodePort(): PortPair[] {
const pairs: PortPair[] = [];
const theme = JSON.parse(readPort('vscode/themes/Digital Rust-color-theme.json')) as {
colors: Record;
tokenColors: { name?: string; settings?: { foreground?: string } }[];
};
const editorBg = portColor(theme.colors['editor.background'] ?? '');
if (!editorBg) return pairs;
const textKeys = [
'editor.foreground',
'editorLineNumber.foreground',
'editorLineNumber.activeForeground',
'editorInfo.foreground',
'editorHint.foreground',
'errorForeground',
'foreground'
];
for (const key of textKeys) {
const hex = portColor(theme.colors[key] ?? '');
if (hex) pairs.push({ where: 'vscode', label: key, fg: hex, bg: editorBg, need: 4.5 });
}
const cursor = portColor(theme.colors['editorCursor.foreground'] ?? '');
if (cursor) pairs.push({ where: 'vscode', label: 'cursor', fg: cursor, bg: editorBg, need: 3 });
// The terminal slots live in the same file as the editor colours.
const terminalBg = portColor(
theme.colors['terminal.background'] ?? theme.colors['editor.background'] ?? ''
);
const seen = new Set();
for (const [key, value] of Object.entries(theme.colors)) {
if (!key.startsWith('terminal.ansi')) continue;
if (key === 'terminal.ansiBlack') continue;
const hex = portColor(value);
if (!hex || !terminalBg || seen.has(hex)) continue;
seen.add(hex);
pairs.push({
where: 'vscode',
label: key.replace('terminal.ansi', 'ansi '),
fg: hex,
bg: terminalBg,
need: 4.5
});
}
for (const entry of theme.tokenColors) {
const name = entry.name ?? 'token';
// A rule, a guide or a whitespace marker is decoration, not text, so it is
// out of scope for the text floor.
if (/rule|whitespace|guide|bracket|separator|indent/i.test(name)) continue;
const hex = portColor(entry.settings?.foreground ?? '');
if (!hex || seen.has(hex)) continue;
seen.add(hex);
pairs.push({
where: 'vscode',
label: name,
fg: hex,
bg: editorBg,
need: 4.5
});
}
// Status bar items paint ink on their own fill, so the fill is the background
// for the pair rather than the editor.
for (const [inkKey, fillKey] of [
['statusBarItem.errorForeground', 'statusBarItem.errorBackground'],
['statusBarItem.warningForeground', 'statusBarItem.warningBackground'],
['statusBarItem.prominentForeground', 'statusBarItem.prominentBackground']
]) {
const ink = portColor(theme.colors[inkKey] ?? '');
const fill = portColor(theme.colors[fillKey] ?? '');
if (ink && fill)
pairs.push({ where: 'vscode', label: inkKey, fg: ink, bg: fill, need: 4.5 });
}
return pairs;
}
/** The pi port resolves its colour names through a vars block. */
function piPort(): PortPair[] {
const pairs: PortPair[] = [];
const port = JSON.parse(readPort('digitalrustpi/digital-rust.json')) as {
vars: Record;
colors: Record;
export: Record;
};
const resolvePi = (name: string): string | null => {
const raw = port.colors[name] ?? port.export[name] ?? name;
return portColor(port.vars[raw] ?? raw);
};
const bg = resolvePi('pageBg');
if (!bg) return pairs;
const text = ['text', 'muted', 'dim', 'thinkingText', 'error', 'warning', 'success'];
const accents = ['accent', 'toolTitle', 'customMessageLabel'];
for (const key of text) {
const hex = resolvePi(key);
if (hex) pairs.push({ where: 'pi', label: key, fg: hex, bg, need: 4.5 });
}
for (const key of accents) {
const hex = resolvePi(key);
if (hex) pairs.push({ where: 'pi', label: key, fg: hex, bg, need: 3 });
}
return pairs;
}
const portPairs: PortPair[] = [
...terminalPorts(),
...kdePort(),
...katePort(),
...vscodePort(),
...piPort()
];
console.log('\nports');
const seenPorts = new Set();
const portFailures = portPairs.filter((pair) => contrast(pair.fg, pair.bg) < pair.need);
let portFiles = 0;
for (const pair of portPairs) {
if (!seenPorts.has(pair.where)) {
seenPorts.add(pair.where);
portFiles += 1;
}
}
console.log(
` ${portPairs.length} pairs read from ${portFiles} port files and sections, ${portFailures.length} below the floor`
);
for (const pair of portFailures) {
const ratio = contrast(pair.fg, pair.bg);
console.log(
` FAIL ${ratio.toFixed(2)}:1 ${pair.fg} on ${pair.bg} (need ${pair.need}) ${pair.where} · ${pair.label}`
);
}
if (portFailures.length > 0) {
console.log(' The ports have not had their compliance pass yet.');
}
failures += portFailures.length;
console.log(failures === 0 ? '\nAll declared pairs pass.' : `\n${failures} pairs below the floor.`);
process.exitCode = failures === 0 ? 0 : 1;