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
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user