/** * 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 ] \ * [--out ] * * 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", "deleted: Option", "modified: Option", "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}`);