feat(studio): add thirteen presets and preview reviewed proposals
Quality / quality (ubuntu-latest) (push) Failing after 10m52s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-10-19 14:51:03 +05:30
1 parent ba752e556d
commit c765d76a06
12 files changed
+331 -19

No files matched your search

+11
View File
@@ -75,4 +75,15 @@ Run `bun run scripts/evaluate-design-agents.ts` for the six-page/twelve-preview
## Installed packages and upgrades
The preset and comparison menus offer 13 starting systems: Square, Soft, Angular,
Swiss, Forest, Ocean, Rose, Violet, Ember, Editorial, Terminal, Brutalist and
Contrast. Each has light and dark modes and uses the existing component contracts.
The original three engine presets and existing saved drafts retain their formats.
Create new seeds an editable design from the selected preset (Swiss when none is selected).
Generation automatically previews the first valid proposal in the large preview.
Reviewing another proposal switches that preview and labels it as an unchanged
draft review. Use **Show current draft** to return, or **Use reviewed proposal**
to save the design as an undoable draft revision. Export/apply remains separate.
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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/design-studio",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"type": "module",
"main": "src/index.ts",
+25 -12
View File
@@ -1,8 +1,8 @@
import { STUDIO_PRESETS } from "./presets.ts";
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,
@@ -26,6 +26,7 @@ let application: { enabled?: boolean; revision: string; previous?: string; direc
};
let page = 0;
let previewGeneration = 0;
let reviewed: { system: DesignSystem; name: string } | undefined;
let activeTab = "foundations";
let noticeTimer: ReturnType<typeof setTimeout>;
function message(text: string) {
@@ -192,7 +193,7 @@ function renderProperties() {
typeof raw === "object" ? "@" + raw.ref : String(raw ?? "");
}
function render() {
$<HTMLSelectElement>("preset").value = Object.hasOwn(CORE_DESIGN_SYSTEMS, session.valid.id)
$<HTMLSelectElement>("preset").value = Object.hasOwn(STUDIO_PRESETS, session.valid.id)
? session.valid.id
: "";
$("system-name").setAttribute("value", session.valid.name);
@@ -254,12 +255,12 @@ async function previews() {
const comparison = value("compare");
$("comparison").hidden = !comparison;
const systems = [
session.valid,
reviewed?.system ?? session.valid,
...(comparison
? [
comparison === "applied"
? session.applied
: CORE_DESIGN_SYSTEMS[comparison as keyof typeof CORE_DESIGN_SYSTEMS],
: STUDIO_PRESETS[comparison as keyof typeof STUDIO_PRESETS],
]
: []),
];
@@ -285,11 +286,15 @@ async function previews() {
$<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";
$("preview-label").textContent = reviewed
? `Reviewing ${reviewed.name} · draft unchanged`
: session.diagnostics.length
? "Last valid draft · source has errors"
: "Current draft";
$("show-draft").hidden = !reviewed;
}
async function changed() {
reviewed = undefined;
render();
await previews();
}
@@ -328,10 +333,10 @@ on("redo", async () => {
await changed();
});
on("new", async () => {
session.replace(
{ schemaVersion: 1, id: "my-design", name: "My design", version: "1.0.0", tokens: {} },
"Created new system",
);
const system = structuredClone(STUDIO_PRESETS[value("preset")] ?? STUDIO_PRESETS.swiss!);
system.id = "my-design";
system.name = "My design";
session.replace(system, "Created new system");
await changed();
});
on(
@@ -339,7 +344,7 @@ on(
async () => {
if (!value("preset")) return;
session.replace(
CORE_DESIGN_SYSTEMS[value("preset") as keyof typeof CORE_DESIGN_SYSTEMS],
STUDIO_PRESETS[value("preset") as keyof typeof STUDIO_PRESETS],
"Selected " + value("preset"),
);
await changed();
@@ -641,6 +646,10 @@ for (let i = 0; i < 7; i++) {
}
$("preview-content").closest(".preview-controls")!.after(pagination);
on("refresh-preview", previews);
on("show-draft", async () => {
reviewed = undefined;
await previews();
});
document.addEventListener("keydown", (event) => {
if (
(event.ctrlKey || event.metaKey) &&
@@ -666,6 +675,10 @@ window.addEventListener("beforeunload", () => {
const guidance = setupGuidance(session, {
message,
changed,
review: async (system, name) => {
reviewed = system ? { system, name: name ?? system.name } : undefined;
await previews();
},
preview: async (system) => (await request("/api/preview", { system, content: "composed" })).url,
});
render();
+1 -1
View File
@@ -479,7 +479,7 @@ export async function generateDesigns(
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.';
'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). Token maps are FLAT: color.primary is ONE key; never use /tokens/color/primary. Change an existing token with /tokens/color.primary/value. When adding a missing token, set /tokens/color.primary to a COMPLETE token object {"type":"color","value":"#002FA7","legacy":"color-primary"}, never a bare string. Allowed token types are color, dimension, number, fontFamily, fontWeight, shadow, duration, colorScheme. Use the same flat keys in /modes/light/color.primary/value and /modes/dark/color.primary/value. Existing mode overrides win over base tokens: update the active and other applicable modes when changing colors. Only set/remove 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();
+10 -1
View File
@@ -78,6 +78,7 @@ export function setupGuidance(
message: (text: string) => void;
changed: () => Promise<void>;
preview: (system: DesignSystem) => Promise<string>;
review?: (system: DesignSystem | undefined, name?: string) => Promise<void>;
},
) {
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
@@ -197,6 +198,7 @@ export function setupGuidance(
)
.join("\n\n") || "No changes.";
$("accept-proposal").removeAttribute("disabled");
hooks.review?.(proposal.system, proposal.name).catch((e) => hooks.message(e.message));
}
function render() {
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((button) => {
@@ -277,6 +279,7 @@ export function setupGuidance(
status = "Generating proposals. Your draft is unchanged.";
render();
try {
await hooks.review?.(undefined);
const response = await fetch(new URL("./api/generate", document.baseURI), {
method: "POST",
headers: { "content-type": "application/json" },
@@ -294,7 +297,9 @@ export function setupGuidance(
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");
status =
"No valid proposals returned. Your draft and previous alternatives are unchanged.\n" +
result.diagnostics.map((d: { message: string }) => d.message).join("\n");
return;
}
batch = result;
@@ -305,6 +310,10 @@ export function setupGuidance(
)
status =
"Proposals arrived after the draft or locks changed. They are preserved for inspection; regenerate before accepting.";
else if (batch!.alternatives[0]) {
review(batch!.alternatives[0]);
status += " The first proposal is shown in the main preview; your draft is unchanged.";
}
save();
} catch (error) {
if (current === epoch)
+146
View File
@@ -0,0 +1,146 @@
import { CORE_DESIGN_SYSTEMS, type DesignSystem, type TokenMap } from "@wrnexus/design-system";
type Palette = [string, string, string, string, string, string, string, string];
function palette(tokens: TokenMap, values: Palette) {
const [bg, surface, muted, text, subtle, border, primary, contrast] = values;
const colors: Record<string, string> = {
bg,
background: bg,
surface,
"surface-2": muted,
text,
foreground: text,
"text-muted": subtle,
border,
primary,
"primary-hover": primary,
"primary-active": primary,
"primary-contrast": contrast,
"primary-text": primary,
"primary-soft": muted,
"surface-raised": surface,
"surface-muted": muted,
"surface-soft": muted,
"text-subtle": subtle,
"border-strong": border,
"primary-muted": muted,
};
for (const [name, value] of Object.entries(colors))
tokens[`color.${name}`] = { ...tokens[`color.${name}`]!, value };
}
function direction(
id: string,
name: string,
light: Palette,
dark: Palette,
radius: string,
font: string,
shadow = "none",
mode = "light",
): DesignSystem {
const system = structuredClone(CORE_DESIGN_SYSTEMS.soft);
system.id = id;
system.name = name;
system.defaultMode = mode;
palette(system.tokens, mode === "dark" ? dark : light);
palette(system.modes!.light!, light);
palette(system.modes!.dark!, dark);
for (const name of ["shape.control", "shape.panel", "shape.dialog", "shape.base"])
system.tokens[name] = { ...system.tokens[name]!, value: radius };
for (const name of ["type.body", "type.heading"])
system.tokens[name] = { ...system.tokens[name]!, value: font };
system.tokens["elevation.panel"] = { ...system.tokens["elevation.panel"]!, value: shadow };
return system;
}
const sans = '"Helvetica Neue", Arial, sans-serif';
const serif = 'Georgia, "Times New Roman", serif';
const mono = '"Courier New", monospace';
/** Studio choices extend the original three without changing the engine's core preset API. */
export const STUDIO_PRESETS: Record<string, DesignSystem> = {
...CORE_DESIGN_SYSTEMS,
swiss: direction(
"swiss",
"Swiss / Blue",
["#FFFFFF", "#FFFFFF", "#F7F7F8", "#101114", "#53565E", "#C9CBD1", "#002FA7", "#FFFFFF"],
["#101114", "#181A20", "#252832", "#F7F7F8", "#BABEC9", "#51596B", "#91B2FF", "#101114"],
"0",
sans,
),
forest: direction(
"forest",
"Forest / Sage",
["#E8DCC7", "#D4B895", "#C7CCB5", "#283320", "#48543E", "#7B866A", "#43552D", "#E8DCC7"],
["#20291C", "#2C3726", "#394530", "#E8DCC7", "#BDC5A5", "#718060", "#B7D092", "#20291C"],
"24px",
serif,
),
ocean: direction(
"ocean",
"Ocean / Teal",
["#F0FAFC", "#FFFFFF", "#DDF0F4", "#103846", "#42636E", "#99C4CE", "#00677B", "#FFFFFF"],
["#091F29", "#10303D", "#194452", "#E4F5FA", "#A3C5CE", "#477783", "#60D5DF", "#091F29"],
"8px",
sans,
),
rose: direction(
"rose",
"Rose / Rounded",
["#FFF3F5", "#FFFFFF", "#FADFE7", "#421E2C", "#76505F", "#D9A4B5", "#A32957", "#FFFFFF"],
["#29131E", "#3D202F", "#512C3D", "#FFEAF1", "#DBB2C2", "#955A71", "#FF9BBC", "#29131E"],
"20px",
sans,
),
violet: direction(
"violet",
"Violet / Studio",
["#F5F3FF", "#FFFFFF", "#E7E1FA", "#282044", "#625775", "#B9ADD4", "#6336AD", "#FFFFFF"],
["#1B142C", "#2A203E", "#3B2B54", "#F3EDFF", "#C2B2D8", "#7C649E", "#C8ABFF", "#1B142C"],
"12px",
sans,
"0 8px 24px #00000018",
),
ember: direction(
"ember",
"Ember / Terracotta",
["#E8DCC7", "#D4B895", "#CEB094", "#3D241C", "#694837", "#A7795A", "#8E3C21", "#E8DCC7"],
["#2B1B15", "#3B2820", "#50362B", "#E8DCC7", "#CEB094", "#9C7057", "#F4AE80", "#2B1B15"],
"16px",
serif,
),
editorial: direction(
"editorial",
"Editorial / Ink",
["#FFFFFF", "#FFFFFF", "#F0F0F0", "#171717", "#555555", "#A3A3A3", "#171717", "#FFFFFF"],
["#101010", "#1C1C1C", "#2B2B2B", "#FAFAFA", "#BCBCBC", "#747474", "#FAFAFA", "#101010"],
"0",
serif,
),
terminal: direction(
"terminal",
"Terminal / Green",
["#FFFFFF", "#FFFFFF", "#E4F7EB", "#001C0D", "#375D44", "#609B77", "#00642C", "#FFFFFF"],
["#000000", "#0B0C0A", "#102318", "#E1FFE9", "#9DCDA9", "#3A7550", "#00E676", "#000000"],
"0",
mono,
"none",
"dark",
),
brutalist: direction(
"brutalist",
"Brutalist / Primary",
["#FFFFFF", "#FFFF00", "#FFFFFF", "#000000", "#000000", "#000000", "#0000FF", "#FFFFFF"],
["#000000", "#000000", "#202020", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFF00", "#000000"],
"0",
mono,
"4px 4px 0 #000000",
),
contrast: direction(
"contrast",
"Contrast / Clear",
["#FFFFFF", "#FFFFFF", "#E6E6E6", "#000000", "#333333", "#000000", "#002FA7", "#FFFFFF"],
["#000000", "#000000", "#1B1B1B", "#FFFFFF", "#E0E0E0", "#FFFFFF", "#FFFF00", "#000000"],
"4px",
sans,
),
};
+15 -2
View File
@@ -1,7 +1,14 @@
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>
<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>
<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>${Object.entries(
STUDIO_PRESETS,
)
.map(([id, system]) => `<option value="${id}">${system.name}</option>`)
.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>
${guidanceHtml}
@@ -10,6 +17,12 @@ ${guidanceHtml}
<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>`;
</section><section class="preview"><div class="preview-heading"><div><p class="eyebrow">Same content, different decisions</p><h2>Preview</h2></div><div class="row"><button id="show-draft" hidden>Show current draft</button><button id="refresh-preview">Refresh</button></div></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>${Object.entries(
STUDIO_PRESETS,
)
.map(([id, system]) => `<option value="${id}">${system.name}</option>`)
.join(
"",
)}</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
@@ -202,6 +202,51 @@ test("provider adapter uses bounded patches, compact capabilities and one repair
expect(context).toContain("Repair these diagnostics");
});
test("empty drafts require flat typed tokens and reject nested color groups before repair", async () => {
const system = {
schemaVersion: 1 as const,
id: "empty",
name: "Empty",
version: "1.0.0",
tokens: {},
};
let calls = 0;
const batch = await generateDesigns(await request("directions", [], system), {
provider: {
name: "fixture",
async generate(_prompt, options) {
calls++;
expect(options!.system).toContain("Token maps are FLAT");
expect(options!.system).toContain("COMPLETE token object");
return {
provider: "fixture",
value: JSON.stringify({
alternatives: ["#002FA7", "#00642C", "#A32957"].map((color, index) => ({
name: `Direction ${index}`,
rationale: "A distinct brand color.",
patch: [
calls === 1
? set("/tokens/color", { primary: { type: "color", value: color } })
: set("/tokens/color.primary", {
type: "color",
value: color,
legacy: "color-primary",
}),
],
})),
}),
};
},
},
});
expect(calls).toBe(2);
expect(batch.alternatives).toHaveLength(3);
expect(batch.diagnostics).toEqual([]);
for (const alternative of batch.alternatives)
expect(validateDesignSystem(alternative.system)).toEqual([]);
expect(system.tokens).toEqual({});
});
test("provider invalid parts and lock violations cannot survive bounded repair", async () => {
let calls = 0;
const provider: GenerationProvider = {
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import {
CORE_DESIGN_SYSTEMS,
validateDesignSystem,
compileDesignSystem,
} from "@wrnexus/design-system";
import { STUDIO_PRESETS } from "../src/presets.ts";
import { studioHtml } from "../src/shell.ts";
import { StudioSession } from "../src/session.ts";
test("thirteen distinct Studio presets validate and compile both modes without changing core presets", () => {
expect(Object.keys(STUDIO_PRESETS)).toHaveLength(13);
expect(Object.keys(CORE_DESIGN_SYSTEMS)).toEqual(["square", "soft", "angular"]);
const signatures = new Set<string>();
for (const [id, system] of Object.entries(STUDIO_PRESETS)) {
expect(system.id).toBe(id);
expect(validateDesignSystem(system)).toEqual([]);
expect(studioHtml).toContain(`<option value="${id}">${system.name}</option>`);
signatures.add(JSON.stringify([system.tokens, system.modes]));
for (const mode of ["light", "dark"])
expect(() => compileDesignSystem(system, { mode })).not.toThrow();
}
expect(signatures.size).toBe(13);
});
test("choosing a new preset stays undoable and never mutates the catalog", () => {
const session = new StudioSession(STUDIO_PRESETS.square);
const original = JSON.stringify(STUDIO_PRESETS.swiss);
session.replace(STUDIO_PRESETS.swiss!, "Selected Swiss");
session.edit(JSON.stringify({ ...session.valid, name: "My Swiss" }));
expect(JSON.stringify(STUDIO_PRESETS.swiss)).toBe(original);
session.undo();
session.undo();
expect(session.valid.id).toBe("square");
});
+2 -1
View File
@@ -8,6 +8,7 @@ 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";
import { STUDIO_PRESETS } from "../src/presets.ts";
const root = mkdtempSync(join(tmpdir(), "wrn-studio-test-"));
afterAll(() => rmSync(root, { recursive: true, force: true }));
@@ -139,7 +140,7 @@ test("server serves a browser bundle, rejects cross-origin, invalid, oversized a
});
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 system of Object.values(STUDIO_PRESETS))
for (const mode of ["light", "dark"]) {
const result = await send("/api/preview", { system, mode, direction: "rtl" });
expect(result.status).toBe(200);