feat(studio): add offline manual theme designer
This commit is contained in:
1 parent
5259668006
commit
ae35ac6714
7 files changed
+570
-8
No files matched your search
@@ -391,7 +391,7 @@
|
|||||||
},
|
},
|
||||||
"packages/design-studio": {
|
"packages/design-studio": {
|
||||||
"name": "@wrnexus/design-studio",
|
"name": "@wrnexus/design-studio",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrnexus-design-studio": "src/cli.ts",
|
"wrnexus-design-studio": "src/cli.ts",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/design-studio",
|
"name": "@wrnexus/design-studio",
|
||||||
"version": "0.1.7",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { setupManual } from "./manual-editor.ts";
|
||||||
import { handlePreviewClipboard } from "./preview-clipboard.ts";
|
import { handlePreviewClipboard } from "./preview-clipboard.ts";
|
||||||
import { STUDIO_PRESETS } from "./presets.ts";
|
import { STUDIO_PRESETS } from "./presets.ts";
|
||||||
import { setupGuidance } from "./guidance.ts";
|
import { setupGuidance } from "./guidance.ts";
|
||||||
@@ -29,7 +30,7 @@ let page = 0;
|
|||||||
let previewGeneration = 0;
|
let previewGeneration = 0;
|
||||||
let previewLoadTimer: ReturnType<typeof setTimeout>;
|
let previewLoadTimer: ReturnType<typeof setTimeout>;
|
||||||
let reviewed: { system: DesignSystem; name: string } | undefined;
|
let reviewed: { system: DesignSystem; name: string } | undefined;
|
||||||
let activeTab = "foundations";
|
let activeTab = "manual";
|
||||||
let noticeTimer: ReturnType<typeof setTimeout>;
|
let noticeTimer: ReturnType<typeof setTimeout>;
|
||||||
function message(text: string) {
|
function message(text: string) {
|
||||||
$("message").textContent = text;
|
$("message").textContent = text;
|
||||||
@@ -93,7 +94,7 @@ function selection() {
|
|||||||
}
|
}
|
||||||
function tab(name: string) {
|
function tab(name: string) {
|
||||||
activeTab = name;
|
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;
|
$(id === "review" ? "review-panel" : id).hidden = id !== name;
|
||||||
document
|
document
|
||||||
.querySelectorAll<HTMLButtonElement>("[data-tab]")
|
.querySelectorAll<HTMLButtonElement>("[data-tab]")
|
||||||
@@ -102,6 +103,7 @@ function tab(name: string) {
|
|||||||
);
|
);
|
||||||
$("editor-title").textContent =
|
$("editor-title").textContent =
|
||||||
{
|
{
|
||||||
|
manual: "Manual design",
|
||||||
guided: "Guided design",
|
guided: "Guided design",
|
||||||
foundations: "Foundations",
|
foundations: "Foundations",
|
||||||
components: "Components",
|
components: "Components",
|
||||||
@@ -245,6 +247,7 @@ function render() {
|
|||||||
: "Export-only workspace. Enable local apply by starting the Studio with --app-root <existing application folder>.";
|
: "Export-only workspace. Enable local apply by starting the Studio with --app-root <existing application folder>.";
|
||||||
tab(activeTab);
|
tab(activeTab);
|
||||||
guidance?.render();
|
guidance?.render();
|
||||||
|
manual?.render();
|
||||||
}
|
}
|
||||||
async function previews() {
|
async function previews() {
|
||||||
const generation = ++previewGeneration;
|
const generation = ++previewGeneration;
|
||||||
@@ -704,6 +707,17 @@ window.addEventListener("beforeunload", () => {
|
|||||||
}
|
}
|
||||||
} else session.persist();
|
} else session.persist();
|
||||||
});
|
});
|
||||||
|
const manual = setupManual(session, {
|
||||||
|
message,
|
||||||
|
changed,
|
||||||
|
mode: (mode) => {
|
||||||
|
const select = $<HTMLSelectElement>("preview-mode");
|
||||||
|
const next = mode === "base" ? "" : mode;
|
||||||
|
if (select.value === next) return;
|
||||||
|
select.value = next;
|
||||||
|
previews().catch((e) => message(e.message));
|
||||||
|
},
|
||||||
|
});
|
||||||
const guidance = setupGuidance(session, {
|
const guidance = setupGuidance(session, {
|
||||||
message,
|
message,
|
||||||
changed,
|
changed,
|
||||||
|
|||||||
@@ -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 = `<section id="manual" class="editor"><p>Create a theme yourself. Choose your settings, then select Update preview to save a recoverable draft. No AI or API key is needed.</p><button type="button" class="primary" data-manual-apply>Update preview</button><label>Default theme<select id="manual-default-mode"></select></label><label>Edit color mode<select id="manual-mode"></select></label><h3>Colors</h3><p class="help">Edit light and dark palettes separately. Brand and status colors get matching hover and readable contrast colors automatically. Foundations below apply to both modes.</p><div id="manual-colors" class="manual-grid"></div><details><summary>All other color tokens</summary><p class="help">Fine-tune hover, contrast, focus, input, code and custom color tokens.</p><div id="manual-extra-colors" class="manual-grid"></div></details><h3>Shape and spacing</h3><div id="manual-shapes" class="manual-grid"></div><p class="help">Use 0 for square corners, 8–16 for rounded corners, or 999 for pill-shaped controls. Switches stay pill-shaped.</p><div class="manual-grid"><label>Button style<select id="manual-button-style"><option value="solid">Solid</option><option value="outline">Outlined</option></select></label><label>Tilt<select id="manual-tilt"><option value="none">None</option><option value="subtle">Subtle</option><option value="expressive">Expressive</option></select></label></div><h3>Typography</h3><div id="manual-fonts"></div><p class="help">Use a system font below or enter your own font stack. Custom fonts must also be loaded in your application.</p><h3>Surface</h3><label>Shadow<select id="manual-shadow"><option value="none">Flat / no shadow</option><option value="0 8px 24px #00000018">Soft shadow</option><option value="2px 2px 0 #000000">Crisp shadow</option></select></label><p class="help">Use Create new or Duplicate in the workspace to name your theme. Export JSON keeps a portable copy. Review & apply exports the CSS or theme configuration for your app. Decision locks also protect manual edits.</p><button type="button" class="primary" data-manual-apply>Update preview</button></section>`;
|
||||||
|
export const manualCss = `.manual-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0 16px}.manual-color{display:flex;gap:8px;align-items:center}.manual-color input[type=color]{width:44px;height:38px;padding:3px;flex:none}.manual-color input[type=text]{flex:1;font-family:Consolas,monospace}.manual-grid input[type=number]{max-width:150px}`;
|
||||||
|
export function setupManual(
|
||||||
|
session: StudioSession,
|
||||||
|
hooks: {
|
||||||
|
changed: () => Promise<void>;
|
||||||
|
message: (text: string) => void;
|
||||||
|
mode: (name: string) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const $ = (id: string) => document.getElementById(id)!;
|
||||||
|
let identity = session.valid.id;
|
||||||
|
let mode = session.valid.defaultMode ?? Object.keys(session.valid.modes ?? {})[0] ?? "base";
|
||||||
|
let dirty = false;
|
||||||
|
async function applyForm() {
|
||||||
|
try {
|
||||||
|
if (session.diagnostics.length)
|
||||||
|
throw Error(
|
||||||
|
"Resolve draft errors before editing manually. Your last valid design is preserved.",
|
||||||
|
);
|
||||||
|
const resolved = session.resolved(mode === "base" ? {} : { mode });
|
||||||
|
const edits: ManualEdit[] = [];
|
||||||
|
$("manual")
|
||||||
|
.querySelectorAll<HTMLInputElement>("input[type=text][data-manual-color]")
|
||||||
|
.forEach((input) => {
|
||||||
|
if (
|
||||||
|
input.value &&
|
||||||
|
input.value !==
|
||||||
|
String(resolved.tokens["color." + input.dataset.manualColor]?.value ?? "")
|
||||||
|
)
|
||||||
|
edits.push({
|
||||||
|
kind: "color",
|
||||||
|
mode,
|
||||||
|
name: input.dataset.manualColor!,
|
||||||
|
value: input.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$("manual")
|
||||||
|
.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-manual-token]")
|
||||||
|
.forEach((input) => {
|
||||||
|
const name = input.dataset.manualToken!,
|
||||||
|
raw = String(resolved.tokens[name]?.value ?? "");
|
||||||
|
if (input instanceof HTMLInputElement && input.type === "number") {
|
||||||
|
const previous = Number(input.dataset.manualOriginal);
|
||||||
|
if (Number(input.value) !== previous)
|
||||||
|
edits.push({ kind: "token", name, value: input.value + "px" });
|
||||||
|
} else if (input.value !== raw) edits.push({ kind: "token", name, value: input.value });
|
||||||
|
});
|
||||||
|
const value = (id: string) => ($(id) as HTMLSelectElement).value;
|
||||||
|
if (
|
||||||
|
value("manual-default-mode") &&
|
||||||
|
value("manual-default-mode") !== session.valid.defaultMode
|
||||||
|
)
|
||||||
|
edits.push({ kind: "mode", value: value("manual-default-mode") });
|
||||||
|
const rule = session.valid.recipes?.Button?.variants?.find(
|
||||||
|
(r) => r.when.variant === "default",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
value("manual-button-style") !==
|
||||||
|
(rule?.parts.control?.backgroundColor === "transparent" ? "outline" : "solid")
|
||||||
|
)
|
||||||
|
edits.push({
|
||||||
|
kind: "buttonStyle",
|
||||||
|
value: value("manual-button-style") as "solid" | "outline",
|
||||||
|
});
|
||||||
|
const tilt = String(session.valid.recipes?.Button?.base?.control?.rotate ?? "0deg"),
|
||||||
|
oldTilt = tilt === "0deg" ? "none" : tilt === "-0.5deg" ? "subtle" : "expressive";
|
||||||
|
if (value("manual-tilt") !== oldTilt)
|
||||||
|
edits.push({
|
||||||
|
kind: "tilt",
|
||||||
|
value: value("manual-tilt") as "none" | "subtle" | "expressive",
|
||||||
|
});
|
||||||
|
if (value("manual-shadow") !== String(resolved.tokens["elevation.panel"]?.value ?? "none"))
|
||||||
|
edits.push({ kind: "token", name: "elevation.panel", value: value("manual-shadow") });
|
||||||
|
const next = edits.reduce((system, edit) => manualDesign(system, edit), session.valid);
|
||||||
|
if (checkDecisionLocks(session.valid, next, session.locks).length)
|
||||||
|
throw Error("This change affects a locked decision. Unlock it in Guided design first.");
|
||||||
|
session.replace(next, "Manual theme settings");
|
||||||
|
dirty = false;
|
||||||
|
const defaultEdit = edits.find((e) => e.kind === "mode");
|
||||||
|
if (defaultEdit) mode = defaultEdit.value;
|
||||||
|
await hooks.changed();
|
||||||
|
hooks.mode(mode);
|
||||||
|
hooks.message("Manual theme saved. Preview updated. Undo restores the previous settings.");
|
||||||
|
} catch (error) {
|
||||||
|
hooks.message((error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("manual")
|
||||||
|
.querySelectorAll("[data-manual-apply]")
|
||||||
|
.forEach((button) =>
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
void applyForm();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
$("manual").addEventListener("input", (event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.id === "manual-mode") return;
|
||||||
|
dirty = true;
|
||||||
|
if (input.dataset.manualColor) {
|
||||||
|
const pair = input
|
||||||
|
.closest("label")
|
||||||
|
?.querySelector<HTMLInputElement>(
|
||||||
|
input.type === "color" ? "input[type=text]" : "input[type=color]",
|
||||||
|
);
|
||||||
|
if (pair && /^#[a-f0-9]{6}$/i.test(input.value)) pair.value = input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$("manual").addEventListener("change", (event) => {
|
||||||
|
if ((event.target as HTMLElement).id !== "manual-mode") dirty = true;
|
||||||
|
});
|
||||||
|
$("manual-mode").addEventListener("change", () => {
|
||||||
|
if (dirty) {
|
||||||
|
($("manual-mode") as HTMLSelectElement).value = mode;
|
||||||
|
hooks.message("Select Update preview to save these settings before switching color modes.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mode = ($("manual-mode") as HTMLSelectElement).value;
|
||||||
|
render();
|
||||||
|
hooks.mode(mode);
|
||||||
|
});
|
||||||
|
document.getElementById("preview-mode")!.addEventListener("change", () => {
|
||||||
|
mode =
|
||||||
|
(document.getElementById("preview-mode") as HTMLSelectElement).value ||
|
||||||
|
session.valid.defaultMode ||
|
||||||
|
"base";
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
function render() {
|
||||||
|
dirty = false;
|
||||||
|
if (identity !== session.valid.id) {
|
||||||
|
identity = session.valid.id;
|
||||||
|
mode =
|
||||||
|
(document.getElementById("preview-mode") as HTMLSelectElement).value ||
|
||||||
|
session.valid.defaultMode ||
|
||||||
|
"base";
|
||||||
|
}
|
||||||
|
const modes = Object.keys(session.valid.modes ?? {});
|
||||||
|
if (!modes.includes(mode)) mode = modes[0] ?? "base";
|
||||||
|
($("manual-mode") as HTMLSelectElement).innerHTML = (modes.length ? modes : ["base"])
|
||||||
|
.map(
|
||||||
|
(m) => `<option ${m === mode ? "selected" : ""} value="${escape(m)}">${escape(m)}</option>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
($("manual-default-mode") as HTMLSelectElement).innerHTML = modes
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`<option value="${escape(m)}" ${m === session.valid.defaultMode ? "selected" : ""}>${escape(m)}</option>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
const resolved = session.resolved(mode === "base" ? {} : { mode });
|
||||||
|
$("manual-colors").innerHTML = Object.entries(MANUAL_COLORS)
|
||||||
|
.map(([name, label]) => {
|
||||||
|
const raw = String(resolved.tokens["color." + name]?.value ?? "");
|
||||||
|
const hex = /^#[a-f0-9]{6}$/i.test(raw) ? raw : "#666666";
|
||||||
|
return `<label>${label}<span class="manual-color"><input type="color" aria-label="${label} color picker" data-manual-color="${name}" value="${hex}"><input type="text" aria-label="${label} hex color" data-manual-color="${name}" value="${escape(raw)}" maxlength="7" placeholder="Inherited"></span></label>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
$("manual-extra-colors").innerHTML = Object.entries(resolved.tokens)
|
||||||
|
.filter(
|
||||||
|
([name, token]) =>
|
||||||
|
name.startsWith("color.") &&
|
||||||
|
token.type === "color" &&
|
||||||
|
!Object.hasOwn(MANUAL_COLORS, name.slice(6)),
|
||||||
|
)
|
||||||
|
.map(([name, token]) => {
|
||||||
|
const color = name.slice(6),
|
||||||
|
raw = String(token.value),
|
||||||
|
hex = /^#[a-f0-9]{6}$/i.test(raw) ? raw : "#666666";
|
||||||
|
return `<label>${escape(color)}<span class="manual-color"><input type="color" aria-label="${escape(color)} color picker" data-manual-color="${escape(color)}" value="${hex}"><input type="text" aria-label="${escape(color)} hex color" data-manual-color="${escape(color)}" value="${escape(raw)}" placeholder="#00677B"></span></label>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
$("manual-shapes").innerHTML = [
|
||||||
|
["shape.control", "Control corners (px)"],
|
||||||
|
["shape.panel", "Panel corners (px)"],
|
||||||
|
["shape.dialog", "Dialog corners (px)"],
|
||||||
|
["space.control", "Control padding (px)"],
|
||||||
|
["space.panel", "Panel padding (px)"],
|
||||||
|
]
|
||||||
|
.map(([name, label]) => {
|
||||||
|
const raw = String(resolved.tokens[name!]?.value ?? "0");
|
||||||
|
const px = parseFloat(raw) * (raw.endsWith("rem") ? 16 : 1);
|
||||||
|
return `<label>${label}<input type="number" min="0" max="${name!.startsWith("shape.") ? 999 : 64}" data-manual-token="${name}" data-manual-original="${Number.isFinite(px) ? px : 0}" value="${Number.isFinite(px) ? px : 0}"></label>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const buttonRule = session.valid.recipes?.Button?.variants?.find(
|
||||||
|
(rule) => rule.when.variant === "default",
|
||||||
|
);
|
||||||
|
($("manual-button-style") as HTMLSelectElement).value =
|
||||||
|
buttonRule?.parts.control?.backgroundColor === "transparent" ? "outline" : "solid";
|
||||||
|
const tilt = String(session.valid.recipes?.Button?.base?.control?.rotate ?? "0deg");
|
||||||
|
($("manual-tilt") as HTMLSelectElement).value =
|
||||||
|
tilt === "0deg" ? "none" : tilt === "-0.5deg" ? "subtle" : "expressive";
|
||||||
|
$("manual-fonts").innerHTML = [
|
||||||
|
["type.body", "Body font"],
|
||||||
|
["type.heading", "Heading font"],
|
||||||
|
]
|
||||||
|
.map(([name, label]) => {
|
||||||
|
const current = String(resolved.tokens[name!]?.value ?? MANUAL_FONTS.sans);
|
||||||
|
return `<label>${label}<select data-manual-token="${name}"><option value="${escape(current)}">Current: ${escape(current)}</option>${Object.entries(
|
||||||
|
MANUAL_FONTS,
|
||||||
|
)
|
||||||
|
.map(([label, font]) => `<option value="${escape(font)}">${label}</option>`)
|
||||||
|
.join(
|
||||||
|
"",
|
||||||
|
)}</select><input aria-label="Custom ${label!.toLowerCase()} stack" data-manual-token="${name}" value="${escape(current)}"></label>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const shadow = $("manual-shadow") as HTMLSelectElement;
|
||||||
|
const current = String(resolved.tokens["elevation.panel"]?.value ?? "none");
|
||||||
|
Array.from(shadow.options)
|
||||||
|
.filter((o) => o.dataset.custom)
|
||||||
|
.forEach((o) => o.remove());
|
||||||
|
if (!Array.from(shadow.options).some((o) => o.value === current)) {
|
||||||
|
const option = new Option("Current: " + current, current);
|
||||||
|
option.dataset.custom = "true";
|
||||||
|
shadow.add(option);
|
||||||
|
}
|
||||||
|
shadow.value = current;
|
||||||
|
}
|
||||||
|
return { render };
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { validateDesignSystem, type DesignSystem, type DesignToken } from "@wrnexus/design-system";
|
||||||
|
|
||||||
|
export const MANUAL_COLORS = {
|
||||||
|
primary: "Brand / primary",
|
||||||
|
secondary: "Secondary",
|
||||||
|
bg: "Page background",
|
||||||
|
surface: "Surface",
|
||||||
|
"surface-2": "Subtle surface",
|
||||||
|
text: "Text",
|
||||||
|
"text-muted": "Muted text",
|
||||||
|
border: "Borders",
|
||||||
|
success: "Success",
|
||||||
|
warning: "Warning",
|
||||||
|
danger: "Error",
|
||||||
|
info: "Information",
|
||||||
|
} as const;
|
||||||
|
export const MANUAL_FONTS = {
|
||||||
|
sans: '"Helvetica Neue", Arial, sans-serif',
|
||||||
|
humanist: '"Trebuchet MS", Arial, sans-serif',
|
||||||
|
serif: 'Georgia, "Times New Roman", serif',
|
||||||
|
mono: '"Courier New", monospace',
|
||||||
|
};
|
||||||
|
export type ManualEdit =
|
||||||
|
| { kind: "color"; mode: string; name: string; value: string }
|
||||||
|
| { kind: "token"; name: string; value: string }
|
||||||
|
| { kind: "mode"; value: string }
|
||||||
|
| { kind: "buttonStyle"; value: "solid" | "outline" }
|
||||||
|
| { kind: "tilt"; value: "none" | "subtle" | "expressive" };
|
||||||
|
function ink(hex: string) {
|
||||||
|
const channels = hex
|
||||||
|
.slice(1)
|
||||||
|
.match(/../g)!
|
||||||
|
.map((s) => parseInt(s, 16) / 255)
|
||||||
|
.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||||
|
const luminance = channels.reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i]!, 0);
|
||||||
|
return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#FFFFFF";
|
||||||
|
}
|
||||||
|
export function manualDesign(source: DesignSystem, edit: ManualEdit): DesignSystem {
|
||||||
|
const system = structuredClone(source);
|
||||||
|
const set = (
|
||||||
|
tokens: Record<string, DesignToken>,
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
type: DesignToken["type"] = "color",
|
||||||
|
legacy?: string,
|
||||||
|
) => {
|
||||||
|
tokens[name] = {
|
||||||
|
...tokens[name],
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
...(legacy ? { legacy: tokens[name]?.legacy ?? legacy } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (edit.kind === "buttonStyle") {
|
||||||
|
const recipe = ((system.recipes ??= {}).Button ??= { contractVersion: 1, base: {} });
|
||||||
|
const variant = {
|
||||||
|
when: { variant: "default" },
|
||||||
|
parts: {
|
||||||
|
control: {
|
||||||
|
backgroundImage: "none",
|
||||||
|
backgroundColor: edit.value === "outline" ? "transparent" : { ref: "color.primary" },
|
||||||
|
color: { ref: edit.value === "outline" ? "color.primary" : "color.primary-contrast" },
|
||||||
|
borderColor: { ref: "color.primary" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
recipe.variants = [
|
||||||
|
...(recipe.variants ?? []).filter(
|
||||||
|
(rule) => !(Object.keys(rule.when).length === 1 && rule.when.variant === "default"),
|
||||||
|
),
|
||||||
|
variant,
|
||||||
|
];
|
||||||
|
} else if (edit.kind === "tilt") {
|
||||||
|
for (const [name, part, angle] of [
|
||||||
|
[
|
||||||
|
"Button",
|
||||||
|
"control",
|
||||||
|
edit.value === "none" ? "0deg" : edit.value === "subtle" ? "-0.5deg" : "-2deg",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Card",
|
||||||
|
"panel",
|
||||||
|
edit.value === "none" ? "0deg" : edit.value === "subtle" ? "0.25deg" : "1deg",
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const recipe = ((system.recipes ??= {})[name!] ??= { contractVersion: 1, base: {} });
|
||||||
|
((recipe.base ??= {})[part!] ??= {}).rotate = angle!;
|
||||||
|
}
|
||||||
|
} else if (edit.kind === "mode") {
|
||||||
|
if (!Object.hasOwn(system.modes ?? {}, edit.value))
|
||||||
|
throw Error("Choose an existing light or dark mode.");
|
||||||
|
system.defaultMode = edit.value;
|
||||||
|
} else if (edit.kind === "color") {
|
||||||
|
if (
|
||||||
|
(!Object.hasOwn(MANUAL_COLORS, edit.name) &&
|
||||||
|
system.tokens["color." + edit.name]?.type !== "color" &&
|
||||||
|
system.modes?.[edit.mode]?.["color." + edit.name]?.type !== "color") ||
|
||||||
|
!/^#[a-f0-9]{6}$/i.test(edit.value)
|
||||||
|
)
|
||||||
|
throw Error("Choose a color or enter a six-digit hex value, such as #00677B.");
|
||||||
|
const layer = edit.mode === "base" ? system.tokens : system.modes?.[edit.mode];
|
||||||
|
if (!layer) throw Error("Choose an existing light or dark mode.");
|
||||||
|
const color = (name: string, value = edit.value) =>
|
||||||
|
set(
|
||||||
|
layer,
|
||||||
|
"color." + name,
|
||||||
|
value,
|
||||||
|
"color",
|
||||||
|
layer["color." + name]?.legacy ?? system.tokens["color." + name]?.legacy ?? "color-" + name,
|
||||||
|
);
|
||||||
|
color(edit.name);
|
||||||
|
for (const alias of (
|
||||||
|
{
|
||||||
|
bg: ["background"],
|
||||||
|
surface: ["surface-raised", "input-background"],
|
||||||
|
"surface-2": [
|
||||||
|
"surface-soft",
|
||||||
|
"surface-muted",
|
||||||
|
"primary-soft",
|
||||||
|
"primary-muted",
|
||||||
|
"code-background",
|
||||||
|
],
|
||||||
|
text: ["foreground", "code-text"],
|
||||||
|
"text-muted": ["text-subtle", "muted"],
|
||||||
|
border: ["border-strong", "input-border", "code-border"],
|
||||||
|
} as Record<string, string[]>
|
||||||
|
)[edit.name] ?? [])
|
||||||
|
color(alias);
|
||||||
|
if (["primary", "secondary", "success", "warning", "danger", "info"].includes(edit.name)) {
|
||||||
|
color(edit.name + "-contrast", ink(edit.value));
|
||||||
|
color(edit.name + "-soft", edit.value + "1A");
|
||||||
|
if (edit.name === "primary") color("primary-muted", edit.value + "1A");
|
||||||
|
color(edit.name + "-text");
|
||||||
|
for (const [suffix, factor] of [
|
||||||
|
["-hover", 0.88],
|
||||||
|
["-active", 0.75],
|
||||||
|
] as const)
|
||||||
|
color(
|
||||||
|
edit.name + suffix,
|
||||||
|
"#" +
|
||||||
|
edit.value
|
||||||
|
.slice(1)
|
||||||
|
.match(/../g)!
|
||||||
|
.map((c) =>
|
||||||
|
Math.round(parseInt(c, 16) * factor)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0"),
|
||||||
|
)
|
||||||
|
.join(""),
|
||||||
|
);
|
||||||
|
if (edit.name === "primary") color("focus");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const allowed: Record<string, [DesignToken["type"], string | undefined]> = {
|
||||||
|
"shape.control": ["dimension", "radius-sm"],
|
||||||
|
"shape.panel": ["dimension", "radius-md"],
|
||||||
|
"shape.dialog": ["dimension", "radius-lg"],
|
||||||
|
"type.body": ["fontFamily", "font-sans"],
|
||||||
|
"type.heading": ["fontFamily", undefined],
|
||||||
|
"elevation.panel": ["shadow", "shadow-1"],
|
||||||
|
"space.control": ["dimension", undefined],
|
||||||
|
"space.panel": ["dimension", "space-panel"],
|
||||||
|
};
|
||||||
|
if (!allowed[edit.name]) throw Error("Unsupported manual design setting.");
|
||||||
|
const [type, legacy] = allowed[edit.name]!;
|
||||||
|
if (
|
||||||
|
type === "dimension" &&
|
||||||
|
(!/^\d+(?:\.\d+)?px$/.test(edit.value) ||
|
||||||
|
parseFloat(edit.value) > (edit.name.startsWith("shape.") ? 999 : 64))
|
||||||
|
)
|
||||||
|
throw Error("Enter corners between 0 and 999 pixels, or padding between 0 and 64 pixels.");
|
||||||
|
set(system.tokens, edit.name, edit.value, type, legacy);
|
||||||
|
// Existing mode overrides must not silently win over a shared foundation edit.
|
||||||
|
for (const layer of Object.values(system.modes ?? {}))
|
||||||
|
if (layer[edit.name]) set(layer, edit.name, edit.value, type, legacy);
|
||||||
|
if (edit.name === "shape.panel")
|
||||||
|
set(system.tokens, "shape.base", edit.value, "dimension", "radius");
|
||||||
|
}
|
||||||
|
const errors = validateDesignSystem(system);
|
||||||
|
if (errors.length) throw Error(errors.map((e) => e.message).join("\n"));
|
||||||
|
return system;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { manualHtml, manualCss } from "./manual-editor.ts";
|
||||||
import { STUDIO_PRESETS } from "./presets.ts";
|
import { STUDIO_PRESETS } from "./presets.ts";
|
||||||
import { guidanceHtml, guidanceCss } from "./guidance-shell.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>
|
export const studioHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WrNexus Design Studio</title><link rel="stylesheet" href="/studio.css"><script type="module" src="/studio.js"></script></head><body>
|
||||||
@@ -9,10 +10,11 @@ export const studioHtml = `<!doctype html><html lang="en"><head><meta charset="u
|
|||||||
.join(
|
.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>
|
)}</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>
|
<nav aria-label="Editors"><button data-tab="manual" aria-current="page">Manual design</button><button data-tab="guided">Guided design</button><button data-tab="foundations">Foundations</button><button data-tab="components">Components</button><button data-tab="source">Source & import</button><button data-tab="history">History</button><button data-tab="review">Review & apply</button></nav><p class="footnote">Local drafts stay in this browser. Export a file to keep a portable copy.</p></aside>
|
||||||
<section class="workspace"><div class="workspace-title"><div><p class="eyebrow">Live authoring</p><h2 id="editor-title">Foundations</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
|
<section class="workspace"><div class="workspace-title"><div><p class="eyebrow">Live authoring</p><h2 id="editor-title">Manual design</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
|
||||||
|
${manualHtml}
|
||||||
${guidanceHtml}
|
${guidanceHtml}
|
||||||
<section id="foundations" class="editor"><div class="row"><label>Search tokens<input id="token-search" type="search" placeholder="Color, shape, typography…"></label><label>Edit layer<select id="token-layer"></select></label></div><p class="help">Aliases use @token.name. The resolved value and its source appear below each field.</p><div id="tokens" class="token-grid"></div><details><summary>Add a token, mode or density</summary><div class="row"><label>Token name<input id="add-token-name" placeholder="space.section"></label><label>Type<select id="add-token-type"></select></label><label>Value<input id="add-token-value" placeholder="24px"></label><button id="add-token">Add token</button></div><div class="row"><label>New layer name<input id="add-layer-name" placeholder="compact"></label><button id="add-mode">Add mode</button><button id="add-density">Add density</button></div></details></section>
|
<section id="foundations" class="editor" hidden><div class="row"><label>Search tokens<input id="token-search" type="search" placeholder="Color, shape, typography…"></label><label>Edit layer<select id="token-layer"></select></label></div><p class="help">Aliases use @token.name. The resolved value and its source appear below each field.</p><div id="tokens" class="token-grid"></div><details><summary>Add a token, mode or density</summary><div class="row"><label>Token name<input id="add-token-name" placeholder="space.section"></label><label>Type<select id="add-token-type"></select></label><label>Value<input id="add-token-value" placeholder="24px"></label><button id="add-token">Add token</button></div><div class="row"><label>New layer name<input id="add-layer-name" placeholder="compact"></label><button id="add-mode">Add mode</button><button id="add-density">Add density</button></div></details></section>
|
||||||
<section id="components" class="editor" hidden><div class="row"><label>Find a component<input type="search" id="component-search" placeholder="Search 104 components"></label><label>Component<select id="component"></select></label></div><div id="contract"></div><div class="row"><label>Part<select id="part"></select></label><label>Property<select id="property"></select></label></div><div class="row"><label>Recipe value<input id="recipe-value" placeholder="@shape.control or 8px"></label><button id="set-recipe">Set default</button><button id="remove-recipe">Remove default</button></div><label>Complete component recipe JSON<textarea id="recipe-source" rows="12" spellcheck="false"></textarea></label><button id="save-recipe">Save component recipe</button><p class="help">Recipe JSON supports the contract’s variants, compound conditions and states. All edits pass through engine validation.</p></section>
|
<section id="components" class="editor" hidden><div class="row"><label>Find a component<input type="search" id="component-search" placeholder="Search 104 components"></label><label>Component<select id="component"></select></label></div><div id="contract"></div><div class="row"><label>Part<select id="part"></select></label><label>Property<select id="property"></select></label></div><div class="row"><label>Recipe value<input id="recipe-value" placeholder="@shape.control or 8px"></label><button id="set-recipe">Set default</button><button id="remove-recipe">Remove default</button></div><label>Complete component recipe JSON<textarea id="recipe-source" rows="12" spellcheck="false"></textarea></label><button id="save-recipe">Save component recipe</button><p class="help">Recipe JSON supports the contract’s variants, compound conditions and states. All edits pass through engine validation.</p></section>
|
||||||
<section id="source" class="editor" hidden><label>Canonical design system JSON<textarea id="source-text" rows="22" spellcheck="false"></textarea></label><button id="save-source">Update draft</button><button id="download-draft">Export raw draft</button><details open><summary>Import a design</summary><label>Format<select id="import-format"><option value="canonical">Canonical design system JSON</option><option value="legacy">WrNexus theme JSON</option><option value="tokens">Exchange tokens: literal scalar / alias subset</option></select></label><label>Choose a local JSON file<input id="import-file" type="file" accept=".json,application/json"></label><label>Or paste JSON<textarea id="import-text" rows="7" spellcheck="false"></textarea></label><button id="inspect-import">Inspect import</button><pre id="import-report"></pre><button id="accept-import" disabled>Use imported design</button></details></section>
|
<section id="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="history" class="editor" hidden><p>Up to 25 local revisions, including incomplete source edits. Undo and redo also restore invalid drafts.</p><ol id="revisions"></ol></section>
|
||||||
@@ -25,4 +27,4 @@ ${guidanceHtml}
|
|||||||
"",
|
"",
|
||||||
)}</select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery uses populated interactive samples. Forms and uploads do not send data.</p><p id="preview-status" role="status" aria-live="polite">Loading preview…</p><div class="frames"><div><p id="preview-label">Current draft</p><div class="frame-scroll"><iframe id="preview-frame" title="Current design preview" sandbox="allow-scripts"></iframe></div></div><div id="comparison" hidden><p>Comparison</p><div class="frame-scroll"><iframe id="compare-frame" title="Comparison design preview" sandbox="allow-scripts"></iframe></div></div></div></section></main><div id="message" role="status" aria-live="polite"></div></body></html>`;
|
)}</select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery uses populated interactive samples. Forms and uploads do not send data.</p><p id="preview-status" role="status" aria-live="polite">Loading preview…</p><div class="frames"><div><p id="preview-label">Current draft</p><div class="frame-scroll"><iframe id="preview-frame" title="Current design preview" sandbox="allow-scripts"></iframe></div></div><div id="comparison" hidden><p>Comparison</p><div class="frame-scroll"><iframe id="compare-frame" title="Comparison design preview" sandbox="allow-scripts"></iframe></div></div></div></section></main><div id="message" role="status" aria-live="polite"></div></body></html>`;
|
||||||
|
|
||||||
export const studioCss = `${guidanceCss}:root{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;color:#171719;background:#fff;line-height:1.45;font-size:14px;--accent:#002fa7}*{box-sizing:border-box}body{margin:0}header{display:flex;align-items:center;gap:10px;min-height:72px;padding:16px 24px;border-bottom:1px solid #d9d9de;position:sticky;top:0;background:white;z-index:2}.brand{text-decoration:none;color:inherit;font-size:17px;letter-spacing:-.5px;margin-right:auto}.brand strong{margin-left:8px}#save-state{font-size:12px;color:#555;max-width:230px}button,input,select,textarea{font:inherit}button{padding:9px 12px;border:1px solid #c8c8d0;background:#fff;color:#171719;cursor:pointer;border-radius:0}button:hover{border-color:var(--accent);color:var(--accent)}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}main{display:grid;grid-template-columns:235px minmax(340px,.9fr) minmax(420px,1.1fr);min-height:calc(100vh - 72px)}aside{border-right:1px solid #d9d9de;padding:28px 20px}.eyebrow{font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:#666;margin:0 0 12px}h1{font-size:31px;line-height:1.08;letter-spacing:-1.4px;font-weight:500;margin:0 0 16px}h2{font-size:23px;line-height:1.2;letter-spacing:-.7px;font-weight:500;margin:0}h3{font-weight:500}.intro,.help,.footnote{color:#63636d;font-size:12px}.intro{margin-bottom:28px;max-width:28em}.footnote{margin-top:32px}label{display:block;font-size:12px;margin:12px 0;color:#3d3d45}input,select,textarea{display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #c8c8d0;border-radius:0;background:white;color:#171719;min-width:0}textarea{font-family:Consolas,monospace;font-size:12px;tab-size:2;resize:vertical}nav{display:grid;margin:28px -20px 0}nav button{text-align:left;border:0;border-top:1px solid #e2e2e7;padding:15px 20px}nav button[aria-current=page]{border-left:3px solid var(--accent);padding-left:17px;color:var(--accent);background:#f7f7f8}.workspace{min-width:0;padding:28px 24px;border-right:1px solid #d9d9de}.workspace-title,.preview-heading{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:24px}.badge{font-size:11px;border:1px solid #c8c8d0;padding:5px 8px;white-space:nowrap}.row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.row>label{flex:1;min-width:100px}.row>button{margin-bottom:12px}.token-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0 18px}.token{border-top:1px solid #e2e2e7;padding:10px 0;min-width:0}.token label{margin:0}.token small{font-size:11px;display:block;overflow-wrap:anywhere;color:#686870;margin-top:7px}.token .token-name{font-weight:600;font-size:12px}.token-actions{display:flex;gap:8px}.token-actions input{flex:1}.swatch{width:18px;height:18px;border:1px solid #999;display:inline-block;vertical-align:middle;margin-right:6px}.preview{min-width:0;padding:28px 24px;background:#f7f7f8;align-self:start;position:sticky;top:72px;max-height:calc(100vh - 72px);overflow:auto}.preview-controls{display:flex;flex-wrap:wrap;gap:10px}.preview-controls label{flex:1;min-width:100px;margin-top:0}.frames{display:grid;gap:16px}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr 1fr}.frames>div{min-width:0}.frame-scroll{overflow:auto;background:white;border:1px solid #d9d9de}iframe{border:0;width:100%;height:680px;display:block;background:white}.frames p{font-size:12px}details{border-top:1px solid #d9d9de;padding-top:16px;margin-top:24px}summary{cursor:pointer}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;background:#f7f7f8;padding:12px;max-height:350px;overflow:auto}#diagnostics:not(:empty){border-left:3px solid #b72032;padding:8px 12px;margin-bottom:20px;color:#8c1526;font-size:12px}#message:not(:empty){position:fixed;bottom:16px;left:24px;max-width:min(650px,calc(100vw - 48px));padding:14px 20px;background:#171719;color:white;z-index:4;white-space:pre-wrap}#contract{font-size:12px}.editor[hidden],[hidden]{display:none!important}#revisions li{margin-bottom:10px}#revisions button{text-align:left;width:100%}@media(min-width:1600px){main{grid-template-columns:250px minmax(470px,.8fr) minmax(600px,1.2fr)}}@media(max-width:1150px){main{grid-template-columns:210px minmax(0,1fr)}.preview{grid-column:2;position:static;max-height:none}.workspace{border-right:0}aside{grid-row:span 2}}@media(max-width:650px){header{flex-wrap:wrap;padding:12px}.brand{width:100%}#save-state{margin-right:auto}main{display:block}aside{border-right:0;padding:24px}h1 br{display:none}nav{display:flex;overflow:auto;margin:24px -24px 0}nav button{white-space:nowrap;padding:12px}.workspace,.preview{padding:24px}aside .intro,aside .footnote{display:none}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr}.token-
Line truncated
|
export const studioCss = `${manualCss}${guidanceCss}:root{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;color:#171719;background:#fff;line-height:1.45;font-size:14px;--accent:#002fa7}*{box-sizing:border-box}body{margin:0}header{display:flex;align-items:center;gap:10px;min-height:72px;padding:16px 24px;border-bottom:1px solid #d9d9de;position:sticky;top:0;background:white;z-index:2}.brand{text-decoration:none;color:inherit;font-size:17px;letter-spacing:-.5px;margin-right:auto}.brand strong{margin-left:8px}#save-state{font-size:12px;color:#555;max-width:230px}button,input,select,textarea{font:inherit}button{padding:9px 12px;border:1px solid #c8c8d0;background:#fff;color:#171719;cursor:pointer;border-radius:0}button:hover{border-color:var(--accent);color:var(--accent)}button.primary{background:var(--accent);color:white;border-color:var(--accent)}button:disabled{opacity:.45;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px}main{display:grid;grid-template-columns:235px minmax(340px,.9fr) minmax(420px,1.1fr);min-height:calc(100vh - 72px)}aside{border-right:1px solid #d9d9de;padding:28px 20px}.eyebrow{font-size:11px;letter-spacing:1.2px;text-transform:uppercase;color:#666;margin:0 0 12px}h1{font-size:31px;line-height:1.08;letter-spacing:-1.4px;font-weight:500;margin:0 0 16px}h2{font-size:23px;line-height:1.2;letter-spacing:-.7px;font-weight:500;margin:0}h3{font-weight:500}.intro,.help,.footnote{color:#63636d;font-size:12px}.intro{margin-bottom:28px;max-width:28em}.footnote{margin-top:32px}label{display:block;font-size:12px;margin:12px 0;color:#3d3d45}input,select,textarea{display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #c8c8d0;border-radius:0;background:white;color:#171719;min-width:0}textarea{font-family:Consolas,monospace;font-size:12px;tab-size:2;resize:vertical}nav{display:grid;margin:28px -20px 0}nav button{text-align:left;border:0;border-top:1px solid #e2e2e7;padding:15px 20px}nav button[aria-current=page]{border-left:3px solid var(--accent);padding-left:17px;color:var(--accent);background:#f7f7f8}.workspace{min-width:0;padding:28px 24px;border-right:1px solid #d9d9de}.workspace-title,.preview-heading{display:flex;justify-content:space-between;gap:12px;align-items:start;margin-bottom:24px}.badge{font-size:11px;border:1px solid #c8c8d0;padding:5px 8px;white-space:nowrap}.row{display:flex;gap:10px;align-items:end;flex-wrap:wrap}.row>label{flex:1;min-width:100px}.row>button{margin-bottom:12px}.token-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0 18px}.token{border-top:1px solid #e2e2e7;padding:10px 0;min-width:0}.token label{margin:0}.token small{font-size:11px;display:block;overflow-wrap:anywhere;color:#686870;margin-top:7px}.token .token-name{font-weight:600;font-size:12px}.token-actions{display:flex;gap:8px}.token-actions input{flex:1}.swatch{width:18px;height:18px;border:1px solid #999;display:inline-block;vertical-align:middle;margin-right:6px}.preview{min-width:0;padding:28px 24px;background:#f7f7f8;align-self:start;position:sticky;top:72px;max-height:calc(100vh - 72px);overflow:auto}.preview-controls{display:flex;flex-wrap:wrap;gap:10px}.preview-controls label{flex:1;min-width:100px;margin-top:0}.frames{display:grid;gap:16px}.frames:has(#comparison:not([hidden])){grid-template-columns:1fr 1fr}.frames>div{min-width:0}.frame-scroll{overflow:auto;background:white;border:1px solid #d9d9de}iframe{border:0;width:100%;height:680px;display:block;background:white}.frames p{font-size:12px}details{border-top:1px solid #d9d9de;padding-top:16px;margin-top:24px}summary{cursor:pointer}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px;background:#f7f7f8;padding:12px;max-height:350px;overflow:auto}#diagnostics:not(:empty){border-left:3px solid #b72032;padding:8px 12px;margin-bottom:20px;color:#8c1526;font-size:12px}#message:not(:empty){position:fixed;bottom:16px;left:24px;max-width:min(650px,calc(100vw - 48px));padding:14px 20px;background:#171719;color:white;z-index:4;white-space:pre-wrap}#contract{font-size:12px}.editor[hidden],[hidden]{display:none!important}#revisions li{margin-bottom:10px}#revisions button{text-align:left;width:100%}@media(min-width:1600px){main{grid-template-columns:250px minmax(470px,.8fr) minmax(600px,1.2fr)}}@media(max-width:1150px){main{grid-template-columns:210px minmax(0,1fr)}.preview{grid-column:2;position:static;max-height:none}.workspace{border-right:0}aside{grid-row:span 2}}@media(max-width:650px){header{flex-wrap:wrap;padding:12px}.brand{width:100%}#save-state{margin-right:auto}main{display:block}aside{border-right:0;padding:24px}h1 br{display:none}nav{display:flex;overflow:auto;margin:24px -24px 0}nav button{white-space:nowrap;padding:12px}.workspace,.preview{padding:24px}aside .intro,aside .footnote{display:none}.frames:has(#comparison:not([hidden])){grid-template-columns
Line truncated
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
CORE_DESIGN_SYSTEMS,
|
||||||
|
validateDesignSystem,
|
||||||
|
compileDesignSystem,
|
||||||
|
} from "@wrnexus/design-system";
|
||||||
|
import { manualDesign } from "../src/manual.ts";
|
||||||
|
import { StudioSession } from "../src/session.ts";
|
||||||
|
import { checkDecisionLocks } from "../src/decisions.ts";
|
||||||
|
|
||||||
|
test("manual colors update only their mode and maintain readable contrast and legacy bindings", () => {
|
||||||
|
const source = CORE_DESIGN_SYSTEMS.soft;
|
||||||
|
const original = JSON.stringify(source);
|
||||||
|
const next = manualDesign(source, {
|
||||||
|
kind: "color",
|
||||||
|
mode: "light",
|
||||||
|
name: "primary",
|
||||||
|
value: "#000000",
|
||||||
|
});
|
||||||
|
expect(next.modes!.light!["color.primary"]!.value).toBe("#000000");
|
||||||
|
expect(next.modes!.light!["color.primary-contrast"]!.value).toBe("#FFFFFF");
|
||||||
|
expect(next.modes!.light!["color.focus"]!.legacy).toBe("color-focus");
|
||||||
|
expect(next.modes!.dark).toEqual(source.modes!.dark);
|
||||||
|
expect(JSON.stringify(source)).toBe(original);
|
||||||
|
expect(validateDesignSystem(next)).toEqual([]);
|
||||||
|
expect(() => compileDesignSystem(next, { mode: "light" })).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
manualDesign(source, {
|
||||||
|
kind: "color",
|
||||||
|
mode: "light",
|
||||||
|
name: "primary",
|
||||||
|
value: "red; display:none",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
test("manual foundations are recoverable and decision locks still detect changes", () => {
|
||||||
|
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
|
||||||
|
const before = JSON.stringify(session.valid);
|
||||||
|
const next = manualDesign(session.valid, { kind: "token", name: "shape.control", value: "8px" });
|
||||||
|
expect(checkDecisionLocks(session.valid, next, ["/tokens/shape.control"])).not.toHaveLength(0);
|
||||||
|
session.replace(next, "Manual shape");
|
||||||
|
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
|
||||||
|
session.undo();
|
||||||
|
expect(JSON.stringify(session.valid)).toBe(before);
|
||||||
|
session.redo();
|
||||||
|
expect(session.valid.tokens["shape.control"]!.value).toBe("8px");
|
||||||
|
expect(() =>
|
||||||
|
manualDesign(next, { kind: "token", name: "shape.control", value: "-2px" }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
test("manual typography, spacing, surfaces and semantic colors validate for all core themes", () => {
|
||||||
|
for (const original of Object.values(CORE_DESIGN_SYSTEMS)) {
|
||||||
|
let system = structuredClone(original);
|
||||||
|
for (const [name, value] of [
|
||||||
|
["type.body", "Arial, sans-serif"],
|
||||||
|
["type.heading", "Georgia, serif"],
|
||||||
|
["space.panel", "24px"],
|
||||||
|
["shape.panel", "12px"],
|
||||||
|
["elevation.panel", "none"],
|
||||||
|
])
|
||||||
|
system = manualDesign(system, { kind: "token", name: name!, value: value! });
|
||||||
|
for (const mode of ["light", "dark"])
|
||||||
|
for (const name of ["secondary", "success", "warning", "danger", "info"] as const)
|
||||||
|
system = manualDesign(system, { kind: "color", mode, name, value: "#00677B" });
|
||||||
|
expect(validateDesignSystem(system)).toEqual([]);
|
||||||
|
expect(() => compileDesignSystem(system, { mode: "dark" })).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manual button styles, tilt, pill shapes, default mode and advanced colors compile", () => {
|
||||||
|
let system = manualDesign(CORE_DESIGN_SYSTEMS.soft, { kind: "buttonStyle", value: "outline" });
|
||||||
|
system = manualDesign(system, { kind: "tilt", value: "none" });
|
||||||
|
system = manualDesign(system, { kind: "token", name: "shape.control", value: "999px" });
|
||||||
|
system = manualDesign(system, { kind: "mode", value: "dark" });
|
||||||
|
system = manualDesign(system, {
|
||||||
|
kind: "color",
|
||||||
|
mode: "dark",
|
||||||
|
name: "primary-contrast",
|
||||||
|
value: "#FFFFFF",
|
||||||
|
});
|
||||||
|
expect(system.defaultMode).toBe("dark");
|
||||||
|
expect(system.recipes!.Button!.variants!.at(-1)!.parts.control!.backgroundColor).toBe(
|
||||||
|
"transparent",
|
||||||
|
);
|
||||||
|
expect(system.recipes!.Button!.base!.control!.rotate).toBe("0deg");
|
||||||
|
expect(() => compileDesignSystem(system)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manual form saves one atomic revision and rejects locked changes without corrupting drafts", async () => {
|
||||||
|
const { Window } = await import("happy-dom");
|
||||||
|
const { manualHtml, setupManual } = await import("../src/manual-editor.ts");
|
||||||
|
const win = new Window();
|
||||||
|
const names = ["document", "HTMLInputElement", "Option"] as const;
|
||||||
|
const previous = Object.fromEntries(names.map((name) => [name, (globalThis as any)[name]]));
|
||||||
|
try {
|
||||||
|
(globalThis as any).document = win.document;
|
||||||
|
(globalThis as any).HTMLInputElement = win.HTMLInputElement;
|
||||||
|
(globalThis as any).Option = win.Option;
|
||||||
|
win.document.body.innerHTML =
|
||||||
|
manualHtml + '<select id="preview-mode"><option value="light">light</option></select>';
|
||||||
|
const session = new StudioSession(CORE_DESIGN_SYSTEMS.soft);
|
||||||
|
let notice = "";
|
||||||
|
const editor = setupManual(session, {
|
||||||
|
changed: async () => {
|
||||||
|
editor.render();
|
||||||
|
},
|
||||||
|
message: (text) => {
|
||||||
|
notice = text;
|
||||||
|
},
|
||||||
|
mode: () => {},
|
||||||
|
});
|
||||||
|
editor.render();
|
||||||
|
const input = win.document.querySelector('[data-manual-token="shape.control"]') as any;
|
||||||
|
input.value = "12";
|
||||||
|
(win.document.querySelector('input[type=text][data-manual-color="primary"]') as any).value =
|
||||||
|
"#6336AD";
|
||||||
|
(win.document.querySelector("[data-manual-apply]") as any).click();
|
||||||
|
await Bun.sleep(10);
|
||||||
|
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
|
||||||
|
expect(session.valid.modes!.light!["color.primary"]!.value).toBe("#6336AD");
|
||||||
|
expect(session.history).toHaveLength(2);
|
||||||
|
session.setLocks(["/tokens/shape.control"]);
|
||||||
|
editor.render();
|
||||||
|
(win.document.querySelector('[data-manual-token="shape.control"]') as any).value = "24";
|
||||||
|
(win.document.querySelector("[data-manual-apply]") as any).click();
|
||||||
|
await Bun.sleep(10);
|
||||||
|
expect(notice).toContain("locked decision");
|
||||||
|
expect(session.valid.tokens["shape.control"]!.value).toBe("12px");
|
||||||
|
expect(session.diagnostics).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
for (const name of names) (globalThis as any)[name] = previous[name];
|
||||||
|
await win.happyDOM.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user