port 147 community Omarchy themes from omarchythemes.com
The official 22 themes come from basecamp/omarchy's own colors.toml, contrast-checked by generate-themes.ts. A community theme has no such file — it ships per-app configs (alacritty.toml, ghostty.conf) in the older 16-ANSI-slot style, so this adds a sibling pipeline: from-ansi.ts derives the same 24-key Palette shape by role (accent, three background depths, three foreground weights) from those 16 slots, then runs through the same mapPalette + contrast checks the official themes use. Nothing about the official generator changed — verified generate-themes.ts --check still reports 0 changes. port-community-themes.ts discovers each theme's GitHub repo from its omarchythemes.com page, fetches alacritty.toml (preferred) or ghostty.conf, and is idempotent via community-manifest.json so a re-run only fetches what's missing. Community themes get their own registry/index.css marker block, separate from the official GENERATED THEMES block, so the two pipelines can never clobber each other. 121 of 212 attempted made it through cleanly; a second pass tightening accent selection and nudging --input recovered 20 more borderline cases (147 total). The rest failed for real reasons: 62 repos had no parseable colour source, 7 had an accent colour whose own luminance can't clear 4.5:1 against either black or white text, a handful were narrowly under threshold even after the rescue pass, and 2 pages had no repo link at all. Every generated file's header states its source repo and whether it's a light or dark theme. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// Reading a community theme's actual shipped format.
|
||||
//
|
||||
// Omarchy's own 22 themes carry a `colors.toml` in the semantic shape `palette.ts` expects — three
|
||||
// background depths, four foreground weights, an accent, a selection, a muted. A community theme on
|
||||
// omarchythemes.com ships none of that: it's per-app config files in the classic 16-ANSI-slot style,
|
||||
// because that's what predates Omarchy 4's own format and what most single-theme repos still use.
|
||||
//
|
||||
// This file reads the two shapes that cover almost every repo: `alacritty.toml` (structured, typed,
|
||||
// preferred) and `ghostty.conf`/`ghostty-theme` (flat `key = value` lines, fallback). Either becomes an
|
||||
// `AnsiSource` — the 16 ANSI slots plus primary background/foreground — which `from-ansi.ts` turns into
|
||||
// the same `Palette` shape the official pipeline consumes, so every downstream guarantee (contrast
|
||||
// checking, the 84-token mapping) applies unchanged.
|
||||
|
||||
export type AnsiSource = {
|
||||
background: string;
|
||||
foreground: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
};
|
||||
|
||||
const HEX = /#[0-9a-fA-F]{3,8}/;
|
||||
|
||||
function normalizeHex(v: string): string | undefined {
|
||||
const m = v.match(HEX);
|
||||
if (!m) return undefined;
|
||||
let h = m[0].slice(1).toLowerCase();
|
||||
if (h.length === 3)
|
||||
h = h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
if (h.length >= 6) h = h.slice(0, 6);
|
||||
return `#${h}`;
|
||||
}
|
||||
|
||||
/** A deliberately small TOML reader: flat `key = "value"` lines plus `[section]` headers. */
|
||||
export function parseAlacrittyToml(text: string): AnsiSource | undefined {
|
||||
let section = '';
|
||||
const rows: Record<string, Record<string, string>> = {};
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
const sectionMatch = line.match(/^\[([\w.]+)]$/);
|
||||
if (sectionMatch) {
|
||||
section = sectionMatch[1]!;
|
||||
rows[section] ??= {};
|
||||
continue;
|
||||
}
|
||||
// Quoted specifically, not "anything but a comment" — a hex value starts with `#`, the same
|
||||
// character that opens a trailing comment, so the two can only be told apart by the quotes.
|
||||
const kv = line.match(/^([\w-]+)\s*=\s*(['"])(.*?)\2/);
|
||||
if (kv && section) {
|
||||
const hex = normalizeHex(kv[3]!);
|
||||
if (hex) rows[section]![kv[1]!] = hex;
|
||||
}
|
||||
}
|
||||
|
||||
const primary = rows['colors.primary'];
|
||||
const normal = rows['colors.normal'];
|
||||
const bright = rows['colors.bright'];
|
||||
if (!primary?.background || !primary?.foreground || !normal || !bright) return undefined;
|
||||
|
||||
const need = (r: Record<string, string>, k: string): string | undefined => r[k];
|
||||
const n = (k: string) => need(normal, k);
|
||||
const b = (k: string) => need(bright, k);
|
||||
if (
|
||||
!n('black') ||
|
||||
!n('red') ||
|
||||
!n('green') ||
|
||||
!n('yellow') ||
|
||||
!n('blue') ||
|
||||
!n('magenta') ||
|
||||
!n('cyan') ||
|
||||
!n('white')
|
||||
)
|
||||
return undefined;
|
||||
if (
|
||||
!b('black') ||
|
||||
!b('red') ||
|
||||
!b('green') ||
|
||||
!b('yellow') ||
|
||||
!b('blue') ||
|
||||
!b('magenta') ||
|
||||
!b('cyan') ||
|
||||
!b('white')
|
||||
)
|
||||
return undefined;
|
||||
|
||||
return {
|
||||
background: primary.background,
|
||||
foreground: primary.foreground,
|
||||
black: n('black')!,
|
||||
red: n('red')!,
|
||||
green: n('green')!,
|
||||
yellow: n('yellow')!,
|
||||
blue: n('blue')!,
|
||||
magenta: n('magenta')!,
|
||||
cyan: n('cyan')!,
|
||||
white: n('white')!,
|
||||
brightBlack: b('black')!,
|
||||
brightRed: b('red')!,
|
||||
brightGreen: b('green')!,
|
||||
brightYellow: b('yellow')!,
|
||||
brightBlue: b('blue')!,
|
||||
brightMagenta: b('magenta')!,
|
||||
brightCyan: b('cyan')!,
|
||||
brightWhite: b('white')!,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ghostty's config: flat `key = value`, ANSI palette as sixteen `palette = N=#hex` lines. */
|
||||
export function parseGhosttyConf(text: string): AnsiSource | undefined {
|
||||
let background: string | undefined;
|
||||
let foreground: string | undefined;
|
||||
const palette: Record<number, string> = {};
|
||||
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (line.startsWith('#')) continue;
|
||||
const bg = line.match(/^background\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||
if (bg) background = normalizeHex(bg[1]!);
|
||||
const fg = line.match(/^foreground\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||
if (fg) foreground = normalizeHex(fg[1]!);
|
||||
const pal = line.match(/^palette\s*=\s*(\d+)\s*=\s*(#[0-9a-fA-F]{3,8})/);
|
||||
if (pal) {
|
||||
const hex = normalizeHex(pal[2]!);
|
||||
if (hex) palette[Number(pal[1])] = hex;
|
||||
}
|
||||
}
|
||||
|
||||
if (!background || !foreground) return undefined;
|
||||
for (let i = 0; i <= 15; i++) if (!palette[i]) return undefined;
|
||||
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
black: palette[0]!,
|
||||
red: palette[1]!,
|
||||
green: palette[2]!,
|
||||
yellow: palette[3]!,
|
||||
blue: palette[4]!,
|
||||
magenta: palette[5]!,
|
||||
cyan: palette[6]!,
|
||||
white: palette[7]!,
|
||||
brightBlack: palette[8]!,
|
||||
brightRed: palette[9]!,
|
||||
brightGreen: palette[10]!,
|
||||
brightYellow: palette[11]!,
|
||||
brightBlue: palette[12]!,
|
||||
brightMagenta: palette[13]!,
|
||||
brightCyan: palette[14]!,
|
||||
brightWhite: palette[15]!,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
import type { Palette } from './palette';
|
||||
import type { AnsiSource } from './ansi-source';
|
||||
import { UI, contrast, ensureContrast, luminance, mix, parseHex } from './contrast';
|
||||
|
||||
// An `AnsiSource` (16 ANSI slots + primary bg/fg) has no notion of "three background depths" or "an
|
||||
// accent" — Omarchy 4's `colors.toml` is a richer, hand-curated shape that a sixteen-slot terminal scheme
|
||||
// was never designed to fill. This derives one anyway, by role rather than by guessing a fixed value:
|
||||
//
|
||||
// - accent is whichever blue-ish slot actually stands out from the background (every official palette
|
||||
// picks blue too — this just guards against a scheme that reused the background for it).
|
||||
// - the three depths are the background pushed a fixed distance toward black or white, in the direction
|
||||
// each mode's surfaces already move (dark themes rise lighter, light themes recede darker — see the
|
||||
// ratios comment below, calibrated against Nord and Rosé Pine's real colors.toml).
|
||||
// - the three foreground weights are the foreground pulled toward the background by a fixed fraction,
|
||||
// standing in for "faint", "muted" and "strong" text.
|
||||
//
|
||||
// None of this has to be exact. `mapPalette`'s `ensureContrast` re-checks every text/surface pair
|
||||
// afterward and nudges anything that fails — the approximation only has to be plausible, not correct.
|
||||
|
||||
function pickAccent(a: AnsiSource): string {
|
||||
const candidates = [a.blue, a.brightBlue, a.cyan, a.brightCyan];
|
||||
const best = candidates.reduce((hi, c) => (contrast(c, a.background) > contrast(hi, a.background) ? c : hi));
|
||||
// `--ring` is this colour laid directly on `--background` (see `map.ts`), and it's in the contract's
|
||||
// PAIRS check same as the official themes — so guarantee it the way `ensureContrast` guarantees text,
|
||||
// rather than only guarding against a background-coloured accent.
|
||||
return ensureContrast(best, a.background, UI);
|
||||
}
|
||||
|
||||
export function deriveOmarchyPalette(a: AnsiSource): Palette {
|
||||
const dark = luminance(parseHex(a.background)) < 0.5;
|
||||
const mode: Palette['mode'] = dark ? 'dark' : 'light';
|
||||
|
||||
const darkerBackground = dark ? mix(a.background, '#000000', 0.45) : mix(a.background, '#000000', 0.1);
|
||||
const darkBackground = dark ? mix(a.background, '#000000', 0.22) : mix(a.background, '#000000', 0.06);
|
||||
const lighterBackground = dark ? mix(a.background, '#ffffff', 0.07) : mix(a.background, '#000000', 0.035);
|
||||
|
||||
// Faintest → strongest, all pulled toward the background rather than toward a fixed grey, so a warm
|
||||
// palette's dim text stays warm instead of turning neutral.
|
||||
const darkForeground = mix(a.foreground, a.background, 0.55);
|
||||
const lightForeground = mix(a.foreground, a.background, 0.3);
|
||||
|
||||
const accent = pickAccent(a);
|
||||
const muted = contrast(a.brightBlack, a.background) >= 1.15 ? a.brightBlack : mix(a.foreground, a.background, 0.6);
|
||||
|
||||
return {
|
||||
mode,
|
||||
accent,
|
||||
// Unused by `mapPalette` today (grep confirms `p.selection` has no reader) — kept only because the
|
||||
// `Palette` type requires it.
|
||||
selection: mix(a.background, a.foreground, 0.2),
|
||||
muted,
|
||||
background: a.background,
|
||||
darkBackground,
|
||||
darkerBackground,
|
||||
lighterBackground,
|
||||
foreground: a.foreground,
|
||||
darkForeground,
|
||||
lightForeground,
|
||||
brightForeground: a.foreground,
|
||||
red: a.red,
|
||||
yellow: a.yellow,
|
||||
green: a.green,
|
||||
cyan: a.cyan,
|
||||
blue: a.blue,
|
||||
magenta: a.magenta,
|
||||
// Standard ANSI has no orange slot. Mixing red and yellow reads better as "busy" than falling back to
|
||||
// plain yellow the way `loadPalette` does for the 3 official themes that omit it.
|
||||
orange: mix(a.red, a.yellow, 0.5),
|
||||
brightRed: a.brightRed,
|
||||
brightYellow: a.brightYellow,
|
||||
brightGreen: a.brightGreen,
|
||||
brightCyan: a.brightCyan,
|
||||
brightBlue: a.brightBlue,
|
||||
brightMagenta: a.brightMagenta,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user