Files
Metal AgentandClaude Opus 5 401760d670 Generate the theme catalog from real themes instead of a hand table
The derivation landed in decision 0018 with the catalog and the terminal ANSI
palette still fixed tables written by hand. tools/theme-gen reads the
TextMate themes under assets/themes/ and emits the catalog and the reference
vectors, so the anchors a palette is derived from are the ones the theme
actually ships rather than the ones somebody transcribed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
2026-09-01 12:51:25 -07:00

212 lines
8.9 KiB
JavaScript

/**
* Emits the golden vectors the Rust derivation is tested against, by running
* Buzz's original TypeScript under Node.
*
* Decision 0018 records why this exists as a script rather than as a table
* someone typed in. A re-implementation of the derivation in Python reports
* `#191c20` for github-dark's chrome; the real answer is `#171a1d`. Python's
* `round` is banker's rounding and JavaScript's `Math.round` is half-up, they
* disagree on exactly one channel value — 22.5 — and that one channel decides
* whether the luminance bisection's convergence test trips a step early. A
* research pass once claimed to have reproduced these byte-exactly and had not.
* So: nothing here reimplements anything. The reference modules are imported
* from the pinned Buzz checkout and called.
*
* node tools/theme-gen/reference/emit-reference-vectors.mjs \
* [--buzz <path to the block/buzz checkout>] \
* [--out <path to reference_vectors.rs>]
*
* Buzz is pinned in docs/RESEARCH_SNAPSHOTS.md at commit cb3144999beb and lives
* outside this repository, so this is not a step anyone can run in CI. That is
* the point of checking the output in: the vectors travel with the repository
* and the test runs everywhere, while regenerating them needs the reference.
*
* Two adaptations, both mechanical, both necessary, neither of them a rewrite:
*
* - `adaptive-theme.ts` keeps `mix`, `adjust` and `calculateChromeColors`
* module-private and only exports a shadcn CSS variable map, whose colours
* have been through a lossy hex→HSL string conversion. A `load` hook appends
* an export statement to the module source so the exact functions can be
* called; the source itself is untouched on disk.
* - Buzz feeds `extractThemeInfo` a theme loaded through Shiki, and reads
* token rules from `theme.settings`. Shiki 4.1.0's bundled themes expose
* that array as `tokenColors`, so the script renames the field before
* handing the document over. Without the rename the reference reports every
* theme's comment colour as its foreground — which is what Buzz itself does
* at the pinned commit, and is a bug there rather than the rule this catalog
* is built on.
*/
import { registerHooks } from "node:module";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../../..");
function argument(name, fallback) {
const index = process.argv.indexOf(`--${name}`);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
const BUZZ = argument(
"buzz",
resolve(ROOT, "../Research/agent-ides/buzz"),
);
const ASSETS = argument("assets", join(ROOT, "assets/themes"));
const OUT = argument(
"out",
join(ROOT, "crates/lumbridge-theme/src/generated/reference_vectors.rs"),
);
const THEME_DIR = `${BUZZ}/desktop/src/shared/theme`;
// TypeScript source in the reference checkout uses extensionless relative
// imports, which Node does not resolve, and keeps the derivation's building
// blocks private. Both are fixed at load time so the files on disk stay as
// upstream wrote them.
registerHooks({
resolve(specifier, context, next) {
if (specifier.startsWith(".") && !/\.[cm]?[jt]sx?$/.test(specifier)) {
return next(`${specifier}.ts`, context);
}
return next(specifier, context);
},
load(url, context, next) {
const result = next(url, context);
if (!url.endsWith("/adaptive-theme.ts")) return result;
const source = result.source.toString();
return {
...result,
source: `${source}\nexport { mix as __mix, adjust as __adjust, calculateChromeColors as __chromeColors, hexToRgb as __hexToRgb, rgbToHex as __rgbToHex };\n`,
};
},
});
const { extractThemeInfo } = await import(`${THEME_DIR}/theme-loader.ts`);
const adaptive = await import(`${THEME_DIR}/adaptive-theme.ts`);
const { luminance, __mix: mix, __adjust: adjust, __chromeColors: chromeColors } = adaptive;
const normalise = (hex) => adaptive.__rgbToHex(adaptive.__hexToRgb(hex));
const names = readdirSync(join(ASSETS, "tm-themes"))
.filter((file) => file.endsWith(".json"))
.map((file) => file.replace(/\.json$/, ""))
.sort();
const vectors = names.map((name) => {
const document = JSON.parse(
readFileSync(join(ASSETS, "tm-themes", `${name}.json`), "utf8"),
);
const info = extractThemeInfo(name, {
...document,
settings: document.tokenColors,
});
// calculateChromeColors + the role formulas, exactly as createThemeVars runs
// them, but keeping hex instead of the CSS-variable HSL strings.
const bg = normalise(info.bg);
const fg = normalise(info.fg);
const { chrome, primary } = chromeColors(info.bg);
const isDark = luminance(info.bg) < 0.5;
const elevate = (amount) => adjust(primary, (isDark ? 1 : -1) * amount);
const border = mix(primary, info.fg, isDark ? 0.15 : 0.12);
return {
name,
bg,
fg,
comment: normalise(info.comment),
added: info.added && normalise(info.added),
deleted: info.deleted && normalise(info.deleted),
modified: info.modified && normalise(info.modified),
chrome: normalise(chrome),
surface: normalise(primary),
surfaceRaised: normalise(elevate(0.04)),
surfaceActive: normalise(elevate(0.06)),
surfaceOverlay: normalise(elevate(0.08)),
surfaceBetween: normalise(mix(chrome, primary, 0.5)),
border: normalise(border),
borderQuiet: normalise(mix(primary, border, 0.5)),
isDark,
ansi: Object.values(info.terminalPalette.ansi).map(normalise),
};
});
const hex = (value) => `Srgb::from_hex(0x${value.replace("#", "")})`;
const option = (value) => (value ? `Some(${hex(value)})` : "None");
const lines = [];
lines.push("//! Golden vectors from Buzz's original TypeScript, run under Node.");
lines.push("//!");
lines.push("//! Generated by `tools/theme-gen/reference/emit-reference-vectors.mjs` against");
lines.push("//! the Buzz checkout pinned in `docs/RESEARCH_SNAPSHOTS.md` (block/buzz,");
lines.push("//! Apache-2.0, commit `cb3144999beb`) and the theme files vendored under");
lines.push("//! `assets/themes/`. Do not edit by hand, and do not \"correct\" a value here to");
lines.push("//! make a test pass: these are the answers, and a disagreement means the Rust");
lines.push("//! port drifted. Decision 0018 records what it cost to learn that.");
lines.push("//!");
lines.push("//! Test-only: the shipped catalog carries the same colours, so this is a");
lines.push("//! second copy that exists to be compared against, not to be read from.");
lines.push("");
lines.push("use crate::color::Srgb;");
lines.push("");
lines.push("/// What the reference produces for one theme.");
lines.push("///");
lines.push("/// `ansi` is the terminal palette *before* Lumbridge forces colliding base hues");
lines.push("/// apart, so a slot the catalog substituted will differ here on purpose;");
lines.push("/// `catalog::ANSI_SUBSTITUTIONS` says which.");
lines.push("pub struct ReferenceVector {");
for (const field of [
"name: &'static str",
"bg: Srgb",
"fg: Srgb",
"comment: Srgb",
"added: Option<Srgb>",
"deleted: Option<Srgb>",
"modified: Option<Srgb>",
"chrome: Srgb",
"surface: Srgb",
"surface_raised: Srgb",
"surface_active: Srgb",
"surface_overlay: Srgb",
"surface_between: Srgb",
"border: Srgb",
"border_quiet: Srgb",
"is_dark: bool",
"ansi: [Srgb; 16]",
]) {
lines.push(` pub ${field},`);
}
lines.push("}");
lines.push("");
lines.push("/// Every vendored theme, including the ones the catalog holds back.");
lines.push("pub const REFERENCE_VECTORS: &[ReferenceVector] = &[");
for (const vector of vectors) {
lines.push(" ReferenceVector {");
lines.push(` name: ${JSON.stringify(vector.name)},`);
lines.push(` bg: ${hex(vector.bg)},`);
lines.push(` fg: ${hex(vector.fg)},`);
lines.push(` comment: ${hex(vector.comment)},`);
lines.push(` added: ${option(vector.added)},`);
lines.push(` deleted: ${option(vector.deleted)},`);
lines.push(` modified: ${option(vector.modified)},`);
lines.push(` chrome: ${hex(vector.chrome)},`);
lines.push(` surface: ${hex(vector.surface)},`);
lines.push(` surface_raised: ${hex(vector.surfaceRaised)},`);
lines.push(` surface_active: ${hex(vector.surfaceActive)},`);
lines.push(` surface_overlay: ${hex(vector.surfaceOverlay)},`);
lines.push(` surface_between: ${hex(vector.surfaceBetween)},`);
lines.push(` border: ${hex(vector.border)},`);
lines.push(` border_quiet: ${hex(vector.borderQuiet)},`);
lines.push(` is_dark: ${vector.isDark},`);
lines.push(" ansi: [");
for (const color of vector.ansi) lines.push(` ${hex(color)},`);
lines.push(" ],");
lines.push(" },");
}
lines.push("];");
lines.push("");
writeFileSync(OUT, lines.join("\n"));
console.error(`wrote ${vectors.length} reference vectors to ${OUT}`);