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
This commit is contained in:
co-authored by
Claude Opus 5
parent
7bee985279
commit
401760d670
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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}`);
|
||||
Reference in New Issue
Block a user