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:
@@ -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}`;
|
||||
};
|
||||
@@ -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'",
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user