feat(studio): add offline manual theme designer
Quality / quality (ubuntu-latest) (push) Failing after 6m40s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-10-19 20:58:12 +05:30
1 parent 5259668006
commit ae35ac6714
7 files changed
+570 -8

No files matched your search

+1 -1
View File
@@ -391,7 +391,7 @@
},
"packages/design-studio": {
"name": "@wrnexus/design-studio",
"version": "0.1.7",
"version": "0.1.8",
"bin": {
"wrnexus-design-studio": "src/cli.ts",
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/design-studio",
"version": "0.1.7",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
+16 -2
View File
@@ -1,3 +1,4 @@
import { setupManual } from "./manual-editor.ts";
import { handlePreviewClipboard } from "./preview-clipboard.ts";
import { STUDIO_PRESETS } from "./presets.ts";
import { setupGuidance } from "./guidance.ts";
@@ -29,7 +30,7 @@ let page = 0;
let previewGeneration = 0;
let previewLoadTimer: ReturnType<typeof setTimeout>;
let reviewed: { system: DesignSystem; name: string } | undefined;
let activeTab = "foundations";
let activeTab = "manual";
let noticeTimer: ReturnType<typeof setTimeout>;
function message(text: string) {
$("message").textContent = text;
@@ -93,7 +94,7 @@ function selection() {
}
function tab(name: string) {
activeTab = name;
for (const id of ["guided", "foundations", "components", "source", "history", "review"])
for (const id of ["manual", "guided", "foundations", "components", "source", "history", "review"])
$(id === "review" ? "review-panel" : id).hidden = id !== name;
document
.querySelectorAll<HTMLButtonElement>("[data-tab]")
@@ -102,6 +103,7 @@ function tab(name: string) {
);
$("editor-title").textContent =
{
manual: "Manual design",
guided: "Guided design",
foundations: "Foundations",
components: "Components",
@@ -245,6 +247,7 @@ function render() {
: "Export-only workspace. Enable local apply by starting the Studio with --app-root <existing application folder>.";
tab(activeTab);
guidance?.render();
manual?.render();
}
async function previews() {
const generation = ++previewGeneration;
@@ -704,6 +707,17 @@ window.addEventListener("beforeunload", () => {
}
} else session.persist();
});
const manual = setupManual(session, {
message,
changed,
mode: (mode) => {
const select = $<HTMLSelectElement>("preview-mode");
const next = mode === "base" ? "" : mode;
if (select.value === next) return;
select.value = next;
previews().catch((e) => message(e.message));
},
});
const guidance = setupGuidance(session, {
message,
changed,
+230
View File
@@ -0,0 +1,230 @@
import { MANUAL_COLORS, MANUAL_FONTS, manualDesign, type ManualEdit } from "./manual.ts";
import { checkDecisionLocks } from "./decisions.ts";
import type { StudioSession } from "./session.ts";
const escape = (value: unknown) =>
String(value).replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!,
);
export const manualHtml = `<section id="manual" class="editor"><p>Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.</p><button type="button" class="primary" data-manual-apply>Update preview</button><label>Default theme<select id="manual-default-mode"></select></label><label>Edit color mode<select id="manual-mode"></select></label><h3>Colors</h3><p class="help">Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.</p><div id="manual-colors" class="manual-grid"></div><details><summary>All other color tokens</summary><p class="help">Fine-tune hover, contrast, focus, input, code and custom color tokens.</p><div id="manual-extra-colors" class="manual-grid"></div></details><h3>Shape and spacing</h3><div id="manual-shapes" class="manual-grid"></div><p class="help">Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.</p><div class="manual-grid"><label>Button style<select id="manual-button-style"><option value="solid">Solid</option><option value="outline">Outlined</option></select></label><label>Tilt<select id="manual-tilt"><option value="none">None</option><option value="subtle">Subtle</option><option value="expressive">Expressive</option></select></label></div><h3>Typography</h3><div id="manual-fonts"></div><p class="help">Use a system font below or enter your own font stack. Custom fonts must also be loaded in your application.</p><h3>Surface</h3><label>Shadow<select id="manual-shadow"><option value="none">Flat / no shadow</option><option value="0 8px 24px #00000018">Soft shadow</option><option value="2px 2px 0 #000000">Crisp shadow</option></select></label><p class="help">Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review &amp; apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.</p><button type="button" class="primary" data-manual-apply>Update preview</button></section>`;
export const manualCss = `.manual-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0 16px}.manual-color{display:flex;gap:8px;align-items:center}.manual-color input[type=color]{width:44px;height:38px;padding:3px;flex:none}.manual-color input[type=text]{flex:1;font-family:Consolas,monospace}.manual-grid input[type=number]{max-width:150px}`;
export function setupManual(
session: StudioSession,
hooks: {
changed: () => Promise<void>;
message: (text: string) => void;
mode: (name: string) => void;
},
) {
const $ = (id: string) => document.getElementById(id)!;
let identity = session.valid.id;
let mode = session.valid.defaultMode ?? Object.keys(session.valid.modes ?? {})[0] ?? "base";
let dirty = false;
async function applyForm() {
try {
if (session.diagnostics.length)
throw Error(
"Resolve draft errors before editing manually. Your last valid design is preserved.",
);
const resolved = session.resolved(mode === "base" ? {} : { mode });
const edits: ManualEdit[] = [];
$("manual")
.querySelectorAll<HTMLInputElement>("input[type=text][data-manual-color]")
.forEach((input) => {
if (
input.value &&
input.value !==
String(resolved.tokens["color." + input.dataset.manualColor]?.value ?? "")
)
edits.push({
kind: "color",
mode,
name: input.dataset.manualColor!,
value: input.value,
});
});
$("manual")
.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-manual-token]")
.forEach((input) => {
const name = input.dataset.manualToken!,
raw = String(resolved.tokens[name]?.value ?? "");
if (input instanceof HTMLInputElement && input.type === "number") {
const previous = Number(input.dataset.manualOriginal);
if (Number(input.value) !== previous)
edits.push({ kind: "token", name, value: input.value + "px" });
} else if (input.value !== raw) edits.push({ kind: "token", name, value: input.value });
});
const value = (id: string) => ($(id) as HTMLSelectElement).value;
if (
value("manual-default-mode") &&
value("manual-default-mode") !== session.valid.defaultMode
)
edits.push({ kind: "mode", value: value("manual-default-mode") });
const rule = session.valid.recipes?.Button?.variants?.find(
(r) => r.when.variant === "default",
);
if (
value("manual-button-style") !==
(rule?.parts.control?.backgroundColor === "transparent" ? "outline" : "solid")
)
edits.push({
kind: "buttonStyle",
value: value("manual-button-style") as "solid" | "outline",
});
const tilt = String(session.valid.recipes?.Button?.base?.control?.rotate ?? "0deg"),
oldTilt = tilt === "0deg" ? "none" : tilt === "-0.5deg" ? "subtle" : "expressive";
if (value("manual-tilt") !== oldTilt)
edits.push({
kind: "tilt",
value: value("manual-tilt") as "none" | "subtle" | "expressive",
});
if (value("manual-shadow") !== String(resolved.tokens["elevation.panel"]?.value ?? "none"))
edits.push({ kind: "token", name: "elevation.panel", value: value("manual-shadow") });
const next = edits.reduce((system, edit) => manualDesign(system, edit), session.valid);
if (checkDecisionLocks(session.valid, next, session.locks).length)
throw Error("This change affects a locked decision. Unlock it in Guided design first.");
session.replace(next, "Manual theme settings");
dirty = false;
const defaultEdit = edits.find((e) => e.kind === "mode");
if (defaultEdit) mode = defaultEdit.value;
await hooks.changed();
hooks.mode(mode);
hooks.message("Manual theme saved. Preview updated. Undo restores the previous settings.");
} catch (error) {
hooks.message((error as Error).message);
}
}
$("manual")
.querySelectorAll("[data-manual-apply]")
.forEach((button) =>
button.addEventListener("click", () => {
void applyForm();
}),
);
$("manual").addEventListener("input", (event) => {
const input = event.target as HTMLInputElement;
if (input.id === "manual-mode") return;
dirty = true;
if (input.dataset.manualColor) {
const pair = input
.closest("label")
?.querySelector<HTMLInputElement>(
input.type === "color" ? "input[type=text]" : "input[type=color]",
);
if (pair && /^#[a-f0-9]{6}$/i.test(input.value)) pair.value = input.value;
}
});
$("manual").addEventListener("change", (event) => {
if ((event.target as HTMLElement).id !== "manual-mode") dirty = true;
});
$("manual-mode").addEventListener("change", () => {
if (dirty) {
($("manual-mode") as HTMLSelectElement).value = mode;
hooks.message("Select Update preview to save these settings before switching color modes.");
return;
}
mode = ($("manual-mode") as HTMLSelectElement).value;
render();
hooks.mode(mode);
});
document.getElementById("preview-mode")!.addEventListener("change", () => {
mode =
(document.getElementById("preview-mode") as HTMLSelectElement).value ||
session.valid.defaultMode ||
"base";
render();
});
function render() {
dirty = false;
if (identity !== session.valid.id) {
identity = session.valid.id;
mode =
(document.getElementById("preview-mode") as HTMLSelectElement).value ||
session.valid.defaultMode ||
"base";
}
const modes = Object.keys(session.valid.modes ?? {});
if (!modes.includes(mode)) mode = modes[0] ?? "base";
($("manual-mode") as HTMLSelectElement).innerHTML = (modes.length ? modes : ["base"])
.map(
(m) => `<option ${m === mode ? "selected" : ""} value="${escape(m)}">${escape(m)}</option>`,
)
.join("");
($("manual-default-mode") as HTMLSelectElement).innerHTML = modes
.map(
(m) =>
`<option value="${escape(m)}" ${m === session.valid.defaultMode ? "selected" : ""}>${escape(m)}</option>`,
)
.join("");
const resolved = session.resolved(mode === "base" ? {} : { mode });
$("manual-colors").innerHTML = Object.entries(MANUAL_COLORS)
.map(([name, label]) => {
const raw = String(resolved.tokens["color." + name]?.value ?? "");
const hex = /^#[a-f0-9]{6}$/i.test(raw) ? raw : "#666666";
return `<label>${label}<span class="manual-color"><input type="color" aria-label="${label} color picker" data-manual-color="${name}" value="${hex}"><input type="text" aria-label="${label} hex color" data-manual-color="${name}" value="${escape(raw)}" maxlength="7" placeholder="Inherited"></span></label>`;
})
.join("");
$("manual-extra-colors").innerHTML = Object.entries(resolved.tokens)
.filter(
([name, token]) =>
name.startsWith("color.") &&
token.type === "color" &&
!Object.hasOwn(MANUAL_COLORS, name.slice(6)),
)
.map(([name, token]) => {
const color = name.slice(6),
raw = String(token.value),
hex = /^#[a-f0-9]{6}$/i.test(raw) ? raw : "#666666";
return `<label>${escape(color)}<span class="manual-color"><input type="color" aria-label="${escape(color)} color picker" data-manual-color="${escape(color)}" value="${hex}"><input type="text" aria-label="${escape(color)} hex color" data-manual-color="${escape(color)}" value="${escape(raw)}" placeholder="#00677B"></span></label>`;
})
.join("");
$("manual-shapes").innerHTML = [
["shape.control", "Control corners (px)"],
["shape.panel", "Panel corners (px)"],
["shape.dialog", "Dialog corners (px)"],
["space.control", "Control padding (px)"],
["space.panel", "Panel padding (px)"],
]
.map(([name, label]) => {
const raw = String(resolved.tokens[name!]?.value ?? "0");
const px = parseFloat(raw) * (raw.endsWith("rem") ? 16 : 1);
return `<label>${label}<input type="number" min="0" max="${name!.startsWith("shape.") ? 999 : 64}" data-manual-token="${name}" data-manual-original="${Number.isFinite(px) ? px : 0}" value="${Number.isFinite(px) ? px : 0}"></label>`;
})
.join("");
const buttonRule = session.valid.recipes?.Button?.variants?.find(
(rule) => rule.when.variant === "default",
);
($("manual-button-style") as HTMLSelectElement).value =
buttonRule?.parts.control?.backgroundColor === "transparent" ? "outline" : "solid";
const tilt = String(session.valid.recipes?.Button?.base?.control?.rotate ?? "0deg");
($("manual-tilt") as HTMLSelectElement).value =
tilt === "0deg" ? "none" : tilt === "-0.5deg" ? "subtle" : "expressive";
$("manual-fonts").innerHTML = [
["type.body", "Body font"],
["type.heading", "Heading font"],
]
.map(([name, label]) => {
const current = String(resolved.tokens[name!]?.value ?? MANUAL_FONTS.sans);
return `<label>${label}<select data-manual-token="${name}"><option value="${escape(current)}">Current: ${escape(current)}</option>${Object.entries(
MANUAL_FONTS,
)
.map(([label, font]) => `<option value="${escape(font)}">${label}</option>`)
.join(
"",
)}</select><input aria-label="Custom ${label!.toLowerCase()} stack" data-manual-token="${name}" value="${escape(current)}"></label>`;
})
.join("");
const shadow = $("manual-shadow") as HTMLSelectElement;
const current = String(resolved.tokens["elevation.panel"]?.value ?? "none");
Array.from(shadow.options)
.filter((o) => o.dataset.custom)
.forEach((o) => o.remove());
if (!Array.from(shadow.options).some((o) => o.value === current)) {
const option = new Option("Current: " + current, current);
option.dataset.custom = "true";
shadow.add(option);
}
shadow.value = current;
}
return { render };
}
+182
View File
@@ -0,0 +1,182 @@
import { validateDesignSystem, type DesignSystem, type DesignToken } from "@wrnexus/design-system";
export const MANUAL_COLORS = {
primary: "Brand / primary",
secondary: "Secondary",
bg: "Page background",
surface: "Surface",
"surface-2": "Subtle surface",
text: "Text",
"text-muted": "Muted text",
border: "Borders",
success: "Success",
warning: "Warning",
danger: "Error",
info: "Information",
} as const;
export const MANUAL_FONTS = {
sans: '"Helvetica Neue", Arial, sans-serif',
humanist: '"Trebuchet MS", Arial, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
mono: '"Courier New", monospace',
};
export type ManualEdit =
| { kind: "color"; mode: string; name: string; value: string }
| { kind: "token"; name: string; value: string }
| { kind: "mode"; value: string }
| { kind: "buttonStyle"; value: "solid" | "outline" }
| { kind: "tilt"; value: "none" | "subtle" | "expressive" };
function ink(hex: string) {
const channels = hex
.slice(1)
.match(/../g)!
.map((s) => parseInt(s, 16) / 255)
.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
const luminance = channels.reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i]!, 0);
return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#FFFFFF";
}
export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSystem {
const system = structuredClone(source);
const set = (
tokens: Record<string, DesignToken>,
name: string,
value: string,
type: DesignToken["type"] = "color",
legacy?: string,
) => {
tokens[name] = {
...tokens[name],
type,
value,
...(legacy ? { legacy: tokens[name]?.legacy ?? legacy } : {}),
};
};
if (edit.kind === "buttonStyle") {
const recipe = ((system.recipes ??= {}).Button ??= { contractVersion: 1, base: {} });
const variant = {
when: { variant: "default" },
parts: {
control: {
backgroundImage: "none",
backgroundColor: edit.value === "outline" ? "transparent" : { ref: "color.primary" },
color: { ref: edit.value === "outline" ? "color.primary" : "color.primary-contrast" },
borderColor: { ref: "color.primary" },
},
},
};
recipe.variants = [
...(recipe.variants ?? []).filter(
(rule) => !(Object.keys(rule.when).length === 1 && rule.when.variant === "default"),
),
variant,
];
} else if (edit.kind === "tilt") {
for (const [name, part, angle] of [
[
"Button",
"control",
edit.value === "none" ? "0deg" : edit.value === "subtle" ? "-0.5deg" : "-2deg",
],
[
"Card",
"panel",
edit.value === "none" ? "0deg" : edit.value === "subtle" ? "0.25deg" : "1deg",
],
]) {
const recipe = ((system.recipes ??= {})[name!] ??= { contractVersion: 1, base: {} });
((recipe.base ??= {})[part!] ??= {}).rotate = angle!;
}
} else if (edit.kind === "mode") {
if (!Object.hasOwn(system.modes ?? {}, edit.value))
throw Error("Choose an existing light or dark mode.");
system.defaultMode = edit.value;
} else if (edit.kind === "color") {
if (
(!Object.hasOwn(MANUAL_COLORS, edit.name) &&
system.tokens["color." + edit.name]?.type !== "color" &&
system.modes?.[edit.mode]?.["color." + edit.name]?.type !== "color") ||
!/^#[a-f0-9]{6}$/i.test(edit.value)
)
throw Error("Choose a color or enter a six-digit hex value, such as #00677B.");
const layer = edit.mode === "base" ? system.tokens : system.modes?.[edit.mode];
if (!layer) throw Error("Choose an existing light or dark mode.");
const color = (name: string, value = edit.value) =>
set(
layer,
"color." + name,
value,
"color",
layer["color." + name]?.legacy ?? system.tokens["color." + name]?.legacy ?? "color-" + name,
);
color(edit.name);
for (const alias of (
{
bg: ["background"],
surface: ["surface-raised", "input-background"],
"surface-2": [
"surface-soft",
"surface-muted",
"primary-soft",
"primary-muted",
"code-background",
],
text: ["foreground", "code-text"],
"text-muted": ["text-subtle", "muted"],
border: ["border-strong", "input-border", "code-border"],
} as Record<string, string[]>
)[edit.name] ?? [])
color(alias);
if (["primary", "secondary", "success", "warning", "danger", "info"].includes(edit.name)) {
color(edit.name + "-contrast", ink(edit.value));
color(edit.name + "-soft", edit.value + "1A");
if (edit.name === "primary") color("primary-muted", edit.value + "1A");
color(edit.name + "-text");
for (const [suffix, factor] of [
["-hover", 0.88],
["-active", 0.75],
] as const)
color(
edit.name + suffix,
"#" +
edit.value
.slice(1)
.match(/../g)!
.map((c) =>
Math.round(parseInt(c, 16) * factor)
.toString(16)
.padStart(2, "0"),
)
.join(""),
);
if (edit.name === "primary") color("focus");
}
} else {
const allowed: Record<string, [DesignToken["type"], string | undefined]> = {
"shape.control": ["dimension", "radius-sm"],
"shape.panel": ["dimension", "radius-md"],
"shape.dialog": ["dimension", "radius-lg"],
"type.body": ["fontFamily", "font-sans"],
"type.heading": ["fontFamily", undefined],
"elevation.panel": ["shadow", "shadow-1"],
"space.control": ["dimension", undefined],
"space.panel": ["dimension", "space-panel"],
};
if (!allowed[edit.name]) throw Error("Unsupported manual design setting.");
const [type, legacy] = allowed[edit.name]!;
if (
type === "dimension" &&
(!/^\d+(?:\.\d+)?px$/.test(edit.value) ||
parseFloat(edit.value) > (edit.name.startsWith("shape.") ? 999 : 64))
)
throw Error("Enter corners between 0 and 999 pixels, or padding between 0 and 64 pixels.");
set(system.tokens, edit.name, edit.value, type, legacy);
// Existing mode overrides must not silently win over a shared foundation edit.
for (const layer of Object.values(system.modes ?? {}))
if (layer[edit.name]) set(layer, edit.name, edit.value, type, legacy);
if (edit.name === "shape.panel")
set(system.tokens, "shape.base", edit.value, "dimension", "radius");
}
const errors = validateDesignSystem(system);
if (errors.length) throw Error(errors.map((e) => e.message).join("\n"));
return system;
}
+6 -4
View File
@@ -1,3 +1,4 @@
import { manualHtml, manualCss } from "./manual-editor.ts";
import { STUDIO_PRESETS } from "./presets.ts";
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>
@@ -9,10 +10,11 @@ export const studioHtml = `<!doctype html><html lang="en"><head><meta charset="u
.join(
"",
)}</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>
<nav aria-label="Editors"><button data-tab="manual" aria-current="page">Manual design</button><button data-tab="guided">Guided design</button><button data-tab="foundations">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">Manual design</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
${manualHtml}
${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="foundations" class="editor" hidden><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>
@@ -25,4 +27,4 @@ ${guidanceHtml}
"",
)}</select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery uses populated interactive samples. Forms and uploads do not send data.</p><p id="preview-status" role="status" aria-live="polite">Loading preview…</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
export const studioCss = `${manualCss}${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 Line truncated
+134
View File
@@ -0,0 +1,134 @@
import { expect, test } from "bun:test";
import {
CORE_DESIGN_SYSTEMS,
validateDesignSystem,
compileDesignSystem,
} from "@wrnexus/design-system";
import { manualDesign } from "../src/manual.ts";
import { StudioSession } from "../src/session.ts";
import { checkDecisionLocks } from "../src/decisions.ts";
test("manual colors update only their mode and maintain readable contrast and legacy bindings", () => {
const source = CORE_DESIGN_SYSTEMS.soft;
const original = JSON.stringify(source);
const next = manualDesign(source, {
kind: "color",
mode: "light",
name: "primary",
value: "#000000",
});
expect(next.modes!.light!["color.primary"]!.value).toBe("#000000");
expect(next.modes!.light!["color.primary-contrast"]!.value).toBe("#FFFFFF");
expect(next.modes!.light!["color.focus"]!.legacy).toBe("color-focus");
expect(next.modes!.dark).toEqual(source.modes!.dark);
expect(JSON.stringify(source)).toBe(original);
expect(validateDesignSystem(next)).toEqual([]);
expect(() => compileDesignSystem(next, { mode: "light" })).not.toThrow();
expect(() =>
manualDesign(source, {
kind: "color",
mode: "light",
name: "primary",
value: "red; display:none",
}),
).toThrow();
});
test("manual foundations are recoverable and decision locks still detect changes", () => {
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
const before = JSON.stringify(session.valid);
const next = manualDesign(session.valid, { kind: "token", name: "shape.control", value: "8px" });
expect(checkDecisionLocks(session.valid, next, ["/tokens/shape.control"])).not.toHaveLength(0);
session.replace(next, "Manual shape");
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
session.undo();
expect(JSON.stringify(session.valid)).toBe(before);
session.redo();
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
expect(() =>
manualDesign(next, { kind: "token", name: "shape.control", value: "-2px" }),
).toThrow();
});
test("manual typography, spacing, surfaces and semantic colors validate for all core themes", () => {
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
let system = structuredClone(original);
for (const [name, value] of [
["type.body", "Arial, sans-serif"],
["type.heading", "Georgia, serif"],
["space.panel", "24px"],
["shape.panel", "12px"],
["elevation.panel", "none"],
])
system = manualDesign(system, { kind: "token", name: name!, value: value! });
for (const mode of ["light", "dark"])
for (const name of ["secondary", "success", "warning", "danger", "info"] as const)
system = manualDesign(system, { kind: "color", mode, name, value: "#00677B" });
expect(validateDesignSystem(system)).toEqual([]);
expect(() => compileDesignSystem(system, { mode: "dark" })).not.toThrow();
}
});
test("manual button styles, tilt, pill shapes, default mode and advanced colors compile", () => {
let system = manualDesign(CORE_DESIGN_SYSTEMS.soft, { kind: "buttonStyle", value: "outline" });
system = manualDesign(system, { kind: "tilt", value: "none" });
system = manualDesign(system, { kind: "token", name: "shape.control", value: "999px" });
system = manualDesign(system, { kind: "mode", value: "dark" });
system = manualDesign(system, {
kind: "color",
mode: "dark",
name: "primary-contrast",
value: "#FFFFFF",
});
expect(system.defaultMode).toBe("dark");
expect(system.recipes!.Button!.variants!.at(-1)!.parts.control!.backgroundColor).toBe(
"transparent",
);
expect(system.recipes!.Button!.base!.control!.rotate).toBe("0deg");
expect(() => compileDesignSystem(system)).not.toThrow();
});
test("manual form saves one atomic revision and rejects locked changes without corrupting drafts", async () => {
const { Window } = await import("happy-dom");
const { manualHtml, setupManual } = await import("../src/manual-editor.ts");
const win = new Window();
const names = ["document", "HTMLInputElement", "Option"] as const;
const previous = Object.fromEntries(names.map((name) => [name, (globalThis as any)[name]]));
try {
(globalThis as any).document = win.document;
(globalThis as any).HTMLInputElement = win.HTMLInputElement;
(globalThis as any).Option = win.Option;
win.document.body.innerHTML =
manualHtml + '<select id="preview-mode"><option value="light">light</option></select>';
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
let notice = "";
const editor = setupManual(session, {
changed: async () => {
editor.render();
},
message: (text) => {
notice = text;
},
mode: () => {},
});
editor.render();
const input = win.document.querySelector('[data-manual-token="shape.control"]') as any;
input.value = "12";
(win.document.querySelector('input[type=text][data-manual-color="primary"]') as any).value =
"#6336AD";
(win.document.querySelector("[data-manual-apply]") as any).click();
await Bun.sleep(10);
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
expect(session.valid.modes!.light!["color.primary"]!.value).toBe("#6336AD");
expect(session.history).toHaveLength(2);
session.setLocks(["/tokens/shape.control"]);
editor.render();
(win.document.querySelector('[data-manual-token="shape.control"]') as any).value = "24";
(win.document.querySelector("[data-manual-apply]") as any).click();
await Bun.sleep(10);
expect(notice).toContain("locked decision");
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
expect(session.diagnostics).toEqual([]);
} finally {
for (const name of names) (globalThis as any)[name] = previous[name];
await win.happyDOM.close();
}
});