#!/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 [--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 [--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 = { 'rose-pine': 'Rosé Pine', 'retro-82': 'Retro 82', 'tokyo-night': 'Tokyo Night', 'catppuccin': 'Catppuccin Mocha', 'catppuccin-latte': 'Catppuccin Latte', }; const FAMILY: Record = { 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);