release: WRNexusJS 0.5.11
This commit is contained in:
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -154,3 +154,4 @@ export {
|
||||
contrast,
|
||||
} from "./audit.ts";
|
||||
export type { CssTokenAudit, StyleSource, ContrastResult } from "./audit.ts";
|
||||
export * from "./theme.ts";
|
||||
|
||||
+309
-101
@@ -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);
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.5.10",
|
||||
"version": "0.5.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user