From c765d76a06fd1fe25f007842da3d9467e1d6fa4b Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 19 Oct 2026 14:51:03 +0530 Subject: [PATCH] feat(studio): add thirteen presets and preview reviewed proposals --- bun.lock | 2 +- ...-19-studio-presets-and-proposal-preview.md | 39 +++++ packages/design-studio/README.md | 11 ++ packages/design-studio/package.json | 2 +- packages/design-studio/src/client.ts | 37 +++-- packages/design-studio/src/generation.ts | 2 +- packages/design-studio/src/guidance.ts | 11 +- packages/design-studio/src/presets.ts | 146 ++++++++++++++++++ packages/design-studio/src/shell.ts | 17 +- .../design-studio/test/generation.test.ts | 45 ++++++ packages/design-studio/test/presets.test.ts | 35 +++++ packages/design-studio/test/server.test.ts | 3 +- 12 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 docs/releases/2026-10-19-studio-presets-and-proposal-preview.md create mode 100644 packages/design-studio/src/presets.ts create mode 100644 packages/design-studio/test/presets.test.ts diff --git a/bun.lock b/bun.lock index 392c3fe2..83b3d1df 100644 --- a/bun.lock +++ b/bun.lock @@ -391,7 +391,7 @@ }, "packages/design-studio": { "name": "@wrnexus/design-studio", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@wrnexus/ai": "workspace:*", "@wrnexus/compiler": "workspace:*", diff --git a/docs/releases/2026-10-19-studio-presets-and-proposal-preview.md b/docs/releases/2026-10-19-studio-presets-and-proposal-preview.md new file mode 100644 index 00000000..60777898 --- /dev/null +++ b/docs/releases/2026-10-19-studio-presets-and-proposal-preview.md @@ -0,0 +1,39 @@ +# Studio 0.1.3: presets and proposal previews + +The Studio now offers 13 presets in both its starting and comparison menus. +The three existing engine presets and public engine API remain unchanged. Ten +Studio choices add distinct palettes, fonts, corner shapes and panel treatments. +All 13 validate and compile in light and dark mode; the preview regression test +renders all 26 combinations using real contracted components. + +The first valid generated alternative automatically appears in the main preview. +Review switches that preview without modifying the draft. Its label identifies +the reviewed proposal; Show current draft returns to the saved design. Accept +still creates an ordinary recoverable, undoable revision. New designs start from +the selected preset instead of an empty token map. Existing empty drafts remain +untouched and can still be edited or replaced explicitly. + +Provider instructions now explicitly describe flat dotted token names, complete +typed token objects for additions, and mode overrides. A regression test rejects +nested color groups on an empty draft, then validates three repaired proposals. +Validation, lock checks, the single repair attempt and deadlines remain enforced. + +## Live APIMart verification + +On October 19, the deployed 60-second configuration returned three valid proposals +in one APIMart attempt using gpt-6-luna: Square — 0px, Rounded — 8px, Soft — 16px. +The brief requested a booking website and these three specific control shapes. +Their frames rendered in the deployed Studio. The exported generation report was +independently checked: three distinct canonical designs, zero schema diagnostics, +and successful compilation of every proposal in both light and dark modes. +No server key was copied or inspected. + +Earlier broader requests in this session produced an invalid nested token group +and a provider failure. They preserved the draft and prior alternatives. The +successful constrained request establishes real provider connectivity and valid +output, not universal success for arbitrary briefs. The improved provider prompt +and main-preview behavior require deploying 0.1.3 before live verification. + +Browser checks of the new local client cover automatic first-proposal preview, +switching reviewed proposals, acceptance and undo. HTTP success alone is not +treated as proof of visible preview rendering. diff --git a/packages/design-studio/README.md b/packages/design-studio/README.md index 52b81bef..5717dff2 100644 --- a/packages/design-studio/README.md +++ b/packages/design-studio/README.md @@ -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. diff --git a/packages/design-studio/package.json b/packages/design-studio/package.json index 2c526270..9f0b1b36 100644 --- a/packages/design-studio/package.json +++ b/packages/design-studio/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/design-studio", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "main": "src/index.ts", diff --git a/packages/design-studio/src/client.ts b/packages/design-studio/src/client.ts index 4b127633..a4e5dc37 100644 --- a/packages/design-studio/src/client.ts +++ b/packages/design-studio/src/client.ts @@ -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; function message(text: string) { @@ -192,7 +193,7 @@ function renderProperties() { typeof raw === "object" ? "@" + raw.ref : String(raw ?? ""); } function render() { - $("preset").value = Object.hasOwn(CORE_DESIGN_SYSTEMS, session.valid.id) + $("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() { $("preview-frame").src = result[0].url; if (comparison) $("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(); diff --git a/packages/design-studio/src/generation.ts b/packages/design-studio/src/generation.ts index 3e11719c..5031d1ff 100644 --- a/packages/design-studio/src/generation.ts +++ b/packages/design-studio/src/generation.ts @@ -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(); diff --git a/packages/design-studio/src/guidance.ts b/packages/design-studio/src/guidance.ts index 1ab8068d..ee05f44f 100644 --- a/packages/design-studio/src/guidance.ts +++ b/packages/design-studio/src/guidance.ts @@ -78,6 +78,7 @@ export function setupGuidance( message: (text: string) => void; changed: () => Promise; preview: (system: DesignSystem) => Promise; + review?: (system: DesignSystem | undefined, name?: string) => Promise; }, ) { const $ = (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("[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) diff --git a/packages/design-studio/src/presets.ts b/packages/design-studio/src/presets.ts new file mode 100644 index 00000000..cfbfb0ce --- /dev/null +++ b/packages/design-studio/src/presets.ts @@ -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 = { + 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 = { + ...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, + ), +}; diff --git a/packages/design-studio/src/shell.ts b/packages/design-studio/src/shell.ts index eb424a46..6986d80e 100644 --- a/packages/design-studio/src/shell.ts +++ b/packages/design-studio/src/shell.ts @@ -1,7 +1,14 @@ +import { STUDIO_PRESETS } from "./presets.ts"; import { guidanceHtml, guidanceCss } from "./guidance-shell.ts"; export const studioHtml = `WrNexus Design Studio
WrNexus Design StudioLocal workspace
-
`; 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-grid{grid-template-columns:1fr}iframe{height:600px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}`; diff --git a/packages/design-studio/test/generation.test.ts b/packages/design-studio/test/generation.test.ts index 95bf4e53..26d995f3 100644 --- a/packages/design-studio/test/generation.test.ts +++ b/packages/design-studio/test/generation.test.ts @@ -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 = { diff --git a/packages/design-studio/test/presets.test.ts b/packages/design-studio/test/presets.test.ts new file mode 100644 index 00000000..017da717 --- /dev/null +++ b/packages/design-studio/test/presets.test.ts @@ -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(); + for (const [id, system] of Object.entries(STUDIO_PRESETS)) { + expect(system.id).toBe(id); + expect(validateDesignSystem(system)).toEqual([]); + expect(studioHtml).toContain(``); + 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"); +}); diff --git a/packages/design-studio/test/server.test.ts b/packages/design-studio/test/server.test.ts index 4df3535e..5df1eef0 100644 --- a/packages/design-studio/test/server.test.ts +++ b/packages/design-studio/test/server.test.ts @@ -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);