feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.8.26",
|
||||
"version": "0.8.27",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./theme": "./src/theme.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/design-system": "workspace:*",
|
||||
"@wrnexus/uploader": "workspace:*",
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*"
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
* emitted as the native CSS property instead of a custom property.
|
||||
*/
|
||||
|
||||
import {
|
||||
adaptLegacyTheme,
|
||||
compileDesignSystem,
|
||||
type DesignSystemInput,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
export type ThemeSemanticColor =
|
||||
"primary" | "secondary" | "info" | "success" | "warning" | "danger" | "error";
|
||||
export type ThemeToken =
|
||||
@@ -169,6 +175,8 @@ export function resolveBrowserCookieOptions(
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
/** Opt-in typed design tokens. Explicit themes below retain final precedence. */
|
||||
designSystem?: DesignSystemInput;
|
||||
/** Built-in palette name, or a complete custom semantic color palette. */
|
||||
palette?: ThemePaletteName | CustomThemePalette;
|
||||
/** Runtime accent/palette switcher configuration. */
|
||||
@@ -180,6 +188,8 @@ export interface ThemeConfig {
|
||||
}
|
||||
|
||||
export interface ResolvedTheme {
|
||||
/** Compiled opt-in recipes, emitted after local component styles by SSR. */
|
||||
recipeCss?: string;
|
||||
default: string;
|
||||
names: string[];
|
||||
themes: Record<string, ThemeTokens>;
|
||||
@@ -569,6 +579,14 @@ export function resolveThemeConfig(
|
||||
config?: ThemeConfig,
|
||||
cookies?: BrowserCookiesConfig,
|
||||
): ResolvedTheme {
|
||||
let recipeCss: string | undefined;
|
||||
if (config?.designSystem) {
|
||||
const { system, ...options } = config.designSystem;
|
||||
recipeCss = compileDesignSystem(system, options).recipeCss || undefined;
|
||||
config = adaptLegacyTheme(system, config, options);
|
||||
// Preserve the imported brand until an accent is explicitly configured/selected.
|
||||
config = { ...config, accent: { default: false, ...config.accent } };
|
||||
}
|
||||
const palette = resolvePalette(config?.palette);
|
||||
const themes: Record<string, ThemeTokens> = {};
|
||||
const names = new Set<string>([
|
||||
@@ -595,6 +613,7 @@ export function resolveThemeConfig(
|
||||
|
||||
return {
|
||||
default: preferred ?? fallback,
|
||||
...(recipeCss ? { recipeCss } : {}),
|
||||
names: list,
|
||||
themes,
|
||||
defaultAccent,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { renderComponent } from "../../test/src/index.ts";
|
||||
import { renderDocument } from "../../ssr/src/index.ts";
|
||||
import { renderThemeCss, resolveThemeConfig, DEFAULT_THEMES } from "../src/theme.ts";
|
||||
import type { DesignSystem } from "../../design-system/src/index.ts";
|
||||
import { Window } from "happy-dom";
|
||||
|
||||
test("legacy Button inherits opted-in tokens after SSR style extraction and mode switching", async () => {
|
||||
const system: DesignSystem = {
|
||||
schemaVersion: 1,
|
||||
id: "square",
|
||||
name: "Square",
|
||||
version: "1",
|
||||
tokens: { radius: { type: "dimension", value: "0", legacy: "radius-sm" } },
|
||||
modes: { light: {}, dark: {} },
|
||||
defaultMode: "dark",
|
||||
};
|
||||
const source = readFileSync(new URL("../../ui/components/button.wrn", import.meta.url), "utf8");
|
||||
const body = await renderComponent(source, { label: "Save", variant: "outline" });
|
||||
const baseline = renderThemeCss(resolveThemeConfig());
|
||||
const css = renderThemeCss(
|
||||
resolveThemeConfig({ designSystem: { system }, themes: { dark: { "radius-sm": "7px" } } }),
|
||||
);
|
||||
const extraHead = `<style id="design-theme">${css}</style>`;
|
||||
const html = renderDocument({
|
||||
meta: {},
|
||||
body,
|
||||
extraHead,
|
||||
htmlAttrs: ' data-theme="dark"',
|
||||
styleNonce: "design-test",
|
||||
});
|
||||
expect(html.indexOf(extraHead)).toBeLessThan(html.indexOf("data-wrnexus-style"));
|
||||
expect(html).toContain('nonce="design-test"');
|
||||
const window = new Window();
|
||||
try {
|
||||
window.document.write(html);
|
||||
const button = window.document.querySelector("button.wrn-btn")!;
|
||||
expect(window.getComputedStyle(button).borderRadius).toBe("7px");
|
||||
window.document.documentElement.dataset.theme = "light";
|
||||
expect(window.getComputedStyle(button).borderRadius).toBe("0px");
|
||||
window.document.querySelector("#design-theme")!.textContent = baseline;
|
||||
expect(window.getComputedStyle(button).borderRadius).toBe(DEFAULT_THEMES.light!["radius-sm"]!);
|
||||
} finally {
|
||||
await window.happyDOM.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
resolveThemeConfig,
|
||||
renderThemeCss,
|
||||
renderActiveThemeCss,
|
||||
DEFAULT_THEMES,
|
||||
} from "../src/theme.ts";
|
||||
import type { DesignSystem } from "../../design-system/src/index.ts";
|
||||
|
||||
const system: DesignSystem = {
|
||||
schemaVersion: 1,
|
||||
id: "square",
|
||||
name: "Square",
|
||||
version: "1.0.0",
|
||||
defaultMode: "dark",
|
||||
tokens: {
|
||||
radius: { type: "dimension", value: "0", legacy: "radius-sm" },
|
||||
primary: { type: "color", value: "#123456", legacy: "color-primary" },
|
||||
},
|
||||
modes: { light: {}, dark: { primary: { type: "color", value: "#abcdef" } } },
|
||||
};
|
||||
|
||||
test("no opt-in preserves existing defaults and accent behavior", () => {
|
||||
const theme = resolveThemeConfig();
|
||||
expect(theme.themes.dark?.["radius-sm"]).toBe(DEFAULT_THEMES.dark?.["radius-sm"]);
|
||||
expect(theme.defaultAccent).toBe("blue");
|
||||
});
|
||||
test("opt-in maps existing component variables in SSR and switchable CSS", () => {
|
||||
const theme = resolveThemeConfig({ designSystem: { system } });
|
||||
expect(theme.default).toBe("dark");
|
||||
expect(theme.defaultAccent).toBeUndefined();
|
||||
expect(theme.themes.light?.["color-primary"]).toBe("#123456");
|
||||
expect(theme.themes.dark?.["color-primary"]).toBe("#abcdef");
|
||||
expect(renderActiveThemeCss(theme, "dark")).toContain("--wrn-radius-sm:0;");
|
||||
expect(renderActiveThemeCss(theme, "dark")).toContain("--wrn-color-primary:#abcdef;");
|
||||
expect(renderThemeCss(theme)).toContain('[data-theme="light"]');
|
||||
});
|
||||
test("explicit app tokens, default, cookies and accent preferences remain authoritative", () => {
|
||||
const theme = resolveThemeConfig(
|
||||
{
|
||||
designSystem: { system },
|
||||
default: "light",
|
||||
themes: { dark: { "radius-sm": "8px" } },
|
||||
accent: { default: "rose" },
|
||||
},
|
||||
{ defaults: { path: "/product" } },
|
||||
);
|
||||
expect(theme.default).toBe("light");
|
||||
expect(theme.themes.dark?.["radius-sm"]).toBe("8px");
|
||||
expect(theme.defaultAccent).toBe("rose");
|
||||
expect(theme.themeCookie?.path).toBe("/product");
|
||||
});
|
||||
test("invalid imported systems fail before emitting stylesheet content", () => {
|
||||
expect(() =>
|
||||
resolveThemeConfig({ designSystem: { system: { ...system, schemaVersion: 99 } as never } }),
|
||||
).toThrow("WRN-DS-VERSION");
|
||||
});
|
||||
@@ -13,28 +13,50 @@ test("shared preferences replace stale host cookies and update another open app"
|
||||
const b = second.mainFrame.window;
|
||||
// Happy DOM coalesces equal-name host/domain cookies. Model their distinct
|
||||
// browser storage keys so the legacy-shadow migration is exercised.
|
||||
const jar = new Map<string, { name: string; value: string; domain: string; hostOnly: boolean }>();
|
||||
const jar = new Map<
|
||||
string,
|
||||
{ name: string; value: string; domain: string; hostOnly: boolean }
|
||||
>();
|
||||
function cookies(win: typeof a) {
|
||||
Object.defineProperty(win.document, "cookie", {
|
||||
get() { return [...jar.values()].filter(c => c.hostOnly ? c.domain === win.location.hostname : win.location.hostname === c.domain || win.location.hostname.endsWith("." + c.domain)).map(c => `${c.name}=${c.value}`).join("; "); },
|
||||
get() {
|
||||
return [...jar.values()]
|
||||
.filter((c) =>
|
||||
c.hostOnly
|
||||
? c.domain === win.location.hostname
|
||||
: win.location.hostname === c.domain ||
|
||||
win.location.hostname.endsWith("." + c.domain),
|
||||
)
|
||||
.map((c) => `${c.name}=${c.value}`)
|
||||
.join("; ");
|
||||
},
|
||||
set(raw: string) {
|
||||
const [pair, ...options] = raw.split(";");
|
||||
const split = pair!.indexOf("=");
|
||||
const name = pair!.slice(0, split), value = pair!.slice(split + 1);
|
||||
const domainOption = options.find(v => v.trim().toLowerCase().startsWith("domain="));
|
||||
const domain = domainOption ? domainOption.trim().slice(7).replace(/^\./, "") : win.location.hostname;
|
||||
const name = pair!.slice(0, split),
|
||||
value = pair!.slice(split + 1);
|
||||
const domainOption = options.find((v) => v.trim().toLowerCase().startsWith("domain="));
|
||||
const domain = domainOption
|
||||
? domainOption.trim().slice(7).replace(/^\./, "")
|
||||
: win.location.hostname;
|
||||
const key = `${name}|${domain}`;
|
||||
if(options.some(v => v.trim().toLowerCase() === "max-age=0")) jar.delete(key);
|
||||
if (options.some((v) => v.trim().toLowerCase() === "max-age=0")) jar.delete(key);
|
||||
else jar.set(key, { name, value, domain, hostOnly: !domainOption });
|
||||
},
|
||||
});
|
||||
}
|
||||
cookies(a); cookies(b);
|
||||
cookies(a);
|
||||
cookies(b);
|
||||
a.document.cookie = "wrn-theme=light;path=/";
|
||||
b.document.cookie = "wrn-theme=light;path=/";
|
||||
a.document.cookie = "wrn-theme=dark;path=/;domain=.example.com;secure";
|
||||
a.document.cookie = "wrn-accent=rose;path=/;domain=.example.com;secure";
|
||||
const js = renderThemeRuntime(resolveThemeConfig({ palette: "emerald" }, { defaults: { domain: ".example.com", secure: true } }));
|
||||
const js = renderThemeRuntime(
|
||||
resolveThemeConfig(
|
||||
{ palette: "emerald" },
|
||||
{ defaults: { domain: ".example.com", secure: true } },
|
||||
),
|
||||
);
|
||||
for (const win of [a, b]) {
|
||||
win.document.documentElement.setAttribute("data-theme", "light");
|
||||
win.document.documentElement.setAttribute("data-accent", "emerald");
|
||||
@@ -42,17 +64,21 @@ test("shared preferences replace stale host cookies and update another open app"
|
||||
expect(win.document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
expect(win.document.cookie.match(/wrn-theme=/g)?.length).toBe(1);
|
||||
}
|
||||
a.eval('wrnTheme.set("light");wrnAccent.set("violet");wrnCookies.set("wrn-lang","hi","language")');
|
||||
a.eval(
|
||||
'wrnTheme.set("light");wrnAccent.set("violet");wrnCookies.set("wrn-lang","hi","language")',
|
||||
);
|
||||
expect(b.document.cookie).toContain("wrn-theme-mode=manual");
|
||||
b.dispatchEvent(new b.Event("focus"));
|
||||
expect(b.document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
expect(b.document.documentElement.getAttribute("data-accent")).toBe("violet");
|
||||
expect(b.document.cookie).toContain("wrn-lang=hi");
|
||||
a.eval('wrnTheme.followSystem()');
|
||||
a.eval("wrnTheme.followSystem()");
|
||||
expect(b.document.cookie).toContain("wrn-theme-mode=system");
|
||||
const outsider = browser.newPage();
|
||||
outsider.url = "https://unrelated.example.net/";
|
||||
cookies(outsider.mainFrame.window);
|
||||
expect(outsider.mainFrame.window.document.cookie).toBe("");
|
||||
} finally { await browser.close(); }
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user