/** * Theme system - design tokens that work SSR and client-side. * * Tokens are plain CSS custom properties (`--wire-`) 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 `` and * `` 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-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; export function defineThemeTokens(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; } export interface ResolvedTheme { default: string; names: string[]; themes: Record; 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 = { 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 --wire-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(); 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 = { 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)", }, 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)", }, }; 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 = {}; const names = new Set([ ...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' + // wraps content spliced into a component's so the client // runtime can tell which scope authored it. It is a pure ownership marker // and must never introduce a box: an unknown element would otherwise // default to display:inline and break the component's own flex/grid layout. /* * Shape and elevation tokens the Wire UI stylesheet depends on. * * ui.css references --wire-radius-sm (51 times) and --wire-radius-md (21) * but nothing ever defined them, so every component using them fell back * to square corners in any app that did not declare them itself. * --wire-shadow-1 had the same problem: the showcase declared it * privately, so the library looked right there and flat everywhere else. * Defining them with the rest of the theme keeps a component looking the * same in every app; an app can still override them. */ ":root{--wire-radius-sm:0.55rem;--wire-radius-md:0.9rem;--wire-radius-lg:1.35rem;" + "--wire-shadow-1:0 1px 2px color-mix(in srgb, black 22%, transparent)," + "0 10px 30px color-mix(in srgb, black 14%, transparent);}\n" + "wrn-slot{display:contents;}\n" + // A [data-for] element is the loop TEMPLATE, not a rendered row: the server // ships it with mustaches unresolved ({item.title}) and hydration replaces // it with a comment marker. Painted as-is it flashes one blank, literal // row on every page load before the runtime boots. Hiding it costs // nothing after hydration -- the runtime strips data-for from the clones // it renders, so this only ever matches the template itself. "[data-for]{display:none !important;}\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=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); } })(); `; }