feat: release design system engine, studio and component contracts

This commit is contained in:
Clintchiz committed 2026-10-19 13:09:03 +05:30
1 parent ff1065fb27
commit 3943b946d6
693 files changed
+175546 -3894

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/ai",
"version": "0.8.9",
"version": "0.8.10",
"private": true,
"type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.8.34
- Add a Resend code control to email and SMS MFA challenges with a 60-second cooldown.
- Explicit resend requests create a fresh challenge and invalidate the previous code while retaining sign-in expiry and device binding.
- Clear entered digits after resend and keep resend hidden for authenticator and recovery-code methods.
## 0.8.19
- Pass the trusted HTTP origin into registration delivery, so signup verification links are populated without custom URL callbacks.
+45 -5
View File
@@ -415,6 +415,10 @@
const challengeInput = form?.querySelector("[name=challengeId]");
const radios = Array.from(form?.querySelectorAll("input[type=radio][name=method]") || []);
const status = form?.querySelector("[data-auth-mfa-status]");
const resendGroup = form?.querySelector("[data-auth-mfa-resend-group]");
const resendButton = form?.querySelector("button[data-auth-mfa-resend]");
const resendDeadlines = new Map();
let resendTimer;
const saved = readMfaState();
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
const challenges = new Map();
@@ -457,6 +461,19 @@
};
const method = () => radios.find((radio) => radio.checked && !radio.disabled)?.value;
const isOtp = (value) => ["email-otp", "sms-otp"].includes(value);
const updateResend = () => {
window.clearTimeout(resendTimer);
const selected = method();
if (resendGroup) resendGroup.hidden = !isOtp(selected) || expired;
if (!resendButton || !isOtp(selected) || expired || disposed) return;
const seconds = Math.max(
0,
Math.ceil(((resendDeadlines.get(selected) || 0) - Date.now()) / 1000),
);
resendButton.disabled = pending.has(selected) || seconds > 0;
resendButton.textContent = seconds > 0 ? "Resend code in " + seconds + "s" : "Resend code";
if (seconds > 0) resendTimer = window.setTimeout(updateResend, 1000);
};
for (const radio of radios) {
radio.disabled = allowed.length > 0 && !allowed.includes(radio.value);
const label = radio.closest("label");
@@ -468,8 +485,10 @@
for (const radio of radios) radio.checked = radio === initial;
restoreToken();
const sendCode = async (selected) => {
const sendCode = async (selected, resend = false) => {
if (disposed || expired || !isOtp(selected)) return;
if (resend && (pending.has(selected) || (resendDeadlines.get(selected) || 0) > Date.now()))
return;
restoreToken();
if (expired) return;
if (!tokenInput?.value) {
@@ -481,12 +500,13 @@
const previous = challenges.get(selected);
if (previous?.expiresAt && Number(previous.expiresAt) <= Date.now())
challenges.delete(selected);
if (!pending.has(selected) && !challenges.has(selected)) {
if (!pending.has(selected) && (resend || !challenges.has(selected))) {
pending.set(
selected,
json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
mfaToken: tokenInput.value,
method: selected,
resend,
}),
);
}
@@ -495,16 +515,27 @@
setMessage(status, "Sending a one-time code…");
}
try {
const result = challenges.get(selected) || (await pending.get(selected));
const result = (!resend && challenges.get(selected)) || (await pending.get(selected));
if (disposed || expired) return;
challenges.set(selected, result);
if (!resendDeadlines.has(selected) || resend)
resendDeadlines.set(selected, Date.now() + (Number(result.retryAfter) || 60) * 1000);
if (method() !== selected) return;
if (challengeInput) challengeInput.value = String(result.id || "");
if (resend) {
form?.querySelectorAll("[data-auth-mfa-pin] input, [name=code]").forEach((input) => {
input.value = "";
input.dispatchEvent(new window.Event("input", { bubbles: true }));
});
form?.querySelectorAll("[data-error]").forEach((error) => {
error.textContent = "";
});
}
setMessage(
status,
selected === "email-otp"
? "A code has been sent to your email."
: "A code has been sent to your mobile number.",
? "An email code has been requested. Check your Inbox and Spam folder."
: "An SMS code has been requested. It may take a moment to arrive.",
);
} catch (error) {
if (!disposed && !expired && method() === selected)
@@ -516,6 +547,7 @@
} finally {
pending.delete(selected);
if (!disposed && !expired && method() === selected) setFormBusy(form, false);
updateResend();
}
};
const updateMethod = () => {
@@ -528,6 +560,7 @@
const error = form?.querySelector("[data-error=code]");
if (error) error.textContent = "";
setMessage(status, "");
updateResend();
for (const [selector, active] of [
["[data-auth-mfa-pin]", selected !== "recovery-code"],
["[data-auth-mfa-recovery]", selected === "recovery-code"],
@@ -546,6 +579,10 @@
const onMethodChange = (event) => {
if (radios.includes(event.target)) updateMethod();
};
const onResend = (event) => {
event.preventDefault();
void sendCode(method(), true);
};
const onSubmit = (event) => {
restoreToken();
const code = form?.querySelector("[name=code]");
@@ -573,11 +610,14 @@
// Capture native change before the shared Radio emits its component output.
form?.addEventListener("change", onMethodChange, true);
form?.addEventListener("submit", onSubmit, true);
resendButton?.addEventListener("click", onResend);
element.addEventListener("wrn:success", onSuccess);
element.addEventListener("wrn:error", onError);
flowCleanups.set(element, () => {
disposed = true;
window.clearTimeout(expiryTimer);
window.clearTimeout(resendTimer);
resendButton?.removeEventListener("click", onResend);
form?.removeEventListener("change", onMethodChange, true);
form?.removeEventListener("submit", onSubmit, true);
element.removeEventListener("wrn:success", onSuccess);
@@ -47,6 +47,10 @@ component TwoFactorChallenge {
<p data-error="code" class="m-0 text-sm text-[var(--wrn-color-danger)]" aria-live="polite"></p>
<Checkbox name="rememberDevice" label="Trust this device for 30 days" value="true" />
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-primary)]"></p>
<div data-auth-mfa-resend-group hidden class="space-y-2">
<Button type="button" data-auth-mfa-resend label="Resend code" variant="outline" color="{color}" size="{size}" />
<p class="m-0 text-xs text-[var(--wrn-color-muted)]">Check your Spam or Junk folder. After requesting a new code, use the latest email.</p>
</div>
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/auth",
"version": "0.8.33",
"version": "0.8.36",
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
"type": "module",
"sideEffects": false,
+21 -9
View File
@@ -132,6 +132,7 @@ export interface AuthEngine {
beginMfaOtp(
mfaToken: string,
method: "email-otp" | "sms-otp",
options?: { resend?: boolean },
): Promise<{ id: string; expiresAt: number } | undefined>;
completeMfa(input: {
rememberDevice?: boolean;
@@ -1711,7 +1712,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
return { ok: true, user: publicUser(user), session };
},
async beginMfaOtp(mfaToken, method) {
async beginMfaOtp(mfaToken, method, request = {}) {
const token = await inspectToken(mfaToken, "login-mfa");
if (!token) return undefined;
const user = await store.findUserById(token.userId);
@@ -1724,12 +1725,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const previousId = token.metadata?.[key];
if (typeof previousId === "string") {
const previous = await store.findOtp(previousId);
if (previous && !previous.usedAt && previous.expiresAt > now())
return { id: previous.id, expiresAt: previous.expiresAt };
if (request.resend && previous && now() - previous.createdAt < 60_000) return undefined;
if (previous && !previous.usedAt && previous.expiresAt > now()) {
if (!request.resend) return { id: previous.id, expiresAt: previous.expiresAt };
}
}
const challenge = await issueOtp(token.userId, method, undefined, "mfa");
token.metadata = { ...token.metadata, [key]: challenge.id };
await store.updateToken(token);
if (request.resend && typeof previousId === "string") {
const previous = await store.findOtp(previousId);
if (previous && !previous.usedAt) await store.updateOtp({ ...previous, usedAt: now() });
}
return challenge;
} catch {
return undefined;
@@ -2081,12 +2088,17 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
const verification = verificationUnavailable(user);
if (verification) return verification;
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
const providerVerifiesDevice = verifiedEmail &&
options.verifiedOAuthDeviceProviders?.some((name) => normalizedOAuthProvider(name) === providerName);
const deviceCheck = providerVerifiesDevice ? undefined : await newDeviceRequirement(user, {
...sessionInput,
oauthProvider: providerName,
});
const providerVerifiesDevice =
verifiedEmail &&
options.verifiedOAuthDeviceProviders?.some(
(name) => normalizedOAuthProvider(name) === providerName,
);
const deviceCheck = providerVerifiesDevice
? undefined
: await newDeviceRequirement(user, {
...sessionInput,
oauthProvider: providerName,
});
if (deviceCheck) return deviceCheck;
if (await hasMfa(user.id)) {
const methods = await availableMfaMethods(user.id);
+12 -3
View File
@@ -505,10 +505,19 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
if (!validation.ok) return validation.response;
const input = validation.value;
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method);
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method, {
resend: input.resend === true,
});
return challenge
? json({ ok: true, ...challenge })
: json({ ok: false, error: "MFA transaction is invalid or expired" }, 400);
? json({ ok: true, ...challenge, retryAfter: 60 })
: json(
{
ok: false,
error:
"Unable to request a code. Wait 60 seconds before retrying, or sign in again if this request has expired.",
},
400,
);
},
async completeMfa(ctx: Context): Promise<Response> {
+1
View File
@@ -148,6 +148,7 @@ export const otpSchema = v.object({
});
export const mfaOtpRequestSchema = v.object({
resend: v.boolean().optional(),
mfaToken: v
.string()
.required("MFA transaction is missing")
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
import { createContext } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { createAuthHttpHandlers } from "../src/http/index.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import type { AuthDeliveryMessage } from "../src/types.ts";
test("MFA resend creates a fresh code after cooldown and invalidates the previous code", async () => {
let now = 1_790_000_000_000;
const messages: AuthDeliveryMessage[] = [];
const engine = createAuthEngine({
store: new MemoryAuthStore(),
secret: "mfa-resend-test-secret-longer-than-thirty-two-characters",
clock: { now: () => now },
requireTrustedDeviceVerification: true,
delivery: {
async send(message) {
messages.push(message);
},
},
});
await engine.register({ email: "resend@example.test", password: "StrongPassword123!" });
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
const login = await engine.login({
identifier: "resend@example.test",
password: "StrongPassword123!",
fingerprint: "browser",
});
const handlers = createAuthHttpHandlers({ engine });
const request = (resend: boolean) => {
const req = new Request("https://sso.example.test/api/auth/mfa/otp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mfaToken: login.mfaToken, method: "email-otp", resend }),
});
return handlers.beginMfaOtp(createContext(req, new URL(req.url)));
};
const first = await (await request(false)).json();
const firstCode = messages.at(-1)!.code!;
const count = messages.length;
expect((await request(true)).status).toBe(400);
expect(messages.length).toBe(count);
expect((await (await request(false)).json()).id).toBe(first.id);
now += 60_000;
const next = await (await request(true)).json();
expect(next.ok).toBe(true);
expect(next.id).not.toBe(first.id);
expect(next.retryAfter).toBe(60);
expect(messages.length).toBe(count + 1);
expect((await engine.verifyOtp(first.id, firstCode, "mfa")).ok).toBe(false);
expect(
(
await engine.completeMfa({
mfaToken: login.mfaToken!,
method: "email-otp",
challengeId: next.id,
code: messages.at(-1)!.code!,
fingerprint: "browser",
})
).ok,
).toBe(true);
now += 60_000;
expect((await request(true)).status).toBe(400);
});
+55 -1
View File
@@ -1,6 +1,60 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
import { Window, type HTMLInputElement, type HTMLButtonElement, type HTMLElement } from "happy-dom";
test("MFA resend waits for cooldown, clears old code and replaces the active challenge", async () => {
const window = new Window({ url: "https://sso.example.test/mfa" });
try {
let now = Date.now();
window.Date.now = () => now;
window.sessionStorage.setItem(
"wrnexus.auth.mfa",
JSON.stringify({
mfaToken: "synthetic-test-challenge",
mfaExpiresAt: now + 300_000,
requires: { mfa: ["email-otp"] },
}),
);
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
<input name="mfaToken"><input name="challengeId"><input name="code"><input type="radio" name="method" value="email-otp">
<div data-auth-mfa-pin><input name="otpCode"></div><p data-auth-mfa-status></p><p data-error="_form">Verification failed</p>
<div data-auth-mfa-resend-group hidden><button type="button" data-auth-mfa-resend>Resend code</button></div><button type="submit">Verify</button>
</form></section>`;
const requests: any[] = [];
window.fetch = (async (_: unknown, init: RequestInit) => {
requests.push(JSON.parse(String(init.body)));
return Response.json({
id: "challenge-" + requests.length,
retryAfter: 60,
expiresAt: now + 300_000,
});
}) as any;
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
(window as any).WRNexusAuth.mount(window.document);
await new Promise((resolve) => setTimeout(resolve, 0));
const button = window.document.querySelector<HTMLButtonElement>("[data-auth-mfa-resend]")!;
expect(button.disabled).toBe(true);
expect(button.textContent).toContain("60s");
button.dispatchEvent(new window.Event("click"));
expect(requests.length).toBe(1);
const code = window.document.querySelector<HTMLInputElement>("[name=otpCode]")!;
code.value = "123456";
now += 60_001;
button.dispatchEvent(new window.Event("click"));
button.dispatchEvent(new window.Event("click"));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(requests.length).toBe(2);
expect(requests[1].resend).toBe(true);
expect(window.document.querySelector<HTMLInputElement>("[name=challengeId]")!.value).toBe(
"challenge-2",
);
expect(code.value).toBe("");
expect(window.document.querySelector("[data-error=_form]")!.textContent).toBe("");
expect(button.disabled).toBe(true);
} finally {
await window.happyDOM.close();
}
});
test("expired and legacy MFA requests are rejected on mount before entering a code", async () => {
for (const deadline of [undefined, Date.now() - 1]) {
+15 -5
View File
@@ -5,8 +5,11 @@ import { MemoryAuthStore } from "../src/stores/memory.ts";
const profile = { id: "google-user", email: "google@example.test", raw: { email_verified: true } };
function setup(providers?: string[]) {
const store = new MemoryAuthStore();
const engine = createAuthEngine({ store, secret: "oauth-device-policy-test-at-least-32-characters",
requireVerifiedEmail: true, requireTrustedDeviceVerification: true,
const engine = createAuthEngine({
store,
secret: "oauth-device-policy-test-at-least-32-characters",
requireVerifiedEmail: true,
requireTrustedDeviceVerification: true,
verifiedOAuthDeviceProviders: providers,
isOAuthEmailVerified: (_provider, value) => value.raw.email_verified === true,
});
@@ -14,18 +17,25 @@ function setup(providers?: string[]) {
}
test("explicit verified Google policy satisfies the device check without trusting future password logins", async () => {
const { engine } = setup(["google"]);
const result = await engine.loginWithOAuth("google", profile, undefined, { fingerprint: "new-browser" });
const result = await engine.loginWithOAuth("google", profile, undefined, {
fingerprint: "new-browser",
});
expect(result.ok).toBe(true);
expect(result.session).toBeDefined();
expect(await engine.listTrustedDevices(result.user!.id)).toHaveLength(0);
});
test("device verification remains the default and applies to other providers", async () => {
expect((await setup().engine.loginWithOAuth("google", profile)).code).toBe("mfa-required");
expect((await setup(["google"]).engine.loginWithOAuth("other", profile)).code).toBe("mfa-required");
expect((await setup(["google"]).engine.loginWithOAuth("other", profile)).code).toBe(
"mfa-required",
);
});
test("unverified provider emails cannot satisfy verification", async () => {
const { engine } = setup(["google"]);
const result = await engine.loginWithOAuth("google", { ...profile, raw: { email_verified: false } });
const result = await engine.loginWithOAuth("google", {
...profile,
raw: { email_verified: false },
});
expect(result.ok).toBe(false);
expect(result.session).toBeUndefined();
});
+51 -14
View File
@@ -3,39 +3,76 @@ import { createContext } from "@wrnexus/core";
import { createAuthEngine } from "../src/engine.ts";
import { MemoryAuthStore } from "../src/stores/memory.ts";
import { configureAuthRuntime } from "../src/plugin.ts";
import { clearDefaultAuthEngine, getDefaultAuthRouteOptions, setDefaultAuthRouteOptions } from "../src/runtime.ts";
import {
clearDefaultAuthEngine,
getDefaultAuthRouteOptions,
setDefaultAuthRouteOptions,
} from "../src/runtime.ts";
import { invokeAuthHandler } from "../src/routes/api.ts";
afterEach(() => { clearDefaultAuthEngine(); setDefaultAuthRouteOptions({}); });
afterEach(() => {
clearDefaultAuthEngine();
setDefaultAuthRouteOptions({});
});
test("production startup restores runtime-only providers and passkey settings for generated routes", async () => {
const engine = createAuthEngine({
store: new MemoryAuthStore(), secret: "production-runtime-test-secret-at-least-32-characters",
store: new MemoryAuthStore(),
secret: "production-runtime-test-secret-at-least-32-characters",
passkeys: {
async authenticationOptions({ rpId }) { return { challenge: "test-challenge", rpId, timeout: 60000, userVerification: "required" }; },
async registrationOptions() { throw new Error("not used"); },
async verifyRegistration() { return { verified: false }; },
async verifyAuthentication() { return { verified: false }; },
async authenticationOptions({ rpId }) {
return { challenge: "test-challenge", rpId, timeout: 60000, userVerification: "required" };
},
async registrationOptions() {
throw new Error("not used");
},
async verifyRegistration() {
return { verified: false };
},
async verifyAuthentication() {
return { verified: false };
},
},
});
// Build-time credentials absent, followed by server-startup runtime secrets.
configureAuthRuntime({ engine, oauth: {} });
const ctx = createContext(new Request("https://sso.example/api/auth/oauth/google"), new URL("https://sso.example/api/auth/oauth/google"));
const ctx = createContext(
new Request("https://sso.example/api/auth/oauth/google"),
new URL("https://sso.example/api/auth/oauth/google"),
);
ctx.params = { provider: "google" };
expect((await invokeAuthHandler("startOAuth", ctx)).status).toBe(404);
configureAuthRuntime({ engine, baseUrl: "https://sso.example", sessionCookieName: "custom_session",
configureAuthRuntime({
engine,
baseUrl: "https://sso.example",
sessionCookieName: "custom_session",
oauth: { google: { clientId: "runtime-client", clientSecret: "runtime-secret" } },
passkey: { rpId: "sso.example", origin: "https://sso.example" }, oauthMfaPath: "/mfa" });
passkey: { rpId: "sso.example", origin: "https://sso.example" },
oauthMfaPath: "/mfa",
});
const response = await invokeAuthHandler("startOAuth", ctx);
expect(response.status).toBe(302);
const location = new URL(response.headers.get("location")!);
expect(location.hostname).toBe("accounts.google.com");
expect(location.searchParams.get("client_id")).toBe("runtime-client");
expect(location.searchParams.get("redirect_uri")).toBe("https://sso.example/api/auth/oauth/google/callback");
expect(location.searchParams.get("redirect_uri")).toBe(
"https://sso.example/api/auth/oauth/google/callback",
);
expect(getDefaultAuthRouteOptions().sessionCookieName).toBe("custom_session");
const request = new Request("https://sso.example/api/auth/passkeys/login/options", { method: "POST",
headers: { "content-type": "application/json", origin: "https://sso.example", cookie: "wrn-csrf=runtime-test-csrf", "x-csrf-token": "runtime-test-csrf" }, body: "{}" });
const result = await invokeAuthHandler("passkeyAuthenticationOptions", createContext(request, new URL(request.url)));
const request = new Request("https://sso.example/api/auth/passkeys/login/options", {
method: "POST",
headers: {
"content-type": "application/json",
origin: "https://sso.example",
cookie: "wrn-csrf=runtime-test-csrf",
"x-csrf-token": "runtime-test-csrf",
},
body: "{}",
});
const result = await invokeAuthHandler(
"passkeyAuthenticationOptions",
createContext(request, new URL(request.url)),
);
expect(result.status).toBe(200);
expect((await result.json()).options.rpId).toBe("sso.example");
configureAuthRuntime({ enabled: false, engine });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/authz",
"version": "0.8.15",
"version": "0.8.16",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/benchmark",
"version": "0.8.8",
"version": "0.8.9",
"type": "module",
"description": "Deterministic benchmark runner and performance regression budgets for WRNexusJS.",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/block-editor",
"version": "0.1.20",
"version": "0.1.21",
"type": "module",
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
"exports": {
+45 -11
View File
@@ -2,14 +2,30 @@ import type { BlockNode } from "@wrnexus/blocks";
import { mountBlockEditor, type MountedEditor, type MountEditorOptions } from "./runtime.ts";
/** Annotates an editor-only rendered document; never changes the canonical tree. */
export function mountBlockCanvas(root: HTMLElement, tree: BlockNode, options: MountEditorOptions): MountedEditor {
const elements = new Map([...root.querySelectorAll<HTMLElement>("[data-block-id]")].map(element => [element.dataset.blockId!, element]));
export function mountBlockCanvas(
root: HTMLElement,
tree: BlockNode,
options: MountEditorOptions,
): MountedEditor {
const elements = new Map(
[...root.querySelectorAll<HTMLElement>("[data-block-id]")].map((element) => [
element.dataset.blockId!,
element,
]),
);
const cleanup: Array<() => void> = [];
const visit = (node: BlockNode, parent?: BlockNode, slot?: string) => {
const element = elements.get(node.id);
if (element) {
const old = ["data-block-parent", "data-block-slot", "data-block-type", "draggable"].map(name => [name, element.getAttribute(name)] as const);
cleanup.push(() => { for (const [name, value] of old) value === null ? element.removeAttribute(name) : element.setAttribute(name, value); });
const old = ["data-block-parent", "data-block-slot", "data-block-type", "draggable"].map(
(name) => [name, element.getAttribute(name)] as const,
);
cleanup.push(() => {
for (const [name, value] of old) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
});
element.setAttribute("draggable", "false");
if (parent && slot !== undefined) {
element.dataset.blockParent = parent.id;
@@ -25,7 +41,10 @@ export function mountBlockCanvas(root: HTMLElement, tree: BlockNode, options: Mo
// Clicks must not bubble into selection or inline-edit handlers.
// Pointer/keyboard sensor events remain available to the DnD backend.
for (const name of ["click", "dblclick"])
handle.addEventListener(name, event => { event.preventDefault(); event.stopPropagation(); });
handle.addEventListener(name, (event) => {
event.preventDefault();
event.stopPropagation();
});
element.prepend(handle);
cleanup.push(() => handle.remove());
}
@@ -38,18 +57,33 @@ export function mountBlockCanvas(root: HTMLElement, tree: BlockNode, options: Mo
target.textContent = `Drop blocks in ${name}`;
// Root is a presentation table; keep editor decorations in a valid cell.
const container = element.tagName === "TABLE" ? element.querySelector("td") : element;
if (container) { container.append(target); cleanup.push(() => target.remove()); }
if (container) {
container.append(target);
cleanup.push(() => target.remove());
}
}
}
for (const [name, children] of Object.entries(node.slots)) for (const child of children) visit(child, node, name);
for (const [name, children] of Object.entries(node.slots))
for (const child of children) visit(child, node, name);
};
visit(tree);
let mounted: MountedEditor;
try { mounted = mountBlockEditor(root, options); }
catch (error) { cleanup.reverse().forEach(fn => fn()); throw error; }
try {
mounted = mountBlockEditor(root, options);
} catch (error) {
cleanup.reverse().forEach((fn) => fn());
throw error;
}
let destroyed = false;
return {
refresh: () => { if (!destroyed) mounted.refresh(); },
destroy() { if (destroyed) return; destroyed = true; mounted.destroy(); cleanup.reverse().forEach(fn => fn()); },
refresh: () => {
if (!destroyed) mounted.refresh();
},
destroy() {
if (destroyed) return;
destroyed = true;
mounted.destroy();
cleanup.reverse().forEach((fn) => fn());
},
};
}
+14 -3
View File
@@ -1,7 +1,12 @@
const downloads = new WeakMap<HTMLElement, () => void>();
/** Keep a direct user-gesture fallback until replaced or the editor is removed. */
export function offerBuilderDownload(host: HTMLElement, content: string, type: string, filename: string): void {
export function offerBuilderDownload(
host: HTMLElement,
content: string,
type: string,
filename: string,
): void {
const outlet = host.querySelector<HTMLElement>("[data-builder-download]");
if (!outlet) throw new Error("Download outlet is unavailable");
const url = URL.createObjectURL(new Blob([content], { type }));
@@ -10,7 +15,9 @@ export function offerBuilderDownload(host: HTMLElement, content: string, type: s
link.download = filename;
link.textContent = `Download prepared file: ${filename}`;
let disposed = false;
const observer = new MutationObserver(() => { if (!host.isConnected) dispose(); });
const observer = new MutationObserver(() => {
if (!host.isConnected) dispose();
});
const dispose = () => {
if (disposed) return;
disposed = true;
@@ -29,5 +36,9 @@ export function offerBuilderDownload(host: HTMLElement, content: string, type: s
throw error;
}
// A blocked automatic click must not destroy the direct-click fallback.
try { link.click(); } catch { /* The visible link remains available. */ }
try {
link.click();
} catch {
/* The visible link remains available. */
}
}
+10 -5
View File
@@ -11,13 +11,18 @@ export function showImageFallbacks(canvas: Document, enabled: boolean): void {
fallback.dataset.imageFallback = "";
const alt = image.getAttribute("alt") ?? "";
fallback.textContent = alt;
if (alt) { fallback.setAttribute("role", "img"); fallback.setAttribute("aria-label", alt); }
else fallback.setAttribute("aria-hidden", "true");
const width = Number(image.getAttribute("width")), height = Number(image.getAttribute("height"));
fallback.style.cssText = "display:flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #64748b;color:#334155;background:#f1f5f9;font:14px Arial;padding:8px;overflow-wrap:anywhere;overflow:hidden;width:100%;min-height:32px;";
if (alt) {
fallback.setAttribute("role", "img");
fallback.setAttribute("aria-label", alt);
} else fallback.setAttribute("aria-hidden", "true");
const width = Number(image.getAttribute("width")),
height = Number(image.getAttribute("height"));
fallback.style.cssText =
"display:flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #64748b;color:#334155;background:#f1f5f9;font:14px Arial;padding:8px;overflow-wrap:anywhere;overflow:hidden;width:100%;min-height:32px;";
if (Number.isFinite(width) && width > 0 && width <= 4000) {
fallback.style.maxWidth = `${width}px`;
if (Number.isFinite(height) && height > 0 && height <= 4000) fallback.style.aspectRatio = `${width}/${height}`;
if (Number.isFinite(height) && height > 0 && height <= 4000)
fallback.style.aspectRatio = `${width}/${height}`;
}
image.after(fallback);
created.push(fallback);
+38 -13
View File
@@ -1,6 +1,13 @@
import { createRegistry, type BlockDefinition, type BlockNode, type LocalizedLabel } from "@wrnexus/blocks";
import {
createRegistry,
type BlockDefinition,
type BlockNode,
type LocalizedLabel,
} from "@wrnexus/blocks";
export interface BuilderCreateContext { id(): string }
export interface BuilderCreateContext {
id(): string;
}
export interface BuilderPreset {
id: string;
name: LocalizedLabel;
@@ -23,36 +30,54 @@ export function createBuilderKit(options: {
}
const validateProps = (type: string, props: Record<string, unknown>): void => {
const definition = registry.get(type);
if (!definition) throw new Error("Unavailable block: " + type + ". Existing data must be preserved.");
if (!definition)
throw new Error("Unavailable block: " + type + ". Existing data must be preserved.");
const parsed = definition.manifest.props.parse(props, "props");
if (!parsed.ok) throw new Error(parsed.errors.map(error => (error.path ?? "props") + ": " + error.message).join("\n"));
if (!parsed.ok)
throw new Error(
parsed.errors.map((error) => (error.path ?? "props") + ": " + error.message).join("\n"),
);
};
const validateCreated = (root: BlockNode): BlockNode => {
const ids = new Set<string>();
const visit = (node: BlockNode, depth: number) => {
if (depth > 30 || ids.size >= 500) throw new Error("Created tree exceeds builder limits");
if (!node.id || ids.has(node.id)) throw new Error("Created node IDs must be nonempty and unique");
if (!node.id || ids.has(node.id))
throw new Error("Created node IDs must be nonempty and unique");
ids.add(node.id);
const definition = registry.get(node.type);
if (!definition || node.version !== definition.manifest.version) throw new Error("Unsupported created block version: " + node.type);
if (!definition || node.version !== definition.manifest.version)
throw new Error("Unsupported created block version: " + node.type);
validateProps(node.type, node.props);
const styles = definition.manifest.styles.parse(node.styles, "styles");
if (!styles.ok) throw new Error(styles.errors.map(error => error.message).join("\n"));
if (!styles.ok) throw new Error(styles.errors.map((error) => error.message).join("\n"));
for (const [name, children] of Object.entries(node.slots)) {
const slot = definition.manifest.slots[name];
if (!slot || !Array.isArray(children)) throw new Error("Unknown or invalid slot: " + name);
if (children.length < (slot.minChildren ?? 0) || children.length > (slot.maxChildren ?? 500)) throw new Error("Invalid slot size: " + name);
if (
children.length < (slot.minChildren ?? 0) ||
children.length > (slot.maxChildren ?? 500)
)
throw new Error("Invalid slot size: " + name);
for (const child of children) {
const childDefinition = registry.get(child.type);
if (slot.accepts?.types && !slot.accepts.types.includes(child.type)) throw new Error("Slot rejects block type: " + child.type);
if (slot.accepts?.capabilities && !slot.accepts.capabilities.every(value => childDefinition?.manifest.capabilities?.includes(value))) throw new Error("Slot rejects block capabilities: " + child.type);
if (slot.accepts?.types && !slot.accepts.types.includes(child.type))
throw new Error("Slot rejects block type: " + child.type);
if (
slot.accepts?.capabilities &&
!slot.accepts.capabilities.every((value) =>
childDefinition?.manifest.capabilities?.includes(value),
)
)
throw new Error("Slot rejects block capabilities: " + child.type);
visit(child, depth + 1);
}
}
for (const [name, slot] of Object.entries(definition.manifest.slots))
if ((slot.minChildren ?? 0) > 0 && !node.slots[name]) throw new Error("Required slot missing: " + name);
const errors = definition.validate?.(node)?.filter(item => item.severity === "error");
if (errors?.length) throw new Error(errors.map(item => item.message).join("\n"));
if ((slot.minChildren ?? 0) > 0 && !node.slots[name])
throw new Error("Required slot missing: " + name);
const errors = definition.validate?.(node)?.filter((item) => item.severity === "error");
if (errors?.length) throw new Error(errors.map((item) => item.message).join("\n"));
};
const copy = structuredClone(root);
visit(copy, 0);
+78 -22
View File
@@ -3,36 +3,58 @@ import { builderPreviewHTML } from "./preview-html.ts";
export function updateBuilderCanvas(frame: HTMLIFrameElement, html: string): void {
const canvas = frame.contentDocument;
if (!canvas?.body) return;
const parsed = new DOMParser().parseFromString(builderPreviewHTML(html, frame.ownerDocument), "text/html");
const parsed = new DOMParser().parseFromString(
builderPreviewHTML(html, frame.ownerDocument),
"text/html",
);
const scrolling = canvas.scrollingElement;
const top = scrolling?.scrollTop ?? 0, left = scrolling?.scrollLeft ?? 0;
canvas.head.replaceChildren(...Array.from(parsed.head.childNodes).map(node => canvas.importNode(node, true)));
const top = scrolling?.scrollTop ?? 0,
left = scrolling?.scrollLeft ?? 0;
canvas.head.replaceChildren(
...Array.from(parsed.head.childNodes).map((node) => canvas.importNode(node, true)),
);
for (const attr of Array.from(canvas.body.attributes)) canvas.body.removeAttribute(attr.name);
for (const attr of Array.from(parsed.body.attributes)) canvas.body.setAttribute(attr.name, attr.value);
canvas.body.replaceChildren(...Array.from(parsed.body.childNodes).map(node => canvas.importNode(node, true)));
if (scrolling) { scrolling.scrollTop = top; scrolling.scrollLeft = left; }
for (const attr of Array.from(parsed.body.attributes))
canvas.body.setAttribute(attr.name, attr.value);
canvas.body.replaceChildren(
...Array.from(parsed.body.childNodes).map((node) => canvas.importNode(node, true)),
);
if (scrolling) {
scrolling.scrollTop = top;
scrolling.scrollLeft = left;
}
}
/** Selection controls are editor decorations, never part of saved/exported HTML. */
export function showBuilderBlockActions(canvas: Document, id: string, label: string, actions: Record<string, () => void>): void {
export function showBuilderBlockActions(
canvas: Document,
id: string,
label: string,
actions: Record<string, () => void>,
): void {
canvas.querySelector("[data-builder-block-actions]")?.remove();
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(
(item) => item.dataset.blockId === id,
);
if (!block) return;
if (!canvas.querySelector('[data-builder-selection-style]')) {
const style = canvas.createElement('style');
style.dataset.builderSelectionStyle = '';
style.textContent = '.wrn-builder-selected[data-block-type="content.button"]{outline:none!important}.wrn-builder-selected[data-block-type="content.button"] a{outline:2px solid var(--wrn-builder-accent);outline-offset:3px}';
if (!canvas.querySelector("[data-builder-selection-style]")) {
const style = canvas.createElement("style");
style.dataset.builderSelectionStyle = "";
style.textContent =
'.wrn-builder-selected[data-block-type="content.button"]{outline:none!important}.wrn-builder-selected[data-block-type="content.button"] a{outline:2px solid var(--wrn-builder-accent);outline-offset:3px}';
canvas.head.append(style);
}
const toolbar = canvas.createElement("div");
toolbar.dataset.builderBlockActions = "";
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", label + " actions");
toolbar.style.cssText = "position:absolute;z-index:100;display:flex;gap:2px;align-items:center;padding:3px;box-sizing:border-box;max-width:100%;border-radius:6px;background:var(--wrn-builder-accent,var(--wrn-color-primary,#2563eb));color:white;font:11px Arial;box-shadow:0 3px 12px #0003";
toolbar.style.cssText =
"position:absolute;z-index:100;display:flex;gap:2px;align-items:center;padding:3px;box-sizing:border-box;max-width:100%;border-radius:6px;background:var(--wrn-builder-accent,var(--wrn-color-primary,#2563eb));color:white;font:11px Arial;box-shadow:0 3px 12px #0003";
const name = canvas.createElement("strong");
name.textContent = label.split(" — ")[0];
name.title = label;
name.style.cssText = "padding:0 6px;max-width:72px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
name.style.cssText =
"padding:0 6px;max-width:72px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
toolbar.append(name);
for (const [name, action] of Object.entries(actions)) {
const button = canvas.createElement("button");
@@ -48,28 +70,62 @@ export function showBuilderBlockActions(canvas: Document, id: string, label: str
};
if (paths[name]) {
const icon = canvas.createElementNS("http://www.w3.org/2000/svg", "svg");
for (const [key, value] of Object.entries({ width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.7", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true" })) icon.setAttribute(key, value);
for (const [key, value] of Object.entries({
width: "15",
height: "15",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.7",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"aria-hidden": "true",
}))
icon.setAttribute(key, value);
const path = canvas.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", paths[name]);
icon.append(path);
button.append(icon);
} else button.textContent = name;
button.style.cssText = "border:0;border-radius:3px;background:transparent;color:white;min-width:28px;height:28px;display:grid;place-items:center;cursor:pointer";
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); action(); });
button.style.cssText =
"border:0;border-radius:3px;background:transparent;color:white;min-width:28px;height:28px;display:grid;place-items:center;cursor:pointer";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
action();
});
toolbar.append(button);
}
// Keep controls outside presentation tables so browser table repair cannot move them.
canvas.body.prepend(toolbar);
const surface = block.dataset.blockType === 'content.button' ? block.querySelector('a') ?? block : block;
const surface =
block.dataset.blockType === "content.button" ? (block.querySelector("a") ?? block) : block;
const bounds = surface.getBoundingClientRect();
toolbar.style.top = Math.max(0, bounds.top + (canvas.defaultView?.scrollY ?? 0) - toolbar.offsetHeight) + "px";
toolbar.style.left = Math.max(0, Math.min(bounds.right - toolbar.offsetWidth, canvas.documentElement.clientWidth - toolbar.offsetWidth)) + "px";
toolbar.style.top =
Math.max(0, bounds.top + (canvas.defaultView?.scrollY ?? 0) - toolbar.offsetHeight) + "px";
toolbar.style.left =
Math.max(
0,
Math.min(
bounds.right - toolbar.offsetWidth,
canvas.documentElement.clientWidth - toolbar.offsetWidth,
),
) + "px";
block.setAttribute("aria-label", label + " selected");
}
/** Inline formatting is returned as semantic runs, never raw HTML. */
export function editBuilderText(canvas: Document, id: string, value: string, update: (text: string, runs?: BuilderTextRun[]) => void, finish: () => void, runs: BuilderTextRun[] = []): boolean {
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
export function editBuilderText(
canvas: Document,
id: string,
value: string,
update: (text: string, runs?: BuilderTextRun[]) => void,
finish: () => void,
runs: BuilderTextRun[] = [],
): boolean {
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(
(item) => item.dataset.blockId === id,
);
const element = block?.querySelector<HTMLElement>("h1,h2,h3,h4,p");
if (!element) return false;
mountInlineRichText(canvas, element, value, update, finish, runs);
+12 -6
View File
@@ -7,11 +7,17 @@ export function builderOutlineLabel(
): string {
const name = definition?.name?.fallback || node.type;
const props = node.props ?? {};
const candidate = [props.text, props.label, props.title, props.question, props.alt]
.find(value => typeof value === "string" && value.trim());
const detail = typeof candidate === "string"
? candidate.replace(/\s+/g, " ").trim()
: node.type === "layout.column" && typeof props.width === "string" ? props.width : "";
const candidate = [props.text, props.label, props.title, props.question, props.alt].find(
(value) => typeof value === "string" && value.trim(),
);
const detail =
typeof candidate === "string"
? candidate.replace(/\s+/g, " ").trim()
: node.type === "layout.column" && typeof props.width === "string"
? props.width
: "";
const bounded = Array.from(detail);
return detail ? `${name} — ${bounded.slice(0, 64).join("")}${bounded.length > 64 ? "…" : ""}` : name;
return detail
? `${name} — ${bounded.slice(0, 64).join("")}${bounded.length > 64 ? "…" : ""}`
: name;
}
+176 -40
View File
@@ -1,4 +1,9 @@
export interface BuilderTextRun { text: string; bold?: boolean; italic?: boolean; underline?: boolean }
export interface BuilderTextRun {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
/** Read only allowlisted inline semantics; pasted HTML and executable content are never stored. */
export function readBuilderText(element: HTMLElement): BuilderTextRun[] {
@@ -6,33 +11,63 @@ export function readBuilderText(element: HTMLElement): BuilderTextRun[] {
const append = (text: string, marks: Omit<BuilderTextRun, "text">) => {
if (!text) return;
const last = runs.at(-1);
if (last && !!last.bold === !!marks.bold && !!last.italic === !!marks.italic && !!last.underline === !!marks.underline) last.text += text;
else runs.push({text, ...marks});
if (
last &&
!!last.bold === !!marks.bold &&
!!last.italic === !!marks.italic &&
!!last.underline === !!marks.underline
)
last.text += text;
else runs.push({ text, ...marks });
};
const walk = (node: Node, marks: Omit<BuilderTextRun, "text">) => {
if (node.nodeType === 3) { append(node.textContent ?? "", marks); return; }
if (node.nodeType === 3) {
append(node.textContent ?? "", marks);
return;
}
if (node.nodeType !== 1) return;
const el = node as HTMLElement, tag = el.tagName;
const el = node as HTMLElement,
tag = el.tagName;
if (["SCRIPT", "STYLE", "IFRAME", "OBJECT"].includes(tag)) return;
if (tag === "BR") { append("\n", marks); return; }
if (["DIV", "P"].includes(tag) && runs.length && !runs.at(-1)!.text.endsWith("\n")) append("\n", marks);
const next = {...marks};
if (["B", "STRONG"].includes(tag) || ["bold", "700"].includes(el.style.fontWeight)) next.bold = true;
if (tag === "BR") {
append("\n", marks);
return;
}
if (["DIV", "P"].includes(tag) && runs.length && !runs.at(-1)!.text.endsWith("\n"))
append("\n", marks);
const next = { ...marks };
if (["B", "STRONG"].includes(tag) || ["bold", "700"].includes(el.style.fontWeight))
next.bold = true;
if (["I", "EM"].includes(tag) || el.style.fontStyle === "italic") next.italic = true;
if (tag === "U" || el.style.textDecoration.includes("underline")) next.underline = true;
el.childNodes.forEach(child => walk(child, next));
el.childNodes.forEach((child) => walk(child, next));
};
element.childNodes.forEach(node => walk(node, {}));
element.childNodes.forEach((node) => walk(node, {}));
return runs;
}
export function mountInlineRichText(canvas: Document, element: HTMLElement, value: string, update: (text: string, runs?: BuilderTextRun[]) => void, finish: () => void, runs: BuilderTextRun[] = []): void {
export function mountInlineRichText(
canvas: Document,
element: HTMLElement,
value: string,
update: (text: string, runs?: BuilderTextRun[]) => void,
finish: () => void,
runs: BuilderTextRun[] = [],
): void {
element.replaceChildren();
const initial = runs.map(run => run.text).join("") === value ? runs : [{text: value}];
const initial = runs.map((run) => run.text).join("") === value ? runs : [{ text: value }];
for (const run of initial) {
let child: Node = canvas.createTextNode(run.text);
for (const [mark, tag] of [[run.underline, "u"], [run.italic, "em"], [run.bold, "strong"]] as const) {
if (mark) { const wrapper = canvas.createElement(tag); wrapper.append(child); child = wrapper; }
for (const [mark, tag] of [
[run.underline, "u"],
[run.italic, "em"],
[run.bold, "strong"],
] as const) {
if (mark) {
const wrapper = canvas.createElement(tag);
wrapper.append(child);
child = wrapper;
}
}
element.append(child);
}
@@ -41,43 +76,144 @@ export function mountInlineRichText(canvas: Document, element: HTMLElement, valu
element.setAttribute("aria-label", "Edit rich text");
element.setAttribute("aria-multiline", "true");
element.style.whiteSpace = "pre-wrap";
const block = element.closest<HTMLElement>('[data-block-id]');
block?.classList.add('wrn-inline-editing');
const editingStyle = canvas.createElement('style');
editingStyle.textContent = '.wrn-inline-editing{outline:none!important}.wrn-inline-editing>[data-block-handle]{visibility:hidden!important}.wrn-inline-editing [contenteditable="true"]{outline:2px solid var(--wrn-builder-accent)!important;outline-offset:4px;border-radius:2px;cursor:text}';
const block = element.closest<HTMLElement>("[data-block-id]");
block?.classList.add("wrn-inline-editing");
const editingStyle = canvas.createElement("style");
editingStyle.textContent =
'.wrn-inline-editing{outline:none!important}.wrn-inline-editing>[data-block-handle]{visibility:hidden!important}.wrn-inline-editing [contenteditable="true"]{outline:2px solid var(--wrn-builder-accent)!important;outline-offset:4px;border-radius:2px;cursor:text}';
canvas.head.append(editingStyle);
const toolbar = canvas.createElement("div");
toolbar.dataset.builderBlockActions = "rich-text";
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "Text formatting");
toolbar.style.cssText = "position:fixed;z-index:1000;display:flex;gap:4px;padding:6px;border-radius:8px;background:var(--wrn-builder-accent,#2563eb);color:white;box-shadow:0 4px 16px #0003;font:14px Arial";
canvas.querySelectorAll('[data-builder-block-actions]').forEach(item => item.remove());
toolbar.style.cssText =
"position:fixed;z-index:1000;display:flex;gap:4px;padding:6px;border-radius:8px;background:var(--wrn-builder-accent,#2563eb);color:white;box-shadow:0 4px 16px #0003;font:14px Arial";
canvas.querySelectorAll("[data-builder-block-actions]").forEach((item) => item.remove());
canvas.body.append(toolbar);
const position = () => { const box = element.getBoundingClientRect(); toolbar.style.top = Math.max(4, box.top - 44) + "px"; toolbar.style.left = Math.max(4, Math.min(box.left, canvas.documentElement.clientWidth - toolbar.offsetWidth - 4)) + "px"; };
const emit = () => { const result = readBuilderText(element); update(result.map(run => run.text).join(""), result); };
const position = () => {
const box = element.getBoundingClientRect();
toolbar.style.top = Math.max(4, box.top - 44) + "px";
toolbar.style.left =
Math.max(
4,
Math.min(box.left, canvas.documentElement.clientWidth - toolbar.offsetWidth - 4),
) + "px";
};
const emit = () => {
const result = readBuilderText(element);
update(result.map((run) => run.text).join(""), result);
};
let selection: Range | null = null;
const remember = () => { const current = canvas.getSelection(); if (current?.rangeCount && element.contains(current.anchorNode) && element.contains(current.focusNode)) selection = current.getRangeAt(0).cloneRange(); };
const restore = () => { element.focus({preventScroll:true}); if (selection) {const current = canvas.getSelection(); current?.removeAllRanges(); current?.addRange(selection);} };
const abort = new (canvas.defaultView!.AbortController)();
const options = {signal: abort.signal};
const remember = () => {
const current = canvas.getSelection();
if (
current?.rangeCount &&
element.contains(current.anchorNode) &&
element.contains(current.focusNode)
)
selection = current.getRangeAt(0).cloneRange();
};
const restore = () => {
element.focus({ preventScroll: true });
if (selection) {
const current = canvas.getSelection();
current?.removeAllRanges();
current?.addRange(selection);
}
};
const abort = new canvas.defaultView!.AbortController();
const options = { signal: abort.signal };
let done = false;
const close = () => { if (done) return; done = true; abort.abort(); toolbar.remove(); editingStyle.remove(); block?.classList.remove('wrn-inline-editing'); element.contentEditable = "false"; finish(); };
for (const [label, command] of [["Bold", "bold"], ["Italic", "italic"], ["Underline", "underline"], ["Clear formatting", "removeFormat"], ["Done", "done"]]) {
const close = () => {
if (done) return;
done = true;
abort.abort();
toolbar.remove();
editingStyle.remove();
block?.classList.remove("wrn-inline-editing");
element.contentEditable = "false";
finish();
};
for (const [label, command] of [
["Bold", "bold"],
["Italic", "italic"],
["Underline", "underline"],
["Clear formatting", "removeFormat"],
["Done", "done"],
]) {
const button = canvas.createElement("button");
button.type = "button"; button.textContent = label; button.setAttribute("aria-label", label!);
button.style.cssText = "background:transparent;color:inherit;border:0;border-radius:4px;padding:6px;cursor:pointer;font:inherit";
button.addEventListener("mousedown", event => event.preventDefault(), options);
button.addEventListener("click", event => {event.stopPropagation(); if (command === "done") { close(); return; } restore(); canvas.execCommand(command!); remember(); emit();}, options);
button.type = "button";
button.textContent = label;
button.setAttribute("aria-label", label!);
button.style.cssText =
"background:transparent;color:inherit;border:0;border-radius:4px;padding:6px;cursor:pointer;font:inherit";
button.addEventListener("mousedown", (event) => event.preventDefault(), options);
button.addEventListener(
"click",
(event) => {
event.stopPropagation();
if (command === "done") {
close();
return;
}
restore();
canvas.execCommand(command!);
remember();
emit();
},
options,
);
toolbar.append(button);
}
element.addEventListener("input", emit, options);
element.addEventListener("paste", event => {event.preventDefault(); canvas.execCommand("insertText", false, event.clipboardData?.getData("text/plain") ?? ""); emit();}, options);
element.addEventListener("drop", event => event.preventDefault(), options);
element.addEventListener("keydown", event => {event.stopPropagation(); if(event.key === "Escape") {event.preventDefault(); close();}}, options);
toolbar.addEventListener("keydown", event => {event.stopPropagation(); if(event.key === "Escape") close();}, options);
element.addEventListener(
"paste",
(event) => {
event.preventDefault();
canvas.execCommand("insertText", false, event.clipboardData?.getData("text/plain") ?? "");
emit();
},
options,
);
element.addEventListener("drop", (event) => event.preventDefault(), options);
element.addEventListener(
"keydown",
(event) => {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
close();
}
},
options,
);
toolbar.addEventListener(
"keydown",
(event) => {
event.stopPropagation();
if (event.key === "Escape") close();
},
options,
);
canvas.addEventListener("selectionchange", remember, options);
canvas.addEventListener("focusin", event => {if (!element.contains(event.target as Node) && !toolbar.contains(event.target as Node)) close();}, options);
element.addEventListener("blur", () => {setTimeout(() => {if (!toolbar.contains(canvas.activeElement) && canvas.activeElement !== element) close();}, 0);}, options);
canvas.addEventListener(
"focusin",
(event) => {
if (!element.contains(event.target as Node) && !toolbar.contains(event.target as Node))
close();
},
options,
);
element.addEventListener(
"blur",
() => {
setTimeout(() => {
if (!toolbar.contains(canvas.activeElement) && canvas.activeElement !== element) close();
}, 0);
},
options,
);
canvas.defaultView?.addEventListener("scroll", position, options);
element.focus({preventScroll:true}); position();
element.focus({ preventScroll: true });
position();
}
+125 -110
View File
@@ -39,123 +39,138 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions)
cleanups.forEach((cleanup) => cleanup());
} finally {
cleanups = [];
try { manager?.destroy(); } finally { live.remove(); }
try {
manager?.destroy();
} finally {
live.remove();
}
}
};
const refresh = () => {
if (destroyed) return;
try {
const currentGeneration = ++generation;
cleanups.forEach((cleanup) => cleanup());
cleanups = [];
manager?.destroy();
let originalChildren = new Map<Node, Node[]>();
let gesture = 0;
const groups = new Map<string, { parentId: string; slot: string }>();
manager = createDndManager({
accept(ids, location) {
const destination = groups.get(String(location.group));
return (
!destroyed &&
generation === currentGeneration &&
!!destination &&
(options.canMove?.({
nodeIds: ids.map(String),
to: { ...destination, index: location.index },
}) ??
true)
);
},
direction:
options.direction ?? (view.getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"),
reducedMotion: view.matchMedia("(prefers-reduced-motion: reduce)").matches,
announce(announcement) {
if (destroyed || generation !== currentGeneration) return;
if (announcement.type === "pickedUp") {
gesture++;
originalChildren = new Map();
for (const element of root.querySelectorAll(
"[data-block-id][data-block-parent][data-block-slot]",
)) {
const parent = element.parentNode;
if (parent && !originalChildren.has(parent))
originalChildren.set(parent, [...parent.childNodes]);
}
} else if (announcement.type === "cancelled") {
// Let the backend finish its own cancellation work before reconciliation.
// Keep the actual nodes (and their listeners), not a stale innerHTML copy.
const snapshot = originalChildren;
const cancelledGesture = gesture;
view.setTimeout(() => {
if (
destroyed ||
generation !== currentGeneration ||
gesture !== cancelledGesture ||
!root.isConnected
)
return;
for (const [parent, children] of snapshot) {
if (parent !== root && !root.contains(parent)) continue;
let anchor: Node | null = null;
for (const child of [...children].reverse()) {
// Never resurrect nodes removed by an application rerender.
if (!root.contains(child)) continue;
parent.insertBefore(child, anchor);
anchor = child;
}
const currentGeneration = ++generation;
cleanups.forEach((cleanup) => cleanup());
cleanups = [];
manager?.destroy();
let originalChildren = new Map<Node, Node[]>();
let gesture = 0;
const groups = new Map<string, { parentId: string; slot: string }>();
manager = createDndManager({
accept(ids, location) {
const destination = groups.get(String(location.group));
return (
!destroyed &&
generation === currentGeneration &&
!!destination &&
(options.canMove?.({
nodeIds: ids.map(String),
to: { ...destination, index: location.index },
}) ??
true)
);
},
direction:
options.direction ?? (view.getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"),
reducedMotion: view.matchMedia("(prefers-reduced-motion: reduce)").matches,
announce(announcement) {
if (destroyed || generation !== currentGeneration) return;
if (announcement.type === "pickedUp") {
gesture++;
originalChildren = new Map();
for (const element of root.querySelectorAll(
"[data-block-id][data-block-parent][data-block-slot]",
)) {
const parent = element.parentNode;
if (parent && !originalChildren.has(parent))
originalChildren.set(parent, [...parent.childNodes]);
}
refresh();
}, 0);
} else if (announcement.type === "dropped") originalChildren.clear();
live.textContent = options.announce?.(announcement) ?? announcement.type;
},
async onMoveIntent(intent) {
if (destroyed || generation !== currentGeneration) return false;
const destination = groups.get(String(intent.to.group));
if (!destination) return false;
return options.onMove({
nodeIds: intent.ids.map(String),
to: { ...destination, index: intent.to.index },
});
},
});
const elements = [...root.querySelectorAll<HTMLElement>(
"[data-block-id][data-block-parent][data-block-slot]",
)];
const groupSizes = new Map<string, number>();
const registrations = elements.map(element => {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlot!;
const group = JSON.stringify([parentId, slot]);
const index = groupSizes.get(group) ?? 0;
groupSizes.set(group, index + 1);
groups.set(group, { parentId, slot });
return { element, group, index };
});
for (const element of root.querySelectorAll<HTMLElement>("[data-block-slot-target][data-block-parent]")) {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlotTarget!;
const group = JSON.stringify([parentId, slot]);
if (groupSizes.has(group)) continue;
groups.set(group, { parentId, slot });
cleanups.push(manager.registerDropZone({ id: `wrn-slot:${group}`, group, index: 0, element }));
}
for (const { element, group, index } of registrations) {
const id = element.dataset.blockId!;
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
cleanups.push(
manager.registerSortable({
id,
group,
index,
element,
handle,
type: element.dataset.blockType,
}),
);
}
} else if (announcement.type === "cancelled") {
// Let the backend finish its own cancellation work before reconciliation.
// Keep the actual nodes (and their listeners), not a stale innerHTML copy.
const snapshot = originalChildren;
const cancelledGesture = gesture;
view.setTimeout(() => {
if (
destroyed ||
generation !== currentGeneration ||
gesture !== cancelledGesture ||
!root.isConnected
)
return;
for (const [parent, children] of snapshot) {
if (parent !== root && !root.contains(parent)) continue;
let anchor: Node | null = null;
for (const child of [...children].reverse()) {
// Never resurrect nodes removed by an application rerender.
if (!root.contains(child)) continue;
parent.insertBefore(child, anchor);
anchor = child;
}
}
refresh();
}, 0);
} else if (announcement.type === "dropped") originalChildren.clear();
live.textContent = options.announce?.(announcement) ?? announcement.type;
},
async onMoveIntent(intent) {
if (destroyed || generation !== currentGeneration) return false;
const destination = groups.get(String(intent.to.group));
if (!destination) return false;
return options.onMove({
nodeIds: intent.ids.map(String),
to: { ...destination, index: intent.to.index },
});
},
});
const elements = [
...root.querySelectorAll<HTMLElement>(
"[data-block-id][data-block-parent][data-block-slot]",
),
];
const groupSizes = new Map<string, number>();
const registrations = elements.map((element) => {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlot!;
const group = JSON.stringify([parentId, slot]);
const index = groupSizes.get(group) ?? 0;
groupSizes.set(group, index + 1);
groups.set(group, { parentId, slot });
return { element, group, index };
});
for (const element of root.querySelectorAll<HTMLElement>(
"[data-block-slot-target][data-block-parent]",
)) {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlotTarget!;
const group = JSON.stringify([parentId, slot]);
if (groupSizes.has(group)) continue;
groups.set(group, { parentId, slot });
cleanups.push(
manager.registerDropZone({ id: `wrn-slot:${group}`, group, index: 0, element }),
);
}
for (const { element, group, index } of registrations) {
const id = element.dataset.blockId!;
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
cleanups.push(
manager.registerSortable({
id,
group,
index,
element,
handle,
type: element.dataset.blockType,
}),
);
}
} catch (error) {
try { destroy(); } finally { throw error; }
try {
destroy();
} catch {
/* Preserve the original registration failure. */
}
throw error;
}
};
refresh();
+19 -3
View File
@@ -12,20 +12,36 @@ export function mountUnsavedChangesGuard(
event.returnValue = "";
};
const click = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const target = event.target as Element | null;
const link = target?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!link || link.hasAttribute("download") || (link.target && link.target !== "_self")) return;
const url = new URL(link.href, document.baseURI);
if (!["http:", "https:"].includes(url.protocol)) return;
const current = new URL(view.location.href);
if (url.origin === current.origin && url.pathname === current.pathname && url.search === current.search && url.hash) return;
if (
url.origin === current.origin &&
url.pathname === current.pathname &&
url.search === current.search &&
url.hash
)
return;
if (dirty() && !options.confirmLeave()) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
const observer = new view.MutationObserver(() => { if (!root.isConnected) destroy(); });
const observer = new view.MutationObserver(() => {
if (!root.isConnected) destroy();
});
function destroy() {
if (destroyed) return;
destroyed = true;
+32 -10
View File
@@ -2,18 +2,28 @@ import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { Window } from "happy-dom";
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(readFileSync(new URL("../src/canvas.ts", import.meta.url), "utf8"))
.replace(/^import .*;\s*$/gm, "").replace("export function mountBlockCanvas", "function mountBlockCanvas");
const code = new Bun.Transpiler({ loader: "ts" })
.transformSync(readFileSync(new URL("../src/canvas.ts", import.meta.url), "utf8"))
.replace(/^import .*;\s*$/gm, "")
.replace("export function mountBlockCanvas", "function mountBlockCanvas");
test("canvas annotates exact node locations and cleans editor decorations without changing content", () => {
const window = new Window();
const root = window.document.body;
root.innerHTML = '<table data-block-id="root"><tbody><tr><td data-block-id="column" draggable="true"><table></table></td></tr></tbody></table>';
root.innerHTML =
'<table data-block-id="root"><tbody><tr><td data-block-id="column" draggable="true"><table></table></td></tr></tbody></table>';
const original = root.innerHTML;
const tree = { id: "root", type: "layout.email-document", slots: { content: [{ id: "column", type: "layout.column", slots: { content: [] } }] } };
const tree = {
id: "root",
type: "layout.email-document",
slots: { content: [{ id: "column", type: "layout.column", slots: { content: [] } }] },
};
const before = JSON.stringify(tree);
let destroyed = 0;
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => ({ destroy: () => destroyed++, refresh() {} }));
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => ({
destroy: () => destroyed++,
refresh() {},
}));
const mounted = mount(root, tree, {});
const column = root.querySelector('[data-block-id="column"]')!;
expect(column.getAttribute("data-block-parent")).toBe("root");
@@ -21,7 +31,8 @@ test("canvas annotates exact node locations and cleans editor decorations withou
expect(column.getAttribute("draggable")).toBe("false");
expect(root.querySelectorAll("[data-block-handle]")).toHaveLength(1);
const handle = root.querySelector("[data-block-handle]")!;
let clicks = 0, pointers = 0;
let clicks = 0,
pointers = 0;
column.addEventListener("click", () => clicks++);
column.addEventListener("dblclick", () => clicks++);
column.addEventListener("pointerdown", () => pointers++);
@@ -30,9 +41,12 @@ test("canvas annotates exact node locations and cleans editor decorations withou
handle.dispatchEvent(new window.PointerEvent("pointerdown", { bubbles: true }));
expect(clicks).toBe(0);
expect(pointers).toBe(1);
expect(root.querySelector("[data-block-slot-target]")?.getAttribute("data-block-parent")).toBe("column");
expect(root.querySelector("[data-block-slot-target]")?.getAttribute("data-block-parent")).toBe(
"column",
);
expect(JSON.stringify(tree)).toBe(before);
mounted.destroy(); mounted.destroy();
mounted.destroy();
mounted.destroy();
expect(destroyed).toBe(1);
expect(root.innerHTML).toBe(original);
});
@@ -41,7 +55,15 @@ test("failed canvas mounting rolls back annotations", () => {
const root = new Window().document.body;
root.innerHTML = '<div data-block-id="root"><div data-block-id="child">Content</div></div>';
const before = root.innerHTML;
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => { throw new Error("backend unavailable"); });
expect(() => mount(root, { id: "root", type: "root", slots: { content: [{ id: "child", type: "text", slots: {} }] } }, {})).toThrow("backend unavailable");
const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => {
throw new Error("backend unavailable");
});
expect(() =>
mount(
root,
{ id: "root", type: "root", slots: { content: [{ id: "child", type: "text", slots: {} }] } },
{},
),
).toThrow("backend unavailable");
expect(root.innerHTML).toBe(before);
});
+65 -13
View File
@@ -1,23 +1,75 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const source = new Bun.Transpiler({ loader: "ts" }).transformSync(readFileSync(new URL("../src/download.ts", import.meta.url), "utf8")).replace("export function", "function");
const source = new Bun.Transpiler({ loader: "ts" })
.transformSync(readFileSync(new URL("../src/download.ts", import.meta.url), "utf8"))
.replace("export function", "function");
test("download keeps direct link after blocked automatic click and releases replaced/detached blobs", () => {
const revoked: string[] = [], observers: Array<{ callback(): void; disconnect(): void }> = [];
let sequence = 0, clicks = 0, disconnected = 0;
const revoked: string[] = [],
observers: Array<{ callback(): void; disconnect(): void }> = [];
let sequence = 0,
clicks = 0,
disconnected = 0;
const links: any[] = [];
const outlet = { replaceChildren: (link: unknown) => { links.splice(0, links.length, link); } };
const host = { isConnected: true, querySelector: () => outlet, ownerDocument: { documentElement: {}, createElement: () => ({ href: "", download: "", textContent: "", click() { clicks++; throw new Error("blocked"); }, remove() { const index = links.indexOf(this); if (index >= 0) links.splice(index, 1); } }) } };
const offer = new Function("URL", "Blob", "MutationObserver", source + ";return offerBuilderDownload;")(
{ createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: (url: string) => revoked.push(url) }, Blob,
class { constructor(public callback: () => void) { observers.push(this); } observe() {} disconnect() { disconnected++; } },
const outlet = {
replaceChildren: (link: unknown) => {
links.splice(0, links.length, link);
},
};
const host = {
isConnected: true,
querySelector: () => outlet,
ownerDocument: {
documentElement: {},
createElement: () => ({
href: "",
download: "",
textContent: "",
click() {
clicks++;
throw new Error("blocked");
},
remove() {
const index = links.indexOf(this);
if (index >= 0) links.splice(index, 1);
},
}),
},
};
const offer = new Function(
"URL",
"Blob",
"MutationObserver",
source + ";return offerBuilderDownload;",
)(
{
createObjectURL: () => `blob:${++sequence}`,
revokeObjectURL: (url: string) => revoked.push(url),
},
Blob,
class {
constructor(public callback: () => void) {
observers.push(this);
}
observe() {}
disconnect() {
disconnected++;
}
},
);
offer(host, "html", "text/html", "draft.html");
expect(links[0].href).toBe("blob:1"); expect(links[0].download).toBe("draft.html");
expect(revoked).toEqual([]); expect(clicks).toBe(1);
expect(links[0].href).toBe("blob:1");
expect(links[0].download).toBe("draft.html");
expect(revoked).toEqual([]);
expect(clicks).toBe(1);
offer(host, "text", "text/plain", "draft.txt");
expect(revoked).toEqual(["blob:1"]); expect(links[0].download).toBe("draft.txt");
host.isConnected = false; observers[1]!.callback(); observers[1]!.callback();
expect(revoked).toEqual(["blob:1", "blob:2"]); expect(links).toEqual([]); expect(disconnected).toBe(2);
expect(revoked).toEqual(["blob:1"]);
expect(links[0].download).toBe("draft.txt");
host.isConnected = false;
observers[1]!.callback();
observers[1]!.callback();
expect(revoked).toEqual(["blob:1", "blob:2"]);
expect(links).toEqual([]);
expect(disconnected).toBe(2);
});
@@ -3,24 +3,39 @@ import { showImageFallbacks } from "../src/image-preview.ts";
test("image-disabled mode exposes safe alt text, proportions and reversible placeholders", () => {
const displayed: any[] = [];
const images = [{ alt: '<b>Logo & name</b>', width: '320', height: '180' }, { alt: '', width: 'not a size', height: '99999' }].map(attributes => ({
const images = [
{ alt: "<b>Logo & name</b>", width: "320", height: "180" },
{ alt: "", width: "not a size", height: "99999" },
].map((attributes) => ({
getAttribute: (key: string) => (attributes as Record<string, string>)[key] ?? null,
after: (element: unknown) => displayed.push(element),
}));
const canvas = {
querySelectorAll: () => images,
createElement: () => ({ dataset: {}, style: {} as Record<string, string>, textContent: "", attributes: {} as Record<string, string>, setAttribute(key: string, value: string) { this.attributes[key] = value; }, remove() { const index = displayed.indexOf(this); if (index >= 0) displayed.splice(index, 1); } }),
createElement: () => ({
dataset: {},
style: {} as Record<string, string>,
textContent: "",
attributes: {} as Record<string, string>,
setAttribute(key: string, value: string) {
this.attributes[key] = value;
},
remove() {
const index = displayed.indexOf(this);
if (index >= 0) displayed.splice(index, 1);
},
}),
} as unknown as Document;
showImageFallbacks(canvas, true);
expect(displayed).toHaveLength(2);
expect(displayed[0].textContent).toBe('<b>Logo & name</b>');
expect(displayed[0].attributes['aria-label']).toBe('<b>Logo & name</b>');
expect(displayed[0].style.aspectRatio).toBe('320/180');
expect(displayed[1].attributes['aria-hidden']).toBe('true');
expect(displayed[0].textContent).toBe("<b>Logo & name</b>");
expect(displayed[0].attributes["aria-label"]).toBe("<b>Logo & name</b>");
expect(displayed[0].style.aspectRatio).toBe("320/180");
expect(displayed[1].attributes["aria-hidden"]).toBe("true");
expect(displayed[1].style.maxWidth).toBeUndefined();
showImageFallbacks(canvas, true);
expect(displayed).toHaveLength(2);
showImageFallbacks(canvas, false);
expect(displayed).toHaveLength(0);
expect(images[0]!.getAttribute('alt')).toBe('<b>Logo & name</b>');
expect(images[0]!.getAttribute("alt")).toBe("<b>Logo & name</b>");
});
+49 -9
View File
@@ -3,16 +3,43 @@ import { defineBlock, object, string } from "@wrnexus/blocks";
import { createBuilderKit } from "../src/kit.ts";
const block = defineBlock({
manifest: { type: "custom.note", version: 1, name: { key: "note", fallback: "Note" }, icon: "note", category: "content",
props: object({ text: string({ min: 1 }) }), styles: object({}), slots: {} },
create: ({ id }: { id(): string }) => ({ id: id(), type: "custom.note", version: 1, props: { text: "Hello" }, styles: {}, slots: {} }),
manifest: {
type: "custom.note",
version: 1,
name: { key: "note", fallback: "Note" },
icon: "note",
category: "content",
props: object({ text: string({ min: 1 }) }),
styles: object({}),
slots: {},
},
create: ({ id }: { id(): string }) => ({
id: id(),
type: "custom.note",
version: 1,
props: { text: "Hello" },
styles: {},
slots: {},
}),
});
test("trusted custom blocks and presets expose independent instances and explicit metadata", () => {
let next = 0; const context = { id: () => String(++next) };
const kit = createBuilderKit({ blocks: [block], presets: [{ id: "note", name: { key: "preset.note", fallback: "Note" }, icon: "note", create: block.create }] });
let next = 0;
const context = { id: () => String(++next) };
const kit = createBuilderKit({
blocks: [block],
presets: [
{
id: "note",
name: { key: "preset.note", fallback: "Note" },
icon: "note",
create: block.create,
},
],
});
expect(kit.blocks()[0].manifest.name.fallback).toBe("Note");
expect(kit.presets()[0].id).toBe("note");
const first = kit.createBlock("custom.note", context), second = kit.createPreset("note", context);
const first = kit.createBlock("custom.note", context),
second = kit.createPreset("note", context);
first.props.text = "Changed";
expect(second.props.text).toBe("Hello");
expect(first.id).not.toBe(second.id);
@@ -22,10 +49,23 @@ test("trusted custom blocks and presets expose independent instances and explici
});
test("duplicate registration and invalid factories fail before insertion", () => {
expect(() => createBuilderKit({ blocks: [block, block] })).toThrow();
const preset = { id: "note", name: { key: "note", fallback: "Note" }, icon: "note", create: block.create };
const preset = {
id: "note",
name: { key: "note", fallback: "Note" },
icon: "note",
create: block.create,
};
expect(() => createBuilderKit({ blocks: [block], presets: [preset, preset] })).toThrow();
const invalid = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), props: { text: "" } }) }] });
const invalid = createBuilderKit({
blocks: [
{ ...block, create: () => ({ ...block.create({ id: () => "id" }), props: { text: "" } }) },
],
});
expect(() => invalid.createBlock("custom.note", { id: () => "id" })).toThrow();
const wrong = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), type: "custom.other" }) }] });
const wrong = createBuilderKit({
blocks: [
{ ...block, create: () => ({ ...block.create({ id: () => "id" }), type: "custom.other" }) },
],
});
expect(() => wrong.createBlock("custom.note", { id: () => "id" })).toThrow();
});
+20 -5
View File
@@ -2,19 +2,34 @@ import { expect, test } from "bun:test";
import type { BlockNode } from "@wrnexus/blocks";
import { builderOutlineLabel } from "../src/outline.ts";
const node = (props: BlockNode["props"], type = "content.button"): BlockNode => ({ id: "n", type, version: 1, props, styles: {}, slots: {} });
const node = (props: BlockNode["props"], type = "content.button"): BlockNode => ({
id: "n",
type,
version: 1,
props,
styles: {},
slots: {},
});
test("outline uses registered names and distinguishes content without mutating it", () => {
const block = node({ label: " Buy\n now " });
expect(builderOutlineLabel(block, { name: { fallback: "Button" } })).toBe("Button — Buy now");
expect(block.props.label).toBe(" Buy\n now ");
expect(builderOutlineLabel(node({ width: "35%" }, "layout.column"), { name: { fallback: "Column" } })).toBe("Column — 35%");
expect(
builderOutlineLabel(node({ width: "35%" }, "layout.column"), { name: { fallback: "Column" } }),
).toBe("Column — 35%");
expect(builderOutlineLabel(node({}, "custom.unknown"))).toBe("custom.unknown");
});
test("outline bounds Unicode snippets and treats content as plain text", () => {
const label = builderOutlineLabel(node({ label: "😀".repeat(65) }), { name: { fallback: "Button" } });
const label = builderOutlineLabel(node({ label: "😀".repeat(65) }), {
name: { fallback: "Button" },
});
expect(label).toBe("Button — " + "😀".repeat(64) + "…");
expect(builderOutlineLabel(node({ label: '<img onerror="bad">' }))).toContain('<img onerror="bad">');
expect(builderOutlineLabel(node({ text: {}, label: "", title: "Product title" }))).toBe("content.button — Product title");
expect(builderOutlineLabel(node({ label: '<img onerror="bad">' }))).toContain(
'<img onerror="bad">',
);
expect(builderOutlineLabel(node({ text: {}, label: "", title: "Product title" }))).toBe(
"content.button — Product title",
);
});
@@ -4,10 +4,15 @@ import { builderPreviewHTML } from "../src/preview-html.ts";
test("preview sanitization preserves email layout and editor markers but removes executable content", () => {
const { window } = new JSDOM("");
const result = String(builderPreviewHTML('<html><head><style>td{padding:12px}</style></head><body><table data-block-id="one"><tr><td><img src="https://example.com/a.png" onerror="alert(1)"><a href="javascript:alert(1)">Hello</a></td></tr></table><script>alert(1)</script><iframe src="https://example.com"></iframe></body></html>', window.document as unknown as Document));
const result = String(
builderPreviewHTML(
'<html><head><style>td{padding:12px}</style></head><body><table data-block-id="one"><tr><td><img src="https://example.com/a.png" onerror="alert(1)"><a href="javascript:alert(1)">Hello</a></td></tr></table><script>alert(1)</script><iframe src="https://example.com"></iframe></body></html>',
window.document as unknown as Document,
),
);
expect(result).toContain('data-block-id="one"');
expect(result).toContain('<table');
expect(result).toContain('<style>');
expect(result).toContain("<table");
expect(result).toContain("<style>");
expect(result).not.toMatch(/onerror|javascript:|<script|<iframe/);
window.close();
});
@@ -20,7 +25,9 @@ test("separately bundled preview modules reuse one realm sanitizer", async () =>
const first = (window as any)[key];
const secondPageModule = "../src/preview-html.ts?second-page";
const secondModule = await import(secondPageModule);
expect(String(secondModule.builderPreviewHTML("<p>Second page</p>", doc))).toContain("Second page");
expect(String(secondModule.builderPreviewHTML("<p>Second page</p>", doc))).toContain(
"Second page",
);
expect((window as any)[key]).toBe(first);
window.close();
});
+19 -5
View File
@@ -5,20 +5,34 @@ import { readBuilderText, mountInlineRichText } from "../src/rich-text.ts";
test("inline rich text extracts only supported semantics", () => {
const window = new Window();
const element = window.document.createElement("p");
element.innerHTML = 'Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>';
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([{text:"Hello "},{text:"bold ",bold:true},{text:"and italic",bold:true,italic:true},{text:"\n"},{text:"line",underline:true}]);
element.innerHTML =
"Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>";
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([
{ text: "Hello " },
{ text: "bold ", bold: true },
{ text: "and italic", bold: true, italic: true },
{ text: "\n" },
{ text: "line", underline: true },
]);
});
test("rich editor commits semantic input and cleans up on Done", () => {
const window = new Window();
const element = window.document.createElement("p");
window.document.body.append(element);
const updates: unknown[] = []; let finished = 0;
mountInlineRichText(window.document as unknown as Document, element as unknown as HTMLElement, "hello", (text, runs) => updates.push({text,runs}), () => finished++);
const updates: unknown[] = [];
let finished = 0;
mountInlineRichText(
window.document as unknown as Document,
element as unknown as HTMLElement,
"hello",
(text, runs) => updates.push({ text, runs }),
() => finished++,
);
expect(element.contentEditable).toBe("true");
element.innerHTML = "<strong>bold</strong>";
element.dispatchEvent(new window.Event("input"));
expect(updates).toEqual([{text:"bold",runs:[{text:"bold",bold:true}]}]);
expect(updates).toEqual([{ text: "bold", runs: [{ text: "bold", bold: true }] }]);
(window.document.querySelector('button[aria-label="Done"]') as any).click();
expect(finished).toBe(1);
expect(element.contentEditable).toBe("false");
+53 -15
View File
@@ -11,22 +11,43 @@ const code = new Bun.Transpiler({ loader: "ts" })
test("partial registration failure cleans the backend and live region on mount and refresh", () => {
for (const failingMount of [1, 2]) {
const window = new Window(), root = window.document.createElement("div");
const window = new Window(),
root = window.document.createElement("div");
window.document.body.append(root);
root.innerHTML = '<div data-block-id="a" data-block-parent="root" data-block-slot="content"></div><div data-block-id="b" data-block-parent="root" data-block-slot="content"></div>';
let mounts = 0, destroyed = 0, cleaned = 0;
root.innerHTML =
'<div data-block-id="a" data-block-parent="root" data-block-slot="content"></div><div data-block-id="b" data-block-parent="root" data-block-slot="content"></div>';
let mounts = 0,
destroyed = 0,
cleaned = 0;
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(() => {
const number = ++mounts; let registrations = 0;
return { registerSortable() { if (++registrations === 2 && number === failingMount) throw new Error("registration failed"); return () => { cleaned++; }; }, destroy() { destroyed++; } };
const number = ++mounts;
let registrations = 0;
return {
registerSortable() {
if (++registrations === 2 && number === failingMount)
throw new Error("registration failed");
return () => {
cleaned++;
};
},
destroy() {
destroyed++;
},
};
});
let editor: MountedEditor | undefined;
if (failingMount === 1) expect(() => mount(root, { onMove() {} })).toThrow("registration failed");
else { editor = mount(root, { onMove() {} }); expect(() => editor!.refresh()).toThrow("registration failed"); }
if (failingMount === 1)
expect(() => mount(root, { onMove() {} })).toThrow("registration failed");
else {
editor = mount(root, { onMove() {} });
expect(() => editor!.refresh()).toThrow("registration failed");
}
expect(root.querySelector('[role="status"]')).toBeNull();
expect(root.querySelectorAll('[data-block-id]')).toHaveLength(2);
expect(root.querySelectorAll("[data-block-id]")).toHaveLength(2);
expect(destroyed).toBe(failingMount);
expect(cleaned).toBe(failingMount === 1 ? 1 : 3);
editor?.destroy(); editor?.refresh();
editor?.destroy();
editor?.refresh();
expect(destroyed).toBe(failingMount);
}
});
@@ -46,7 +67,12 @@ function fixture(onMove: MountEditorOptions["onMove"] = () => false) {
}> = [];
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(
(options: DndManagerOptions) => {
const backend = { options, registrations: [] as SortableRegistration[], zones: [] as DropZoneRegistration[], destroyed: 0 };
const backend = {
options,
registrations: [] as SortableRegistration[],
zones: [] as DropZoneRegistration[],
destroyed: 0,
};
backends.push(backend);
return {
registerDropZone: (registration: DropZoneRegistration) => {
@@ -100,9 +126,15 @@ test("large canvas registration scans blocks once and preserves interleaved slot
const backend = f.backends.at(-1)!;
expect(scans).toBe(1);
expect(backend.registrations.map(({ id, group, index }) => ({ id, group, index }))).toEqual(
Array.from({ length: 300 }, (_, i) => ({ id: String(i), group: JSON.stringify([`parent:${i % 3}`, "content"]), index: Math.floor(i / 3) })),
Array.from({ length: 300 }, (_, i) => ({
id: String(i),
group: JSON.stringify([`parent:${i % 3}`, "content"]),
index: Math.floor(i / 3),
})),
);
expect(backend.zones.map(zone => zone.group)).toEqual([JSON.stringify(["parent:3", "content"])]);
expect(backend.zones.map((zone) => zone.group)).toEqual([
JSON.stringify(["parent:3", "content"]),
]);
f.editor.destroy();
});
@@ -127,7 +159,10 @@ test("cancelled optimistic moves restore original DOM nodes across containers",
test("empty named slot targets produce validated editor move intents and refresh away when populated", async () => {
const moves: unknown[] = [];
const f = fixture(intent => { moves.push(intent); return true; });
const f = fixture((intent) => {
moves.push(intent);
return true;
});
const target = f.document.createElement("div");
target.dataset.blockParent = "parent:empty";
target.dataset.blockSlotTarget = "content:empty";
@@ -138,9 +173,12 @@ test("empty named slot targets produce validated editor move intents and refresh
const zone = backend.zones[0]!;
expect(backend.options.accept!(["a"], { group: zone.group, index: 0 })).toBe(true);
await backend.options.onMoveIntent({ ids: ["a"], from: [], to: { group: zone.group, index: 0 } });
expect(moves).toEqual([{ nodeIds: ["a"], to: { parentId: "parent:empty", slot: "content:empty", index: 0 } }]);
expect(moves).toEqual([
{ nodeIds: ["a"], to: { parentId: "parent:empty", slot: "content:empty", index: 0 } },
]);
const child = f.root.querySelector('[data-block-id="a"]')! as any;
child.dataset.blockParent = "parent:empty"; child.dataset.blockSlot = "content:empty";
child.dataset.blockParent = "parent:empty";
child.dataset.blockSlot = "content:empty";
target.append(child);
f.editor.refresh();
expect(f.backends.at(-1)!.zones).toHaveLength(0);
@@ -3,10 +3,16 @@ import { Window } from "happy-dom";
import { showBuilderBlockActions } from "../src/live-canvas.ts";
test("compact selection actions retain accessible names and callbacks without interpreting labels", () => {
const window = new Window(), document = window.document;
const window = new Window(),
document = window.document;
document.body.innerHTML = '<div data-block-id="text">Content</div>';
let calls = 0;
showBuilderBlockActions(document as unknown as Document, "text", "Text — <script>example</script>", { Edit: () => calls++, Delete: () => calls++ });
showBuilderBlockActions(
document as unknown as Document,
"text",
"Text — <script>example</script>",
{ Edit: () => calls++, Delete: () => calls++ },
);
const toolbar = document.querySelector('[role="toolbar"]')!;
expect(toolbar.getAttribute("aria-label")).toBe("Text — <script>example</script> actions");
expect(toolbar.querySelector("strong")?.textContent).toBe("Text");
+24 -6
View File
@@ -6,13 +6,29 @@ test("unsaved guard protects unload and ordinary navigation, permits safe links,
const window = new Window({ url: "https://email.example.com/builder/one" });
const root = window.document.createElement("main");
window.document.body.append(root);
let dirty = true, allow = false, confirmations = 0;
const guard = mountUnsavedChangesGuard(root as unknown as HTMLElement, { hasUnsavedChanges: () => dirty, confirmLeave: () => { confirmations++; return allow; } });
const unload = () => { const event = new window.Event("beforeunload", { cancelable: true }); window.dispatchEvent(event); return event.defaultPrevented; };
let dirty = true,
allow = false,
confirmations = 0;
const guard = mountUnsavedChangesGuard(root as unknown as HTMLElement, {
hasUnsavedChanges: () => dirty,
confirmLeave: () => {
confirmations++;
return allow;
},
});
const unload = () => {
const event = new window.Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
};
const click = (href: string, target = "") => {
const link = window.document.createElement("a"); link.href = href; link.target = target; root.append(link);
const link = window.document.createElement("a");
link.href = href;
link.target = target;
root.append(link);
const event = new window.MouseEvent("click", { bubbles: true, cancelable: true });
link.dispatchEvent(event); return event.defaultPrevented;
link.dispatchEvent(event);
return event.defaultPrevented;
};
expect(unload()).toBe(true);
expect(click("/builder/two")).toBe(true);
@@ -23,6 +39,8 @@ test("unsaved guard protects unload and ordinary navigation, permits safe links,
expect(click("/builder/two")).toBe(false);
dirty = false;
expect(unload()).toBe(false);
guard.destroy(); guard.destroy(); dirty = true;
guard.destroy();
guard.destroy();
dirty = true;
expect(unload()).toBe(false);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/blocks",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"description": "Pure dynamic block document engine for WRNexus builders.",
"exports": {
+35 -8
View File
@@ -161,7 +161,10 @@ export function applyCommand(
).length;
root = insert(
remove(root, new Set(command.intent.nodeIds)),
{ ...command.intent.to, index: Math.max(0, command.intent.to.index - removedBeforeDestination) },
{
...command.intent.to,
index: Math.max(0, command.intent.to.index - removedBeforeDestination),
},
moving as BlockNode[],
);
affected = command.intent.nodeIds;
@@ -180,19 +183,43 @@ export function applyCommand(
const originals = command.nodeIds.map((id) => findBlock(root, id));
const locations = command.nodeIds.map((id) => locationOf(root, id));
if (originals.some((node) => !node) || locations.some((location) => !location))
return { ok: false, errors: [{ code: "command.missing", message: "A duplicated block does not exist.", severity: "error" }] };
return {
ok: false,
errors: [
{
code: "command.missing",
message: "A duplicated block does not exist.",
severity: "error",
},
],
};
const countNodes = (node: BlockNode): number =>
1 + Object.values(node.slots).flat().reduce((total, child) => total + countNodes(child), 0);
1 +
Object.values(node.slots)
.flat()
.reduce((total, child) => total + countNodes(child), 0);
const requiredIds = (originals as BlockNode[]).reduce(
(total, node) => total + countNodes(node),
0,
);
if (command.ids.length !== requiredIds || new Set(command.ids).size !== command.ids.length)
return { ok: false, errors: [{ code: "command.ids", message: "Duplicate requires one unique id for every copied node.", severity: "error" }] };
const copies = duplicateNodes(originals as BlockNode[], (() => {
let index = 0;
return () => command.ids[index++]!;
})());
return {
ok: false,
errors: [
{
code: "command.ids",
message: "Duplicate requires one unique id for every copied node.",
severity: "error",
},
],
};
const copies = duplicateNodes(
originals as BlockNode[],
(() => {
let index = 0;
return () => command.ids[index++]!;
})(),
);
const commands = copies.map((node, index) => ({
type: "insert" as const,
location: { ...locations[index]!, index: locations[index]!.index + 1 },
+32 -5
View File
@@ -31,13 +31,27 @@ export function resolveDocumentVariables(
): Result<BlockDocument> {
const diagnostics: Diagnostic[] = [];
if (options.preserveTokens && options.requireValues)
return { ok: false, errors: [{ code: "variable.mode", message: "Template tokens cannot be preserved when resolved values are required.", severity: "error" }] };
return {
ok: false,
errors: [
{
code: "variable.mode",
message: "Template tokens cannot be preserved when resolved values are required.",
severity: "error",
},
],
};
const visit = (value: JsonValue, path: string): JsonValue => {
if (typeof value === "string")
return value.replace(TOKEN, (raw, key: string, fallback: string | undefined) => {
const type = catalog[key];
if (!type) {
diagnostics.push({ code: "variable.unknown", message: `Unknown variable ${key}.`, severity: "error", path });
diagnostics.push({
code: "variable.unknown",
message: `Unknown variable ${key}.`,
severity: "error",
path,
});
return raw;
}
if (options.preserveTokens) return raw;
@@ -45,11 +59,21 @@ export function resolveDocumentVariables(
if (supplied === undefined || supplied === null || supplied === "") {
if (fallback !== undefined) return fallback.trim();
if (options.requireValues)
diagnostics.push({ code: "variable.missing", message: `Variable ${key} is required.`, severity: "error", path });
diagnostics.push({
code: "variable.missing",
message: `Variable ${key} is required.`,
severity: "error",
path,
});
return raw;
}
if (!validType(supplied, type)) {
diagnostics.push({ code: "variable.type", message: `Variable ${key} must be ${type}.`, severity: "error", path });
diagnostics.push({
code: "variable.type",
message: `Variable ${key} must be ${type}.`,
severity: "error",
path,
});
return raw;
}
return supplied instanceof Date ? supplied.toISOString() : String(supplied);
@@ -61,6 +85,9 @@ export function resolveDocumentVariables(
);
return value;
};
const resolved = visit(structuredClone(document) as unknown as JsonValue, "document") as unknown as BlockDocument;
const resolved = visit(
structuredClone(document) as unknown as JsonValue,
"document",
) as unknown as BlockDocument;
return diagnostics.length ? { ok: false, errors: diagnostics } : { ok: true, value: resolved };
}
+28 -16
View File
@@ -146,30 +146,42 @@ describe("dynamic blocks engine", () => {
...text.create({ id: () => nodeId }),
props: { text: nodeId },
}));
const moved = applyCommand(document, {
type: "move",
intent: {
nodeIds: ["a"],
from: [{ parentId: "root", slot: "content", index: 0 }],
to: { parentId: "root", slot: "content", index: 3 },
const moved = applyCommand(
document,
{
type: "move",
intent: {
nodeIds: ["a"],
from: [{ parentId: "root", slot: "content", index: 0 }],
to: { parentId: "root", slot: "content", index: 3 },
},
},
}, kind, registry);
kind,
registry,
);
expect(moved.ok && moved.value.document.root.slots.content.map((node) => node.id)).toEqual([
"b", "c", "a",
"b",
"c",
"a",
]);
});
test("duplicates blocks with deterministic injected ids", () => {
const document = doc();
document.root.slots.content = [{ ...text.create({ id: () => "a" }), props: { text: "A" } }];
const duplicated = applyCommand(document, {
type: "duplicate",
nodeIds: ["a"],
ids: ["a-copy"],
}, kind, registry);
expect(duplicated.ok && duplicated.value.document.root.slots.content.map((node) => node.id)).toEqual([
"a", "a-copy",
]);
const duplicated = applyCommand(
document,
{
type: "duplicate",
nodeIds: ["a"],
ids: ["a-copy"],
},
kind,
registry,
);
expect(
duplicated.ok && duplicated.value.document.root.slots.content.map((node) => node.id),
).toEqual(["a", "a-copy"]);
});
test("validates and resolves typed personalization tokens", () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cache",
"version": "0.8.8",
"version": "0.8.9",
"type": "module",
"description": "Tag-aware memory and response caching with stale-while-revalidate for WRNexusJS.",
"main": "src/index.ts",
+1 -1
View File
@@ -229,7 +229,7 @@ component Captcha {
tabindex="-1"
autocomplete="off"
aria-hidden="true"
class='pointer-events-none absolute -left-[10000px] top-auto h-px w-px overflow-hidden opacity-0'
class='pointer-events-none absolute start-0 top-0 h-px w-px overflow-hidden [clip-path:inset(50%)] opacity-0'
/>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/captcha",
"version": "0.8.16",
"version": "0.8.19",
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
"type": "module",
"sideEffects": false,
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/cli",
"version": "0.8.76",
"version": "0.8.77",
"type": "module",
"main": "src/index.ts",
"exports": {
@@ -16,6 +16,7 @@
"src"
],
"dependencies": {
"@wrnexus/design-system": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/router": "workspace:*",
"@wrnexus/csr": "workspace:*",
+27 -8
View File
@@ -23,7 +23,13 @@ import {
} from "node:fs";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { buildRouter, type Route } from "@wrnexus/router";
import { getComponentControllerRuntime, getReactiveRuntime, getNavRuntime, getActionRuntime, getRealtimeRuntime } from "@wrnexus/csr";
import {
getComponentControllerRuntime,
getReactiveRuntime,
getNavRuntime,
getActionRuntime,
getRealtimeRuntime,
} from "@wrnexus/csr";
import { getIslandRuntime } from "@wrnexus/react/runtime";
import {
analyzeRuntimeImports,
@@ -351,7 +357,11 @@ export async function runBuild(appRoot: string): Promise<void> {
const bundledClientOut = join(clientModulesDir, `${clientStem}-${bundleHash}.mjs`);
writeFileSync(bundledClientOut, browserSource, "utf8");
clientFiles.set(file, bundledClientOut);
writeFileSync(out, code.replaceAll(clientUrl, `/__wrnexus/client/${basename(bundledClientOut)}`), "utf8");
writeFileSync(
out,
code.replaceAll(clientUrl, `/__wrnexus/client/${basename(bundledClientOut)}`),
"utf8",
);
rmSync(clientEntry, { force: true });
} catch (error) {
compiledFiles.delete(file);
@@ -724,14 +734,17 @@ applyAuthzManifestEarly([${authzSetupEntries}]);
const runtimeConfigPath = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"]
.map((name) => resolve(appRoot, name))
.find(existsSync);
if (runtimeConfigPath && config.auth) {
if (runtimeConfigPath && "auth" in config && config.auth) {
// Importing config creates the engine, but does not run plugin.configure.
// Initialize route options before route/middleware modules are evaluated.
// Credentials are read by the config at server startup, never serialized here.
writeFileSync(join(distDir, ".auth-runtime.ts"),
writeFileSync(
join(distDir, ".auth-runtime.ts"),
`import config from ${JSON.stringify(fwd(runtimeConfigPath))};\n` +
`import { configureAuthRuntime } from "@wrnexus/auth/plugin";\n` +
`configureAuthRuntime(config.auth);\n`, "utf8");
`import { configureAuthRuntime } from "@wrnexus/auth/plugin";\n` +
`configureAuthRuntime(config.auth);\n`,
"utf8",
);
imports.push(`import "./.auth-runtime.ts";`);
} else if (runtimeConfigPath) imports.push(`import ${JSON.stringify(fwd(runtimeConfigPath))};`);
@@ -1072,8 +1085,14 @@ async function emitPluginAssets(
// Keep that alias revalidatable while the HTML uses the immutable URL.
const stablePath = runtime.publicPath ?? `/__wrnexus/assets/${safeAssetName(runtime.id)}.js`;
if (stablePath !== publicPath) {
assets.push({ id: runtime.id, publicPath: stablePath, file,
contentType: "text/javascript; charset=utf-8", runtime: true, immutable: false });
assets.push({
id: runtime.id,
publicPath: stablePath,
file,
contentType: "text/javascript; charset=utf-8",
runtime: true,
immutable: false,
});
}
// Keep an empty source marker so @wrnexus/plugin can normalize this
// already-emitted descriptor during production HTML rendering without
+46
View File
@@ -0,0 +1,46 @@
import { readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import {
assertDesignSystem,
compileDesignSystem,
DesignSystemError,
resolveDesignSystem,
validateDesignSystem,
type DesignSystem,
type ResolveOptions,
} from "@wrnexus/design-system";
function readJson(file: string): unknown {
const path = resolve(file);
if (statSync(path).size > 1024 * 1024)
throw new Error("WRN-DS-LIMIT: Design input exceeds 1 MiB.");
return JSON.parse(readFileSync(path, "utf8"));
}
/** Data-only CLI: never imports executable user configuration or writes files. */
export async function runDesignCommand(args: string[]): Promise<string> {
const [command, file, ...flags] = args;
if (!file || !["validate", "inspect", "compile"].includes(command ?? ""))
throw new Error(
"Usage: wrnexus design <validate|inspect|compile> <system.json> [--mode=name] [--density=name] [--registry=systems.json]",
);
const options: ResolveOptions = {};
for (const flag of flags) {
const match = /^--(mode|density|registry)=(.+)$/.exec(flag);
if (!match) throw new Error(`WRN-DS-OPTION: Unknown design option '${flag}'.`);
if (match[1] === "registry") {
const registry = readJson(match[2]!);
if (!registry || typeof registry !== "object" || Array.isArray(registry))
throw new Error("WRN-DS-SCHEMA: Registry must map identifiers to systems.");
for (const value of Object.values(registry)) assertDesignSystem(value);
options.registry = registry as Record<string, DesignSystem>;
} else options[match[1] as "mode" | "density"] = match[2]!;
}
const system = readJson(file);
const diagnostics = validateDesignSystem(system, options);
if (diagnostics.length) throw new DesignSystemError(diagnostics);
assertDesignSystem(system);
if (command === "compile") return compileDesignSystem(system, options).css.trimEnd();
if (command === "inspect") return JSON.stringify(resolveDesignSystem(system, options), null, 2);
return JSON.stringify({ valid: true, id: system.id, schemaVersion: system.schemaVersion });
}
+7
View File
@@ -39,6 +39,8 @@ function help(): void {
console.log(`wrnexus — WrNexus CLI
Usage:
wrnexus design <validate|inspect|compile> <system.json> [--mode=light] [--density=compact]
Validate or compile an opt-in design system
wrnexus dev [app-dir] [--port=3000] [--host=::]
Start the development server (live reload)
wrnexus dev [app-dir] --services Start local production-service simulators with the app
@@ -121,6 +123,11 @@ async function main(): Promise<void> {
notifyIfUpdateAvailable(currentCliVersion(), command);
switch (command) {
case "design": {
const { runDesignCommand } = await import("./design.ts");
console.log(await runDesignCommand(rest));
break;
}
case "dev": {
const appRoot = rest.find((a) => !a.startsWith("--")) ?? ".";
const portArg = rest.find((a) => a.startsWith("--port="));
@@ -1,24 +1,33 @@
import {afterAll,expect,test} from "bun:test";
import {mkdirSync,mkdtempSync,readFileSync,readdirSync,rmSync,writeFileSync} from "node:fs";
import {join} from "node:path";
import {runBuild} from "../src/build.ts";
const scratch=join(import.meta.dir,".tmp-client-dependency");
mkdirSync(scratch,{recursive:true});
afterAll(()=>rmSync(scratch,{recursive:true,force:true}));
test("changing a bundled dependency changes the immutable client URL",async()=>{
const root=mkdtempSync(join(scratch,"app-"));
mkdirSync(join(root,"app/pages"),{recursive:true});
writeFileSync(join(root,"package.json"),JSON.stringify({name:"client-cache-fixture"}));
writeFileSync(join(root,"wrnexus.config.ts"),"export default {};\n");
writeFileSync(join(root,"app/pages/index.wrn"),'import {message} from "../message.ts"\npage Home { functions { client function show(){alert(message)} } view { <button type="button" on:click={show()}>Show message</button> } }');
const files:string[]=[];
for(const message of ["first dependency","updated dependency"]){
writeFileSync(join(root,"app/message.ts"),`export const message=${JSON.stringify(message)};`);
import { afterAll, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runBuild } from "../src/build.ts";
const scratch = join(import.meta.dir, ".tmp-client-dependency");
mkdirSync(scratch, { recursive: true });
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
test("changing a bundled dependency changes the immutable client URL", async () => {
const root = mkdtempSync(join(scratch, "app-"));
mkdirSync(join(root, "app/pages"), { recursive: true });
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "client-cache-fixture" }));
writeFileSync(join(root, "wrnexus.config.ts"), "export default {};\n");
writeFileSync(
join(root, "app/pages/index.wrn"),
'import {message} from "../message.ts"\npage Home { functions { client function show(){alert(message)} } view { <button type="button" on:click={show()}>Show message</button> } }',
);
const files: string[] = [];
for (const message of ["first dependency", "updated dependency"]) {
writeFileSync(join(root, "app/message.ts"), `export const message=${JSON.stringify(message)};`);
await runBuild(root);
const file=readdirSync(join(root,"dist/client")).find(name=>name.startsWith("index-")&&readFileSync(join(root,"dist/client",name),"utf8").includes(message));
const file = readdirSync(join(root, "dist/client")).find(
(name) =>
name.startsWith("index-") &&
readFileSync(join(root, "dist/client", name), "utf8").includes(message),
);
expect(file).toBeDefined();
files.push(file!);
expect(readFileSync(join(root,"dist/server.js"),"utf8")).toContain(`/__wrnexus/client/${file}`);
expect(readFileSync(join(root, "dist/server.js"), "utf8")).toContain(
`/__wrnexus/client/${file}`,
);
}
expect(files[0]).not.toBe(files[1]);
},120000);
}, 120000);
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from "bun:test";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runDesignCommand } from "../src/design.ts";
test("design CLI validates data, inspects provenance and compiles without modifying input", async () => {
const directory = mkdtempSync(join(tmpdir(), "wrn-design-cli-"));
try {
const file = join(directory, "system.json");
const source = JSON.stringify({
schemaVersion: 1,
id: "square",
name: "Square",
version: "1",
tokens: { radius: { type: "dimension", value: "0", legacy: "radius-sm" } },
});
writeFileSync(file, source);
expect(JSON.parse(await runDesignCommand(["validate", file])).valid).toBe(true);
expect(JSON.parse(await runDesignCommand(["inspect", file])).tokens.radius.source).toBe(
"square.tokens.radius",
);
expect(await runDesignCommand(["compile", file])).toContain("--wrn-radius-sm:0;");
expect(await Bun.file(file).text()).toBe(source);
await expect(runDesignCommand(["compile", file, "--unknown=x"])).rejects.toThrow(
"WRN-DS-OPTION",
);
writeFileSync(file, source.replace('"0"', '"url(bad)"'));
await expect(runDesignCommand(["validate", file])).rejects.toThrow("WRN-DS-VALUE");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+6 -2
View File
@@ -11,10 +11,14 @@ test("production CSS inlining uses a configurable UTF-8 byte budget", () => {
});
test("relative asset URLs stay external so their base does not change", () => {
for (const url of ['./font.woff2', '../font.woff2', 'font.woff2', '#mask']) {
for (const url of ["./font.woff2", "../font.woff2", "font.woff2", "#mask"]) {
expect(shouldInlineCss(`a{background:url("${url}")}`, undefined)).toBe(false);
}
for (const url of ['/font.woff2', 'https://cdn.example/font.woff2', 'data:image/svg+xml;base64,AAAA']) {
for (const url of [
"/font.woff2",
"https://cdn.example/font.woff2",
"data:image/svg+xml;base64,AAAA",
]) {
expect(shouldInlineCss(`a{background:url('${url}')}`, undefined)).toBe(true);
}
});
@@ -38,8 +38,10 @@ export default {
join(root, "app", "pages", "index.wrn"),
`import RuntimeProbe from "../components/RuntimeProbe.wrn"\npage Home { view { <RuntimeProbe /> } }\n`,
);
writeFileSync(join(root, "app", "pages", "[id].wrn"),
`page Dynamic { functions { client function selectPanel() { console.log('panel-ready') } } view { <button @click="selectPanel()">Panel</button> } }`);
writeFileSync(
join(root, "app", "pages", "[id].wrn"),
`page Dynamic { functions { client function selectPanel() { console.log('panel-ready') } } view { <button @click="selectPanel()">Panel</button> } }`,
);
await runBuild(root);
@@ -3,7 +3,10 @@ import { productionConnections } from "../src/runtime-connections.ts";
test("deployment connections override build placeholders without accepting server settings", () => {
const db = { driver: "postgres", url: "postgres://runtime:secret@db/app" };
expect(productionConnections(JSON.stringify({ db, realtime: { scale: true } }))).toEqual({ db, realtime: { scale: true } });
expect(productionConnections(JSON.stringify({ db, realtime: { scale: true } }))).toEqual({
db,
realtime: { scale: true },
});
expect(productionConnections("")).toEqual({});
for (const value of ["null", "[]", "invalid", '{"port":80}', '{"db":null}', '{"db":[]}'])
expect(() => productionConnections(value)).toThrow();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/compiler",
"version": "0.8.30",
"version": "0.8.44",
"type": "module",
"main": "src/index.ts",
"exports": {
+102 -14
View File
@@ -236,7 +236,6 @@ function _functionEntry(
: "";
const stateAliases = stateNames.length ? `let { ${stateNames.join(", ")} } = context.state;` : "";
const propAliases = propNames.length ? `const { ${propNames.join(", ")} } = context.props;` : "";
const syncStateToContext = stateNames.map((name) => `context.state.${name} = ${name};`).join(" ");
const syncStateFromContext = stateNames
.map((name) => `${name} = context.state.${name};`)
.join(" ");
@@ -247,7 +246,12 @@ function _functionEntry(
`const ${name} = (...__wrnexusPeerArgs) => context.functions[${JSON.stringify(name)}](...__wrnexusPeerArgs);`,
)
.join("\n")
: `const __wrnexusFlush = () => { ${syncStateToContext} };
: `const __wrnexusFlush = () => {
const __wrnexusPendingState = { ${stateNames.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const __wrnexusApplyState = () => { ${stateNames.map((name) => `context.state.${name} = __wrnexusPendingState.${name};`).join(" ")} };
if (typeof context.batch === "function") context.batch(__wrnexusApplyState);
else __wrnexusApplyState();
};
const __wrnexusRestore = () => { ${syncStateFromContext} };
const __wrnexusPeer = (name, args) => {
__wrnexusFlush();
@@ -474,19 +478,93 @@ export function generateBrowserModule(ast: PageAst): string {
!sharedState.includes(name) &&
!sharedProps.includes(name),
);
const sharedCommit = sharedState.map((name) => `context.state.${name} = ${name};`).join(" ");
const sharedRestore = [
...sharedState.map((name) => `${name} = context.state.${name};`),
...sharedProps.map((name) => `${name} = context.props.${name};`),
].join(" ");
// A display helper must not subscribe to unrelated state just to synchronize
// the closure. Conservatively include identifiers from called/returned peers
// (including callbacks and parameter defaults) so indirect reads remain reactive.
const referencesPeer = (source: string, name: string) => {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(?:^|[^A-Za-z0-9_$.])${escaped}(?![A-Za-z0-9_$])`);
// Permission strings such as "calendar.create" are not calls to create().
// Keep template interpolations conservative; masking a whole template would
// otherwise hide helper references inside ${...}.
return (
pattern.test(maskStringsAndComments(source)) ||
(source.includes("${") && pattern.test(source))
);
};
const functionSources = new Map(
functions.map((fn) => [
fn.name,
fn.body + "\n" + fn.parameters.map((parameter) => parameter.default ?? "").join("\n"),
]),
);
const restoredNames = new Map<string, string[]>();
for (const fn of functions) {
const visited = new Set<string>();
const collect = (name: string): string => {
if (visited.has(name)) return "";
visited.add(name);
const source = functionSources.get(name) ?? "";
return (
source +
"\n" +
functions
.filter((peer) => referencesPeer(source, peer.name))
.map((peer) => collect(peer.name))
.join("\n")
);
};
const source = collect(fn.name);
restoredNames.set(
fn.name,
[...sharedState, ...sharedProps].filter((name) => identifierReferenced(source, name)),
);
}
const sharedCommit = `const values = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const pending = {};
for (const name of Object.keys(values)) {
if (!Object.is(values[name], committed[name])) { pending[name] = values[name]; committed[name] = values[name]; }
}
if (Object.keys(pending).length === 0) return;
const apply = () => { for (const name of Object.keys(pending)) context.state[name] = pending[name]; };
if (typeof context.batch === "function") context.batch(apply); else apply();`;
const restores = [...sharedState, ...sharedProps]
.map(
(name) =>
`${JSON.stringify(name)}:()=>{${
sharedState.includes(name)
? `${name} = context.state.${name}; committed.${name} = ${name};`
: `${name} = context.props.${name};`
}}`,
)
.join(",\n");
const restoreGroups: string[][] = [];
const restoreKeys: Record<string, number> = {};
for (const [name, keys] of restoredNames) {
let index = restoreGroups.findIndex((group) => JSON.stringify(group) === JSON.stringify(keys));
if (index < 0) {
index = restoreGroups.length;
restoreGroups.push(keys);
}
restoreKeys[name] = index;
}
const hasAuthoredCommit = callableAliases.includes("commit");
const hasAuthoredSetTimeout = callableAliases.includes("setTimeout");
const implementations = functions
.map((fn) => {
const parameters = fn.parameters.map((parameter) => parameter.name).join(", ");
const parameters = fn.parameters
.map(
(parameter) =>
parameter.name +
(parameter.default !== undefined
? ` = (${eraseFunctionTypes(parameter.default)})`
: ""),
)
.join(", ");
return `${JSON.stringify(fn.name)}: ${fn.async ? "async " : ""}function(${parameters}) {
try { ${eraseFunctionTypes(fn.body)} } finally { __wrnexusCommit(); }
}`;
${eraseFunctionTypes(fn.body)}
}`;
})
.join(",\n");
return `// generated WRNexusJS browser module for ${ast.name}
@@ -510,8 +588,11 @@ function __wrnexusCreateClientFunctions(context) {
const props = context.props;
const refs = context.refs;
${apiBindings(ast)}
const committed = { ${sharedState.map((name) => `${JSON.stringify(name)}: ${name}`).join(", ")} };
const __wrnexusCommit = () => { ${sharedCommit} };
const __wrnexusRestore = () => { ${sharedRestore} };
const __wrnexusRestores = { ${restores} };
const __wrnexusRestoreKeys = ${JSON.stringify(restoreKeys)};
const __wrnexusRestoreGroups = ${JSON.stringify(restoreGroups)};
${!hasAuthoredCommit ? "const commit = __wrnexusCommit;" : ""}
${
!hasAuthoredSetTimeout
@@ -523,12 +604,19 @@ function __wrnexusCreateClientFunctions(context) {
const implementations = {
${implementations}
};
${callableAliases.map((name) => `const ${name} = (...args) => implementations[${JSON.stringify(name)}](...args);`).join("\n ")}
${callableAliases.map((name) => `const ${name} = (...args) => ${functions.find((fn) => fn.name === name)?.async ? `__wrnexusInvoke(implementations[${JSON.stringify(name)}], args)` : `implementations[${JSON.stringify(name)}](...args)`};`).join("\n ")}
const __wrnexusInvoke = (fn, args) => {
let result;
try { result = fn(...args); } catch (error) { __wrnexusCommit(); throw error; }
__wrnexusCommit();
if (result && typeof result.then === "function") return Promise.resolve(result).finally(__wrnexusCommit);
return result;
};
const functions = {};
for (const name of Object.keys(implementations)) {
functions[name] = (...args) => {
__wrnexusRestore();
return implementations[name](...args);
for (const key of __wrnexusRestoreGroups[__wrnexusRestoreKeys[name]]) __wrnexusRestores[key]();
return __wrnexusInvoke(implementations[name], args);
};
}
return functions;
+27 -8
View File
@@ -918,7 +918,10 @@ function renderNestedComponentInvocation(
if (
!attr.value.includes("{") ||
(!exprRefsState(attr.value, ctx.stateNames) && !exprRefsState(attr.value, ctx.propNames))
// A bare callback reference is not a computed field value. Keep its
// existing transport while subscribing helper calls to their reads.
(wholeExpression && ctx.functionNames.has(wholeExpression.trim())) ||
!exprRefsComponentReactiveValue(attr.value, ctx)
) {
return rendered;
}
@@ -944,6 +947,7 @@ function renderNestedComponentInvocation(
return (
`<div data-component="${attrEscape(node.tag)}"` +
serverLoopLocalsAttribute(ctx) +
`${ctx.forwardRestAttrs ? "${__wrnSpreadAttrs(__attrs)}" : ""}` +
`${attrs}>${inner}</div>`
);
@@ -2344,7 +2348,7 @@ function renderClientControlTemplate(nodes: ViewNode[]): string {
}
const rendered = ` ${name}="${attrEscape(attribute.value)}"`;
return attribute.value.includes("{")
? `${rendered} data-wrn-bind-${bindIndex++}="${attrEscape(
? `${rendered} data-wrn-${componentTag ? "prop-bind" : "bind"}-${bindIndex++}="${attrEscape(
JSON.stringify([name, attribute.value]),
)}"`
: rendered;
@@ -2368,6 +2372,13 @@ function encodeClientControl(value: unknown): string {
function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
let expression = "``";
const clientBranches: string[] = [];
let previousConditions = "false";
const selectedConditions = node.branches.map((branch) => {
const condition = branch.cond === null ? "true" : `(${ctx.resolveExpr(branch.cond)})`;
const selected = `!(${previousConditions}) && ${condition}`;
previousConditions += ` || ${condition}`;
return selected;
});
for (let index = node.branches.length - 1; index >= 0; index--) {
const branch = node.branches[index]!;
@@ -2375,7 +2386,7 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
const bodyExpression = "`" + body + "`";
clientBranches.unshift(
`{ cond: ${branch.cond === null ? "null" : JSON.stringify(branch.cond)}, body: ${bodyExpression} }`,
`{ cond: ${branch.cond === null ? "null" : JSON.stringify(branch.cond)}, body: (${selectedConditions[index]}) ? ${bodyExpression} : ${JSON.stringify(renderClientControlTemplate(branch.body))} }`,
);
expression =
@@ -2384,10 +2395,17 @@ function renderComponentIfNode(node: IfNode, ctx: CompCtx): string {
: `(${ctx.resolveExpr(branch.cond)}) ? __branches[${index}].body : ${expression}`;
}
// Render all branches on the server into an inert, encoded definition.
// A branch first selected in the browser therefore contains the real nested
// component HTML and scope metadata, not a data-component placeholder.
return '${(() => { const __branches = [' + clientBranches.join(",") + ']; return `<template data-wrn-if="${__wrnexusEncodeControl(__branches)}"></template>${' + expression + '}<template data-wrn-control-end></template>`; })()}';
// Only evaluate the selected branch on the server. Inactive branches retain
// browser templates so guarded values may remain null until state changes.
return (
"${(() => { const __branches = [" +
clientBranches.join(",") +
']; return `<template data-wrn-if="${__wrnexusEncodeControl(__branches)}"' +
serverLoopLocalsAttribute(ctx) +
"></template>${" +
expression +
"}<template data-wrn-control-end></template>`; })()}"
);
}
function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
@@ -2427,7 +2445,7 @@ function renderComponentEachNode(node: EachNode, ctx: CompCtx): string {
empty: renderClientControlTemplate(node.empty),
});
return `<template data-wrn-each="${definition}"></template>${serverBody}<template data-wrn-control-end></template>`;
return `<template data-wrn-each="${definition}"${serverLoopLocalsAttribute(ctx)}></template>${serverBody}<template data-wrn-control-end></template>`;
}
function serverLoopLocalsAttribute(ctx: CompCtx): string {
@@ -3073,6 +3091,7 @@ function __wrnSpreadAttrs(value: unknown): string {
// and prop bindings ride from the mount onto the
// rendered child root so the mounting scope can bind them there.
(lowerName.startsWith("data-wrn") &&
lowerName !== "data-wrn-loop-locals" &&
!lowerName.startsWith("data-wrn-out-") &&
!lowerName.startsWith("data-wrn-prop-bind-"))
) {
@@ -180,6 +180,7 @@ function __wrnSpreadAttrs(value: unknown): string {
// and prop bindings ride from the mount onto the
// rendered child root so the mounting scope can bind them there.
(lowerName.startsWith("data-wrn") &&
lowerName !== "data-wrn-loop-locals" &&
!lowerName.startsWith("data-wrn-out-") &&
!lowerName.startsWith("data-wrn-prop-bind-"))
) {
+15
View File
@@ -1161,6 +1161,21 @@ test("page state passed to a component retains a parent-owned prop binding", ()
expect(output).toContain("data-wrn-prop-bind-");
expect(output).toContain("{count}");
});
test("nested component helper results retain reactive bindings", () => {
const output = generate(
parse(`component Signature {
props { value = {} }
functions {
shared function currentName() { return value.name || '' }
shared function currentConsent() { return !!value.consent }
}
view { <Input value="{currentName()}" /><Switch checked="{currentConsent()}" /> }
}`),
);
expect(output).toContain("[&quot;value&quot;,&quot;{currentName()}&quot;]");
expect(output).toContain("[&quot;checked&quot;,&quot;{currentConsent()}&quot;]");
});
test("nested component props retain parent-owned reactive bindings", () => {
const output = generate(
parse(`component Parent {
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test";
import { compile } from "../src/index.ts";
test("component conditional templates retain enclosing loop locals for reactive reevaluation", async () => {
const source = `component FormFields {
props { pages = [{id:'page',fields:[{id:'name',hidden:false}]}] }
state answers = {}
view { {#each pages as page}<section>{#each page.fields as field}{#if !field.hidden}<Input name="{field.id}" @input="answers[field.id]=payload.value" />{/if}{/each}</section>{/each} }
}`;
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
compile(source, "FormFields.wrn").code,
);
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
const html = mod.render({});
const markers = [...html.matchAll(/<template([^>]+)>/g)].map((match) => match[1]!);
const conditional = markers.find((marker) => marker.includes("data-wrn-if="))!;
const locals = /data-wrn-loop-locals="([^"]+)"/.exec(conditional);
expect(conditional?.trim().startsWith("data-wrn-if=")).toBe(true);
expect(locals).not.toBeNull();
const decoded = JSON.parse(Buffer.from(locals![1]!, "base64").toString());
expect(decoded.field.id).toBe("name");
expect(decoded.page.id).toBe("page");
const nestedEach = markers.filter((marker) => marker.includes("data-wrn-each="))[1]!;
const eachLocals = /data-wrn-loop-locals="([^"]+)"/.exec(nestedEach);
expect(eachLocals).not.toBeNull();
expect(JSON.parse(Buffer.from(eachLocals![1]!, "base64").toString()).page.id).toBe("page");
const child = /<div data-component="Input"([^>]+)>/.exec(html)!;
const childLocals = /data-wrn-loop-locals="([^"]+)"/.exec(child[1]!);
expect(childLocals).not.toBeNull();
expect(JSON.parse(Buffer.from(childLocals![1]!, "base64").toString()).field.id).toBe("name");
const childCode = new Bun.Transpiler({ loader: "ts" }).transformSync(
compile("component Child { view { <input /> } }", "Child.wrn").code,
);
const childModule = await import(
"data:text/javascript;base64," + Buffer.from(childCode).toString("base64")
);
const renderedChild = childModule.render({
"data-wrn-loop-locals": childLocals![1]!,
"data-wrn-if": "forbidden",
});
expect(renderedChild).toContain('data-wrn-loop-locals="' + childLocals![1] + '"');
expect(renderedChild).not.toContain('data-wrn-if="forbidden"');
});
@@ -7,7 +7,9 @@ test("deep conditional components render each branch once with bounded document
let view = "<p>Leaf</p>";
for (let i = 0; i < 12; i++) view = `{#if open}${view}{:else}<i>Closed</i>{/if}`;
const source = `component Nested {\n state open = true\n view { ${view} }\n }`;
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(compile(source, "Nested.wrn").code);
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
compile(source, "Nested.wrn").code,
);
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
runWithRequestContext({} as never, () => {
const body = mod.render({});
@@ -15,6 +17,6 @@ test("deep conditional components render each branch once with bounded document
expect(body).toContain("<p>Leaf</p>");
expect(document.length).toBeLessThan(30000);
const registry = JSON.parse(document.match(/data-wrn-controls[^>]*>(.*?)<\/script>/s)![1]!);
expect(registry).toHaveLength(12);
expect(registry.filter(Array.isArray)).toHaveLength(12);
});
});
@@ -0,0 +1,34 @@
import { expect, test } from "bun:test";
import { parse } from "@wrnexus/syntax";
import { generateBrowserModule } from "../src/client-codegen.ts";
const source = `component FunctionDefaults {
props { timezone = "UTC" }
state selected = ""
functions {
client function choose(section = "connections", hour = 9) { selected = section; return hour }
shared function label(prefix = "event", suffix = prefix + " details", zone = timezone) { return [prefix, suffix, zone] }
}
view { <button @click="choose()">Choose</button> }
}`;
test("browser functions retain defaults, prior-argument references and prop references", () => {
const module = generateBrowserModule(parse(source));
const functions = new Function(
module.replace(/\bexport\s+(?=const|function)/g, "") + ";return __wrnexusClientFunctions;",
)();
const context = {
state: { selected: "" },
props: { timezone: "Asia/Kolkata" },
batch: (apply: () => void) => apply(),
};
expect(functions.choose(context)).toBe(9);
expect(context.state.selected).toBe("connections");
expect(functions.label(context)).toEqual(["event", "event details", "Asia/Kolkata"]);
expect(functions.label(context, "meeting")).toEqual([
"meeting",
"meeting details",
"Asia/Kolkata",
]);
expect(functions.choose(context, undefined, 0)).toBe(0);
expect(functions.label(context, null, "explicit", "UTC")).toEqual([null, "explicit", "UTC"]);
});
@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { compile } from "../src/index.ts";
test("inactive conditional branches do not dereference null state during server rendering", async () => {
const source = `component AddressReview {
state review = null
view { <h3>Address review</h3>{#if review}<p>{review.address}</p>{#if review.suppression.active}<button>Reactivate</button>{/if}{/if} }
}`;
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
compile(source, "AddressReview.wrn").code,
);
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
expect(() => mod.render({})).not.toThrow();
expect(mod.render({})).toContain("<h3>Address review</h3>");
});
test("selected branches render state while inactive nested branches remain browser templates", async () => {
const source = `component AddressReview {
props { review = { address: 'review@example.test', suppression: null } }
view { {#if review}<p>{review.address}</p>{#if review.suppression}<b>{review.suppression.reason}</b>{:else}<i>No block</i>{/if}{/if} }
}`;
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
compile(source, "ReviewedAddress.wrn").code,
);
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
const html = mod.render({});
expect(html).toContain('data-text="review.address">review@example.test</span>');
expect(html).toContain("<i>No block</i>");
expect(html).not.toContain("<b>null</b>");
});
test("inactive branch child components retain parent prop bindings", async () => {
const source = `component AddressReview {
props { messages = { evidence: 'Evidence', reactivate: 'Reactivate' } }
state review = null
view { {#if review}<Input label="{messages.evidence}" /><Button label="{messages.reactivate}" />{/if} }
}`;
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
compile(source, "LocalizedReview.wrn").code,
);
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
const html = mod.render({});
const match = /data-wrn-if="([A-Za-z0-9+/=]+)"/.exec(html)!;
const body = JSON.parse(Buffer.from(match[1]!, "base64").toString())[0].body;
expect(body).toContain("data-wrn-prop-bind-0=");
expect(body).not.toContain("data-wrn-bind-0=");
expect(body).toContain("messages.evidence");
expect(body).toContain("messages.reactivate");
});
+222
View File
@@ -225,3 +225,225 @@ test("generated client modules stay below decoded-size and duplication ratchets"
expect(decoded).toBeLessThanOrEqual(13_000);
expect(decoded / compressed).toBeLessThanOrEqual(10);
});
test("async completion commits sibling state atomically before reactive readers run", async () => {
const targets = generateTargets(
parse(`component AtomicCompletion {
state { busy: boolean = true; submitted: boolean = false }
functions { shared function visible(){return !submitted} client async function finish(){await Promise.resolve();busy=false;submitted=true} }
view { <p>{visible()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
let batching = false,
reads = 0;
const values = { busy: true, submitted: false };
const state = new Proxy(values, {
set(target, key, value) {
const previous = Reflect.get(target, key);
Reflect.set(target, key, value);
if (!batching && previous !== value) {
reads++;
functions.visible();
}
return true;
},
});
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
batch(callback: () => void) {
batching = true;
try {
callback();
} finally {
batching = false;
}
reads++;
},
});
await functions.finish();
expect(values.busy).toBe(false);
expect(values.submitted).toBe(true);
expect(reads).toBeGreaterThan(0);
});
test("display helpers subscribe only to their transitive inputs and never write unchanged state", () => {
const targets = generateTargets(
parse(`component FocusedHelpers {
state { events: string[] = []; title: string = ""; selected: string = "" }
props { timezone: string = "UTC"; unrelated: string = "" }
functions {
shared function zone(){return timezone}
shared function matches(item){return item===selected}
shared function visible(){return events.filter(matches).map(item=>item+zone())}
client function changeTitle(value){title=value}
}
view { <p>{visible()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const reads: string[] = [],
writes: string[] = [];
const values = { events: ["chosen"], title: "draft", selected: "chosen" };
const functions = executable.bindClientScope({
state: new Proxy(values, {
get(target, key) {
reads.push(String(key));
return Reflect.get(target, key);
},
set(target, key, value) {
writes.push(String(key));
return Reflect.set(target, key, value);
},
}),
props: { timezone: "UTC", unrelated: "" },
output: {},
server: {},
refs: {},
});
reads.length = 0;
writes.length = 0;
expect(functions.visible()).toEqual(["chosenUTC"]);
expect([...new Set(reads)].sort()).toEqual(["events", "selected"]);
expect(writes).toEqual([]);
functions.changeTitle("new draft");
expect(writes).toEqual(["title"]);
expect(values.title).toBe("new draft");
reads.length = 0;
writes.length = 0;
expect(functions.visible()).toEqual(["chosenUTC"]);
expect(reads).not.toContain("title");
expect(writes).toEqual([]);
});
test("helper defaults, template expressions and returned callbacks retain state dependencies", () => {
const targets = generateTargets(
parse(`component IndirectReads {
state { selected: string = "A"; title: string = "draft"; irrelevant: number = 0 }
functions { shared function value(input=selected){return input} shared function label(){return () => \`\${title}:\${value()}\`} }
view { <p>{label()()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const reads: string[] = [];
const values = { selected: "A", title: "draft", irrelevant: 0 };
const functions = executable.bindClientScope({
state: new Proxy(values, {
get(target, key) {
reads.push(String(key));
return Reflect.get(target, key);
},
}),
props: {},
output: {},
server: {},
refs: {},
});
reads.length = 0;
expect(functions.label()()).toBe("draft:A");
expect([...new Set(reads)].sort()).toEqual(["selected", "title"]);
values.title = "updated";
expect(functions.label()()).toBe("updated:A");
});
test("a focused helper cannot overwrite unrelated state changed outside the bound closure", async () => {
const targets = generateTargets(
parse(`component PreserveExternalState {
state { count: number = 0; title: string = "draft" }
functions { shared function countLabel(){return String(count)} client async function increment(){await Promise.resolve();count+=1} }
view { <p>{countLabel()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const state = { count: 0, title: "draft" };
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
});
state.title = "external update";
expect(functions.countLabel()).toBe("0");
expect(state.title).toBe("external update");
await functions.increment();
expect(state.count).toBe(1);
expect(state.title).toBe("external update");
});
test("permission strings and method names do not create false helper dependencies", () => {
const targets = generateTargets(
parse(`component PermissionHelpers {
state { title: string = "draft" }
props { roles: string[] = [] }
functions { shared function canCreate(){return roles.includes('calendar.create')} client function create(){return title} }
view { <p>{canCreate()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const reads: string[] = [];
const functions = executable.bindClientScope({
state: new Proxy(
{ title: "draft" },
{
get(target, key) {
reads.push(String(key));
return Reflect.get(target, key);
},
},
),
props: { roles: ["calendar.create"] },
output: {},
server: {},
refs: {},
});
reads.length = 0;
expect(functions.canCreate()).toBe(true);
expect(reads).toEqual([]);
});
test("async functions publish loading and navigation state before yielding", async () => {
const targets = generateTargets(
parse(`component ImmediateAsyncState {
state { busy: boolean = false; selected: string = "old" }
functions { shared function label(){return selected} client async function load(value){busy=true;selected=value;await server.wait();busy=false} }
view { <p>{label()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
let release: any;
const gate = new Promise<void>((resolve) => (release = resolve));
const state = { busy: false, selected: "old" };
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: { wait: () => gate },
refs: {},
});
const pending = functions.load("new");
expect(state.busy).toBe(true);
expect(state.selected).toBe("new");
expect(functions.label()).toBe("new");
expect(state.busy).toBe(true);
release();
await pending;
expect(state.busy).toBe(false);
expect(state.selected).toBe("new");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/content",
"version": "0.8.9",
"version": "0.8.10",
"type": "module",
"description": "Typed Markdown content collections, loaders, indexes, feeds, and preview workflows for WRNexusJS.",
"main": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/core",
"version": "0.8.17",
"version": "0.8.25",
"type": "module",
"main": "src/index.ts",
"exports": {
+63 -2
View File
@@ -4,6 +4,18 @@ import { Buffer } from "node:buffer";
type Registry = { id: string; definitions: unknown[] };
const registries = new WeakMap<object, Registry>();
/** Read a control definition owned by the active request for SSR preparation. */
export function resolveControlTemplate(reference: string): unknown {
const ctx = getRequestContext();
const registry = ctx && registries.get(ctx);
if (!registry) return undefined;
const prefix = `@${registry.id}:`;
if (!reference.startsWith(prefix)) return undefined;
const index = reference.slice(prefix.length);
if (!/^(0|[1-9][0-9]*)$/.test(index)) return undefined;
return registry.definitions[Number(index)];
}
/** Hoist nested templates out of each other for linear-size hydration data. */
export function encodeControlTemplate(value: unknown): string {
const ctx = getRequestContext();
@@ -17,10 +29,59 @@ export function encodeControlTemplate(value: unknown): string {
return `@${registry.id}:${index}`;
}
export function renderControlTemplates(): string {
/** Nested controls own their rendered content; keep it once in their definition. */
function compactControlMarkup(markup: string): string {
const tokenPattern =
/<template\s+(?:data-wrn-(?:if|each)="([^"]+)"|data-wrn-control-end)(?:\s[^>]*)?>\s*<\/template>/g;
const stack: Array<{ markerStart: number; marker: string; owned: boolean }> = [];
const replacements: Array<{ start: number; end: number; value: string }> = [];
for (const match of markup.matchAll(tokenPattern)) {
if (match[1]) {
stack.push({
markerStart: match.index!,
marker: match[0],
owned: !!resolveControlTemplate(match[1]),
});
continue;
}
const open = stack.pop();
if (!open || !open.owned || stack.some((entry) => entry.owned)) continue;
replacements.push({
start: open.markerStart,
end: match.index!,
value: open.marker.replace(/>\s*<\/template>$/, " data-wrn-control-client></template>"),
});
}
// Do not alter incomplete control ranges.
if (stack.length) return markup;
for (const replacement of replacements.reverse()) {
markup = markup.slice(0, replacement.start) + replacement.value + markup.slice(replacement.end);
}
return markup;
}
export function renderControlTemplates(transformMarkup?: (markup: string) => string): string {
const ctx = getRequestContext();
const registry = ctx && registries.get(ctx);
if (!registry) return "";
const json = JSON.stringify(registry.definitions).replace(/</g, "\\u003c");
const definitions: unknown[] = [];
// A markup transform may register shared metadata while visiting a template.
// Include those new request-owned entries in this same document.
for (let index = 0; index < registry.definitions.length; index++) {
const definition = registry.definitions[index];
const transform = (entry: unknown) => {
if (!entry || typeof entry !== "object") return entry;
const copy = { ...entry } as Record<string, unknown>;
for (const key of ["body", "empty"]) {
if (typeof copy[key] === "string")
copy[key] = compactControlMarkup(
transformMarkup ? transformMarkup(copy[key]) : copy[key],
);
}
return copy;
};
definitions.push(Array.isArray(definition) ? definition.map(transform) : transform(definition));
}
const json = JSON.stringify(definitions).replace(/</g, "\\u003c");
return `<script type="application/json" data-wrn-controls id="${registry.id}">${json}</script>`;
}
+52 -1
View File
@@ -93,6 +93,10 @@ export interface SecurityConfig {
crossOriginOpenerPolicy?: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none";
/** Defaults to "SAMEORIGIN". */
frameOptions?: false | "DENY" | "SAMEORIGIN";
/** Allow trusted routes to supply X-WRNexus-Frame-Ancestors (exact HTTPS origins only). */
routeFrameAncestors?: boolean;
/** Allow a trusted route's explicit CSP to replace the framework default. Opt-in only. */
routeContentSecurityPolicy?: boolean;
/** Defaults to "strict-origin-when-cross-origin". */
referrerPolicy?: false | string;
/** Defaults to "same-origin". */
@@ -219,6 +223,9 @@ export function withSecurityHeaders(
nonce?: string,
): Response {
const headers = new Headers(res.headers);
const routeCsp = headers.get("Content-Security-Policy");
const routeAncestors = headers.get("X-WRNexus-Frame-Ancestors");
headers.delete("X-WRNexus-Frame-Ancestors");
const cors = normalizeCors(security?.cors);
if (cors.enabled) {
@@ -227,6 +234,46 @@ export function withSecurityHeaders(
if (security?.headers !== false) {
applyBaseSecurityHeaders(headers, mode, security, nonce);
if (security?.routeContentSecurityPolicy && routeCsp?.trim()) {
headers.set("Content-Security-Policy", routeCsp);
// CSP frame-ancestors is authoritative for explicitly embeddable routes.
if (/(?:^|;)\s*frame-ancestors\s+/i.test(routeCsp)) headers.delete("X-Frame-Options");
}
if (
security?.routeFrameAncestors &&
routeAncestors !== null &&
headers.has("Content-Security-Policy")
) {
const sources = routeAncestors.trim().split(/\s+/);
const valid =
sources.length > 0 &&
sources.length <= 21 &&
sources.every((source) => {
if (source === "'self'" || source === "'none'") return true;
try {
const url = new URL(source);
return (
url.protocol === "https:" &&
!url.username &&
!url.password &&
url.origin === source &&
!source.includes("*")
);
} catch {
return false;
}
}) &&
(!sources.includes("'none'") || sources.length === 1);
const ancestors = valid ? sources.join(" ") : "'none'";
const policy = headers
.get("Content-Security-Policy")!
.split(";")
.filter((value) => !/^\s*frame-ancestors(?:\s|$)/i.test(value));
headers.set("Content-Security-Policy", policy.join(";") + "; frame-ancestors " + ancestors);
if (ancestors === "'none'") headers.set("X-Frame-Options", "DENY");
else if (ancestors === "'self'") headers.set("X-Frame-Options", "SAMEORIGIN");
else headers.delete("X-Frame-Options");
}
}
if (security?.extraHeaders) {
@@ -261,7 +308,11 @@ function applyBaseSecurityHeaders(
// A handler can deliberately expose a public image for email embedding.
// An explicitly configured application policy still takes precedence.
const responseCorp = headers.get("Cross-Origin-Resource-Policy");
if (corp !== false && (security?.crossOriginResourcePolicy !== undefined || !["same-origin", "same-site", "cross-origin"].includes(responseCorp ?? ""))) {
if (
corp !== false &&
(security?.crossOriginResourcePolicy !== undefined ||
!["same-origin", "same-site", "cross-origin"].includes(responseCorp ?? ""))
) {
headers.set("Cross-Origin-Resource-Policy", corp);
}
if (security?.originAgentCluster !== false) headers.set("Origin-Agent-Cluster", "?1");
+5 -1
View File
@@ -237,4 +237,8 @@ export type {
IdempotencyRecord,
IdempotencyStore,
} from "./platform.ts";
export { encodeControlTemplate, renderControlTemplates } from "./control-templates.ts";
export {
encodeControlTemplate,
resolveControlTemplate,
renderControlTemplates,
} from "./control-templates.ts";
+76 -16
View File
@@ -1,23 +1,60 @@
import { expect, test } from "bun:test";
import { encodeControlTemplate, renderControlTemplates } from "../src/control-templates.ts";
import {
encodeControlTemplate,
resolveControlTemplate,
renderControlTemplates,
} from "../src/control-templates.ts";
import { runWithRequestContext } from "../src/request-context.ts";
import { createContext } from "../src/context.ts";
import { resolveRequestUrl } from "../src/headers.ts";
test("registry templates omit duplicated SSR ranges but preserve source and foreign controls", () => {
runWithRequestContext({} as never, () => {
const leaf = `<p>${"Large leaf".repeat(400)}</p>`;
let body = leaf;
for (let depth = 0; depth < 12; depth++) {
const reference = encodeControlTemplate([{ cond: "open", body }]);
body = `<template data-wrn-if="${reference}"></template>${body}<template data-wrn-control-end></template>`;
}
const html = renderControlTemplates();
expect(html.length).toBeLessThan(10000);
const compacted = JSON.parse(/data-wrn-controls[^>]*>(.*?)<\/script>/s.exec(html)![1]!);
expect(
compacted.filter((branches: Array<{ body: string }>) => branches[0]!.body.includes(leaf)),
).toHaveLength(1);
expect(html).toContain("data-wrn-control-client");
expect(body).toContain(leaf);
const foreign =
'<template data-wrn-if="@foreign:0"></template><b>Keep</b><template data-wrn-control-end></template>';
const incomplete = body.replace(/<template data-wrn-control-end><\/template>$/, "");
encodeControlTemplate([
{ cond: null, body: foreign },
{ cond: null, body: incomplete },
]);
const definitions = JSON.parse(
/data-wrn-controls[^>]*>(.*?)<\/script>/s.exec(renderControlTemplates())![1]!,
);
expect(definitions[12][0].body).toBe(foreign);
expect(definitions[12][1].body).toBe(incomplete);
});
});
test("nested controls use short references and HTML-safe isolated registries", async () => {
const results = await Promise.all(["first", "second"].map((name) =>
runWithRequestContext({} as never, async () => {
let body = `</script><p>${name}</p>`;
for (let i = 0; i < 30; i++) {
body = `<template data-wrn-if="${encodeControlTemplate([{cond: null, body}])}"></template>`;
}
await Promise.resolve();
const html = renderControlTemplates();
expect(html.length).toBeLessThan(7000);
expect(html.match(/<\/script>/g)).toHaveLength(1);
return html;
}),
));
const results = await Promise.all(
["first", "second"].map((name) =>
runWithRequestContext({} as never, async () => {
let body = `</script><p>${name}</p>`;
for (let i = 0; i < 30; i++) {
body = `<template data-wrn-if="${encodeControlTemplate([{ cond: null, body }])}"></template>`;
}
await Promise.resolve();
const html = renderControlTemplates();
expect(html.length).toBeLessThan(7000);
expect(html.match(/<\/script>/g)).toHaveLength(1);
return html;
}),
),
);
expect(results[0]).toContain("first");
expect(results[0]).not.toContain("second");
expect(results[1]).not.toContain("first");
@@ -28,11 +65,34 @@ test("standalone rendering keeps the base64 format", () => {
expect(JSON.parse(Buffer.from(encodeControlTemplate([1]), "base64").toString())).toEqual([1]);
});
test("SSR control resolution accepts only the owning request and exact registry index", async () => {
let reference = "";
await runWithRequestContext({} as never, async () => {
const definition = [{ cond: "review", body: "<p>Review</p>" }];
reference = encodeControlTemplate(definition);
expect(resolveControlTemplate(reference)).toBe(definition);
expect(resolveControlTemplate(reference + "0")).toBeUndefined();
expect(resolveControlTemplate(reference.replace(/:0$/, ":-1"))).toBeUndefined();
expect(resolveControlTemplate(reference.replace(/:0$/, ":1"))).toBeUndefined();
await runWithRequestContext({} as never, async () => {
encodeControlTemplate([{ body: "Other request" }]);
expect(resolveControlTemplate(reference)).toBeUndefined();
});
expect(resolveControlTemplate(reference)).toBe(definition);
});
expect(resolveControlTemplate(reference)).toBeUndefined();
});
test("trusted proxy request URL and body agree with context; untrusted headers are ignored", async () => {
for (const trust of [true, false]) {
const request = new Request("http://internal:3000/api/captcha/challenge", {
method: "POST", body: '{"test":1}',
headers: { "x-forwarded-proto": "https", "x-forwarded-host": "wrnexus.com", origin: "https://evil.example" },
method: "POST",
body: '{"test":1}',
headers: {
"x-forwarded-proto": "https",
"x-forwarded-host": "wrnexus.com",
origin: "https://evil.example",
},
});
const context = createContext(request, resolveRequestUrl(request, trust));
expect(context.req.url).toBe(context.url.href);
+28 -9
View File
@@ -1,10 +1,29 @@
import {expect,test} from "bun:test";
import {withSecurityHeaders} from "../src/headers.ts";
test("explicit response resource policies survive defaults while configured application policy wins",()=>{
const req=new Request("https://example.com/image.png");
const image=()=>new Response("image",{headers:{"Cross-Origin-Resource-Policy":"cross-origin","Content-Type":"image/png"}});
expect(withSecurityHeaders(req,image(),"production").headers.get("Cross-Origin-Resource-Policy")).toBe("cross-origin");
expect(withSecurityHeaders(req,new Response("private"),"production").headers.get("Cross-Origin-Resource-Policy")).toBe("same-origin");
expect(withSecurityHeaders(req,image(),"production",{crossOriginResourcePolicy:"same-origin"}).headers.get("Cross-Origin-Resource-Policy")).toBe("same-origin");
expect(withSecurityHeaders(req,new Response("x",{headers:{"Cross-Origin-Resource-Policy":"invalid"}}),"production").headers.get("Cross-Origin-Resource-Policy")).toBe("same-origin");
import { expect, test } from "bun:test";
import { withSecurityHeaders } from "../src/headers.ts";
test("explicit response resource policies survive defaults while configured application policy wins", () => {
const req = new Request("https://example.com/image.png");
const image = () =>
new Response("image", {
headers: { "Cross-Origin-Resource-Policy": "cross-origin", "Content-Type": "image/png" },
});
expect(
withSecurityHeaders(req, image(), "production").headers.get("Cross-Origin-Resource-Policy"),
).toBe("cross-origin");
expect(
withSecurityHeaders(req, new Response("private"), "production").headers.get(
"Cross-Origin-Resource-Policy",
),
).toBe("same-origin");
expect(
withSecurityHeaders(req, image(), "production", {
crossOriginResourcePolicy: "same-origin",
}).headers.get("Cross-Origin-Resource-Policy"),
).toBe("same-origin");
expect(
withSecurityHeaders(
req,
new Response("x", { headers: { "Cross-Origin-Resource-Policy": "invalid" } }),
"production",
).headers.get("Cross-Origin-Resource-Policy"),
).toBe("same-origin");
});
+43
View File
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test";
import { withSecurityHeaders } from "../src/headers.ts";
const request = new Request("https://app.example.test/");
const policy =
"default-src 'none'; style-src 'unsafe-inline'; frame-ancestors https://customer.example.test; sandbox";
test("route CSP does not override the default without explicit application opt-in", () => {
const response = withSecurityHeaders(
request,
new Response("static", { headers: { "content-security-policy": policy } }),
"production",
);
expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
});
test("trusted static routes retain strict CSP and explicit cross-origin framing when enabled", () => {
const response = withSecurityHeaders(
request,
new Response("static", { headers: { "content-security-policy": policy } }),
"production",
{ routeContentSecurityPolicy: true },
);
expect(response.headers.get("content-security-policy")).toBe(policy);
expect(response.headers.has("x-frame-options")).toBe(false);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("strict-transport-security")).toContain("max-age=");
});
test("ordinary routes keep defaults and a route without frame-ancestors keeps frame restrictions", () => {
const ordinary = withSecurityHeaders(request, new Response("app"), "production", {
routeContentSecurityPolicy: true,
});
expect(ordinary.headers.get("content-security-policy")).toContain("default-src 'self'");
expect(ordinary.headers.get("x-frame-options")).toBe("SAMEORIGIN");
const staticResponse = withSecurityHeaders(
request,
new Response("static", {
headers: { "content-security-policy": "default-src 'none'; sandbox" },
}),
"production",
{ routeContentSecurityPolicy: true },
);
expect(staticResponse.headers.get("content-security-policy")).toBe("default-src 'none'; sandbox");
expect(staticResponse.headers.get("x-frame-options")).toBe("SAMEORIGIN");
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.44",
"version": "0.8.74",
"type": "module",
"main": "src/index.ts",
"exports": {
+8 -3
View File
@@ -73,9 +73,13 @@ function stripControllerSections(source: string): string {
const CONTROLLER_LOADER = String.raw`
var componentControllerSelector = "[data-wrn-anchored],[data-wrn-dialog],[data-wrn-roving],[data-wrn-scrollspy],[data-wrn-splitter],[data-wrn-navbar],[data-wrn-preferences],[data-wrn-select],[data-wrn-pin-input]";
var componentControllerPromise = null;
var componentControllerUrl = (document.currentScript && document.currentScript.src)
? new URL("./controllers.js", document.currentScript.src).href
: "/__wrnexus/controllers.js";
var reactiveScript = document.currentScript || document.querySelector('script[src*="/__wrnexus/reactive.js"]');
var componentControllerUrl = "/__wrnexus/controllers.js";
if (reactiveScript && reactiveScript.src) {
var controllerUrl = new URL("./controllers.js", reactiveScript.src);
controllerUrl.search = new URL(reactiveScript.src).search;
componentControllerUrl = controllerUrl.href;
}
function needsComponentControllers(root) {
var host = root || document;
return !!((host.matches && host.matches(componentControllerSelector)) ||
@@ -166,6 +170,7 @@ ${sections}
setupRovingFocus();
setupScrollspy();
setupSplitters();
startDocumentWatch();
window.__wrnexusComponentControllers = { hydrate: hydrate, reposition: repositionAnchored };
hydrate(document);
})();
+7 -1
View File
@@ -285,6 +285,11 @@ export const NAV_RUNTIME = String.raw`
return loaded;
}
// Track the original document and scripts explicitly scheduled by navigation.
// Imported page script nodes are inert; seeing them in the swapped DOM does
// not mean their code has run.
var loadedNavigationScripts = loadedScriptPaths();
function runtimeIds(root) {
var ids = {};
if (!root || !root.querySelectorAll) return ids;
@@ -330,7 +335,7 @@ export const NAV_RUNTIME = String.raw`
* currently loaded.
*/
function ensureScripts(doc) {
var loaded = loadedScriptPaths();
var loaded = loadedNavigationScripts;
doc
.querySelectorAll("script[src]")
@@ -683,6 +688,7 @@ export const NAV_RUNTIME = String.raw`
return fetch(url, {
headers: {
"x-wrnexus-nav": "1",
"x-wrnexus-behaviors": "registry-v2",
...(isPrefetch ? { "x-wrnexus-prefetch": "1" } : {}),
accept: "text/html",
},
File diff suppressed because it is too large. Load diff
+50
View File
@@ -90,3 +90,53 @@ test("loop locals survive non-ASCII values", () => {
const decoded = JSON.parse(Buffer.from(String(raw), "base64").toString("utf8"));
expect(decoded.row.label).toBe("Ünïcode — 日本語");
});
test("replaced loop rows release their native effects and stop observing state", () => {
const win = mount(
`<div id="owner" data-scope="items: [1], value: 0"><p class="row" data-for="item in items" data-wrn-bind-title='["title","{value}"]'>{item}</p></div>`,
);
const owner = win.document.querySelector("#owner") as any;
const removed: any[] = [];
for (let i = 1; i <= 20; i++) {
const row = win.document.querySelector(".row") as any;
removed.push(row);
owner.__wrnexusScopeApi.set("items", [i + 1]);
owner.__wrnexusScopeApi.set("value", i);
expect(win.document.querySelector(".row")!.getAttribute("title")).toBe(String(i));
expect(row.getAttribute("title")).toBe(String(i - 1));
expect(row.__wrnexusReactiveDisposers?.size ?? 0).toBe(0);
}
expect(removed.every((row) => !row.isConnected)).toBe(true);
});
test("removing nested rows disposes their loop markers and descendant effects", () => {
const win = mount(
`<div id="owner" data-scope="groups: [{items:[1,2]}], value: 0"><section class="group" data-for="group in groups"><p class="row" data-for="item in group.items" data-wrn-bind-title='["title","{value}"]'>{item}</p></section></div>`,
);
const owner = win.document.querySelector("#owner") as any;
const group = win.document.querySelector(".group") as any;
const rows = Array.from(group.querySelectorAll(".row")) as any[];
owner.__wrnexusScopeApi.set("groups", []);
owner.__wrnexusScopeApi.set("value", 4);
expect(rows.map((row) => row.getAttribute("title"))).toEqual(["0", "0"]);
const walker = win.document.createTreeWalker(group, (win as any).NodeFilter.SHOW_ALL);
let node: any = group;
do {
expect(node.__wrnexusReactiveDisposers?.size ?? 0).toBe(0);
} while ((node = walker.nextNode()) !== null);
});
test("moving a mounted scope keeps its listeners and state alive", async () => {
const win = mount(
`<div id="owner" data-scope="count: 0"><button data-on-click="count++">Increment</button><span data-text="count"></span></div>`,
);
const owner = win.document.querySelector("#owner") as any;
const button = owner.querySelector("button");
button.click();
expect(owner.querySelector("span").textContent).toBe("1");
win.document.body.appendChild(owner);
await new Promise((resolve) => setTimeout(resolve, 0));
button.click();
expect(owner.querySelector("span").textContent).toBe("2");
expect(owner.__wrnexusScopeApi.get("count")).toBe(2);
});
+27 -7
View File
@@ -58,6 +58,20 @@ beforeEach(() => {
}
});
test("loads a new page script even when its inert tag is copied inside app content", async () => {
install('<div id="app"><a href="/report" id="lnk">Report</a></div>', (window) => {
window.happyDOM.settings.disableJavaScriptFileLoading = true;
});
nextHtml =
'<html><body><div id="app"><button>Download</button><script type="module" src="/reports.js?v=1"></script></div></body></html>';
win.document.getElementById("lnk").click();
await flush();
expect(win.document.body.querySelectorAll('script[src="/reports.js?v=1"]')).toHaveLength(2);
win.__wrnexusNavigate("/report?environment=live");
await flush();
expect(win.document.body.querySelectorAll('script[src="/reports.js?v=1"]')).toHaveLength(2);
});
test("intercepts an internal link click and swaps #app in place", async () => {
install(`<div id="app"><h1>Home</h1><a href="/about" id="lnk">About</a></div>`);
nextHtml =
@@ -84,10 +98,12 @@ test("fragment history navigation preserves the page without fetching", async ()
test("navigation supplies TrustedHTML to the document parser", async () => {
let parses = 0;
install('<div id="app"><a href="/next" id="lnk">Next</a></div>', (window) => {
window.trustedTypes = { createPolicy: (_name: string, rules: any) => ({
createHTML: (value: string) => ({ trusted: true, value: rules.createHTML(value) }),
createScriptURL: rules.createScriptURL,
}) };
window.trustedTypes = {
createPolicy: (_name: string, rules: any) => ({
createHTML: (value: string) => ({ trusted: true, value: rules.createHTML(value) }),
createScriptURL: rules.createScriptURL,
}),
};
const Parser = window.DOMParser;
(globalThis as any).DOMParser = class {
parseFromString(value: any, type: string) {
@@ -105,13 +121,17 @@ test("navigation supplies TrustedHTML to the document parser", async () => {
});
test("navigation replaces document control registries before hydration", async () => {
install('<div id="app"><a href="/next" id="lnk">Next</a></div><script type="application/json" data-wrn-controls id="old">[]</script>');
install(
'<div id="app"><a href="/next" id="lnk">Next</a></div><script type="application/json" data-wrn-controls id="old">[]</script>',
);
const importNode = win.document.importNode.bind(win.document);
win.document.importNode = (node: any, deep: boolean) => {
if (node.nodeName === "SCRIPT") throw new Error("Script text must not be cloned under Trusted Types");
if (node.nodeName === "SCRIPT")
throw new Error("Script text must not be cloned under Trusted Types");
return importNode(node, deep);
};
nextHtml = '<html><body><div id="app">Next</div><script type="application/json" data-wrn-controls id="new">[[{"cond":null,"body":"Ready"}]]</script><script data-wrn-controls id="executable">window.bad = true</script></body></html>';
nextHtml =
'<html><body><div id="app">Next</div><script type="application/json" data-wrn-controls id="new">[[{"cond":null,"body":"Ready"}]]</script><script data-wrn-controls id="executable">window.bad = true</script></body></html>';
win.document.getElementById("lnk").click();
await flush();
expect(win.document.getElementById("old")).toBeNull();
+684 -17
View File
@@ -1,9 +1,87 @@
import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { Window, type HTMLButtonElement as HappyButtonElement } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
import { mountHtml } from "@wrnexus/test";
import { restoreGlobalsAfterAll } from "./global-restore.ts";
import {
runWithRequestContext,
encodeControlTemplate,
renderControlTemplates,
} from "@wrnexus/core";
test("controlled select survives option reconciliation and uses the latest value", async () => {
const win = mount(
`<div data-scope="choice: 'field'"><select data-wrn-bind-0='["value","{choice}"]'><option value="">Choose</option><option value="field">Field</option><option value="form">Form</option></select><button data-on-click="choice = 'form'">Change</button></div>`,
);
const select = win.document.querySelector("select")!;
// Native option reconciliation can reset selection after the value binding.
select.innerHTML =
'<option value="">Choose</option><option value="field">Field</option><option value="form">Form</option>';
await new Promise((resolve) => setTimeout(resolve, 0));
expect(select.value).toBe("field");
select.innerHTML =
'<option value="">Choose</option><option value="field">Field</option><option value="form">Form</option>';
await new Promise((resolve) => setTimeout(resolve, 10));
expect(select.value).toBe("field");
win.document.querySelector("button")!.click();
select.value = "";
await new Promise((resolve) => setTimeout(resolve, 0));
expect(select.value).toBe("form");
});
test("an unchanged conditional branch preserves input identity and focus", () => {
const definition = Buffer.from(
JSON.stringify([{ cond: "count >= 0", body: '<input name="answer" />' }]),
).toString("base64");
const win = mount(
`<div data-scope="count: 0"><button data-on-click="count++">Update</button><template data-wrn-if="${definition}"></template><input name="answer" /><template data-wrn-control-end></template></div>`,
);
const input = win.document.querySelector("input")!;
input.value = "Typing";
input.focus();
win.document.querySelector("button")!.click();
expect(win.document.querySelector("input") === input).toBe(true);
expect(win.document.activeElement === input).toBe(true);
expect(input.value).toBe("Typing");
});
test("compacted nested component controls materialize immediately and keep loop reactivity", () => {
runWithRequestContext({} as never, () => {
const rows = encodeControlTemplate({
list: "items",
item: "item",
index: "index",
body: '<p class="row">{index}:{item}</p>',
empty: '<p class="empty">Empty</p>',
});
const inner = encodeControlTemplate([
{
cond: "active",
body: `<template data-wrn-each="${rows}"></template><p>Stale SSR row</p><template data-wrn-control-end></template>`,
},
]);
const child = `<div data-scope="active: true, items: ['a','b']"><button data-on-click="items = []">Clear</button><template data-wrn-if="${inner}"></template><p>Stale SSR branch</p><template data-wrn-control-end></template></div>`;
const outer = encodeControlTemplate([
{ cond: "open", body: child },
{ cond: null, body: "<i>Closed</i>" },
]);
const win = mount(
renderControlTemplates() +
`<div data-scope="open: false"><button data-on-click="open = !open">Toggle</button><template data-wrn-if="${outer}"></template><i>Closed</i><template data-wrn-control-end></template></div>`,
);
win.document.querySelector("button")!.click();
expect([...win.document.querySelectorAll(".row")].map((node) => node.textContent)).toEqual([
"0:a",
"1:b",
]);
expect(win.document.querySelector("#app")!.textContent).not.toContain("Stale SSR");
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector(".row")).toBeNull();
expect(win.document.querySelector(".empty")?.textContent).toBe("Empty");
win.document.querySelector("button")!.click();
expect(win.document.querySelector("i")?.textContent).toBe("Closed");
});
});
// Fresh DOM per test, with the runtime's globals bound.
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
@@ -26,6 +104,18 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
(globalThis as Record<string, unknown>).CustomEvent = (
win as unknown as { CustomEvent: unknown }
).CustomEvent;
(globalThis as Record<string, unknown>).HTMLOptionElement = (
win as unknown as { HTMLOptionElement: unknown }
).HTMLOptionElement;
(globalThis as Record<string, unknown>).HTMLInputElement = (
win as unknown as { HTMLInputElement: unknown }
).HTMLInputElement;
(globalThis as Record<string, unknown>).HTMLTextAreaElement = (
win as unknown as { HTMLTextAreaElement: unknown }
).HTMLTextAreaElement;
(globalThis as Record<string, unknown>).HTMLSelectElement = (
win as unknown as { HTMLSelectElement: unknown }
).HTMLSelectElement;
(0, eval)(runtime);
if (controllers) (0, eval)(controllers);
// Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
@@ -35,10 +125,22 @@ function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Wind
return win as unknown as Window;
}
const REPLACED_GLOBALS = ["window", "document", "location", "fetch", "MutationObserver"];
const REPLACED_GLOBALS = [
"window",
"document",
"location",
"fetch",
"MutationObserver",
"HTMLOptionElement",
"HTMLInputElement",
"HTMLTextAreaElement",
"HTMLSelectElement",
];
test("optional properties and nullish fallbacks hydrate without crashing", () => {
const win = mount(`<div data-scope="records: null, row: { count: 0 }, items: ['ok']"><b data-text="records?.rows?.length ?? 0"></b><i data-text="row?.count ?? 5"></i><em data-text="items?.[0] ?? 'empty'"></em><u data-text="!records?.rows?.length"></u></div>`);
const win = mount(
`<div data-scope="records: null, row: { count: 0 }, items: ['ok']"><b data-text="records?.rows?.length ?? 0"></b><i data-text="row?.count ?? 5"></i><em data-text="items?.[0] ?? 'empty'"></em><u data-text="!records?.rows?.length"></u></div>`,
);
expect(win.document.querySelector("b")?.textContent).toBe("0");
expect(win.document.querySelector("i")?.textContent).toBe("0");
expect(win.document.querySelector("em")?.textContent).toBe("ok");
@@ -135,6 +237,24 @@ test("@event (data-on-click) mutates a signal and re-renders", () => {
expect(btn.textContent).toBe("2");
});
test("new conditional content preserves nested component field state", async () => {
const definition = Buffer.from(
JSON.stringify([
{
cond: "open",
body: '<div data-scope="label: &quot;Evidence&quot;, name: &quot;evidenceReference&quot;"><label data-text="label">Evidence</label><input name="{name}" data-wrn-bind-0="[&quot;name&quot;,&quot;{name}&quot;]"></div>',
},
]),
).toString("base64");
const win = mount(
`<div data-scope="open: false"><button data-on-click="open = true">Open</button><template data-wrn-if="${definition}"></template><template data-wrn-control-end></template></div>`,
);
win.document.querySelector("button")!.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(win.document.querySelector("label")?.textContent).toBe("Evidence");
expect(win.document.querySelector("input")?.name).toBe("evidenceReference");
});
test("compiled if blocks switch branches after hydration", () => {
const definition = Buffer.from(
JSON.stringify([
@@ -159,11 +279,19 @@ test("compiled if blocks switch branches after hydration", () => {
test("registry controls retain nested markup and work after the registry leaves the document", () => {
const definitions = [
[{ cond: "open", body: '<template data-wrn-if="@registry:1"></template><b>Nested</b><template data-wrn-control-end></template>' }, { cond: null, body: '<i>Closed</i>' }],
[{ cond: null, body: '<b>Nested</b>' }],
[
{
cond: "open",
body: '<template data-wrn-if="@registry:1"></template><b>Nested</b><template data-wrn-control-end></template>',
},
{ cond: null, body: "<i>Closed</i>" },
],
[{ cond: null, body: "<b>Nested</b>" }],
];
const win = mount(`<script type="application/json" data-wrn-controls id="registry">${JSON.stringify(definitions)}</script>` +
'<div data-scope="open: false"><button data-on-click="open = !open">Toggle</button><template data-wrn-if="@registry:0"></template><i>Closed</i><template data-wrn-control-end></template></div>');
const win = mount(
`<script type="application/json" data-wrn-controls id="registry">${JSON.stringify(definitions)}</script>` +
'<div data-scope="open: false"><button data-on-click="open = !open">Toggle</button><template data-wrn-if="@registry:0"></template><i>Closed</i><template data-wrn-control-end></template></div>',
);
win.document.getElementById("registry")!.remove();
win.document.querySelector("button")!.click();
expect(win.document.querySelector("b")?.textContent).toBe("Nested");
@@ -294,6 +422,44 @@ test("nested scopes don't clobber each other (regression)", () => {
expect(b!.textContent).toBe("B10"); // unchanged
});
test("a delayed parent does not replace a nested select's scope API", async () => {
let resolveModule: (module: object) => void = () => {};
const globals = globalThis as Record<string, unknown>;
globals.__wrnSelectParentModule = new Promise<object>((resolve) => {
resolveModule = resolve;
});
try {
const runtime = REACTIVE_RUNTIME.replace(
"promise = import(url)",
"promise = globalThis.__wrnSelectParentModule",
);
const win = mount(
`<div id="parent" data-scope="value: 'parent'" data-wrn-client-module="/select-parent-delayed.mjs"><div id="child" data-scope="value: 'personal'"><select data-wrn-select><option value="personal">Personal</option></select></div></div>`,
runtime,
);
const child = win.document.querySelector("#child") as unknown as {
__wrnexusScopeApi: { get(name: string): unknown };
};
const select = win.document.querySelector("select") as unknown as {
__wrnexusScopeApi: { get(name: string): unknown };
};
expect(select.__wrnexusScopeApi.get("value")).toBe("personal");
resolveModule({});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(
(
win.document.querySelector("#parent") as unknown as {
__wrnexusScopeApi: { get(name: string): unknown };
}
).__wrnexusScopeApi.get("value"),
).toBe("parent");
expect(select.__wrnexusScopeApi).toBe(child.__wrnexusScopeApi);
expect(select.__wrnexusScopeApi.get("value")).toBe("personal");
} finally {
delete globals.__wrnSelectParentModule;
}
});
test("data-text binds an element's textContent to an expression", () => {
const win = mount(
`<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
@@ -786,6 +952,16 @@ test("slot content is hydrated by the parent scope, not the component it lands i
expect(win.document.querySelector("i")?.getAttribute("data-show")).toBe("true");
});
test("forwarded slots keep the original author through wrapper and modal scopes", () => {
const win = mount(
`<div data-scope="label: 'Audit hours', hours: 168"><b id="original">{hours}</b><div data-scope="label: 'wrapper', hours: 1"><div data-scope="label: 'modal', hours: 2"><wrn-slot data-wrn-slot><section><wrn-slot data-wrn-slot><span id="label">{label}</span><input data-wrn-bind-0='["value","{hours}"]' /><button data-on-click="hours++">Change</button></wrn-slot></section></wrn-slot></div></div></div>`,
);
expect(win.document.querySelector("#label")?.textContent).toBe("Audit hours");
expect(win.document.querySelector("input")?.value).toBe("168");
win.document.querySelector("button")!.click();
expect(win.document.querySelector("#original")?.textContent).toBe("169");
});
test("slot event handlers write to the parent scope", () => {
const win = mount(
`<div data-scope="count: 0">` +
@@ -1914,15 +2090,506 @@ test("a @change binding still fires for a bubbling event inside a component", ()
expect(win.document.querySelector("#out")?.textContent).toBe("changed");
});
test("appending keyed notifications does not detach existing rows", () => {
const win = mount(`<div data-scope="rows: [{id: 1, name: 'first'}]"><ul><li data-for="row in rows key row.id">{row.name}</li></ul><button data-on-click="rows = [...rows, {id: 2, name: 'second'}]">add</button></div>`);
const list = win.document.querySelector("ul")!;
const first = list.querySelector("li");
const observer = new win.MutationObserver(() => {});
observer.observe(list, {childList: true});
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(list.querySelectorAll("li").length).toBe(2);
expect(observer.takeRecords().flatMap(record => Array.from(record.removedNodes))).not.toContain(first);
observer.disconnect();
const win = mount(
`<div data-scope="rows: [{id: 1, name: 'first'}]"><ul><li data-for="row in rows key row.id">{row.name}</li></ul><button data-on-click="rows = [...rows, {id: 2, name: 'second'}]">add</button></div>`,
);
const list = win.document.querySelector("ul")!;
const first = list.querySelector("li");
const observer = new win.MutationObserver(() => {});
observer.observe(list, { childList: true });
(win.document.querySelector("button") as unknown as HTMLElement).click();
expect(list.querySelectorAll("li").length).toBe(2);
expect(observer.takeRecords().flatMap((record) => Array.from(record.removedNodes))).not.toContain(
first,
);
observer.disconnect();
});
test("literal server-rendered JSON survives hydration inside component scopes", () => {
const json = '{"ok":true,"data":{"message":"Test event","sample":"{count}"}}';
const win = mount(
`<section data-scope="count: 2"><pre data-wrn-literal><code>${json}</code></pre><b>{count}</b></section>`,
);
expect(win.document.querySelector("pre")?.textContent).toBe(json);
expect(win.document.querySelector("b")?.textContent).toBe("2");
});
test("nested structured prop bindings preserve attachment arrays across slot ownership", async () => {
const files = [{ nodeId: "file", filename: "report.pdf" }];
const encoded = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64");
const win = mount(
`<div data-scope="data: {}" data-wrn-scope="${encoded({ data: { files } })}"><div data-scope="data: {}" data-wrn-scope="${encoded({ data: { files } })}" data-wrn-prop-bind-0='["data","{data}"]'><div data-scope="title: &#39;Card&#39;"><div data-wrn-slot><div data-scope="files: []" data-wrn-scope="${encoded({ files })}" data-wrn-prop-bind-0='["files","{data.files}"]'><b id="files" data-text="JSON.stringify(files)">${JSON.stringify(files)}</b><span data-text="files[0]?.filename">report.pdf</span></div></div></div></div></div>`,
);
await Promise.resolve();
expect(win.document.querySelector("#files")?.textContent).toBe(JSON.stringify(files));
expect(win.document.querySelector("span")?.textContent).toBe("report.pdf");
});
test("dynamic child props receive parent translations and subsequent changes", async () => {
const body =
'<div data-scope="label: &quot;Input&quot;" data-wrn-prop-bind-0="[&quot;label&quot;,&quot;{messages.evidence}&quot;]"><label data-text="label">Input</label></div>';
const definition = Buffer.from(JSON.stringify([{ cond: "open", body }])).toString("base64");
const win = mount(
`<div data-scope='open: false, messages: {evidence: "Evidence"}'><button data-on-click="open = true">Open</button><button data-on-click="messages = {evidence: 'Updated'}">Change</button><template data-wrn-if="${definition}"></template><template data-wrn-control-end></template></div>`,
);
win.document.querySelector("button")!.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(win.document.querySelector("label")?.textContent).toBe("Evidence");
win.document.querySelectorAll("button")[1]!.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(win.document.querySelector("label")?.textContent).toBe("Updated");
});
test("child props wait for an asynchronous module and retain the latest parent values", async () => {
let resolveModule: (module: object) => void = () => {};
const globals = globalThis as Record<string, unknown>;
globals.__wrnDelayedTestModule = new Promise<object>((resolve) => {
resolveModule = resolve;
});
try {
const runtime = REACTIVE_RUNTIME.replace(
"promise = import(url)",
"promise = globalThis.__wrnDelayedTestModule",
);
expect(runtime).not.toBe(REACTIVE_RUNTIME);
const win = mount(
'<div id="parent" data-scope="value: 1"><div id="child" data-scope="value: 0" data-wrn-client-module="/delayed.mjs" data-wrn-prop-bind-0=\'["value","{value}"]\'><b data-text="value">0</b></div></div>',
runtime,
);
const parent = win.document.querySelector("#parent") as any,
child = win.document.querySelector("#child") as any;
parent.__wrnexusScopeApi.set("value", 7);
expect(child.__wrnexusScopeApi).toBeUndefined();
resolveModule({});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(child.__wrnexusScopeApi.get("value")).toBe(7);
expect(child.querySelector("b").textContent).toBe("7");
parent.__wrnexusScopeApi.set("value", 9);
expect(child.querySelector("b").textContent).toBe("9");
} finally {
delete globals.__wrnDelayedTestModule;
}
});
test("child prop bindings preserve inline objects and mixed text", () => {
const win = mount(
'<div id="parent" data-scope="answer: null, first: \'One\', second: \'Two\'"><div id="child" data-scope="value: null, label: \'\'" data-wrn-prop-bind-0=\'["value","{answer||{}}"]\' data-wrn-prop-bind-1=\'["label","{first} of {second}"]\'><b data-text="label"></b></div></div>',
);
const parent = win.document.querySelector("#parent") as any,
child = win.document.querySelector("#child") as any;
expect(child.__wrnexusScopeApi.get("value")).toEqual({});
expect(child.querySelector("b").textContent).toBe("One of Two");
parent.__wrnexusScopeApi.set("answer", { line1: "10 Test Street", country: "India" });
expect(child.__wrnexusScopeApi.get("value")).toEqual({
line1: "10 Test Street",
country: "India",
});
parent.__wrnexusScopeApi.set("first", "Updated");
expect(child.querySelector("b").textContent).toBe("Updated of Two");
});
test("child props retain arrays produced by inline object callbacks", () => {
const win = mount(
'<div id="parent" data-scope="items: [\'a\']"><div id="child" data-scope="options: []" data-wrn-prop-bind-0=\'["options","{items.map(item=>({value:item,label:item}))}"]\'></div></div>',
);
const parent = win.document.querySelector("#parent") as any,
child = win.document.querySelector("#child") as any;
expect(child.__wrnexusScopeApi.get("options")).toEqual([{ value: "a", label: "a" }]);
parent.__wrnexusScopeApi.set("items", ["b", "c"]);
expect(child.__wrnexusScopeApi.get("options")).toEqual([
{ value: "b", label: "b" },
{ value: "c", label: "c" },
]);
});
test("loop hydration preserves structured child prop expressions", () => {
const body =
'<div data-scope="value: {}" data-wrn-prop-bind-0=\'["value","{answers[item.id]||{}}"]\'><b data-text="value.line1"></b></div>';
const definition = Buffer.from(
JSON.stringify({ list: "items", item: "item", body, empty: "" }),
).toString("base64");
const win = mount(
`<div id="parent" data-scope="items: [], answers: {}"><template data-wrn-each="${definition}"></template><template data-wrn-control-end></template></div>`,
);
const parent = win.document.querySelector("#parent") as any;
parent.__wrnexusScopeApi.set("answers", { address: { line1: "10 Test Street" } });
parent.__wrnexusScopeApi.set("items", [{ id: "address" }]);
expect(win.document.querySelector("b")?.textContent).toBe("10 Test Street");
parent.__wrnexusScopeApi.set("answers", { address: { line1: "Updated" } });
expect(win.document.querySelector("b")?.textContent).toBe("Updated");
});
test("new async component controls render pending props on their first hydration", async () => {
const globals = globalThis as Record<string, unknown>;
globals.__wrnPendingRows = Promise.resolve({ bindClientScope: () => ({}) });
try {
const rows = Buffer.from(
JSON.stringify({ list: "fields", item: "field", body: "<b>{field.label}</b>", empty: "" }),
).toString("base64");
const child = `<div class="child" data-scope="fields: []" data-wrn-client-module="/pending.mjs" data-wrn-prop-bind-0='["fields","{fields}"]'><template data-wrn-each="${rows}"></template><template data-wrn-control-end></template></div>`;
const branches = Buffer.from(JSON.stringify([{ cond: "open", body: child }])).toString(
"base64",
);
const runtime = REACTIVE_RUNTIME.replace(
"promise = import(url)",
"promise = globalThis.__wrnPendingRows",
);
const win = mount(
`<div id="parent" data-scope="open: false, fields: [{label:'Section heading'}]"><template data-wrn-if="${branches}"></template><template data-wrn-control-end></template></div>`,
runtime,
);
(win.document.querySelector("#parent") as any).__wrnexusScopeApi.set("open", true);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(win.document.querySelector(".child b")?.textContent).toBe("Section heading");
} finally {
delete globals.__wrnPendingRows;
}
});
test("async nested controls restore structured field props after navigation", async () => {
const globals = globalThis as Record<string, unknown>;
globals.__wrnRestoreModule = Promise.resolve({
bindClientScope: (ctx: any) => ({
currentName: () => ctx.props.value?.name || "",
currentConsent: () => !!ctx.props.value?.consent,
}),
});
try {
const runtime = REACTIVE_RUNTIME.replace(
"promise = import(url)",
"promise = globalThis.__wrnRestoreModule",
);
const input =
'<div class="input" data-scope="value: \'\'" data-wrn-client-module="/input.mjs" data-wrn-prop-bind-0=\'["value","{currentName()}"]\'><input data-wrn-bind-0=\'["value","{value}"]\' /></div>';
const signature = `<div class="signature" data-scope="value: {}" data-wrn-client-module="/signature.mjs" data-wrn-prop-bind-0='["value","{answers[field.id] || {}}"]'>${input}</div>`;
const fields = Buffer.from(
JSON.stringify({ list: "fields", item: "field", body: signature, empty: "" }),
).toString("base64");
const branch = Buffer.from(
JSON.stringify([
{
cond: "open",
body: `<template data-wrn-each="${fields}"></template><template data-wrn-control-end></template>`,
},
]),
).toString("base64");
const win = mount(
`<div id="parent" data-scope="fields: [], answers: {}, open: false"><template data-wrn-if="${branch}"></template><template data-wrn-control-end></template></div>`,
runtime,
);
const parent = win.document.querySelector("#parent") as any;
parent.__wrnexusScopeApi.set("answers", {
signature: { name: "Browser Signer", consent: true },
});
parent.__wrnexusScopeApi.set("fields", [{ id: "signature" }]);
parent.__wrnexusScopeApi.set("open", true);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(win.document.querySelector("input")?.value).toBe("Browser Signer");
parent.__wrnexusScopeApi.set("open", false);
await new Promise((resolve) => setTimeout(resolve, 10));
parent.__wrnexusScopeApi.set("open", true);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(win.document.querySelector("input")?.value).toBe("Browser Signer");
} finally {
delete globals.__wrnRestoreModule;
}
});
test("nested page conditions restore structured field props after navigation", () => {
const signature =
'<div class="signature" data-scope="field: {}, value: {}" data-wrn-prop-bind-0=\'["field","{field}"]\' data-wrn-prop-bind-1=\'["value","{answers[field.id]||{}}"]\'><b data-text="value.name"></b></div>';
const fields = Buffer.from(
JSON.stringify({ list: "page.fields", item: "field", body: signature, empty: "" }),
).toString("base64");
const branch = Buffer.from(
JSON.stringify([
{
cond: "index === pageIndex",
body: `<template data-wrn-each="${fields}"></template><template data-wrn-control-end></template>`,
},
]),
).toString("base64");
const pages = Buffer.from(
JSON.stringify({
list: "pages",
item: "page",
index: "index",
body: `<template data-wrn-if="${branch}"></template><template data-wrn-control-end></template>`,
empty: "",
}),
).toString("base64");
const win = mount(
`<div id="parent" data-scope="pages: [], answers: {}, pageIndex: 0"><template data-wrn-each="${pages}"></template><template data-wrn-control-end></template></div>`,
);
const parent = win.document.querySelector("#parent") as any;
parent.__wrnexusScopeApi.set("answers", { signature: { name: "Browser Signer", consent: true } });
parent.__wrnexusScopeApi.set("pages", [
{ id: "first", fields: [{ id: "signature" }] },
{ id: "last", fields: [] },
]);
expect(win.document.querySelector(".signature b")?.textContent).toBe("Browser Signer");
parent.__wrnexusScopeApi.set("pageIndex", 1);
expect(win.document.querySelector(".signature")).toBeNull();
parent.__wrnexusScopeApi.set("pageIndex", 0);
expect(win.document.querySelector(".signature b")?.textContent).toBe("Browser Signer");
});
test("compiled helpers react to prop changes, including controlled multiple selections", async () => {
const globals = globalThis as Record<string, unknown>;
globals.__wrnCompiledPropsModule = Promise.resolve({
bindClientScope: (ctx: any) => ({
selectedA: () => ctx.props.values.includes("a"),
selectedB: () => ctx.props.values.includes("b"),
}),
});
try {
const runtime = REACTIVE_RUNTIME.replace(
"promise = import(url)",
"promise = globalThis.__wrnCompiledPropsModule",
);
const win = mount(
'<div id="parent" data-scope="values: [\'a\']"><div id="child" data-scope="values: []" data-wrn-client-module="/props.mjs" data-wrn-prop-bind-0=\'["values","{values}"]\'><select multiple><option value="a" data-wrn-bind-0=\'["selected","{selectedA()}"]\'>A</option><option value="b" data-wrn-bind-0=\'["selected","{selectedB()}"]\'>B</option></select></div></div>',
runtime,
);
await new Promise((resolve) => setTimeout(resolve, 20));
const parent = win.document.querySelector("#parent") as any;
const values = () =>
Array.from(win.document.querySelector("select")!.selectedOptions).map(
(option) => option.value,
);
expect(values()).toEqual(["a"]);
parent.__wrnexusScopeApi.set("values", ["b"]);
expect(values()).toEqual(["b"]);
parent.__wrnexusScopeApi.set("values", ["a", "b"]);
expect(values()).toEqual(["a", "b"]);
} finally {
delete globals.__wrnCompiledPropsModule;
}
});
test("client control rows expose loop locals for child component bindings", () => {
runWithRequestContext({} as never, () => {
const rows = encodeControlTemplate({
list: "items",
item: "item",
body: '<div class="row"><span data-text="item.id"></span></div>',
empty: "",
});
const win = mount(
renderControlTemplates() +
`<div data-scope="items: []"><button data-on-click="items = [{ id: 'new-node' }]">Add</button><template data-wrn-each="${rows}"></template><template data-wrn-control-end></template></div>`,
);
win.document.querySelector("button")!.click();
const row = win.document.querySelector(".row")!;
expect(row.textContent).toBe("new-node");
expect(JSON.parse(win.atob(row.getAttribute("data-wrn-loop-locals")!)).item.id).toBe(
"new-node",
);
});
});
test("components inserted by a conditional bind outputs in their parent scope", () => {
const child =
'<div data-scope="count: 0" data-wrn-hydration="Control:test"><div data-wrn-events="confirm" data-wrn-out-confirm="saved = payload.value"><button data-on-click="output.confirm({value: 7})">Confirm</button></div></div>';
const definition = Buffer.from(JSON.stringify([{ cond: "open", body: child }])).toString(
"base64",
);
const win = mount(
`<div data-scope="open: false, saved: 0"><button id="toggle" data-on-click="open = !open">Toggle</button><span id="saved">{saved}</span><template data-wrn-if="${definition}"></template><template data-wrn-control-end></template></div>`,
);
win.document.querySelector<HappyButtonElement>("#toggle")!.click();
win.document.querySelectorAll("button")[1]!.click();
expect(win.document.querySelector("#saved")!.textContent).toBe("7");
win.document.querySelector<HappyButtonElement>("#toggle")!.click();
win.document.querySelector<HappyButtonElement>("#toggle")!.click();
const target = win.document.querySelector("[data-wrn-events]") as any;
expect(target.__wrnexusOutputHandlers.confirm.size).toBe(1);
});
test("forwarded component events use their binding node's nested loop locals", () => {
const outer = Buffer.from(JSON.stringify({ group: "outer" })).toString("base64");
const inner = Buffer.from(JSON.stringify({ group: "outer", row: { id: "nested-row" } })).toString(
"base64",
);
const win = mount(
`<div data-scope="saved: ''"><span id="saved" data-text="saved"></span><div data-scope="unused: 0" data-wrn-loop-locals="${outer}"><span data-wrn-events="click,confirm"><button data-wrn-events="click,confirm" data-wrn-loop-locals="${inner}" data-wrn-out-click="saved = row.id" data-wrn-out-confirm="saved = row.id">Save</button></span></div></div>`,
);
const button = win.document.querySelector("button") as any;
button.click();
expect(win.document.querySelector("#saved")?.textContent).toBe("nested-row");
const parent = win.document.querySelector("[data-text]")?.parentElement as any;
parent.__wrnexusScopeApi.set("saved", "");
for (const handler of button.__wrnexusOutputHandlers.confirm) handler({});
expect(win.document.querySelector("#saved")?.textContent).toBe("nested-row");
});
test("forwarded events created during async child hydration bind in the parent", async () => {
const globals = globalThis as Record<string, unknown>;
globals.__wrnAsyncControlModule = Promise.resolve({});
try {
const body = '<button data-wrn-events="click" data-wrn-out-click="saved = 7">Save</button>';
const definition = Buffer.from(JSON.stringify([{ cond: "active", body }])).toString("base64");
const runtime = REACTIVE_RUNTIME.replace(
"promise = import(url)",
"promise = globalThis.__wrnAsyncControlModule",
);
const win = mount(
`<div data-scope="saved: 0"><b data-text="saved"></b><div data-scope="active: true" data-wrn-client-module="/async-control.mjs"><template data-wrn-if="${definition}" data-wrn-control-client></template><template data-wrn-control-end></template></div></div>`,
runtime,
);
await new Promise((resolve) => setTimeout(resolve, 20));
win.document.querySelector("button")!.click();
expect(win.document.querySelector("b")?.textContent).toBe("7");
} finally {
delete globals.__wrnAsyncControlModule;
}
});
test("an unhandled nested output cannot overwrite a same-named parent output", () => {
const win = mount(
'<div data-scope="saved: 0"><b data-text="saved"></b><div data-scope="unused: 0"><div data-wrn-events="change" data-wrn-out-change="saved = payload.value"><button id="own" data-on-click="output.change({value: 7})">Own</button><div data-scope="unused: 0"><div data-wrn-events="change"><button id="nested" data-on-click="output.change({value: 99})">Nested</button></div></div></div></div></div>',
);
win.document.querySelector<HappyButtonElement>("#own")!.click();
expect(win.document.querySelector("b")?.textContent).toBe("7");
win.document.querySelector<HappyButtonElement>("#nested")!.click();
expect(win.document.querySelector("b")?.textContent).toBe("7");
});
test("async repeated field components render nested conditional rows after dynamic insertion", async () => {
const globals = globalThis as Record<string, unknown>;
globals.__wrnNestedModules = {
"/nested-fields.mjs": Promise.resolve({
bindClientScope: () => ({ visible: (field: any) => !!field }),
}),
"/nested-repeat.mjs": Promise.resolve({
bindClientScope: (ctx: any) => ({
currentRows: () => ctx.state.rows || [],
add: () => {
ctx.state.rows = [{ id: 0, values: {} }];
},
}),
}),
};
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64");
try {
const innerIf = encode([
{ cond: "visible(field)", body: '<label class="entry-label">{field.label}</label>' },
]);
const innerRows = encode({
list: "fields",
item: "field",
body: `<template data-wrn-if="${innerIf}"></template><template data-wrn-control-end></template>`,
empty: "",
});
const inner = `<div class="entry-fields" data-scope="fields: []" data-wrn-client-module="/nested-fields.mjs" data-wrn-prop-bind-0='["fields","{field.fields}"]'><template data-wrn-each="${innerRows}"></template><template data-wrn-control-end></template></div>`;
const entries = encode({ list: "currentRows()", item: "row", body: inner, empty: "" });
const repeat = `<div class="repeat" data-scope="field: {}, rows: null" data-wrn-client-module="/nested-repeat.mjs" data-wrn-prop-bind-0='["field","{field}"]'><template data-wrn-each="${entries}"></template><template data-wrn-control-end></template><button class="add-entry" data-on-click="add()">Add</button></div>`;
const fields = encode({ list: "fields", item: "field", body: repeat, empty: "" });
const root = `<div data-scope="fields: []" data-wrn-client-module="/nested-fields.mjs" data-wrn-prop-bind-0='["fields","{fields}"]'><template data-wrn-each="${fields}"></template><template data-wrn-control-end></template></div>`;
const branch = encode([{ cond: "open", body: root }]);
const runtime = REACTIVE_RUNTIME.replace(
"promise = import(url)",
"promise = globalThis.__wrnNestedModules[url]",
);
const win = mount(
`<div id="parent" data-scope="open: false, fields: []"><template data-wrn-if="${branch}"></template><template data-wrn-control-end></template></div>`,
runtime,
);
const parent = win.document.querySelector("#parent") as any;
parent.__wrnexusScopeApi.set("fields", [
{
id: "group",
fields: [
{ id: "age", label: "Group age" },
{ id: "name", label: "Group member" },
],
},
]);
parent.__wrnexusScopeApi.set("open", true);
await new Promise((resolve) => setTimeout(resolve, 50));
win.document.querySelector<HappyButtonElement>(".add-entry")!.click();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(
Array.from(win.document.querySelectorAll(".entry-label")).map((label) => label.textContent),
).toEqual(["Group age", "Group member"]);
} finally {
delete globals.__wrnNestedModules;
}
});
test("request-registry behavior references hydrate isolated component state", () => {
const behavior = {
functions: "function increment(){count++}",
outputs: [],
computed: [],
effects: [],
lifecycle: {},
watches: [],
};
const markup =
'<div data-scope="count: 0" data-wrn-behavior="@behaviors:0"><button data-on-click="increment()">Increment</button><b data-text="count"></b></div>';
const win = mount(
'<script type="application/json" data-wrn-controls id="behaviors">' +
JSON.stringify([behavior]) +
"</script>" +
markup +
markup,
);
const buttons = win.document.querySelectorAll("button");
buttons[0]!.click();
expect([...win.document.querySelectorAll("b")].map((node) => node.textContent)).toEqual([
"1",
"0",
]);
buttons[1]!.click();
buttons[1]!.click();
expect([...win.document.querySelectorAll("b")].map((node) => node.textContent)).toEqual([
"1",
"2",
]);
});
test("split controllers observe drawers opened after initial hydration", async () => {
const win = new Window({ settings: { enableJavaScriptEvaluation: true } });
win.document.body.innerHTML = `<div data-scope="open: false"><button id="opener" data-on-click="open=true">Open</button><div data-wrn-dialog data-open="false" data-wrn-bind-0="[&quot;data-open&quot;,&quot;{open ? 'true' : 'false'}&quot;]"><section role="dialog" aria-modal="true"><button id="closer" data-on-click="open=false">Close</button></section></div></div>`;
win.eval(getReactiveRuntime(true));
win.eval(getComponentControllerRuntime(true));
(win as unknown as { __wrnexusHydrateScopes: (node: unknown) => void }).__wrnexusHydrateScopes(
win.document,
);
(win.document.querySelector("#opener") as HappyButtonElement).focus();
(win.document.querySelector("#opener") as HappyButtonElement).click();
for (let attempt = 0; attempt < 30 && win.document.activeElement?.id !== "closer"; attempt++)
await new Promise((resolve) => setTimeout(resolve, 10));
expect(win.document.activeElement?.id).toBe("closer");
expect(win.document.body.style.overflow).toBe("hidden");
(win.document.querySelector("#closer") as HappyButtonElement).click();
for (let attempt = 0; attempt < 30 && win.document.activeElement?.id !== "opener"; attempt++)
await new Promise((resolve) => setTimeout(resolve, 10));
expect(win.document.querySelector("[data-wrn-dialog]")?.getAttribute("data-open")).toBe("false");
expect(win.document.activeElement?.id).toBe("opener");
expect(win.document.body.style.overflow).toBe("");
expect(getReactiveRuntime()).toContain(
"controllerUrl.search = new URL(reactiveScript.src).search",
);
});
test("shared scope and loop metadata preserves per-instance state and row values", () => {
const values = [{ label: "start", count: 0 }, { entry: { label: "Selected row" } }];
const node =
'<div data-scope="" data-wrn-scope="@metadata:0"><button class="choose" data-wrn-loop-locals="@metadata:1" data-on-click="label = entry.label">Choose</button><button class="add" data-on-click="count++">Add</button><span data-text="label"></span><b data-text="count"></b></div>';
const win = mount(
'<script type="application/json" data-wrn-controls id="metadata">' +
JSON.stringify(values) +
"</script>" +
node +
node,
);
(win.document.querySelector(".choose") as HappyButtonElement).click();
(win.document.querySelector(".add") as HappyButtonElement).click();
expect([...win.document.querySelectorAll("span")].map((el) => el.textContent)).toEqual([
"Selected row",
"start",
]);
expect([...win.document.querySelectorAll("b")].map((el) => el.textContent)).toEqual(["1", "0"]);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/db",
"version": "0.8.23",
"version": "0.8.24",
"private": true,
"type": "module",
"main": "./src/index.ts",
+14 -4
View File
@@ -51,10 +51,20 @@ function runnerFor(client: BunSqlClient, dialect: Dialect): TxHandle {
export function bunSql(url: string, dialect: Dialect): Driver {
const max = Number(process.env.WRN_DB_POOL_MAX || 10);
const idleTimeout = Number(process.env.WRN_DB_IDLE_TIMEOUT || 30);
if (!Number.isInteger(max) || max < 1 || max > 100 || !Number.isInteger(idleTimeout) || idleTimeout < 0)
throw new Error('Invalid WRN_DB_POOL_MAX or WRN_DB_IDLE_TIMEOUT');
const Ctor = (Bun as unknown as { SQL: new (u: {url:string;max:number;idleTimeout:number}) => BunSqlClient }).SQL;
const client = new Ctor({url,max,idleTimeout});
if (
!Number.isInteger(max) ||
max < 1 ||
max > 100 ||
!Number.isInteger(idleTimeout) ||
idleTimeout < 0
)
throw new Error("Invalid WRN_DB_POOL_MAX or WRN_DB_IDLE_TIMEOUT");
const Ctor = (
Bun as unknown as {
SQL: new (u: { url: string; max: number; idleTimeout: number }) => BunSqlClient;
}
).SQL;
const client = new Ctor({ url, max, idleTimeout });
const runner = runnerFor(client, dialect);
return {
dialect,
+78
View File
@@ -0,0 +1,78 @@
# Design Studio
A local editor for `@wrnexus/design-system`. The authoring package adds an authoring surface; applications continue to use the existing theme adapter and components. Neither Studio code nor AI credentials are needed by an application.
## Start
From the repository root:
```sh
bun run design:studio
bun run design:studio --app-root path/to/existing/application
```
Open the printed loopback URL (default port 4320; override `DESIGN_STUDIO_PORT`). Without `--app-root`, the Studio supports editing, previews and portable exports. The second form enables local revision bundles inside the specified application's `.wrnexus/design-studio` directory. It does not overwrite application configuration, publish or deploy.
## Authoring workflow
1. Create a blank design, select one of the three complete presets, duplicate the current design, or inspect/import local JSON.
2. Edit named foundations in the base, mode or density layer. Token references use `@token.name`; legacy suffixes bind values to existing `--wrn-*` variables. Save a token explicitly or commit by leaving its input. Search includes names, types and bindings. Resolved values identify their actual source, including mode overrides.
3. Inspect any of the 104 contracts and its ownership limits. Edit allowed default part properties, or use the component recipe JSON editor for variants, compound conditions and states. The canonical source editor supports every engine-supported field.
4. Compare the current draft with a preset or the last applied design. Preview a composed page or seven gallery pages. Choose mode, density, viewport width and LTR/RTL. Frame widths scroll independently of the authoring workspace.
5. Review before/after values and affected paths. Export the validated design or apply a local revision bundle. Rollback changes the application's current pointer and preserves the working draft.
Raw canonical edits autosave after a short pause. Invalid JSON, invalid aliases and unsupported recipes retain the last valid preview. Undo/redo and local history preserve invalid revisions as well as valid ones. History keeps up to 25 revisions. Storage corruption/quota failures show a message and preserve in-memory work; export the raw draft if autosave is unavailable. Local storage is not collaborative storage or a backup. Multiple browser tabs do not merge drafts.
## Imports and exports
Canonical JSON is the lossless format: all tokens, modes, densities, recipes and supported metadata round-trip. Documents requiring an external inheritance registry must be resolved with that registry before importing; the Studio reports missing parents rather than inventing them.
Legacy import accepts JSON shaped like `{ "default": "light", "themes": { "light": { "color-primary": "#112233" } } }`. Known semantic bindings are typed using the engine preset vocabulary. Unsupported fields and unknown suffixes are listed. This importer never evaluates a JavaScript/TypeScript configuration or guesses a dynamic palette.
The exchange subset accepts grouped or flat scalar tokens with `$type` and `$value`, group type inheritance, `$description`, and `{token.path}` aliases. Supported types are color strings, dimension strings, numbers, font-family strings, font weights and duration strings. Structured color objects, composite typography/shadows/dimensions, extensions and assets are unsupported. Tokens without WrNexus bindings are reported as ambiguous; map their legacy suffix in Foundations before expecting component changes. This is not a full DTCG implementation. Review the preservation/ambiguity/unsupported lists before accepting an import.
Exports include canonical JSON, standalone compiled CSS, theme configuration, resolved flat exchange tokens and a compatibility report. Exchange exports intentionally flatten modes/aliases to the selected resolved values and do not contain component recipes. Canonical JSON remains the editable source of truth. Invalid drafts can only be exported as raw drafts.
## Application revisions
Apply validates the full document and selected mode/density again on the server. The server only writes to the root configured at startup; the browser cannot select arbitrary filesystem paths. Each immutable revision includes `system.json`, `system.css`, `theme.json`, `tokens.json`, `report.json` and integration guidance. `CURRENT.json` selects the active revision. Applying from a stale base revision fails with `WRN-STUDIO-CONFLICT`; refresh Review to inspect the new target state. Rollback swaps the pointer with the previous revision, including returning a first apply to `none`.
To integrate a reviewed revision into an existing app, import its generated `theme.json` and assign it to the existing config's `theme` field, retaining any explicit app overrides:
```ts
import theme from "./.wrnexus/design-studio/revisions/<revision>/theme.json";
// In your existing WrNexus configuration:
// theme: { ...theme, themes: existingThemeOverrides }
```
The generated theme configuration uses the existing `designSystem` adapter, preserving instance props and explicit application token overrides. Alternatively use `system.css` as a standalone stylesheet. Do not load both paths unless you deliberately manage precedence. Existing app configuration is never automatically rewritten. Applications pin the imported revision; changing the Studio pointer alone does not rebuild or deploy them. `/application-preview` renders the selected on-disk revision through the normal theme adapter for local verification.
## Isolation and limits
The shell uses a separate browser-safe model entry. Filesystem/compiler/SSR code exists only under the server entry. The server binds loopback, rejects non-local hosts/cross-origin requests and bounds JSON inputs. Preview documents contain only trusted shipped component markup and validated styling. Frames permit scripts for component behavior, omit same-origin privileges, block network/forms/fonts, and retain provider/native/content ownership boundaries. The preview cache retains 24 generated documents; Refresh regenerates an expired one.
The component gallery is a baseline of default states, not full provider or accessibility certification. The composed preview exercises form controls, InputNumber and dialogs. Existing DatePicker calendar interaction emitted a client expression diagnostic during browser exploration; it remains available in the default-state gallery and its component source/API is unchanged. External provider rendering, all populated interaction states, extra browsers, collaborative storage, agent mutations, package publication and deployment remain separate scopes.
Run `bun test packages/design-studio/test` for model, import/export, application and preview checks. See the Phase 4 implementation report in `docs/plans` for broader regression and browser verification.
## Guided generation and locked decisions
Open **Guided design** for a progressive brief, three previewable alternatives, foundation mixing and word-based edits. Proposals are staged until reviewed and accepted into normal draft history. The default is local rules, with a documented phrase vocabulary; unsupported requests leave the draft unchanged. Local prose fields are context, not interpreted instructions. Canonical exports continue to use the existing engine schema.
Use foundation-group locks or JSON pointers such as `/tokens/shape.control` or `/recipes/Button`. Locks protect raw decisions and referenced effective token values across modes/densities, including aliases. Lock-violating raw edits remain recoverable; unlock explicitly before accepting them. Lock preferences persist independently of undo/redo. Generated proposals bind to the source and locks and become stale when either changes.
For optional LLM interpretation, configure `ANTHROPIC_API_KEY` on the server and run `bun run design:studio --ai-model <explicit-provider-model-id>`. Or inject an existing AI provider into `createStudioHandler({ generationProvider, generationModel })`. The browser receives provider/model metadata, never credentials. Only a generation action sends the brief, design and capabilities; ordinary editing/previews require no provider.
Provider proposals are bounded data-only patches, validated against the engine and contracts, with at most one repair and a 20-second deadline. Failures/cancellation preserve the draft and previous candidates. The brief, alternatives and provenance autosave separately and can be exported as a generation report. See the [Phase 5 report](../../docs/plans/2026-10-19-design-system-phase-5-implementation.md) for exact command semantics, fidelity, budgets, recovery and live-provider verification limits.
## Agent operations (Phase 6)
The existing MCP server now has opt-in design tools for recoverable drafts, locks, token patches, page specifications, context, conformance, preview, diff, export, apply and reversion. Start the local workspace server with `bun packages/mcp/src/stdio.ts --root=APPLICATION_ROOT --design-write`; `--design` exposes read operations only. Existing default tools and APIs are unchanged.
See the [Phase 6 operation and recovery guide](../../docs/plans/2026-10-19-design-system-phase-6-implementation.md) for revision requirements, tool schemas, examples and limits. Explicit server subpaths are `./agents`, `./agent-context`, `./agent-pages` and `./conformance`; filesystem/compiler functionality stays outside the browser root export.
Run `bun run scripts/evaluate-design-agents.ts` for the six-page/twelve-preview deterministic evaluation. No model credentials are needed. Design bundles and exported `.wrn` pages require explicit app integration. No routes or deployments are modified. Standalone staging and installed-consumer verification are available; see [release and upgrade guidance](RELEASE.md).
## Installed packages and upgrades
The staged package ships an installed `wrnexus-design-studio` binary, a prebuilt browser client and explicit server/agent subpaths. MCP consumes Studio as an optional peer. See [RELEASE.md](RELEASE.md) for installed usage, verified shared design bundles, upgrade planning, migrations, recovery and the release gate. The new package/upgrade APIs are programmatic; existing Studio authoring controls and APIs remain available.
+81
View File
@@ -0,0 +1,81 @@
# Design release and upgrade guide
These packages support Bun 1.3 or newer. The design engine and Studio root/upgrade APIs are browser safe. Studio's server, CLI and agent service are local authoring tools; an application only needs the engine, theme adapter and UI components.
## Installed use
Install `@wrnexus/design-system` for the data engine. Add `@wrnexus/design-studio` for authoring; the installed `wrnexus-design-studio --app-root EXISTING_APP` binary binds to loopback port 4320 (`DESIGN_STUDIO_PORT` overrides it). Without `--app-root`, only exports are enabled. No AI key is needed. Existing `createStudioHandler` programmatic integration remains available from `@wrnexus/design-studio/server`.
Install `@wrnexus/mcp` for the existing eight framework tools. Studio is an **optional peer**, so a normal MCP-only installation does not install or load Studio. Install compatible Studio explicitly and use `wrnexus-mcp --root=EXISTING_APP --design` for inspection or `--design-write` for draft and bundle mutations. Missing Studio produces `WRN-MCP-DESIGN-INSTALL` when a design operation is invoked. The default server still exposes eight tools.
Studio packages ship compiled JS/declarations, component metadata through declared dependencies, and a prebuilt browser client. Published packages do not require a sibling source checkout. Application configuration and routes are never rewritten implicitly.
## Share a pinned design
```ts
import { createDesignPackage, readDesignPackage } from "@wrnexus/design-system/package";
// Run after validating/reviewing your self-contained canonical design:
const artifact = await createDesignPackage(system, "Reviewed brand release");
const source = JSON.stringify(artifact);
// Store this JSON with your application or distribute it in your own versioned package.
const verified = await readDesignPackage(source);
const theme = { designSystem: { system: verified.package.system } };
```
Assign that theme through the existing app configuration. Alternatively use `compileDesignSystem` to generate standalone CSS; avoid loading both integrations. Explicit app tokens and instance props retain precedence.
The envelope has format version 1, contract version 1, the canonical schema-v1 design and a deterministic SHA-256 content digest. Object key order does not change the pin. The digest detects changed content; it is not a publisher signature. Use a trusted distribution source and pin the artifact together with engine/UI package versions and the app lockfile. Version-1 contracts do not replace visual regression review after dependency updates.
Bundles are self-contained: unresolved external inheritance is rejected. Resolve and flatten your inherited specification before sharing it. Names and types remain canonical; there is no automatic token renaming. Package source is capped at 2 MB and release notes at 2,000 characters.
## Review an upgrade
```ts
import { planDesignUpgrade, applyDesignUpgrade } from "@wrnexus/design-studio/upgrades";
const plan = await planDesignUpgrade(session, incomingJson);
// Review plan.changes, diagnostics, migrations and the matching page previews.
if (plan.canApply) await applyDesignUpgrade(session, incomingJson, plan.baseRevision);
// Existing session.undo()/redo() and autosave still apply.
```
Planning does not mutate the session. Adoption rereads and verifies the package, checks system identity and the exact current design/lock revision, then uses the existing history mechanism. Invalid source must be resolved first. Locked decisions and their effective alias/recipe dependencies remain protected. Another design id requires an explicit import. Version numbers do not trigger automatic downloads or updates: a supported version with the same id may be adopted explicitly after review, including a deliberate rollback.
MCP provides `wrnexus_design_package` to export a verified package, `wrnexus_design_upgrade_plan` to review incoming `source`, and `wrnexus_design_upgrade` with `source` plus the latest `baseRevision` to adopt it. Existing draft/application revisions remain separate. Application changes still require `baseApplicationRevision`; exporting or upgrading a draft never deploys it.
## Migration and recovery
- Existing canonical schema-v1 JSON can be wrapped through `readDesignPackage`; it reports `canonical-v1-to-package-v1`. Existing canonical/legacy/token imports continue to use the existing explicit import APIs.
- Old Studio browser drafts and agent state without a storage version remain readable. Successful subsequent saves add storage version 1, retaining source history, invalid raw edits, locks and saved pages.
- Unknown package formats or contract versions fail closed. Unknown/faulty browser storage is kept byte-for-byte: `session.recoverySource` exposes the saved bytes and automatic writes stay blocked until storage is deliberately reset after backup. Keep the original browser storage key `wrnexus.design-studio.v1`; do not clear it before exporting recovery data. If you are using the UI, back up that local-storage value before resetting it; new package/upgrade APIs are programmatic, not additional Studio controls.
- Unknown agent-state versions also fail closed; `STATE.json` is not rewritten. Back up `.wrnexus/design-studio` before changing authoring versions. Older software cannot promise to read newer formats; retain a supported tool version alongside the backup.
- Agent state lives under the configured app's `.wrnexus/design-studio/agent`; browser local storage remains separate. Application revisions are immutable bundles selected by `CURRENT.json`. Existing application pointers and bundles need no migration.
- On a crash-held `LOCK` or `APPLY.lock`, stop all writers and back up the managed directory before removing the empty lock directory. Locks are never stolen automatically. Do not edit saved state with active writers.
Upgrading Studio does not apply a theme, alter app configuration or rewrite route files. Exported pages and generated theme bundles still require explicit application integration. Preview execution remains limited to trusted shipped components; submitted arbitrary source is only statically checked.
## Local release workflow (maintainers)
From the framework repository:
```sh
bun run design:release:plan
bun run design:release:stage
bun run design:release:check
# Full repository production checks followed by installed-consumer checks:
bun run design:release:gate
```
Staging builds the complete declared local dependency closure under `.publish-design`, preserving the standard `.publish` staging area. It validates assets/declarations, rewrites workspace ranges and preserves optional-peer metadata. Checks create real npm tarballs and install them into temporary consumers with lifecycle scripts disabled. They exercise two independent apps, shared pins, upgrades/recovery, browser-safe exports, prebuilt Studio assets and CLI, real component previews, installed types, MCP stdio and a separate MCP-only installation.
These commands never authenticate, bump source versions, publish or deploy. Output reports and tarballs are under `output/design-system-phase-7`. A successful packaging report is **not** a publication approval or a passing repository-wide production gate. Before publishing, confirm unused package versions against the target registry, run the existing version/release process intentionally, restage, rerun all production and consumer gates, and review the package integrity manifest. The default staged access remains restricted. Do not automatically switch package access or publish dependencies to a public registry.
## Hosted export-only demo
`createStudioHandler({ hosted: { basePath: "/design-studio" } })` enables an
explicit same-origin hosted demo at that path. Forward the complete path and
Host through your reverse proxy. Hosted mode rejects applicationRoot and paid
provider configuration: it generates local alternatives and ephemeral previews,
with browser-local recoverable drafts and exports, without writing server files.
The default local handler and CLI remain loopback-only with their existing APIs.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@wrnexus/design-studio",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"description": "Local design system authoring, guided proposals and review.",
"exports": {
".": "./src/index.ts",
"./server": "./src/server.ts",
"./agents": "./src/agent-server.ts",
"./conformance": "./src/conformance.ts",
"./agent-context": "./src/agent-context.ts",
"./agent-pages": "./src/agent-pages.ts",
"./upgrades": "./src/upgrades.ts"
},
"dependencies": {
"@wrnexus/ai": "workspace:*",
"@wrnexus/design-system": "workspace:*",
"@wrnexus/compiler": "workspace:*",
"@wrnexus/core": "workspace:*",
"@wrnexus/csr": "workspace:*",
"@wrnexus/ssr": "workspace:*",
"@wrnexus/styles": "workspace:*",
"@wrnexus/ui": "workspace:*"
},
"bin": {
"wrnexus-design-studio": "src/cli.ts"
},
"files": [
"assets",
"RELEASE.md"
]
}
@@ -0,0 +1,82 @@
import {
COMPONENT_CONTRACTS,
resolveDesignSystem,
validateDesignSystem,
type DesignSystem,
type ResolveOptions,
} from "@wrnexus/design-system";
import reference from "@wrnexus/ui/component-reference.json";
export const AGENT_CONTEXT_VERSION = 1;
export function agentGenerationContext(
system: DesignSystem,
options: ResolveOptions & { components?: string[]; locks?: string[] } = {},
) {
const diagnostics = validateDesignSystem(system, options);
if (diagnostics.length) throw Error("WRN-DESIGN-CONTEXT: source system must validate.");
const names = options.components ?? [
"Button",
"Input",
"Textarea",
"Select",
"Card",
"Alert",
"PageHeader",
"Container",
];
if (
!Array.isArray(names) ||
names.length > 12 ||
names.some((name) => !Object.hasOwn(COMPONENT_CONTRACTS, name))
)
throw Error("WRN-DESIGN-CONTEXT: select up to 12 known components.");
const resolved = resolveDesignSystem(system, options);
return {
contextVersion: AGENT_CONTEXT_VERSION,
system: {
id: system.id,
version: system.version,
mode: resolved.mode,
density: resolved.density,
},
locks: options.locks ?? [],
tokens: resolved.tokens,
components: Object.fromEntries(
names.map((name) => {
const contract = COMPONENT_CONTRACTS[name as keyof typeof COMPONENT_CONTRACTS];
const meta = reference.components.find((c) => c.name === name)!;
return [
name,
{
contractVersion: contract.contractVersion,
axes: contract.axes,
parts: contract.parts,
props: meta.props,
outputs: meta.outputs,
slots: contract.slots,
constraints: contract.constraints,
support: contract.support,
},
];
}),
),
composition: {
container: "Container",
spacingToken: "space.panel",
legacySpacing: "--wrn-space-panel",
rules: [
"Use declared component props and variants. Preserve instance APIs and explicit choices.",
"Use semantic token references for styling. Do not invent variants or provider capabilities.",
"Custom/ejected application components need separate contracts.",
"Page-spec previews contain trusted shipped components only; arbitrary source is analyzed, never executed.",
],
},
examples: [
'<Button label={"Continue"} variant={"default"} />',
"color: var(--wrn-color-text); gap: var(--wrn-space-panel);",
],
limitations: [
"Static conformance does not certify accessibility, contrast, dynamic expressions or visual quality.",
"Native/provider/content-owned regions retain their contract limits.",
],
};
}
+139
View File
@@ -0,0 +1,139 @@
import reference from "@wrnexus/ui/component-reference.json";
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
import { checkDesignConformance } from "./conformance.ts";
export interface AgentPage {
id: string;
title: string;
components: Array<{ component: string; props?: Record<string, unknown> }>;
}
function jsonLiteral(value: unknown, depth = 0): boolean {
if (depth > 16) return false;
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (Array.isArray(value))
return value.length <= 1000 && value.every((item) => jsonLiteral(item, depth + 1));
return (
!!value &&
typeof value === "object" &&
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
Object.entries(value).every(
([key, item]) =>
!["__proto__", "constructor", "prototype"].includes(key) && jsonLiteral(item, depth + 1),
)
);
}
export function validateAgentPage(input: unknown): asserts input is AgentPage {
if (!input || typeof input !== "object") throw Error("WRN-DESIGN-PAGE: expected a page object.");
const page = input as AgentPage;
if (
typeof page.id !== "string" ||
!/^[a-z][a-z0-9-]{0,63}$/.test(page.id) ||
typeof page.title !== "string" ||
!page.title.trim() ||
page.title.length > 200 ||
!Array.isArray(page.components) ||
page.components.length < 1 ||
page.components.length > 12
)
throw Error("WRN-DESIGN-PAGE: valid id, title and 1–12 components required.");
for (const entry of page.components) {
if (
!entry ||
typeof entry.component !== "string" ||
!Object.hasOwn(COMPONENT_CONTRACTS, entry.component)
)
throw Error("WRN-DESIGN-PAGE: unknown contracted component.");
if (
entry.props !== undefined &&
(!entry.props || typeof entry.props !== "object" || Array.isArray(entry.props))
)
throw Error("WRN-DESIGN-PAGE: props must be an object.");
const props = reference.components.find((c) => c.name === entry.component)!.props;
for (const [key, value] of Object.entries(entry.props ?? {})) {
const definition = props.find((p) => p.name === key);
if (
!definition ||
["attrs", "class", "controlClass", "style", "html"].includes(key) ||
!/^[A-Za-z]\w*$/.test(key)
)
throw Error(`WRN-DESIGN-PAGE: unsupported prop '${key}'.`);
if (!jsonLiteral(value))
throw Error("WRN-DESIGN-PAGE: props require bounded plain JSON literals.");
const serialized = JSON.stringify(value);
if (
serialized === undefined ||
typeof value === "function" ||
(typeof value === "number" && !Number.isFinite(value))
)
throw Error("WRN-DESIGN-PAGE: props require finite JSON literals.");
if (
serialized.length > 10000 ||
/(?:javascript|vbscript)\s*:|data\s*:\s*text\/html/i.test(JSON.stringify(value))
)
throw Error("WRN-DESIGN-PAGE: unsafe or oversized prop value.");
if (
(definition.type === "string" && typeof value !== "string") ||
(definition.type === "boolean" && typeof value !== "boolean") ||
(definition.type === "number" && (typeof value !== "number" || !Number.isFinite(value)))
)
throw Error(`WRN-DESIGN-PAGE: invalid type for ${key}.`);
const axis = (
COMPONENT_CONTRACTS[entry.component as keyof typeof COMPONENT_CONTRACTS].axes as Record<
string,
{ values: readonly string[] }
>
)[key];
if (axis && !axis.values.includes(String(value)))
throw Error(`WRN-DESIGN-PAGE: unsupported ${key} value.`);
}
}
}
const literal = (value: unknown) =>
JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
const text = (value: string) =>
value.replace(
/[&<>"'{}]/g,
(c) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"{": "&#123;",
"}": "&#125;",
})[c]!,
);
export function agentPageSource(page: AgentPage) {
validateAgentPage(page);
return `page AgentPage {\n seo { title = ${literal(page.title)} }\n view {\n <main class="wrn-agent-page"><h1>${text(page.title)}</h1>\n${page.components
.map(
(entry) =>
`<${entry.component} ${Object.entries(entry.props ?? {})
.map(([key, value]) => `${key}={${literal(value)}}`)
.join(" ")} />`,
)
.join(
"\n",
)}\n </main>\n }\n style { .wrn-agent-page { display: grid; padding: var(--wrn-space-panel); gap: var(--wrn-space-panel); color: var(--wrn-color-text); background: var(--wrn-color-bg); font-family: var(--wrn-font-sans); } }\n}\n`;
}
export function checkAgentPage(input: unknown) {
try {
validateAgentPage(input);
const source = agentPageSource(input);
return { ...checkDesignConformance(source), source };
} catch (error) {
return {
ok: false,
source: undefined,
diagnostics: [
{
code: "WRN-DESIGN-PAGE",
path: "$",
message: (error as Error).message,
severity: "error" as const,
},
],
};
}
}
+318
View File
@@ -0,0 +1,318 @@
import { createDesignPackage } from "@wrnexus/design-system/package";
import { planDesignUpgrade, applyDesignUpgrade } from "./upgrades.ts";
import {
mkdirSync,
existsSync,
lstatSync,
readFileSync,
writeFileSync,
renameSync,
rmdirSync,
} from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { StudioApplication } from "./application.ts";
import { StudioSession, exportDesign, reviewDesign } from "./session.ts";
import { writeDecision } from "./decisions.ts";
import { agentGenerationContext } from "./agent-context.ts";
import { checkDesignConformance } from "./conformance.ts";
import { agentPageSource, checkAgentPage, type AgentPage } from "./agent-pages.ts";
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
import { previewAgentPage } from "./preview.ts";
interface State {
storageVersion?: 1;
revision: string;
session: string;
pages: Record<string, AgentPage>;
previous?: { session: string; pages: Record<string, AgentPage> };
}
/** Local, revision-checked agent workspace. Never writes application routes or configuration. */
export class DesignAgentService {
private application: StudioApplication;
private directory: string;
constructor(
root: string,
private writable = false,
) {
this.application = new StudioApplication(root);
this.directory = join(this.application.directory, "agent");
}
private check(path: string, file = false) {
if (
existsSync(path) &&
(lstatSync(path).isSymbolicLink() ||
!(file ? lstatSync(path).isFile() : lstatSync(path).isDirectory()))
)
throw Error("WRN-DESIGN-PATH: managed storage must not contain links.");
}
private load() {
// Re-check every ancestor through the application boundary on every operation.
this.application.state();
this.check(this.directory);
const file = join(this.directory, "STATE.json");
this.check(file, true);
let serialized = "";
const session = new StudioSession(this.application.system(), {
getItem: () => serialized || null,
setItem: (_key, value) => {
serialized = value;
},
});
let state: State = { revision: "none", session: "", pages: {} };
if (existsSync(file)) {
const raw = readFileSync(file, "utf8");
if (raw.length > 12_000_000) throw Error("WRN-DESIGN-LIMIT: workspace too large.");
state = JSON.parse(raw);
if (state.storageVersion !== undefined && state.storageVersion !== 1)
throw Error(
"WRN-DESIGN-RECOVERY: unsupported agent state version; preserve original bytes.",
);
serialized = state.session;
if (!session.restore())
throw Error("WRN-DESIGN-RECOVERY: saved draft could not be restored.");
for (const page of Object.values(state.pages))
if (!checkAgentPage(page).ok) throw Error("WRN-DESIGN-RECOVERY: invalid saved page.");
}
session.persist();
state.session = serialized;
return {
state,
session,
serialize: () => {
session.persist();
return serialized;
},
};
}
async call(operation: string, args: Record<string, unknown> = {}): Promise<unknown> {
if (JSON.stringify(args).length > 2_000_000)
throw Error("WRN-DESIGN-LIMIT: request exceeds 2 MB.");
const writes = ["create", "propose", "locks", "page", "apply", "revert", "upgrade"];
const mutation = writes.includes(operation);
if (mutation && !this.writable)
throw Error("WRN-DESIGN-READONLY: enable designWrite at server startup.");
let locked = false;
const lock = join(this.directory, "LOCK");
if (mutation) {
this.application.state();
this.check(this.directory);
mkdirSync(this.directory, { recursive: true });
try {
mkdirSync(lock);
locked = true;
} catch {
throw Error(
"WRN-DESIGN-BUSY: workspace is locked; retry after the current writer completes.",
);
}
}
try {
const { state, session, serialize } = this.load();
if (mutation && args.baseRevision !== state.revision)
throw Error("WRN-DESIGN-CONFLICT: draft changed; inspect and review again.");
const before = { session: state.session, pages: structuredClone(state.pages) };
const options = {
mode: typeof args.mode === "string" ? args.mode : undefined,
density: typeof args.density === "string" ? args.density : undefined,
};
let result: unknown;
switch (operation) {
case "inspect":
return {
revision: state.revision,
application: this.application.state(),
source: session.source,
valid: session.valid,
locks: session.locks,
diagnostics: session.diagnostics,
pages: state.pages,
history: session.history.map((r) => r.label),
cursor: session.cursor,
};
case "capabilities":
return {
contextVersion: 1,
writable: this.writable,
components: Object.keys(COMPONENT_CONTRACTS),
operations: [
"package",
"upgrade_plan",
"upgrade",
"inspect",
"capabilities",
"context",
"create",
"propose",
"locks",
"validate",
"preview",
"diff",
"apply",
"revert",
"export",
"conformance",
"page",
],
limits: { pageComponents: 12, pages: 12, patches: 48 },
boundaries: [
"Local managed draft only. Explicit revision required for mutations.",
"Application bundles require explicit integration; routes and deployments are never modified.",
"Static analysis is heuristic; arbitrary submitted code is never executed.",
],
};
case "context":
return {
revision: state.revision,
...agentGenerationContext(session.valid, {
...options,
components: args.components as string[] | undefined,
locks: session.locks,
}),
};
case "package":
return {
revision: state.revision,
diagnostics: session.diagnostics,
package: await createDesignPackage(session.valid, args.notes as string | undefined),
};
case "upgrade_plan":
return {
revision: state.revision,
...(await planDesignUpgrade(session, args.source as string)),
};
case "upgrade": {
const plan = await planDesignUpgrade(session, args.source as string);
result = await applyDesignUpgrade(session, args.source as string, plan.baseRevision);
break;
}
case "create":
if (args.source !== undefined) {
if (typeof args.source !== "string")
throw Error("WRN-DESIGN-INPUT: source must be a string.");
session.edit(args.source, "Agent draft");
}
break;
case "propose":
if (typeof args.source === "string") session.edit(args.source, "Agent source proposal");
else {
if (!Array.isArray(args.patches) || args.patches.length > 48)
throw Error("WRN-DESIGN-INPUT: provide source or up to 48 patches.");
session.mutate((system) => {
for (const patch of args.patches as Array<{ path: string; value: unknown }>)
writeDecision(system, patch.path, patch.value);
}, "Agent token proposal");
}
break;
case "locks":
session.setLocks(args.locks as string[]);
break;
case "page": {
const checked = checkAgentPage(args.page);
if (!checked.ok) return { revision: state.revision, ...checked };
const page = args.page as AgentPage;
if (!Object.hasOwn(state.pages, page.id) && Object.keys(state.pages).length >= 12)
throw Error("WRN-DESIGN-LIMIT: at most 12 pages.");
state.pages[page.id] = structuredClone(page);
result = checked;
break;
}
case "validate":
return {
revision: state.revision,
ok: !session.diagnostics.length,
diagnostics: session.diagnostics,
pages: Object.values(state.pages).map((page) => ({
id: page.id,
...checkAgentPage(page),
})),
};
case "conformance":
return checkDesignConformance(args.source as string, args.exceptions as never);
case "diff":
return {
revision: state.revision,
changes: reviewDesign(session.applied, session.valid),
pages: Object.keys(state.pages),
diagnostics: session.diagnostics,
};
case "preview": {
const page = state.pages[String(args.pageId)];
if (!page) throw Error("WRN-DESIGN-PAGE: select a saved page.");
return {
revision: state.revision,
diagnostics: session.diagnostics,
html: await previewAgentPage(session.valid, page, options),
};
}
case "export":
return {
revision: state.revision,
source: session.source,
locks: session.locks,
diagnostics: session.diagnostics,
artifacts: exportDesign(session.valid, options),
pages: Object.fromEntries(
Object.values(state.pages).map((page) => [page.id + ".wrn", agentPageSource(page)]),
),
};
case "apply":
if (session.diagnostics.length)
throw Error("WRN-DESIGN-INVALID: resolve draft diagnostics before applying.");
result = this.application.apply(
session.valid,
String(args.baseApplicationRevision),
options,
);
session.markApplied();
break;
case "revert":
if (args.target === "application") {
result = this.application.rollback(String(args.baseApplicationRevision));
} else if (args.target === "checkpoint") {
if (!state.previous) throw Error("WRN-DESIGN-RECOVERY: no checkpoint.");
state.session = state.previous.session;
state.pages = state.previous.pages;
} else {
session.undo();
}
break;
default:
throw Error("WRN-DESIGN-OPERATION: unknown operation.");
}
if (!(operation === "revert" && args.target === "checkpoint")) state.session = serialize();
state.storageVersion = 1;
state.previous = before;
state.revision = randomUUID();
const raw = JSON.stringify(state);
if (raw.length > 12_000_000)
throw Error("WRN-DESIGN-LIMIT: export or shorten draft history.");
const temp = join(this.directory, randomUUID() + ".tmp");
writeFileSync(temp, raw, { flag: "wx" });
renameSync(temp, join(this.directory, "STATE.json"));
const saved = this.load().session;
return {
revision: state.revision,
diagnostics: saved.diagnostics,
affectedPaths:
operation === "page"
? ["pages." + (args.page as AgentPage).id]
: operation === "locks"
? ["locks"]
: operation === "apply" || (operation === "revert" && args.target === "application")
? ["application.CURRENT"]
: reviewDesign(JSON.parse(before.session).valid, saved.valid)
.map((change) => change.path)
.concat(
saved.source !==
JSON.parse(before.session).history[JSON.parse(before.session).cursor].source
? ["draft.source"]
: [],
),
result,
};
} finally {
if (locked) rmdirSync(lock);
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import {
mkdirSync,
existsSync,
lstatSync,
realpathSync,
readFileSync,
writeFileSync,
renameSync,
rmdirSync,
} from "node:fs";
import { resolve, join } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { exportDesign } from "./session.ts";
import type { DesignSystem, ResolveOptions } from "@wrnexus/design-system";
interface Pointer {
revision: string;
previous?: string;
}
/** Immutable revision bundles with one atomic pointer. No caller-supplied path or app configuration edits. */
export class StudioApplication {
readonly directory: string;
constructor(root: string) {
if (!existsSync(root)) throw Error("Application root must already exist.");
this.directory = join(realpathSync(resolve(root)), ".wrnexus", "design-studio");
this.checkDirectories();
}
private checkDirectories() {
for (const directory of [
resolve(this.directory, ".."),
this.directory,
join(this.directory, "revisions"),
]) {
if (
existsSync(directory) &&
(!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink())
)
throw Error("Studio directory must be a real directory, not a link.");
}
const pointer = join(this.directory, "CURRENT.json");
if (
existsSync(pointer) &&
(!lstatSync(pointer).isFile() || lstatSync(pointer).isSymbolicLink())
)
throw Error("Invalid application pointer.");
}
state(): Pointer {
this.checkDirectories();
const path = join(this.directory, "CURRENT.json");
if (!existsSync(path)) return { revision: "none" };
const pointer = JSON.parse(readFileSync(path, "utf8")) as Pointer;
if (
!/^(?:[a-f0-9]{64}|none)$/.test(pointer.revision) ||
(pointer.previous && !/^(?:[a-f0-9]{64}|none)$/.test(pointer.previous))
)
throw Error("Invalid application revision.");
return pointer;
}
system(): DesignSystem | undefined {
const current = this.state();
if (current.revision === "none") return undefined;
const target = join(this.directory, "revisions", current.revision);
const file = join(target, "system.json");
if (
!lstatSync(target).isDirectory() ||
lstatSync(target).isSymbolicLink() ||
!lstatSync(file).isFile() ||
lstatSync(file).isSymbolicLink()
)
throw Error("Invalid revision bundle.");
return JSON.parse(readFileSync(file, "utf8")) as DesignSystem;
}
selection(): ResolveOptions {
const current = this.state();
if (current.revision === "none") return {};
// Validate the revision directory before reading another artifact inside it.
this.system();
const file = join(this.directory, "revisions", current.revision, "theme.json");
if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink())
throw Error("Invalid theme artifact.");
const theme = JSON.parse(readFileSync(file, "utf8"));
return { mode: theme.designSystem?.mode, density: theme.designSystem?.density };
}
private point(pointer: Pointer) {
const path = join(this.directory, `CURRENT-${randomUUID()}.tmp`);
writeFileSync(path, JSON.stringify(pointer, null, 2));
renameSync(path, join(this.directory, "CURRENT.json"));
}
private exclusive<T>(run: () => T): T {
this.checkDirectories();
mkdirSync(this.directory, { recursive: true });
const lock = join(this.directory, "APPLY.lock");
try {
mkdirSync(lock);
} catch {
throw Error("WRN-STUDIO-BUSY: another application writer is active.");
}
try {
return run();
} finally {
rmdirSync(lock);
}
}
apply(system: DesignSystem, base: string, options: ResolveOptions = {}) {
return this.exclusive(() => this.applyUnlocked(system, base, options));
}
private applyUnlocked(system: DesignSystem, base: string, options: ResolveOptions) {
const artifacts = exportDesign(system, options);
const current = this.state();
if (base !== current.revision)
throw Error("WRN-STUDIO-CONFLICT: application changed; refresh review before applying.");
const revision = createHash("sha256").update(JSON.stringify({ system, options })).digest("hex");
mkdirSync(join(this.directory, "revisions"), { recursive: true });
this.checkDirectories();
const target = join(this.directory, "revisions", revision);
if (
existsSync(target) &&
(!lstatSync(target).isDirectory() || lstatSync(target).isSymbolicLink())
)
throw Error("Invalid revision directory.");
if (!existsSync(target)) {
const staging = join(this.directory, "revisions", `draft-${randomUUID()}`);
mkdirSync(staging);
for (const [filename, body] of Object.entries({
"system.json": artifacts.canonical,
"system.css": artifacts.css,
"theme.json": artifacts.theme,
"tokens.json": artifacts.tokens,
"report.json": artifacts.report,
}))
writeFileSync(join(staging, filename), body);
writeFileSync(
join(staging, "README.md"),
`# ${system.name}\n\nImport theme.json and assign it to your WrNexus app configuration's theme field. Preserve explicit application overrides. This bundle does not edit app configuration or deploy. Alternatively use system.css as the standalone stylesheet; do not load both integrations.\n`,
);
renameSync(staging, target);
}
if (revision !== current.revision) this.point({ revision, previous: current.revision });
return { ...this.state(), directory: target };
}
rollback(base: string) {
return this.exclusive(() => this.rollbackUnlocked(base));
}
private rollbackUnlocked(base: string) {
const current = this.state();
if (base !== current.revision)
throw Error("WRN-STUDIO-CONFLICT: application changed; refresh review.");
if (!current.previous) throw Error("No previous application revision.");
this.point({ revision: current.previous, previous: current.revision });
return this.state();
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bun
import { createStudioHandler } from "./server.ts";
export async function runDesignStudio() {
const index = process.argv.indexOf("--app-root");
if (index >= 0 && !process.argv[index + 1])
throw Error("--app-root requires an existing application directory.");
const fetch = await createStudioHandler({
applicationRoot: index >= 0 ? process.argv[index + 1] : undefined,
...(await (async () => {
const position = process.argv.indexOf("--ai-model");
if (position < 0) return {};
const model = process.argv[position + 1];
if (!model || model.startsWith("--"))
throw Error("--ai-model requires an explicit provider model ID.");
if (!process.env.ANTHROPIC_API_KEY)
throw Error("ANTHROPIC_API_KEY is required when --ai-model is enabled.");
const { anthropicProvider } = await import("@wrnexus/ai");
return { generationProvider: anthropicProvider({ model }), generationModel: model };
})()),
});
const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(process.env.DESIGN_STUDIO_PORT ?? 4320),
fetch,
});
console.log(
`Design Studio: ${server.url} (${index >= 0 ? "local application enabled" : "export-only; pass --app-root to enable local apply"})`,
);
return server;
}
if (import.meta.main) await runDesignStudio();
+674
View File
@@ -0,0 +1,674 @@
import { setupGuidance } from "./guidance.ts";
import { StudioSession, importDesign, exportDesign, reviewDesign } from "./session.ts";
import {
COMPONENT_CONTRACTS,
CORE_DESIGN_SYSTEMS,
TOKEN_TYPES,
type DesignSystem,
type ComponentDesignContract,
type DesignToken,
} from "@wrnexus/design-system";
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const value = (id: string) => $<HTMLInputElement>(id).value;
const escape = (text: unknown) =>
String(text).replace(
/[&<>"']/g,
(char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!,
);
const option = (name: string, label = name) =>
`<option value="${escape(name)}">${escape(label)}</option>`;
const session = new StudioSession(undefined, localStorage);
session.restore();
let importCandidate: DesignSystem | undefined;
let application: { enabled?: boolean; revision: string; previous?: string; directory?: string } = {
revision: "none",
};
let page = 0;
let previewGeneration = 0;
let activeTab = "foundations";
let noticeTimer: ReturnType<typeof setTimeout>;
function message(text: string) {
$("message").textContent = text;
clearTimeout(noticeTimer);
noticeTimer = setTimeout(() => {
$("message").textContent = "";
}, 10000);
}
async function request(path: string, body?: unknown) {
const response = await fetch(
new URL("." + path, document.baseURI),
body === undefined
? undefined
: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
const result = await response.json();
if (!response.ok) throw Error(result.error ?? "Request failed.");
return result;
}
function on(id: string, action: () => unknown | Promise<unknown>, event = "click") {
$(id).addEventListener(event, () => {
Promise.resolve()
.then(() => {
flushSource();
return action();
})
.catch((error) => message(error.message));
});
}
function flushSource() {
if (activeTab === "source" && value("source-text") !== session.source) {
clearTimeout(sourceTimer);
session.edit(value("source-text"));
render();
}
}
function download(body: string, filename: string, type = "application/json") {
const url = URL.createObjectURL(new Blob([body], { type }));
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function selectOptions(id: string, entries: string[], empty?: string) {
const select = $<HTMLSelectElement>(id);
const previous = select.value;
select.innerHTML =
(empty !== undefined ? option("", empty) : "") + entries.map((name) => option(name)).join("");
if ([...select.options].some((o) => o.value === previous)) select.value = previous;
}
function selection() {
return {
mode: value("preview-mode") || undefined,
density: value("preview-density") || undefined,
};
}
function tab(name: string) {
activeTab = name;
for (const id of ["guided", "foundations", "components", "source", "history", "review"])
$(id === "review" ? "review-panel" : id).hidden = id !== name;
document
.querySelectorAll<HTMLButtonElement>("[data-tab]")
.forEach((button) =>
button.setAttribute("aria-current", button.dataset.tab === name ? "page" : "false"),
);
$("editor-title").textContent =
{
guided: "Guided design",
foundations: "Foundations",
components: "Components",
source: "Source & import",
history: "History",
review: "Review & apply",
}[name] ?? name;
}
function renderTokens() {
const layer = value("token-layer");
const [kind, name] = layer.split(":");
const override =
kind === "mode"
? session.valid.modes?.[name!]
: kind === "density"
? session.valid.densities?.[name!]
: undefined;
const tokens = { ...session.valid.tokens, ...override };
const selected = selection();
const resolved = session.resolved({
...selected,
...(kind === "mode" ? { mode: name } : kind === "density" ? { density: name } : {}),
});
const search = value("token-search").toLowerCase();
const filtered = Object.entries(tokens).filter(([key, token]) =>
`${key} ${token.type} ${token.legacy ?? ""}`.toLowerCase().includes(search),
);
$("tokens").innerHTML =
filtered
.map(([key, token]) => {
const actual = resolved.tokens[key];
const raw = typeof token.value === "object" ? "@" + token.value.ref : token.value;
return `<div class="token"><label><span class="token-name">${escape(key)}</span><input data-token="${escape(key)}" value="${escape(raw)}" ${session.diagnostics.length ? "disabled" : ""}></label><small>${escape(token.type)} · ${escape(actual?.value ?? "unresolved")}<br>Source: ${escape(actual?.source ?? "")}${actual?.reference ? ` → ${escape(actual.reference)}` : ""}</small><label>Legacy token suffix<input data-binding="${escape(key)}" placeholder="e.g. radius-sm" value="${escape(token.legacy ?? "")}" ${session.diagnostics.length ? "disabled" : ""}></label><button data-save-token="${escape(key)}" ${session.diagnostics.length ? "disabled" : ""}>Save token</button><button data-delete-token="${escape(key)}" ${session.diagnostics.length ? "disabled" : ""}>Remove ${kind === "base" ? "token" : "override"}</button></div>`;
})
.join("") || "<p>No tokens match this search.</p>";
}
function tokenMap(system: DesignSystem) {
const [kind, name] = value("token-layer").split(":");
if (kind === "mode") return (system.modes![name!] ??= {});
if (kind === "density") return (system.densities![name!] ??= {});
return system.tokens;
}
function recipeContract(): ComponentDesignContract {
return COMPONENT_CONTRACTS[
value("component") as keyof typeof COMPONENT_CONTRACTS
] as unknown as ComponentDesignContract;
}
function renderContract() {
const contract = recipeContract();
if (!contract) {
$("contract").textContent = "No components match this search.";
selectOptions("part", []);
selectOptions("property", []);
$<HTMLTextAreaElement>("recipe-source").value = "";
for (const id of ["set-recipe", "remove-recipe", "save-recipe"])
$(id).setAttribute("disabled", "");
return;
}
for (const id of ["remove-recipe", "save-recipe"])
$(id).toggleAttribute("disabled", !!session.diagnostics.length);
$("contract").innerHTML =
`<p><strong>${escape(value("component"))}</strong> · Contract version ${contract.contractVersion}</p><p>Axes: ${escape(
Object.entries(contract.axes)
.map(([name, axis]) => `${name}: ${axis.values.join(", ")}`)
.join("; ") || "none",
)}</p><p>${escape(contract.constraints.join(" "))}</p><details><summary>Ownership and limits</summary><pre>${escape(JSON.stringify(contract.regions ?? {}, null, 2))}</pre></details>`;
selectOptions("part", Object.keys(contract.parts));
renderProperties();
$("recipe-source").textContent = JSON.stringify(
session.valid.recipes?.[value("component")] ?? { contractVersion: 1, base: {} },
null,
2,
);
$<HTMLTextAreaElement>("recipe-source").value = JSON.stringify(
session.valid.recipes?.[value("component")] ?? { contractVersion: 1, base: {} },
null,
2,
);
}
function renderProperties() {
const contract = recipeContract();
const part = contract?.parts[value("part")];
selectOptions("property", part ? [...part.properties] : []);
$("set-recipe").toggleAttribute(
"disabled",
!part?.properties.length || !!session.diagnostics.length,
);
const raw =
session.valid.recipes?.[value("component")]?.base?.[value("part")]?.[value("property")];
$<HTMLInputElement>("recipe-value").value =
typeof raw === "object" ? "@" + raw.ref : String(raw ?? "");
}
function render() {
$<HTMLSelectElement>("preset").value = Object.hasOwn(CORE_DESIGN_SYSTEMS, session.valid.id)
? session.valid.id
: "";
$("system-name").setAttribute("value", session.valid.name);
$<HTMLInputElement>("system-name").value = session.valid.name;
$<HTMLInputElement>("system-id").value = session.valid.id;
if (document.activeElement !== $("source-text"))
$<HTMLTextAreaElement>("source-text").value = session.source;
$("validation-state").textContent = session.diagnostics.length
? "Draft has errors"
: "Valid draft";
$("diagnostics").textContent = session.diagnostics
.map((d) => `${d.code} · ${d.path}: ${d.message}`)
.join("\n");
$("save-state").textContent = session.storageError || "Draft saved locally";
$("undo").toggleAttribute("disabled", session.cursor === 0);
$("redo").toggleAttribute("disabled", session.cursor === session.history.length - 1);
selectOptions("preview-mode", Object.keys(session.valid.modes ?? {}), "Default");
selectOptions("preview-density", Object.keys(session.valid.densities ?? {}), "Default");
selectOptions("token-layer", [
"base",
...Object.keys(session.valid.modes ?? {}).map((n) => "mode:" + n),
...Object.keys(session.valid.densities ?? {}).map((n) => "density:" + n),
]);
renderTokens();
renderContract();
$("revisions").innerHTML = session.history
.map(
(revision, i) =>
`<li><button data-revision="${i}" ${i === session.cursor ? 'aria-current="step"' : ""}>${i + 1}. ${escape(revision.label)}${i === session.cursor ? " · current" : ""}</button></li>`,
)
.join("");
const changes = session.changes();
const details = reviewDesign(session.applied, session.valid);
$("changes").textContent = changes.length
? `${changes.length} changed paths\n${changes.some((path) => path.startsWith("$.tokens") || path.startsWith("$.modes") || path.startsWith("$.densities")) ? "Foundation changes may affect all 104 contracted components." : "Affected components: " + [...new Set(changes.filter((path) => path.startsWith("$.recipes.")).map((path) => path.split(".")[2]))].join(", ")}\n\n` +
details
.map(
(entry) =>
`${entry.path}\nBefore: ${JSON.stringify(entry.before) ?? "(absent)"}\nAfter: ${JSON.stringify(entry.after) ?? "(removed)"}`,
)
.join("\n\n")
: "No changes from the last applied/opened design.";
$("apply").toggleAttribute("disabled", !application.enabled || !!session.diagnostics.length);
$("rollback").toggleAttribute("disabled", !application.previous);
$("target").textContent = application.enabled
? `Local folder: ${application.directory}\nCurrent revision: ${application.revision}`
: "Export-only workspace. Enable local apply by starting the Studio with --app-root <existing application folder>.";
tab(activeTab);
guidance?.render();
}
async function previews() {
const generation = ++previewGeneration;
const settings = {
...selection(),
direction: value("preview-direction"),
content: value("preview-content"),
page,
};
const comparison = value("compare");
$("comparison").hidden = !comparison;
const systems = [
session.valid,
...(comparison
? [
comparison === "applied"
? session.applied
: CORE_DESIGN_SYSTEMS[comparison as keyof typeof CORE_DESIGN_SYSTEMS],
]
: []),
];
const result = await Promise.all(
systems.map((system) =>
request("/api/preview", {
system,
...settings,
...(system !== session.valid
? {
mode: Object.hasOwn(system!.modes ?? {}, settings.mode ?? "")
? settings.mode
: undefined,
density: Object.hasOwn(system!.densities ?? {}, settings.density ?? "")
? settings.density
: undefined,
}
: {}),
}),
),
);
if (generation !== previewGeneration) return;
$<HTMLIFrameElement>("preview-frame").src = result[0].url;
if (comparison) $<HTMLIFrameElement>("compare-frame").src = result[1].url;
for (const id of ["preview-frame", "compare-frame"]) $(id).style.width = value("preview-width");
$("preview-label").textContent = session.diagnostics.length
? "Last valid draft · source has errors"
: "Current draft";
}
async function changed() {
render();
await previews();
}
function raw(input: string): string | number | { ref: string } {
if (input.startsWith("@")) return { ref: input.slice(1) };
return input;
}
function tokenValue(type: DesignToken["type"], input: string) {
return !input.startsWith("@") && input.trim() && ["number", "fontWeight"].includes(type)
? Number(input)
: raw(input);
}
async function applicationState() {
const state = await request("/api/application");
application = state;
if (state.system) {
session.applied = structuredClone(state.system);
session.persist();
}
render();
}
document.querySelectorAll<HTMLButtonElement>("[data-tab]").forEach((button) =>
button.addEventListener("click", () => {
flushSource();
tab(button.dataset.tab!);
if (button.dataset.tab === "review") applicationState().catch((e) => message(e.message));
}),
);
on("undo", async () => {
session.undo();
await changed();
});
on("redo", async () => {
session.redo();
await changed();
});
on("new", async () => {
session.replace(
{ schemaVersion: 1, id: "my-design", name: "My design", version: "1.0.0", tokens: {} },
"Created new system",
);
await changed();
});
on(
"preset",
async () => {
if (!value("preset")) return;
session.replace(
CORE_DESIGN_SYSTEMS[value("preset") as keyof typeof CORE_DESIGN_SYSTEMS],
"Selected " + value("preset"),
);
await changed();
},
"change",
);
on("duplicate", async () => {
const system = structuredClone(session.valid);
system.id += "-copy";
system.name += " copy";
session.replace(system, "Duplicated system");
await changed();
});
for (const [id, field] of [
["system-name", "name"],
["system-id", "id"],
] as const)
on(
id,
async () => {
const next = value(id);
session.mutate((s) => {
s[field] = next;
}, "Changed " + field);
await changed();
},
"change",
);
on("token-search", renderTokens, "input");
on("token-layer", renderTokens, "change");
$("tokens").addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
try {
session.mutate(
(system) => {
const map = tokenMap(system);
const name = input.dataset.token ?? input.dataset.binding!;
const token = map[name] ?? structuredClone(system.tokens[name]!);
if (!token) throw Error("Token not found.");
map[name] = token;
if (input.dataset.token) token.value = tokenValue(token.type, input.value);
else if (input.value) token.legacy = input.value;
else delete token.legacy;
},
"Edited token " + (input.dataset.token ?? input.dataset.binding),
);
changed().catch((e) => message(e.message));
} catch (error) {
message((error as Error).message);
}
});
$("tokens").addEventListener("click", (event) => {
const save = (event.target as HTMLElement).dataset.saveToken;
if (save) {
const parent = (event.target as HTMLElement).closest(".token")!;
const input = parent.querySelector<HTMLInputElement>("[data-token]")!;
const binding = parent.querySelector<HTMLInputElement>("[data-binding]")!;
const inputValue = input.value,
bindingValue = binding.value;
try {
session.mutate((system) => {
const map = tokenMap(system);
const token = map[save] ?? structuredClone(system.tokens[save]!);
token.value = tokenValue(token.type, inputValue);
if (bindingValue) token.legacy = bindingValue;
else delete token.legacy;
map[save] = token;
}, "Edited token " + save);
changed().catch((e) => message(e.message));
} catch (error) {
message((error as Error).message);
}
return;
}
const name = (event.target as HTMLElement).dataset.deleteToken;
if (!name) return;
try {
session.mutate((s) => {
delete tokenMap(s)[name];
}, "Removed " + name);
changed().catch((e) => message(e.message));
} catch (error) {
message((error as Error).message);
}
});
$<HTMLSelectElement>("add-token-type").innerHTML = TOKEN_TYPES.map((t) => option(t)).join("");
on("add-token", async () => {
session.mutate((s) => {
const name = value("add-token-name");
if (
!/^[A-Za-z][\w.-]*$/.test(name) ||
name.split(/[.-]/).some((p) => ["__proto__", "constructor", "prototype"].includes(p))
)
throw Error("Choose a valid token name.");
const map = tokenMap(s);
if (Object.hasOwn(map, name)) throw Error("Token already exists.");
map[name] = {
type: value("add-token-type") as DesignToken["type"],
value: tokenValue(value("add-token-type") as DesignToken["type"], value("add-token-value")),
};
}, "Added token");
await changed();
});
for (const kind of ["mode", "density"] as const)
on("add-" + kind, async () => {
session.mutate((s) => {
const name = value("add-layer-name");
if (
!/^[A-Za-z][\w-]*$/.test(name) ||
["constructor", "prototype", "__proto__"].includes(name)
)
throw Error("Choose a valid layer name.");
const layers = kind === "mode" ? (s.modes ??= {}) : (s.densities ??= {});
if (Object.hasOwn(layers, name)) throw Error("Layer already exists.");
layers[name] = {};
}, "Added " + kind);
await changed();
});
on(
"component-search",
() => {
const filter = value("component-search").toLowerCase();
selectOptions(
"component",
Object.keys(COMPONENT_CONTRACTS).filter((n) => n.toLowerCase().includes(filter)),
);
renderContract();
},
"input",
);
selectOptions("component", Object.keys(COMPONENT_CONTRACTS));
on("component", renderContract, "change");
on("part", renderProperties, "change");
on("property", renderProperties, "change");
on("set-recipe", async () => {
session.mutate((s) => {
const recipe = ((s.recipes ??= {})[value("component")] ??= { contractVersion: 1 });
const part = ((recipe.base ??= {})[value("part")] ??= {});
part[value("property")] = raw(value("recipe-value"));
}, "Changed component default");
await changed();
});
on("remove-recipe", async () => {
session.mutate((s) => {
const part = s.recipes?.[value("component")]?.base?.[value("part")];
if (part) delete part[value("property")];
}, "Removed component default");
await changed();
});
on("save-recipe", async () => {
const recipe = JSON.parse(value("recipe-source"));
session.mutate((s) => {
(s.recipes ??= {})[value("component")] = recipe;
}, "Edited component recipe");
await changed();
});
on("save-source", async () => {
session.edit(value("source-text"));
await changed();
});
let sourceTimer: ReturnType<typeof setTimeout>;
$("source-text").addEventListener("input", () => {
clearTimeout(sourceTimer);
sourceTimer = setTimeout(() => {
try {
session.edit(value("source-text"));
changed().catch((e) => message(e.message));
} catch (e) {
message((e as Error).message);
}
}, 600);
});
on(
"import-file",
async () => {
const file = $<HTMLInputElement>("import-file").files?.[0];
if (!file) return;
if (file.size > 2_000_000) throw Error("File exceeds 2 MB.");
$<HTMLTextAreaElement>("import-text").value = await file.text();
importCandidate = undefined;
$("accept-import").setAttribute("disabled", "");
},
"change",
);
on("inspect-import", () => {
const report = importDesign(
value("import-text"),
value("import-format") as "canonical" | "legacy" | "tokens",
);
importCandidate = report.system;
$("import-report").textContent = JSON.stringify(
{
...report,
system: report.system ? { id: report.system.id, name: report.system.name } : undefined,
},
null,
2,
);
$("accept-import").toggleAttribute("disabled", !importCandidate);
});
for (const id of ["import-text", "import-format"])
on(
id,
() => {
importCandidate = undefined;
$("accept-import").setAttribute("disabled", "");
},
id === "import-text" ? "input" : "change",
);
on("accept-import", async () => {
if (!importCandidate) throw Error("Inspect the current import first.");
session.replace(importCandidate, "Imported design");
await changed();
message("Imported design. Review mapping warnings before applying.");
});
on("download-draft", () => download(value("source-text"), "design-draft.json"));
on("export", () => {
if (session.diagnostics.length)
throw Error("Resolve errors or export the raw draft from Source & import.");
download(exportDesign(session.valid, selection()).canonical, session.valid.id + ".json");
});
document.querySelectorAll<HTMLButtonElement>("[data-export]").forEach((button) =>
button.addEventListener("click", () => {
try {
if (session.diagnostics.length) throw Error("Resolve draft errors before exporting.");
const kind = button.dataset.export as keyof ReturnType<typeof exportDesign>;
download(
exportDesign(session.valid, selection())[kind],
`${session.valid.id}-${kind}.${kind === "css" ? "css" : "json"}`,
kind === "css" ? "text/css" : "application/json",
);
} catch (e) {
message((e as Error).message);
}
}),
);
on("review", async () => {
tab("review");
await applicationState();
});
on("apply", async () => {
if (session.diagnostics.length) throw Error("Resolve draft errors before applying.");
const submitted = structuredClone(session.valid);
const result = await request("/api/apply", {
system: submitted,
base: application.revision,
...selection(),
});
session.applied = submitted;
session.persist();
const { directory: _bundleDirectory, ...revisionState } = result;
application = { ...application, ...revisionState };
$("integration").textContent =
`Applied to ${result.directory}\n\nImport theme from ${result.directory.replaceAll("\\", "/")}/theme.json\nand set theme in your existing WrNexus app configuration.\n\nCurrent pointer: ${application.directory}/CURRENT.json\nNo application configuration was overwritten.`;
render();
message("Applied validated design to the local folder.");
});
on("rollback", async () => {
await request("/api/rollback", { base: application.revision });
await applicationState();
message("Application pointer restored to its previous revision. Your draft is preserved.");
});
$("revisions").addEventListener("click", (event) => {
const index = (event.target as HTMLElement).dataset.revision;
if (index === undefined) return;
while (session.cursor > Number(index)) session.undo();
while (session.cursor < Number(index)) session.redo();
changed().catch((e) => message(e.message));
});
for (const id of [
"preview-mode",
"preview-density",
"preview-width",
"preview-direction",
"preview-content",
"compare",
])
on(
id,
async () => {
renderTokens();
await previews();
},
"change",
);
// Gallery pagination belongs to the Studio so isolated frames never navigate the workspace.
const pagination = document.createElement("div");
pagination.className = "row";
for (let i = 0; i < 7; i++) {
const button = document.createElement("button");
button.textContent = String(i + 1);
button.setAttribute("aria-label", `Gallery page ${i + 1}`);
button.addEventListener("click", () => {
page = i;
$<HTMLSelectElement>("preview-content").value = "catalog";
previews().catch((e) => message(e.message));
});
pagination.append(button);
}
$("preview-content").closest(".preview-controls")!.after(pagination);
on("refresh-preview", previews);
document.addEventListener("keydown", (event) => {
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "z" &&
!["INPUT", "TEXTAREA"].includes((event.target as HTMLElement).tagName)
) {
event.preventDefault();
if (event.shiftKey) session.redo();
else session.undo();
changed().catch((e) => message(e.message));
}
});
window.addEventListener("beforeunload", () => {
clearTimeout(sourceTimer);
if (activeTab === "source" && value("source-text") !== session.source) {
try {
session.edit(value("source-text"));
} catch {
/* Existing local draft remains recoverable. */
}
} else session.persist();
});
const guidance = setupGuidance(session, {
message,
changed,
preview: async (system) => (await request("/api/preview", { system, content: "composed" })).url,
});
render();
applicationState()
.then(previews)
.catch((e) => message(e.message));
+214
View File
@@ -0,0 +1,214 @@
import { parse } from "@wrnexus/compiler";
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
import reference from "@wrnexus/ui/component-reference.json";
export interface ConformanceDiagnostic {
code: string;
path: string;
message: string;
severity: "error" | "warning";
exception?: string;
}
export interface ConformanceException {
code: string;
path: string;
reason: string;
}
export function checkDesignConformance(source: string, exceptions: ConformanceException[] = []) {
const diagnostics: ConformanceDiagnostic[] = [];
const add = (
code: string,
path: string,
message: string,
severity: "error" | "warning" = "error",
) => diagnostics.push({ code, path, message, severity });
if (typeof source !== "string" || source.length > 128000)
return {
ok: false,
diagnostics: [
{
code: "WRN-CONFORMANCE-LIMIT",
path: "$",
message: "Source must be at most 128,000 characters.",
severity: "error" as const,
},
],
};
if (
!Array.isArray(exceptions) ||
exceptions.length > 64 ||
exceptions.some(
(e) =>
!e ||
typeof e.code !== "string" ||
typeof e.path !== "string" ||
typeof e.reason !== "string" ||
!e.reason.trim() ||
e.reason.length > 500,
)
)
throw Error(
"WRN-CONFORMANCE-EXCEPTION: provide up to 64 exact code/path exceptions with reasons.",
);
let ast: ReturnType<typeof parse>;
try {
ast = parse(source);
} catch (error) {
return {
ok: false,
diagnostics: [
{
code: "WRN-CONFORMANCE-PARSE",
path: "$",
message: (error as Error).message,
severity: "error" as const,
},
],
};
}
let count = 0;
function css(text: string, path: string) {
const withoutVars = text.replace(/var\(\s*--wrn-[\w-]+(?:\s*,[^)]*)?\)/g, "TOKEN");
if (/#[a-f\d]{3,8}\b|\b(?:rgb|hsl)a?\(/i.test(withoutVars))
add(
"WRN-CONFORMANCE-COLOR",
path,
"Literal color bypasses the system. Prefer a semantic --wrn-* token.",
"warning",
);
if (
/(?:font-family|font-size|border-radius|box-shadow|padding(?:-\w+)?|margin(?:-\w+)?|gap)\s*:[^;}]*?(?:\d+(?:\.\d+)?(?:px|rem|em)|["'][A-Za-z])/i.test(
withoutVars,
)
)
add(
"WRN-CONFORMANCE-FOUNDATION",
path,
"Literal typography, spacing, shape or elevation bypasses a foundation token.",
"warning",
);
}
function walk(value: unknown, path: string) {
if (++count > 20000) throw Error("AST traversal limit exceeded.");
if (Array.isArray(value)) {
value.forEach((node, index) => walk(node, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
const node = value as {
type?: string;
tag?: string;
attrs?: Array<{ name: string; value: string; event?: boolean }>;
};
if (node.type === "element" && node.tag) {
const mount = node.attrs?.find((a) => a.name === "data-component");
const name = mount?.value ?? (/^[A-Z]/.test(node.tag) ? node.tag : undefined);
if (name) {
if (name.includes("{"))
add(
"WRN-CONFORMANCE-DYNAMIC",
path,
"Dynamic component mount cannot be verified.",
"warning",
);
else if (!Object.hasOwn(COMPONENT_CONTRACTS, name))
add(
"WRN-CONFORMANCE-COMPONENT",
path,
`No contract for '${name}'. Custom components require an explicit reviewed exception.`,
"warning",
);
else {
const contract = COMPONENT_CONTRACTS[name as keyof typeof COMPONENT_CONTRACTS];
const meta = reference.components.find((c) => c.name === name)!;
for (const attr of node.attrs ?? []) {
if (attr.event) {
if (!meta.outputs.some((output) => output.name === attr.name))
add(
"WRN-CONFORMANCE-EVENT",
path + "." + attr.name,
`Unknown ${name} event '${attr.name}'.`,
);
continue;
}
if (
["data-component", "slot"].includes(attr.name) ||
attr.name.startsWith("aria-") ||
attr.name.startsWith("data-") ||
attr.name.startsWith("@")
)
continue;
const prop = meta.props.find((p) => p.name === attr.name);
if (!prop && !["class", "attrs"].includes(attr.name))
add(
"WRN-CONFORMANCE-PROP",
path + "." + attr.name,
`Unknown ${name} prop '${attr.name}'.`,
);
const axis = (contract.axes as Record<string, { values: readonly string[] }>)[
attr.name
];
if (axis) {
let literal = attr.value;
if (literal.startsWith("{")) {
try {
literal = String(JSON.parse(literal.slice(1, -1)));
} catch {
add(
"WRN-CONFORMANCE-DYNAMIC",
path + "." + attr.name,
"Dynamic axis requires runtime validation.",
"warning",
);
continue;
}
}
if (!axis.values.includes(literal))
add(
"WRN-CONFORMANCE-AXIS",
path + "." + attr.name,
`Unsupported ${name}.${attr.name} '${literal}'. Allowed: ${axis.values.join(", ")}.`,
);
}
}
}
}
for (const attr of node.attrs ?? []) {
if (attr.name === "style") css(attr.value, path + ".style");
if (
attr.name === "class" &&
/(?:^|\s)(?:bg|text|rounded|shadow|p[xytrbl]?|m[xytrbl]?|gap)-(?:\[|\d|(?:red|blue|green|gray)-)/.test(
attr.value,
)
)
add(
"WRN-CONFORMANCE-UTILITY",
path + ".class",
"Utility styling may bypass tokens; use a token-backed class or document the exception.",
"warning",
);
}
}
for (const [key, child] of Object.entries(value))
if (key !== "attrs") walk(child, `${path}.${key}`);
}
try {
walk(ast.view, "$.view");
ast.styles?.forEach((style, index) => css(style, `$.styles[${index}]`));
} catch (error) {
add("WRN-CONFORMANCE-LIMIT", "$", (error as Error).message);
}
for (const exception of exceptions) {
const item = diagnostics.find(
(d) => d.code === exception.code && d.path === exception.path && d.severity === "warning",
);
if (item) item.exception = exception.reason;
else
add(
"WRN-CONFORMANCE-EXCEPTION",
exception.path,
"Exception does not match a warning; errors cannot be waived.",
);
}
return { ok: !diagnostics.some((d) => !d.exception), diagnostics };
}
+191
View File
@@ -0,0 +1,191 @@
import {
resolveDesignSystem,
validateDesignSystem,
type DesignSystem,
type DesignDiagnostic,
} from "@wrnexus/design-system";
export type DecisionLock = string;
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
export function decisionPath(path: string): string[] {
if (typeof path !== "string" || path.length > 512 || !path.startsWith("/"))
throw Error("WRN-STUDIO-LOCK-PATH: use a JSON pointer.");
const parts = path
.slice(1)
.split("/")
.map((p) => {
if (/~(?![01])/u.test(p)) throw Error("Invalid pointer escape.");
return p.replace(/~1/g, "/").replace(/~0/g, "~");
});
if (
!["tokens", "modes", "densities", "recipes", "name", "id", "version", "defaultMode"].includes(
parts[0]!,
) ||
parts.some((p) => !p || forbidden.has(p)) ||
parts.length > 8
)
throw Error("WRN-STUDIO-LOCK-PATH: unsupported decision path.");
return parts;
}
export const pointerPart = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1");
export function readDecision(value: unknown, path: string): unknown {
let current = value;
for (const part of decisionPath(path)) {
if (!current || typeof current !== "object" || !Object.hasOwn(current, part)) return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
export function writeDecision(system: DesignSystem, path: string, value: unknown) {
const parts = decisionPath(path);
let target = system as unknown as Record<string, unknown>;
for (const part of parts.slice(0, -1)) {
if (!Object.hasOwn(target, part)) target[part] = {};
if (!target[part] || typeof target[part] !== "object" || Array.isArray(target[part]))
throw Error("WRN-STUDIO-PATCH: parent must be an object.");
target = target[part] as Record<string, unknown>;
}
const last = parts.at(-1)!;
if (value === undefined) delete target[last];
else target[last] = structuredClone(value);
}
export function validateLocks(locks: unknown): asserts locks is DecisionLock[] {
if (
!Array.isArray(locks) ||
locks.length > 128 ||
locks.some((p) => typeof p !== "string") ||
new Set(locks).size !== locks.length
)
throw Error("WRN-STUDIO-LOCKS: provide up to 128 unique decision paths.");
for (const path of locks) decisionPath(path);
}
function references(value: unknown, result = new Set<string>()) {
if (value && typeof value === "object") {
if ("ref" in value && typeof value.ref === "string") result.add(value.ref);
else for (const child of Object.values(value)) references(child, result);
}
return result;
}
function tokenNames(system: DesignSystem, path: string) {
const [kind, layer, name] = decisionPath(path);
const names = new Set<string>();
if (kind === "tokens")
for (const token of Object.keys(system.tokens)) if (!layer || token === layer) names.add(token);
if (kind === "modes" || kind === "densities") {
const layers = system[kind] ?? {};
for (const [key, tokens] of Object.entries(layers))
if (!layer || key === layer)
for (const token of Object.keys(tokens)) if (!name || name === token) names.add(token);
}
if (kind === "recipes") references(readDecision(system, path), names);
return names;
}
function configurations(a: DesignSystem, b: DesignSystem) {
const modes = [
undefined,
...new Set([...Object.keys(a.modes ?? {}), ...Object.keys(b.modes ?? {})]),
];
const densities = [
undefined,
...new Set([...Object.keys(a.densities ?? {}), ...Object.keys(b.densities ?? {})]),
];
if (modes.length * densities.length > 128)
throw Error("WRN-STUDIO-LOCK-LIMIT: too many lock configurations.");
return modes.flatMap((mode) => densities.map((density) => ({ mode, density })));
}
/** Locks protect raw decisions and effective referenced values, including aliases and modes. */
export function checkDecisionLocks(
before: DesignSystem,
after: DesignSystem,
locks: DecisionLock[],
): DesignDiagnostic[] {
validateLocks(locks);
if (!locks.length) return [];
const diagnostics: DesignDiagnostic[] = [];
const fail = (path: string, message: string) =>
diagnostics.push({ code: "WRN-STUDIO-LOCK", path, message });
const configurationsList = configurations(before, after);
const resolve = (system: DesignSystem, mode?: string, density?: string) =>
resolveDesignSystem(system, {
mode: mode && Object.hasOwn(system.modes ?? {}, mode) ? mode : undefined,
density: density && Object.hasOwn(system.densities ?? {}, density) ? density : undefined,
}).tokens;
const pairs = configurationsList.map(({ mode, density }) => ({
mode,
density,
before: resolve(before, mode, density),
after: resolve(after, mode, density),
}));
for (const path of locks) {
if (JSON.stringify(readDecision(before, path)) !== JSON.stringify(readDecision(after, path))) {
fail(path, "This decision is locked. Unlock it before changing or removing it.");
continue;
}
const names = tokenNames(before, path);
let changed = false;
for (const pair of pairs) {
const parts = decisionPath(path);
if (parts[0] === "modes" && parts[1] && pair.mode !== parts[1]) continue;
if (parts[0] === "densities" && parts[1] && pair.density !== parts[1]) continue;
for (const name of names)
if (
JSON.stringify({ type: pair.before[name]?.type, value: pair.before[name]?.value }) !==
JSON.stringify({ type: pair.after[name]?.type, value: pair.after[name]?.value })
) {
changed = true;
break;
}
if (changed) break;
}
if (changed)
fail(path, "A referenced token or mode changes the effective value of this locked decision.");
}
return diagnostics;
}
/** Local directions copy lock dependencies; arbitrary provider patches must pass checks unchanged. */
export function preserveDecisionLocks(
before: DesignSystem,
after: DesignSystem,
locks: DecisionLock[],
) {
validateLocks(locks);
const next = structuredClone(after);
const names = new Set<string>();
for (const path of locks) {
writeDecision(next, path, readDecision(before, path));
for (const name of tokenNames(before, path)) names.add(name);
}
// The default selection is part of an effective token/recipe decision.
if (names.size) next.defaultMode = before.defaultMode;
for (const name of names) {
for (const token of [
before.tokens[name],
...Object.values(before.modes ?? {}).map((t) => t[name]),
...Object.values(before.densities ?? {}).map((t) => t[name]),
])
for (const ref of references(token)) names.add(ref);
writeDecision(next, "/tokens/" + pointerPart(name), before.tokens[name]);
for (const kind of ["modes", "densities"] as const)
for (const layer of new Set([
...Object.keys(before[kind] ?? {}),
...Object.keys(next[kind] ?? {}),
]))
writeDecision(
next,
`/${kind}/${pointerPart(layer)}/${pointerPart(name)}`,
before[kind]?.[layer]?.[name],
);
}
if (validateDesignSystem(next).length)
throw Error("WRN-STUDIO-LOCK: locked decisions cannot be combined with this direction.");
const failures = checkDecisionLocks(before, next, locks);
if (failures.length) throw Error(failures.map((d) => d.message).join(" "));
return next;
}
export async function designRevision(system: DesignSystem, locks: DecisionLock[] = []) {
validateLocks(locks);
const bytes = new TextEncoder().encode(JSON.stringify({ system, locks: [...locks].sort() }));
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
+563
View File
@@ -0,0 +1,563 @@
import {
CORE_DESIGN_SYSTEMS,
COMPONENT_CONTRACTS,
validateDesignSystem,
type DesignSystem,
type DesignDiagnostic,
type TokenMap,
} from "@wrnexus/design-system";
import {
checkDecisionLocks,
preserveDecisionLocks,
designRevision,
validateLocks,
writeDecision,
decisionPath,
type DecisionLock,
} from "./decisions.ts";
import { diffDesign } from "./session.ts";
export interface DesignBrief {
product: string;
audience: string;
tone: "restrained" | "warm" | "expressive";
geometry: "square" | "rounded" | "angular";
density: "compact" | "comfortable" | "spacious";
surface: "flat" | "soft" | "hard";
brandColor?: string;
references?: string;
constraints?: string;
}
export interface DesignProposal {
id: string;
name: string;
rationale: string;
system: DesignSystem;
changedPaths: string[];
}
export interface GenerationRequest {
system: DesignSystem;
locks: DecisionLock[];
brief: DesignBrief;
baseRevision: string;
instruction?: string;
kind: "directions" | "edit";
}
export interface GenerationBatch {
brief: DesignBrief;
locks: DecisionLock[];
instruction?: string;
baseRevision: string;
alternatives: DesignProposal[];
provenance: {
provider: string;
model?: string;
templateVersion: 1;
attempts: number;
maxOutputTokens: number;
createdAt: string;
};
diagnostics: DesignDiagnostic[];
}
export interface GenerationProvider {
name: string;
generate(
prompt: string,
options?: { system?: string; model?: string; maxTokens?: number; signal?: AbortSignal },
): Promise<{ value: string; provider: string; model?: string }>;
}
export const DEFAULT_DESIGN_BRIEF: DesignBrief = {
product: "",
audience: "",
tone: "restrained",
geometry: "square",
density: "comfortable",
surface: "flat",
};
const diagnostic = (code: string, message: string, path = "$"): DesignDiagnostic => ({
code,
path,
message,
});
export function validateGenerationRequest(input: unknown): asserts input is GenerationRequest {
if (!input || typeof input !== "object")
throw Error("WRN-STUDIO-GENERATION: request must be an object.");
const r = input as GenerationRequest;
if (validateDesignSystem(r.system).length)
throw Error("WRN-STUDIO-GENERATION: source design must validate.");
validateLocks(r.locks);
if (
!["directions", "edit"].includes(r.kind) ||
typeof r.baseRevision !== "string" ||
!/^[a-f0-9]{64}$/.test(r.baseRevision)
)
throw Error("WRN-STUDIO-GENERATION: invalid kind or revision.");
const b = r.brief;
if (
b &&
typeof b === "object" &&
Object.keys(b).some(
(name) =>
![
"product",
"audience",
"tone",
"geometry",
"density",
"surface",
"brandColor",
"references",
"constraints",
].includes(name),
)
)
throw Error("WRN-STUDIO-BRIEF: unknown brief field.");
if (
!b ||
typeof b !== "object" ||
!["restrained", "warm", "expressive"].includes(b.tone) ||
!["square", "rounded", "angular"].includes(b.geometry) ||
!["compact", "comfortable", "spacious"].includes(b.density) ||
!["flat", "soft", "hard"].includes(b.surface)
)
throw Error("WRN-STUDIO-BRIEF: choose valid design options.");
for (const name of ["product", "audience", "references", "constraints"] as const)
if (b[name] !== undefined && (typeof b[name] !== "string" || b[name]!.length > 2000))
throw Error("WRN-STUDIO-BRIEF: text fields are limited to 2000 characters.");
if (typeof b.product !== "string" || typeof b.audience !== "string")
throw Error("WRN-STUDIO-BRIEF: product and audience must be text.");
if (b.brandColor && !/^#[a-f0-9]{6}$/i.test(b.brandColor))
throw Error("WRN-STUDIO-BRIEF: brand color must be #RRGGBB.");
if (
r.instruction !== undefined &&
(typeof r.instruction !== "string" || r.instruction.length > 2000)
)
throw Error("WRN-STUDIO-INSTRUCTION: enter up to 2000 characters.");
if (
r.kind === "edit" &&
(typeof r.instruction !== "string" || !r.instruction.trim() || r.instruction.length > 2000)
)
throw Error("WRN-STUDIO-INSTRUCTION: enter up to 2000 characters.");
}
function color(system: DesignSystem, hex: string) {
const rgb = hex
.slice(1)
.match(/../g)!
.map((n) => parseInt(n, 16));
const luminance = rgb
.map((n) => {
const c = n / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
})
.reduce((s, c, i) => s + c * [0.2126, 0.7152, 0.0722][i]!, 0);
const foreground = (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#ffffff";
const shade = (factor: number) =>
"#" +
rgb
.map((n) =>
Math.round(n * factor)
.toString(16)
.padStart(2, "0"),
)
.join("");
for (const tokens of [system.tokens, ...Object.values(system.modes ?? {})])
for (const [name, value] of Object.entries({
"color.primary": hex,
"color.primary-hover": shade(0.88),
"color.primary-active": shade(0.75),
"color.primary-contrast": foreground,
})) {
if (tokens[name]) tokens[name] = { ...tokens[name]!, value };
}
}
function foundation(system: DesignSystem, name: string, value: string) {
if (system.tokens[name]) system.tokens[name] = { ...system.tokens[name]!, value };
}
function applyGeometry(system: DesignSystem, geometry: DesignBrief["geometry"]) {
foundation(system, "shape.control", geometry === "rounded" ? "16px" : "0");
foundation(system, "shape.panel", geometry === "rounded" ? "24px" : "0");
foundation(system, "shape.dialog", geometry === "rounded" ? "32px" : "0");
foundation(system, "shape.base", geometry === "rounded" ? "24px" : "0");
// Independent rotation preserves interaction transforms, matching the existing Angular contract.
for (const [name, part, angle] of [
["Button", "control", "-2deg"],
["Card", "panel", "1deg"],
] as const) {
const recipe = system.recipes?.[name];
if (!recipe) continue;
const styles = ((recipe.base ??= {})[part] ??= {});
styles.rotate = geometry === "angular" ? angle : "0deg";
}
}
function applyDensity(system: DesignSystem, density: DesignBrief["density"]) {
const compact = density === "compact",
spacious = density === "spacious";
foundation(system, "space.control", compact ? "0.625rem" : spacious ? "1.25rem" : "0.875rem");
foundation(system, "space.panel", compact ? "0.75rem" : spacious ? "1.75rem" : "1.25rem");
foundation(system, "space.page", compact ? "1rem" : spacious ? "2rem" : "1.5rem");
foundation(system, "control.height", compact ? "2.25rem" : spacious ? "3rem" : "2.5rem");
}
function applySurface(system: DesignSystem, surface: DesignBrief["surface"]) {
foundation(
system,
"elevation.panel",
surface === "flat"
? "none"
: surface === "soft"
? "0 8px 24px rgba(0, 0, 0, 0.12)"
: "4px 4px 0 #000000",
);
}
function mapLayers(
base: Record<string, TokenMap> | undefined,
extra: Record<string, TokenMap> | undefined,
) {
return Object.fromEntries(
[...new Set([...Object.keys(base ?? {}), ...Object.keys(extra ?? {})])].map((name) => [
name,
{ ...base?.[name], ...extra?.[name] },
]),
);
}
function direction(
request: GenerationRequest,
preset: keyof typeof CORE_DESIGN_SYSTEMS,
index: number,
) {
const template = CORE_DESIGN_SYSTEMS[preset];
const system: DesignSystem = {
...structuredClone(request.system),
tokens: { ...structuredClone(request.system.tokens), ...structuredClone(template.tokens) },
modes: mapLayers(request.system.modes, template.modes),
recipes: { ...structuredClone(request.system.recipes), ...structuredClone(template.recipes) },
};
const desired =
index === 0
? request.brief.geometry
: preset === "soft"
? "rounded"
: preset === "angular"
? "angular"
: "square";
applyGeometry(system, desired);
applyDensity(
system,
index === 0 ? request.brief.density : preset === "soft" ? "spacious" : "compact",
);
applySurface(
system,
index === 0
? request.brief.surface
: preset === "angular"
? "hard"
: preset === "soft"
? "soft"
: "flat",
);
if (request.brief.brandColor) color(system, request.brief.brandColor);
return preserveDecisionLocks(request.system, system, request.locks);
}
function localEdit(request: GenerationRequest) {
const text = request.instruction!.trim().toLowerCase();
const system = structuredClone(request.system);
const clauses = text.split(/\s*(?:;|,|\band\b)\s*/).filter(Boolean);
if (clauses.length > 8) throw Error("Use at most eight local edit clauses.");
for (const clause of clauses) {
if (
/^(?:make (?:the )?(?:buttons?|controls?|corners?) |use |set (?:the )?geometry (?:to )?)?(?:square|sharp)(?: corners?| buttons?| controls?)?$/.test(
clause,
)
)
applyGeometry(system, "square");
else if (
/^(?:make (?:the )?(?:buttons?|controls?|corners?) |use |set (?:the )?geometry (?:to )?)?(?:rounded|round|rounder)(?: corners?| buttons?| controls?)?$/.test(
clause,
)
)
applyGeometry(system, "rounded");
else if (/^(?:tilt (?:the )?buttons?|use angular(?: buttons?| geometry)?)$/.test(clause))
applyGeometry(system, "angular");
else if (
/^(?:(?:make (?:it |the layout )?|use )(?:more )?)?(compact|comfortable|spacious)(?: spacing| density| layout)?$/.test(
clause,
)
)
applyDensity(
system,
clause.includes("compact")
? "compact"
: clause.includes("spacious")
? "spacious"
: "comfortable",
);
else if (/^(?:remove shadows|no shadows|use flat surfaces)$/.test(clause))
applySurface(system, "flat");
else if (/^(?:use soft shadows|add soft shadows)$/.test(clause)) applySurface(system, "soft");
else if (/^(?:use hard shadows|add hard shadows)$/.test(clause)) applySurface(system, "hard");
else if (
/^(?:use |set (?:the )?(?:brand|primary)(?: color)? (?:to )?)(?:brand |primary |color )?#[a-f0-9]{6}$/.test(
clause,
)
)
color(system, clause.match(/#[a-f0-9]{6}/)![0]);
else if (/^(?:use )?(serif|sans-serif|monospace)(?: typography| fonts?)?$/.test(clause)) {
const font = clause.includes("sans-serif")
? "Helvetica, Arial, sans-serif"
: clause.includes("monospace")
? '"Courier New", monospace'
: "Georgia, serif";
foundation(system, "type.body", font);
foundation(system, "type.heading", font);
} else
throw Error(
`WRN-STUDIO-LOCAL-EDIT: unsupported clause '${clause}'. Use square/rounded controls, compact/spacious spacing, flat/soft/hard shadows, serif/sans-serif/monospace typography, or set primary color to #RRGGBB. Broader requests require a configured provider.`,
);
}
const failures = checkDecisionLocks(request.system, system, request.locks);
if (failures.length) throw Error("WRN-STUDIO-LOCK: " + failures.map((d) => d.path).join(", "));
return system;
}
function proposal(
request: GenerationRequest,
system: DesignSystem,
index: number,
name: string,
rationale: string,
): DesignProposal {
const errors = validateDesignSystem(system);
if (errors.length) throw Error(errors.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n"));
const locks = checkDecisionLocks(request.system, system, request.locks);
if (locks.length) throw Error("WRN-STUDIO-LOCK: " + locks.map((d) => d.path).join(", "));
return {
id: "direction-" + (index + 1),
name,
rationale,
system,
changedPaths: diffDesign(request.system, system),
};
}
export function applyDesignPatch(system: DesignSystem, patch: unknown): DesignSystem {
if (!Array.isArray(patch) || patch.length > 48)
throw Error("WRN-STUDIO-PATCH: at most 48 operations per alternative.");
const next = structuredClone(system);
for (const op of patch) {
if (
!op ||
typeof op !== "object" ||
!["set", "remove"].includes(op.op) ||
typeof op.path !== "string"
)
throw Error("WRN-STUDIO-PATCH: expected set/remove path operations.");
decisionPath(op.path);
if (op.op === "set" && !Object.hasOwn(op, "value"))
throw Error("WRN-STUDIO-PATCH: set requires value.");
writeDecision(next, op.path, op.op === "remove" ? undefined : op.value);
}
return next;
}
export async function generateDesigns(
request: GenerationRequest,
options: {
provider?: GenerationProvider;
model?: string;
signal?: AbortSignal;
maxOutputTokens?: number;
timeoutMs?: number;
} = {},
): Promise<GenerationBatch> {
validateGenerationRequest(request);
if (request.baseRevision !== (await designRevision(request.system, request.locks)))
throw Error("WRN-STUDIO-CONFLICT: source revision does not match.");
const budget = options.maxOutputTokens ?? 4096;
if (!Number.isInteger(budget) || budget < 256 || budget > 8192)
throw Error("WRN-STUDIO-BUDGET: output budget must be 256–8192 tokens.");
const timeout = options.timeoutMs ?? 20000;
if (!Number.isInteger(timeout) || timeout < 10 || timeout > 60000)
throw Error("WRN-STUDIO-BUDGET: timeout must be 10–60000 ms.");
const signal = AbortSignal.any([
...(options.signal ? [options.signal] : []),
AbortSignal.timeout(timeout),
]);
signal.throwIfAborted();
const batch: GenerationBatch = {
brief: structuredClone(request.brief),
locks: [...request.locks],
instruction: request.instruction,
baseRevision: request.baseRevision,
alternatives: [],
diagnostics: [],
provenance: {
provider: options.provider?.name ?? "local-rules",
model: options.model,
templateVersion: 1,
attempts: 0,
maxOutputTokens: options.provider ? budget : 0,
createdAt: new Date().toISOString(),
},
};
if (!options.provider) {
batch.provenance.attempts = 1;
try {
if (request.kind === "edit")
batch.alternatives = [
proposal(
request,
localEdit(request),
0,
"Requested edit",
"Local rule-based edit. Review the exact changed paths before accepting.",
),
];
else {
const first =
request.brief.geometry === "rounded"
? "soft"
: request.brief.geometry === "angular"
? "angular"
: request.brief.tone === "warm"
? "soft"
: request.brief.tone === "expressive"
? "angular"
: "square";
const order = [
first,
...(["square", "soft", "angular"] as const).filter((name) => name !== first),
] as Array<keyof typeof CORE_DESIGN_SYSTEMS>;
batch.alternatives = order.map((name, index) =>
proposal(
request,
direction(request, name, index),
index,
index === 0 ? "Brief direction" : CORE_DESIGN_SYSTEMS[name].name,
`${index === 0 ? "Uses your geometry, spacing and surface choices." : "An alternative foundation direction."} ${request.locks.length} locked decisions preserved. Product, audience and reference prose are recorded as context; local rules do not interpret free-form prose.`,
),
);
if (new Set(batch.alternatives.map((p) => JSON.stringify(p.system))).size !== 3)
throw Error(
"Locked decisions or brief choices collapse the directions. Unlock a decision or refine the brief to obtain three distinct alternatives.",
);
}
} catch (error) {
batch.alternatives = [];
batch.diagnostics = [diagnostic("WRN-STUDIO-GENERATION", (error as Error).message)];
}
signal.throwIfAborted();
return batch;
}
const propertyProfiles: string[][] = [];
const profile = (properties: readonly string[]) => {
const key = JSON.stringify(properties);
const index = propertyProfiles.findIndex((p) => JSON.stringify(p) === key);
if (index >= 0) return index;
propertyProfiles.push([...properties]);
return propertyProfiles.length - 1;
};
const capabilities = Object.fromEntries(
Object.entries(COMPONENT_CONTRACTS).map(([name, c]) => [
name,
{
parts: Object.fromEntries(
Object.entries(c.parts).map(([part, p]) => [part, profile(p.properties)]),
),
axes: c.axes,
states: Object.fromEntries(
Object.entries(c.parts)
.filter(([, p]) => Object.keys(p.states).length)
.map(([part, p]) => [part, Object.keys(p.states)]),
),
},
]),
);
const context = {
propertyProfiles,
templateVersion: 1,
kind: request.kind,
brief: request.brief,
instruction: request.instruction,
locks: request.locks,
system: request.system,
capabilities,
};
const rules =
'Return JSON only: {"alternatives":[{"name":"...","rationale":"...","patch":[{"op":"set","path":"/tokens/shape.control/value","value":"0"}]}]}. Return exactly three DISTINCT alternatives for directions, one for edit. Capability part values index propertyProfiles; states list accepted state names. Paths are JSON pointers, escape dots as literal characters (no escape needed). Only set/remove existing schema decisions: tokens, modes, densities, recipes, id/name/version/defaultMode. Never change locked raw or effective values, including token aliases. Use only declared component parts/properties/axes. No tools, code execution, remote assets or new schema fields. Do not embed secrets. A set operation has a value. Preserve source identity unless explicitly requested. Each alternative has at most 48 operations. Respect the brief and explain its tradeoffs.';
let repair = "";
for (let attempt = 0; attempt < 2; attempt++) {
signal.throwIfAborted();
batch.provenance.attempts = attempt + 1;
const prompt = JSON.stringify(context) + repair;
if (prompt.length > 180000)
throw Error("WRN-STUDIO-BUDGET: generation context exceeds 180,000 characters.");
let stop: () => void = () => {};
let received = false;
try {
const aborted = new Promise<never>((_, reject) => {
const listener = () =>
reject(new DOMException("Generation cancelled or timed out.", "AbortError"));
signal.addEventListener("abort", listener, { once: true });
stop = () => signal.removeEventListener("abort", listener);
if (signal.aborted) listener();
});
const output = await Promise.race([
options.provider.generate(prompt, {
system: rules,
model: options.model,
maxTokens: budget,
signal,
}),
aborted,
]);
received = true;
signal.throwIfAborted();
if (typeof output.value !== "string" || output.value.length > 100000)
throw Error("Provider output exceeds 100,000 characters.");
const data = JSON.parse(output.value.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, ""));
const count = request.kind === "directions" ? 3 : 1;
if (!Array.isArray(data.alternatives) || data.alternatives.length !== count)
throw Error(`Expected ${count} alternatives.`);
const alternatives = data.alternatives.map((item: unknown, index: number) => {
if (!item || typeof item !== "object") throw Error("Invalid alternative.");
const entry = item as Record<string, unknown>;
if (
typeof entry.name !== "string" ||
entry.name.length > 120 ||
typeof entry.rationale !== "string" ||
entry.rationale.length > 2000
)
throw Error("Alternative name/rationale is invalid.");
return proposal(
request,
applyDesignPatch(request.system, entry.patch),
index,
entry.name,
entry.rationale,
);
});
if (new Set(alternatives.map((p: DesignProposal) => JSON.stringify(p.system))).size !== count)
throw Error("Alternatives must be distinct.");
batch.alternatives = alternatives;
batch.diagnostics = [];
batch.provenance.model = output.model ?? options.model;
return batch;
} catch (error) {
if (signal.aborted)
throw new DOMException("Generation cancelled or timed out.", "AbortError");
if (!received) {
batch.diagnostics = [
diagnostic(
"WRN-STUDIO-PROVIDER",
"Provider request failed. Your draft is unchanged; check server-side provider configuration.",
),
];
return batch;
}
const message = (error as Error).message;
batch.diagnostics = [diagnostic("WRN-STUDIO-PROPOSAL", message)];
repair =
"\nThe previous attempt was invalid. Repair these diagnostics without changing locks: " +
message;
} finally {
stop();
}
}
return batch;
}
@@ -0,0 +1,12 @@
export const guidanceHtml = `<section id="guided" class="editor" hidden>
<p>Create three directions from a brief, then preview and review each before changing your draft.</p><p id="generation-provider" class="help">Local rules · no network generation</p>
<div class="guide-step" data-step="0"><h3>1. Product and people</h3><label>What are you building?<input id="brief-product" maxlength="2000" placeholder="For example, a booking website"></label><label>Who will use it?<input id="brief-audience" maxlength="2000" placeholder="For example, people booking on their phone"></label></div>
<div class="guide-step" data-step="1" hidden><h3>2. Visual decisions</h3><label>Visual tone<select id="brief-tone"><option value="restrained">Restrained</option><option value="warm">Warm</option><option value="expressive">Expressive</option></select></label><label>Geometry<select id="brief-geometry"><option value="square">Square</option><option value="rounded">Rounded</option><option value="angular">Angular / tilted</option></select></label><div class="geometry-choices"><button type="button" data-geometry="square"><span class="geometry-square">Button</span>Square</button><button type="button" data-geometry="rounded"><span class="geometry-rounded">Button</span>Rounded</button><button type="button" data-geometry="angular"><span class="geometry-angular">Button</span>Angular</button></div><div class="row"><label>Density<select id="brief-density"><option value="compact">Compact</option><option value="comfortable">Comfortable</option><option value="spacious">Spacious</option></select></label><label>Surfaces<select id="brief-surface"><option value="flat">Flat / outlined</option><option value="soft">Soft shadows</option><option value="hard">Hard shadows</option></select></label></div></div>
<div class="guide-step" data-step="2" hidden><h3>3. Brand and constraints</h3><label>Brand color, if fixed<input id="brief-brandColor" placeholder="#002FA7" maxlength="7"></label><label>Reference notes<textarea id="brief-references" rows="3" maxlength="2000" placeholder="Describe references; URLs are recorded, not fetched."></textarea></label><label>Constraints<textarea id="brief-constraints" rows="3" maxlength="2000" placeholder="Describe requirements. Use decision locks below to enforce exact values."></textarea></label><p class="help">Local rules use the selected visual choices and brand color. Product, audience and prose are saved as context. A configured LLM can interpret the prose; exact decisions are enforced by locks.</p></div>
<div class="row"><button id="brief-back">Back</button><button id="brief-next">Next</button><button id="generate-directions" class="primary">Generate three directions</button><button id="cancel-generation" disabled>Cancel</button></div><p id="generation-state" role="status" aria-live="polite"></p>
<details open><summary>Decision locks</summary><p class="help">Locks protect manual edits, imports, generation and token aliases across modes. Unlock a decision before changing it. Locks override conflicting brief choices. Lock changes are explicit preferences and are independent of undo/redo.</p><div class="row"><button data-lock-group="colors">Lock colors</button><button data-lock-group="geometry">Lock geometry</button><button data-lock-group="typography">Lock typography</button><button data-lock-group="spacing">Lock spacing</button><button data-lock-group="elevation">Lock elevation</button></div><label>Specific decision (JSON pointer)<input id="decision-path" placeholder="/tokens/shape.control or /recipes/Button"></label><button id="lock-decision">Lock decision</button><ul id="decision-locks"></ul></details>
<details open><summary>Edit with words</summary><label>Requested change<textarea id="edit-instruction" rows="3" maxlength="2000" placeholder="Make buttons square and use compact spacing"></textarea></label><p class="help">Local examples: rounded controls; use spacious spacing; remove shadows; use serif typography; set primary color to #002FA7. Unrecognized clauses are reported, not guessed.</p><button id="generate-edit">Propose edit</button></details>
<h3>Alternatives</h3><p class="help">Every alternative uses the same composed preview content. Choosing one creates a normal recoverable draft revision; applying to an application still uses Review & apply.</p><div id="design-alternatives"></div>
<details id="combine-panel" hidden><summary>Combine foundation decisions</summary><p class="help">Mix named color, shape, type, spacing and elevation groups. The combined proposal is validated again before review.</p><div id="combine-choices"></div><button id="combine-directions">Propose combination</button></details><pre id="proposal-diff"></pre><button id="accept-proposal" disabled>Use reviewed proposal</button><button id="export-generation" disabled>Export generation report</button>
</section>`;
export const guidanceCss = `.geometry-choices{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.geometry-choices button{display:grid;gap:14px;padding:16px 8px;font-size:12px}.geometry-choices span{display:block;padding:8px;border:1px solid #171719;background:#f7f7f8}.geometry-rounded{border-radius:18px}.geometry-angular{rotate:-3deg;box-shadow:3px 3px 0 #171719}.geometry-choices [aria-pressed=true]{border-color:#002fa7;outline:1px solid #002fa7}.alternative{border-top:1px solid #d9d9de;padding:18px 0}.alternative h4{margin:0 0 8px;font-size:16px;font-weight:500}.alternative p{font-size:12px}.alternative iframe{height:300px;margin:12px 0}#decision-locks{max-height:180px;overflow:auto;padding-left:20px;font-size:12px;overflow-wrap:anywhere}#decision-locks li{margin-bottom:8px}#decision-locks button{font-size:11px;margin-left:8px;padding:4px 8px}#generation-state{white-space:pre-wrap;font-size:12px;color:#002fa7}`;
+444
View File
@@ -0,0 +1,444 @@
import {
DEFAULT_DESIGN_BRIEF,
type DesignBrief,
type GenerationBatch,
type DesignProposal,
} from "./generation.ts";
import {
designRevision,
checkDecisionLocks,
pointerPart,
readDecision,
writeDecision,
validateLocks,
} from "./decisions.ts";
import { StudioSession, reviewDesign } from "./session.ts";
import { validateDesignSystem, type DesignSystem } from "@wrnexus/design-system";
const groups = ["colors", "geometry", "typography", "spacing", "elevation"] as const;
export type FoundationGroup = (typeof groups)[number];
export function foundationDecisions(system: DesignSystem, group: FoundationGroup) {
const names = Object.keys(system.tokens).filter((name) =>
group === "colors"
? name.startsWith("color.")
: group === "geometry"
? name.startsWith("shape.")
: group === "typography"
? name.startsWith("type.")
: group === "spacing"
? name.startsWith("space.") || name === "control.height"
: name.startsWith("elevation."),
);
const paths = names.map((name) => "/tokens/" + pointerPart(name));
if (group === "geometry")
paths.push("/recipes/Button/base/control/rotate", "/recipes/Card/base/panel/rotate");
return paths;
}
export async function combineDesignProposals(
system: DesignSystem,
batch: GenerationBatch,
choices: Partial<Record<FoundationGroup, string>>,
locks: string[],
) {
if (batch.baseRevision !== (await designRevision(system, locks)))
throw Error("WRN-STUDIO-CONFLICT: the source or locks changed. Generate again.");
const next = structuredClone(system);
for (const group of groups) {
const id = choices[group];
if (!id) continue;
const alternative = batch.alternatives.find((p) => p.id === id);
if (!alternative) throw Error("Unknown alternative.");
for (const path of foundationDecisions(alternative.system, group)) {
writeDecision(next, path, readDecision(alternative.system, path));
if (path.startsWith("/tokens/")) {
const name = path.slice("/tokens/".length);
for (const kind of ["modes", "densities"] as const)
for (const layer of new Set([
...Object.keys(next[kind] ?? {}),
...Object.keys(alternative.system[kind] ?? {}),
]))
writeDecision(
next,
`/${kind}/${pointerPart(layer)}/${name}`,
readDecision(alternative.system, `/${kind}/${pointerPart(layer)}/${name}`),
);
}
}
}
const diagnostics = [...validateDesignSystem(next), ...checkDecisionLocks(system, next, locks)];
if (diagnostics.length)
throw Error(diagnostics.map((d) => `${d.code}: ${d.path} ${d.message}`).join("\n"));
return next;
}
/** Browser controller; proposals live separately from committed canonical draft history. */
export function setupGuidance(
session: StudioSession,
hooks: {
message: (text: string) => void;
changed: () => Promise<void>;
preview: (system: DesignSystem) => Promise<string>;
},
) {
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const escape = (s: unknown) =>
String(s).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
const value = (id: string) => $(id) as HTMLInputElement;
let brief: DesignBrief = structuredClone(DEFAULT_DESIGN_BRIEF),
batch: GenerationBatch | undefined,
selected: DesignProposal | undefined,
step = 0;
let controller: AbortController | undefined,
epoch = 0,
rendered: GenerationBatch | undefined,
status = "Ready to generate proposals. Your current draft is unchanged.";
const key = "wrnexus.design-studio.guidance.v1";
function save() {
try {
localStorage.setItem(
key,
JSON.stringify({ brief, batch, instruction: value("edit-instruction").value }),
);
} catch {
hooks.message(
"Guided workspace autosave is full. Export its report; the canonical draft is preserved separately.",
);
}
}
try {
const stored = localStorage.getItem(key);
if (stored) {
if (stored.length > 2_000_000) throw Error("Guidance history too large.");
const parsed = JSON.parse(stored);
if (parsed.brief && typeof parsed.brief === "object") brief = { ...brief, ...parsed.brief };
if (
parsed.batch &&
parsed.batch.provenance &&
typeof parsed.batch.provenance.provider === "string" &&
/^[a-f0-9]{64}$/.test(parsed.batch.baseRevision) &&
Array.isArray(parsed.batch.alternatives) &&
parsed.batch.alternatives.length <= 3 &&
parsed.batch.alternatives.every(
(p: DesignProposal) =>
p &&
typeof p.id === "string" &&
typeof p.name === "string" &&
typeof p.rationale === "string" &&
Array.isArray(p.changedPaths) &&
p.changedPaths.every((path) => typeof path === "string") &&
!validateDesignSystem(p.system).length,
)
)
batch = parsed.batch;
if (typeof parsed.instruction === "string")
value("edit-instruction").value = parsed.instruction.slice(0, 2000);
}
} catch {
status = "Guided workspace could not be restored. The canonical draft is preserved.";
}
for (const name of Object.keys(DEFAULT_DESIGN_BRIEF).concat([
"brandColor",
"references",
"constraints",
]) as Array<keyof DesignBrief>) {
const input = value("brief-" + name);
if (input) input.value = brief[name] ?? "";
}
function readBrief() {
const next = {} as Record<string, string>;
for (const name of [
"product",
"audience",
"tone",
"geometry",
"density",
"surface",
"brandColor",
"references",
"constraints",
])
next[name] = value("brief-" + name).value;
brief = next as unknown as DesignBrief;
save();
return brief;
}
function event(id: string, action: () => void | Promise<void>) {
$(id).addEventListener("click", () =>
Promise.resolve()
.then(action)
.catch((e) => {
status = e.message;
render();
hooks.message(e.message);
}),
);
}
async function fresh() {
if (
!batch ||
session.diagnostics.length ||
JSON.stringify(readBrief()) !== JSON.stringify(batch.brief) ||
batch.baseRevision !== (await designRevision(session.valid, session.locks))
)
throw Error(
"WRN-STUDIO-CONFLICT: source, brief or locks changed. Resolve draft errors and generate again.",
);
}
function review(proposal: DesignProposal) {
selected = proposal;
$("proposal-diff").textContent =
reviewDesign(session.valid, proposal.system)
.map(
(d) =>
`${d.path}\nBefore: ${JSON.stringify(d.before) ?? "(absent)"}\nAfter: ${JSON.stringify(d.after) ?? "(removed)"}`,
)
.join("\n\n") || "No changes.";
$("accept-proposal").removeAttribute("disabled");
}
function render() {
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((button) => {
const group = button.dataset.lockGroup as FoundationGroup;
const paths = foundationDecisions(session.valid, group);
const locked = paths.length > 0 && paths.every((path) => session.locks.includes(path));
button.textContent = (locked ? "Unlock " : "Lock ") + group;
button.setAttribute("aria-pressed", String(locked));
});
$("generation-state").textContent = status;
$("brief-back").toggleAttribute("disabled", step === 0);
$("brief-next").toggleAttribute("disabled", step === 2);
document.querySelectorAll<HTMLElement>(".guide-step").forEach((el) => {
el.hidden = Number(el.dataset.step) !== step;
});
for (const id of ["generate-directions", "generate-edit"])
$(id).toggleAttribute("disabled", !!controller || !!session.diagnostics.length);
$("cancel-generation").toggleAttribute("disabled", !controller);
document
.querySelectorAll<HTMLButtonElement>("[data-geometry]")
.forEach((b) =>
b.setAttribute(
"aria-pressed",
String(b.dataset.geometry === value("brief-geometry").value),
),
);
$("decision-locks").innerHTML =
session.locks
.map(
(path) => `<li>${escape(path)}<button data-unlock="${escape(path)}">Unlock</button></li>`,
)
.join("") || "<li>No decisions locked.</li>";
$("export-generation").toggleAttribute("disabled", !batch);
if (!batch || rendered === batch) return;
rendered = batch;
$("design-alternatives").innerHTML = batch.alternatives
.map(
(p, i) =>
`<article class="alternative"><h4>${escape(p.name)}</h4><p>${escape(p.rationale)}</p><p>${p.changedPaths.length} changed paths</p><div class="frame-scroll"><iframe title="Alternative ${i + 1} preview" id="alternative-${i}" sandbox="allow-scripts"></iframe></div><button data-review-proposal="${escape(p.id)}">Review ${escape(p.name)}</button></article>`,
)
.join("");
$("combine-panel").hidden = batch.alternatives.length < 2;
$("combine-choices").innerHTML = groups
.map(
(group) =>
`<label>${group}<select data-combine="${group}"><option value="">Current draft</option>${batch!.alternatives.map((p) => `<option value="${escape(p.id)}">${escape(p.name)}</option>`).join("")}</select></label>`,
)
.join("");
const displayed = batch;
batch.alternatives.forEach((p, i) => {
hooks
.preview(p.system)
.then((url) => {
if (batch === displayed) $<HTMLIFrameElement>("alternative-" + i).src = url;
})
.catch(() => {
status = "An alternative preview failed. Refresh generation before accepting.";
$("generation-state").textContent = status;
});
});
}
async function generate(kind: "directions" | "edit") {
if (session.diagnostics.length) throw Error("Resolve the source draft before generating.");
readBrief();
if (kind === "directions" && (!brief.product.trim() || !brief.audience.trim())) {
step = 0;
throw Error("Describe your product and audience first.");
}
const current = ++epoch;
controller = new AbortController();
const signal = controller.signal;
const system = structuredClone(session.valid),
locks = [...session.locks];
const requestedBrief = structuredClone(brief);
const baseRevision = await designRevision(system, locks);
selected = undefined;
$("accept-proposal").setAttribute("disabled", "");
status = "Generating proposals. Your draft is unchanged.";
render();
try {
const response = await fetch(new URL("./api/generate", document.baseURI), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
system,
locks,
brief: requestedBrief,
baseRevision,
kind,
instruction: kind === "edit" ? value("edit-instruction").value : undefined,
}),
signal,
});
const result = await response.json();
if (!response.ok) throw Error(result.error ?? "Generation failed.");
if (current !== epoch || signal.aborted) return;
if (result.diagnostics.length) {
status = result.diagnostics.map((d: { message: string }) => d.message).join("\n");
return;
}
batch = result;
status = `${batch!.alternatives.length} proposals ready. Review one before using it. ${batch!.provenance.provider}; ${batch!.provenance.attempts} attempt(s).`;
if (
batch!.baseRevision !== (await designRevision(session.valid, session.locks)) ||
JSON.stringify(brief) !== JSON.stringify(requestedBrief)
)
status =
"Proposals arrived after the draft or locks changed. They are preserved for inspection; regenerate before accepting.";
save();
} catch (error) {
if (current === epoch)
status = signal.aborted
? "Generation cancelled. Your draft and prior alternatives are preserved."
: `Generation failed. Your draft is unchanged. ${(error as Error).message}`;
} finally {
if (current === epoch) {
controller = undefined;
render();
}
}
}
event("brief-back", () => {
step = Math.max(0, step - 1);
readBrief();
render();
});
event("brief-next", () => {
step = Math.min(2, step + 1);
readBrief();
render();
});
event("generate-directions", () => generate("directions"));
event("generate-edit", () => generate("edit"));
event("cancel-generation", () => {
epoch++;
controller?.abort();
controller = undefined;
status = "Generation cancelled. Your draft and prior alternatives are preserved.";
render();
});
$("guided").addEventListener("input", () => {
readBrief();
});
document.querySelectorAll<HTMLButtonElement>("[data-geometry]").forEach((b) =>
b.addEventListener("click", () => {
value("brief-geometry").value = b.dataset.geometry!;
readBrief();
render();
}),
);
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((b) =>
b.addEventListener("click", () => {
try {
const paths = foundationDecisions(session.valid, b.dataset.lockGroup as FoundationGroup);
const all = paths.every((p) => session.locks.includes(p));
session.setLocks(
all
? session.locks.filter((p) => !paths.includes(p))
: [...new Set([...session.locks, ...paths])],
);
hooks.changed().catch((e) => hooks.message(e.message));
} catch (e) {
hooks.message((e as Error).message);
}
}),
);
event("lock-decision", async () => {
const path = value("decision-path").value;
validateLocks([...new Set([...session.locks, path])]);
session.setLocks([...new Set([...session.locks, path])]);
await hooks.changed();
});
$("decision-locks").addEventListener("click", (e) => {
const path = (e.target as HTMLElement).dataset.unlock;
if (path) {
session.setLocks(session.locks.filter((p) => p !== path));
hooks.changed().catch((e) => hooks.message(e.message));
}
});
$("design-alternatives").addEventListener("click", (e) => {
const id = (e.target as HTMLElement).dataset.reviewProposal;
if (!id) return;
fresh()
.then(() => {
const p = batch!.alternatives.find((p) => p.id === id);
if (p) review(p);
})
.catch((e) => hooks.message(e.message));
});
event("combine-directions", async () => {
await fresh();
const choices = Object.fromEntries(
[...document.querySelectorAll<HTMLSelectElement>("[data-combine]")].map((s) => [
s.dataset.combine!,
s.value,
]),
);
const system = await combineDesignProposals(session.valid, batch!, choices, session.locks);
review({
id: "combined",
name: "Combined foundations",
rationale: "Selected foundation groups, revalidated together.",
system,
changedPaths: reviewDesign(session.valid, system).map((d) => d.path),
});
});
event("accept-proposal", async () => {
await fresh();
if (!selected) throw Error("Review a proposal first.");
const failures = [
...validateDesignSystem(selected.system),
...checkDecisionLocks(session.valid, selected.system, session.locks),
];
if (failures.length) throw Error(failures.map((d) => d.message).join("\n"));
session.replace(selected.system, "Accepted " + selected.name);
status = "Proposal saved as a draft revision. Use Review & apply for the application.";
selected = undefined;
$("accept-proposal").setAttribute("disabled", "");
await hooks.changed();
});
event("export-generation", () => {
if (!batch) return;
const url = URL.createObjectURL(
new Blob([JSON.stringify({ brief, locks: session.locks, batch }, null, 2)], {
type: "application/json",
}),
);
const link = document.createElement("a");
link.href = url;
link.download = "design-generation-report.json";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
fetch(new URL("./api/generation", document.baseURI))
.then((r) => r.json())
.then((config) => {
$("generation-provider").textContent =
config.provider === "local-rules"
? "Local rules · no network generation"
: `${config.provider} / ${config.model ?? "configured model"} · Sends the current design, brief and contract capabilities to this configured provider only when you generate. At most two attempts, 4096 output tokens per attempt, 20-second deadline.`;
})
.catch(() => {});
render();
return { render };
}
+14
View File
@@ -0,0 +1,14 @@
export { StudioSession, importDesign, exportDesign, diffDesign, reviewDesign } from "./session.ts";
export type { ImportReport, StudioRevision, StudioStorage } from "./session.ts";
export { checkDecisionLocks, designRevision, pointerPart } from "./decisions.ts";
export type { DecisionLock } from "./decisions.ts";
export { generateDesigns, applyDesignPatch, DEFAULT_DESIGN_BRIEF } from "./generation.ts";
export type {
DesignBrief,
DesignProposal,
GenerationRequest,
GenerationBatch,
GenerationProvider,
} from "./generation.ts";
export { combineDesignProposals, foundationDecisions } from "./guidance.ts";
export type { FoundationGroup } from "./guidance.ts";
+175
View File
@@ -0,0 +1,175 @@
import { validateAgentPage } from "./agent-pages.ts";
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createHash } from "node:crypto";
import { compileWrnFile } from "@wrnexus/compiler";
import { runWithRequestContext } from "@wrnexus/core";
import { REACTIVE_RUNTIME } from "@wrnexus/csr";
import { renderDocument } from "@wrnexus/ssr";
import { uiCss, uiComponentsDir } from "@wrnexus/ui/registry";
import { resolveThemeConfig, renderThemeCss } from "@wrnexus/styles/theme";
import {
COMPONENT_CONTRACTS,
type DesignSystem,
type ResolveOptions,
} from "@wrnexus/design-system";
const modules = new Map<string, Promise<{ render: (props: Record<string, unknown>) => string }>>();
const escape = (text: string) =>
text.replace(
/[&<>"']/g,
(char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!,
);
async function component(name: string, props: Record<string, unknown> = {}) {
if (!Object.hasOwn(COMPONENT_CONTRACTS, name)) throw Error("Unknown component.");
if (!modules.has(name)) {
const sourcePath = resolve(
uiComponentsDir(),
name === "ComboBox" ? "Combobox.wrn" : `${name}.wrn`,
);
const source = readFileSync(sourcePath, "utf8");
const directory = join(tmpdir(), "wrnexus-design-studio");
mkdirSync(directory, { recursive: true });
const target = join(
directory,
`${name}-${createHash("sha256")
.update(import.meta.url + source)
.digest("hex")
.slice(0, 16)}.ts`,
);
const core = fileURLToPath(import.meta.resolve("@wrnexus/core"));
writeFileSync(
target,
compileWrnFile(source, target).replace(
/from (["'])@wrnexus\/core\1/g,
`from ${JSON.stringify(pathToFileURL(core).href)}`,
),
);
modules.set(name, import(pathToFileURL(target).href));
}
return String((await modules.get(name)!).render(props));
}
export async function previewDocument(
system: DesignSystem,
options: ResolveOptions & { content?: string; page?: number; direction?: string } = {},
) {
const theme = resolveThemeConfig({
designSystem: { system, mode: options.mode, density: options.density },
default: options.mode ?? system.defaultMode,
});
const mode = options.mode ?? system.defaultMode ?? "light";
return runWithRequestContext({} as never, async () => {
let body: string;
if (options.content === "catalog") {
const page = Math.max(0, Math.min(6, options.page ?? 0));
const names = Object.keys(COMPONENT_CONTRACTS).slice(page * 16, (page + 1) * 16);
const cards = [];
for (const name of names)
cards.push(
`<section class="sample"><h2>${escape(name)}</h2>${await component(name)}</section>`,
);
body = `<h1>Component gallery</h1><p>Page ${page + 1} of 7 · Default states</p><div class="gallery">${cards.join("")}</div>`;
} else {
const nav = await component("Nav", {
items: [
{ label: "Profile", href: "#profile" },
{ label: "Preferences", href: "#preferences" },
],
});
const heading = await component("PageHeader", {
title: "Design system preview",
description: "The same components and content, styled by your draft.",
});
const card = await component("Card", {
title: "Profile",
description: "Sample form. Changes stay inside this preview.",
});
const input = await component("Input", {
id: "display-name",
label: "Display name",
placeholder: "Your name",
});
const select = await component("Select", {
id: "language",
label: "Language",
value: "en",
options: [
{ label: "English", value: "en" },
{ label: "Hindi", value: "hi" },
],
});
const textarea = await component("Textarea", {
id: "bio",
label: "About you",
placeholder: "Add a short introduction",
});
const toggle = await component("Switch", { id: "updates", label: "Email updates" });
const checkbox = await component("Checkbox", {
id: "remember",
label: "Remember preferences",
});
const button = await component("Button", { label: "Save changes" });
const disabled = await component("Button", { label: "Unavailable", disabled: true });
const loading = await component("Button", { label: "Loading…", loading: true });
const alert = await component("Alert", {
title: "Preview only",
description: "This form does not send data.",
color: "info",
dismissible: true,
});
const modal = await component("Modal", {
title: "Review changes",
description: "Check your preferences before continuing.",
triggerLabel: "Open dialog",
confirmLabel: "Continue",
});
const radio = await component("Radio", {
label: "Comfortable spacing",
id: "density-comfortable",
checked: true,
});
const number = await component("InputNumber", {
label: "Quantity",
id: "quantity",
value: 2,
});
body = `${nav}${heading}<div class="layout"><section id="profile">${card}<form>${input}${select}${textarea}<div id="preferences">${toggle}${checkbox}</div><div class="actions">${button}${disabled}${loading}</div></form></section><aside>${alert}<h2>More components</h2>${radio}${number}<div class="actions">${modal}</div></aside></div>`;
}
const runtime = REACTIVE_RUNTIME;
return renderDocument({
meta: { title: system.name + " preview" },
body,
htmlAttrs: ` dir="${options.direction === "rtl" ? "rtl" : "ltr"}" data-theme="${escape(mode)}"`,
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{padding:24px;margin:0;font-family:var(--wrn-font-sans);color:var(--wrn-color-text);background:var(--wrn-color-bg);line-height:1.5}h1{font-size:28px}h2{font-size:18px}.layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px}.layout>*,form{min-width:0;display:grid;gap:16px;align-content:start}.actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.sample{min-width:0;overflow:auto;border:1px solid var(--wrn-color-border);padding:16px}@media(max-width:650px){.layout{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}</style>`,
componentRecipeCss: theme.recipeCss,
extraBody: `<script>${runtime.replace(/<\/script/gi, "<\\/script")}\ndocument.addEventListener('submit',e=>e.preventDefault());</script>`,
});
});
}
/** Safe page-spec preview. Only shipped components execute; submitted page source never executes. */
export async function previewAgentPage(
system: DesignSystem,
page: import("./agent-pages.ts").AgentPage,
options: ResolveOptions = {},
) {
validateAgentPage(page);
const theme = resolveThemeConfig({
designSystem: { system, ...options },
default: options.mode ?? system.defaultMode,
});
return runWithRequestContext({} as never, async () => {
const body = [];
for (const entry of page.components) body.push(await component(entry.component, entry.props));
return renderDocument({
meta: { title: page.title },
body: `<main class="wrn-agent-page"><h1>${escape(page.title)}</h1>${body.join("")}</main>`,
htmlAttrs: ` data-theme="${escape(options.mode ?? system.defaultMode ?? "light")}"`,
extraHead: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; form-action 'none'; base-uri 'none'"><style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{margin:0}.wrn-agent-page{display:grid;min-width:0;padding:var(--wrn-space-panel);gap:var(--wrn-space-panel);color:var(--wrn-color-text);background:var(--wrn-color-bg);font-family:var(--wrn-font-sans)}</style>`,
componentRecipeCss: theme.recipeCss,
});
});
}
+207
View File
@@ -0,0 +1,207 @@
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { StudioApplication } from "./application.ts";
import { studioHtml, studioCss } from "./shell.ts";
import { previewDocument } from "./preview.ts";
import { validateDesignSystem, type DesignSystem } from "@wrnexus/design-system";
import { generateDesigns, type GenerationProvider } from "./generation.ts";
/** Explicit local server subpath. The root entry remains browser safe. */
export async function createStudioHandler(
options: {
applicationRoot?: string;
generationProvider?: GenerationProvider;
generationModel?: string;
/** Public demos can generate previews and exports, never write application files. */
hosted?: { basePath: string };
} = {},
) {
const basePath = options.hosted?.basePath ?? "";
if (basePath && !/^\/[a-zA-Z0-9/_-]+$/.test(basePath))
throw Error("Hosted basePath must be an absolute path without a trailing slash.");
if (basePath.endsWith("/")) throw Error("Hosted basePath must not end with a slash.");
if (options.hosted && (options.applicationRoot || options.generationProvider))
throw Error("Hosted Studio supports export-only local generation.");
const app = options.applicationRoot ? new StudioApplication(options.applicationRoot) : undefined;
const asset = fileURLToPath(new URL("../assets/studio-client.js", import.meta.url));
let client: string;
if (existsSync(asset)) client = readFileSync(asset, "utf8");
else {
const build = await Bun.build({
entrypoints: [fileURLToPath(new URL("./client.ts", import.meta.url))],
target: "browser",
minify: true,
});
if (!build.success) throw Error(build.logs.join("\n"));
client = await build.outputs[0]!.text();
}
const previews = new Map<string, string>();
let generating = 0;
const headers = {
"cache-control": "no-store",
"x-content-type-options": "nosniff",
"content-security-policy":
"default-src 'self'; script-src 'self'; style-src 'self'; frame-src 'self' about:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'",
};
const response = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { ...headers, "content-type": "application/json" },
});
const previewResponse = (html: string) =>
new Response(html, {
headers: {
...headers,
"content-type": "text/html; charset=utf-8",
"content-security-policy":
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
},
});
return async (request: Request): Promise<Response> => {
const url = new URL(request.url);
// Hosted handlers are served behind the deployment's loopback reverse proxy.
const protocol = request.headers.get("x-forwarded-proto");
if (options.hosted && (protocol === "https" || protocol === "http"))
url.protocol = protocol + ":";
if (
(!options.hosted && !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) ||
(request.headers.get("origin") && request.headers.get("origin") !== url.origin)
)
return response({ error: "Local same-origin requests only." }, 403);
if (basePath) {
if (!url.pathname.startsWith(basePath + "/")) return response({ error: "Not found" }, 404);
url.pathname = url.pathname.slice(basePath.length);
}
try {
if (request.method === "GET") {
if (url.pathname === "/api/generation")
return response({
provider: options.generationProvider?.name ?? "local-rules",
model: options.generationModel,
templateVersion: 1,
maxAttempts: 2,
maxOutputTokens: 4096,
timeoutMs: 20000,
});
if (url.pathname === "/application-preview") {
const system = app?.system();
return system
? previewResponse(await previewDocument(system, app!.selection()))
: response({ error: "No applied design. Apply a design first." }, 404);
}
if (url.pathname.startsWith("/preview/")) {
const html = previews.get(url.pathname);
return html
? previewResponse(html)
: response({ error: "Preview expired. Refresh the preview." }, 404);
}
if (url.pathname === "/")
return new Response(
basePath
? studioHtml
.replaceAll('href="/', 'href="./')
.replaceAll('src="/', 'src="./')
.replace("<head>", `<head><base href="${basePath}/">`)
: studioHtml,
{
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
},
);
if (url.pathname === "/studio.css")
return new Response(studioCss, { headers: { ...headers, "content-type": "text/css" } });
if (url.pathname === "/studio.js")
return new Response(client, {
headers: { ...headers, "content-type": "text/javascript" },
});
if (url.pathname === "/api/application")
return response({
enabled: !!app,
directory: app?.directory,
...(app?.state() ?? { revision: "none" }),
system: app?.system(),
});
}
if (
request.method !== "POST" ||
!["/api/preview", "/api/apply", "/api/rollback", "/api/generate"].includes(url.pathname)
)
return response({ error: "Not found" }, 404);
if (!(request.headers.get("content-type") ?? "").startsWith("application/json"))
return response({ error: "JSON required." }, 415);
if (Number(request.headers.get("content-length")) > 2_000_000)
return response({ error: "Request too large." }, 413);
let text = "";
const reader = request.body?.getReader();
const decoder = new TextDecoder();
let bytes = 0;
if (reader) {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 2_000_000) {
await reader.cancel();
return response({ error: "Request too large." }, 413);
}
text += decoder.decode(chunk.value, { stream: true });
}
text += decoder.decode();
}
const input = JSON.parse(text);
if (!input || typeof input !== "object" || Array.isArray(input))
return response({ error: "JSON object required." }, 400);
if (url.pathname === "/api/generate") {
if (generating >= 2)
return response(
{ error: "Generation is busy. Try again after the current requests finish." },
429,
);
generating++;
try {
return response(
await generateDesigns(input, {
provider: options.generationProvider,
model: options.generationModel,
signal: request.signal,
}),
);
} finally {
generating--;
}
}
if (url.pathname === "/api/rollback") {
if (!app) return response({ error: "Local application is not configured." }, 409);
return response(app.rollback(input.base));
}
const diagnostics = validateDesignSystem(input.system);
if (diagnostics.length)
return response({ error: "Invalid design system.", diagnostics }, 422);
const selection = {
mode: typeof input.mode === "string" ? input.mode : undefined,
density: typeof input.density === "string" ? input.density : undefined,
};
if (url.pathname === "/api/preview") {
const html = await previewDocument(input.system as DesignSystem, {
...selection,
content: input.content === "catalog" ? "catalog" : "composed",
page: Number.isInteger(input.page) ? input.page : 0,
direction: input.direction,
});
const path = "/preview/" + randomUUID();
previews.set(path, html);
while (previews.size > 24) previews.delete(previews.keys().next().value!);
return response({ url: basePath + path });
}
if (!app)
return response(
{ error: "Local application is not configured. Export or restart with --app-root." },
409,
);
return response(app.apply(input.system as DesignSystem, input.base, selection));
} catch (error) {
const message = (error as Error).message;
return response({ error: message }, message.includes("WRN-STUDIO-CONFLICT") ? 409 : 400);
}
};
}
+369
View File
@@ -0,0 +1,369 @@
import {
CORE_DESIGN_SYSTEMS,
COMPONENT_CONTRACTS,
compileDesignSystem,
resolveDesignSystem,
validateDesignSystem,
type DesignSystem,
type DesignDiagnostic,
type ResolveOptions,
type DesignToken,
} from "@wrnexus/design-system";
import { checkDecisionLocks, validateLocks, type DecisionLock } from "./decisions.ts";
export interface StudioRevision {
source: string;
label: string;
}
export interface StudioStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export interface ImportReport {
system?: DesignSystem;
preserved: string[];
unsupported: string[];
ambiguous: string[];
diagnostics: DesignDiagnostic[];
}
const limit = 2_000_000;
const key = "wrnexus.design-studio.v1";
const json = (value: unknown) => JSON.stringify(value, null, 2);
const clone = <T>(value: T): T => structuredClone(value);
const diagnostic = (code: string, message: string): DesignDiagnostic => ({
code,
path: "$",
message,
});
function parse(source: string): unknown {
if (source.length > limit) throw Error("Document exceeds the 2 MB import limit.");
return JSON.parse(source);
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const safeName = (name: string) =>
/^[A-Za-z][\w.-]*$/.test(name) &&
!name.split(/[.-]/).some((part) => ["__proto__", "constructor", "prototype"].includes(part));
/** Deliberate subset: canonical JSON, literal legacy themes and grouped exchange tokens. No code/assets execute. */
export function importDesign(
source: string,
format: "canonical" | "legacy" | "tokens" = "canonical",
): ImportReport {
const report: ImportReport = { preserved: [], unsupported: [], ambiguous: [], diagnostics: [] };
try {
const input = parse(source);
if (format === "canonical") {
report.diagnostics = validateDesignSystem(input);
if (!report.diagnostics.length) {
report.system = clone(input as DesignSystem);
report.preserved.push("Complete canonical document");
}
return report;
}
if (!record(input)) throw Error("Import must be a JSON object.");
const system: DesignSystem = {
schemaVersion: 1,
id: "imported",
name: "Imported design",
version: "1.0.0",
tokens: {},
};
if (format === "legacy") {
const baseline = CORE_DESIGN_SYSTEMS.square;
const bindings = new Map(
Object.entries(baseline.tokens)
.filter(([, t]) => t.legacy)
.map(([name, t]) => [t.legacy!, { name, type: t.type }]),
);
if (!record(input.themes))
throw Error("Legacy JSON requires a themes object with literal token values.");
system.modes = {};
for (const [mode, values] of Object.entries(input.themes)) {
if (!safeName(mode)) throw Error("Invalid legacy mode name.");
if (!record(values)) {
report.unsupported.push(`themes.${mode}`);
continue;
}
const tokens: Record<string, DesignToken> = {};
for (const [suffix, value] of Object.entries(values)) {
const binding = bindings.get(suffix);
if (!binding || (typeof value !== "string" && typeof value !== "number")) {
report.unsupported.push(`themes.${mode}.${suffix}`);
continue;
}
tokens[binding.name] = { type: binding.type, value, legacy: suffix };
report.preserved.push(`themes.${mode}.${suffix}`);
}
system.modes[mode] = tokens;
}
system.defaultMode =
typeof input.default === "string" ? input.default : Object.keys(system.modes)[0];
for (const field of Object.keys(input))
if (!["themes", "default"].includes(field)) report.unsupported.push(field);
} else {
const types: Record<string, DesignToken["type"]> = {
color: "color",
dimension: "dimension",
number: "number",
fontFamily: "fontFamily",
fontWeight: "fontWeight",
duration: "duration",
};
const walk = (
group: Record<string, unknown>,
prefix: string,
inheritedType?: string,
depth = 0,
) => {
if (depth > 32) throw Error("Exchange token nesting exceeds 32 levels.");
for (const [name, value] of Object.entries(group)) {
if (name.startsWith("$")) {
if (!["$type", "$description"].includes(name))
report.unsupported.push(prefix ? `${prefix}.${name}` : name);
continue;
}
const path = prefix ? `${prefix}.${name}` : name;
if (!safeName(path)) throw Error("Invalid exchange token path.");
if (!record(value)) {
report.unsupported.push(path);
continue;
}
const type = typeof value.$type === "string" ? value.$type : inheritedType;
if ("$value" in value) {
const raw = value.$value;
if (!type || !types[type] || (typeof raw !== "string" && typeof raw !== "number")) {
report.unsupported.push(path);
continue;
}
const alias = typeof raw === "string" && /^\{[\w.-]+\}$/.test(raw);
system.tokens[path] = {
type: types[type]!,
value: alias ? { ref: (raw as string).slice(1, -1) } : (raw as string | number),
};
report.preserved.push(path);
report.ambiguous.push(
`${path}: no WrNexus semantic binding; map a legacy suffix in Foundations before applying.`,
);
for (const field of Object.keys(value))
if (!["$type", "$value", "$description"].includes(field))
report.unsupported.push(`${path}.${field}`);
if (typeof value.$description === "string")
system.tokens[path]!.description = value.$description;
} else walk(value, path, type, depth + 1);
}
};
walk(input, "", typeof input.$type === "string" ? input.$type : undefined);
}
if (!report.preserved.length) throw Error("No supported tokens found.");
report.diagnostics = validateDesignSystem(system);
if (!report.diagnostics.length) report.system = system;
} catch (error) {
report.diagnostics = [diagnostic("WRN-STUDIO-IMPORT", (error as Error).message)];
}
return report;
}
export function reviewDesign(
before: unknown,
after: unknown,
path = "$",
): Array<{ path: string; before: unknown; after: unknown }> {
if (JSON.stringify(before) === JSON.stringify(after)) return [];
if (!record(before) || !record(after))
return [{ path, before: clone(before), after: clone(after) }];
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
.sort()
.flatMap((name) => reviewDesign(before[name], after[name], `${path}.${name}`));
}
export function diffDesign(before: unknown, after: unknown, path = "$"): string[] {
return reviewDesign(before, after, path).map((entry) => entry.path);
}
export function exportDesign(system: DesignSystem, options: ResolveOptions = {}) {
const diagnostics = validateDesignSystem(system, options);
if (diagnostics.length) throw Error(diagnostics.map((d) => `${d.path}: ${d.message}`).join("\n"));
const compiled = compileDesignSystem(system, options);
const theme = {
default: options.mode ?? system.defaultMode,
designSystem: { system, ...options },
};
const exchange = Object.fromEntries(
Object.entries(compiled.resolved.tokens).map(([name, token]) => [
name,
{ $type: token.type, $value: token.value },
]),
);
return {
canonical: json(system),
css: compiled.css,
theme: json(theme),
tokens: json(exchange),
report: json({
system: system.id,
diagnostics,
components: Object.keys(COMPONENT_CONTRACTS).length,
recipes: Object.keys(compiled.resolved.recipes),
limitations:
"Only declared parts are styled. Native, provider and content-owned regions retain their documented boundaries.",
}),
};
}
/** Browser-safe state machine. Raw partial edits never replace the last valid preview. */
export class StudioSession {
locks: DecisionLock[] = [];
source: string;
valid: DesignSystem;
diagnostics: DesignDiagnostic[] = [];
history: StudioRevision[];
cursor = 0;
applied: DesignSystem;
storageError = "";
private savedRecovery?: string;
/** Untouched saved bytes when a future or damaged draft cannot be restored. */
get recoverySource(): string | undefined {
return this.savedRecovery;
}
constructor(
system: DesignSystem = CORE_DESIGN_SYSTEMS.square,
private storage?: StudioStorage,
) {
if (validateDesignSystem(system).length) throw Error("Initial system must validate.");
this.valid = clone(system);
this.applied = clone(system);
this.source = json(system);
this.history = [{ source: this.source, label: "Opened system" }];
}
restore(): boolean {
let saved: string | null | undefined;
try {
saved = this.storage?.getItem(key);
if (!saved) return false;
if (saved.length > 8_000_000) throw Error("Saved history exceeds 8 MB.");
const input = JSON.parse(saved);
if (input?.storageVersion !== undefined && input.storageVersion !== 1)
throw Error("Unsupported saved draft version; retain the original bytes.");
if (
!record(input) ||
!Array.isArray(input.history) ||
input.history.length < 1 ||
input.history.length > 25 ||
!Number.isInteger(input.cursor)
)
throw Error("Invalid local history.");
const history = input.history as StudioRevision[];
if (
history.some((r) => !r || typeof r.source !== "string" || typeof r.label !== "string") ||
(input.cursor as number) < 0 ||
(input.cursor as number) >= history.length
)
throw Error("Invalid local revisions.");
const valid = importDesign(json(input.valid));
const applied = importDesign(json(input.applied));
if (!valid.system || !applied.system)
throw Error("Saved valid system could not be restored.");
validateLocks(input.locks ?? []);
this.locks = structuredClone((input.locks ?? []) as DecisionLock[]);
this.valid = valid.system;
this.applied = applied.system;
this.history = clone(history);
this.cursor = input.cursor as number;
this.source = history[this.cursor]!.source;
this.check();
this.savedRecovery = undefined;
return true;
} catch (error) {
this.savedRecovery = saved ?? undefined;
this.storageError = `Local draft could not be restored: ${(error as Error).message}`;
return false;
}
}
private check() {
const report = importDesign(this.source);
this.diagnostics = report.diagnostics;
if (report.system) {
try {
this.diagnostics = checkDecisionLocks(this.valid, report.system, this.locks);
} catch (error) {
this.diagnostics = [diagnostic("WRN-STUDIO-LOCK", (error as Error).message)];
}
if (!this.diagnostics.length) this.valid = report.system;
}
}
persist() {
if (this.savedRecovery !== undefined) {
this.storageError =
"Autosave unavailable. Saved recovery data is protected. Export recoverySource before resetting local storage.";
return;
}
try {
this.storage?.setItem(
key,
json({
storageVersion: 1,
history: this.history,
cursor: this.cursor,
valid: this.valid,
applied: this.applied,
locks: this.locks,
}),
);
this.storageError = "";
} catch {
this.storageError = "Autosave unavailable or full. Export your draft to keep it.";
}
}
edit(source: string, label = "Edited source") {
if (source.length > limit) throw Error("Draft exceeds 2 MB.");
if (source === this.source) return;
this.source = source;
this.history = this.history.slice(0, this.cursor + 1);
this.history.push({ source, label });
this.history = this.history.slice(-25);
this.cursor = this.history.length - 1;
this.check();
this.persist();
}
replace(system: DesignSystem, label: string) {
this.edit(json(system), label);
}
setLocks(locks: DecisionLock[]) {
validateLocks(locks);
this.locks = structuredClone(locks);
this.check();
this.persist();
}
undo() {
if (this.cursor > 0) {
this.source = this.history[--this.cursor]!.source;
this.check();
this.persist();
}
}
redo() {
if (this.cursor < this.history.length - 1) {
this.source = this.history[++this.cursor]!.source;
this.check();
this.persist();
}
}
mutate(change: (system: DesignSystem) => void, label: string) {
if (this.diagnostics.length)
throw Error("Resolve source errors before using structured editors.");
const next = clone(this.valid);
change(next);
this.replace(next, label);
}
resolved(options: ResolveOptions = {}) {
return resolveDesignSystem(this.valid, options);
}
changes() {
return diffDesign(this.applied, this.valid);
}
markApplied() {
if (this.diagnostics.length) throw Error("Resolve errors before applying.");
this.applied = clone(this.valid);
this.persist();
}
}
+15
View File
@@ -0,0 +1,15 @@
import { guidanceHtml, guidanceCss } from "./guidance-shell.ts";
export const studioHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WrNexus Design Studio</title><link rel="stylesheet" href="/studio.css"><script type="module" src="/studio.js"></script></head><body>
<header><a class="brand" href="/">WrNexus <strong>Design Studio</strong></a><span id="save-state" role="status">Local workspace</span><button id="undo">Undo</button><button id="redo">Redo</button><button id="export">Export JSON</button><button id="review" class="primary">Review changes</button></header>
<main><aside><p class="eyebrow">Workspace</p><h1>Your system.<br>Your components.</h1><p class="intro">Edit one design system and preview it on the existing component library.</p><label>Start from a preset<select id="preset"><option value="">Choose a preset</option><option value="square">Square / Industrial</option><option value="soft">Soft / Organic</option><option value="angular">Angular / Lo-Fi</option></select></label><div class="row"><button id="new">Create new</button><button id="duplicate">Duplicate</button></div><label>System name<input id="system-name"></label><label>System ID<input id="system-id"></label>
<nav aria-label="Editors"><button data-tab="guided">Guided design</button><button data-tab="foundations" aria-current="page">Foundations</button><button data-tab="components">Components</button><button data-tab="source">Source & import</button><button data-tab="history">History</button><button data-tab="review">Review & apply</button></nav><p class="footnote">Local drafts stay in this browser. Export a file to keep a portable copy.</p></aside>
<section class="workspace"><div class="workspace-title"><div><p class="eyebrow">Live authoring</p><h2 id="editor-title">Foundations</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
${guidanceHtml}
<section id="foundations" class="editor"><div class="row"><label>Search tokens<input id="token-search" type="search" placeholder="Color, shape, typography…"></label><label>Edit layer<select id="token-layer"></select></label></div><p class="help">Aliases use @token.name. The resolved value and its source appear below each field.</p><div id="tokens" class="token-grid"></div><details><summary>Add a token, mode or density</summary><div class="row"><label>Token name<input id="add-token-name" placeholder="space.section"></label><label>Type<select id="add-token-type"></select></label><label>Value<input id="add-token-value" placeholder="24px"></label><button id="add-token">Add token</button></div><div class="row"><label>New layer name<input id="add-layer-name" placeholder="compact"></label><button id="add-mode">Add mode</button><button id="add-density">Add density</button></div></details></section>
<section id="components" class="editor" hidden><div class="row"><label>Find a component<input type="search" id="component-search" placeholder="Search 104 components"></label><label>Component<select id="component"></select></label></div><div id="contract"></div><div class="row"><label>Part<select id="part"></select></label><label>Property<select id="property"></select></label></div><div class="row"><label>Recipe value<input id="recipe-value" placeholder="@shape.control or 8px"></label><button id="set-recipe">Set default</button><button id="remove-recipe">Remove default</button></div><label>Complete component recipe JSON<textarea id="recipe-source" rows="12" spellcheck="false"></textarea></label><button id="save-recipe">Save component recipe</button><p class="help">Recipe JSON supports the contract’s variants, compound conditions and states. All edits pass through engine validation.</p></section>
<section id="source" class="editor" hidden><label>Canonical design system JSON<textarea id="source-text" rows="22" spellcheck="false"></textarea></label><button id="save-source">Update draft</button><button id="download-draft">Export raw draft</button><details open><summary>Import a design</summary><label>Format<select id="import-format"><option value="canonical">Canonical design system JSON</option><option value="legacy">WrNexus theme JSON</option><option value="tokens">Exchange tokens: literal scalar / alias subset</option></select></label><label>Choose a local JSON file<input id="import-file" type="file" accept=".json,application/json"></label><label>Or paste JSON<textarea id="import-text" rows="7" spellcheck="false"></textarea></label><button id="inspect-import">Inspect import</button><pre id="import-report"></pre><button id="accept-import" disabled>Use imported design</button></details></section>
<section id="history" class="editor" hidden><p>Up to 25 local revisions, including incomplete source edits. Undo and redo also restore invalid drafts.</p><ol id="revisions"></ol></section>
<section id="review-panel" class="editor" hidden><h3>Review before applying</h3><p id="target"></p><p>Apply writes a validated system, compiled CSS and theme configuration to the configured local application folder. Existing application configuration is preserved. Import the generated theme configuration to use it.</p><pre id="changes"></pre><div class="row"><button id="apply" class="primary">Apply to local folder</button><button id="rollback">Rollback last apply</button></div><h3>Portable exports</h3><div class="row"><button data-export="canonical">Canonical JSON</button><button data-export="css">Compiled CSS</button><button data-export="theme">Theme config</button><button data-export="tokens">Exchange tokens</button><button data-export="report">Compatibility report</button></div><p class="help">CSS and theme configuration are alternative integration paths. Publication and deployment are separate.</p><pre id="integration"></pre></section>
</section><section class="preview"><div class="preview-heading"><div><p class="eyebrow">Same content, different decisions</p><h2>Preview</h2></div><button id="refresh-preview">Refresh</button></div><div class="preview-controls"><label>Mode<select id="preview-mode"></select></label><label>Density<select id="preview-density"></select></label><label>Width<select id="preview-width"><option value="100%">Responsive</option><option value="390px">390 px</option><option value="768px">768 px</option><option value="1280px">1280 px</option></select></label><label>Direction<select id="preview-direction"><option value="ltr">LTR</option><option value="rtl">RTL</option></select></label><label>Content<select id="preview-content"><option value="composed">Composed page</option><option value="catalog">Component gallery</option></select></label><label>Compare<select id="compare"><option value="">No comparison</option><option value="applied">Last applied</option><option value="square">Square</option><option value="soft">Soft</option><option value="angular">Angular</option></select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery shows default component states; provider regions retain their own styling.</p><div class="frames"><div><p id="preview-label">Current draft</p><div class="frame-scroll"><iframe id="preview-frame" title="Current design preview" sandbox="allow-scripts"></iframe></div></div><div id="comparison" hidden><p>Comparison</p><div class="frame-scroll"><iframe id="compare-frame" title="Comparison design preview" sandbox="allow-scripts"></iframe></div></div></div></section></main><div id="message" role="status" aria-live="polite"></div></body></html>`;
export const studioCss = `${guidanceCss}:root{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;color:#171719;background:#fff;line-height:1.45;font-size:14px;--accent:#002fa7}*{box-sizing:border-box}body{margin:0}header{display:flex;align-items:center;gap:10px;min-height:72px;padding:16px 24px;border-bottom:1px solid #d9d9de;position:sticky;top:0;background:white;z-index:2}.brand{text-decoration:none;color:inherit;font-size:17px;letter-spacing:-.5px;margin-right:auto}.brand strong{margin-left:8px}#save-state{font-size:12px;color:#555;max-width:230px}button,input,select,textarea{font:inherit}button{padding:9px 12px;border:1px solid #c8c8d0;background:#fff;color:#171719;cursor:pointer;border-radius:0}button:hover{border-color:var(--accent);color:var(--accent)}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}main{display:grid;grid-template-columns:235px minmax(340px,.9fr) minmax(420px,1.1fr);min-height:calc(100vh - 72px)}aside{border-right:1px solid #d9d9de;padding:28px 20px}.eyebrow{font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:#666;margin:0 0 12px}h1{font-size:31px;line-height:1.08;letter-spacing:-1.4px;font-weight:500;margin:0 0 16px}h2{font-size:23px;line-height:1.2;letter-spacing:-.7px;font-weight:500;margin:0}h3{font-weight:500}.intro,.help,.footnote{color:#63636d;font-size:12px}.intro{margin-bottom:28px;max-width:28em}.footnote{margin-top:32px}label{display:block;font-size:12px;margin:12px 0;color:#3d3d45}input,select,textarea{display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #c8c8d0;border-radius:0;background:white;color:#171719;min-width:0}textarea{font-family:Consolas,monospace;font-size:12px;tab-size:2;resize:vertical}nav{display:grid;margin:28px -20px 0}nav button{text-align:left;border:0;border-top:1px solid #e2e2e7;padding:15px 20px}nav button[aria-current=page]{border-left:3px solid var(--accent);padding-left:17px;color:var(--accent);background:#f7f7f8}.workspace{min-width:0;padding:28px 24px;border-right:1px solid #d9d9de}.workspace-title,.preview-heading{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:24px}.badge{font-size:11px;border:1px solid #c8c8d0;padding:5px 8px;white-space:nowrap}.row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.row>label{flex:1;min-width:100px}.row>button{margin-bottom:12px}.token-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0 18px}.token{border-top:1px solid #e2e2e7;padding:10px 0;min-width:0}.token label{margin:0}.token small{font-size:11px;display:block;overflow-wrap:anywhere;color:#686870;margin-top:7px}.token .token-name{font-weight:600;font-size:12px}.token-actions{display:flex;gap:8px}.token-actions input{flex:1}.swatch{width:18px;height:18px;border:1px solid #999;display:inline-block;vertical-align:middle;margin-right:6px}.preview{min-width:0;padding:28px 24px;background:#f7f7f8;align-self:start;position:sticky;top:72px;max-height:calc(100vh - 72px);overflow:auto}.preview-controls{display:flex;flex-wrap:wrap;gap:10px}.preview-controls label{flex:1;min-width:100px;margin-top:0}.frames{display:grid;gap:16px}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr 1fr}.frames>div{min-width:0}.frame-scroll{overflow:auto;background:white;border:1px solid #d9d9de}iframe{border:0;width:100%;height:680px;display:block;background:white}.frames p{font-size:12px}details{border-top:1px solid #d9d9de;padding-top:16px;margin-top:24px}summary{cursor:pointer}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;background:#f7f7f8;padding:12px;max-height:350px;overflow:auto}#diagnostics:not(:empty){border-left:3px solid #b72032;padding:8px 12px;margin-bottom:20px;color:#8c1526;font-size:12px}#message:not(:empty){position:fixed;bottom:16px;left:24px;max-width:min(650px,calc(100vw - 48px));padding:14px 20px;background:#171719;color:white;z-index:4;white-space:pre-wrap}#contract{font-size:12px}.editor[hidden],[hidden]{display:none!important}#revisions li{margin-bottom:10px}#revisions button{text-align:left;width:100%}@media(min-width:1600px){main{grid-template-columns:250px minmax(470px,.8fr) minmax(600px,1.2fr)}}@media(max-width:1150px){main{grid-template-columns:210px minmax(0,1fr)}.preview{grid-column:2;position:static;max-height:none}.workspace{border-right:0}aside{grid-row:span 2}}@media(max-width:650px){header{flex-wrap:wrap;padding:12px}.brand{width:100%}#save-state{margin-right:auto}main{display:block}aside{border-right:0;padding:24px}h1 br{display:none}nav{display:flex;overflow:auto;margin:24px -24px 0}nav button{white-space:nowrap;padding:12px}.workspace,.preview{padding:24px}aside .intro,aside .footnote{display:none}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr}.token- Line truncated
+35
View File
@@ -0,0 +1,35 @@
import { readDesignPackage } from "@wrnexus/design-system/package";
import { checkDecisionLocks, designRevision } from "./decisions.ts";
import { reviewDesign, type StudioSession } from "./session.ts";
/** Review-only upgrade planning. Adoption always revalidates bytes, draft revision and locks. */
export async function planDesignUpgrade(session: StudioSession, source: string) {
if (session.diagnostics.length)
throw Error("WRN-STUDIO-UPGRADE: resolve source errors before planning an upgrade.");
const parsed = await readDesignPackage(source);
const next = parsed.package.system;
if (next.id !== session.valid.id)
throw Error(
"WRN-STUDIO-UPGRADE: system identity changed; import a different design explicitly.",
);
const diagnostics = checkDecisionLocks(session.valid, next, session.locks);
return {
baseRevision: await designRevision(session.valid, session.locks),
package: parsed.package,
migrations: parsed.migrations,
changes: reviewDesign(session.valid, next),
diagnostics,
canApply: !diagnostics.length,
};
}
export async function applyDesignUpgrade(
session: StudioSession,
source: string,
baseRevision: string,
) {
const plan = await planDesignUpgrade(session, source);
if (plan.baseRevision !== baseRevision)
throw Error("WRN-STUDIO-CONFLICT: draft or locks changed; review the upgrade again.");
if (!plan.canApply) throw Error("WRN-STUDIO-UPGRADE-LOCK: upgrade changes locked decisions.");
session.replace(plan.package.system, "Adopted design package " + plan.package.system.version);
return plan;
}
+250
View File
@@ -0,0 +1,250 @@
import { test, expect, afterEach } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createFrameworkMcpServer } from "../../mcp/src/index.ts";
import { DesignAgentService } from "../src/agent-server.ts";
import { agentGenerationContext } from "../src/agent-context.ts";
import { checkDesignConformance } from "../src/conformance.ts";
import { agentPageSource, checkAgentPage, type AgentPage } from "../src/agent-pages.ts";
import { CORE_DESIGN_SYSTEMS } from "../../design-system/src/index.ts";
import { compileWrnFile } from "../../compiler/src/index.ts";
const roots: string[] = [];
function root() {
const path = mkdtempSync(join(tmpdir(), "wrn-phase6-"));
roots.push(path);
writeFileSync(join(path, "package.json"), "{}");
return path;
}
afterEach(() => {
for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
});
const page = (id: string): AgentPage => ({
id,
title: id === "account" ? "Account settings" : "Contact support",
components: [
{ component: "PageHeader", props: { title: "Your workspace" } },
{ component: "Input", props: { id: id + "-name", label: "Name" } },
{ component: "Button", props: { label: "Continue", variant: "default" } },
],
});
test("MCP defaults stay unchanged; explicit design opt-in exposes read tools and separate write opt-in", async () => {
const path = root();
expect(createFrameworkMcpServer(path).tools()).toHaveLength(8);
const readonly = createFrameworkMcpServer(path, { design: true });
expect(readonly.tools().some((t) => t.name === "wrnexus_design_context")).toBe(true);
expect(readonly.tools().some((t) => t.name === "wrnexus_design_apply")).toBe(false);
expect(
((await readonly.call("wrnexus_design_context")) as any).components.Button.parts,
).toBeDefined();
expect(existsSync(join(path, ".wrnexus"))).toBe(false);
const response = await readonly.handle({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "wrnexus_design_apply", arguments: { baseRevision: "none" } },
});
expect((response as any).result.isError).toBe(true);
});
test("recover invalid edits, stale conflicts, locks, undo and checkpoints across service instances", async () => {
const path = root();
let service = new DesignAgentService(path, true);
let draft: any = await service.call("create", { baseRevision: "none" });
const first = draft.revision;
draft = await service.call("locks", { baseRevision: first, locks: ["/tokens/color.primary"] });
await expect(service.call("create", { baseRevision: first })).rejects.toThrow("CONFLICT");
draft = await service.call("propose", { baseRevision: draft.revision, source: "{unfinished" });
expect(draft.diagnostics.length).toBeGreaterThan(0);
service = new DesignAgentService(path, true);
let state: any = await service.call("inspect");
expect(state.source).toBe("{unfinished");
expect(state.valid.id).toBe("square");
await expect(
service.call("apply", { baseRevision: state.revision, baseApplicationRevision: "none" }),
).rejects.toThrow("INVALID");
draft = await service.call("revert", { baseRevision: state.revision });
state = await service.call("inspect");
expect(state.diagnostics).toEqual([]);
const locked = structuredClone(state.valid);
locked.tokens["color.primary"].value = "#abcdef";
draft = await service.call("propose", {
baseRevision: draft.revision,
source: JSON.stringify(locked),
});
expect(draft.diagnostics.length).toBeGreaterThan(0);
await service.call("revert", { baseRevision: draft.revision, target: "checkpoint" });
state = await service.call("inspect");
expect(state.diagnostics).toEqual([]);
expect(state.locks).toEqual(["/tokens/color.primary"]);
});
test("agents create two compilable consistent pages across all three designs and both modes", async () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
const service = new DesignAgentService(root(), true);
let draft: any = await service.call("create", {
baseRevision: "none",
source: JSON.stringify(system),
});
for (const id of ["account", "support"]) {
draft = await service.call("page", { baseRevision: draft.revision, page: page(id) });
expect(draft.result.ok).toBe(true);
const source = agentPageSource(page(id));
expect(checkDesignConformance(source).ok).toBe(true);
expect(compileWrnFile(source)).toBeTruthy();
}
for (const mode of ["light", "dark"])
for (const id of ["account", "support"]) {
const preview: any = await service.call("preview", { pageId: id, mode });
expect(preview.html).toContain("Content-Security-Policy");
expect(preview.html).toContain('data-wrn-component="Button"');
expect(preview.html).toContain('data-theme="' + mode + '"');
expect(preview.html).toContain("--wrn-radius");
}
const exported: any = await service.call("export");
expect(Object.keys(exported.pages)).toEqual(["account.wrn", "support.wrn"]);
expect(((await service.call("validate")) as any).ok).toBe(true);
}
});
test("conformance rejects invented axes and props; only exact warnings can be waived", () => {
const source =
'page Test { view { <div data-component="Button" variant="invented" nonsense="x"></div> } style { main { color: #fff; padding: 17px; } } }';
const report = checkDesignConformance(source);
expect(report.ok).toBe(false);
expect(report.diagnostics.some((d) => d.code === "WRN-CONFORMANCE-AXIS")).toBe(true);
expect(report.diagnostics.some((d) => d.code === "WRN-CONFORMANCE-PROP")).toBe(true);
const warning = "page Test { view { <main>Test</main> } style { main { color: #fff; } } }";
const d = checkDesignConformance(warning).diagnostics[0]!;
expect(
checkDesignConformance(warning, [
{ code: d.code, path: d.path, reason: "Reviewed brand illustration" },
]).ok,
).toBe(true);
expect(
checkDesignConformance(source, [
{ code: "WRN-CONFORMANCE-AXIS", path: "$.view[0].variant", reason: "No" },
]).ok,
).toBe(false);
expect(checkDesignConformance("broken").ok).toBe(false);
expect(
checkAgentPage({
...page("account"),
components: [{ component: "Button", props: { variant: "invented" } }],
}).ok,
).toBe(false);
expect(
checkAgentPage({
...page("account"),
components: [{ component: "Button", props: { label: "javascript:alert(1)" } }],
}).ok,
).toBe(false);
});
test("application revision checks, exports and reversal preserve existing app configuration", async () => {
const path = root();
writeFileSync(join(path, "wrn.config.ts"), "existing config");
const service = new DesignAgentService(path, true);
let draft: any = await service.call("create", { baseRevision: "none" });
await expect(
service.call("apply", { baseRevision: draft.revision, baseApplicationRevision: "stale" }),
).rejects.toThrow("CONFLICT");
draft = await service.call("apply", {
baseRevision: draft.revision,
baseApplicationRevision: "none",
});
expect(draft.result.revision).not.toBe("none");
expect(readFileSync(join(path, "wrn.config.ts"), "utf8")).toBe("existing config");
const reverted: any = await service.call("revert", {
baseRevision: draft.revision,
target: "application",
baseApplicationRevision: draft.result.revision,
});
expect(reverted.result.revision).toBe("none");
mkdirSync(join(path, ".wrnexus", "design-studio", "agent", "LOCK"));
await expect(service.call("create", { baseRevision: reverted.revision })).rejects.toThrow("BUSY");
});
test("agent context carries resolved tokens and real contract boundaries, with bounded selection", () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS)) {
const context = agentGenerationContext(system, { components: ["Button", "Input"] });
expect(Object.keys(context.components)).toEqual(["Button", "Input"]);
expect(context.tokens["color.primary"]).toBeDefined();
expect((context.components.Button.axes as any).variant.values).toContain("default");
}
expect(() =>
agentGenerationContext(CORE_DESIGN_SYSTEMS.square, { components: ["Invented"] }),
).toThrow("CONTEXT");
});
test("stdio design protocol advertises opt-in tools and returns structured context without model credentials", async () => {
const child = Bun.spawn(["bun", "packages/mcp/src/stdio.ts", "--root=" + root(), "--design"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
child.stdin.write(
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }) +
"\n" +
JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: "wrnexus_design_context", arguments: { components: ["Button"] } },
}) +
"\n",
);
child.stdin.end();
const lines = (await new Response(child.stdout).text())
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(await child.exited).toBe(0);
expect(lines[0].result.tools.some((t: any) => t.name === "wrnexus_design_context")).toBe(true);
expect(lines[1].result.structuredContent.value.components.Button).toBeDefined();
});
test("exported pages preserve literal string, boolean and structured props in compiled mount markup", async () => {
const source = agentPageSource({
id: "literal-props",
title: "Literal props",
components: [
{ component: "Button", props: { label: "Continue", disabled: false } },
{ component: "Select", props: { options: [{ label: "One", value: "one" }] } },
],
});
const code = new Bun.Transpiler({ loader: "ts" }).transformSync(
compileWrnFile(source).replace(
/from (["'])@wrnexus\/core\1/g,
`from ${JSON.stringify(new URL("../../core/src/index.ts", import.meta.url).href)}`,
),
);
const mod = await import("data:text/javascript;base64," + Buffer.from(code).toString("base64"));
const html = await mod.default({});
expect(html).toContain('label="Continue"');
expect(html).toContain('disabled="false"');
expect(html).toContain(
'options="[{&quot;label&quot;:&quot;One&quot;,&quot;value&quot;:&quot;one&quot;}]"',
);
});
import { createDesignPackage } from "@wrnexus/design-system/package";
test("MCP package upgrades preserve history and reject future agent storage without overwriting it", async () => {
const path = root();
const server = createFrameworkMcpServer(path, { design: true, designWrite: true });
let draft: any = await server.call("wrnexus_design_create", { baseRevision: "none" });
const next = structuredClone(CORE_DESIGN_SYSTEMS.square);
next.version = "1.2.0";
const source = JSON.stringify(await createDesignPackage(next));
expect(((await server.call("wrnexus_design_upgrade_plan", { source })) as any).canApply).toBe(
true,
);
draft = await server.call("wrnexus_design_upgrade", { baseRevision: draft.revision, source });
expect(((await server.call("wrnexus_design_inspect")) as any).valid.version).toBe("1.2.0");
await expect(
server.call("wrnexus_design_upgrade", { baseRevision: "none", source }),
).rejects.toThrow("CONFLICT");
await server.call("wrnexus_design_revert", { baseRevision: draft.revision });
expect(((await server.call("wrnexus_design_inspect")) as any).valid.version).toBe("1.0.0");
const file = join(path, ".wrnexus", "design-studio", "agent", "STATE.json");
const state = JSON.parse(readFileSync(file, "utf8"));
const future = JSON.stringify({ ...state, storageVersion: 2 });
writeFileSync(file, future);
await expect(
server.call("wrnexus_design_create", { baseRevision: state.revision }),
).rejects.toThrow("RECOVERY");
expect(readFileSync(file, "utf8")).toBe(future);
});
@@ -0,0 +1,286 @@
import { test, expect } from "bun:test";
import { CORE_DESIGN_SYSTEMS, validateDesignSystem } from "../../design-system/src/index.ts";
import {
generateDesigns,
DEFAULT_DESIGN_BRIEF,
type GenerationRequest,
type GenerationProvider,
} from "../src/generation.ts";
import { designRevision, checkDecisionLocks, preserveDecisionLocks } from "../src/decisions.ts";
import { combineDesignProposals } from "../src/guidance.ts";
import { StudioSession } from "../src/session.ts";
const source = () => structuredClone(CORE_DESIGN_SYSTEMS.square);
async function request(
kind: "directions" | "edit" = "directions",
locks: string[] = [],
system = source(),
): Promise<GenerationRequest> {
return {
system,
locks,
brief: { ...DEFAULT_DESIGN_BRIEF, product: "Booking site", audience: "Mobile customers" },
baseRevision: await designRevision(system, locks),
kind,
instruction: kind === "edit" ? "make buttons rounded and use spacious spacing" : undefined,
};
}
const patched = (changes: unknown[]) =>
JSON.stringify({
alternatives: [{ name: "Edited", rationale: "Requested change", patch: changes }],
});
const set = (path: string, value: unknown) => ({ op: "set", path, value });
test("local guided generation produces three distinct validated directions without changing source", async () => {
const input = await request();
const original = JSON.stringify(input.system);
const result = await generateDesigns(input);
expect(result.diagnostics).toEqual([]);
expect(result.alternatives).toHaveLength(3);
expect(new Set(result.alternatives.map((p) => JSON.stringify(p.system))).size).toBe(3);
for (const proposal of result.alternatives) {
expect(validateDesignSystem(proposal.system)).toEqual([]);
expect(Object.keys(proposal.system.recipes!)).toHaveLength(104);
expect(proposal.system.id).toBe(input.system.id);
}
expect(JSON.stringify(input.system)).toBe(original);
expect(result.provenance.provider).toBe("local-rules");
expect(result.provenance.maxOutputTokens).toBe(0);
});
test("guided geometry, density, surface and brand choices affect the brief direction", async () => {
const input = await request();
input.brief = {
...input.brief,
geometry: "rounded",
density: "spacious",
surface: "soft",
brandColor: "#112233",
};
const result = await generateDesigns(input);
expect(result.diagnostics).toEqual([]);
const system = result.alternatives[0]!.system;
expect(system.tokens["shape.control"]!.value).toBe("16px");
expect(system.tokens["space.control"]!.value).toBe("1.25rem");
expect(system.tokens["elevation.panel"]!.value).toContain("24px");
expect(system.modes!.light!["color.primary"]!.value).toBe("#112233");
expect(result.brief).toEqual(input.brief);
});
test("direct locks, mode overrides and alias changes cannot alter a locked token", () => {
const base = source();
base.tokens["shape.alias"] = { type: "dimension", value: { ref: "shape.control" } };
let next = structuredClone(base);
next.tokens["shape.control"]!.value = "8px";
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
next = structuredClone(base);
next.modes!.light!["shape.control"] = { type: "dimension", value: "8px" };
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).not.toEqual([]);
next = structuredClone(base);
next.tokens["space.page"]!.value = "5rem";
expect(checkDecisionLocks(base, next, ["/tokens/shape.alias"])).toEqual([]);
});
test("component recipe locks protect referenced token values and absent decisions", () => {
const base = source(),
next = structuredClone(base);
next.tokens["shape.control"]!.value = "8px";
expect(checkDecisionLocks(base, next, ["/recipes/Button"])).not.toEqual([]);
const added = structuredClone(base);
added.recipes!.Button!.base!.control!.rotate = "2deg";
expect(checkDecisionLocks(base, added, ["/recipes/Button/base/control/rotate"])).not.toEqual([]);
});
test("directions preserve locks, including recipe token dependencies", async () => {
const input = await request("directions", ["/recipes/Button"]);
const result = await generateDesigns(input);
expect(result.diagnostics).toEqual([]);
for (const p of result.alternatives)
expect(checkDecisionLocks(input.system, p.system, input.locks)).toEqual([]);
const next = preserveDecisionLocks(
input.system,
structuredClone(CORE_DESIGN_SYSTEMS.soft),
input.locks,
);
expect(checkDecisionLocks(input.system, next, input.locks)).toEqual([]);
});
test("manual edits, undo, reload and unlock keep lock-violating drafts recoverable", () => {
const memory = new Map<string, string>();
const storage = {
getItem: (k: string) => memory.get(k) ?? null,
setItem: (k: string, v: string) => {
memory.set(k, v);
},
};
const session = new StudioSession(undefined, storage);
session.setLocks(["/tokens/shape.control"]);
session.mutate((s) => {
s.tokens["shape.control"]!.value = "9px";
}, "Locked edit");
expect(session.valid.tokens["shape.control"]!.value).toBe("0");
expect(session.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
expect(session.source).toContain("9px");
const restored = new StudioSession(undefined, storage);
expect(restored.restore()).toBe(true);
expect(restored.locks).toEqual(session.locks);
expect(restored.diagnostics[0]!.code).toBe("WRN-STUDIO-LOCK");
restored.setLocks([]);
expect(restored.valid.tokens["shape.control"]!.value).toBe("9px");
restored.undo();
expect(restored.valid.tokens["shape.control"]!.value).toBe("0");
});
test("word-based edits validate whole requests and reject unknown or locked changes", async () => {
const result = await generateDesigns(await request("edit"));
expect(result.diagnostics).toEqual([]);
expect(result.alternatives[0]!.system.tokens["shape.control"]!.value).toBe("16px");
const locked = await generateDesigns(await request("edit", ["/tokens/shape.control"]));
expect(locked.alternatives).toEqual([]);
expect(locked.diagnostics[0]!.message).toContain("LOCK");
const input = await request("edit");
input.instruction = "make buttons rounded and invent a new calendar";
const failed = await generateDesigns(input);
expect(failed.alternatives).toEqual([]);
expect(input.system.tokens["shape.control"]!.value).toBe("0");
input.instruction = "set primary color to #aabbcc and remove shadows and use serif typography";
expect((await generateDesigns(input)).diagnostics).toEqual([]);
});
test("stale generation base and stale combinations fail without applying", async () => {
const input = await request();
input.baseRevision = "a".repeat(64);
expect(generateDesigns(input)).rejects.toThrow("WRN-STUDIO-CONFLICT");
const good = await request();
const batch = await generateDesigns(good);
const next = structuredClone(good.system);
next.name = "Changed";
await expect(combineDesignProposals(next, batch, { colors: "direction-2" }, [])).rejects.toThrow(
"CONFLICT",
);
});
test("foundation decisions combine and revalidate before entering a draft", async () => {
const input = await request();
const batch = await generateDesigns(input);
const next = await combineDesignProposals(
input.system,
batch,
{ geometry: "direction-2", typography: "direction-3" },
[],
);
expect(validateDesignSystem(next)).toEqual([]);
expect(next.tokens["shape.control"]).toEqual(
batch.alternatives[1]!.system.tokens["shape.control"],
);
expect(next.tokens["type.body"]).toEqual(batch.alternatives[2]!.system.tokens["type.body"]);
});
test("provider adapter uses bounded patches, compact capabilities and one repair", async () => {
let calls = 0;
let context = "";
const provider: GenerationProvider = {
name: "fixture",
async generate(prompt, options) {
calls++;
context = prompt;
expect(options!.maxTokens).toBe(512);
expect(options!.signal).toBeDefined();
return {
provider: "fixture",
model: "test-model",
value: calls === 1 ? "not json" : patched([set("/tokens/shape.control/value", "11px")]),
};
},
};
const result = await generateDesigns(await request("edit"), { provider, maxOutputTokens: 512 });
expect(calls).toBe(2);
expect(result.diagnostics).toEqual([]);
expect(result.provenance.model).toBe("test-model");
expect(result.provenance.attempts).toBe(2);
expect(context).toContain("capabilities");
expect(context).toContain("Repair these diagnostics");
});
test("provider invalid parts and lock violations cannot survive bounded repair", async () => {
let calls = 0;
const provider: GenerationProvider = {
name: "fixture",
async generate() {
calls++;
return { provider: "fixture", value: patched([set("/tokens/shape.control/value", "12px")]) };
},
};
const result = await generateDesigns(await request("edit", ["/tokens/shape.control"]), {
provider,
});
expect(calls).toBe(2);
expect(result.alternatives).toEqual([]);
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROPOSAL");
const invalid: GenerationProvider = {
name: "fixture",
async generate() {
return {
provider: "fixture",
value: patched([set("/recipes/Button/base/control/position", "fixed")]),
};
},
};
expect(
(await generateDesigns(await request("edit"), { provider: invalid })).alternatives,
).toEqual([]);
});
test("prototype paths, excessive operations and execution strings are rejected", async () => {
for (const patch of [
[set("/tokens/__proto__/value", "x")],
Array.from({ length: 49 }, () => set("/name", "x")),
[set("/tokens/shape.control/value", "0;}body{display:none")],
]) {
const provider: GenerationProvider = {
name: "fixture",
async generate() {
return { provider: "fixture", value: patched(patch) };
},
};
expect((await generateDesigns(await request("edit"), { provider })).alternatives).toEqual([]);
}
});
test("cancellation and deadline stop even a provider that ignores its signal", async () => {
const provider: GenerationProvider = { name: "hung", generate: () => new Promise(() => {}) };
const controller = new AbortController();
const input = await request("edit");
const pending = generateDesigns(input, { provider, signal: controller.signal });
controller.abort();
await expect(pending).rejects.toThrow();
await expect(generateDesigns(input, { provider, timeoutMs: 15 })).rejects.toThrow("cancelled");
expect(input.system.tokens["shape.control"]!.value).toBe("0");
});
test("transport failure is not retried or returned with secret-bearing messages", async () => {
let count = 0;
const provider: GenerationProvider = {
name: "fixture",
async generate() {
count++;
throw Error("secret-key-in-provider-error");
},
};
const result = await generateDesigns(await request("edit"), { provider });
expect(count).toBe(1);
expect(JSON.stringify(result)).not.toContain("secret-key-in-provider-error");
expect(result.diagnostics[0]!.code).toBe("WRN-STUDIO-PROVIDER");
});
test("untrusted brief fields and generation budgets are bounded", async () => {
const input = await request();
input.brief.product = "x".repeat(2001);
await expect(generateDesigns(input)).rejects.toThrow("2000");
await expect(generateDesigns(await request(), { maxOutputTokens: 9000 })).rejects.toThrow(
"BUDGET",
);
const altered = await request();
altered.brief.brandColor = "red;}body{}";
await expect(generateDesigns(altered)).rejects.toThrow("BRIEF");
});
+174
View File
@@ -0,0 +1,174 @@
import { test, expect, afterAll } from "bun:test";
import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createStudioHandler } from "../src/server.ts";
import { StudioApplication } from "../src/application.ts";
import { CORE_DESIGN_SYSTEMS } from "../../design-system/src/index.ts";
import { resolveThemeConfig } from "../../styles/src/theme.ts";
import { designRevision } from "../src/decisions.ts";
import { DEFAULT_DESIGN_BRIEF } from "../src/generation.ts";
const root = mkdtempSync(join(tmpdir(), "wrn-studio-test-"));
afterAll(() => rmSync(root, { recursive: true, force: true }));
const handler = await createStudioHandler({ applicationRoot: root });
const send = (path: string, body?: unknown, origin?: string) =>
handler(
new Request(
"http://127.0.0.1:4320" + path,
body === undefined
? undefined
: {
method: "POST",
headers: { "content-type": "application/json", ...(origin ? { origin } : {}) },
body: JSON.stringify(body),
},
),
);
test("hosted Studio keeps its path, same-origin checks and export-only boundary", async () => {
const hosted = await createStudioHandler({ hosted: { basePath: "/design-studio" } });
const origin = "https://components.example";
const html = await hosted(new Request(origin + "/design-studio/"));
expect(await html.text()).toContain('<base href="/design-studio/">');
expect((await hosted(new Request(origin + "/api/application"))).status).toBe(404);
const state = await hosted(new Request(origin + "/design-studio/api/application"));
expect((await state.json()).enabled).toBe(false);
const proxied = await hosted(
new Request("http://components.example/design-studio/api/application", {
headers: { origin, "x-forwarded-proto": "https" },
}),
);
expect(proxied.status).toBe(200);
const request = (path: string, body: unknown, from = origin) =>
hosted(
new Request(origin + "/design-studio" + path, {
method: "POST",
headers: { "content-type": "application/json", origin: from },
body: JSON.stringify(body),
}),
);
expect(
(await request("/api/apply", { system: CORE_DESIGN_SYSTEMS.soft, base: "none" })).status,
).toBe(409);
expect(
(await request("/api/preview", { system: CORE_DESIGN_SYSTEMS.soft }, "https://other.example"))
.status,
).toBe(403);
const preview = await request("/api/preview", { system: CORE_DESIGN_SYSTEMS.soft });
const { url } = await preview.json();
expect(url).toStartWith("/design-studio/preview/");
expect((await hosted(new Request(origin + url))).status).toBe(200);
await expect(
createStudioHandler({ applicationRoot: root, hosted: { basePath: "/design-studio" } }),
).rejects.toThrow("export-only");
});
test("generation endpoint exposes only configured metadata and stages validated proposals", async () => {
const meta = await (await send("/api/generation")).json();
expect(meta.provider).toBe("local-rules");
expect(JSON.stringify(meta)).not.toContain("apiKey");
const system = structuredClone(CORE_DESIGN_SYSTEMS.soft),
locks = ["/tokens/shape.control"];
const input = {
kind: "directions",
brief: { ...DEFAULT_DESIGN_BRIEF, product: "A website", audience: "Customers" },
system,
locks,
baseRevision: await designRevision(system, locks),
};
const response = await send("/api/generate", input);
expect(response.status).toBe(200);
const batch = await response.json();
expect(batch.alternatives).toHaveLength(3);
expect(batch.provenance.provider).toBe("local-rules");
expect((await send("/api/generate", { ...input, baseRevision: "a".repeat(64) })).status).toBe(
409,
);
expect((await send("/api/generate", input, "https://example.org")).status).toBe(403);
});
test("local application supports immutable bundles, conflicts, rollback and existing config preservation", () => {
const app = new StudioApplication(root);
writeFileSync(join(root, "wrn.config.ts"), "export default { existing: true };");
const first = app.apply(CORE_DESIGN_SYSTEMS.square, "none", { mode: "light" });
expect(existsSync(join(first.directory, "system.css"))).toBe(true);
const theme = JSON.parse(readFileSync(join(first.directory, "theme.json"), "utf8"));
expect(resolveThemeConfig(theme).recipeCss).toContain("data-wrn-owner");
expect(() => app.apply(CORE_DESIGN_SYSTEMS.soft, "none")).toThrow("WRN-STUDIO-CONFLICT");
const second = app.apply(CORE_DESIGN_SYSTEMS.soft, first.revision);
expect(app.rollback(second.revision).revision).toBe(first.revision);
expect(app.system()!.id).toBe("square");
expect(readFileSync(join(root, "wrn.config.ts"), "utf8")).toContain("existing: true");
const fresh = mkdtempSync(join(root, "first-"));
const initial = new StudioApplication(fresh);
const applied = initial.apply(CORE_DESIGN_SYSTEMS.soft, "none");
expect(initial.rollback(applied.revision).revision).toBe("none");
expect(initial.system()).toBeUndefined();
});
test("server serves a browser bundle, rejects cross-origin, invalid, oversized and unconfigured writes", async () => {
expect((await send("/")).status).toBe(200);
const bundle = await send("/studio.js");
expect(bundle.status).toBe(200);
expect(await bundle.text()).not.toContain("node:fs");
expect(
(
await send(
"/api/apply",
{ system: CORE_DESIGN_SYSTEMS.soft, base: "none" },
"https://other.example",
)
).status,
).toBe(403);
expect((await handler(new Request("http://evil.example/api/application"))).status).toBe(403);
expect((await send("/api/apply", { system: {}, base: "none" })).status).toBe(422);
expect((await send("/api/apply", { data: "x".repeat(2_000_001) })).status).toBe(413);
const readOnly = await createStudioHandler();
expect(
(
await readOnly(
new Request("http://127.0.0.1/api/apply", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ system: CORE_DESIGN_SYSTEMS.soft, base: "none" }),
}),
)
).status,
).toBe(409);
});
test("all presets generate isolated real component previews with light/dark and responsive/RTL controls", async () => {
for (const system of Object.values(CORE_DESIGN_SYSTEMS))
for (const mode of ["light", "dark"]) {
const result = await send("/api/preview", { system, mode, direction: "rtl" });
expect(result.status).toBe(200);
const { url } = await result.json();
const preview = await send(url);
const html = await preview.text();
expect(html).toContain(`data-theme="${mode}"`);
expect(html).toContain('dir="rtl"');
expect(html).toContain('data-wrn-component="Radio"');
expect(html).toContain('data-wrn-component="InputNumber"');
expect(html).toContain("data-scope");
expect(html).toContain("data-wrn-controls");
expect(html).not.toContain("failed to load component controllers");
expect(preview.headers.get("content-security-policy")).toContain("connect-src 'none'");
}
});
test("catalog previews cover all 104 component defaults and unknown routes do not expose files", async () => {
let count = 0;
for (let page = 0; page < 7; page++) {
const result = await send("/api/preview", {
system: CORE_DESIGN_SYSTEMS.soft,
content: "catalog",
page,
});
const { url } = await result.json();
const html = await (await send(url)).text();
count += (html.match(/data-wrn-component=/g) ?? []).length;
}
expect(count).toBe(104);
expect((await send("/../../package.json")).status).toBe(404);
});
Loaded 100 of 693 files, more files were not shown because too many files have changed in this diff. Show more