// 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;