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>
This commit is contained in:
2026-08-21 04:18:58 +00:00
co-authored by Claude Opus 5
parent a2cca526f9
commit 4e2db3dc2f
29 changed files with 2567 additions and 474 deletions
+172
View File
@@ -0,0 +1,172 @@
#!/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);
+96
View File
@@ -0,0 +1,96 @@
// WCAG contrast, so "is this readable" stops being a matter of taste.
//
// Every complaint about the current themes reduces to one of two failures: a highlight so strong it
// masks the content on it, or text so close to its background it disappears. Both are a contrast ratio,
// and a ratio is checkable. This file is what turns the whole exercise from opinion into a test.
export type Rgb = { r: number; g: number; b: number };
export function parseHex(hex: string): Rgb {
const h = hex.trim().replace('#', '');
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
return {
r: Number.parseInt(full.slice(0, 2), 16),
g: Number.parseInt(full.slice(2, 4), 16),
b: Number.parseInt(full.slice(4, 6), 16),
};
}
export const toHex = ({ r, g, b }: Rgb): string =>
`#${[r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')}`;
/** Relative luminance, per WCAG 2.1. */
export function luminance(c: Rgb): number {
const channel = (v: number) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b);
}
/** 1 (identical) to 21 (black on white). */
export function contrast(a: string, b: string): number {
const [x, y] = [luminance(parseHex(a)), luminance(parseHex(b))];
const [hi, lo] = x > y ? [x, y] : [y, x];
return (hi + 0.05) / (lo + 0.05);
}
/**
* The thresholds, and why these ones.
*
* `TEXT` is WCAG AA for body text. `UI` is AA for large text and non-text contrast — borders, icons, the
* edge of a control. A border at 4.5 would be a hard line around everything, which is a different design
* rather than a more accessible one.
*/
export const TEXT = 4.5;
export const UI = 3;
/**
* Push a foreground away from its background until it is readable.
*
* Moves toward black or white — whichever the background is further from — in small steps, so a colour
* that is nearly readable keeps almost all of its hue and only a hopeless one ends up monochrome. That
* matters: the point is a theme that still looks like Nord, not one that passes and looks like nothing.
*
* Returns the original when it already passes, so a well-chosen palette is untouched.
*/
export function ensureContrast(fg: string, bg: string, min = TEXT): string {
if (contrast(fg, bg) >= min) return fg;
// Whichever extreme is FURTHER from the background, not whichever side of mid-grey it falls on.
// Those differ for a mid-tone fill and the difference is the whole answer: rose-pine's rose is at
// luminance 0.25, so a `> 0.5` test sends this toward white and caps at 3.5:1, while black reaches
// 6:1 easily. Six of its tones were unfixable until this stopped guessing and measured.
const black = '#000000';
const white = '#ffffff';
const target = parseHex(contrast(black, bg) >= contrast(white, bg) ? black : white);
const start = parseHex(fg);
// 40 steps of 2.5% each. Fine enough that the first passing value is close to the original.
for (let i = 1; i <= 40; i++) {
const t = i / 40;
const moved = toHex({
r: start.r + (target.r - start.r) * t,
g: start.g + (target.g - start.g) * t,
b: start.b + (target.b - start.b) * t,
});
if (contrast(moved, bg) >= min) return moved;
}
return toHex(target);
}
/** Blend two colours. `amount` is how much of `b`. */
export function mix(a: string, b: string, amount: number): string {
const [x, y] = [parseHex(a), parseHex(b)];
return toHex({
r: x.r + (y.r - x.r) * amount,
g: x.g + (y.g - x.g) * amount,
b: x.b + (y.b - x.b) * amount,
});
}
/** `#rrggbb` → `r g b` for `rgb(var(--x) / 0.5)`, which is how the surfaces get their opacity. */
export const rgbTriplet = (hex: string): string => {
const { r, g, b } = parseHex(hex);
return `${r} ${g} ${b}`;
};
+193
View File
@@ -0,0 +1,193 @@
import type { Palette } from './palette';
import { UI, contrast, ensureContrast, mix, rgbTriplet } from './contrast';
// Omarchy palette → Officer's 84 declarations.
//
// This file is the whole port. It exists once so that twenty-two themes cannot disagree about what
// "muted" means, and so that improving the mapping improves all of them at once.
//
// ── What went wrong before, and what this fixes ──
//
// The previous port produced 34 declarations per theme against a contract of 84. 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 previous theme left behind. That is why highlights masked content and text faded
// out: not taste, absence. It also shipped thirteen tokens nothing reads (`--chart-1..5`, `--sidebar-*`),
// which is the tell that it came from a shadcn template rather than from this codebase's contract.
//
// ── The rule that makes them readable ──
//
// Every foreground is run through `ensureContrast` against the surface it actually sits on. A palette
// whose colours are already readable is untouched; one that is not gets nudged toward black or white
// until it passes, keeping as much hue as it can. So a theme cannot ship with invisible text, no matter
// what its author chose.
export type Tokens = Record<string, string>;
/** Depth ordering differs by mode: a light theme's "darker" is its raised surface, not its page. */
function surfaces(p: Palette) {
const dark = p.mode === 'dark';
return {
/** The page. The deepest surface in dark, the plainest in light. */
page: dark ? p.darkerBackground : p.background,
/** Panels and cards sit one step up from the page, so a tile reads as a tile. */
raised: dark ? p.background : p.lighterBackground,
/** Hovers, secondary fills, the header. One step up again. */
high: dark ? p.lighterBackground : p.darkBackground,
dark,
};
}
export function mapPalette(p: Palette): Tokens {
const s = surfaces(p);
// Text, checked against the surface each weight actually appears on.
const fg = ensureContrast(p.foreground, s.page);
const fgRaised = ensureContrast(p.foreground, s.raised);
// Checked against `s.high`, not `s.raised`: `--muted-foreground` is the text ON `--muted`, and
// `--muted` is the high surface. Deriving it against the wrong one left everforest at 4.44:1 and
// miasma at 4.00 — close enough to look fine and still fail, which is the kind of near-miss that a
// person eyeballing a screenshot would have shipped.
const fgMuted = ensureContrast(p.lightForeground, s.high);
const fgFaint = ensureContrast(p.darkForeground, s.raised, UI);
/**
* Text for a solid fill of `bg`.
*
* Tries the palette's own extremes first, so a button's label still belongs to the theme, and only
* falls through to `ensureContrast` when neither is readable. Both steps are needed: picking the
* better of two candidates does NOT guarantee either passes — rose-pine's fills are all mid-tone and
* every one came out between 2.2:1 and 4.2:1 until this drove it the rest of the way.
*/
const on = (bg: string): string => {
const light = p.brightForeground;
const dark = p.darkerBackground;
const better = contrast(light, bg) >= contrast(dark, bg) ? light : dark;
return ensureContrast(better, bg);
};
const accent = p.accent;
// Borders are DECORATION, not information, so they are not held to a text ratio — a 3:1 line drawn
// around every panel is a different design, not a more accessible one. What they must be is
// discernible, which `--input` below is checked for: a form field whose boundary you cannot see is a
// real accessibility failure, unlike a subtle panel edge.
const border = mix(s.raised, s.dark ? p.lightForeground : p.darkForeground, 0.28);
return {
// ── Ground ──
'--background': s.page,
'--foreground': fg,
// ── Surfaces ──
'--card': s.raised,
'--card-foreground': fgRaised,
'--popover': s.raised,
'--popover-foreground': fgRaised,
// `muted` is a SURFACE here, not the palette's `muted` colour — that one is a mid-grey meant for
// dimmed text, and using it as a fill is precisely how a highlight ends up masking its content.
'--muted': s.high,
'--muted-foreground': fgMuted,
'--accent': s.high,
'--accent-foreground': fgRaised,
// ── Actions ──
'--primary': accent,
'--primary-foreground': on(accent),
'--secondary': s.high,
'--secondary-foreground': fgRaised,
'--cta': accent,
'--cta-foreground': on(accent),
'--ctahover': mix(accent, s.dark ? '#ffffff' : '#000000', 0.12),
// ── State — five tones, each readable on its own fill ──
'--success': p.green,
'--success-foreground': on(p.green),
'--warning': p.yellow,
'--warning-foreground': on(p.yellow),
'--destructive': p.red,
'--destructive-foreground': on(p.red),
'--info': p.blue,
'--info-foreground': on(p.blue),
'--busy': p.orange,
'--busy-foreground': on(p.orange),
// The odd one, per the contract: a fill bright enough for black text is too light to BE text. So ink
// is the same hue pushed until it reads on the page.
'--busy-ink': ensureContrast(p.orange, s.page),
// ── Lines ──
'--border': border,
'--input': border,
'--ring': accent,
'--radius': '0.625rem',
// ── Data ──
'--chart-primary': accent,
'--chart-secondary': p.magenta,
'--status-success': p.green,
'--status-warning': p.yellow,
'--status-in-progress': p.blue,
// ── Surfaces, as complete CSS values ──
'--page-bg': s.page,
'--page-bg-src': "''",
'--panel-bg': `rgb(${rgbTriplet(s.raised)} / 0.72)`,
'--panel-border': `rgb(${rgbTriplet(border)} / 0.9)`,
'--panel-border-bare': `rgb(${rgbTriplet(border)} / 0.55)`,
// DENSER, not darker — panel chrome is dark text and a heavy fill hides every icon. The contract
// says so; this is the one place a value is chosen to obey a sentence rather than a ratio.
'--panel-raised-bg': `rgb(${rgbTriplet(s.raised)} / 0.95)`,
'--panel-raised-border': `rgb(${rgbTriplet(border)} / 1)`,
'--panel-accent': accent,
'--panel-header-bg': `rgb(${rgbTriplet(s.high)} / 0.6)`,
'--panel-header-fg': fgRaised,
'--panel-radius': '0.75rem',
'--panel-blur': '12px',
'--card-bg': s.raised,
'--glass-bg': `rgb(${rgbTriplet(s.raised)} / 0.55)`,
'--glass-border': `rgb(${rgbTriplet(border)} / 0.7)`,
'--dock-bg': `rgb(${rgbTriplet(s.raised)} / 0.7)`,
'--dock-border': `rgb(${rgbTriplet(border)} / 0.8)`,
'--dock-tooltip-bg': s.high,
'--header-bg': `rgb(${rgbTriplet(s.raised)} / 0.8)`,
'--header-border': `rgb(${rgbTriplet(border)} / 0.9)`,
'--header-fg': fgRaised,
'--header-blur': '12px',
// Grid lines are a texture, not a colour anyone should notice — kept just above the surface.
'--pixel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.07)`,
'--card-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.05)`,
'--panel-grid-color': `rgb(${rgbTriplet(fgFaint)} / 0.06)`,
'--scrollbar-thumb': `rgb(${rgbTriplet(p.muted)} / 0.6)`,
'--scrollbar-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.85)`,
'--scrollbar-thin-thumb': `rgb(${rgbTriplet(p.muted)} / 0.45)`,
'--scrollbar-thin-thumb-hover': `rgb(${rgbTriplet(p.muted)} / 0.7)`,
// ── Brand slots ──
//
// A ported theme has no brand of its own, so these follow its accent rather than importing Duck's
// green into somebody else's palette. `--ink` is the app's text colour and is used ~950 times.
'--brand': accent,
'--brand-foreground': on(accent),
'--brand-primary': accent,
'--brand-accent': p.cyan,
'--brand-warm': p.orange,
'--brand-deep': s.dark ? p.darkerBackground : p.darkBackground,
'--brand-muted': p.muted,
'--ink': fg,
'--surface-warm': s.raised,
'--naturegreen': p.green,
'--naturegreen-foreground': on(p.green),
// Legacy aliases the contract keeps until the utility rename lands — `prose.css` and some plugins
// read them directly, so a theme without them has unstyled prose.
'--duck-dark': fg,
'--duck-teal': p.cyan,
'--duck-yellow': p.yellow,
'--duck-orange': p.orange,
'--duck-forest': p.green,
'--duck-beige': mix(s.raised, fg, 0.1),
'--logo-src': "'/officer-logo.svg'",
};
}
+86
View File
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs';
// Reading an Omarchy theme's `colors.toml`.
//
// Omarchy 4 ships a genuinely semantic palette rather than sixteen ANSI slots: three background depths,
// four foreground weights, an accent, a selection and a muted, plus the named hues. That is why this port
// can be a mapping at all — the previous one had to guess, and the guessing is what produced themes with
// no surfaces and unreadable highlights.
//
// Verified across all 22 themes in basecamp/omarchy@master: every one declares the 24 keys below.
export type Palette = {
mode: 'dark' | 'light';
accent: string;
selection: string;
muted: string;
background: string;
darkBackground: string;
darkerBackground: string;
lighterBackground: string;
foreground: string;
darkForeground: string;
lightForeground: string;
brightForeground: string;
red: string;
yellow: string;
green: string;
cyan: string;
blue: string;
magenta: string;
/** Not in every theme — 19 of 22. Falls back to yellow-red mid, which is what orange is. */
orange: string;
brightRed: string;
brightYellow: string;
brightGreen: string;
brightCyan: string;
brightBlue: string;
brightMagenta: string;
};
/** A deliberately small TOML reader: this file is flat `key = "value"` and nothing else. */
function readToml(path: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of readFileSync(path, 'utf8').split('\n')) {
const m = line.match(/^\s*([a-z_0-9]+)\s*=\s*"([^"]*)"/i);
if (m) out[m[1]!] = m[2]!;
}
return out;
}
export function loadPalette(tomlPath: string): Palette {
const t = readToml(tomlPath);
const need = (key: string): string => {
const v = t[key];
if (!v) throw new Error(`${tomlPath}: missing "${key}"`);
return v;
};
return {
mode: t.mode === 'light' ? 'light' : 'dark',
accent: need('accent'),
selection: need('selection'),
muted: need('muted'),
background: need('background'),
darkBackground: need('dark_background'),
darkerBackground: need('darker_background'),
lighterBackground: need('lighter_background'),
foreground: need('foreground'),
darkForeground: need('dark_foreground'),
lightForeground: need('light_foreground'),
brightForeground: need('bright_foreground'),
red: need('red'),
yellow: need('yellow'),
green: need('green'),
cyan: need('cyan'),
blue: need('blue'),
magenta: need('magenta'),
orange: t.orange ?? need('yellow'),
brightRed: need('bright_red'),
brightYellow: need('bright_yellow'),
brightGreen: need('bright_green'),
brightCyan: need('bright_cyan'),
brightBlue: need('bright_blue'),
brightMagenta: need('bright_magenta'),
};
}