skill
Adom Theme
Public Made by Adomby adom
The canonical Adom application theme spec: five schemes, exact tokens, font pairings, and the implementation contract.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
#!/usr/bin/env node
// Generate the Adom VS Code themes FROM the app's design tokens.
//
// Why this exists (John 2026-07-25: "vscode should feel like its part of the entire
// app, as if they are one consistent app"):
//
// The themes were hand-authored, so they drifted. When the brand sweep corrected the
// Adom teal to #00b8b1, the four themes still carried the old #00b8b0 in 148 places —
// the editor and the app disagreed about the single most brand-critical colour, and
// nothing in the build could notice. Hand-patching those 148 values would fix today
// and re-break on the next token change.
//
// So the themes are no longer a source of colour. `src/routes/styles.css` is the ONLY
// place a hex is decided; this script reads the :root and [data-scheme='kickstand']
// blocks and projects them onto VS Code's colour keys. Change a token, re-run, and the
// editor follows automatically.
//
// The structural guarantee is ASSERT_NO_UNMAPPED below: every colour in the template
// must resolve to a token, or the script exits non-zero. A new hardcoded hex cannot
// sneak into a theme without failing the build — which is exactly the failure mode
// that let #00b8b0 survive the sweep.
//
// node tools/gen-vscode-themes.mjs [outDir]
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const outDir = resolve(process.argv[2] ?? join(root, 'vscode-themes'));
// ---------------------------------------------------------------------------
// 1. Read the canonical tokens out of styles.css
// ---------------------------------------------------------------------------
// Comments are stripped BEFORE parsing. A token comment that mentions another token
// ("/* 11.9:1 on --bg: ... */") otherwise parses as a declaration and yields a garbage value.
// This has now caught two separate parsers, so it is handled at the source rather than by
// keeping token names out of prose.
const css = readFileSync(join(root, 'src/routes/styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
function readTokenBlock(selectorRe) {
const m = css.match(new RegExp(selectorRe.source + String.raw`\s*\{([\s\S]*?)\n\}`, 'm'));
if (!m) throw new Error(`token block not found: ${selectorRe}`);
const out = {};
for (const [, name, value] of m[1].matchAll(/--([a-z0-9-]+):\s*([^;]+);/g)) {
out[name] = value.split('/*')[0].trim();
}
return out;
}
const studio = readTokenBlock(/^:root(?!\[)/);
const kickstand = { ...studio, ...readTokenBlock(/^:root\[data-scheme='kickstand'\]/) };
const slate = { ...studio, ...readTokenBlock(/^:root\[data-scheme='slate'\]/) };
// Bright Inverse is Studio Bright with its two darks traded, so it must spread over
// studioBright, NOT studio -- spreading over studio would silently drop Bright's text
// ramp and make the generated theme disagree with the name and with HD's own CSS.
// It is declared after studioBright below for that reason.
// Studio Bright shares Studio's ground and only overrides text tokens, so it spreads over
// studio rather than standing alone. The brighten flag below does the chrome promotion.
const studioBright = { ...studio, ...readTokenBlock(/^:root\[data-scheme='studio-bright'\]/) };
const studioBrightInverse = {
...studioBright,
// Slug renamed 2026-07-25: the flagship block is [data-scheme='studio'] now. The var
// name keeps its history ("bright inverse") because that is what the scheme IS.
...readTokenBlock(/^:root\[data-scheme='studio'\]/)
};
// Only real hex values are usable here; a token whose value is `var(--other)` has
// already been resolved by the spread above or is a font, not a colour.
const TOKENS = [
'bg', 'surface', 'elevated', 'overlay', 'border',
'text', 'text-body', 'text-2', 'text-3',
'accent', 'accent-hover', 'accent-bright', 'on-accent',
'blue', 'blue-fill', 'purple', 'green', 'red', 'yellow'
];
for (const scheme of [studio, kickstand, slate, studioBright, studioBrightInverse]) {
for (const t of TOKENS) {
if (!/^#[0-9a-f]{6}$/i.test(scheme[t] ?? '')) {
throw new Error(`token --${t} is not a 6-digit hex: ${scheme[t]}`);
}
}
}
// ---------------------------------------------------------------------------
// 2. Map every legacy hex in the template to the token that OWNS that role
// ---------------------------------------------------------------------------
// Keyed by the value the hand-authored themes used. The comment on each line is why
// that role belongs to that token, because the mapping is the actual design decision
// here — everything else is mechanics.
const LEGACY_TO_TOKEN = {
'#00b8b0': 'accent', // the wrong teal, one digit off. THE reason this file exists.
'#00b8b1': 'accent', // already-correct teal
'#00d4cb': 'accent-hover',
'#00e6dc': 'accent-bright',
'#05221f': 'on-accent', // text/icons sitting on a teal fill
'#0d1117': 'bg',
'#161b22': 'surface',
'#1c2128': 'elevated',
'#21262d': 'overlay',
'#30363d': 'border',
'#e6edf3': 'text',
'#c9d1d9': 'text-body',
'#adb6c0': 'text-body', // off-token grey; body copy is what it was doing
'#8b949e': 'text-2',
'#6e7681': 'text-2', // off-token grey at 4.4:1 on --bg, i.e. BELOW AA for
// normal text. It was VS Code's descriptionForeground,
// which is text a person actually reads, so it goes to
// --text-2 (7.0:1) rather than staying decorative.
'#484f58': 'text-3', // 2.3:1 — borders and disabled only, never live text
'#64abff': 'blue', // informational TEXT / links
'#0061ef': 'blue-fill', // brand blue: fills only, fails as text
'#8c6bf7': 'purple',
'#3fb950': 'green',
'#f85149': 'red',
'#d29922': 'yellow',
// --- syntax highlighting -------------------------------------------------
// These six came from GitHub Dark's palette and were the last place the editor
// used colours the app doesn't have. Folding them onto the same semantic tokens
// is the point of the exercise: a keyword in the editor is now literally the same
// red as a destructive action in HD.
//
// Measured on all three grounds before committing to this — every one clears WCAG
// AA, and all but purple clear AAA:
// Studio Kickstand Contrast
// red 5.6:1 5.2:1 6.3:1
// blue 7.9:1 7.4:1 8.8:1
// purple 5.0:1 4.7:1 5.6:1 <- lowest, still above the 4.5 AA floor
// green 7.4:1 6.9:1 8.3:1
// yellow 7.5:1 7.0:1 8.3:1
// They land slightly below the originals (7-10:1), which is the intent: past ~14:1
// dark-mode text haloes, and 5-8:1 is the comfortable band for dense code.
'#ff7b72': 'red', // keywords, deleted lines
'#79c0ff': 'blue', // entities, function names
'#58a6ff': 'blue', // links
'#a78bfa': 'purple', // constants
'#56d364': 'green', // strings, added lines
'#e3b341': 'yellow' // string literals, modified lines
};
const ASSERT_NO_UNMAPPED = true;
/** Re-point one colour string at the given scheme, preserving any alpha suffix. */
function retint(value, scheme, unmapped) {
if (typeof value !== 'string' || !value.startsWith('#')) return value;
const base = value.slice(0, 7).toLowerCase();
const alpha = value.slice(7); // '', '55', 'ff', ...
const token = LEGACY_TO_TOKEN[base];
if (!token) {
unmapped.add(value);
return value;
}
return scheme[token] + alpha;
}
// ---------------------------------------------------------------------------
// Text-brightness tier
// ---------------------------------------------------------------------------
// John 2026-07-25: "make the text brighter in adom studio vscode theme cuz i'm finding it a
// bit hard to read". He was right, and it was a ROLE error, not a taste one.
//
// The template mapped VS Code's PRIMARY chrome text onto --text-2, which this project's own
// design skill defines as "secondary text, labels" at 6.2:1 on --bg. But the file explorer,
// the status bar and the general UI foreground are not secondary text -- they are the main
// content of those surfaces, read constantly. Only descriptions and inactive items are
// genuinely secondary.
//
// So these keys move up to --text-body (12.3:1, the token documented as "the default for
// running text"). Everything NOT listed keeps --text-2, which preserves the hierarchy:
// active > primary > secondary. Brightening all of it would flatten that and read worse.
const PRIMARY_TEXT_KEYS = new Set([
'foreground', // general UI text
'sideBar.foreground', // the file tree: a lot of text, read constantly
'sideBarSectionHeader.foreground',
'statusBar.foreground',
'panel.foreground',
'panelTitle.activeForeground',
'list.foreground',
'list.inactiveSelectionForeground',
'menu.foreground',
'quickInput.foreground',
'editorWidget.foreground',
'input.foreground',
'dropdown.foreground',
'titleBar.activeForeground',
'breadcrumb.foreground',
'terminal.foreground'
]);
// Syntax lift. `keyword` is the one that matters: it is everywhere in code and sat at 5.0:1,
// the lowest ratio in the theme.
//
// Lighten WITHIN the hue rather than swapping to a brighter token. The first attempt mapped
// purple -> accent-bright and red -> yellow, which is wrong twice over: teal is already
// `string`, so keywords and strings would have collided and syntax distinction would collapse;
// and `invalid` must stay RED, because the colour IS the meaning. Raising lightness keeps both
// the hue and the semantics and only changes how legible it is.
const SYNTAX_TARGET_RATIO = 7.0; // AAA, matching what the other syntax colours already clear
function srgbToLin(c) {
c /= 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function relLum(hex) {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return 0.2126 * srgbToLin(r) + 0.7152 * srgbToLin(g) + 0.0722 * srgbToLin(b);
}
function contrastRatio(a, b) {
const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
/** Mix `hex` toward black by `t` (0..1). Twin of lighten(), for surfaces that must RECEDE. */
function darken(hex, t) {
const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const out = ch.map((c) => Math.round(c * (1 - t)));
return '#' + out.map((c) => c.toString(16).padStart(2, '0')).join('');
}
/** Mix `hex` toward white by `t` (0..1), preserving hue far better than scaling channels. */
function lighten(hex, t) {
const ch = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const out = ch.map((c) => Math.round(c + (255 - c) * t));
return '#' + out.map((c) => c.toString(16).padStart(2, '0')).join('');
}
/** Smallest lightening of `hex` that reaches the target ratio on `bg`. Returns hex unchanged
* if it already passes, so a colour that is fine is never touched. */
function liftToRatio(hex, bg, target = SYNTAX_TARGET_RATIO) {
if (contrastRatio(hex, bg) >= target) return hex;
for (let t = 0.05; t <= 1.0001; t += 0.05) {
const c = lighten(hex, t);
if (contrastRatio(c, bg) >= target) return c;
}
return '#ffffff';
}
function retintTheme(template, scheme, name, unmapped, brighten = false) {
const out = { ...template, name, colors: {}, tokenColors: [] };
for (const [k, v] of Object.entries(template.colors)) {
let value = retint(v, scheme, unmapped);
// Promote only where the ROLE is primary, and only when the template had it at the
// secondary token — never override a key that was already emphasis-level.
if (brighten && PRIMARY_TEXT_KEYS.has(k) && value.slice(0, 7) === scheme['text-2']) {
value = scheme['text-body'] + value.slice(7);
}
out.colors[k] = value;
}
for (const tc of template.tokenColors) {
const settings = { ...tc.settings };
for (const k of ['foreground', 'background']) {
if (settings[k]) settings[k] = retint(settings[k], scheme, unmapped);
}
if (brighten && settings.foreground) {
const base = settings.foreground.slice(0, 7);
// Comments are the one place --text-2 is CORRECT: they are meant to recede, so
// they are exempt. Everything else gets lifted to the target only if it is short.
if (base.toLowerCase() !== scheme['text-2'].toLowerCase()) {
settings.foreground = liftToRatio(base, scheme.bg) + settings.foreground.slice(7);
}
}
out.tokenColors.push({ ...tc, settings });
}
return out;
}
// ---------------------------------------------------------------------------
// Surface-distinction law (John 2026-07-27: "my rule stands for all themes",
// and "Dark 2026 ... that's generally the approach we should learn from")
// ---------------------------------------------------------------------------
// The reference is VS Code's own 2026-dark theme, extracted from this exact
// code-server build (editor #121314):
// - tab.activeBackground == editor.background ("that tab goes to THIS content")
// - ONE chrome surface, LIGHTER than the ground (#191A1B): inactive tabs, the
// tab strip, sideBar, activityBar, panel, statusBar, titleBar, inputs, dropdowns
// - overlays one step lighter again (#202122): menus, quick input, hover widgets
// Projected onto the app ladder (bg < surface < elevated < overlay) that is:
// ground=bg, chrome=surface, overlays=overlay — which the DEFAULT scheme already
// satisfies with pure tokens. The law exists for schemes whose ladder collapses
// (Studio's bg token IS the body color): each relationship is verified per scheme
// and repaired by climbing to the next distinct token, or a computed shade when
// no token qualifies. Values already compliant are never touched.
// 1.09:1 is the app's own bg→surface step (#0d1117 vs #161b22 measures 1.10),
// the smallest delta the design system treats as "a different surface".
const SURFACE_MIN = 1.09;
function stepUntil(hex, ref, minRatio, dir) {
if (contrastRatio(hex, ref) >= minRatio) return hex;
for (let t = 0.04; t <= 1.0001; t += 0.04) {
const c = dir === 'darker' ? darken(hex, t) : lighten(hex, t);
if (contrastRatio(c, ref) >= minRatio) return c;
}
return null; // no headroom in this direction — caller flips direction
}
/** A surface `minRatio` LIGHTER than `ref` (2026 chrome/overlay direction), snapping
* to the nearest qualifying scheme token so the palette stays in-family; computed
* shade only when no token qualifies; darker only if lighter has no headroom. */
function resolveAbove(base, ref, minRatio, scheme) {
if (relLum(base) > relLum(ref) && contrastRatio(base, ref) >= minRatio) return base;
for (const d of ['lighter', 'darker']) {
const refL = relLum(ref);
const sameSide = (h) => (d === 'darker' ? relLum(h) < refL : relLum(h) > refL);
const toks = ['surface', 'elevated', 'overlay', 'bg']
.map((t) => scheme[t])
.filter((h) => h && sameSide(h) && contrastRatio(h, ref) >= minRatio)
.sort((a, b) => contrastRatio(a, ref) - contrastRatio(b, ref));
if (toks.length) return toks[0];
const c = stepUntil(base, ref, minRatio, d);
if (c) return c;
}
return base;
}
const CHROME_KEYS = [
'tab.inactiveBackground', 'editorGroupHeader.tabsBackground',
'sideBar.background', 'input.background', 'dropdown.background'
];
const OVERLAY_KEYS = [
'editorWidget.background', 'quickInput.background',
'editorHoverWidget.background', 'menu.background'
];
function enforceSurfaceDistinction(colors, label, scheme) {
const body = colors['editor.background'];
const fixes = [];
const fix = (key, next) => {
if (colors[key] !== next) { fixes.push(`${key} ${colors[key] ?? '(unset)'} -> ${next}`); colors[key] = next; }
};
// 1. Active tab IS the content ground (2026). The accent top border the
// template already carries is what says "active"; the chrome being
// lighter is what says the others are not.
fix('tab.activeBackground', body);
fix('tab.unfocusedActiveBackground', body);
fix('tab.hoverBackground', body);
// 2. ONE chrome surface, lighter than the ground: inactive tabs, strip,
// file explorer, text boxes, dropdowns.
const chrome = resolveAbove(colors['tab.inactiveBackground'] ?? body, body, SURFACE_MIN, scheme);
for (const k of CHROME_KEYS) fix(k, chrome);
// 3. Overlays one step lighter than the chrome.
const overlay = resolveAbove(colors['editorWidget.background'] ?? chrome, chrome, SURFACE_MIN, scheme);
for (const k of OVERLAY_KEYS) fix(k, overlay);
// Popover affordances: a shadow and real borders so floating surfaces read
// as floating even where fills sit close.
if (!colors['widget.shadow']) fix('widget.shadow', darken(body, 0.6) + 'aa');
if (!colors['quickInput.border']) fix('quickInput.border', scheme.border);
if (!colors['notificationCenter.border']) fix('notificationCenter.border', scheme.border);
if (fixes.length) console.log(` ${label}: surface-distinction corrections\n ${fixes.join('\n ')}`);
return colors;
}
// ---------------------------------------------------------------------------
// 3. Derive the eye-comfort variants from the token set, don't hand-pick them
// ---------------------------------------------------------------------------
// Dark mode is easier on the eyes because the panel emits less light (John's own
// reasoning), but past roughly 14:1 white-on-black the text haloes — so the variants
// move the GROUND, and let the text tokens ride along, instead of cranking the text.
const dimmed = {
...studio,
bg: studio.surface, // lift the floor one step: less delta with a lit room
surface: studio.elevated,
elevated: studio.overlay,
text: studio['text-body'] // drop peak emphasis from 16:1 to 12.3:1
};
const contrast = {
...studio,
bg: '#000000',
surface: studio.bg,
elevated: studio.surface,
overlay: studio.elevated,
text: '#ffffff'
};
// ---------------------------------------------------------------------------
// 4. Emit
// ---------------------------------------------------------------------------
const templatePath = process.env.ADOM_THEME_TEMPLATE
?? join(root, 'tools/vscode-theme-template.json');
const template = JSON.parse(readFileSync(templatePath, 'utf8'));
const VARIANTS = [
// FIVE, matching HD's five schemes exactly. HD pushes these names into the editor, and an
// unknown name makes VS Code silently fall back to its default LIGHT theme -- so this list
// and VSCODE_THEME_FOR_SCHEME in settings.ts must stay in lockstep. That is what
// scripts/check-vscode-theme-names.sh enforces.
// Adom Studio Dark and Adom Studio Bright are NO LONGER template projections either
// (John 2026-08-01): both are emitted by gen-adom-studio-v2.mjs (invoked below)
// alongside the flagship, so they get the exact same brand-accurate Dark-2026
// re-grounding -- Dark at the deeper #0d1117 ground, Bright at that same ground with
// the text anchor lifted to #e6edf3 (+ the 7:1 within-hue syntax lift). Removed from
// this list to avoid two sources writing the same files.
{ file: 'adom-kickstand', label: 'Adom Kickstand', scheme: kickstand },
{ file: 'adom-slate', label: 'Adom Slate', scheme: slate },
// The flagship 'Adom Studio' is NO LONGER a template projection: since
// 2026-07-27 it is the Dark-2026-derived design emitted by
// gen-adom-studio-v2.mjs (invoked below), which owns the name and the
// adom-studio-color-theme.json file. The old projection is abandoned.
];
// Wipe the themes dir before writing. Without this, a RENAMED theme leaves its old file
// behind: the pack then ships a theme that package.json no longer declares, and the stale
// name keeps resolving on any machine whose settings still point at it -- while a machine
// with a fresh install gets VS Code's default LIGHT theme instead, silently. That white
// editor has now happened twice for adjacent reasons.
rmSync(join(outDir, 'themes'), { recursive: true, force: true });
mkdirSync(join(outDir, 'themes'), { recursive: true });
const unmapped = new Set();
for (const v of VARIANTS) {
const theme = retintTheme(template, v.scheme, v.label, unmapped, v.brighten === true);
enforceSurfaceDistinction(theme.colors, v.label, v.scheme);
// THE ONE-GROUND CONTRACT (John 2026-08-02, same rule as gen-adom-studio-v2):
// unify sideBar onto the editor ground so BOTH Claude chat modes, file bodies,
// and the active tab all share one color; inactive tabs/strip keep the raised
// chrome tone. See gen-adom-studio-v2.mjs for the full rationale.
theme.colors['sideBar.background'] = theme.colors['editor.background'];
theme.colors['sideBar.foreground'] = theme.colors['editor.foreground'];
writeFileSync(
join(outDir, 'themes', `${v.file}-color-theme.json`),
JSON.stringify(theme, null, 2) + '\n'
);
console.log(` ${v.label.padEnd(20)} ground ${v.scheme.bg} accent ${v.scheme.accent}`);
}
// Adom Studio v2 (the STANDARD, John 2026-07-27) is built by its own script —
// Dark 2026 re-grounded on the brand, not a template projection. Run it here so
// one command still emits the whole pack, and register it in the manifest below.
execFileSync(process.execPath, [join(root, 'tools/gen-adom-studio-v2.mjs'), outDir], { stdio: 'inherit' });
// Identity + version rule (John 2026-08-02): the pack is adom.adom-theme and its version
// is LOCKED to the adom-theme wiki package (one number for both, bumped together there).
// This locally-emitted manifest is the dev preview of that contract; the shipping manifest
// lives in the wiki package's vscode/package.json.
writeFileSync(join(outDir, 'package.json'), JSON.stringify({
name: 'adom-theme',
displayName: 'Adom Theme',
publisher: 'adom',
version: '2.0.0',
description:
'The Adom brand theme family for VS Code. Generated from the Adom theme tokens ' +
'and version-locked to the adom-theme wiki package, so the editor and the app ' +
'can never disagree about a colour.',
engines: { vscode: '^1.70.0' },
categories: ['Themes'],
contributes: {
themes: [
...VARIANTS.map((v) => ({
label: v.label,
uiTheme: 'vs-dark',
path: `./themes/${v.file}-color-theme.json`
})),
{ label: 'Adom Studio', uiTheme: 'vs-dark', path: './themes/adom-studio-color-theme.json' }
]
}
}, null, 2) + '\n');
if (unmapped.size) {
console.error(`\nFAIL: ${unmapped.size} colour(s) in the template map to no token:`);
for (const u of [...unmapped].sort()) console.error(` ${u}`);
console.error('\nAdd each to LEGACY_TO_TOKEN with the role it plays, or replace it in');
console.error('the template with a colour that already has one. A theme is not allowed');
console.error('to introduce a hex the app does not have.');
if (ASSERT_NO_UNMAPPED) process.exit(1);
}
console.log(`\nOK: ${VARIANTS.length} themes -> ${outDir} (every colour resolved to a token)`);