Files
platform/scripts/generate-themes.ts
T
pastilhasandClaude Opus 5 4e2db3dc2f themes: generate all 22 from Omarchy, with contrast as a test rather than a taste
The eleven ported themes declared 34 of an 84-token contract. The missing fifty were every surface
(--panel-bg, --card-bg, --glass-bg), every state colour (--success, --warning, --busy, --info), the
header, the scrollbars and the grid — so they fell back to whatever the base stylesheet or the
previously applied theme had left behind. They also carried thirteen tokens nothing in this codebase
reads (--chart-1..5, --sidebar-*), which is the tell: they came from a shadcn template, not from the
contract in the README beside them.

So "highlights mask the content" and "text fades into the background" were not taste. They were
absence, and no amount of re-picking colours would have fixed them.

Generated now, from Omarchy 4's colors.toml, which is genuinely semantic — three background depths,
four foreground weights, accent, selection, muted, mode — and consistent: all 24 keys present in all 22
themes, checked. One mapping in scripts/themes/map.ts means one place to be wrong, one place to fix,
and an Omarchy update becomes a re-run.

Every foreground is driven through ensureContrast against the surface it actually sits on, and the
generator then re-checks sixteen pairs per theme and EXITS NONZERO on a failure. It found four real
bugs in my own mapping, which is the argument for having written it first:

  - `on(fill)` picked the better of two palette extremes without checking either passed; rose-pine's
    tones are all mid and came out between 2.2:1 and 4.2:1
  - ensureContrast chose black or white by a luminance>0.5 test rather than by measuring. rose-pine's
    rose sits at 0.25, so it drove toward white and capped at 3.5:1 where black reaches 6:1 easily
  - --muted-foreground was derived against the raised surface but renders on the high one; everforest
    landed at 4.44:1 and miasma at 4.00 — near-misses that look fine in a screenshot
  - borders were held to 3:1, which would ring every panel in a hard line. A panel edge is decoration;
    what must be discernible is a form field's boundary, so --input is checked and --border is not

35 failures at first run, 0 now.

The registry and the stylesheet imports are written from the same list, because the old port maintained
them by hand and drifted — catppuccin-mocha sat in THEMES after its stylesheet was gone. That file is
now deleted; Omarchy renamed it to catppuccin.

Eleven new themes arrive with this: ethereal, flexoki-light, hackerman, last-horizon, lumon, lupine,
miasma, retro-82, solitude, vantablack, white. Duck and Plain are Officer's own and are never generated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 04:18:58 +00:00

173 lines
7.0 KiB
TypeScript

#!/usr/bin/env bun
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { loadPalette } from './themes/palette';
import { mapPalette } from './themes/map';
import { TEXT, UI, contrast } from './themes/contrast';
// Generate Officer themes from Omarchy's, and refuse to write an unreadable one.
//
// bun scripts/generate-themes.ts <path-to-omarchy-checkout> [--check]
//
// `--check` writes nothing and reports what would change, which is what a test runs.
//
// ── Why generated rather than authored ──
//
// Twenty-two themes hand-written against an 84-token contract will disagree, and the previous attempt
// proved it: eleven themes, 34 tokens each, every surface missing. One mapping means one place to be
// wrong and one place to fix — and a palette that Omarchy updates is a re-run rather than a rewrite.
//
// The Duck and Plain themes are NOT generated. They are Officer's own, authored, and this must never
// overwrite them.
const OMARCHY = process.argv[2];
const CHECK = process.argv.includes('--check');
const THEME_DIR = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'css', 'themes');
if (!OMARCHY || !existsSync(join(OMARCHY, 'themes'))) {
console.error('usage: bun scripts/generate-themes.ts <path-to-omarchy-checkout> [--check]');
process.exit(1);
}
/** Officer's own. Generated themes may never take these names. */
const AUTHORED = new Set(['duck-light', 'duck-dark', 'plain']);
const title = (id: string): string =>
id
.split('-')
.map((w) => (w.length <= 2 ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
.join(' ');
/**
* The pairs that must be readable, checked after mapping rather than trusted.
*
* `ensureContrast` already guarantees most of these — this is the belt to that braces. A mapping change
* that quietly breaks one shows up here as a failure instead of as somebody squinting at a screenshot.
*/
const PAIRS: [string, string, number][] = [
['--foreground', '--background', TEXT],
['--card-foreground', '--card', TEXT],
['--popover-foreground', '--popover', TEXT],
['--muted-foreground', '--muted', TEXT],
['--accent-foreground', '--accent', TEXT],
['--primary-foreground', '--primary', TEXT],
['--secondary-foreground', '--secondary', TEXT],
['--cta-foreground', '--cta', TEXT],
['--success-foreground', '--success', TEXT],
['--warning-foreground', '--warning', TEXT],
['--destructive-foreground', '--destructive', TEXT],
['--info-foreground', '--info', TEXT],
['--busy-foreground', '--busy', TEXT],
['--busy-ink', '--background', TEXT],
// Not `--border` at UI: a panel edge is decoration and 3:1 would ring everything in a hard line. What
// must be visible is a form field's boundary and the focus ring — the two that carry information.
['--input', '--background', 1.35],
['--ring', '--background', UI],
];
let failures = 0;
let written = 0;
const generated: { id: string; name: string; dark: boolean }[] = [];
for (const id of readdirSync(join(OMARCHY, 'themes')).sort()) {
const toml = join(OMARCHY, 'themes', id, 'colors.toml');
if (!existsSync(toml)) continue;
if (AUTHORED.has(id)) {
console.log(` ${id}: SKIPPED — Officer's own theme, never generated`);
continue;
}
const palette = loadPalette(toml);
const tokens = mapPalette(palette);
for (const [fgKey, bgKey, min] of PAIRS) {
const fg = tokens[fgKey]!;
const bg = tokens[bgKey]!;
// Only flat hex pairs are checkable — a translucent surface depends on what is behind it.
if (!fg.startsWith('#') || !bg.startsWith('#')) continue;
const ratio = contrast(fg, bg);
if (ratio < min) {
console.error(` ${id}: ${fgKey} on ${bgKey} is ${ratio.toFixed(2)}:1, needs ${min}:1`);
failures++;
}
}
const body = Object.entries(tokens)
.map(([k, v]) => ` ${k}: ${v};`)
.join('\n');
const css = `/*
${title(id)} — generated from Omarchy's palette by scripts/generate-themes.ts.
DO NOT EDIT BY HAND. Change the mapping in scripts/themes/map.ts and re-run, so every theme moves
together — the previous port drifted precisely because each file was edited on its own.
Source: basecamp/omarchy themes/${id}/colors.toml (mode: ${palette.mode})
*/
[data-theme='${id}'] {
${body}
}
`;
const path = join(THEME_DIR, `${id}.css`);
const before = existsSync(path) ? readFileSync(path, 'utf8') : '';
if (before !== css) {
if (!CHECK) writeFileSync(path, css);
written++;
}
generated.push({ id, name: title(id), dark: palette.mode === 'dark' });
}
console.log(`\n ${generated.length} themes, ${written} ${CHECK ? 'would change' : 'written'}, ${failures} contrast failures`);
if (!CHECK) {
// The registry and the stylesheet index, rewritten from what was generated so a theme cannot exist as
// a file nobody imports — which is a theme that silently does not work.
const imports = generated.map((t) => `@import './themes/${t.id}.css';`).join('\n');
const indexPath = join(THEME_DIR, '..', 'index.css');
const index = readFileSync(indexPath, 'utf8');
const marker = '/* Palette themes';
const head = index.slice(0, index.indexOf(marker));
writeFileSync(
indexPath,
`${head}/* Palette themes — generated from Omarchy by scripts/generate-themes.ts. Do not edit this block. */\n${imports}\n\n@import './chrome.css';\n`,
);
console.log(` index.css: ${generated.length} imports`);
// The REGISTRY, from the same list. A theme file nothing imports does not work; a theme imported but
// absent from here does not appear in the picker. Both were true of the previous port — catppuccin-mocha
// outlived its stylesheet — so both halves are written from one source rather than maintained by hand.
//
// Display names get a pass for the ones a slug cannot spell.
const NICER: Record<string, string> = {
'rose-pine': 'Rosé Pine',
'retro-82': 'Retro 82',
'tokyo-night': 'Tokyo Night',
'catppuccin': 'Catppuccin Mocha',
'catppuccin-latte': 'Catppuccin Latte',
};
const FAMILY: Record<string, string> = { catppuccin: 'Catppuccin', 'catppuccin-latte': 'Catppuccin' };
const rows = generated
.map((t) => {
const name = NICER[t.id] ?? t.name;
const family = FAMILY[t.id] ?? name;
return ` { id: '${t.id}', name: '${name}', family: '${family}', applyDarkClass: ${t.dark} },`;
})
.join('\n');
const registryPath = join(import.meta.dir, '..', 'src', 'workspaces', 'theme', 'src', 'themes.ts');
const registry = readFileSync(registryPath, 'utf8');
const START = ' /* GENERATED THEMES — start */';
const END = ' /* GENERATED THEMES — end */';
if (!registry.includes(START)) {
console.error(' themes.ts: missing the GENERATED THEMES markers — not touching it');
process.exit(1);
}
const registryHead = registry.slice(0, registry.indexOf(START) + START.length);
const registryTail = registry.slice(registry.indexOf(END));
writeFileSync(registryPath, `${registryHead}\n${rows}\n${registryTail}`);
console.log(` themes.ts: ${generated.length} entries`);
}
process.exit(failures > 0 ? 1 : 0);