release: WRNexusJS 0.5.11

This commit is contained in:
2026-07-30 15:03:52 +05:30
parent d1b0c55b53
commit f37303b22d
67 changed files with 530 additions and 213 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.5.10",
"version": "0.5.11",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.5.10",
"version": "0.5.11",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+8
View File
@@ -1129,6 +1129,14 @@ const MIGRATIONS: Migration[] = [
// Gateway, helper, validation, and UI improvements are package-managed.
},
},
{
version: "0.5.11",
id: "latest-release-after-upgrade",
description: "Latest New Packages and release ready packages",
apply() {
// Gateway, helper, validation, and UI improvements are package-managed.
},
},
];
/** Release tooling uses this to require an explicit migration entry per version. */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+48 -11
View File
@@ -40,14 +40,16 @@ import { renderDocument, type RenderScript, type ScriptAsset } from "@wrnexus/ss
import type { ClientRuntimeDefinition } from "@wrnexus/plugin";
import { runtimeScriptsForMarkup } from "./plugin-assets.ts";
import {
ACCENT_COOKIE,
THEME_COOKIE,
THEME_CSS_HREF,
THEME_JS_HREF,
resolveAccentName,
resolveThemeName,
type ResolvedTheme,
type MobileConfig,
type PwaConfig,
type ObservabilityConfig,
type PwaConfig,
type ResolvedTheme,
type TenancyConfig,
} from "@wrnexus/styles";
import {
@@ -587,16 +589,37 @@ export const HMR_CLIENT_JS = `
function applyHtml(html) {
pendingSync = false;
var doc = new DOMParser().parseFromString(html, "text/html");
if (doc.title) document.title = doc.title;
if (doc.title) {
document.title = doc.title;
}
["data-theme", "data-accent", "lang"].forEach(function (name) {
var value = doc.documentElement.getAttribute(name);
if (value == null) {
document.documentElement.removeAttribute(name);
} else {
document.documentElement.setAttribute(name, value);
}
});
var from = document.getElementById("app");
var to = doc.getElementById("app");
if (from && to) morph(from, to);
if (from && to) {
morph(from, to);
}
swapCss("html");
// Components are re-rendered server-side into the new HTML; re-hydrate
// their reactive scopes (and any browser-side API fetches) in place.
if (window.__wrnexusHydrateScopes) window.__wrnexusHydrateScopes(document);
if (window.__wrnexusHydrateCsrFetches) window.__wrnexusHydrateCsrFetches(document);
window.dispatchEvent(new CustomEvent("wrnexus:hmr", { detail: { type: "html" } }));
if (window.__wrnexusHydrateScopes) {
window.__wrnexusHydrateScopes(document);
}
if (window.__wrnexusHydrateCsrFetches) {
window.__wrnexusHydrateCsrFetches(document);
}
}
// Minimal index-based DOM morph: preserve matching nodes (keeps state/focus),
@@ -1324,7 +1347,18 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
// <html> attributes: no-flash theme (from cookie, validated) + active lang.
const attrs: string[] = [];
if (deps.theme) attrs.push(`data-theme="${resolvedTheme}"`);
if (deps.theme) {
const themeName = resolveThemeName(ctx.cookies.get(THEME_COOKIE), deps.theme);
attrs.push(`data-theme="${themeName}"`);
const accentName = resolveAccentName(ctx.cookies.get(ACCENT_COOKIE), deps.theme);
if (accentName) {
attrs.push(`data-accent="${accentName}"`);
}
}
attrs.push(`lang="${safeLanguageTag(language)}"`);
const htmlAttrs = attrs.length ? ` ${attrs.join(" ")}` : undefined;
@@ -1699,7 +1733,10 @@ export function collectScripts(
scripts.push("/__wrnexus/reactive.js");
}
// The theme runtime is only needed when the page can switch themes.
if (/\bdata-wire-theme-(toggle|set)\b/.test(body)) {
if (
/\bdata-wire-theme-(toggle|set)\b/.test(body) ||
/\bdata-wire-accent-(set|clear)\b/.test(body)
) {
scripts.push(THEME_JS_HREF);
}
// Validation: schema descriptors + the generic validator, only for pages with a form.
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test";
import { collectScripts } from "../src/runtime.ts";
test("accent controls load the shared theme runtime", () => {
expect(collectScripts('<button data-wire-accent-set="rose">Rose</button>')).toContain(
"/__wrnexus/theme.js",
);
});
test("accent clear controls load the shared theme runtime", () => {
expect(collectScripts("<button data-wire-accent-clear>Reset</button>")).toContain(
"/__wrnexus/theme.js",
);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-toolbar",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"sideEffects": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/encryption",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/helpers",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/i18n",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/jwt",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/mobile",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/native",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/oauth",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/plugin",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/pubsub",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/queue",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/reactive",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/router",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ssr",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/styles",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1
View File
@@ -154,3 +154,4 @@ export {
contrast,
} from "./audit.ts";
export type { CssTokenAudit, StyleSource, ContrastResult } from "./audit.ts";
export * from "./theme.ts";
+309 -101
View File
@@ -1,14 +1,14 @@
/**
* Theme system design tokens that work SSR and client-side.
* 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 `<html data-theme="">` from the `wire-theme` cookie (no
* flash), and a tiny client runtime toggles/persists it. The reserved token key
* `color-scheme` is emitted as the native CSS property (not a variable) so form
* controls and scrollbars match the theme.
* 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 ThemeTokens = Record<string, string>;
@@ -40,9 +40,23 @@ export interface CustomThemePalette {
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. */
@@ -53,13 +67,20 @@ export interface ResolvedTheme {
default: string;
names: string[];
themes: Record<string, ThemeTokens>;
defaultAccent?: ThemePaletteName;
accentNames: ThemePaletteName[];
}
/** Cookie the resolved theme is read from / persisted to. */
/** 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",
@@ -159,24 +180,74 @@ export const THEME_PALETTES: Record<ThemePaletteName, CustomThemePalette> = {
},
};
function paletteTokens(palette: CustomThemePalette): ThemeTokens {
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) {
@@ -186,16 +257,47 @@ function resolvePalette(value?: ThemeConfig["palette"]): CustomThemePalette {
}
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: {
@@ -208,12 +310,6 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"color-text-muted": "#5a6178",
"color-text-subtle": "#737b91",
"color-border-strong": "#c7cedd",
"color-primary-soft": "#eff6ff",
"color-secondary-soft": "#f1f5f9",
"color-info-soft": "#e0f2fe",
"color-success-soft": "#ecfdf3",
"color-warning-soft": "#fffbeb",
"color-danger-soft": "#fef2f2",
"color-code-background": "#0b1020",
"color-code-surface": "#141a30",
"color-code-text": "#e7ecff",
@@ -230,17 +326,7 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"color-text": "#0b1020",
"color-muted": "#5a6178",
"color-border": "#e2e6f0",
"color-primary": "#2563eb",
"color-primary-hover": "#1d4ed8",
"color-primary-contrast": "#ffffff",
"color-secondary": "#475569",
"color-secondary-hover": "#334155",
"color-secondary-contrast": "#ffffff",
"color-info": "#0284c7",
"color-danger": "#dc2626",
"color-success": "#16a34a",
"color-warning": "#d97706",
"color-error": "#dc2626",
...paletteTokens(THEME_PALETTES.blue, "light"),
radius: "8px",
"radius-sm": "5px",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
@@ -256,12 +342,6 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"color-text-muted": "#b3b3b3",
"color-text-subtle": "#8a8a8a",
"color-border-strong": "#ffffff38",
"color-primary-soft": "#6c8cff24",
"color-secondary-soft": "#a78bfa24",
"color-info-soft": "#38bdf824",
"color-success-soft": "#4ade8024",
"color-warning-soft": "#fbbf2424",
"color-danger-soft": "#f8717124",
"color-code-background": "#050505",
"color-code-surface": "#101010",
"color-code-text": "#f5f5f5",
@@ -278,17 +358,7 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
"color-text": "#f5f5f5",
"color-muted": "#a3a3a3",
"color-border": "#ffffff1f",
"color-primary": "#6c8cff",
"color-primary-hover": "#8aa2ff",
"color-primary-contrast": "#000000",
"color-secondary": "#a78bfa",
"color-secondary-hover": "#c4b5fd",
"color-secondary-contrast": "#000000",
"color-info": "#38bdf8",
"color-danger": "#f87171",
"color-success": "#4ade80",
"color-warning": "#fbbf24",
"color-error": "#f87171",
...paletteTokens(THEME_PALETTES.blue, "dark"),
radius: "10px",
"radius-sm": "6px",
"font-sans": '"Plus Jakarta Sans",ui-sans-serif,system-ui,sans-serif',
@@ -296,25 +366,43 @@ export const DEFAULT_THEMES: Record<string, ThemeTokens> = {
},
};
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 semanticTokens = paletteTokens(resolvePalette(config?.palette));
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] = {
...(DEFAULT_THEMES[name] ?? DEFAULT_THEMES.light),
...semanticTokens,
...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]!;
return { default: preferred ?? fallback, names: list, themes };
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. */
@@ -322,6 +410,18 @@ export function resolveThemeName(cookieValue: string | undefined, theme: Resolve
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]) =>
@@ -330,14 +430,50 @@ function tokensToDeclarations(tokens: ThemeTokens): string {
.join("");
}
/** Generate the theme stylesheet: a `:root` default plus one block per theme. */
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 def = theme.themes[theme.default];
if (def) blocks.push(`:root{${tokensToDeclarations(def)}}`);
const defaultTheme = theme.themes[theme.default];
if (defaultTheme) blocks.push(`:root{${tokensToDeclarations(defaultTheme)}}`);
for (const name of theme.names) {
blocks.push(`[data-theme="${name}"]{${tokensToDeclarations(theme.themes[name]!)}}`);
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'
@@ -345,94 +481,166 @@ export function renderThemeCss(theme: ResolvedTheme): string {
}
/**
* Generate the client theme runtime. It exposes `window.wireTheme` and binds
* `[data-wire-theme-toggle]` / `[data-wire-theme-set]` elements. The configured
* theme names are baked in so `toggle()` cycles through them in order.
* 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 COOKIE=${JSON.stringify(THEME_COOKIE)};
var THEME_COOKIE=${JSON.stringify(THEME_COOKIE)};
var ACCENT_COOKIE=${JSON.stringify(ACCENT_COOKIE)};
var THEMES=${names};
var ACCENT_COOKIE="wire-accent";
var COOKIE_MAX_AGE=31536000;
var ACCENTS={
blue:["#2563eb","#1d4ed8","#ffffff"],
cyan:["#0891b2","#0e7490","#ffffff"],
emerald:["#059669","#047857","#ffffff"],
violet:["#7c3aed","#6d28d9","#ffffff"],
rose:["#e11d48","#be123c","#ffffff"],
amber:["#d97706","#b45309","#ffffff"]
};
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){
try{return decodeURIComponent(part.slice(prefix.length));}catch(_){return part.slice(prefix.length);}
}
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="+COOKIE_MAX_AGE+";samesite=lax"+secure;
document.cookie=encodeURIComponent(name)+"="+encodeURIComponent(value)+
";path=/;max-age="+MAX_AGE+";samesite=lax"+secure;
}
function get(){return el.getAttribute("data-theme")||${JSON.stringify(theme.default)};}
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){
(root||document).querySelectorAll("[data-wire-theme-set]").forEach(function(n){
n.setAttribute("aria-pressed",String(n.getAttribute("data-wire-theme-set")===get()));
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,name){
(root||document).querySelectorAll("[data-wire-accent-set]").forEach(function(n){
n.setAttribute("aria-pressed",String(n.getAttribute("data-wire-accent-set")===name));
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 set(name){
function setTheme(name){
if(THEMES.indexOf(name)<0)return;
el.setAttribute("data-theme",name);
writeCookie(COOKIE,name);
writeCookie(THEME_COOKIE,name);
syncThemeButtons(document);
window.dispatchEvent(new CustomEvent("wrnexus:theme-changed",{detail:{theme:name}}));
}
function toggle(){var i=THEMES.indexOf(get());set(THEMES[(i+1)%THEMES.length]);}
function toggleTheme(){
var index=THEMES.indexOf(getTheme());
setTheme(THEMES[(index+1)%THEMES.length]);
}
function setAccent(name){
var value=ACCENTS[name];if(!value)return;
el.style.setProperty("--wire-color-primary",value[0]);
el.style.setProperty("--wire-color-primary-hover",value[1]);
el.style.setProperty("--wire-color-primary-contrast",value[2]);
if(ACCENTS.indexOf(name)<0)return;
el.setAttribute("data-accent",name);
writeCookie(ACCENT_COOKIE,name);
syncAccentButtons(document,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){
(root||document).querySelectorAll("[data-wire-theme-toggle]").forEach(function(n){
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
n.addEventListener("click",function(){toggle();});
var target=root||document;
target.querySelectorAll("[data-wire-theme-toggle]").forEach(function(node){
if(node.__wireThemeBound)return;
node.__wireThemeBound=1;
node.addEventListener("click",toggleTheme);
});
(root||document).querySelectorAll("[data-wire-theme-set]").forEach(function(n){
if(n.__wireThemeBound)return;n.__wireThemeBound=1;
n.addEventListener("click",function(){set(n.getAttribute("data-wire-theme-set"));});
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"));
});
});
(root||document).querySelectorAll("[data-wire-accent-set]").forEach(function(n){
if(n.__wireAccentBound)return;n.__wireAccentBound=1;
n.addEventListener("click",function(){setAccent(n.getAttribute("data-wire-accent-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"));
});
});
syncThemeButtons(root);
syncAccentButtons(root,el.getAttribute("data-accent")||"blue");
target.querySelectorAll("[data-wire-accent-clear]").forEach(function(node){
if(node.__wireAccentBound)return;
node.__wireAccentBound=1;
node.addEventListener("click",clearAccent);
});
syncThemeButtons(target);
syncAccentButtons(target);
}
function getAccent(){
var current=el.getAttribute("data-accent")||readCookie(ACCENT_COOKIE)||"blue";
return ACCENTS[current]?current:"blue";
}
window.wireTheme={get:get,set:set,toggle:toggle,bind:bind,themes:THEMES};
window.wireAccent={get:getAccent,set:setAccent,colors:Object.keys(ACCENTS)};
setAccent(getAccent());
// 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);
if(document.readyState==="loading"){
document.addEventListener("DOMContentLoaded",function(){bind(document);});
}else{
bind(document);
}
})();
`;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import {
ACCENT_COOKIE,
renderThemeCss,
renderThemeRuntime,
resolveAccentName,
resolveThemeConfig,
} from "../src/theme.ts";
describe("theme accents", () => {
test("uses the configured named palette as the default accent", () => {
const theme = resolveThemeConfig({ palette: "rose", default: "light" });
expect(theme.defaultAccent).toBe("rose");
expect(resolveAccentName(undefined, theme)).toBe("rose");
});
test("validates the accent cookie against enabled palettes", () => {
const theme = resolveThemeConfig({
palette: "rose",
accent: { options: ["rose", "emerald"] },
});
expect(resolveAccentName("emerald", theme)).toBe("emerald");
expect(resolveAccentName("blue", theme)).toBe("rose");
expect(resolveAccentName("not-real", theme)).toBe("rose");
});
test("generates complete palette selectors for light and dark themes", () => {
const css = renderThemeCss(resolveThemeConfig({ palette: "rose" }));
expect(css).toContain('[data-theme="light"][data-accent="emerald"]');
expect(css).toContain('[data-theme="dark"][data-accent="emerald"]');
expect(css).toContain("--wire-color-primary-soft:");
expect(css).toContain("--wire-color-primary-muted:");
expect(css).toContain("--wire-color-primary-text:");
expect(css).toContain("--wire-color-on-primary:");
expect(css).toContain("--wire-color-secondary-soft:");
});
test("runtime persists accents with cookies and never writes inline tokens", () => {
const runtime = renderThemeRuntime(resolveThemeConfig({ palette: "rose" }));
expect(runtime).toContain(ACCENT_COOKIE);
expect(runtime).toContain('el.setAttribute("data-accent",name)');
expect(runtime).not.toContain("localStorage");
expect(runtime).not.toContain("style.setProperty");
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/syntax",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"main": "src/index.ts",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/test",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/tracking",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ui",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/uploader",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/validation",
"version": "0.5.10",
"version": "0.5.11",
"private": true,
"type": "module",
"main": "src/index.ts",