Files
2026-07-13 12:12:04 +08:00

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}`);