Files
openchamber/scripts/harmonize-theme.mjs
T

276 lines
12 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* harmonize-theme.mjs
*
* Aligns a theme's accent palette in OKLCH space so every accent shares one
* saturation (chroma) and one brightness (lightness) target while keeping its
* own hue. This is how you make colors borrowed from different themes feel like
* one family — e.g. pulling Flexoki's punchy status hues down to Vitesse's
* calmer saturation.
*
* It only touches the accent roles listed in ACCENT_ROLES (primary / status /
* pr). Surfaces, borders, text, and syntax are left untouched — syntax is
* usually already the reference you are matching to.
*
* Pipeline per color: hex -> linear sRGB -> OKLab -> OKLCH
* -> set L = target.l, C = target.c, keep H
* -> gamut-fit (reduce C until it fits sRGB) -> hex (alpha preserved)
*
* Usage:
* node scripts/harmonize-theme.mjs <theme.json> # dry-run table
* node scripts/harmonize-theme.mjs <theme.json> --write # rewrite in place
* node scripts/harmonize-theme.mjs <theme.json> --out=x.json
* Overrides: --l=0.70 --c=0.085 (defaults are chosen per light/dark variant)
*
* No dependencies; math ported from the OpenCode desktop color engine.
*/
import fs from 'node:fs';
// ---- sRGB <-> OKLCH ---------------------------------------------------------
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const hue = (v) => ((v % 360) + 360) % 360;
function parseHex(hex) {
let h = hex.replace('#', '');
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
const a = h.length === 8 ? h.slice(6, 8) : null;
const n = parseInt(h.slice(0, 6), 16);
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, alpha: a };
}
function toHex(r, g, b, alpha) {
const c = (v) => Math.round(clamp(v, 0, 1) * 255).toString(16).padStart(2, '0');
return `#${c(r)}${c(g)}${c(b)}${alpha ?? ''}`;
}
const srgbToLinear = (v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
const linearToSrgb = (v) => (v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055);
function rgbToOklch(r, g, b) {
const lr = srgbToLinear(r), lg = srgbToLinear(g), lb = srgbToLinear(b);
const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
const l = Math.cbrt(l_), m = Math.cbrt(m_), s = Math.cbrt(s_);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
let H = Math.atan2(bb, a) * (180 / Math.PI);
if (H < 0) H += 360;
return { l: L, c: Math.sqrt(a * a + bb * bb), h: H };
}
function oklchToRgb({ l: L, c: C, h: H }) {
const a = C * Math.cos((H * Math.PI) / 180);
const b = C * Math.sin((H * Math.PI) / 180);
const l = L + 0.3963377774 * a + 0.2158037573 * b;
const m = L - 0.1055613458 * a - 0.0638541728 * b;
const s = L - 0.0894841775 * a - 1.291485548 * b;
const l3 = l * l * l, m3 = m * m * m, s3 = s * s * s;
return {
r: linearToSrgb(4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3),
g: linearToSrgb(-1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3),
b: linearToSrgb(-0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3),
};
}
const inGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
function fitOklch(o) {
const base = { l: clamp(o.l, 0, 1), c: Math.max(0, o.c), h: hue(o.h) };
if (inGamut(oklchToRgb(base))) return base;
let c = base.c;
for (let i = 0; i < 24; i++) {
c *= 0.9;
const next = { ...base, c };
if (inGamut(oklchToRgb(next))) return next;
}
return { ...base, c: 0 };
}
function oklchToHex(o, alpha) {
const { r, g, b } = oklchToRgb(fitOklch(o));
return toHex(r, g, b, alpha);
}
// ---- harmonization ----------------------------------------------------------
// Per variant: the single chroma (saturation) and lightness every accent lands
// on. Dark values match Vitesse Dark's calm accents (~L .68, C .085); light
// values match Vitesse Light (~L .55, C .10) and stay readable on white.
const TARGETS = {
dark: { l: 0.68, c: 0.135 },
light: { l: 0.55, c: 0.145 },
};
// Status accent roles to align. Each re-derives its translucent background,
// border, and on-fill text color from the harmonized base.
const STATUS_ROLES = ['error', 'warning', 'success', 'info'];
// pr accents that carry a hue (draft stays a neutral grey, so it is skipped).
const PR_ROLES = ['open', 'blocked', 'merged', 'closed'];
const STATUS_BG_ALPHA = '20';
const STATUS_BORDER_ALPHA = '50';
// Lightness offsets applied to the harmonized primary base to derive its
// interaction shades (dark themes lift on hover/press, light themes deepen).
const PRIMARY_OFFSETS = {
dark: { hover: 0.05, active: 0.1, emphasis: 0.1 },
light: { hover: -0.06, active: -0.11, emphasis: -0.06 },
};
function harmonize(hex, target) {
const { r, g, b, alpha } = parseHex(hex);
const src = rgbToOklch(r, g, b);
const out = { l: target.l, c: target.c, h: src.h };
return { hex: oklchToHex(out, alpha), src, out };
}
// shift a harmonized base to a new lightness, keeping chroma + hue
function atLightness(hex, l) {
const { r, g, b } = parseHex(hex);
const o = rgbToOklch(r, g, b);
return oklchToHex({ l: clamp(l, 0, 1), c: o.c, h: o.h });
}
// pick #000 / #fff for text on a solid fill
function onColor(hex) {
const { r, g, b } = parseHex(hex);
const L = rgbToOklch(r, g, b).l;
return L > 0.6 ? '#000000' : '#ffffff';
}
// multiply chroma by a factor, keeping lightness + hue (gamut-fit clamps the
// already-saturated ones, so pastels gain the most). Used to de-pastel syntax.
function scaleChroma(hex, factor) {
const { r, g, b, alpha } = parseHex(hex);
const src = rgbToOklch(r, g, b);
const out = { l: src.l, c: src.c * factor, h: src.h };
return { hex: oklchToHex(out, alpha), src, out };
}
// Syntax token colors are boosted, but not the code panel background, the
// default text color, or the deliberately-muted local-variable tone.
const SYNTAX_SKIP_BASE = new Set(['background', 'foreground']);
const SYNTAX_SKIP_TOKENS = new Set(['variableLocal']);
function saturateSyntax(theme, factor) {
const syntax = theme.colors?.syntax;
const rows = [];
const bump = (bag, skip, prefix) => {
if (!bag) return;
for (const [key, val] of Object.entries(bag)) {
if (typeof val !== 'string' || skip.has(key)) continue;
const { hex, src, out } = scaleChroma(val, factor);
bag[key] = hex;
rows.push([`${prefix}.${key}`, val, hex, src, out]);
}
};
bump(syntax?.base, SYNTAX_SKIP_BASE, 'base');
bump(syntax?.tokens, SYNTAX_SKIP_TOKENS, 'tokens');
return rows;
}
function main() {
const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
if (!file) {
console.error(
'usage: node scripts/harmonize-theme.mjs <theme.json> [--write] [--out=path] [--l=..] [--c=..]\n' +
' node scripts/harmonize-theme.mjs <theme.json> --syntax[=factor] [--write] (boost syntax saturation only)',
);
process.exit(1);
}
const write = args.includes('--write');
const outArg = args.find((a) => a.startsWith('--out='));
const lArg = args.find((a) => a.startsWith('--l='));
const cArg = args.find((a) => a.startsWith('--c='));
const theme = JSON.parse(fs.readFileSync(file, 'utf8'));
const variant = theme.metadata?.variant === 'light' ? 'light' : 'dark';
const target = { ...TARGETS[variant] };
if (lArg) target.l = parseFloat(lArg.split('=')[1]);
if (cArg) target.c = parseFloat(cArg.split('=')[1]);
// --- syntax-only mode: boost token saturation, leave accents untouched ---
const synArg = args.find((a) => a === '--syntax' || a.startsWith('--syntax='));
if (synArg) {
const factor = synArg.includes('=') ? parseFloat(synArg.split('=')[1]) : 1.3;
const rows = saturateSyntax(theme, factor);
console.log(`\n${file} (syntax saturation ×${factor}, variant: ${variant})\n`);
console.log(' token before -> after C (chroma)');
for (const [name, before, after, src, out] of rows) {
console.log(
` ${name.padEnd(20)} ${before.slice(0, 7)} -> ${after.slice(0, 7)} ${src.c.toFixed(3)}${out.c.toFixed(3)}`,
);
}
const outPath = outArg ? outArg.split('=')[1] : file;
if (write || outArg) {
fs.writeFileSync(outPath, JSON.stringify(theme, null, 2) + '\n');
console.log(`\nwrote ${outPath}`);
} else {
console.log('\n(dry run — pass --write to save)');
}
return;
}
const rows = [];
const record = (name, before, after, src, out) => rows.push([name, before, after, src, out]);
// --- primary: harmonize base, derive interaction shades from it ----------
const primary = theme.colors?.primary;
if (primary && typeof primary.base === 'string') {
const { hex, src, out } = harmonize(primary.base, target);
record('primary.base', primary.base, hex, src, out);
primary.base = hex;
const off = PRIMARY_OFFSETS[variant];
if ('hover' in primary) primary.hover = atLightness(hex, target.l + off.hover);
if ('active' in primary) primary.active = atLightness(hex, target.l + off.active);
if ('emphasis' in primary) primary.emphasis = atLightness(hex, target.l + off.emphasis);
if ('muted' in primary) primary.muted = hex.slice(0, 7) + '80';
if ('foreground' in primary) primary.foreground = onColor(hex);
// Wire the interactive focus/selection accent to the harmonized primary.
const inter = theme.colors?.interactive;
if (inter) {
if ('borderFocus' in inter) inter.borderFocus = hex;
if ('focus' in inter) inter.focus = hex;
if ('focusRing' in inter) inter.focusRing = hex.slice(0, 7) + '55';
if ('selection' in inter) inter.selection = hex.slice(0, 7) + '2b';
}
}
// --- status: harmonize base, re-derive tints + on-fill text --------------
const status = theme.colors?.status;
if (status) {
for (const key of STATUS_ROLES) {
if (typeof status[key] !== 'string') continue;
const { hex, src, out } = harmonize(status[key], target);
record(`status.${key}`, status[key], hex, src, out);
status[key] = hex;
const bare = hex.slice(0, 7);
if (`${key}Background` in status) status[`${key}Background`] = bare + STATUS_BG_ALPHA;
if (`${key}Border` in status) status[`${key}Border`] = bare + STATUS_BORDER_ALPHA;
if (`${key}Foreground` in status) status[`${key}Foreground`] = onColor(hex);
}
}
// --- pr: harmonize the hued roles ----------------------------------------
const pr = theme.colors?.pr;
if (pr) {
for (const key of PR_ROLES) {
if (typeof pr[key] !== 'string') continue;
const { hex, src, out } = harmonize(pr[key], target);
record(`pr.${key}`, pr[key], hex, src, out);
pr[key] = hex;
}
}
console.log(`\n${file} (variant: ${variant}, target L=${target.l} C=${target.c})\n`);
console.log(' token before -> after ΔC (chroma)');
for (const [name, before, after, src, out] of rows) {
console.log(
` ${name.padEnd(20)} ${before.slice(0, 7)} -> ${after.slice(0, 7)} ` +
`${src.c.toFixed(3)}${out.c.toFixed(3)} L ${src.l.toFixed(2)}${out.l.toFixed(2)} H${Math.round(src.h)}`,
);
}
const outPath = outArg ? outArg.split('=')[1] : file;
if (write || outArg) {
fs.writeFileSync(outPath, JSON.stringify(theme, null, 2) + '\n');
console.log(`\nwrote ${outPath}`);
} else {
console.log('\n(dry run — pass --write to save, or --out=path)');
}
}
main();