Files
WRNexusJS/packages/styles/src/theme.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

692 lines
21 KiB
TypeScript

/**
* Theme system - design tokens that work SSR and client-side.
*
* Tokens are plain CSS custom properties (`--wire-<key>`) so they cascade and
* can be overridden by user CSS. Each theme is a flat token map; the framework
* ships default `light`/`dark` sets and the user's config deep-merges over them.
*
* The server renders both `<html data-theme="...">` and
* `<html data-accent="...">` from cookies, so the correct theme and accent are
* present before the first paint. The reserved token key `color-scheme` is
* emitted as the native CSS property instead of a custom property.
*/
export type ThemeSemanticColor =
"primary" | "secondary" | "info" | "success" | "warning" | "danger" | "error";
export type ThemeToken =
| "color-scheme"
| "color-bg"
| "color-background"
| "color-foreground"
| "color-surface"
| "color-surface-2"
| "color-surface-raised"
| "color-surface-muted"
| "color-text"
| "color-text-muted"
| "color-text-subtle"
| "color-muted"
| "color-border"
| "color-border-strong"
| "color-code-background"
| "color-code-surface"
| "color-code-text"
| "color-code-muted"
| "color-code-border"
| `color-${ThemeSemanticColor}`
| `color-${ThemeSemanticColor}-${"hover" | "active" | "contrast" | "soft" | "muted" | "text"}`
| `color-on-${"primary" | "secondary"}`
| "radius"
| "radius-sm"
| "shadow-1"
| "shadow-sm"
| "shadow-md"
| "shadow-lg"
| "space-section"
| "space-section-sm"
| "container-max"
| "font-sans";
/** Known tokens get autocomplete while applications may add namespaced custom tokens. */
export type ThemeTokens = Partial<Record<ThemeToken, string>> & Record<string, string>;
export function defineThemeTokens<T extends ThemeTokens>(tokens: T): T {
return tokens;
}
export function themeVar(token: ThemeToken, fallback?: string): string {
return `var(--wire-${token}${fallback ? `, ${fallback}` : ""})`;
}
export const THEME_PALETTE_NAMES = [
"blue",
"indigo",
"violet",
"emerald",
"cyan",
"rose",
"amber",
"slate",
] as const;
export type ThemePaletteName = (typeof THEME_PALETTE_NAMES)[number];
/** Required semantic colors for a custom application palette. */
export interface CustomThemePalette {
primary: string;
primaryHover: string;
primaryContrast: string;
secondary: string;
secondaryHover: string;
secondaryContrast: string;
info: string;
success: string;
warning: string;
danger: string;
}
export interface ThemeAccentConfig {
/**
* Accent used when no `wire-accent` cookie is present.
*
* - Omitted: use the named `palette`, or `blue` when no palette is configured.
* - `false`: keep the configured base palette until the user explicitly picks an accent.
*/
default?: ThemePaletteName | false;
/** Runtime-selectable accent names. Defaults to every built-in THEME_PALETTE. */
options?: ThemePaletteName[];
}
export interface ThemeConfig {
/** Built-in palette name, or a complete custom semantic color palette. */
palette?: ThemePaletteName | CustomThemePalette;
/** Runtime accent/palette switcher configuration. */
accent?: ThemeAccentConfig;
/** Name of the theme used when no `wire-theme` cookie is present. */
default?: string;
/** Named token maps. Deep-merged over the framework's built-in light/dark. */
themes?: Record<string, ThemeTokens>;
}
export interface ResolvedTheme {
default: string;
names: string[];
themes: Record<string, ThemeTokens>;
defaultAccent?: ThemePaletteName;
accentNames: ThemePaletteName[];
}
/** Cookies used by the SSR renderer and client runtime. */
export const THEME_COOKIE = "wire-theme";
export const ACCENT_COOKIE = "wire-accent";
export const THEME_CSS_HREF = "/__wrnexus/theme.css";
export const THEME_JS_HREF = "/__wrnexus/theme.js";
/**
* Single source of truth for both configured palettes and runtime accents.
* Do not create a second hard-coded ACCENTS map in the browser runtime.
*/
export const THEME_PALETTES: Record<ThemePaletteName, CustomThemePalette> = {
blue: {
primary: "#2563eb",
primaryHover: "#1d4ed8",
primaryContrast: "#ffffff",
secondary: "#475569",
secondaryHover: "#334155",
secondaryContrast: "#ffffff",
info: "#0284c7",
success: "#16a34a",
warning: "#d97706",
danger: "#dc2626",
},
indigo: {
primary: "#4f46e5",
primaryHover: "#4338ca",
primaryContrast: "#ffffff",
secondary: "#7c3aed",
secondaryHover: "#6d28d9",
secondaryContrast: "#ffffff",
info: "#0284c7",
success: "#16a34a",
warning: "#d97706",
danger: "#dc2626",
},
violet: {
primary: "#7c3aed",
primaryHover: "#6d28d9",
primaryContrast: "#ffffff",
secondary: "#db2777",
secondaryHover: "#be185d",
secondaryContrast: "#ffffff",
info: "#2563eb",
success: "#059669",
warning: "#d97706",
danger: "#dc2626",
},
emerald: {
primary: "#059669",
primaryHover: "#047857",
primaryContrast: "#ffffff",
secondary: "#0f766e",
secondaryHover: "#115e59",
secondaryContrast: "#ffffff",
info: "#0284c7",
success: "#16a34a",
warning: "#d97706",
danger: "#dc2626",
},
cyan: {
primary: "#0891b2",
primaryHover: "#0e7490",
primaryContrast: "#ffffff",
secondary: "#2563eb",
secondaryHover: "#1d4ed8",
secondaryContrast: "#ffffff",
info: "#0284c7",
success: "#16a34a",
warning: "#d97706",
danger: "#dc2626",
},
rose: {
primary: "#e11d48",
primaryHover: "#be123c",
primaryContrast: "#ffffff",
secondary: "#9333ea",
secondaryHover: "#7e22ce",
secondaryContrast: "#ffffff",
info: "#2563eb",
success: "#059669",
warning: "#d97706",
danger: "#dc2626",
},
amber: {
primary: "#d97706",
primaryHover: "#b45309",
primaryContrast: "#ffffff",
secondary: "#92400e",
secondaryHover: "#78350f",
secondaryContrast: "#ffffff",
info: "#0284c7",
success: "#15803d",
warning: "#d97706",
danger: "#dc2626",
},
slate: {
primary: "#475569",
primaryHover: "#334155",
primaryContrast: "#ffffff",
secondary: "#0f172a",
secondaryHover: "#020617",
secondaryContrast: "#ffffff",
info: "#0369a1",
success: "#15803d",
warning: "#b45309",
danger: "#b91c1c",
},
};
type ColorScheme = "light" | "dark";
function colorMix(color: string, amount: number, base: "white" | "black"): string {
return `color-mix(in srgb, ${color} ${amount}%, ${base})`;
}
function semanticColorTokens(
name: "primary" | "secondary" | "info" | "success" | "warning" | "danger",
color: string,
scheme: ColorScheme,
): ThemeTokens {
const dark = scheme === "dark";
return {
[`color-${name}-soft`]: colorMix(color, dark ? 16 : 8, dark ? "black" : "white"),
[`color-${name}-muted`]: colorMix(color, dark ? 28 : 16, dark ? "black" : "white"),
[`color-${name}-text`]: dark ? colorMix(color, 64, "white") : colorMix(color, 82, "black"),
};
}
function paletteTokens(palette: CustomThemePalette, scheme: ColorScheme): ThemeTokens {
return {
"color-primary": palette.primary,
"color-primary-hover": palette.primaryHover,
"color-primary-active": palette.primaryHover,
"color-primary-contrast": palette.primaryContrast,
"color-on-primary": palette.primaryContrast,
...semanticColorTokens("primary", palette.primary, scheme),
"color-secondary": palette.secondary,
"color-secondary-hover": palette.secondaryHover,
"color-secondary-active": palette.secondaryHover,
"color-secondary-contrast": palette.secondaryContrast,
"color-on-secondary": palette.secondaryContrast,
...semanticColorTokens("secondary", palette.secondary, scheme),
"color-info": palette.info,
...semanticColorTokens("info", palette.info, scheme),
"color-success": palette.success,
...semanticColorTokens("success", palette.success, scheme),
"color-warning": palette.warning,
...semanticColorTokens("warning", palette.warning, scheme),
"color-danger": palette.danger,
"color-error": palette.danger,
...semanticColorTokens("danger", palette.danger, scheme),
"color-error-soft": colorMix(
palette.danger,
scheme === "dark" ? 16 : 8,
scheme === "dark" ? "black" : "white",
),
"color-error-muted": colorMix(
palette.danger,
scheme === "dark" ? 28 : 16,
scheme === "dark" ? "black" : "white",
),
"color-error-text":
scheme === "dark"
? colorMix(palette.danger, 64, "white")
: colorMix(palette.danger, 82, "black"),
};
}
function resolvePalette(value?: ThemeConfig["palette"]): CustomThemePalette {
if (!value) return THEME_PALETTES.blue;
if (typeof value === "string") {
const palette = THEME_PALETTES[value];
if (!palette) {
throw new Error(
`Unknown theme palette "${value}". Choose one of: ${THEME_PALETTE_NAMES.join(", ")}.`,
);
}
return palette;
}
const missing = Object.keys(THEME_PALETTES.blue).filter((key) => {
const color = value[key as keyof CustomThemePalette];
return typeof color !== "string" || !color.trim();
});
if (missing.length) {
throw new Error(`Custom theme palette is missing required colors: ${missing.join(", ")}.`);
}
return value;
}
function uniqueAccentNames(values?: ThemePaletteName[]): ThemePaletteName[] {
if (!values?.length) return [...THEME_PALETTE_NAMES];
const allowed = new Set<ThemePaletteName>();
for (const value of values) {
if (THEME_PALETTE_NAMES.includes(value)) allowed.add(value);
}
return allowed.size ? [...allowed] : [...THEME_PALETTE_NAMES];
}
function resolveDefaultAccent(
config: ThemeConfig | undefined,
accentNames: ThemePaletteName[],
): ThemePaletteName | undefined {
if (config?.accent?.default === false) return undefined;
const explicit = config?.accent?.default;
if (explicit && accentNames.includes(explicit)) return explicit;
const palette = config?.palette;
if (typeof palette === "string" && accentNames.includes(palette)) return palette;
if (!palette && accentNames.includes("blue")) return "blue";
return undefined;
}
/** Built-in themes so components have tokens out of the box. */
export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
light: {
"color-scheme": "light",
"color-bg": "#ffffff",
"color-background": "#ffffff",
"color-foreground": "#0b1020",
"color-surface-raised": "#ffffff",
"color-surface-muted": "#eceff6",
"color-text-muted": "#5a6178",
"color-text-subtle": "#737b91",
"color-border-strong": "#c7cedd",
"color-code-background": "#0b1020",
"color-code-surface": "#141a30",
"color-code-text": "#e7ecff",
"color-code-muted": "#9aa6d0",
"color-code-border": "#27304d",
"shadow-sm": "0 1px 2px rgba(16,24,40,0.06)",
"shadow-md": "0 8px 24px rgba(16,24,40,0.10)",
"shadow-lg": "0 20px 50px rgba(16,24,40,0.14)",
"space-section": "5rem",
"space-section-sm": "3.5rem",
"container-max": "80rem",
"color-surface": "#f6f7fb",
"color-surface-2": "#eceff6",
"color-text": "#0b1020",
"color-muted": "#5a6178",
"color-border": "#e2e6f0",
...paletteTokens(THEME_PALETTES.blue, "light"),
radius: "8px",
"radius-sm": "5px",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
"shadow-1": "0 1px 2px rgba(16,24,40,0.06), 0 1px 3px rgba(16,24,40,0.1)",
},
dark: {
"color-scheme": "dark",
"color-bg": "#000000",
"color-background": "#000000",
"color-foreground": "#f5f5f5",
"color-surface-raised": "#111111",
"color-surface-muted": "#181818",
"color-text-muted": "#b3b3b3",
"color-text-subtle": "#8a8a8a",
"color-border-strong": "#ffffff38",
"color-code-background": "#050505",
"color-code-surface": "#101010",
"color-code-text": "#f5f5f5",
"color-code-muted": "#a3a3a3",
"color-code-border": "#ffffff24",
"shadow-sm": "0 1px 2px rgba(0,0,0,0.28)",
"shadow-md": "0 10px 30px rgba(0,0,0,0.35)",
"shadow-lg": "0 24px 64px rgba(0,0,0,0.45)",
"space-section": "5rem",
"space-section-sm": "3.5rem",
"container-max": "80rem",
"color-surface": "#0a0a0a",
"color-surface-2": "#171717",
"color-text": "#f5f5f5",
"color-muted": "#a3a3a3",
"color-border": "#ffffff1f",
...paletteTokens(THEME_PALETTES.blue, "dark"),
radius: "10px",
"radius-sm": "6px",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
"shadow-1": "0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)",
},
};
function themeScheme(tokens: ThemeTokens): ColorScheme {
return tokens["color-scheme"] === "dark" ? "dark" : "light";
}
/** Merge the user's theme config over the built-in defaults. */
export function resolveThemeConfig(config?: ThemeConfig): ResolvedTheme {
const palette = resolvePalette(config?.palette);
const themes: Record<string, ThemeTokens> = {};
const names = new Set<string>([
...Object.keys(DEFAULT_THEMES),
...Object.keys(config?.themes ?? {}),
]);
for (const name of names) {
const base = DEFAULT_THEMES[name] ?? DEFAULT_THEMES.light;
const scheme = themeScheme(base);
themes[name] = {
...base,
...paletteTokens(palette, scheme),
...(config?.themes?.[name] ?? {}),
};
}
const list = Object.keys(themes);
const preferred = config?.default && themes[config.default] ? config.default : undefined;
const fallback = themes.dark ? "dark" : list[0]!;
const accentNames = uniqueAccentNames(config?.accent?.options);
const defaultAccent = resolveDefaultAccent(config, accentNames);
return {
default: preferred ?? fallback,
names: list,
themes,
defaultAccent,
accentNames,
};
}
/** Pick a valid theme name from a cookie value, falling back to the default. */
export function resolveThemeName(cookieValue: string | undefined, theme: ResolvedTheme): string {
return cookieValue && theme.themes[cookieValue] ? cookieValue : theme.default;
}
/** Pick a valid accent name from a cookie value, falling back to the configured default. */
export function resolveAccentName(
cookieValue: string | undefined,
theme: ResolvedTheme,
): ThemePaletteName | undefined {
if (cookieValue && theme.accentNames.includes(cookieValue as ThemePaletteName)) {
return cookieValue as ThemePaletteName;
}
return theme.defaultAccent;
}
function tokensToDeclarations(tokens: ThemeTokens): string {
return Object.entries(tokens)
.map(([key, value]) =>
key === "color-scheme" ? `color-scheme:${value};` : `--wire-${key}:${value};`,
)
.join("");
}
function selectorValue(value: string): string {
return JSON.stringify(value);
}
/**
* Generate the theme stylesheet.
*
* Theme selectors are emitted first. Accent selectors are emitted afterwards,
* so a selected accent consistently overrides every semantic palette token,
* including soft/muted/text variants, before the first paint.
*/
export function renderThemeCss(theme: ResolvedTheme): string {
const blocks: string[] = [];
const defaultTheme = theme.themes[theme.default];
if (defaultTheme) blocks.push(`:root{${tokensToDeclarations(defaultTheme)}}`);
for (const name of theme.names) {
blocks.push(
`[data-theme=${selectorValue(name)}]{${tokensToDeclarations(theme.themes[name]!)}}`,
);
}
for (const accentName of theme.accentNames) {
const palette = THEME_PALETTES[accentName];
if (defaultTheme) {
blocks.push(
`:root[data-accent=${selectorValue(accentName)}]{${tokensToDeclarations(
paletteTokens(palette, themeScheme(defaultTheme)),
)}}`,
);
}
for (const themeName of theme.names) {
const themeTokens = theme.themes[themeName]!;
blocks.push(
`[data-theme=${selectorValue(themeName)}][data-accent=${selectorValue(
accentName,
)}]{${tokensToDeclarations(paletteTokens(palette, themeScheme(themeTokens)))}}`,
);
}
}
return (
blocks.join("\n") +
'\nhtml,body{font-family:var(--wire-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n'
);
}
/**
* Generate the client theme runtime. It exposes `window.wireTheme` and
* `window.wireAccent`, and binds theme/accent controls.
*
* The runtime changes only data attributes and cookies. It never writes inline
* CSS variables and never uses localStorage, so CSS and SSR remain the single
* source of truth.
*/
export function renderThemeRuntime(theme: ResolvedTheme): string {
const names = JSON.stringify(theme.names);
const accentNames = JSON.stringify(theme.accentNames);
const defaultAccent = JSON.stringify(theme.defaultAccent ?? null);
return `(function(){
var THEME_COOKIE=${JSON.stringify(THEME_COOKIE)};
var ACCENT_COOKIE=${JSON.stringify(ACCENT_COOKIE)};
var THEMES=${names};
var ACCENTS=${accentNames};
var DEFAULT_THEME=${JSON.stringify(theme.default)};
var DEFAULT_ACCENT=${defaultAccent};
var MAX_AGE=31536000;
var el=document.documentElement;
function readCookie(name){
var prefix=encodeURIComponent(name)+"=";
var parts=document.cookie?document.cookie.split(";"):[];
for(var i=0;i<parts.length;i++){
var part=parts[i].trim();
if(part.indexOf(prefix)!==0)continue;
var value=part.slice(prefix.length);
try{return decodeURIComponent(value);}catch(_){return value;}
}
return null;
}
function writeCookie(name,value){
var secure=location.protocol==="https:"?";secure":"";
document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+
";path=/;max-age="+MAX_AGE+";samesite=lax"+secure;
}
function deleteCookie(name){
var secure=location.protocol==="https:"?";secure":"";
document.cookie=encodeURIComponent(name)+"=;path=/;max-age=0;samesite=lax"+secure;
}
function getTheme(){
var current=el.getAttribute("data-theme")||readCookie(THEME_COOKIE)||DEFAULT_THEME;
return THEMES.indexOf(current)>=0?current:DEFAULT_THEME;
}
function getAccent(){
var current=el.getAttribute("data-accent")||readCookie(ACCENT_COOKIE)||DEFAULT_ACCENT;
return current&&ACCENTS.indexOf(current)>=0?current:null;
}
function syncThemeButtons(root){
var current=getTheme();
(root||document).querySelectorAll("[data-wire-theme-set]").forEach(function(node){
node.setAttribute("aria-pressed",String(node.getAttribute("data-wire-theme-set")===current));
});
}
function syncAccentButtons(root){
var current=getAccent();
(root||document).querySelectorAll("[data-wire-accent-set]").forEach(function(node){
node.setAttribute("aria-pressed",String(node.getAttribute("data-wire-accent-set")===current));
});
}
function setTheme(name){
if(THEMES.indexOf(name)<0)return;
el.setAttribute("data-theme",name);
writeCookie(THEME_COOKIE,name);
syncThemeButtons(document);
window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:name}}));
}
function toggleTheme(){
var index=THEMES.indexOf(getTheme());
setTheme(THEMES[(index+1)%THEMES.length]);
}
function setAccent(name){
if(ACCENTS.indexOf(name)<0)return;
el.setAttribute("data-accent",name);
writeCookie(ACCENT_COOKIE,name);
syncAccentButtons(document);
window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:name}}));
}
function clearAccent(){
el.removeAttribute("data-accent");
deleteCookie(ACCENT_COOKIE);
syncAccentButtons(document);
window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:null}}));
}
function bind(root){
var target=root||document;
target.querySelectorAll("[data-wire-theme-toggle]").forEach(function(node){
if(node.__wireThemeBound)return;
node.__wireThemeBound=1;
node.addEventListener("click",toggleTheme);
});
target.querySelectorAll("[data-wire-theme-set]").forEach(function(node){
if(node.__wireThemeBound)return;
node.__wireThemeBound=1;
node.addEventListener("click",function(){
setTheme(node.getAttribute("data-wire-theme-set"));
});
});
target.querySelectorAll("[data-wire-accent-set]").forEach(function(node){
if(node.__wireAccentBound)return;
node.__wireAccentBound=1;
node.addEventListener("click",function(){
setAccent(node.getAttribute("data-wire-accent-set"));
});
});
target.querySelectorAll("[data-wire-accent-clear]").forEach(function(node){
if(node.__wireAccentBound)return;
node.__wireAccentBound=1;
node.addEventListener("click",clearAccent);
});
syncThemeButtons(target);
syncAccentButtons(target);
}
// SSR should already have these attributes. The cookie fallback only protects
// manually generated/static documents that did not pass through WRNexus SSR.
var initialTheme=getTheme();
if(!el.hasAttribute("data-theme"))el.setAttribute("data-theme",initialTheme);
var initialAccent=getAccent();
if(initialAccent&&!el.hasAttribute("data-accent"))el.setAttribute("data-accent",initialAccent);
window.wireTheme={
get:getTheme,
set:setTheme,
toggle:toggleTheme,
bind:bind,
themes:THEMES.slice()
};
window.wireAccent={
get:getAccent,
set:setAccent,
clear:clearAccent,
colors:ACCENTS.slice()
};
window.addEventListener("wrnexus:navigated",function(){bind(document);});
if(document.readyState==="loading"){
document.addEventListener("DOMContentLoaded",function(){bind(document);});
}else{
bind(document);
}
})();
`;
}