900 lines
29 KiB
TypeScript
900 lines
29 KiB
TypeScript
/**
|
|
* Theme system - design tokens that work SSR and client-side.
|
|
*
|
|
* Tokens are plain CSS custom properties (`--wrn-<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-surface-soft"
|
|
| "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-2"
|
|
| "shadow-3"
|
|
| "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(--wrn-${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 `wrn-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[];
|
|
/** Cookie scope used to persist the selected accent across related applications. */
|
|
cookie?: ThemeCookieConfig;
|
|
}
|
|
|
|
export interface ThemeCookieConfig {
|
|
/** Parent domain shared by related applications, for example `localhost` or `.example.com`. */
|
|
domain?: string;
|
|
/** Cookie path. Defaults to `/`. */
|
|
path?: string;
|
|
/** SameSite policy. Defaults to `Lax`. */
|
|
sameSite?: "Strict" | "Lax" | "None";
|
|
/** Force or disable Secure. When omitted it follows the current protocol. */
|
|
secure?: boolean;
|
|
/** Cookie lifetime in seconds. Defaults to one year. */
|
|
maxAge?: number;
|
|
}
|
|
|
|
export type BrowserCookieOptions = ThemeCookieConfig;
|
|
|
|
/** Shared browser-cookie policy with optional per-preference overrides. */
|
|
export interface BrowserCookiesConfig {
|
|
defaults?: BrowserCookieOptions;
|
|
theme?: BrowserCookieOptions;
|
|
accent?: BrowserCookieOptions;
|
|
language?: BrowserCookieOptions;
|
|
}
|
|
|
|
export type BrowserCookiePreference = "theme" | "accent" | "language";
|
|
|
|
export interface BrowserCookieApi {
|
|
readonly config: BrowserCookiesConfig;
|
|
get(name: string): string | null;
|
|
options(
|
|
preference?: BrowserCookiePreference | BrowserCookieOptions,
|
|
override?: BrowserCookieOptions,
|
|
): BrowserCookieOptions;
|
|
set(
|
|
name: string,
|
|
value: string,
|
|
preference?: BrowserCookiePreference | BrowserCookieOptions,
|
|
override?: BrowserCookieOptions,
|
|
): void;
|
|
remove(
|
|
name: string,
|
|
preference?: BrowserCookiePreference | BrowserCookieOptions,
|
|
override?: BrowserCookieOptions,
|
|
): void;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
wrnCookies: BrowserCookieApi;
|
|
}
|
|
}
|
|
|
|
export function resolveBrowserCookieOptions(
|
|
config: BrowserCookiesConfig | undefined,
|
|
preference: BrowserCookiePreference,
|
|
override?: BrowserCookieOptions,
|
|
): BrowserCookieOptions {
|
|
return { ...(config?.defaults ?? {}), ...(config?.[preference] ?? {}), ...(override ?? {}) };
|
|
}
|
|
|
|
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 `wrn-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[];
|
|
accentCookie?: ThemeCookieConfig;
|
|
themeCookie?: ThemeCookieConfig;
|
|
cookies?: BrowserCookiesConfig;
|
|
}
|
|
|
|
/** Cookies used by the SSR renderer and client runtime. */
|
|
export const THEME_COOKIE = "wrn-theme";
|
|
export const ACCENT_COOKIE = "wrn-accent";
|
|
export const THEME_CSS_HREF = "/__wrnexus/theme.css";
|
|
export const THEME_CSS_PREFIX = "/__wrnexus/theme/";
|
|
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"),
|
|
/*
|
|
* hover and contrast are generated for every semantic colour, not just
|
|
* primary and secondary. Components referenced --wrn-color-danger-hover
|
|
* and the rest for a long time with nothing defining them, so those states
|
|
* simply did not paint.
|
|
*
|
|
* primary and secondary override these from the palette, which is why
|
|
* this is spread before their explicit entries rather than after.
|
|
*/
|
|
[`color-${name}-hover`]: dark ? colorMix(color, 84, "white") : colorMix(color, 88, "black"),
|
|
[`color-${name}-contrast`]: "#ffffff",
|
|
};
|
|
}
|
|
|
|
function paletteTokens(palette: CustomThemePalette, scheme: ColorScheme): ThemeTokens {
|
|
return {
|
|
// Spread first so the palette's own hover and contrast win over the
|
|
// derived defaults below it.
|
|
...semanticColorTokens("primary", palette.primary, scheme),
|
|
"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("secondary", palette.secondary, 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,
|
|
|
|
"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"),
|
|
|
|
/*
|
|
* Tokens the components have always referenced but nothing defined.
|
|
*
|
|
* An undefined custom property does not warn -- it resolves to nothing --
|
|
* so every focus ring drew with no colour and every soft surface rendered
|
|
* transparent. Derived here so they follow the palette and the accent
|
|
* rather than being pinned per scheme.
|
|
*/
|
|
// The palette has no danger contrast; danger is saturated in both schemes,
|
|
// so white is right either way.
|
|
"color-on-danger": "#ffffff",
|
|
"color-focus": palette.primary,
|
|
"color-surface-subtle": colorMix(
|
|
palette.primary,
|
|
scheme === "dark" ? 6 : 4,
|
|
scheme === "dark" ? "black" : "white",
|
|
),
|
|
"color-input-background": scheme === "dark" ? "#0f0f0f" : "#ffffff",
|
|
"color-input-text": scheme === "dark" ? "#f5f5f5" : "#0b1020",
|
|
"color-input-placeholder": scheme === "dark" ? "#8a8a8a" : "#737b91",
|
|
"color-input-border": scheme === "dark" ? "#ffffff26" : "#d7dced",
|
|
"color-input-border-hover": scheme === "dark" ? "#ffffff38" : "#c7cedd",
|
|
"color-input-border-focus": palette.primary,
|
|
};
|
|
}
|
|
|
|
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-surface-soft": "#f1f4f9",
|
|
"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)",
|
|
"shadow-2": "0 8px 24px rgba(16,24,40,0.10)",
|
|
"shadow-3": "0 20px 50px rgba(16,24,40,0.14)",
|
|
},
|
|
dark: {
|
|
"color-scheme": "dark",
|
|
"color-bg": "#000000",
|
|
"color-background": "#000000",
|
|
"color-foreground": "#f5f5f5",
|
|
"color-surface-raised": "#111111",
|
|
"color-surface-muted": "#181818",
|
|
"color-surface-soft": "#141414",
|
|
"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)",
|
|
"shadow-2": "0 10px 30px rgba(0,0,0,0.35)",
|
|
"shadow-3": "0 24px 64px rgba(0,0,0,0.45)",
|
|
},
|
|
};
|
|
|
|
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,
|
|
cookies?: BrowserCookiesConfig,
|
|
): 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,
|
|
themeCookie: resolveBrowserCookieOptions(cookies, "theme"),
|
|
accentCookie: resolveBrowserCookieOptions(cookies, "accent", config?.accent?.cookie),
|
|
cookies,
|
|
};
|
|
}
|
|
|
|
/** 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};` : `--wrn-${key}:${value};`,
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
function selectorValue(value: string): string {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function globalThemeCss(): string {
|
|
return (
|
|
'\nhtml,body{font-family:var(--wrn-font-sans,"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif);}\n' +
|
|
":root{--wrn-radius-sm:0.55rem;--wrn-radius-md:0.9rem;--wrn-radius-lg:1.35rem;" +
|
|
"--wrn-shadow-1:0 1px 2px color-mix(in srgb, black 22%, transparent)," +
|
|
"0 10px 30px color-mix(in srgb, black 14%, transparent);" +
|
|
"--wrn-shadow-2:0 10px 30px color-mix(in srgb, black 18%, transparent);" +
|
|
"--wrn-shadow-3:0 24px 64px color-mix(in srgb, black 24%, transparent);}\n" +
|
|
"wrn-slot{display:contents;}\n" +
|
|
"[data-for]{display:none !important;}\n"
|
|
);
|
|
}
|
|
|
|
/** URL for the small stylesheet containing only one active theme/accent pair. */
|
|
export function activeThemeCssHref(themeName: string, accentName?: string): string {
|
|
return `${THEME_CSS_PREFIX}${encodeURIComponent(themeName)}/${accentName ? encodeURIComponent(accentName) : "_"}.css`;
|
|
}
|
|
|
|
/** Render only the tokens needed for the current SSR-selected theme and accent. */
|
|
export function renderActiveThemeCss(
|
|
theme: ResolvedTheme,
|
|
themeName: string,
|
|
accentName?: string,
|
|
): string {
|
|
const selectedTheme = theme.themes[themeName];
|
|
if (!selectedTheme) throw new Error(`Unknown theme '${themeName}'.`);
|
|
const blocks = [`:root{${tokensToDeclarations(selectedTheme)}}`];
|
|
if (accentName) {
|
|
if (!theme.accentNames.includes(accentName as ThemePaletteName)) {
|
|
throw new Error(`Unknown theme accent '${accentName}'.`);
|
|
}
|
|
blocks.push(
|
|
`:root{${tokensToDeclarations(
|
|
paletteTokens(THEME_PALETTES[accentName as ThemePaletteName], themeScheme(selectedTheme)),
|
|
)}}`,
|
|
);
|
|
}
|
|
return blocks.join("\n") + globalThemeCss();
|
|
}
|
|
|
|
/**
|
|
* 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") + globalThemeCss();
|
|
}
|
|
|
|
/**
|
|
* Generate the client theme runtime. It exposes `window.wrnTheme` and
|
|
* `window.wrnAccent`, 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);
|
|
const cssPrefix = JSON.stringify(THEME_CSS_PREFIX);
|
|
const cookieConfig = JSON.stringify(theme.cookies ?? {});
|
|
const themeCookie = JSON.stringify(theme.themeCookie ?? {});
|
|
const accentCookie = JSON.stringify(theme.accentCookie ?? {});
|
|
|
|
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 THEME_CSS_PREFIX=${cssPrefix};
|
|
var COOKIE_CONFIG=${cookieConfig};
|
|
var THEME_COOKIE_OPTIONS=${themeCookie};
|
|
var ACCENT_COOKIE_OPTIONS=${accentCookie};
|
|
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 cookieOptions(options){
|
|
options=options||{};
|
|
var path=options.path||"/";
|
|
var sameSite=options.sameSite||"Lax";
|
|
// localhost is not a registrable parent domain. Some browsers reject a
|
|
// Domain=localhost cookie entirely, so keep it host-only in local dev.
|
|
var configuredDomain=options.domain;
|
|
var domain=configuredDomain&&configuredDomain!=="localhost"
|
|
?";domain="+configuredDomain
|
|
:"";
|
|
var secure=options.secure===true||(options.secure!==false&&location.protocol==="https:")?";secure":"";
|
|
return ";path="+path+";samesite="+sameSite+domain+secure;
|
|
}
|
|
|
|
function writeCookie(name,value,options){
|
|
options=options||{};
|
|
var maxAge=options.maxAge===undefined?MAX_AGE:Math.max(0,Math.floor(options.maxAge));
|
|
document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+
|
|
";max-age="+maxAge+cookieOptions(options);
|
|
}
|
|
|
|
function deleteCookie(name,options){
|
|
document.cookie=encodeURIComponent(name)+"=;max-age=0"+cookieOptions(options);
|
|
}
|
|
|
|
function configuredCookieOptions(preference,override){
|
|
var resolved=Object.assign({},COOKIE_CONFIG.defaults||{});
|
|
if(typeof preference==="string")Object.assign(resolved,COOKIE_CONFIG[preference]||{});
|
|
else if(preference)Object.assign(resolved,preference);
|
|
if(override)Object.assign(resolved,override);
|
|
return resolved;
|
|
}
|
|
|
|
window.wrnCookies={
|
|
config:COOKIE_CONFIG,
|
|
get:readCookie,
|
|
options:configuredCookieOptions,
|
|
set:function(name,value,preference,override){
|
|
writeCookie(name,value,configuredCookieOptions(preference,override));
|
|
},
|
|
remove:function(name,preference,override){
|
|
deleteCookie(name,configuredCookieOptions(preference,override));
|
|
}
|
|
};
|
|
|
|
function syncStylesheet(themeName,accentName){
|
|
var link=document.querySelector("link[data-wrnexus-theme]");
|
|
if(!link)return;
|
|
var accent=accentName?encodeURIComponent(accentName):"_";
|
|
link.href=THEME_CSS_PREFIX+encodeURIComponent(themeName)+"/"+accent+".css";
|
|
}
|
|
|
|
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-wrn-theme-set]").forEach(function(node){
|
|
node.setAttribute("aria-pressed",String(node.getAttribute("data-wrn-theme-set")===current));
|
|
});
|
|
}
|
|
|
|
function syncAccentButtons(root){
|
|
var current=getAccent();
|
|
(root||document).querySelectorAll("[data-wrn-accent-set]").forEach(function(node){
|
|
node.setAttribute("aria-pressed",String(node.getAttribute("data-wrn-accent-set")===current));
|
|
});
|
|
}
|
|
|
|
function setTheme(name){
|
|
if(THEMES.indexOf(name)<0)return;
|
|
el.setAttribute("data-theme",name);
|
|
writeCookie(THEME_COOKIE,name,THEME_COOKIE_OPTIONS);
|
|
syncStylesheet(name,getAccent());
|
|
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);
|
|
deleteCookie(ACCENT_COOKIE);
|
|
writeCookie(ACCENT_COOKIE,name,ACCENT_COOKIE_OPTIONS);
|
|
syncStylesheet(getTheme(),name);
|
|
syncAccentButtons(document);
|
|
window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:name}}));
|
|
}
|
|
|
|
function clearAccent(){
|
|
el.removeAttribute("data-accent");
|
|
deleteCookie(ACCENT_COOKIE);
|
|
deleteCookie(ACCENT_COOKIE,ACCENT_COOKIE_OPTIONS);
|
|
syncStylesheet(getTheme(),null);
|
|
syncAccentButtons(document);
|
|
window.dispatchEvent(new CustomEvent("wrnexus:accent-changed",{detail:{accent:null}}));
|
|
}
|
|
|
|
function bind(root){
|
|
var target=root||document;
|
|
|
|
target.querySelectorAll("[data-wrn-theme-toggle]").forEach(function(node){
|
|
if(node.__wrnThemeBound)return;
|
|
node.__wrnThemeBound=1;
|
|
node.addEventListener("click",toggleTheme);
|
|
});
|
|
|
|
target.querySelectorAll("[data-wrn-theme-set]").forEach(function(node){
|
|
if(node.__wrnThemeBound)return;
|
|
node.__wrnThemeBound=1;
|
|
node.addEventListener("click",function(){
|
|
setTheme(node.getAttribute("data-wrn-theme-set"));
|
|
});
|
|
});
|
|
|
|
target.querySelectorAll("[data-wrn-accent-set]").forEach(function(node){
|
|
if(node.__wrnAccentBound)return;
|
|
node.__wrnAccentBound=1;
|
|
node.addEventListener("click",function(){
|
|
setAccent(node.getAttribute("data-wrn-accent-set"));
|
|
});
|
|
});
|
|
|
|
target.querySelectorAll("[data-wrn-accent-clear]").forEach(function(node){
|
|
if(node.__wrnAccentBound)return;
|
|
node.__wrnAccentBound=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.wrnTheme={
|
|
get:getTheme,
|
|
set:setTheme,
|
|
toggle:toggleTheme,
|
|
bind:bind,
|
|
themes:THEMES.slice()
|
|
};
|
|
|
|
window.wrnAccent={
|
|
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);
|
|
}
|
|
})();
|
|
`;
|
|
}
|