Files
openchamber/scripts/harmonize-theme.mjs
T
Bohdan Triapitsyn 83a66d0a6d feat(theme): add OpenChamber hybrid theme and make it the default
- Add OpenChamber brand theme (dark+light): Vitesse syntax over OpenCode-deep neutrals with Flexoki accents, harmonized in OKLCH

- Add scripts/harmonize-theme.mjs: align accent saturation/lightness and boost syntax chroma via OKLCH

- Set default theme to OpenChamber (was Flexoki)

- Rename fields-of-the-shire off the openchamber-* id; keep it as a normal preset

- Remove OpenCode and OC-2 ports and all disabled theme JSON definitions

- Make panel/header/sidebar borders opaque and theme-driven; global default border now uses --border instead of hardcoded rgba
2026-07-30 17:43:39 +03:00

276 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();