495 lines
15 KiB
JavaScript
495 lines
15 KiB
JavaScript
/**
|
|
* Build script: generates docs theme-presets.css from the frontend's preset definitions.
|
|
*
|
|
* Usage: node apps/docs/scripts/build-theme-presets.mjs
|
|
* Output: apps/docs/src/styles/theme-presets.css
|
|
*/
|
|
|
|
import {writeFileSync} from "fs";
|
|
import {dirname, resolve} from "path";
|
|
import {fileURLToPath} from "url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const OUTPUT = resolve(__dirname, "../src/styles/theme-presets.css");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Color math
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function hexToOklch(hex) {
|
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
|
|
const toLinear = (v) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
|
|
|
|
const lr = toLinear(r),
|
|
lg = toLinear(g),
|
|
lb = toLinear(b);
|
|
|
|
const x = 0.4124564 * lr + 0.3575761 * lg + 0.1804375 * lb;
|
|
const y = 0.2126729 * lr + 0.7151522 * lg + 0.072175 * lb;
|
|
const z = 0.0193339 * lr + 0.119192 * lg + 0.9503041 * lb;
|
|
|
|
const l_ = 0.8189330101 * x + 0.3618667424 * y - 0.1288597137 * z;
|
|
const m_ = 0.0329845436 * x + 0.9293118715 * y + 0.0361456387 * z;
|
|
const s_ = 0.0482003018 * x + 0.2643662691 * y + 0.633851707 * z;
|
|
|
|
const l3 = Math.cbrt(l_),
|
|
m3 = Math.cbrt(m_),
|
|
s3 = Math.cbrt(s_);
|
|
|
|
const okL = 0.2104542553 * l3 + 0.793617785 * m3 - 0.0040720468 * s3;
|
|
const okA = 1.9779984951 * l3 - 2.428592205 * m3 + 0.4505937099 * s3;
|
|
const okB = 0.0259040371 * l3 + 0.7827717662 * m3 - 0.808675766 * s3;
|
|
|
|
const c = Math.sqrt(okA * okA + okB * okB);
|
|
let h = (Math.atan2(okB, okA) * 180) / Math.PI;
|
|
|
|
if (h < 0) h += 360;
|
|
|
|
return {c, h, l: Math.max(0, Math.min(1, okL))};
|
|
}
|
|
|
|
function fmt(color) {
|
|
return `oklch(${(color.l * 100).toFixed(2)}% ${color.c.toFixed(4)} ${color.h.toFixed(2)})`;
|
|
}
|
|
|
|
function adjustHue(color, newHue) {
|
|
return {...color, h: newHue};
|
|
}
|
|
|
|
function adjustChroma(color, newChroma) {
|
|
return {...color, c: Math.max(0, newChroma)};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Default baseline neutral values (from generate-css-variables.ts)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULTS = {
|
|
dark: {
|
|
background: {c: 0.005, h: 285.823, l: 0.12},
|
|
border: {c: 0.006, h: 286.033, l: 0.28},
|
|
default: {c: 0.006, h: 286.033, l: 0.274},
|
|
foreground: {c: 0, h: 0, l: 0.9911},
|
|
muted: {c: 0.015, h: 286.067, l: 0.705},
|
|
overlay: {c: 0.0059, h: 285.89, l: 0.2103},
|
|
scrollbar: {c: 0.015, h: 286.067, l: 0.705},
|
|
segment: {c: 0.01, h: 285.93, l: 0.3964},
|
|
separator: {c: 0.006, h: 286.033, l: 0.25},
|
|
surface: {c: 0.0059, h: 285.89, l: 0.2103},
|
|
surfaceSecondary: {c: 0.0037, h: 286.14, l: 0.257},
|
|
surfaceTertiary: {c: 0.0024, h: 247.91, l: 0.2721},
|
|
},
|
|
light: {
|
|
background: {c: 0, h: 0, l: 0.9702},
|
|
border: {c: 0.004, h: 286.32, l: 0.9},
|
|
default: {c: 0.001, h: 286.375, l: 0.94},
|
|
foreground: {c: 0.0059, h: 285.89, l: 0.2103},
|
|
muted: {c: 0.0138, h: 285.94, l: 0.5517},
|
|
overlay: {c: 0, h: 0, l: 1},
|
|
scrollbar: {c: 0.006, h: 286.286, l: 0.871},
|
|
segment: {c: 0, h: 0, l: 1},
|
|
separator: {c: 0.004, h: 286.32, l: 0.92},
|
|
surface: {c: 0, h: 0, l: 1},
|
|
surfaceSecondary: {c: 0.0013, h: 286.37, l: 0.9524},
|
|
surfaceTertiary: {c: 0.0013, h: 286.37, l: 0.9373},
|
|
},
|
|
};
|
|
|
|
const SEMANTIC_HUE_BLEND = 0.12;
|
|
const DEFAULT_ACCENT_HUE = 253.83;
|
|
|
|
function semanticHue(semanticDefault, baseHue) {
|
|
let h = semanticDefault + (baseHue - DEFAULT_ACCENT_HUE) * SEMANTIC_HUE_BLEND;
|
|
|
|
while (h < 0) h += 360;
|
|
while (h >= 360) h -= 360;
|
|
|
|
return h;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Variable generators
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function accentForeground(l, c, h) {
|
|
if (l > 0.65) {
|
|
return `oklch(15% ${Math.min(c * 0.2, 0.03).toFixed(4)} ${h.toFixed(2)})`;
|
|
}
|
|
|
|
return "oklch(99.11% 0 0)";
|
|
}
|
|
|
|
function genAccent(l, c, h, fgOverride) {
|
|
const accent = fmt({c, h, l});
|
|
const fg = fgOverride ?? accentForeground(l, c, h);
|
|
|
|
return {
|
|
"--accent": accent,
|
|
"--accent-foreground": fg,
|
|
"--focus": accent,
|
|
};
|
|
}
|
|
|
|
function genNeutrals(hue, grayChroma, mode) {
|
|
const defs = DEFAULTS[mode];
|
|
const f = (color, chromaMul) => fmt(adjustChroma(adjustHue(color, hue), grayChroma * chromaMul));
|
|
|
|
const bg = f(defs.background, 1);
|
|
const fg = f(defs.foreground, 1);
|
|
const muted = f(defs.muted, 2);
|
|
const surface = f(defs.surface, mode === "dark" ? 2 : 0.5);
|
|
const surfaceSec = f(defs.surfaceSecondary, 1.5);
|
|
const surfaceTer = f(defs.surfaceTertiary, 1.5);
|
|
const defaultBg = f(defs.default, 1);
|
|
const border = f(defs.border, 1);
|
|
const separator = f(defs.separator, 1);
|
|
const overlay = f(defs.overlay, mode === "dark" ? 2 : 0.3);
|
|
const scrollbar = f(defs.scrollbar, 1);
|
|
const segment = f(defs.segment, 1);
|
|
const fieldBg = surface;
|
|
const fieldFg = fg;
|
|
|
|
const defaultFg = mode === "dark" ? "oklch(99.11% 0 0)" : fmt({c: 0.0059, h: hue, l: 0.2103});
|
|
|
|
return {
|
|
"--background": bg,
|
|
"--border": border,
|
|
"--default": defaultBg,
|
|
"--default-foreground": defaultFg,
|
|
"--field-background": fieldBg,
|
|
"--field-border": border,
|
|
"--field-foreground": fieldFg,
|
|
"--field-placeholder": muted,
|
|
"--foreground": fg,
|
|
"--link": fg,
|
|
"--muted": muted,
|
|
"--overlay": overlay,
|
|
"--overlay-foreground": fg,
|
|
"--scrollbar": scrollbar,
|
|
"--segment": segment,
|
|
"--segment-foreground": fg,
|
|
"--separator": separator,
|
|
"--surface": surface,
|
|
"--surface-foreground": fg,
|
|
"--surface-secondary": surfaceSec,
|
|
"--surface-secondary-foreground": fg,
|
|
"--surface-tertiary": surfaceTer,
|
|
"--surface-tertiary-foreground": fg,
|
|
};
|
|
}
|
|
|
|
function genSemanticFromHex(name, hex, baseHue, grayChroma) {
|
|
const oklch = hexToOklch(hex);
|
|
const h = semanticHue(oklch.h, baseHue);
|
|
const boost = 1 + grayChroma * 2;
|
|
const c = Math.min(oklch.c * boost, 0.35);
|
|
const color = fmt({c, h, l: oklch.l});
|
|
const fg = oklch.l > 0.67 ? fmt({c: 0.0059, h, l: 0.2103}) : "oklch(99.11% 0 0)";
|
|
|
|
return {
|
|
[`--${name}`]: color,
|
|
[`--${name}-foreground`]: fg,
|
|
};
|
|
}
|
|
|
|
function genSemanticFromOklch(name, oklchStr, fgOverride) {
|
|
const lMatch = oklchStr.match(/oklch\(([\d.]+)/);
|
|
const l = lMatch?.[1] ? parseFloat(lMatch[1]) : 0.5;
|
|
let autoFg;
|
|
|
|
if (l > 0.65) {
|
|
const hMatch = oklchStr.match(/oklch\([\d.]+\s+[\d.]+\s+([\d.]+)/);
|
|
const h = hMatch?.[1] ? parseFloat(hMatch[1]) : 0;
|
|
const cMatch = oklchStr.match(/oklch\([\d.]+\s+([\d.]+)/);
|
|
const c = cMatch?.[1] ? parseFloat(cMatch[1]) : 0;
|
|
|
|
autoFg = `oklch(15% ${Math.min(c * 0.3, 0.05).toFixed(4)} ${h.toFixed(2)})`;
|
|
} else {
|
|
autoFg = "oklch(99.11% 0 0)";
|
|
}
|
|
const fg = fgOverride ?? autoFg;
|
|
|
|
return {
|
|
[`--${name}`]: oklchStr,
|
|
[`--${name}-foreground`]: fg,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Radius map
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const RADIUS_MAP = {
|
|
"extra-large": "1rem",
|
|
"extra-small": "0.125rem",
|
|
large: "0.75rem",
|
|
medium: "0.5rem",
|
|
none: "0",
|
|
small: "0.25rem",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Preset definitions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DEFAULT_SEMANTIC = {
|
|
dangerHex: "#FF383C",
|
|
successHex: "#17C964",
|
|
warningHex: "#F5A524",
|
|
};
|
|
|
|
const PRESETS = [
|
|
{
|
|
accentHex: "#7DD3FC",
|
|
base: 0.015,
|
|
formRadius: "large",
|
|
id: "sky",
|
|
radius: "medium",
|
|
},
|
|
{
|
|
accentHex: "#C084FC",
|
|
base: 0.015,
|
|
formRadius: "large",
|
|
id: "lavender",
|
|
radius: "medium",
|
|
},
|
|
{
|
|
accentHex: "#86EFAC",
|
|
base: 0.015,
|
|
formRadius: "large",
|
|
id: "mint",
|
|
radius: "medium",
|
|
},
|
|
{
|
|
accentHex: "#E50914",
|
|
base: 0.008,
|
|
formRadius: "extra-small",
|
|
id: "netflix",
|
|
radius: "extra-small",
|
|
semanticOverrides: {
|
|
dark: {
|
|
danger: {color: "oklch(0.4964 0.1994 28.56)"},
|
|
success: {color: "oklch(0.7677 0.1899 148.1)"},
|
|
warning: {color: "oklch(0.8239 0.153 74.6)"},
|
|
},
|
|
light: {
|
|
danger: {color: "oklch(0.4823 0.1938 27.64)"},
|
|
success: {color: "oklch(0.5148 0.1337 146.82)"},
|
|
warning: {color: "oklch(0.561 0.116571 78.9352)"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
accentHex: "#000000",
|
|
base: 0.005,
|
|
darkAccent: {c: 0, h: 0, l: 0.9848},
|
|
darkAccentHex: "#FBFBFB",
|
|
formRadius: "small",
|
|
id: "uber",
|
|
radius: "small",
|
|
semanticOverrides: {
|
|
dark: {
|
|
danger: {color: "oklch(0.7044 0.1872 23.19)"},
|
|
success: {color: "oklch(0.6514 0.1321 156.22)"},
|
|
warning: {color: "oklch(0.8803 0.1348 86.06)"},
|
|
},
|
|
light: {
|
|
danger: {color: "oklch(0.573 0.2249 21.97)"},
|
|
success: {color: "oklch(0.6277 0.1604 153.06)"},
|
|
warning: {color: "oklch(0.8446 0.1525 80.6)"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
accentHex: "#1ED760",
|
|
base: 0.002,
|
|
formRadius: "extra-small",
|
|
id: "spotify",
|
|
radius: "medium",
|
|
semanticOverrides: {
|
|
dark: {
|
|
danger: {color: "oklch(0.5931 0.2338 25.42)"},
|
|
success: {color: "oklch(0.7697 0.2124 148.67)"},
|
|
warning: {color: "oklch(0.7921 0.1626 67.42)"},
|
|
},
|
|
light: {
|
|
danger: {color: "oklch(0.5509 0.2166 25.29)"},
|
|
success: {color: "oklch(0.6072 0.1647 149.02)"},
|
|
warning: {color: "oklch(0.6972 0.1687 54.22)"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
accentHex: "#4F44E0",
|
|
base: 0.002,
|
|
formRadius: "extra-small",
|
|
id: "coinbase",
|
|
radius: "medium",
|
|
semanticOverrides: {
|
|
dark: {
|
|
danger: {color: "oklch(0.6545 0.2145 22.31)"},
|
|
success: {color: "oklch(0.7574 0.180554 156.931)"},
|
|
warning: {color: "oklch(0.8095 0.1119 61.69)"},
|
|
},
|
|
light: {
|
|
danger: {color: "oklch(0.5507 0.2062 24)"},
|
|
success: {color: "oklch(0.5438 0.1268 157.17)"},
|
|
warning: {color: "oklch(0.8095 0.1119 61.69)"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
accentHex: "#FF5A5F",
|
|
base: 0,
|
|
formRadius: "large",
|
|
id: "airbnb",
|
|
radius: "medium",
|
|
semanticOverrides: {
|
|
dark: {
|
|
accentForeground: "oklch(0.9911 0 0)",
|
|
danger: {color: "oklch(0.5392 0.1816 33.72)", foreground: "oklch(0.9911 0 0)"},
|
|
success: {color: "oklch(0.652 0.114864 185.0749)", foreground: "oklch(0.9911 0 0)"},
|
|
warning: {color: "oklch(0.8197 0.170602 78.4658)"},
|
|
},
|
|
light: {
|
|
accentForeground: "oklch(0.9911 0 0)",
|
|
danger: {color: "oklch(0.5392 0.1816 33.72)", foreground: "oklch(0.9911 0 0)"},
|
|
success: {color: "oklch(0.5573 0.0947 199.48)", foreground: "oklch(0.9911 0 0)"},
|
|
warning: {color: "oklch(0.6904 0.1972 38.75)", foreground: "oklch(0.9911 0 0)"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
accentHex: "#5865F2",
|
|
base: 0.01,
|
|
formRadius: "large",
|
|
id: "discord",
|
|
radius: "small",
|
|
semanticOverrides: {
|
|
dark: {
|
|
danger: {color: "oklch(0.6318 0.2075 24.57)"},
|
|
success: {color: "oklch(0.8548 0.1967 150.16)"},
|
|
warning: {color: "oklch(0.9218 0.1571 99.87)"},
|
|
},
|
|
light: {
|
|
danger: {color: "oklch(0.5884 0.1993 24.39)"},
|
|
success: {color: "oklch(0.532 0.1238 151.57)"},
|
|
warning: {color: "oklch(0.9218 0.1571 99.87)"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
accentHex: "#FF6600",
|
|
base: 0.01,
|
|
formRadius: "extra-large",
|
|
id: "rabbit",
|
|
radius: "medium",
|
|
semanticOverrides: {
|
|
dark: {
|
|
danger: {color: "oklch(0.6291 0.2565 29.09)"},
|
|
success: {color: "oklch(0.7113 0.2043 140.81)"},
|
|
},
|
|
light: {
|
|
danger: {color: "oklch(0.6291 0.2565 29.09)"},
|
|
success: {color: "oklch(0.7113 0.2043 140.81)"},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generate CSS for a single preset
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function generatePresetCss(preset) {
|
|
const lightAccent = hexToOklch(preset.accentHex);
|
|
const darkAccent = preset.darkAccentHex ? hexToOklch(preset.darkAccentHex) : lightAccent;
|
|
const hue = lightAccent.h;
|
|
const grayChroma = preset.base;
|
|
const so = preset.semanticOverrides;
|
|
|
|
function modeVars(mode) {
|
|
const accent = mode === "dark" ? darkAccent : lightAccent;
|
|
const modeAccent = preset[`${mode}Accent`] ?? accent;
|
|
const accentVars = genAccent(
|
|
modeAccent.l,
|
|
modeAccent.c,
|
|
modeAccent.h,
|
|
so?.[mode]?.accentForeground,
|
|
);
|
|
const neutrals = genNeutrals(hue, grayChroma, mode);
|
|
|
|
let semantics = {};
|
|
|
|
for (const name of ["success", "warning", "danger"]) {
|
|
if (so?.[mode]?.[name]) {
|
|
semantics = {
|
|
...semantics,
|
|
...genSemanticFromOklch(name, so[mode][name].color, so[mode][name].foreground),
|
|
};
|
|
} else {
|
|
semantics = {
|
|
...semantics,
|
|
...genSemanticFromHex(name, DEFAULT_SEMANTIC[`${name}Hex`], hue, grayChroma),
|
|
};
|
|
}
|
|
}
|
|
|
|
const radius = {};
|
|
|
|
if (preset.radius !== "medium") {
|
|
radius["--radius"] = RADIUS_MAP[preset.radius];
|
|
}
|
|
if (preset.formRadius !== "large") {
|
|
radius["--field-radius"] = RADIUS_MAP[preset.formRadius];
|
|
}
|
|
|
|
const charts = {
|
|
"--chart-1": "oklch(from var(--accent) calc(l - 0.24) c h)",
|
|
"--chart-2": "oklch(from var(--accent) calc(l - 0.12) c h)",
|
|
"--chart-3": "var(--accent)",
|
|
"--chart-4": "oklch(from var(--accent) calc(l + 0.12) c h)",
|
|
"--chart-5": "oklch(from var(--accent) calc(l + 0.24) c h)",
|
|
};
|
|
|
|
return {...neutrals, ...accentVars, ...semantics, ...radius, ...charts};
|
|
}
|
|
|
|
const light = modeVars("light");
|
|
const dark = modeVars("dark");
|
|
|
|
const renderVars = (vars) =>
|
|
Object.entries(vars)
|
|
.map(([k, v]) => ` ${k}: ${v};`)
|
|
.join("\n");
|
|
|
|
return `/* ${preset.id.charAt(0).toUpperCase() + preset.id.slice(1)} theme */
|
|
[data-design-theme="${preset.id}"]:not(.dark):not([data-theme="dark"]) {
|
|
${renderVars(light)}
|
|
}
|
|
|
|
[data-design-theme="${preset.id}"].dark,
|
|
[data-design-theme="${preset.id}"][data-theme="dark"] {
|
|
color-scheme: dark;
|
|
${renderVars(dark)}
|
|
}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const banner = `/* ============================================================================
|
|
Auto-generated theme presets for docs.
|
|
Run: node apps/docs/scripts/build-theme-presets.mjs
|
|
============================================================================ */\n`;
|
|
|
|
const body = PRESETS.map(generatePresetCss).join("\n\n");
|
|
const css = `${banner}@layer base {\n${body}\n}\n`;
|
|
|
|
writeFileSync(OUTPUT, css);
|
|
console.log(`✓ Generated ${PRESETS.length} theme presets → ${OUTPUT}`);
|