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
212 lines
8.9 KiB
JavaScript
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}`);
|