Something went wrong. Try again.
A warm, tech-dystopian color scheme inspired by corrupted hardware and failing systems. digitalrust.katsuricata.com
theme color-scheme color-theme warm color
Something went wrong. Try again.
20 kB · 595 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596// SPDX-FileCopyrightText: 2025-2026 Kat Suricata <https://KatSuricata.com/>// 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<number, string>;
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<string, Map<string, string>> { const sections = new Map<string, Map<string, string>>(); 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<string, string> | 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<string, string>(); 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<string, unknown>; const walk = (node: unknown, path: string): void => { if (!node || typeof node !== 'object') return; const record = node as Record<string, unknown>; 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<string, string>; 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<string>(); 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<string, string>; colors: Record<string, string>; export: Record<string, string>; }; 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<string>();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;