diff --git a/bun.lock b/bun.lock index 32fd4933..1089690f 100644 --- a/bun.lock +++ b/bun.lock @@ -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", }, diff --git a/packages/design-studio/package.json b/packages/design-studio/package.json index 77e1c1d1..fe324738 100644 --- a/packages/design-studio/package.json +++ b/packages/design-studio/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/design-studio", - "version": "0.1.7", + "version": "0.1.8", "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 c114a504..05a6d594 100644 --- a/packages/design-studio/src/client.ts +++ b/packages/design-studio/src/client.ts @@ -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; let reviewed: { system: DesignSystem; name: string } | undefined; -let activeTab = "foundations"; +let activeTab = "manual"; let noticeTimer: ReturnType; 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("[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 ."; 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 = $("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, diff --git a/packages/design-studio/src/manual-editor.ts b/packages/design-studio/src/manual-editor.ts new file mode 100644 index 00000000..7a3aadf0 --- /dev/null +++ b/packages/design-studio/src/manual-editor.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!, + ); +export const manualHtml = `

Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.

Colors

Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.

All other color tokens

Fine-tune hover, contrast, focus, input, code and custom color tokens.

Shape and spacing

Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.

Typography

Use a system font below or enter your own font stack. Custom fonts must also be loaded in your application.

Surface

Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review & apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.

`; +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; + 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("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("[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( + 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) => ``, + ) + .join(""); + ($("manual-default-mode") as HTMLSelectElement).innerHTML = modes + .map( + (m) => + ``, + ) + .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 ``; + }) + .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 ``; + }) + .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 ``; + }) + .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 ``; + }) + .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 }; +} diff --git a/packages/design-studio/src/manual.ts b/packages/design-studio/src/manual.ts new file mode 100644 index 00000000..2e20d908 --- /dev/null +++ b/packages/design-studio/src/manual.ts @@ -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, + 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 + )[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 = { + "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; +} diff --git a/packages/design-studio/src/shell.ts b/packages/design-studio/src/shell.ts index f97e43fa..42d1789b 100644 --- a/packages/design-studio/src/shell.ts +++ b/packages/design-studio/src/shell.ts @@ -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 = `WrNexus Design Studio @@ -9,10 +10,11 @@ export const studioHtml = `
-

Local drafts stay in this browser. Export a file to keep a portable copy.

-

Live authoring

Foundations

Checking
+

Local drafts stay in this browser. Export a file to keep a portable copy.

+

Live authoring

Manual design

Checking
+${manualHtml} ${guidanceHtml} -

Aliases use @token.name. The resolved value and its source appear below each field.

Add a token, mode or density
+ @@ -25,4 +27,4 @@ ${guidanceHtml} "", )}

Previews use isolated frames. Invalid drafts keep the last valid design. Gallery uses populated interactive samples. Forms and uploads do not send data.

Loading preview…

Current draft

`; -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}}`; +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: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/manual.test.ts b/packages/design-studio/test/manual.test.ts new file mode 100644 index 00000000..fd8cabc2 --- /dev/null +++ b/packages/design-studio/test/manual.test.ts @@ -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 + ''; + 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(); + } +});