Files
WRNexusJS/packages/styles/src/theme.ts
T
ClintchizandClaude Opus 5 c7ab605e85
Quality / quality (ubuntu-latest) (push) Failing after 12m21s
Quality / quality (windows-latest) (push) Canceled after 0s
feat(ui): migrate the page and section components, add the layout tour
Section, SectionHeader and PublicPageShell move onto wire-* classes with
local style blocks, variants as data attributes. SectionHeader loses 38
utility lines, and Section stops spending one line per variant and colour
pairing: tinted and solid now select on two attributes. PageHeader was already
on the convention and only needed the audit.

examples/basic-app/app/pages/layout.wrn composes the whole set into one page.

Building it surfaced a library-wide bug. Ten custom properties were referenced
by components and defined by nothing: --wire-color-focus, --wire-color-surface-soft,
--wire-color-on-danger, --wire-color-surface-subtle and the input-* family,
plus hover and contrast for every semantic colour except primary and
secondary. An undefined custom property does not warn, it resolves to nothing,
so focus rings drew with no colour and every soft surface rendered
transparent -- 27 components referenced surface-soft alone. They are derived
in the theme now, and a test checks every token a component references against
the rendered theme CSS rather than the source, since most are generated.

The semantic spread also had to move ahead of the primary and secondary
entries so the palette keeps winning for those two.

Known and unresolved: LayoutSplitter emits its sizeChange output and the
component does fire it, but a parent binding on the tag is not invoked. The
tour page therefore points at the handle aria-valuenow rather than wiring a
handler that would never update. Outputs work elsewhere, so this is narrower
than an outputs-are-broken problem and needs its own investigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:50:33 +05:30

758 lines
25 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-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<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"),
/*
* 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<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)",
},
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<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' +
// <wrn-slot> wraps content spliced into a component's <slot> 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<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);
}
})();
`;
}