feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
resolveDesignSystem,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type ResolveOptions,
|
||||
} from "@wrnexus/design-system";
|
||||
import reference from "@wrnexus/ui/component-reference.json";
|
||||
export const AGENT_CONTEXT_VERSION = 1;
|
||||
export function agentGenerationContext(
|
||||
system: DesignSystem,
|
||||
options: ResolveOptions & { components?: string[]; locks?: string[] } = {},
|
||||
) {
|
||||
const diagnostics = validateDesignSystem(system, options);
|
||||
if (diagnostics.length) throw Error("WRN-DESIGN-CONTEXT: source system must validate.");
|
||||
const names = options.components ?? [
|
||||
"Button",
|
||||
"Input",
|
||||
"Textarea",
|
||||
"Select",
|
||||
"Card",
|
||||
"Alert",
|
||||
"PageHeader",
|
||||
"Container",
|
||||
];
|
||||
if (
|
||||
!Array.isArray(names) ||
|
||||
names.length > 12 ||
|
||||
names.some((name) => !Object.hasOwn(COMPONENT_CONTRACTS, name))
|
||||
)
|
||||
throw Error("WRN-DESIGN-CONTEXT: select up to 12 known components.");
|
||||
const resolved = resolveDesignSystem(system, options);
|
||||
return {
|
||||
contextVersion: AGENT_CONTEXT_VERSION,
|
||||
system: {
|
||||
id: system.id,
|
||||
version: system.version,
|
||||
mode: resolved.mode,
|
||||
density: resolved.density,
|
||||
},
|
||||
locks: options.locks ?? [],
|
||||
tokens: resolved.tokens,
|
||||
components: Object.fromEntries(
|
||||
names.map((name) => {
|
||||
const contract = COMPONENT_CONTRACTS[name as keyof typeof COMPONENT_CONTRACTS];
|
||||
const meta = reference.components.find((c) => c.name === name)!;
|
||||
return [
|
||||
name,
|
||||
{
|
||||
contractVersion: contract.contractVersion,
|
||||
axes: contract.axes,
|
||||
parts: contract.parts,
|
||||
props: meta.props,
|
||||
outputs: meta.outputs,
|
||||
slots: contract.slots,
|
||||
constraints: contract.constraints,
|
||||
support: contract.support,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
composition: {
|
||||
container: "Container",
|
||||
spacingToken: "space.panel",
|
||||
legacySpacing: "--wrn-space-panel",
|
||||
rules: [
|
||||
"Use declared component props and variants. Preserve instance APIs and explicit choices.",
|
||||
"Use semantic token references for styling. Do not invent variants or provider capabilities.",
|
||||
"Custom/ejected application components need separate contracts.",
|
||||
"Page-spec previews contain trusted shipped components only; arbitrary source is analyzed, never executed.",
|
||||
],
|
||||
},
|
||||
examples: [
|
||||
'<Button label={"Continue"} variant={"default"} />',
|
||||
"color: var(--wrn-color-text); gap: var(--wrn-space-panel);",
|
||||
],
|
||||
limitations: [
|
||||
"Static conformance does not certify accessibility, contrast, dynamic expressions or visual quality.",
|
||||
"Native/provider/content-owned regions retain their contract limits.",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import reference from "@wrnexus/ui/component-reference.json";
|
||||
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
|
||||
import { checkDesignConformance } from "./conformance.ts";
|
||||
export interface AgentPage {
|
||||
id: string;
|
||||
title: string;
|
||||
components: Array<{ component: string; props?: Record<string, unknown> }>;
|
||||
}
|
||||
function jsonLiteral(value: unknown, depth = 0): boolean {
|
||||
if (depth > 16) return false;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value))
|
||||
return value.length <= 1000 && value.every((item) => jsonLiteral(item, depth + 1));
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
|
||||
Object.entries(value).every(
|
||||
([key, item]) =>
|
||||
!["__proto__", "constructor", "prototype"].includes(key) && jsonLiteral(item, depth + 1),
|
||||
)
|
||||
);
|
||||
}
|
||||
export function validateAgentPage(input: unknown): asserts input is AgentPage {
|
||||
if (!input || typeof input !== "object") throw Error("WRN-DESIGN-PAGE: expected a page object.");
|
||||
const page = input as AgentPage;
|
||||
if (
|
||||
typeof page.id !== "string" ||
|
||||
!/^[a-z][a-z0-9-]{0,63}$/.test(page.id) ||
|
||||
typeof page.title !== "string" ||
|
||||
!page.title.trim() ||
|
||||
page.title.length > 200 ||
|
||||
!Array.isArray(page.components) ||
|
||||
page.components.length < 1 ||
|
||||
page.components.length > 12
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: valid id, title and 1–12 components required.");
|
||||
for (const entry of page.components) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry.component !== "string" ||
|
||||
!Object.hasOwn(COMPONENT_CONTRACTS, entry.component)
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: unknown contracted component.");
|
||||
if (
|
||||
entry.props !== undefined &&
|
||||
(!entry.props || typeof entry.props !== "object" || Array.isArray(entry.props))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: props must be an object.");
|
||||
const props = reference.components.find((c) => c.name === entry.component)!.props;
|
||||
for (const [key, value] of Object.entries(entry.props ?? {})) {
|
||||
const definition = props.find((p) => p.name === key);
|
||||
if (
|
||||
!definition ||
|
||||
["attrs", "class", "controlClass", "style", "html"].includes(key) ||
|
||||
!/^[A-Za-z]\w*$/.test(key)
|
||||
)
|
||||
throw Error(`WRN-DESIGN-PAGE: unsupported prop '${key}'.`);
|
||||
if (!jsonLiteral(value))
|
||||
throw Error("WRN-DESIGN-PAGE: props require bounded plain JSON literals.");
|
||||
const serialized = JSON.stringify(value);
|
||||
if (
|
||||
serialized === undefined ||
|
||||
typeof value === "function" ||
|
||||
(typeof value === "number" && !Number.isFinite(value))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: props require finite JSON literals.");
|
||||
if (
|
||||
serialized.length > 10000 ||
|
||||
/(?:javascript|vbscript)\s*:|data\s*:\s*text\/html/i.test(JSON.stringify(value))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PAGE: unsafe or oversized prop value.");
|
||||
if (
|
||||
(definition.type === "string" && typeof value !== "string") ||
|
||||
(definition.type === "boolean" && typeof value !== "boolean") ||
|
||||
(definition.type === "number" && (typeof value !== "number" || !Number.isFinite(value)))
|
||||
)
|
||||
throw Error(`WRN-DESIGN-PAGE: invalid type for ${key}.`);
|
||||
const axis = (
|
||||
COMPONENT_CONTRACTS[entry.component as keyof typeof COMPONENT_CONTRACTS].axes as Record<
|
||||
string,
|
||||
{ values: readonly string[] }
|
||||
>
|
||||
)[key];
|
||||
if (axis && !axis.values.includes(String(value)))
|
||||
throw Error(`WRN-DESIGN-PAGE: unsupported ${key} value.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const literal = (value: unknown) =>
|
||||
JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
||||
const text = (value: string) =>
|
||||
value.replace(
|
||||
/[&<>"'{}]/g,
|
||||
(c) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"{": "{",
|
||||
"}": "}",
|
||||
})[c]!,
|
||||
);
|
||||
export function agentPageSource(page: AgentPage) {
|
||||
validateAgentPage(page);
|
||||
return `page AgentPage {\n seo { title = ${literal(page.title)} }\n view {\n <main class="wrn-agent-page"><h1>${text(page.title)}</h1>\n${page.components
|
||||
.map(
|
||||
(entry) =>
|
||||
`<${entry.component} ${Object.entries(entry.props ?? {})
|
||||
.map(([key, value]) => `${key}={${literal(value)}}`)
|
||||
.join(" ")} />`,
|
||||
)
|
||||
.join(
|
||||
"\n",
|
||||
)}\n </main>\n }\n style { .wrn-agent-page { display: grid; padding: var(--wrn-space-panel); gap: var(--wrn-space-panel); color: var(--wrn-color-text); background: var(--wrn-color-bg); font-family: var(--wrn-font-sans); } }\n}\n`;
|
||||
}
|
||||
export function checkAgentPage(input: unknown) {
|
||||
try {
|
||||
validateAgentPage(input);
|
||||
const source = agentPageSource(input);
|
||||
return { ...checkDesignConformance(source), source };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
source: undefined,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "WRN-DESIGN-PAGE",
|
||||
path: "$",
|
||||
message: (error as Error).message,
|
||||
severity: "error" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { createDesignPackage } from "@wrnexus/design-system/package";
|
||||
import { planDesignUpgrade, applyDesignUpgrade } from "./upgrades.ts";
|
||||
import {
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StudioApplication } from "./application.ts";
|
||||
import { StudioSession, exportDesign, reviewDesign } from "./session.ts";
|
||||
import { writeDecision } from "./decisions.ts";
|
||||
import { agentGenerationContext } from "./agent-context.ts";
|
||||
import { checkDesignConformance } from "./conformance.ts";
|
||||
import { agentPageSource, checkAgentPage, type AgentPage } from "./agent-pages.ts";
|
||||
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
|
||||
import { previewAgentPage } from "./preview.ts";
|
||||
|
||||
interface State {
|
||||
storageVersion?: 1;
|
||||
revision: string;
|
||||
session: string;
|
||||
pages: Record<string, AgentPage>;
|
||||
previous?: { session: string; pages: Record<string, AgentPage> };
|
||||
}
|
||||
/** Local, revision-checked agent workspace. Never writes application routes or configuration. */
|
||||
export class DesignAgentService {
|
||||
private application: StudioApplication;
|
||||
private directory: string;
|
||||
constructor(
|
||||
root: string,
|
||||
private writable = false,
|
||||
) {
|
||||
this.application = new StudioApplication(root);
|
||||
this.directory = join(this.application.directory, "agent");
|
||||
}
|
||||
private check(path: string, file = false) {
|
||||
if (
|
||||
existsSync(path) &&
|
||||
(lstatSync(path).isSymbolicLink() ||
|
||||
!(file ? lstatSync(path).isFile() : lstatSync(path).isDirectory()))
|
||||
)
|
||||
throw Error("WRN-DESIGN-PATH: managed storage must not contain links.");
|
||||
}
|
||||
private load() {
|
||||
// Re-check every ancestor through the application boundary on every operation.
|
||||
this.application.state();
|
||||
this.check(this.directory);
|
||||
const file = join(this.directory, "STATE.json");
|
||||
this.check(file, true);
|
||||
let serialized = "";
|
||||
const session = new StudioSession(this.application.system(), {
|
||||
getItem: () => serialized || null,
|
||||
setItem: (_key, value) => {
|
||||
serialized = value;
|
||||
},
|
||||
});
|
||||
let state: State = { revision: "none", session: "", pages: {} };
|
||||
if (existsSync(file)) {
|
||||
const raw = readFileSync(file, "utf8");
|
||||
if (raw.length > 12_000_000) throw Error("WRN-DESIGN-LIMIT: workspace too large.");
|
||||
state = JSON.parse(raw);
|
||||
if (state.storageVersion !== undefined && state.storageVersion !== 1)
|
||||
throw Error(
|
||||
"WRN-DESIGN-RECOVERY: unsupported agent state version; preserve original bytes.",
|
||||
);
|
||||
serialized = state.session;
|
||||
if (!session.restore())
|
||||
throw Error("WRN-DESIGN-RECOVERY: saved draft could not be restored.");
|
||||
for (const page of Object.values(state.pages))
|
||||
if (!checkAgentPage(page).ok) throw Error("WRN-DESIGN-RECOVERY: invalid saved page.");
|
||||
}
|
||||
session.persist();
|
||||
state.session = serialized;
|
||||
return {
|
||||
state,
|
||||
session,
|
||||
serialize: () => {
|
||||
session.persist();
|
||||
return serialized;
|
||||
},
|
||||
};
|
||||
}
|
||||
async call(operation: string, args: Record<string, unknown> = {}): Promise<unknown> {
|
||||
if (JSON.stringify(args).length > 2_000_000)
|
||||
throw Error("WRN-DESIGN-LIMIT: request exceeds 2 MB.");
|
||||
const writes = ["create", "propose", "locks", "page", "apply", "revert", "upgrade"];
|
||||
const mutation = writes.includes(operation);
|
||||
if (mutation && !this.writable)
|
||||
throw Error("WRN-DESIGN-READONLY: enable designWrite at server startup.");
|
||||
let locked = false;
|
||||
const lock = join(this.directory, "LOCK");
|
||||
if (mutation) {
|
||||
this.application.state();
|
||||
this.check(this.directory);
|
||||
mkdirSync(this.directory, { recursive: true });
|
||||
try {
|
||||
mkdirSync(lock);
|
||||
locked = true;
|
||||
} catch {
|
||||
throw Error(
|
||||
"WRN-DESIGN-BUSY: workspace is locked; retry after the current writer completes.",
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { state, session, serialize } = this.load();
|
||||
if (mutation && args.baseRevision !== state.revision)
|
||||
throw Error("WRN-DESIGN-CONFLICT: draft changed; inspect and review again.");
|
||||
const before = { session: state.session, pages: structuredClone(state.pages) };
|
||||
const options = {
|
||||
mode: typeof args.mode === "string" ? args.mode : undefined,
|
||||
density: typeof args.density === "string" ? args.density : undefined,
|
||||
};
|
||||
let result: unknown;
|
||||
switch (operation) {
|
||||
case "inspect":
|
||||
return {
|
||||
revision: state.revision,
|
||||
application: this.application.state(),
|
||||
source: session.source,
|
||||
valid: session.valid,
|
||||
locks: session.locks,
|
||||
diagnostics: session.diagnostics,
|
||||
pages: state.pages,
|
||||
history: session.history.map((r) => r.label),
|
||||
cursor: session.cursor,
|
||||
};
|
||||
case "capabilities":
|
||||
return {
|
||||
contextVersion: 1,
|
||||
writable: this.writable,
|
||||
components: Object.keys(COMPONENT_CONTRACTS),
|
||||
operations: [
|
||||
"package",
|
||||
"upgrade_plan",
|
||||
"upgrade",
|
||||
"inspect",
|
||||
"capabilities",
|
||||
"context",
|
||||
"create",
|
||||
"propose",
|
||||
"locks",
|
||||
"validate",
|
||||
"preview",
|
||||
"diff",
|
||||
"apply",
|
||||
"revert",
|
||||
"export",
|
||||
"conformance",
|
||||
"page",
|
||||
],
|
||||
limits: { pageComponents: 12, pages: 12, patches: 48 },
|
||||
boundaries: [
|
||||
"Local managed draft only. Explicit revision required for mutations.",
|
||||
"Application bundles require explicit integration; routes and deployments are never modified.",
|
||||
"Static analysis is heuristic; arbitrary submitted code is never executed.",
|
||||
],
|
||||
};
|
||||
case "context":
|
||||
return {
|
||||
revision: state.revision,
|
||||
...agentGenerationContext(session.valid, {
|
||||
...options,
|
||||
components: args.components as string[] | undefined,
|
||||
locks: session.locks,
|
||||
}),
|
||||
};
|
||||
case "package":
|
||||
return {
|
||||
revision: state.revision,
|
||||
diagnostics: session.diagnostics,
|
||||
package: await createDesignPackage(session.valid, args.notes as string | undefined),
|
||||
};
|
||||
case "upgrade_plan":
|
||||
return {
|
||||
revision: state.revision,
|
||||
...(await planDesignUpgrade(session, args.source as string)),
|
||||
};
|
||||
case "upgrade": {
|
||||
const plan = await planDesignUpgrade(session, args.source as string);
|
||||
result = await applyDesignUpgrade(session, args.source as string, plan.baseRevision);
|
||||
break;
|
||||
}
|
||||
case "create":
|
||||
if (args.source !== undefined) {
|
||||
if (typeof args.source !== "string")
|
||||
throw Error("WRN-DESIGN-INPUT: source must be a string.");
|
||||
session.edit(args.source, "Agent draft");
|
||||
}
|
||||
break;
|
||||
case "propose":
|
||||
if (typeof args.source === "string") session.edit(args.source, "Agent source proposal");
|
||||
else {
|
||||
if (!Array.isArray(args.patches) || args.patches.length > 48)
|
||||
throw Error("WRN-DESIGN-INPUT: provide source or up to 48 patches.");
|
||||
session.mutate((system) => {
|
||||
for (const patch of args.patches as Array<{ path: string; value: unknown }>)
|
||||
writeDecision(system, patch.path, patch.value);
|
||||
}, "Agent token proposal");
|
||||
}
|
||||
break;
|
||||
case "locks":
|
||||
session.setLocks(args.locks as string[]);
|
||||
break;
|
||||
case "page": {
|
||||
const checked = checkAgentPage(args.page);
|
||||
if (!checked.ok) return { revision: state.revision, ...checked };
|
||||
const page = args.page as AgentPage;
|
||||
if (!Object.hasOwn(state.pages, page.id) && Object.keys(state.pages).length >= 12)
|
||||
throw Error("WRN-DESIGN-LIMIT: at most 12 pages.");
|
||||
state.pages[page.id] = structuredClone(page);
|
||||
result = checked;
|
||||
break;
|
||||
}
|
||||
case "validate":
|
||||
return {
|
||||
revision: state.revision,
|
||||
ok: !session.diagnostics.length,
|
||||
diagnostics: session.diagnostics,
|
||||
pages: Object.values(state.pages).map((page) => ({
|
||||
id: page.id,
|
||||
...checkAgentPage(page),
|
||||
})),
|
||||
};
|
||||
case "conformance":
|
||||
return checkDesignConformance(args.source as string, args.exceptions as never);
|
||||
case "diff":
|
||||
return {
|
||||
revision: state.revision,
|
||||
changes: reviewDesign(session.applied, session.valid),
|
||||
pages: Object.keys(state.pages),
|
||||
diagnostics: session.diagnostics,
|
||||
};
|
||||
case "preview": {
|
||||
const page = state.pages[String(args.pageId)];
|
||||
if (!page) throw Error("WRN-DESIGN-PAGE: select a saved page.");
|
||||
return {
|
||||
revision: state.revision,
|
||||
diagnostics: session.diagnostics,
|
||||
html: await previewAgentPage(session.valid, page, options),
|
||||
};
|
||||
}
|
||||
case "export":
|
||||
return {
|
||||
revision: state.revision,
|
||||
source: session.source,
|
||||
locks: session.locks,
|
||||
diagnostics: session.diagnostics,
|
||||
artifacts: exportDesign(session.valid, options),
|
||||
pages: Object.fromEntries(
|
||||
Object.values(state.pages).map((page) => [page.id + ".wrn", agentPageSource(page)]),
|
||||
),
|
||||
};
|
||||
case "apply":
|
||||
if (session.diagnostics.length)
|
||||
throw Error("WRN-DESIGN-INVALID: resolve draft diagnostics before applying.");
|
||||
result = this.application.apply(
|
||||
session.valid,
|
||||
String(args.baseApplicationRevision),
|
||||
options,
|
||||
);
|
||||
session.markApplied();
|
||||
break;
|
||||
case "revert":
|
||||
if (args.target === "application") {
|
||||
result = this.application.rollback(String(args.baseApplicationRevision));
|
||||
} else if (args.target === "checkpoint") {
|
||||
if (!state.previous) throw Error("WRN-DESIGN-RECOVERY: no checkpoint.");
|
||||
state.session = state.previous.session;
|
||||
state.pages = state.previous.pages;
|
||||
} else {
|
||||
session.undo();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw Error("WRN-DESIGN-OPERATION: unknown operation.");
|
||||
}
|
||||
if (!(operation === "revert" && args.target === "checkpoint")) state.session = serialize();
|
||||
state.storageVersion = 1;
|
||||
state.previous = before;
|
||||
state.revision = randomUUID();
|
||||
const raw = JSON.stringify(state);
|
||||
if (raw.length > 12_000_000)
|
||||
throw Error("WRN-DESIGN-LIMIT: export or shorten draft history.");
|
||||
const temp = join(this.directory, randomUUID() + ".tmp");
|
||||
writeFileSync(temp, raw, { flag: "wx" });
|
||||
renameSync(temp, join(this.directory, "STATE.json"));
|
||||
const saved = this.load().session;
|
||||
return {
|
||||
revision: state.revision,
|
||||
diagnostics: saved.diagnostics,
|
||||
affectedPaths:
|
||||
operation === "page"
|
||||
? ["pages." + (args.page as AgentPage).id]
|
||||
: operation === "locks"
|
||||
? ["locks"]
|
||||
: operation === "apply" || (operation === "revert" && args.target === "application")
|
||||
? ["application.CURRENT"]
|
||||
: reviewDesign(JSON.parse(before.session).valid, saved.valid)
|
||||
.map((change) => change.path)
|
||||
.concat(
|
||||
saved.source !==
|
||||
JSON.parse(before.session).history[JSON.parse(before.session).cursor].source
|
||||
? ["draft.source"]
|
||||
: [],
|
||||
),
|
||||
result,
|
||||
};
|
||||
} finally {
|
||||
if (locked) rmdirSync(lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
realpathSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
} from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { exportDesign } from "./session.ts";
|
||||
import type { DesignSystem, ResolveOptions } from "@wrnexus/design-system";
|
||||
|
||||
interface Pointer {
|
||||
revision: string;
|
||||
previous?: string;
|
||||
}
|
||||
/** Immutable revision bundles with one atomic pointer. No caller-supplied path or app configuration edits. */
|
||||
export class StudioApplication {
|
||||
readonly directory: string;
|
||||
constructor(root: string) {
|
||||
if (!existsSync(root)) throw Error("Application root must already exist.");
|
||||
this.directory = join(realpathSync(resolve(root)), ".wrnexus", "design-studio");
|
||||
this.checkDirectories();
|
||||
}
|
||||
private checkDirectories() {
|
||||
for (const directory of [
|
||||
resolve(this.directory, ".."),
|
||||
this.directory,
|
||||
join(this.directory, "revisions"),
|
||||
]) {
|
||||
if (
|
||||
existsSync(directory) &&
|
||||
(!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink())
|
||||
)
|
||||
throw Error("Studio directory must be a real directory, not a link.");
|
||||
}
|
||||
const pointer = join(this.directory, "CURRENT.json");
|
||||
if (
|
||||
existsSync(pointer) &&
|
||||
(!lstatSync(pointer).isFile() || lstatSync(pointer).isSymbolicLink())
|
||||
)
|
||||
throw Error("Invalid application pointer.");
|
||||
}
|
||||
state(): Pointer {
|
||||
this.checkDirectories();
|
||||
const path = join(this.directory, "CURRENT.json");
|
||||
if (!existsSync(path)) return { revision: "none" };
|
||||
const pointer = JSON.parse(readFileSync(path, "utf8")) as Pointer;
|
||||
if (
|
||||
!/^(?:[a-f0-9]{64}|none)$/.test(pointer.revision) ||
|
||||
(pointer.previous && !/^(?:[a-f0-9]{64}|none)$/.test(pointer.previous))
|
||||
)
|
||||
throw Error("Invalid application revision.");
|
||||
return pointer;
|
||||
}
|
||||
system(): DesignSystem | undefined {
|
||||
const current = this.state();
|
||||
if (current.revision === "none") return undefined;
|
||||
const target = join(this.directory, "revisions", current.revision);
|
||||
const file = join(target, "system.json");
|
||||
if (
|
||||
!lstatSync(target).isDirectory() ||
|
||||
lstatSync(target).isSymbolicLink() ||
|
||||
!lstatSync(file).isFile() ||
|
||||
lstatSync(file).isSymbolicLink()
|
||||
)
|
||||
throw Error("Invalid revision bundle.");
|
||||
return JSON.parse(readFileSync(file, "utf8")) as DesignSystem;
|
||||
}
|
||||
selection(): ResolveOptions {
|
||||
const current = this.state();
|
||||
if (current.revision === "none") return {};
|
||||
// Validate the revision directory before reading another artifact inside it.
|
||||
this.system();
|
||||
const file = join(this.directory, "revisions", current.revision, "theme.json");
|
||||
if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink())
|
||||
throw Error("Invalid theme artifact.");
|
||||
const theme = JSON.parse(readFileSync(file, "utf8"));
|
||||
return { mode: theme.designSystem?.mode, density: theme.designSystem?.density };
|
||||
}
|
||||
private point(pointer: Pointer) {
|
||||
const path = join(this.directory, `CURRENT-${randomUUID()}.tmp`);
|
||||
writeFileSync(path, JSON.stringify(pointer, null, 2));
|
||||
renameSync(path, join(this.directory, "CURRENT.json"));
|
||||
}
|
||||
private exclusive<T>(run: () => T): T {
|
||||
this.checkDirectories();
|
||||
mkdirSync(this.directory, { recursive: true });
|
||||
const lock = join(this.directory, "APPLY.lock");
|
||||
try {
|
||||
mkdirSync(lock);
|
||||
} catch {
|
||||
throw Error("WRN-STUDIO-BUSY: another application writer is active.");
|
||||
}
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
rmdirSync(lock);
|
||||
}
|
||||
}
|
||||
apply(system: DesignSystem, base: string, options: ResolveOptions = {}) {
|
||||
return this.exclusive(() => this.applyUnlocked(system, base, options));
|
||||
}
|
||||
private applyUnlocked(system: DesignSystem, base: string, options: ResolveOptions) {
|
||||
const artifacts = exportDesign(system, options);
|
||||
const current = this.state();
|
||||
if (base !== current.revision)
|
||||
throw Error("WRN-STUDIO-CONFLICT: application changed; refresh review before applying.");
|
||||
const revision = createHash("sha256").update(JSON.stringify({ system, options })).digest("hex");
|
||||
mkdirSync(join(this.directory, "revisions"), { recursive: true });
|
||||
this.checkDirectories();
|
||||
const target = join(this.directory, "revisions", revision);
|
||||
if (
|
||||
existsSync(target) &&
|
||||
(!lstatSync(target).isDirectory() || lstatSync(target).isSymbolicLink())
|
||||
)
|
||||
throw Error("Invalid revision directory.");
|
||||
if (!existsSync(target)) {
|
||||
const staging = join(this.directory, "revisions", `draft-${randomUUID()}`);
|
||||
mkdirSync(staging);
|
||||
for (const [filename, body] of Object.entries({
|
||||
"system.json": artifacts.canonical,
|
||||
"system.css": artifacts.css,
|
||||
"theme.json": artifacts.theme,
|
||||
"tokens.json": artifacts.tokens,
|
||||
"report.json": artifacts.report,
|
||||
}))
|
||||
writeFileSync(join(staging, filename), body);
|
||||
writeFileSync(
|
||||
join(staging, "README.md"),
|
||||
`# ${system.name}\n\nImport theme.json and assign it to your WrNexus app configuration's theme field. Preserve explicit application overrides. This bundle does not edit app configuration or deploy. Alternatively use system.css as the standalone stylesheet; do not load both integrations.\n`,
|
||||
);
|
||||
renameSync(staging, target);
|
||||
}
|
||||
if (revision !== current.revision) this.point({ revision, previous: current.revision });
|
||||
return { ...this.state(), directory: target };
|
||||
}
|
||||
rollback(base: string) {
|
||||
return this.exclusive(() => this.rollbackUnlocked(base));
|
||||
}
|
||||
private rollbackUnlocked(base: string) {
|
||||
const current = this.state();
|
||||
if (base !== current.revision)
|
||||
throw Error("WRN-STUDIO-CONFLICT: application changed; refresh review.");
|
||||
if (!current.previous) throw Error("No previous application revision.");
|
||||
this.point({ revision: current.previous, previous: current.revision });
|
||||
return this.state();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bun
|
||||
import { createStudioHandler } from "./server.ts";
|
||||
export async function runDesignStudio() {
|
||||
const index = process.argv.indexOf("--app-root");
|
||||
if (index >= 0 && !process.argv[index + 1])
|
||||
throw Error("--app-root requires an existing application directory.");
|
||||
const fetch = await createStudioHandler({
|
||||
applicationRoot: index >= 0 ? process.argv[index + 1] : undefined,
|
||||
...(await (async () => {
|
||||
const position = process.argv.indexOf("--ai-model");
|
||||
if (position < 0) return {};
|
||||
const model = process.argv[position + 1];
|
||||
if (!model || model.startsWith("--"))
|
||||
throw Error("--ai-model requires an explicit provider model ID.");
|
||||
if (!process.env.ANTHROPIC_API_KEY)
|
||||
throw Error("ANTHROPIC_API_KEY is required when --ai-model is enabled.");
|
||||
const { anthropicProvider } = await import("@wrnexus/ai");
|
||||
return { generationProvider: anthropicProvider({ model }), generationModel: model };
|
||||
})()),
|
||||
});
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.DESIGN_STUDIO_PORT ?? 4320),
|
||||
fetch,
|
||||
});
|
||||
console.log(
|
||||
`Design Studio: ${server.url} (${index >= 0 ? "local application enabled" : "export-only; pass --app-root to enable local apply"})`,
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
if (import.meta.main) await runDesignStudio();
|
||||
@@ -0,0 +1,674 @@
|
||||
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,
|
||||
type DesignToken,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const value = (id: string) => $<HTMLInputElement>(id).value;
|
||||
const escape = (text: unknown) =>
|
||||
String(text).replace(
|
||||
/[&<>"']/g,
|
||||
(char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!,
|
||||
);
|
||||
const option = (name: string, label = name) =>
|
||||
`<option value="${escape(name)}">${escape(label)}</option>`;
|
||||
const session = new StudioSession(undefined, localStorage);
|
||||
session.restore();
|
||||
let importCandidate: DesignSystem | undefined;
|
||||
let application: { enabled?: boolean; revision: string; previous?: string; directory?: string } = {
|
||||
revision: "none",
|
||||
};
|
||||
let page = 0;
|
||||
let previewGeneration = 0;
|
||||
let activeTab = "foundations";
|
||||
let noticeTimer: ReturnType<typeof setTimeout>;
|
||||
function message(text: string) {
|
||||
$("message").textContent = text;
|
||||
clearTimeout(noticeTimer);
|
||||
noticeTimer = setTimeout(() => {
|
||||
$("message").textContent = "";
|
||||
}, 10000);
|
||||
}
|
||||
async function request(path: string, body?: unknown) {
|
||||
const response = await fetch(
|
||||
new URL("." + path, document.baseURI),
|
||||
body === undefined
|
||||
? undefined
|
||||
: {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw Error(result.error ?? "Request failed.");
|
||||
return result;
|
||||
}
|
||||
function on(id: string, action: () => unknown | Promise<unknown>, event = "click") {
|
||||
$(id).addEventListener(event, () => {
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
flushSource();
|
||||
return action();
|
||||
})
|
||||
.catch((error) => message(error.message));
|
||||
});
|
||||
}
|
||||
function flushSource() {
|
||||
if (activeTab === "source" && value("source-text") !== session.source) {
|
||||
clearTimeout(sourceTimer);
|
||||
session.edit(value("source-text"));
|
||||
render();
|
||||
}
|
||||
}
|
||||
function download(body: string, filename: string, type = "application/json") {
|
||||
const url = URL.createObjectURL(new Blob([body], { type }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
function selectOptions(id: string, entries: string[], empty?: string) {
|
||||
const select = $<HTMLSelectElement>(id);
|
||||
const previous = select.value;
|
||||
select.innerHTML =
|
||||
(empty !== undefined ? option("", empty) : "") + entries.map((name) => option(name)).join("");
|
||||
if ([...select.options].some((o) => o.value === previous)) select.value = previous;
|
||||
}
|
||||
function selection() {
|
||||
return {
|
||||
mode: value("preview-mode") || undefined,
|
||||
density: value("preview-density") || undefined,
|
||||
};
|
||||
}
|
||||
function tab(name: string) {
|
||||
activeTab = name;
|
||||
for (const id of ["guided", "foundations", "components", "source", "history", "review"])
|
||||
$(id === "review" ? "review-panel" : id).hidden = id !== name;
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>("[data-tab]")
|
||||
.forEach((button) =>
|
||||
button.setAttribute("aria-current", button.dataset.tab === name ? "page" : "false"),
|
||||
);
|
||||
$("editor-title").textContent =
|
||||
{
|
||||
guided: "Guided design",
|
||||
foundations: "Foundations",
|
||||
components: "Components",
|
||||
source: "Source & import",
|
||||
history: "History",
|
||||
review: "Review & apply",
|
||||
}[name] ?? name;
|
||||
}
|
||||
function renderTokens() {
|
||||
const layer = value("token-layer");
|
||||
const [kind, name] = layer.split(":");
|
||||
const override =
|
||||
kind === "mode"
|
||||
? session.valid.modes?.[name!]
|
||||
: kind === "density"
|
||||
? session.valid.densities?.[name!]
|
||||
: undefined;
|
||||
const tokens = { ...session.valid.tokens, ...override };
|
||||
const selected = selection();
|
||||
const resolved = session.resolved({
|
||||
...selected,
|
||||
...(kind === "mode" ? { mode: name } : kind === "density" ? { density: name } : {}),
|
||||
});
|
||||
const search = value("token-search").toLowerCase();
|
||||
const filtered = Object.entries(tokens).filter(([key, token]) =>
|
||||
`${key} ${token.type} ${token.legacy ?? ""}`.toLowerCase().includes(search),
|
||||
);
|
||||
$("tokens").innerHTML =
|
||||
filtered
|
||||
.map(([key, token]) => {
|
||||
const actual = resolved.tokens[key];
|
||||
const raw = typeof token.value === "object" ? "@" + token.value.ref : token.value;
|
||||
return `<div class="token"><label><span class="token-name">${escape(key)}</span><input data-token="${escape(key)}" value="${escape(raw)}" ${session.diagnostics.length ? "disabled" : ""}></label><small>${escape(token.type)} · ${escape(actual?.value ?? "unresolved")}<br>Source: ${escape(actual?.source ?? "")}${actual?.reference ? ` → ${escape(actual.reference)}` : ""}</small><label>Legacy token suffix<input data-binding="${escape(key)}" placeholder="e.g. radius-sm" value="${escape(token.legacy ?? "")}" ${session.diagnostics.length ? "disabled" : ""}></label><button data-save-token="${escape(key)}" ${session.diagnostics.length ? "disabled" : ""}>Save token</button><button data-delete-token="${escape(key)}" ${session.diagnostics.length ? "disabled" : ""}>Remove ${kind === "base" ? "token" : "override"}</button></div>`;
|
||||
})
|
||||
.join("") || "<p>No tokens match this search.</p>";
|
||||
}
|
||||
function tokenMap(system: DesignSystem) {
|
||||
const [kind, name] = value("token-layer").split(":");
|
||||
if (kind === "mode") return (system.modes![name!] ??= {});
|
||||
if (kind === "density") return (system.densities![name!] ??= {});
|
||||
return system.tokens;
|
||||
}
|
||||
function recipeContract(): ComponentDesignContract {
|
||||
return COMPONENT_CONTRACTS[
|
||||
value("component") as keyof typeof COMPONENT_CONTRACTS
|
||||
] as unknown as ComponentDesignContract;
|
||||
}
|
||||
function renderContract() {
|
||||
const contract = recipeContract();
|
||||
if (!contract) {
|
||||
$("contract").textContent = "No components match this search.";
|
||||
selectOptions("part", []);
|
||||
selectOptions("property", []);
|
||||
$<HTMLTextAreaElement>("recipe-source").value = "";
|
||||
for (const id of ["set-recipe", "remove-recipe", "save-recipe"])
|
||||
$(id).setAttribute("disabled", "");
|
||||
return;
|
||||
}
|
||||
for (const id of ["remove-recipe", "save-recipe"])
|
||||
$(id).toggleAttribute("disabled", !!session.diagnostics.length);
|
||||
$("contract").innerHTML =
|
||||
`<p><strong>${escape(value("component"))}</strong> · Contract version ${contract.contractVersion}</p><p>Axes: ${escape(
|
||||
Object.entries(contract.axes)
|
||||
.map(([name, axis]) => `${name}: ${axis.values.join(", ")}`)
|
||||
.join("; ") || "none",
|
||||
)}</p><p>${escape(contract.constraints.join(" "))}</p><details><summary>Ownership and limits</summary><pre>${escape(JSON.stringify(contract.regions ?? {}, null, 2))}</pre></details>`;
|
||||
selectOptions("part", Object.keys(contract.parts));
|
||||
renderProperties();
|
||||
$("recipe-source").textContent = JSON.stringify(
|
||||
session.valid.recipes?.[value("component")] ?? { contractVersion: 1, base: {} },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
$<HTMLTextAreaElement>("recipe-source").value = JSON.stringify(
|
||||
session.valid.recipes?.[value("component")] ?? { contractVersion: 1, base: {} },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
function renderProperties() {
|
||||
const contract = recipeContract();
|
||||
const part = contract?.parts[value("part")];
|
||||
selectOptions("property", part ? [...part.properties] : []);
|
||||
$("set-recipe").toggleAttribute(
|
||||
"disabled",
|
||||
!part?.properties.length || !!session.diagnostics.length,
|
||||
);
|
||||
const raw =
|
||||
session.valid.recipes?.[value("component")]?.base?.[value("part")]?.[value("property")];
|
||||
$<HTMLInputElement>("recipe-value").value =
|
||||
typeof raw === "object" ? "@" + raw.ref : String(raw ?? "");
|
||||
}
|
||||
function render() {
|
||||
$<HTMLSelectElement>("preset").value = Object.hasOwn(CORE_DESIGN_SYSTEMS, session.valid.id)
|
||||
? session.valid.id
|
||||
: "";
|
||||
$("system-name").setAttribute("value", session.valid.name);
|
||||
$<HTMLInputElement>("system-name").value = session.valid.name;
|
||||
$<HTMLInputElement>("system-id").value = session.valid.id;
|
||||
if (document.activeElement !== $("source-text"))
|
||||
$<HTMLTextAreaElement>("source-text").value = session.source;
|
||||
$("validation-state").textContent = session.diagnostics.length
|
||||
? "Draft has errors"
|
||||
: "Valid draft";
|
||||
$("diagnostics").textContent = session.diagnostics
|
||||
.map((d) => `${d.code} · ${d.path}: ${d.message}`)
|
||||
.join("\n");
|
||||
$("save-state").textContent = session.storageError || "Draft saved locally";
|
||||
$("undo").toggleAttribute("disabled", session.cursor === 0);
|
||||
$("redo").toggleAttribute("disabled", session.cursor === session.history.length - 1);
|
||||
selectOptions("preview-mode", Object.keys(session.valid.modes ?? {}), "Default");
|
||||
selectOptions("preview-density", Object.keys(session.valid.densities ?? {}), "Default");
|
||||
selectOptions("token-layer", [
|
||||
"base",
|
||||
...Object.keys(session.valid.modes ?? {}).map((n) => "mode:" + n),
|
||||
...Object.keys(session.valid.densities ?? {}).map((n) => "density:" + n),
|
||||
]);
|
||||
renderTokens();
|
||||
renderContract();
|
||||
$("revisions").innerHTML = session.history
|
||||
.map(
|
||||
(revision, i) =>
|
||||
`<li><button data-revision="${i}" ${i === session.cursor ? 'aria-current="step"' : ""}>${i + 1}. ${escape(revision.label)}${i === session.cursor ? " · current" : ""}</button></li>`,
|
||||
)
|
||||
.join("");
|
||||
const changes = session.changes();
|
||||
const details = reviewDesign(session.applied, session.valid);
|
||||
$("changes").textContent = changes.length
|
||||
? `${changes.length} changed paths\n${changes.some((path) => path.startsWith("$.tokens") || path.startsWith("$.modes") || path.startsWith("$.densities")) ? "Foundation changes may affect all 104 contracted components." : "Affected components: " + [...new Set(changes.filter((path) => path.startsWith("$.recipes.")).map((path) => path.split(".")[2]))].join(", ")}\n\n` +
|
||||
details
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.path}\nBefore: ${JSON.stringify(entry.before) ?? "(absent)"}\nAfter: ${JSON.stringify(entry.after) ?? "(removed)"}`,
|
||||
)
|
||||
.join("\n\n")
|
||||
: "No changes from the last applied/opened design.";
|
||||
$("apply").toggleAttribute("disabled", !application.enabled || !!session.diagnostics.length);
|
||||
$("rollback").toggleAttribute("disabled", !application.previous);
|
||||
$("target").textContent = application.enabled
|
||||
? `Local folder: ${application.directory}\nCurrent revision: ${application.revision}`
|
||||
: "Export-only workspace. Enable local apply by starting the Studio with --app-root <existing application folder>.";
|
||||
tab(activeTab);
|
||||
guidance?.render();
|
||||
}
|
||||
async function previews() {
|
||||
const generation = ++previewGeneration;
|
||||
const settings = {
|
||||
...selection(),
|
||||
direction: value("preview-direction"),
|
||||
content: value("preview-content"),
|
||||
page,
|
||||
};
|
||||
const comparison = value("compare");
|
||||
$("comparison").hidden = !comparison;
|
||||
const systems = [
|
||||
session.valid,
|
||||
...(comparison
|
||||
? [
|
||||
comparison === "applied"
|
||||
? session.applied
|
||||
: CORE_DESIGN_SYSTEMS[comparison as keyof typeof CORE_DESIGN_SYSTEMS],
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const result = await Promise.all(
|
||||
systems.map((system) =>
|
||||
request("/api/preview", {
|
||||
system,
|
||||
...settings,
|
||||
...(system !== session.valid
|
||||
? {
|
||||
mode: Object.hasOwn(system!.modes ?? {}, settings.mode ?? "")
|
||||
? settings.mode
|
||||
: undefined,
|
||||
density: Object.hasOwn(system!.densities ?? {}, settings.density ?? "")
|
||||
? settings.density
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (generation !== previewGeneration) return;
|
||||
$<HTMLIFrameElement>("preview-frame").src = result[0].url;
|
||||
if (comparison) $<HTMLIFrameElement>("compare-frame").src = result[1].url;
|
||||
for (const id of ["preview-frame", "compare-frame"]) $(id).style.width = value("preview-width");
|
||||
$("preview-label").textContent = session.diagnostics.length
|
||||
? "Last valid draft · source has errors"
|
||||
: "Current draft";
|
||||
}
|
||||
async function changed() {
|
||||
render();
|
||||
await previews();
|
||||
}
|
||||
function raw(input: string): string | number | { ref: string } {
|
||||
if (input.startsWith("@")) return { ref: input.slice(1) };
|
||||
return input;
|
||||
}
|
||||
function tokenValue(type: DesignToken["type"], input: string) {
|
||||
return !input.startsWith("@") && input.trim() && ["number", "fontWeight"].includes(type)
|
||||
? Number(input)
|
||||
: raw(input);
|
||||
}
|
||||
async function applicationState() {
|
||||
const state = await request("/api/application");
|
||||
application = state;
|
||||
if (state.system) {
|
||||
session.applied = structuredClone(state.system);
|
||||
session.persist();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-tab]").forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
flushSource();
|
||||
tab(button.dataset.tab!);
|
||||
if (button.dataset.tab === "review") applicationState().catch((e) => message(e.message));
|
||||
}),
|
||||
);
|
||||
on("undo", async () => {
|
||||
session.undo();
|
||||
await changed();
|
||||
});
|
||||
on("redo", async () => {
|
||||
session.redo();
|
||||
await changed();
|
||||
});
|
||||
on("new", async () => {
|
||||
session.replace(
|
||||
{ schemaVersion: 1, id: "my-design", name: "My design", version: "1.0.0", tokens: {} },
|
||||
"Created new system",
|
||||
);
|
||||
await changed();
|
||||
});
|
||||
on(
|
||||
"preset",
|
||||
async () => {
|
||||
if (!value("preset")) return;
|
||||
session.replace(
|
||||
CORE_DESIGN_SYSTEMS[value("preset") as keyof typeof CORE_DESIGN_SYSTEMS],
|
||||
"Selected " + value("preset"),
|
||||
);
|
||||
await changed();
|
||||
},
|
||||
"change",
|
||||
);
|
||||
on("duplicate", async () => {
|
||||
const system = structuredClone(session.valid);
|
||||
system.id += "-copy";
|
||||
system.name += " copy";
|
||||
session.replace(system, "Duplicated system");
|
||||
await changed();
|
||||
});
|
||||
for (const [id, field] of [
|
||||
["system-name", "name"],
|
||||
["system-id", "id"],
|
||||
] as const)
|
||||
on(
|
||||
id,
|
||||
async () => {
|
||||
const next = value(id);
|
||||
session.mutate((s) => {
|
||||
s[field] = next;
|
||||
}, "Changed " + field);
|
||||
await changed();
|
||||
},
|
||||
"change",
|
||||
);
|
||||
on("token-search", renderTokens, "input");
|
||||
on("token-layer", renderTokens, "change");
|
||||
$("tokens").addEventListener("change", (event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
try {
|
||||
session.mutate(
|
||||
(system) => {
|
||||
const map = tokenMap(system);
|
||||
const name = input.dataset.token ?? input.dataset.binding!;
|
||||
const token = map[name] ?? structuredClone(system.tokens[name]!);
|
||||
if (!token) throw Error("Token not found.");
|
||||
map[name] = token;
|
||||
if (input.dataset.token) token.value = tokenValue(token.type, input.value);
|
||||
else if (input.value) token.legacy = input.value;
|
||||
else delete token.legacy;
|
||||
},
|
||||
"Edited token " + (input.dataset.token ?? input.dataset.binding),
|
||||
);
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (error) {
|
||||
message((error as Error).message);
|
||||
}
|
||||
});
|
||||
$("tokens").addEventListener("click", (event) => {
|
||||
const save = (event.target as HTMLElement).dataset.saveToken;
|
||||
if (save) {
|
||||
const parent = (event.target as HTMLElement).closest(".token")!;
|
||||
const input = parent.querySelector<HTMLInputElement>("[data-token]")!;
|
||||
const binding = parent.querySelector<HTMLInputElement>("[data-binding]")!;
|
||||
const inputValue = input.value,
|
||||
bindingValue = binding.value;
|
||||
try {
|
||||
session.mutate((system) => {
|
||||
const map = tokenMap(system);
|
||||
const token = map[save] ?? structuredClone(system.tokens[save]!);
|
||||
token.value = tokenValue(token.type, inputValue);
|
||||
if (bindingValue) token.legacy = bindingValue;
|
||||
else delete token.legacy;
|
||||
map[save] = token;
|
||||
}, "Edited token " + save);
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (error) {
|
||||
message((error as Error).message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const name = (event.target as HTMLElement).dataset.deleteToken;
|
||||
if (!name) return;
|
||||
try {
|
||||
session.mutate((s) => {
|
||||
delete tokenMap(s)[name];
|
||||
}, "Removed " + name);
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (error) {
|
||||
message((error as Error).message);
|
||||
}
|
||||
});
|
||||
$<HTMLSelectElement>("add-token-type").innerHTML = TOKEN_TYPES.map((t) => option(t)).join("");
|
||||
on("add-token", async () => {
|
||||
session.mutate((s) => {
|
||||
const name = value("add-token-name");
|
||||
if (
|
||||
!/^[A-Za-z][\w.-]*$/.test(name) ||
|
||||
name.split(/[.-]/).some((p) => ["__proto__", "constructor", "prototype"].includes(p))
|
||||
)
|
||||
throw Error("Choose a valid token name.");
|
||||
const map = tokenMap(s);
|
||||
if (Object.hasOwn(map, name)) throw Error("Token already exists.");
|
||||
map[name] = {
|
||||
type: value("add-token-type") as DesignToken["type"],
|
||||
value: tokenValue(value("add-token-type") as DesignToken["type"], value("add-token-value")),
|
||||
};
|
||||
}, "Added token");
|
||||
await changed();
|
||||
});
|
||||
for (const kind of ["mode", "density"] as const)
|
||||
on("add-" + kind, async () => {
|
||||
session.mutate((s) => {
|
||||
const name = value("add-layer-name");
|
||||
if (
|
||||
!/^[A-Za-z][\w-]*$/.test(name) ||
|
||||
["constructor", "prototype", "__proto__"].includes(name)
|
||||
)
|
||||
throw Error("Choose a valid layer name.");
|
||||
const layers = kind === "mode" ? (s.modes ??= {}) : (s.densities ??= {});
|
||||
if (Object.hasOwn(layers, name)) throw Error("Layer already exists.");
|
||||
layers[name] = {};
|
||||
}, "Added " + kind);
|
||||
await changed();
|
||||
});
|
||||
on(
|
||||
"component-search",
|
||||
() => {
|
||||
const filter = value("component-search").toLowerCase();
|
||||
selectOptions(
|
||||
"component",
|
||||
Object.keys(COMPONENT_CONTRACTS).filter((n) => n.toLowerCase().includes(filter)),
|
||||
);
|
||||
renderContract();
|
||||
},
|
||||
"input",
|
||||
);
|
||||
selectOptions("component", Object.keys(COMPONENT_CONTRACTS));
|
||||
on("component", renderContract, "change");
|
||||
on("part", renderProperties, "change");
|
||||
on("property", renderProperties, "change");
|
||||
on("set-recipe", async () => {
|
||||
session.mutate((s) => {
|
||||
const recipe = ((s.recipes ??= {})[value("component")] ??= { contractVersion: 1 });
|
||||
const part = ((recipe.base ??= {})[value("part")] ??= {});
|
||||
part[value("property")] = raw(value("recipe-value"));
|
||||
}, "Changed component default");
|
||||
await changed();
|
||||
});
|
||||
on("remove-recipe", async () => {
|
||||
session.mutate((s) => {
|
||||
const part = s.recipes?.[value("component")]?.base?.[value("part")];
|
||||
if (part) delete part[value("property")];
|
||||
}, "Removed component default");
|
||||
await changed();
|
||||
});
|
||||
on("save-recipe", async () => {
|
||||
const recipe = JSON.parse(value("recipe-source"));
|
||||
session.mutate((s) => {
|
||||
(s.recipes ??= {})[value("component")] = recipe;
|
||||
}, "Edited component recipe");
|
||||
await changed();
|
||||
});
|
||||
on("save-source", async () => {
|
||||
session.edit(value("source-text"));
|
||||
await changed();
|
||||
});
|
||||
let sourceTimer: ReturnType<typeof setTimeout>;
|
||||
$("source-text").addEventListener("input", () => {
|
||||
clearTimeout(sourceTimer);
|
||||
sourceTimer = setTimeout(() => {
|
||||
try {
|
||||
session.edit(value("source-text"));
|
||||
changed().catch((e) => message(e.message));
|
||||
} catch (e) {
|
||||
message((e as Error).message);
|
||||
}
|
||||
}, 600);
|
||||
});
|
||||
on(
|
||||
"import-file",
|
||||
async () => {
|
||||
const file = $<HTMLInputElement>("import-file").files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2_000_000) throw Error("File exceeds 2 MB.");
|
||||
$<HTMLTextAreaElement>("import-text").value = await file.text();
|
||||
importCandidate = undefined;
|
||||
$("accept-import").setAttribute("disabled", "");
|
||||
},
|
||||
"change",
|
||||
);
|
||||
on("inspect-import", () => {
|
||||
const report = importDesign(
|
||||
value("import-text"),
|
||||
value("import-format") as "canonical" | "legacy" | "tokens",
|
||||
);
|
||||
importCandidate = report.system;
|
||||
$("import-report").textContent = JSON.stringify(
|
||||
{
|
||||
...report,
|
||||
system: report.system ? { id: report.system.id, name: report.system.name } : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
$("accept-import").toggleAttribute("disabled", !importCandidate);
|
||||
});
|
||||
for (const id of ["import-text", "import-format"])
|
||||
on(
|
||||
id,
|
||||
() => {
|
||||
importCandidate = undefined;
|
||||
$("accept-import").setAttribute("disabled", "");
|
||||
},
|
||||
id === "import-text" ? "input" : "change",
|
||||
);
|
||||
on("accept-import", async () => {
|
||||
if (!importCandidate) throw Error("Inspect the current import first.");
|
||||
session.replace(importCandidate, "Imported design");
|
||||
await changed();
|
||||
message("Imported design. Review mapping warnings before applying.");
|
||||
});
|
||||
on("download-draft", () => download(value("source-text"), "design-draft.json"));
|
||||
on("export", () => {
|
||||
if (session.diagnostics.length)
|
||||
throw Error("Resolve errors or export the raw draft from Source & import.");
|
||||
download(exportDesign(session.valid, selection()).canonical, session.valid.id + ".json");
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-export]").forEach((button) =>
|
||||
button.addEventListener("click", () => {
|
||||
try {
|
||||
if (session.diagnostics.length) throw Error("Resolve draft errors before exporting.");
|
||||
const kind = button.dataset.export as keyof ReturnType<typeof exportDesign>;
|
||||
download(
|
||||
exportDesign(session.valid, selection())[kind],
|
||||
`${session.valid.id}-${kind}.${kind === "css" ? "css" : "json"}`,
|
||||
kind === "css" ? "text/css" : "application/json",
|
||||
);
|
||||
} catch (e) {
|
||||
message((e as Error).message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
on("review", async () => {
|
||||
tab("review");
|
||||
await applicationState();
|
||||
});
|
||||
on("apply", async () => {
|
||||
if (session.diagnostics.length) throw Error("Resolve draft errors before applying.");
|
||||
const submitted = structuredClone(session.valid);
|
||||
const result = await request("/api/apply", {
|
||||
system: submitted,
|
||||
base: application.revision,
|
||||
...selection(),
|
||||
});
|
||||
session.applied = submitted;
|
||||
session.persist();
|
||||
const { directory: _bundleDirectory, ...revisionState } = result;
|
||||
application = { ...application, ...revisionState };
|
||||
$("integration").textContent =
|
||||
`Applied to ${result.directory}\n\nImport theme from ${result.directory.replaceAll("\\", "/")}/theme.json\nand set theme in your existing WrNexus app configuration.\n\nCurrent pointer: ${application.directory}/CURRENT.json\nNo application configuration was overwritten.`;
|
||||
render();
|
||||
message("Applied validated design to the local folder.");
|
||||
});
|
||||
on("rollback", async () => {
|
||||
await request("/api/rollback", { base: application.revision });
|
||||
await applicationState();
|
||||
message("Application pointer restored to its previous revision. Your draft is preserved.");
|
||||
});
|
||||
$("revisions").addEventListener("click", (event) => {
|
||||
const index = (event.target as HTMLElement).dataset.revision;
|
||||
if (index === undefined) return;
|
||||
while (session.cursor > Number(index)) session.undo();
|
||||
while (session.cursor < Number(index)) session.redo();
|
||||
changed().catch((e) => message(e.message));
|
||||
});
|
||||
for (const id of [
|
||||
"preview-mode",
|
||||
"preview-density",
|
||||
"preview-width",
|
||||
"preview-direction",
|
||||
"preview-content",
|
||||
"compare",
|
||||
])
|
||||
on(
|
||||
id,
|
||||
async () => {
|
||||
renderTokens();
|
||||
await previews();
|
||||
},
|
||||
"change",
|
||||
);
|
||||
// Gallery pagination belongs to the Studio so isolated frames never navigate the workspace.
|
||||
const pagination = document.createElement("div");
|
||||
pagination.className = "row";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = String(i + 1);
|
||||
button.setAttribute("aria-label", `Gallery page ${i + 1}`);
|
||||
button.addEventListener("click", () => {
|
||||
page = i;
|
||||
$<HTMLSelectElement>("preview-content").value = "catalog";
|
||||
previews().catch((e) => message(e.message));
|
||||
});
|
||||
pagination.append(button);
|
||||
}
|
||||
$("preview-content").closest(".preview-controls")!.after(pagination);
|
||||
on("refresh-preview", previews);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key.toLowerCase() === "z" &&
|
||||
!["INPUT", "TEXTAREA"].includes((event.target as HTMLElement).tagName)
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) session.redo();
|
||||
else session.undo();
|
||||
changed().catch((e) => message(e.message));
|
||||
}
|
||||
});
|
||||
window.addEventListener("beforeunload", () => {
|
||||
clearTimeout(sourceTimer);
|
||||
if (activeTab === "source" && value("source-text") !== session.source) {
|
||||
try {
|
||||
session.edit(value("source-text"));
|
||||
} catch {
|
||||
/* Existing local draft remains recoverable. */
|
||||
}
|
||||
} else session.persist();
|
||||
});
|
||||
const guidance = setupGuidance(session, {
|
||||
message,
|
||||
changed,
|
||||
preview: async (system) => (await request("/api/preview", { system, content: "composed" })).url,
|
||||
});
|
||||
render();
|
||||
applicationState()
|
||||
.then(previews)
|
||||
.catch((e) => message(e.message));
|
||||
@@ -0,0 +1,214 @@
|
||||
import { parse } from "@wrnexus/compiler";
|
||||
import { COMPONENT_CONTRACTS } from "@wrnexus/design-system";
|
||||
import reference from "@wrnexus/ui/component-reference.json";
|
||||
|
||||
export interface ConformanceDiagnostic {
|
||||
code: string;
|
||||
path: string;
|
||||
message: string;
|
||||
severity: "error" | "warning";
|
||||
exception?: string;
|
||||
}
|
||||
export interface ConformanceException {
|
||||
code: string;
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
export function checkDesignConformance(source: string, exceptions: ConformanceException[] = []) {
|
||||
const diagnostics: ConformanceDiagnostic[] = [];
|
||||
const add = (
|
||||
code: string,
|
||||
path: string,
|
||||
message: string,
|
||||
severity: "error" | "warning" = "error",
|
||||
) => diagnostics.push({ code, path, message, severity });
|
||||
if (typeof source !== "string" || source.length > 128000)
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "WRN-CONFORMANCE-LIMIT",
|
||||
path: "$",
|
||||
message: "Source must be at most 128,000 characters.",
|
||||
severity: "error" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (
|
||||
!Array.isArray(exceptions) ||
|
||||
exceptions.length > 64 ||
|
||||
exceptions.some(
|
||||
(e) =>
|
||||
!e ||
|
||||
typeof e.code !== "string" ||
|
||||
typeof e.path !== "string" ||
|
||||
typeof e.reason !== "string" ||
|
||||
!e.reason.trim() ||
|
||||
e.reason.length > 500,
|
||||
)
|
||||
)
|
||||
throw Error(
|
||||
"WRN-CONFORMANCE-EXCEPTION: provide up to 64 exact code/path exceptions with reasons.",
|
||||
);
|
||||
let ast: ReturnType<typeof parse>;
|
||||
try {
|
||||
ast = parse(source);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "WRN-CONFORMANCE-PARSE",
|
||||
path: "$",
|
||||
message: (error as Error).message,
|
||||
severity: "error" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
let count = 0;
|
||||
function css(text: string, path: string) {
|
||||
const withoutVars = text.replace(/var\(\s*--wrn-[\w-]+(?:\s*,[^)]*)?\)/g, "TOKEN");
|
||||
if (/#[a-f\d]{3,8}\b|\b(?:rgb|hsl)a?\(/i.test(withoutVars))
|
||||
add(
|
||||
"WRN-CONFORMANCE-COLOR",
|
||||
path,
|
||||
"Literal color bypasses the system. Prefer a semantic --wrn-* token.",
|
||||
"warning",
|
||||
);
|
||||
if (
|
||||
/(?:font-family|font-size|border-radius|box-shadow|padding(?:-\w+)?|margin(?:-\w+)?|gap)\s*:[^;}]*?(?:\d+(?:\.\d+)?(?:px|rem|em)|["'][A-Za-z])/i.test(
|
||||
withoutVars,
|
||||
)
|
||||
)
|
||||
add(
|
||||
"WRN-CONFORMANCE-FOUNDATION",
|
||||
path,
|
||||
"Literal typography, spacing, shape or elevation bypasses a foundation token.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
function walk(value: unknown, path: string) {
|
||||
if (++count > 20000) throw Error("AST traversal limit exceeded.");
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((node, index) => walk(node, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
const node = value as {
|
||||
type?: string;
|
||||
tag?: string;
|
||||
attrs?: Array<{ name: string; value: string; event?: boolean }>;
|
||||
};
|
||||
if (node.type === "element" && node.tag) {
|
||||
const mount = node.attrs?.find((a) => a.name === "data-component");
|
||||
const name = mount?.value ?? (/^[A-Z]/.test(node.tag) ? node.tag : undefined);
|
||||
if (name) {
|
||||
if (name.includes("{"))
|
||||
add(
|
||||
"WRN-CONFORMANCE-DYNAMIC",
|
||||
path,
|
||||
"Dynamic component mount cannot be verified.",
|
||||
"warning",
|
||||
);
|
||||
else if (!Object.hasOwn(COMPONENT_CONTRACTS, name))
|
||||
add(
|
||||
"WRN-CONFORMANCE-COMPONENT",
|
||||
path,
|
||||
`No contract for '${name}'. Custom components require an explicit reviewed exception.`,
|
||||
"warning",
|
||||
);
|
||||
else {
|
||||
const contract = COMPONENT_CONTRACTS[name as keyof typeof COMPONENT_CONTRACTS];
|
||||
const meta = reference.components.find((c) => c.name === name)!;
|
||||
for (const attr of node.attrs ?? []) {
|
||||
if (attr.event) {
|
||||
if (!meta.outputs.some((output) => output.name === attr.name))
|
||||
add(
|
||||
"WRN-CONFORMANCE-EVENT",
|
||||
path + "." + attr.name,
|
||||
`Unknown ${name} event '${attr.name}'.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
["data-component", "slot"].includes(attr.name) ||
|
||||
attr.name.startsWith("aria-") ||
|
||||
attr.name.startsWith("data-") ||
|
||||
attr.name.startsWith("@")
|
||||
)
|
||||
continue;
|
||||
const prop = meta.props.find((p) => p.name === attr.name);
|
||||
if (!prop && !["class", "attrs"].includes(attr.name))
|
||||
add(
|
||||
"WRN-CONFORMANCE-PROP",
|
||||
path + "." + attr.name,
|
||||
`Unknown ${name} prop '${attr.name}'.`,
|
||||
);
|
||||
const axis = (contract.axes as Record<string, { values: readonly string[] }>)[
|
||||
attr.name
|
||||
];
|
||||
if (axis) {
|
||||
let literal = attr.value;
|
||||
if (literal.startsWith("{")) {
|
||||
try {
|
||||
literal = String(JSON.parse(literal.slice(1, -1)));
|
||||
} catch {
|
||||
add(
|
||||
"WRN-CONFORMANCE-DYNAMIC",
|
||||
path + "." + attr.name,
|
||||
"Dynamic axis requires runtime validation.",
|
||||
"warning",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!axis.values.includes(literal))
|
||||
add(
|
||||
"WRN-CONFORMANCE-AXIS",
|
||||
path + "." + attr.name,
|
||||
`Unsupported ${name}.${attr.name} '${literal}'. Allowed: ${axis.values.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const attr of node.attrs ?? []) {
|
||||
if (attr.name === "style") css(attr.value, path + ".style");
|
||||
if (
|
||||
attr.name === "class" &&
|
||||
/(?:^|\s)(?:bg|text|rounded|shadow|p[xytrbl]?|m[xytrbl]?|gap)-(?:\[|\d|(?:red|blue|green|gray)-)/.test(
|
||||
attr.value,
|
||||
)
|
||||
)
|
||||
add(
|
||||
"WRN-CONFORMANCE-UTILITY",
|
||||
path + ".class",
|
||||
"Utility styling may bypass tokens; use a token-backed class or document the exception.",
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(value))
|
||||
if (key !== "attrs") walk(child, `${path}.${key}`);
|
||||
}
|
||||
try {
|
||||
walk(ast.view, "$.view");
|
||||
ast.styles?.forEach((style, index) => css(style, `$.styles[${index}]`));
|
||||
} catch (error) {
|
||||
add("WRN-CONFORMANCE-LIMIT", "$", (error as Error).message);
|
||||
}
|
||||
for (const exception of exceptions) {
|
||||
const item = diagnostics.find(
|
||||
(d) => d.code === exception.code && d.path === exception.path && d.severity === "warning",
|
||||
);
|
||||
if (item) item.exception = exception.reason;
|
||||
else
|
||||
add(
|
||||
"WRN-CONFORMANCE-EXCEPTION",
|
||||
exception.path,
|
||||
"Exception does not match a warning; errors cannot be waived.",
|
||||
);
|
||||
}
|
||||
return { ok: !diagnostics.some((d) => !d.exception), diagnostics };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
resolveDesignSystem,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type DesignDiagnostic,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
export type DecisionLock = string;
|
||||
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
|
||||
export function decisionPath(path: string): string[] {
|
||||
if (typeof path !== "string" || path.length > 512 || !path.startsWith("/"))
|
||||
throw Error("WRN-STUDIO-LOCK-PATH: use a JSON pointer.");
|
||||
const parts = path
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((p) => {
|
||||
if (/~(?![01])/u.test(p)) throw Error("Invalid pointer escape.");
|
||||
return p.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
});
|
||||
if (
|
||||
!["tokens", "modes", "densities", "recipes", "name", "id", "version", "defaultMode"].includes(
|
||||
parts[0]!,
|
||||
) ||
|
||||
parts.some((p) => !p || forbidden.has(p)) ||
|
||||
parts.length > 8
|
||||
)
|
||||
throw Error("WRN-STUDIO-LOCK-PATH: unsupported decision path.");
|
||||
return parts;
|
||||
}
|
||||
export const pointerPart = (value: string) => value.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
export function readDecision(value: unknown, path: string): unknown {
|
||||
let current = value;
|
||||
for (const part of decisionPath(path)) {
|
||||
if (!current || typeof current !== "object" || !Object.hasOwn(current, part)) return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
export function writeDecision(system: DesignSystem, path: string, value: unknown) {
|
||||
const parts = decisionPath(path);
|
||||
let target = system as unknown as Record<string, unknown>;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!Object.hasOwn(target, part)) target[part] = {};
|
||||
if (!target[part] || typeof target[part] !== "object" || Array.isArray(target[part]))
|
||||
throw Error("WRN-STUDIO-PATCH: parent must be an object.");
|
||||
target = target[part] as Record<string, unknown>;
|
||||
}
|
||||
const last = parts.at(-1)!;
|
||||
if (value === undefined) delete target[last];
|
||||
else target[last] = structuredClone(value);
|
||||
}
|
||||
export function validateLocks(locks: unknown): asserts locks is DecisionLock[] {
|
||||
if (
|
||||
!Array.isArray(locks) ||
|
||||
locks.length > 128 ||
|
||||
locks.some((p) => typeof p !== "string") ||
|
||||
new Set(locks).size !== locks.length
|
||||
)
|
||||
throw Error("WRN-STUDIO-LOCKS: provide up to 128 unique decision paths.");
|
||||
for (const path of locks) decisionPath(path);
|
||||
}
|
||||
function references(value: unknown, result = new Set<string>()) {
|
||||
if (value && typeof value === "object") {
|
||||
if ("ref" in value && typeof value.ref === "string") result.add(value.ref);
|
||||
else for (const child of Object.values(value)) references(child, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function tokenNames(system: DesignSystem, path: string) {
|
||||
const [kind, layer, name] = decisionPath(path);
|
||||
const names = new Set<string>();
|
||||
if (kind === "tokens")
|
||||
for (const token of Object.keys(system.tokens)) if (!layer || token === layer) names.add(token);
|
||||
if (kind === "modes" || kind === "densities") {
|
||||
const layers = system[kind] ?? {};
|
||||
for (const [key, tokens] of Object.entries(layers))
|
||||
if (!layer || key === layer)
|
||||
for (const token of Object.keys(tokens)) if (!name || name === token) names.add(token);
|
||||
}
|
||||
if (kind === "recipes") references(readDecision(system, path), names);
|
||||
return names;
|
||||
}
|
||||
function configurations(a: DesignSystem, b: DesignSystem) {
|
||||
const modes = [
|
||||
undefined,
|
||||
...new Set([...Object.keys(a.modes ?? {}), ...Object.keys(b.modes ?? {})]),
|
||||
];
|
||||
const densities = [
|
||||
undefined,
|
||||
...new Set([...Object.keys(a.densities ?? {}), ...Object.keys(b.densities ?? {})]),
|
||||
];
|
||||
if (modes.length * densities.length > 128)
|
||||
throw Error("WRN-STUDIO-LOCK-LIMIT: too many lock configurations.");
|
||||
return modes.flatMap((mode) => densities.map((density) => ({ mode, density })));
|
||||
}
|
||||
/** Locks protect raw decisions and effective referenced values, including aliases and modes. */
|
||||
export function checkDecisionLocks(
|
||||
before: DesignSystem,
|
||||
after: DesignSystem,
|
||||
locks: DecisionLock[],
|
||||
): DesignDiagnostic[] {
|
||||
validateLocks(locks);
|
||||
if (!locks.length) return [];
|
||||
const diagnostics: DesignDiagnostic[] = [];
|
||||
const fail = (path: string, message: string) =>
|
||||
diagnostics.push({ code: "WRN-STUDIO-LOCK", path, message });
|
||||
const configurationsList = configurations(before, after);
|
||||
const resolve = (system: DesignSystem, mode?: string, density?: string) =>
|
||||
resolveDesignSystem(system, {
|
||||
mode: mode && Object.hasOwn(system.modes ?? {}, mode) ? mode : undefined,
|
||||
density: density && Object.hasOwn(system.densities ?? {}, density) ? density : undefined,
|
||||
}).tokens;
|
||||
const pairs = configurationsList.map(({ mode, density }) => ({
|
||||
mode,
|
||||
density,
|
||||
before: resolve(before, mode, density),
|
||||
after: resolve(after, mode, density),
|
||||
}));
|
||||
for (const path of locks) {
|
||||
if (JSON.stringify(readDecision(before, path)) !== JSON.stringify(readDecision(after, path))) {
|
||||
fail(path, "This decision is locked. Unlock it before changing or removing it.");
|
||||
continue;
|
||||
}
|
||||
const names = tokenNames(before, path);
|
||||
let changed = false;
|
||||
for (const pair of pairs) {
|
||||
const parts = decisionPath(path);
|
||||
if (parts[0] === "modes" && parts[1] && pair.mode !== parts[1]) continue;
|
||||
if (parts[0] === "densities" && parts[1] && pair.density !== parts[1]) continue;
|
||||
for (const name of names)
|
||||
if (
|
||||
JSON.stringify({ type: pair.before[name]?.type, value: pair.before[name]?.value }) !==
|
||||
JSON.stringify({ type: pair.after[name]?.type, value: pair.after[name]?.value })
|
||||
) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
if (changed) break;
|
||||
}
|
||||
if (changed)
|
||||
fail(path, "A referenced token or mode changes the effective value of this locked decision.");
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
/** Local directions copy lock dependencies; arbitrary provider patches must pass checks unchanged. */
|
||||
export function preserveDecisionLocks(
|
||||
before: DesignSystem,
|
||||
after: DesignSystem,
|
||||
locks: DecisionLock[],
|
||||
) {
|
||||
validateLocks(locks);
|
||||
const next = structuredClone(after);
|
||||
const names = new Set<string>();
|
||||
for (const path of locks) {
|
||||
writeDecision(next, path, readDecision(before, path));
|
||||
for (const name of tokenNames(before, path)) names.add(name);
|
||||
}
|
||||
// The default selection is part of an effective token/recipe decision.
|
||||
if (names.size) next.defaultMode = before.defaultMode;
|
||||
for (const name of names) {
|
||||
for (const token of [
|
||||
before.tokens[name],
|
||||
...Object.values(before.modes ?? {}).map((t) => t[name]),
|
||||
...Object.values(before.densities ?? {}).map((t) => t[name]),
|
||||
])
|
||||
for (const ref of references(token)) names.add(ref);
|
||||
writeDecision(next, "/tokens/" + pointerPart(name), before.tokens[name]);
|
||||
for (const kind of ["modes", "densities"] as const)
|
||||
for (const layer of new Set([
|
||||
...Object.keys(before[kind] ?? {}),
|
||||
...Object.keys(next[kind] ?? {}),
|
||||
]))
|
||||
writeDecision(
|
||||
next,
|
||||
`/${kind}/${pointerPart(layer)}/${pointerPart(name)}`,
|
||||
before[kind]?.[layer]?.[name],
|
||||
);
|
||||
}
|
||||
if (validateDesignSystem(next).length)
|
||||
throw Error("WRN-STUDIO-LOCK: locked decisions cannot be combined with this direction.");
|
||||
const failures = checkDecisionLocks(before, next, locks);
|
||||
if (failures.length) throw Error(failures.map((d) => d.message).join(" "));
|
||||
return next;
|
||||
}
|
||||
export async function designRevision(system: DesignSystem, locks: DecisionLock[] = []) {
|
||||
validateLocks(locks);
|
||||
const bytes = new TextEncoder().encode(JSON.stringify({ system, locks: [...locks].sort() }));
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import {
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
COMPONENT_CONTRACTS,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type DesignDiagnostic,
|
||||
type TokenMap,
|
||||
} from "@wrnexus/design-system";
|
||||
import {
|
||||
checkDecisionLocks,
|
||||
preserveDecisionLocks,
|
||||
designRevision,
|
||||
validateLocks,
|
||||
writeDecision,
|
||||
decisionPath,
|
||||
type DecisionLock,
|
||||
} from "./decisions.ts";
|
||||
import { diffDesign } from "./session.ts";
|
||||
|
||||
export interface DesignBrief {
|
||||
product: string;
|
||||
audience: string;
|
||||
tone: "restrained" | "warm" | "expressive";
|
||||
geometry: "square" | "rounded" | "angular";
|
||||
density: "compact" | "comfortable" | "spacious";
|
||||
surface: "flat" | "soft" | "hard";
|
||||
brandColor?: string;
|
||||
references?: string;
|
||||
constraints?: string;
|
||||
}
|
||||
export interface DesignProposal {
|
||||
id: string;
|
||||
name: string;
|
||||
rationale: string;
|
||||
system: DesignSystem;
|
||||
changedPaths: string[];
|
||||
}
|
||||
export interface GenerationRequest {
|
||||
system: DesignSystem;
|
||||
locks: DecisionLock[];
|
||||
brief: DesignBrief;
|
||||
baseRevision: string;
|
||||
instruction?: string;
|
||||
kind: "directions" | "edit";
|
||||
}
|
||||
export interface GenerationBatch {
|
||||
brief: DesignBrief;
|
||||
locks: DecisionLock[];
|
||||
instruction?: string;
|
||||
baseRevision: string;
|
||||
alternatives: DesignProposal[];
|
||||
provenance: {
|
||||
provider: string;
|
||||
model?: string;
|
||||
templateVersion: 1;
|
||||
attempts: number;
|
||||
maxOutputTokens: number;
|
||||
createdAt: string;
|
||||
};
|
||||
diagnostics: DesignDiagnostic[];
|
||||
}
|
||||
export interface GenerationProvider {
|
||||
name: string;
|
||||
generate(
|
||||
prompt: string,
|
||||
options?: { system?: string; model?: string; maxTokens?: number; signal?: AbortSignal },
|
||||
): Promise<{ value: string; provider: string; model?: string }>;
|
||||
}
|
||||
export const DEFAULT_DESIGN_BRIEF: DesignBrief = {
|
||||
product: "",
|
||||
audience: "",
|
||||
tone: "restrained",
|
||||
geometry: "square",
|
||||
density: "comfortable",
|
||||
surface: "flat",
|
||||
};
|
||||
const diagnostic = (code: string, message: string, path = "$"): DesignDiagnostic => ({
|
||||
code,
|
||||
path,
|
||||
message,
|
||||
});
|
||||
export function validateGenerationRequest(input: unknown): asserts input is GenerationRequest {
|
||||
if (!input || typeof input !== "object")
|
||||
throw Error("WRN-STUDIO-GENERATION: request must be an object.");
|
||||
const r = input as GenerationRequest;
|
||||
if (validateDesignSystem(r.system).length)
|
||||
throw Error("WRN-STUDIO-GENERATION: source design must validate.");
|
||||
validateLocks(r.locks);
|
||||
if (
|
||||
!["directions", "edit"].includes(r.kind) ||
|
||||
typeof r.baseRevision !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(r.baseRevision)
|
||||
)
|
||||
throw Error("WRN-STUDIO-GENERATION: invalid kind or revision.");
|
||||
const b = r.brief;
|
||||
if (
|
||||
b &&
|
||||
typeof b === "object" &&
|
||||
Object.keys(b).some(
|
||||
(name) =>
|
||||
![
|
||||
"product",
|
||||
"audience",
|
||||
"tone",
|
||||
"geometry",
|
||||
"density",
|
||||
"surface",
|
||||
"brandColor",
|
||||
"references",
|
||||
"constraints",
|
||||
].includes(name),
|
||||
)
|
||||
)
|
||||
throw Error("WRN-STUDIO-BRIEF: unknown brief field.");
|
||||
if (
|
||||
!b ||
|
||||
typeof b !== "object" ||
|
||||
!["restrained", "warm", "expressive"].includes(b.tone) ||
|
||||
!["square", "rounded", "angular"].includes(b.geometry) ||
|
||||
!["compact", "comfortable", "spacious"].includes(b.density) ||
|
||||
!["flat", "soft", "hard"].includes(b.surface)
|
||||
)
|
||||
throw Error("WRN-STUDIO-BRIEF: choose valid design options.");
|
||||
for (const name of ["product", "audience", "references", "constraints"] as const)
|
||||
if (b[name] !== undefined && (typeof b[name] !== "string" || b[name]!.length > 2000))
|
||||
throw Error("WRN-STUDIO-BRIEF: text fields are limited to 2000 characters.");
|
||||
if (typeof b.product !== "string" || typeof b.audience !== "string")
|
||||
throw Error("WRN-STUDIO-BRIEF: product and audience must be text.");
|
||||
if (b.brandColor && !/^#[a-f0-9]{6}$/i.test(b.brandColor))
|
||||
throw Error("WRN-STUDIO-BRIEF: brand color must be #RRGGBB.");
|
||||
if (
|
||||
r.instruction !== undefined &&
|
||||
(typeof r.instruction !== "string" || r.instruction.length > 2000)
|
||||
)
|
||||
throw Error("WRN-STUDIO-INSTRUCTION: enter up to 2000 characters.");
|
||||
if (
|
||||
r.kind === "edit" &&
|
||||
(typeof r.instruction !== "string" || !r.instruction.trim() || r.instruction.length > 2000)
|
||||
)
|
||||
throw Error("WRN-STUDIO-INSTRUCTION: enter up to 2000 characters.");
|
||||
}
|
||||
function color(system: DesignSystem, hex: string) {
|
||||
const rgb = hex
|
||||
.slice(1)
|
||||
.match(/../g)!
|
||||
.map((n) => parseInt(n, 16));
|
||||
const luminance = rgb
|
||||
.map((n) => {
|
||||
const c = n / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
})
|
||||
.reduce((s, c, i) => s + c * [0.2126, 0.7152, 0.0722][i]!, 0);
|
||||
const foreground = (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? "#000000" : "#ffffff";
|
||||
const shade = (factor: number) =>
|
||||
"#" +
|
||||
rgb
|
||||
.map((n) =>
|
||||
Math.round(n * factor)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("");
|
||||
for (const tokens of [system.tokens, ...Object.values(system.modes ?? {})])
|
||||
for (const [name, value] of Object.entries({
|
||||
"color.primary": hex,
|
||||
"color.primary-hover": shade(0.88),
|
||||
"color.primary-active": shade(0.75),
|
||||
"color.primary-contrast": foreground,
|
||||
})) {
|
||||
if (tokens[name]) tokens[name] = { ...tokens[name]!, value };
|
||||
}
|
||||
}
|
||||
function foundation(system: DesignSystem, name: string, value: string) {
|
||||
if (system.tokens[name]) system.tokens[name] = { ...system.tokens[name]!, value };
|
||||
}
|
||||
function applyGeometry(system: DesignSystem, geometry: DesignBrief["geometry"]) {
|
||||
foundation(system, "shape.control", geometry === "rounded" ? "16px" : "0");
|
||||
foundation(system, "shape.panel", geometry === "rounded" ? "24px" : "0");
|
||||
foundation(system, "shape.dialog", geometry === "rounded" ? "32px" : "0");
|
||||
foundation(system, "shape.base", geometry === "rounded" ? "24px" : "0");
|
||||
// Independent rotation preserves interaction transforms, matching the existing Angular contract.
|
||||
for (const [name, part, angle] of [
|
||||
["Button", "control", "-2deg"],
|
||||
["Card", "panel", "1deg"],
|
||||
] as const) {
|
||||
const recipe = system.recipes?.[name];
|
||||
if (!recipe) continue;
|
||||
const styles = ((recipe.base ??= {})[part] ??= {});
|
||||
styles.rotate = geometry === "angular" ? angle : "0deg";
|
||||
}
|
||||
}
|
||||
function applyDensity(system: DesignSystem, density: DesignBrief["density"]) {
|
||||
const compact = density === "compact",
|
||||
spacious = density === "spacious";
|
||||
foundation(system, "space.control", compact ? "0.625rem" : spacious ? "1.25rem" : "0.875rem");
|
||||
foundation(system, "space.panel", compact ? "0.75rem" : spacious ? "1.75rem" : "1.25rem");
|
||||
foundation(system, "space.page", compact ? "1rem" : spacious ? "2rem" : "1.5rem");
|
||||
foundation(system, "control.height", compact ? "2.25rem" : spacious ? "3rem" : "2.5rem");
|
||||
}
|
||||
function applySurface(system: DesignSystem, surface: DesignBrief["surface"]) {
|
||||
foundation(
|
||||
system,
|
||||
"elevation.panel",
|
||||
surface === "flat"
|
||||
? "none"
|
||||
: surface === "soft"
|
||||
? "0 8px 24px rgba(0, 0, 0, 0.12)"
|
||||
: "4px 4px 0 #000000",
|
||||
);
|
||||
}
|
||||
function mapLayers(
|
||||
base: Record<string, TokenMap> | undefined,
|
||||
extra: Record<string, TokenMap> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(base ?? {}), ...Object.keys(extra ?? {})])].map((name) => [
|
||||
name,
|
||||
{ ...base?.[name], ...extra?.[name] },
|
||||
]),
|
||||
);
|
||||
}
|
||||
function direction(
|
||||
request: GenerationRequest,
|
||||
preset: keyof typeof CORE_DESIGN_SYSTEMS,
|
||||
index: number,
|
||||
) {
|
||||
const template = CORE_DESIGN_SYSTEMS[preset];
|
||||
const system: DesignSystem = {
|
||||
...structuredClone(request.system),
|
||||
tokens: { ...structuredClone(request.system.tokens), ...structuredClone(template.tokens) },
|
||||
modes: mapLayers(request.system.modes, template.modes),
|
||||
recipes: { ...structuredClone(request.system.recipes), ...structuredClone(template.recipes) },
|
||||
};
|
||||
const desired =
|
||||
index === 0
|
||||
? request.brief.geometry
|
||||
: preset === "soft"
|
||||
? "rounded"
|
||||
: preset === "angular"
|
||||
? "angular"
|
||||
: "square";
|
||||
applyGeometry(system, desired);
|
||||
applyDensity(
|
||||
system,
|
||||
index === 0 ? request.brief.density : preset === "soft" ? "spacious" : "compact",
|
||||
);
|
||||
applySurface(
|
||||
system,
|
||||
index === 0
|
||||
? request.brief.surface
|
||||
: preset === "angular"
|
||||
? "hard"
|
||||
: preset === "soft"
|
||||
? "soft"
|
||||
: "flat",
|
||||
);
|
||||
if (request.brief.brandColor) color(system, request.brief.brandColor);
|
||||
return preserveDecisionLocks(request.system, system, request.locks);
|
||||
}
|
||||
function localEdit(request: GenerationRequest) {
|
||||
const text = request.instruction!.trim().toLowerCase();
|
||||
const system = structuredClone(request.system);
|
||||
const clauses = text.split(/\s*(?:;|,|\band\b)\s*/).filter(Boolean);
|
||||
if (clauses.length > 8) throw Error("Use at most eight local edit clauses.");
|
||||
for (const clause of clauses) {
|
||||
if (
|
||||
/^(?:make (?:the )?(?:buttons?|controls?|corners?) |use |set (?:the )?geometry (?:to )?)?(?:square|sharp)(?: corners?| buttons?| controls?)?$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
applyGeometry(system, "square");
|
||||
else if (
|
||||
/^(?:make (?:the )?(?:buttons?|controls?|corners?) |use |set (?:the )?geometry (?:to )?)?(?:rounded|round|rounder)(?: corners?| buttons?| controls?)?$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
applyGeometry(system, "rounded");
|
||||
else if (/^(?:tilt (?:the )?buttons?|use angular(?: buttons?| geometry)?)$/.test(clause))
|
||||
applyGeometry(system, "angular");
|
||||
else if (
|
||||
/^(?:(?:make (?:it |the layout )?|use )(?:more )?)?(compact|comfortable|spacious)(?: spacing| density| layout)?$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
applyDensity(
|
||||
system,
|
||||
clause.includes("compact")
|
||||
? "compact"
|
||||
: clause.includes("spacious")
|
||||
? "spacious"
|
||||
: "comfortable",
|
||||
);
|
||||
else if (/^(?:remove shadows|no shadows|use flat surfaces)$/.test(clause))
|
||||
applySurface(system, "flat");
|
||||
else if (/^(?:use soft shadows|add soft shadows)$/.test(clause)) applySurface(system, "soft");
|
||||
else if (/^(?:use hard shadows|add hard shadows)$/.test(clause)) applySurface(system, "hard");
|
||||
else if (
|
||||
/^(?:use |set (?:the )?(?:brand|primary)(?: color)? (?:to )?)(?:brand |primary |color )?#[a-f0-9]{6}$/.test(
|
||||
clause,
|
||||
)
|
||||
)
|
||||
color(system, clause.match(/#[a-f0-9]{6}/)![0]);
|
||||
else if (/^(?:use )?(serif|sans-serif|monospace)(?: typography| fonts?)?$/.test(clause)) {
|
||||
const font = clause.includes("sans-serif")
|
||||
? "Helvetica, Arial, sans-serif"
|
||||
: clause.includes("monospace")
|
||||
? '"Courier New", monospace'
|
||||
: "Georgia, serif";
|
||||
foundation(system, "type.body", font);
|
||||
foundation(system, "type.heading", font);
|
||||
} else
|
||||
throw Error(
|
||||
`WRN-STUDIO-LOCAL-EDIT: unsupported clause '${clause}'. Use square/rounded controls, compact/spacious spacing, flat/soft/hard shadows, serif/sans-serif/monospace typography, or set primary color to #RRGGBB. Broader requests require a configured provider.`,
|
||||
);
|
||||
}
|
||||
const failures = checkDecisionLocks(request.system, system, request.locks);
|
||||
if (failures.length) throw Error("WRN-STUDIO-LOCK: " + failures.map((d) => d.path).join(", "));
|
||||
return system;
|
||||
}
|
||||
function proposal(
|
||||
request: GenerationRequest,
|
||||
system: DesignSystem,
|
||||
index: number,
|
||||
name: string,
|
||||
rationale: string,
|
||||
): DesignProposal {
|
||||
const errors = validateDesignSystem(system);
|
||||
if (errors.length) throw Error(errors.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n"));
|
||||
const locks = checkDecisionLocks(request.system, system, request.locks);
|
||||
if (locks.length) throw Error("WRN-STUDIO-LOCK: " + locks.map((d) => d.path).join(", "));
|
||||
return {
|
||||
id: "direction-" + (index + 1),
|
||||
name,
|
||||
rationale,
|
||||
system,
|
||||
changedPaths: diffDesign(request.system, system),
|
||||
};
|
||||
}
|
||||
export function applyDesignPatch(system: DesignSystem, patch: unknown): DesignSystem {
|
||||
if (!Array.isArray(patch) || patch.length > 48)
|
||||
throw Error("WRN-STUDIO-PATCH: at most 48 operations per alternative.");
|
||||
const next = structuredClone(system);
|
||||
for (const op of patch) {
|
||||
if (
|
||||
!op ||
|
||||
typeof op !== "object" ||
|
||||
!["set", "remove"].includes(op.op) ||
|
||||
typeof op.path !== "string"
|
||||
)
|
||||
throw Error("WRN-STUDIO-PATCH: expected set/remove path operations.");
|
||||
decisionPath(op.path);
|
||||
if (op.op === "set" && !Object.hasOwn(op, "value"))
|
||||
throw Error("WRN-STUDIO-PATCH: set requires value.");
|
||||
writeDecision(next, op.path, op.op === "remove" ? undefined : op.value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
export async function generateDesigns(
|
||||
request: GenerationRequest,
|
||||
options: {
|
||||
provider?: GenerationProvider;
|
||||
model?: string;
|
||||
signal?: AbortSignal;
|
||||
maxOutputTokens?: number;
|
||||
timeoutMs?: number;
|
||||
} = {},
|
||||
): Promise<GenerationBatch> {
|
||||
validateGenerationRequest(request);
|
||||
if (request.baseRevision !== (await designRevision(request.system, request.locks)))
|
||||
throw Error("WRN-STUDIO-CONFLICT: source revision does not match.");
|
||||
const budget = options.maxOutputTokens ?? 4096;
|
||||
if (!Number.isInteger(budget) || budget < 256 || budget > 8192)
|
||||
throw Error("WRN-STUDIO-BUDGET: output budget must be 256–8192 tokens.");
|
||||
const timeout = options.timeoutMs ?? 20000;
|
||||
if (!Number.isInteger(timeout) || timeout < 10 || timeout > 60000)
|
||||
throw Error("WRN-STUDIO-BUDGET: timeout must be 10–60000 ms.");
|
||||
const signal = AbortSignal.any([
|
||||
...(options.signal ? [options.signal] : []),
|
||||
AbortSignal.timeout(timeout),
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
const batch: GenerationBatch = {
|
||||
brief: structuredClone(request.brief),
|
||||
locks: [...request.locks],
|
||||
instruction: request.instruction,
|
||||
baseRevision: request.baseRevision,
|
||||
alternatives: [],
|
||||
diagnostics: [],
|
||||
provenance: {
|
||||
provider: options.provider?.name ?? "local-rules",
|
||||
model: options.model,
|
||||
templateVersion: 1,
|
||||
attempts: 0,
|
||||
maxOutputTokens: options.provider ? budget : 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
if (!options.provider) {
|
||||
batch.provenance.attempts = 1;
|
||||
try {
|
||||
if (request.kind === "edit")
|
||||
batch.alternatives = [
|
||||
proposal(
|
||||
request,
|
||||
localEdit(request),
|
||||
0,
|
||||
"Requested edit",
|
||||
"Local rule-based edit. Review the exact changed paths before accepting.",
|
||||
),
|
||||
];
|
||||
else {
|
||||
const first =
|
||||
request.brief.geometry === "rounded"
|
||||
? "soft"
|
||||
: request.brief.geometry === "angular"
|
||||
? "angular"
|
||||
: request.brief.tone === "warm"
|
||||
? "soft"
|
||||
: request.brief.tone === "expressive"
|
||||
? "angular"
|
||||
: "square";
|
||||
const order = [
|
||||
first,
|
||||
...(["square", "soft", "angular"] as const).filter((name) => name !== first),
|
||||
] as Array<keyof typeof CORE_DESIGN_SYSTEMS>;
|
||||
batch.alternatives = order.map((name, index) =>
|
||||
proposal(
|
||||
request,
|
||||
direction(request, name, index),
|
||||
index,
|
||||
index === 0 ? "Brief direction" : CORE_DESIGN_SYSTEMS[name].name,
|
||||
`${index === 0 ? "Uses your geometry, spacing and surface choices." : "An alternative foundation direction."} ${request.locks.length} locked decisions preserved. Product, audience and reference prose are recorded as context; local rules do not interpret free-form prose.`,
|
||||
),
|
||||
);
|
||||
if (new Set(batch.alternatives.map((p) => JSON.stringify(p.system))).size !== 3)
|
||||
throw Error(
|
||||
"Locked decisions or brief choices collapse the directions. Unlock a decision or refine the brief to obtain three distinct alternatives.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
batch.alternatives = [];
|
||||
batch.diagnostics = [diagnostic("WRN-STUDIO-GENERATION", (error as Error).message)];
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
return batch;
|
||||
}
|
||||
const propertyProfiles: string[][] = [];
|
||||
const profile = (properties: readonly string[]) => {
|
||||
const key = JSON.stringify(properties);
|
||||
const index = propertyProfiles.findIndex((p) => JSON.stringify(p) === key);
|
||||
if (index >= 0) return index;
|
||||
propertyProfiles.push([...properties]);
|
||||
return propertyProfiles.length - 1;
|
||||
};
|
||||
const capabilities = Object.fromEntries(
|
||||
Object.entries(COMPONENT_CONTRACTS).map(([name, c]) => [
|
||||
name,
|
||||
{
|
||||
parts: Object.fromEntries(
|
||||
Object.entries(c.parts).map(([part, p]) => [part, profile(p.properties)]),
|
||||
),
|
||||
axes: c.axes,
|
||||
states: Object.fromEntries(
|
||||
Object.entries(c.parts)
|
||||
.filter(([, p]) => Object.keys(p.states).length)
|
||||
.map(([part, p]) => [part, Object.keys(p.states)]),
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const context = {
|
||||
propertyProfiles,
|
||||
templateVersion: 1,
|
||||
kind: request.kind,
|
||||
brief: request.brief,
|
||||
instruction: request.instruction,
|
||||
locks: request.locks,
|
||||
system: request.system,
|
||||
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.';
|
||||
let repair = "";
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
signal.throwIfAborted();
|
||||
batch.provenance.attempts = attempt + 1;
|
||||
const prompt = JSON.stringify(context) + repair;
|
||||
if (prompt.length > 180000)
|
||||
throw Error("WRN-STUDIO-BUDGET: generation context exceeds 180,000 characters.");
|
||||
let stop: () => void = () => {};
|
||||
let received = false;
|
||||
try {
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
const listener = () =>
|
||||
reject(new DOMException("Generation cancelled or timed out.", "AbortError"));
|
||||
signal.addEventListener("abort", listener, { once: true });
|
||||
stop = () => signal.removeEventListener("abort", listener);
|
||||
if (signal.aborted) listener();
|
||||
});
|
||||
const output = await Promise.race([
|
||||
options.provider.generate(prompt, {
|
||||
system: rules,
|
||||
model: options.model,
|
||||
maxTokens: budget,
|
||||
signal,
|
||||
}),
|
||||
aborted,
|
||||
]);
|
||||
received = true;
|
||||
signal.throwIfAborted();
|
||||
if (typeof output.value !== "string" || output.value.length > 100000)
|
||||
throw Error("Provider output exceeds 100,000 characters.");
|
||||
const data = JSON.parse(output.value.replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, ""));
|
||||
const count = request.kind === "directions" ? 3 : 1;
|
||||
if (!Array.isArray(data.alternatives) || data.alternatives.length !== count)
|
||||
throw Error(`Expected ${count} alternatives.`);
|
||||
const alternatives = data.alternatives.map((item: unknown, index: number) => {
|
||||
if (!item || typeof item !== "object") throw Error("Invalid alternative.");
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof entry.name !== "string" ||
|
||||
entry.name.length > 120 ||
|
||||
typeof entry.rationale !== "string" ||
|
||||
entry.rationale.length > 2000
|
||||
)
|
||||
throw Error("Alternative name/rationale is invalid.");
|
||||
return proposal(
|
||||
request,
|
||||
applyDesignPatch(request.system, entry.patch),
|
||||
index,
|
||||
entry.name,
|
||||
entry.rationale,
|
||||
);
|
||||
});
|
||||
if (new Set(alternatives.map((p: DesignProposal) => JSON.stringify(p.system))).size !== count)
|
||||
throw Error("Alternatives must be distinct.");
|
||||
batch.alternatives = alternatives;
|
||||
batch.diagnostics = [];
|
||||
batch.provenance.model = output.model ?? options.model;
|
||||
return batch;
|
||||
} catch (error) {
|
||||
if (signal.aborted)
|
||||
throw new DOMException("Generation cancelled or timed out.", "AbortError");
|
||||
if (!received) {
|
||||
batch.diagnostics = [
|
||||
diagnostic(
|
||||
"WRN-STUDIO-PROVIDER",
|
||||
"Provider request failed. Your draft is unchanged; check server-side provider configuration.",
|
||||
),
|
||||
];
|
||||
return batch;
|
||||
}
|
||||
const message = (error as Error).message;
|
||||
batch.diagnostics = [diagnostic("WRN-STUDIO-PROPOSAL", message)];
|
||||
repair =
|
||||
"\nThe previous attempt was invalid. Repair these diagnostics without changing locks: " +
|
||||
message;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export const guidanceHtml = `<section id="guided" class="editor" hidden>
|
||||
<p>Create three directions from a brief, then preview and review each before changing your draft.</p><p id="generation-provider" class="help">Local rules · no network generation</p>
|
||||
<div class="guide-step" data-step="0"><h3>1. Product and people</h3><label>What are you building?<input id="brief-product" maxlength="2000" placeholder="For example, a booking website"></label><label>Who will use it?<input id="brief-audience" maxlength="2000" placeholder="For example, people booking on their phone"></label></div>
|
||||
<div class="guide-step" data-step="1" hidden><h3>2. Visual decisions</h3><label>Visual tone<select id="brief-tone"><option value="restrained">Restrained</option><option value="warm">Warm</option><option value="expressive">Expressive</option></select></label><label>Geometry<select id="brief-geometry"><option value="square">Square</option><option value="rounded">Rounded</option><option value="angular">Angular / tilted</option></select></label><div class="geometry-choices"><button type="button" data-geometry="square"><span class="geometry-square">Button</span>Square</button><button type="button" data-geometry="rounded"><span class="geometry-rounded">Button</span>Rounded</button><button type="button" data-geometry="angular"><span class="geometry-angular">Button</span>Angular</button></div><div class="row"><label>Density<select id="brief-density"><option value="compact">Compact</option><option value="comfortable">Comfortable</option><option value="spacious">Spacious</option></select></label><label>Surfaces<select id="brief-surface"><option value="flat">Flat / outlined</option><option value="soft">Soft shadows</option><option value="hard">Hard shadows</option></select></label></div></div>
|
||||
<div class="guide-step" data-step="2" hidden><h3>3. Brand and constraints</h3><label>Brand color, if fixed<input id="brief-brandColor" placeholder="#002FA7" maxlength="7"></label><label>Reference notes<textarea id="brief-references" rows="3" maxlength="2000" placeholder="Describe references; URLs are recorded, not fetched."></textarea></label><label>Constraints<textarea id="brief-constraints" rows="3" maxlength="2000" placeholder="Describe requirements. Use decision locks below to enforce exact values."></textarea></label><p class="help">Local rules use the selected visual choices and brand color. Product, audience and prose are saved as context. A configured LLM can interpret the prose; exact decisions are enforced by locks.</p></div>
|
||||
<div class="row"><button id="brief-back">Back</button><button id="brief-next">Next</button><button id="generate-directions" class="primary">Generate three directions</button><button id="cancel-generation" disabled>Cancel</button></div><p id="generation-state" role="status" aria-live="polite"></p>
|
||||
<details open><summary>Decision locks</summary><p class="help">Locks protect manual edits, imports, generation and token aliases across modes. Unlock a decision before changing it. Locks override conflicting brief choices. Lock changes are explicit preferences and are independent of undo/redo.</p><div class="row"><button data-lock-group="colors">Lock colors</button><button data-lock-group="geometry">Lock geometry</button><button data-lock-group="typography">Lock typography</button><button data-lock-group="spacing">Lock spacing</button><button data-lock-group="elevation">Lock elevation</button></div><label>Specific decision (JSON pointer)<input id="decision-path" placeholder="/tokens/shape.control or /recipes/Button"></label><button id="lock-decision">Lock decision</button><ul id="decision-locks"></ul></details>
|
||||
<details open><summary>Edit with words</summary><label>Requested change<textarea id="edit-instruction" rows="3" maxlength="2000" placeholder="Make buttons square and use compact spacing"></textarea></label><p class="help">Local examples: rounded controls; use spacious spacing; remove shadows; use serif typography; set primary color to #002FA7. Unrecognized clauses are reported, not guessed.</p><button id="generate-edit">Propose edit</button></details>
|
||||
<h3>Alternatives</h3><p class="help">Every alternative uses the same composed preview content. Choosing one creates a normal recoverable draft revision; applying to an application still uses Review & apply.</p><div id="design-alternatives"></div>
|
||||
<details id="combine-panel" hidden><summary>Combine foundation decisions</summary><p class="help">Mix named color, shape, type, spacing and elevation groups. The combined proposal is validated again before review.</p><div id="combine-choices"></div><button id="combine-directions">Propose combination</button></details><pre id="proposal-diff"></pre><button id="accept-proposal" disabled>Use reviewed proposal</button><button id="export-generation" disabled>Export generation report</button>
|
||||
</section>`;
|
||||
export const guidanceCss = `.geometry-choices{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.geometry-choices button{display:grid;gap:14px;padding:16px 8px;font-size:12px}.geometry-choices span{display:block;padding:8px;border:1px solid #171719;background:#f7f7f8}.geometry-rounded{border-radius:18px}.geometry-angular{rotate:-3deg;box-shadow:3px 3px 0 #171719}.geometry-choices [aria-pressed=true]{border-color:#002fa7;outline:1px solid #002fa7}.alternative{border-top:1px solid #d9d9de;padding:18px 0}.alternative h4{margin:0 0 8px;font-size:16px;font-weight:500}.alternative p{font-size:12px}.alternative iframe{height:300px;margin:12px 0}#decision-locks{max-height:180px;overflow:auto;padding-left:20px;font-size:12px;overflow-wrap:anywhere}#decision-locks li{margin-bottom:8px}#decision-locks button{font-size:11px;margin-left:8px;padding:4px 8px}#generation-state{white-space:pre-wrap;font-size:12px;color:#002fa7}`;
|
||||
@@ -0,0 +1,444 @@
|
||||
import {
|
||||
DEFAULT_DESIGN_BRIEF,
|
||||
type DesignBrief,
|
||||
type GenerationBatch,
|
||||
type DesignProposal,
|
||||
} from "./generation.ts";
|
||||
import {
|
||||
designRevision,
|
||||
checkDecisionLocks,
|
||||
pointerPart,
|
||||
readDecision,
|
||||
writeDecision,
|
||||
validateLocks,
|
||||
} from "./decisions.ts";
|
||||
import { StudioSession, reviewDesign } from "./session.ts";
|
||||
import { validateDesignSystem, type DesignSystem } from "@wrnexus/design-system";
|
||||
|
||||
const groups = ["colors", "geometry", "typography", "spacing", "elevation"] as const;
|
||||
export type FoundationGroup = (typeof groups)[number];
|
||||
export function foundationDecisions(system: DesignSystem, group: FoundationGroup) {
|
||||
const names = Object.keys(system.tokens).filter((name) =>
|
||||
group === "colors"
|
||||
? name.startsWith("color.")
|
||||
: group === "geometry"
|
||||
? name.startsWith("shape.")
|
||||
: group === "typography"
|
||||
? name.startsWith("type.")
|
||||
: group === "spacing"
|
||||
? name.startsWith("space.") || name === "control.height"
|
||||
: name.startsWith("elevation."),
|
||||
);
|
||||
const paths = names.map((name) => "/tokens/" + pointerPart(name));
|
||||
if (group === "geometry")
|
||||
paths.push("/recipes/Button/base/control/rotate", "/recipes/Card/base/panel/rotate");
|
||||
return paths;
|
||||
}
|
||||
export async function combineDesignProposals(
|
||||
system: DesignSystem,
|
||||
batch: GenerationBatch,
|
||||
choices: Partial<Record<FoundationGroup, string>>,
|
||||
locks: string[],
|
||||
) {
|
||||
if (batch.baseRevision !== (await designRevision(system, locks)))
|
||||
throw Error("WRN-STUDIO-CONFLICT: the source or locks changed. Generate again.");
|
||||
const next = structuredClone(system);
|
||||
for (const group of groups) {
|
||||
const id = choices[group];
|
||||
if (!id) continue;
|
||||
const alternative = batch.alternatives.find((p) => p.id === id);
|
||||
if (!alternative) throw Error("Unknown alternative.");
|
||||
for (const path of foundationDecisions(alternative.system, group)) {
|
||||
writeDecision(next, path, readDecision(alternative.system, path));
|
||||
if (path.startsWith("/tokens/")) {
|
||||
const name = path.slice("/tokens/".length);
|
||||
for (const kind of ["modes", "densities"] as const)
|
||||
for (const layer of new Set([
|
||||
...Object.keys(next[kind] ?? {}),
|
||||
...Object.keys(alternative.system[kind] ?? {}),
|
||||
]))
|
||||
writeDecision(
|
||||
next,
|
||||
`/${kind}/${pointerPart(layer)}/${name}`,
|
||||
readDecision(alternative.system, `/${kind}/${pointerPart(layer)}/${name}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const diagnostics = [...validateDesignSystem(next), ...checkDecisionLocks(system, next, locks)];
|
||||
if (diagnostics.length)
|
||||
throw Error(diagnostics.map((d) => `${d.code}: ${d.path} ${d.message}`).join("\n"));
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Browser controller; proposals live separately from committed canonical draft history. */
|
||||
export function setupGuidance(
|
||||
session: StudioSession,
|
||||
hooks: {
|
||||
message: (text: string) => void;
|
||||
changed: () => Promise<void>;
|
||||
preview: (system: DesignSystem) => Promise<string>;
|
||||
},
|
||||
) {
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
const escape = (s: unknown) =>
|
||||
String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!,
|
||||
);
|
||||
const value = (id: string) => $(id) as HTMLInputElement;
|
||||
let brief: DesignBrief = structuredClone(DEFAULT_DESIGN_BRIEF),
|
||||
batch: GenerationBatch | undefined,
|
||||
selected: DesignProposal | undefined,
|
||||
step = 0;
|
||||
let controller: AbortController | undefined,
|
||||
epoch = 0,
|
||||
rendered: GenerationBatch | undefined,
|
||||
status = "Ready to generate proposals. Your current draft is unchanged.";
|
||||
const key = "wrnexus.design-studio.guidance.v1";
|
||||
function save() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ brief, batch, instruction: value("edit-instruction").value }),
|
||||
);
|
||||
} catch {
|
||||
hooks.message(
|
||||
"Guided workspace autosave is full. Export its report; the canonical draft is preserved separately.",
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
if (stored.length > 2_000_000) throw Error("Guidance history too large.");
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.brief && typeof parsed.brief === "object") brief = { ...brief, ...parsed.brief };
|
||||
if (
|
||||
parsed.batch &&
|
||||
parsed.batch.provenance &&
|
||||
typeof parsed.batch.provenance.provider === "string" &&
|
||||
/^[a-f0-9]{64}$/.test(parsed.batch.baseRevision) &&
|
||||
Array.isArray(parsed.batch.alternatives) &&
|
||||
parsed.batch.alternatives.length <= 3 &&
|
||||
parsed.batch.alternatives.every(
|
||||
(p: DesignProposal) =>
|
||||
p &&
|
||||
typeof p.id === "string" &&
|
||||
typeof p.name === "string" &&
|
||||
typeof p.rationale === "string" &&
|
||||
Array.isArray(p.changedPaths) &&
|
||||
p.changedPaths.every((path) => typeof path === "string") &&
|
||||
!validateDesignSystem(p.system).length,
|
||||
)
|
||||
)
|
||||
batch = parsed.batch;
|
||||
if (typeof parsed.instruction === "string")
|
||||
value("edit-instruction").value = parsed.instruction.slice(0, 2000);
|
||||
}
|
||||
} catch {
|
||||
status = "Guided workspace could not be restored. The canonical draft is preserved.";
|
||||
}
|
||||
for (const name of Object.keys(DEFAULT_DESIGN_BRIEF).concat([
|
||||
"brandColor",
|
||||
"references",
|
||||
"constraints",
|
||||
]) as Array<keyof DesignBrief>) {
|
||||
const input = value("brief-" + name);
|
||||
if (input) input.value = brief[name] ?? "";
|
||||
}
|
||||
function readBrief() {
|
||||
const next = {} as Record<string, string>;
|
||||
for (const name of [
|
||||
"product",
|
||||
"audience",
|
||||
"tone",
|
||||
"geometry",
|
||||
"density",
|
||||
"surface",
|
||||
"brandColor",
|
||||
"references",
|
||||
"constraints",
|
||||
])
|
||||
next[name] = value("brief-" + name).value;
|
||||
brief = next as unknown as DesignBrief;
|
||||
save();
|
||||
return brief;
|
||||
}
|
||||
function event(id: string, action: () => void | Promise<void>) {
|
||||
$(id).addEventListener("click", () =>
|
||||
Promise.resolve()
|
||||
.then(action)
|
||||
.catch((e) => {
|
||||
status = e.message;
|
||||
render();
|
||||
hooks.message(e.message);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function fresh() {
|
||||
if (
|
||||
!batch ||
|
||||
session.diagnostics.length ||
|
||||
JSON.stringify(readBrief()) !== JSON.stringify(batch.brief) ||
|
||||
batch.baseRevision !== (await designRevision(session.valid, session.locks))
|
||||
)
|
||||
throw Error(
|
||||
"WRN-STUDIO-CONFLICT: source, brief or locks changed. Resolve draft errors and generate again.",
|
||||
);
|
||||
}
|
||||
function review(proposal: DesignProposal) {
|
||||
selected = proposal;
|
||||
$("proposal-diff").textContent =
|
||||
reviewDesign(session.valid, proposal.system)
|
||||
.map(
|
||||
(d) =>
|
||||
`${d.path}\nBefore: ${JSON.stringify(d.before) ?? "(absent)"}\nAfter: ${JSON.stringify(d.after) ?? "(removed)"}`,
|
||||
)
|
||||
.join("\n\n") || "No changes.";
|
||||
$("accept-proposal").removeAttribute("disabled");
|
||||
}
|
||||
function render() {
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((button) => {
|
||||
const group = button.dataset.lockGroup as FoundationGroup;
|
||||
const paths = foundationDecisions(session.valid, group);
|
||||
const locked = paths.length > 0 && paths.every((path) => session.locks.includes(path));
|
||||
button.textContent = (locked ? "Unlock " : "Lock ") + group;
|
||||
button.setAttribute("aria-pressed", String(locked));
|
||||
});
|
||||
$("generation-state").textContent = status;
|
||||
$("brief-back").toggleAttribute("disabled", step === 0);
|
||||
$("brief-next").toggleAttribute("disabled", step === 2);
|
||||
document.querySelectorAll<HTMLElement>(".guide-step").forEach((el) => {
|
||||
el.hidden = Number(el.dataset.step) !== step;
|
||||
});
|
||||
for (const id of ["generate-directions", "generate-edit"])
|
||||
$(id).toggleAttribute("disabled", !!controller || !!session.diagnostics.length);
|
||||
$("cancel-generation").toggleAttribute("disabled", !controller);
|
||||
document
|
||||
.querySelectorAll<HTMLButtonElement>("[data-geometry]")
|
||||
.forEach((b) =>
|
||||
b.setAttribute(
|
||||
"aria-pressed",
|
||||
String(b.dataset.geometry === value("brief-geometry").value),
|
||||
),
|
||||
);
|
||||
$("decision-locks").innerHTML =
|
||||
session.locks
|
||||
.map(
|
||||
(path) => `<li>${escape(path)}<button data-unlock="${escape(path)}">Unlock</button></li>`,
|
||||
)
|
||||
.join("") || "<li>No decisions locked.</li>";
|
||||
$("export-generation").toggleAttribute("disabled", !batch);
|
||||
if (!batch || rendered === batch) return;
|
||||
rendered = batch;
|
||||
$("design-alternatives").innerHTML = batch.alternatives
|
||||
.map(
|
||||
(p, i) =>
|
||||
`<article class="alternative"><h4>${escape(p.name)}</h4><p>${escape(p.rationale)}</p><p>${p.changedPaths.length} changed paths</p><div class="frame-scroll"><iframe title="Alternative ${i + 1} preview" id="alternative-${i}" sandbox="allow-scripts"></iframe></div><button data-review-proposal="${escape(p.id)}">Review ${escape(p.name)}</button></article>`,
|
||||
)
|
||||
.join("");
|
||||
$("combine-panel").hidden = batch.alternatives.length < 2;
|
||||
$("combine-choices").innerHTML = groups
|
||||
.map(
|
||||
(group) =>
|
||||
`<label>${group}<select data-combine="${group}"><option value="">Current draft</option>${batch!.alternatives.map((p) => `<option value="${escape(p.id)}">${escape(p.name)}</option>`).join("")}</select></label>`,
|
||||
)
|
||||
.join("");
|
||||
const displayed = batch;
|
||||
batch.alternatives.forEach((p, i) => {
|
||||
hooks
|
||||
.preview(p.system)
|
||||
.then((url) => {
|
||||
if (batch === displayed) $<HTMLIFrameElement>("alternative-" + i).src = url;
|
||||
})
|
||||
.catch(() => {
|
||||
status = "An alternative preview failed. Refresh generation before accepting.";
|
||||
$("generation-state").textContent = status;
|
||||
});
|
||||
});
|
||||
}
|
||||
async function generate(kind: "directions" | "edit") {
|
||||
if (session.diagnostics.length) throw Error("Resolve the source draft before generating.");
|
||||
readBrief();
|
||||
if (kind === "directions" && (!brief.product.trim() || !brief.audience.trim())) {
|
||||
step = 0;
|
||||
throw Error("Describe your product and audience first.");
|
||||
}
|
||||
const current = ++epoch;
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const system = structuredClone(session.valid),
|
||||
locks = [...session.locks];
|
||||
const requestedBrief = structuredClone(brief);
|
||||
const baseRevision = await designRevision(system, locks);
|
||||
selected = undefined;
|
||||
$("accept-proposal").setAttribute("disabled", "");
|
||||
status = "Generating proposals. Your draft is unchanged.";
|
||||
render();
|
||||
try {
|
||||
const response = await fetch(new URL("./api/generate", document.baseURI), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
system,
|
||||
locks,
|
||||
brief: requestedBrief,
|
||||
baseRevision,
|
||||
kind,
|
||||
instruction: kind === "edit" ? value("edit-instruction").value : undefined,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const result = await response.json();
|
||||
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");
|
||||
return;
|
||||
}
|
||||
batch = result;
|
||||
status = `${batch!.alternatives.length} proposals ready. Review one before using it. ${batch!.provenance.provider}; ${batch!.provenance.attempts} attempt(s).`;
|
||||
if (
|
||||
batch!.baseRevision !== (await designRevision(session.valid, session.locks)) ||
|
||||
JSON.stringify(brief) !== JSON.stringify(requestedBrief)
|
||||
)
|
||||
status =
|
||||
"Proposals arrived after the draft or locks changed. They are preserved for inspection; regenerate before accepting.";
|
||||
save();
|
||||
} catch (error) {
|
||||
if (current === epoch)
|
||||
status = signal.aborted
|
||||
? "Generation cancelled. Your draft and prior alternatives are preserved."
|
||||
: `Generation failed. Your draft is unchanged. ${(error as Error).message}`;
|
||||
} finally {
|
||||
if (current === epoch) {
|
||||
controller = undefined;
|
||||
render();
|
||||
}
|
||||
}
|
||||
}
|
||||
event("brief-back", () => {
|
||||
step = Math.max(0, step - 1);
|
||||
readBrief();
|
||||
render();
|
||||
});
|
||||
event("brief-next", () => {
|
||||
step = Math.min(2, step + 1);
|
||||
readBrief();
|
||||
render();
|
||||
});
|
||||
event("generate-directions", () => generate("directions"));
|
||||
event("generate-edit", () => generate("edit"));
|
||||
event("cancel-generation", () => {
|
||||
epoch++;
|
||||
controller?.abort();
|
||||
controller = undefined;
|
||||
status = "Generation cancelled. Your draft and prior alternatives are preserved.";
|
||||
render();
|
||||
});
|
||||
$("guided").addEventListener("input", () => {
|
||||
readBrief();
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-geometry]").forEach((b) =>
|
||||
b.addEventListener("click", () => {
|
||||
value("brief-geometry").value = b.dataset.geometry!;
|
||||
readBrief();
|
||||
render();
|
||||
}),
|
||||
);
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-lock-group]").forEach((b) =>
|
||||
b.addEventListener("click", () => {
|
||||
try {
|
||||
const paths = foundationDecisions(session.valid, b.dataset.lockGroup as FoundationGroup);
|
||||
const all = paths.every((p) => session.locks.includes(p));
|
||||
session.setLocks(
|
||||
all
|
||||
? session.locks.filter((p) => !paths.includes(p))
|
||||
: [...new Set([...session.locks, ...paths])],
|
||||
);
|
||||
hooks.changed().catch((e) => hooks.message(e.message));
|
||||
} catch (e) {
|
||||
hooks.message((e as Error).message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
event("lock-decision", async () => {
|
||||
const path = value("decision-path").value;
|
||||
validateLocks([...new Set([...session.locks, path])]);
|
||||
session.setLocks([...new Set([...session.locks, path])]);
|
||||
await hooks.changed();
|
||||
});
|
||||
$("decision-locks").addEventListener("click", (e) => {
|
||||
const path = (e.target as HTMLElement).dataset.unlock;
|
||||
if (path) {
|
||||
session.setLocks(session.locks.filter((p) => p !== path));
|
||||
hooks.changed().catch((e) => hooks.message(e.message));
|
||||
}
|
||||
});
|
||||
$("design-alternatives").addEventListener("click", (e) => {
|
||||
const id = (e.target as HTMLElement).dataset.reviewProposal;
|
||||
if (!id) return;
|
||||
fresh()
|
||||
.then(() => {
|
||||
const p = batch!.alternatives.find((p) => p.id === id);
|
||||
if (p) review(p);
|
||||
})
|
||||
.catch((e) => hooks.message(e.message));
|
||||
});
|
||||
event("combine-directions", async () => {
|
||||
await fresh();
|
||||
const choices = Object.fromEntries(
|
||||
[...document.querySelectorAll<HTMLSelectElement>("[data-combine]")].map((s) => [
|
||||
s.dataset.combine!,
|
||||
s.value,
|
||||
]),
|
||||
);
|
||||
const system = await combineDesignProposals(session.valid, batch!, choices, session.locks);
|
||||
review({
|
||||
id: "combined",
|
||||
name: "Combined foundations",
|
||||
rationale: "Selected foundation groups, revalidated together.",
|
||||
system,
|
||||
changedPaths: reviewDesign(session.valid, system).map((d) => d.path),
|
||||
});
|
||||
});
|
||||
event("accept-proposal", async () => {
|
||||
await fresh();
|
||||
if (!selected) throw Error("Review a proposal first.");
|
||||
const failures = [
|
||||
...validateDesignSystem(selected.system),
|
||||
...checkDecisionLocks(session.valid, selected.system, session.locks),
|
||||
];
|
||||
if (failures.length) throw Error(failures.map((d) => d.message).join("\n"));
|
||||
session.replace(selected.system, "Accepted " + selected.name);
|
||||
status = "Proposal saved as a draft revision. Use Review & apply for the application.";
|
||||
selected = undefined;
|
||||
$("accept-proposal").setAttribute("disabled", "");
|
||||
await hooks.changed();
|
||||
});
|
||||
event("export-generation", () => {
|
||||
if (!batch) return;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify({ brief, locks: session.locks, batch }, null, 2)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "design-generation-report.json";
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
});
|
||||
fetch(new URL("./api/generation", document.baseURI))
|
||||
.then((r) => r.json())
|
||||
.then((config) => {
|
||||
$("generation-provider").textContent =
|
||||
config.provider === "local-rules"
|
||||
? "Local rules · no network generation"
|
||||
: `${config.provider} / ${config.model ?? "configured model"} · Sends the current design, brief and contract capabilities to this configured provider only when you generate. At most two attempts, 4096 output tokens per attempt, 20-second deadline.`;
|
||||
})
|
||||
.catch(() => {});
|
||||
render();
|
||||
return { render };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { StudioSession, importDesign, exportDesign, diffDesign, reviewDesign } from "./session.ts";
|
||||
export type { ImportReport, StudioRevision, StudioStorage } from "./session.ts";
|
||||
export { checkDecisionLocks, designRevision, pointerPart } from "./decisions.ts";
|
||||
export type { DecisionLock } from "./decisions.ts";
|
||||
export { generateDesigns, applyDesignPatch, DEFAULT_DESIGN_BRIEF } from "./generation.ts";
|
||||
export type {
|
||||
DesignBrief,
|
||||
DesignProposal,
|
||||
GenerationRequest,
|
||||
GenerationBatch,
|
||||
GenerationProvider,
|
||||
} from "./generation.ts";
|
||||
export { combineDesignProposals, foundationDecisions } from "./guidance.ts";
|
||||
export type { FoundationGroup } from "./guidance.ts";
|
||||
@@ -0,0 +1,175 @@
|
||||
import { validateAgentPage } from "./agent-pages.ts";
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createHash } from "node:crypto";
|
||||
import { compileWrnFile } from "@wrnexus/compiler";
|
||||
import { runWithRequestContext } from "@wrnexus/core";
|
||||
import { REACTIVE_RUNTIME } from "@wrnexus/csr";
|
||||
import { renderDocument } from "@wrnexus/ssr";
|
||||
import { uiCss, uiComponentsDir } from "@wrnexus/ui/registry";
|
||||
import { resolveThemeConfig, renderThemeCss } from "@wrnexus/styles/theme";
|
||||
import {
|
||||
COMPONENT_CONTRACTS,
|
||||
type DesignSystem,
|
||||
type ResolveOptions,
|
||||
} from "@wrnexus/design-system";
|
||||
|
||||
const modules = new Map<string, Promise<{ render: (props: Record<string, unknown>) => string }>>();
|
||||
const escape = (text: string) =>
|
||||
text.replace(
|
||||
/[&<>"']/g,
|
||||
(char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!,
|
||||
);
|
||||
async function component(name: string, props: Record<string, unknown> = {}) {
|
||||
if (!Object.hasOwn(COMPONENT_CONTRACTS, name)) throw Error("Unknown component.");
|
||||
if (!modules.has(name)) {
|
||||
const sourcePath = resolve(
|
||||
uiComponentsDir(),
|
||||
name === "ComboBox" ? "Combobox.wrn" : `${name}.wrn`,
|
||||
);
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const directory = join(tmpdir(), "wrnexus-design-studio");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const target = join(
|
||||
directory,
|
||||
`${name}-${createHash("sha256")
|
||||
.update(import.meta.url + source)
|
||||
.digest("hex")
|
||||
.slice(0, 16)}.ts`,
|
||||
);
|
||||
const core = fileURLToPath(import.meta.resolve("@wrnexus/core"));
|
||||
writeFileSync(
|
||||
target,
|
||||
compileWrnFile(source, target).replace(
|
||||
/from (["'])@wrnexus\/core\1/g,
|
||||
`from ${JSON.stringify(pathToFileURL(core).href)}`,
|
||||
),
|
||||
);
|
||||
modules.set(name, import(pathToFileURL(target).href));
|
||||
}
|
||||
return String((await modules.get(name)!).render(props));
|
||||
}
|
||||
|
||||
export async function previewDocument(
|
||||
system: DesignSystem,
|
||||
options: ResolveOptions & { content?: string; page?: number; direction?: string } = {},
|
||||
) {
|
||||
const theme = resolveThemeConfig({
|
||||
designSystem: { system, mode: options.mode, density: options.density },
|
||||
default: options.mode ?? system.defaultMode,
|
||||
});
|
||||
const mode = options.mode ?? system.defaultMode ?? "light";
|
||||
return runWithRequestContext({} as never, async () => {
|
||||
let body: string;
|
||||
if (options.content === "catalog") {
|
||||
const page = Math.max(0, Math.min(6, options.page ?? 0));
|
||||
const names = Object.keys(COMPONENT_CONTRACTS).slice(page * 16, (page + 1) * 16);
|
||||
const cards = [];
|
||||
for (const name of names)
|
||||
cards.push(
|
||||
`<section class="sample"><h2>${escape(name)}</h2>${await component(name)}</section>`,
|
||||
);
|
||||
body = `<h1>Component gallery</h1><p>Page ${page + 1} of 7 · Default states</p><div class="gallery">${cards.join("")}</div>`;
|
||||
} else {
|
||||
const nav = await component("Nav", {
|
||||
items: [
|
||||
{ label: "Profile", href: "#profile" },
|
||||
{ label: "Preferences", href: "#preferences" },
|
||||
],
|
||||
});
|
||||
const heading = await component("PageHeader", {
|
||||
title: "Design system preview",
|
||||
description: "The same components and content, styled by your draft.",
|
||||
});
|
||||
const card = await component("Card", {
|
||||
title: "Profile",
|
||||
description: "Sample form. Changes stay inside this preview.",
|
||||
});
|
||||
const input = await component("Input", {
|
||||
id: "display-name",
|
||||
label: "Display name",
|
||||
placeholder: "Your name",
|
||||
});
|
||||
const select = await component("Select", {
|
||||
id: "language",
|
||||
label: "Language",
|
||||
value: "en",
|
||||
options: [
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Hindi", value: "hi" },
|
||||
],
|
||||
});
|
||||
const textarea = await component("Textarea", {
|
||||
id: "bio",
|
||||
label: "About you",
|
||||
placeholder: "Add a short introduction",
|
||||
});
|
||||
const toggle = await component("Switch", { id: "updates", label: "Email updates" });
|
||||
const checkbox = await component("Checkbox", {
|
||||
id: "remember",
|
||||
label: "Remember preferences",
|
||||
});
|
||||
const button = await component("Button", { label: "Save changes" });
|
||||
const disabled = await component("Button", { label: "Unavailable", disabled: true });
|
||||
const loading = await component("Button", { label: "Loading…", loading: true });
|
||||
const alert = await component("Alert", {
|
||||
title: "Preview only",
|
||||
description: "This form does not send data.",
|
||||
color: "info",
|
||||
dismissible: true,
|
||||
});
|
||||
const modal = await component("Modal", {
|
||||
title: "Review changes",
|
||||
description: "Check your preferences before continuing.",
|
||||
triggerLabel: "Open dialog",
|
||||
confirmLabel: "Continue",
|
||||
});
|
||||
const radio = await component("Radio", {
|
||||
label: "Comfortable spacing",
|
||||
id: "density-comfortable",
|
||||
checked: true,
|
||||
});
|
||||
const number = await component("InputNumber", {
|
||||
label: "Quantity",
|
||||
id: "quantity",
|
||||
value: 2,
|
||||
});
|
||||
body = `${nav}${heading}<div class="layout"><section id="profile">${card}<form>${input}${select}${textarea}<div id="preferences">${toggle}${checkbox}</div><div class="actions">${button}${disabled}${loading}</div></form></section><aside>${alert}<h2>More components</h2>${radio}${number}<div class="actions">${modal}</div></aside></div>`;
|
||||
}
|
||||
const runtime = REACTIVE_RUNTIME;
|
||||
return renderDocument({
|
||||
meta: { title: system.name + " preview" },
|
||||
body,
|
||||
htmlAttrs: ` dir="${options.direction === "rtl" ? "rtl" : "ltr"}" data-theme="${escape(mode)}"`,
|
||||
extraHead: `<style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{padding:24px;margin:0;font-family:var(--wrn-font-sans);color:var(--wrn-color-text);background:var(--wrn-color-bg);line-height:1.5}h1{font-size:28px}h2{font-size:18px}.layout{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px}.layout>*,form{min-width:0;display:grid;gap:16px;align-content:start}.actions{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.gallery{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:20px}.sample{min-width:0;overflow:auto;border:1px solid var(--wrn-color-border);padding:16px}@media(max-width:650px){.layout{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}</style>`,
|
||||
componentRecipeCss: theme.recipeCss,
|
||||
extraBody: `<script>${runtime.replace(/<\/script/gi, "<\\/script")}\ndocument.addEventListener('submit',e=>e.preventDefault());</script>`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Safe page-spec preview. Only shipped components execute; submitted page source never executes. */
|
||||
export async function previewAgentPage(
|
||||
system: DesignSystem,
|
||||
page: import("./agent-pages.ts").AgentPage,
|
||||
options: ResolveOptions = {},
|
||||
) {
|
||||
validateAgentPage(page);
|
||||
const theme = resolveThemeConfig({
|
||||
designSystem: { system, ...options },
|
||||
default: options.mode ?? system.defaultMode,
|
||||
});
|
||||
return runWithRequestContext({} as never, async () => {
|
||||
const body = [];
|
||||
for (const entry of page.components) body.push(await component(entry.component, entry.props));
|
||||
return renderDocument({
|
||||
meta: { title: page.title },
|
||||
body: `<main class="wrn-agent-page"><h1>${escape(page.title)}</h1>${body.join("")}</main>`,
|
||||
htmlAttrs: ` data-theme="${escape(options.mode ?? system.defaultMode ?? "light")}"`,
|
||||
extraHead: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; form-action 'none'; base-uri 'none'"><style>${uiCss()}${renderThemeCss(theme)}*{box-sizing:border-box}body{margin:0}.wrn-agent-page{display:grid;min-width:0;padding:var(--wrn-space-panel);gap:var(--wrn-space-panel);color:var(--wrn-color-text);background:var(--wrn-color-bg);font-family:var(--wrn-font-sans)}</style>`,
|
||||
componentRecipeCss: theme.recipeCss,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StudioApplication } from "./application.ts";
|
||||
import { studioHtml, studioCss } from "./shell.ts";
|
||||
import { previewDocument } from "./preview.ts";
|
||||
import { validateDesignSystem, type DesignSystem } from "@wrnexus/design-system";
|
||||
import { generateDesigns, type GenerationProvider } from "./generation.ts";
|
||||
|
||||
/** Explicit local server subpath. The root entry remains browser safe. */
|
||||
export async function createStudioHandler(
|
||||
options: {
|
||||
applicationRoot?: string;
|
||||
generationProvider?: GenerationProvider;
|
||||
generationModel?: string;
|
||||
/** Public demos can generate previews and exports, never write application files. */
|
||||
hosted?: { basePath: string };
|
||||
} = {},
|
||||
) {
|
||||
const basePath = options.hosted?.basePath ?? "";
|
||||
if (basePath && !/^\/[a-zA-Z0-9/_-]+$/.test(basePath))
|
||||
throw Error("Hosted basePath must be an absolute path without a trailing slash.");
|
||||
if (basePath.endsWith("/")) throw Error("Hosted basePath must not end with a slash.");
|
||||
if (options.hosted && (options.applicationRoot || options.generationProvider))
|
||||
throw Error("Hosted Studio supports export-only local generation.");
|
||||
const app = options.applicationRoot ? new StudioApplication(options.applicationRoot) : undefined;
|
||||
const asset = fileURLToPath(new URL("../assets/studio-client.js", import.meta.url));
|
||||
let client: string;
|
||||
if (existsSync(asset)) client = readFileSync(asset, "utf8");
|
||||
else {
|
||||
const build = await Bun.build({
|
||||
entrypoints: [fileURLToPath(new URL("./client.ts", import.meta.url))],
|
||||
target: "browser",
|
||||
minify: true,
|
||||
});
|
||||
if (!build.success) throw Error(build.logs.join("\n"));
|
||||
client = await build.outputs[0]!.text();
|
||||
}
|
||||
const previews = new Map<string, string>();
|
||||
let generating = 0;
|
||||
const headers = {
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy":
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; frame-src 'self' about:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'",
|
||||
};
|
||||
const response = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
});
|
||||
const previewResponse = (html: string) =>
|
||||
new Response(html, {
|
||||
headers: {
|
||||
...headers,
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-security-policy":
|
||||
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
|
||||
},
|
||||
});
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
// Hosted handlers are served behind the deployment's loopback reverse proxy.
|
||||
const protocol = request.headers.get("x-forwarded-proto");
|
||||
if (options.hosted && (protocol === "https" || protocol === "http"))
|
||||
url.protocol = protocol + ":";
|
||||
if (
|
||||
(!options.hosted && !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)) ||
|
||||
(request.headers.get("origin") && request.headers.get("origin") !== url.origin)
|
||||
)
|
||||
return response({ error: "Local same-origin requests only." }, 403);
|
||||
if (basePath) {
|
||||
if (!url.pathname.startsWith(basePath + "/")) return response({ error: "Not found" }, 404);
|
||||
url.pathname = url.pathname.slice(basePath.length);
|
||||
}
|
||||
try {
|
||||
if (request.method === "GET") {
|
||||
if (url.pathname === "/api/generation")
|
||||
return response({
|
||||
provider: options.generationProvider?.name ?? "local-rules",
|
||||
model: options.generationModel,
|
||||
templateVersion: 1,
|
||||
maxAttempts: 2,
|
||||
maxOutputTokens: 4096,
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
if (url.pathname === "/application-preview") {
|
||||
const system = app?.system();
|
||||
return system
|
||||
? previewResponse(await previewDocument(system, app!.selection()))
|
||||
: response({ error: "No applied design. Apply a design first." }, 404);
|
||||
}
|
||||
if (url.pathname.startsWith("/preview/")) {
|
||||
const html = previews.get(url.pathname);
|
||||
return html
|
||||
? previewResponse(html)
|
||||
: response({ error: "Preview expired. Refresh the preview." }, 404);
|
||||
}
|
||||
if (url.pathname === "/")
|
||||
return new Response(
|
||||
basePath
|
||||
? studioHtml
|
||||
.replaceAll('href="/', 'href="./')
|
||||
.replaceAll('src="/', 'src="./')
|
||||
.replace("<head>", `<head><base href="${basePath}/">`)
|
||||
: studioHtml,
|
||||
{
|
||||
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
if (url.pathname === "/studio.css")
|
||||
return new Response(studioCss, { headers: { ...headers, "content-type": "text/css" } });
|
||||
if (url.pathname === "/studio.js")
|
||||
return new Response(client, {
|
||||
headers: { ...headers, "content-type": "text/javascript" },
|
||||
});
|
||||
if (url.pathname === "/api/application")
|
||||
return response({
|
||||
enabled: !!app,
|
||||
directory: app?.directory,
|
||||
...(app?.state() ?? { revision: "none" }),
|
||||
system: app?.system(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
!["/api/preview", "/api/apply", "/api/rollback", "/api/generate"].includes(url.pathname)
|
||||
)
|
||||
return response({ error: "Not found" }, 404);
|
||||
if (!(request.headers.get("content-type") ?? "").startsWith("application/json"))
|
||||
return response({ error: "JSON required." }, 415);
|
||||
if (Number(request.headers.get("content-length")) > 2_000_000)
|
||||
return response({ error: "Request too large." }, 413);
|
||||
let text = "";
|
||||
const reader = request.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 2_000_000) {
|
||||
await reader.cancel();
|
||||
return response({ error: "Request too large." }, 413);
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
}
|
||||
const input = JSON.parse(text);
|
||||
if (!input || typeof input !== "object" || Array.isArray(input))
|
||||
return response({ error: "JSON object required." }, 400);
|
||||
if (url.pathname === "/api/generate") {
|
||||
if (generating >= 2)
|
||||
return response(
|
||||
{ error: "Generation is busy. Try again after the current requests finish." },
|
||||
429,
|
||||
);
|
||||
generating++;
|
||||
try {
|
||||
return response(
|
||||
await generateDesigns(input, {
|
||||
provider: options.generationProvider,
|
||||
model: options.generationModel,
|
||||
signal: request.signal,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
generating--;
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/rollback") {
|
||||
if (!app) return response({ error: "Local application is not configured." }, 409);
|
||||
return response(app.rollback(input.base));
|
||||
}
|
||||
const diagnostics = validateDesignSystem(input.system);
|
||||
if (diagnostics.length)
|
||||
return response({ error: "Invalid design system.", diagnostics }, 422);
|
||||
const selection = {
|
||||
mode: typeof input.mode === "string" ? input.mode : undefined,
|
||||
density: typeof input.density === "string" ? input.density : undefined,
|
||||
};
|
||||
if (url.pathname === "/api/preview") {
|
||||
const html = await previewDocument(input.system as DesignSystem, {
|
||||
...selection,
|
||||
content: input.content === "catalog" ? "catalog" : "composed",
|
||||
page: Number.isInteger(input.page) ? input.page : 0,
|
||||
direction: input.direction,
|
||||
});
|
||||
const path = "/preview/" + randomUUID();
|
||||
previews.set(path, html);
|
||||
while (previews.size > 24) previews.delete(previews.keys().next().value!);
|
||||
return response({ url: basePath + path });
|
||||
}
|
||||
if (!app)
|
||||
return response(
|
||||
{ error: "Local application is not configured. Export or restart with --app-root." },
|
||||
409,
|
||||
);
|
||||
return response(app.apply(input.system as DesignSystem, input.base, selection));
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
return response({ error: message }, message.includes("WRN-STUDIO-CONFLICT") ? 409 : 400);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import {
|
||||
CORE_DESIGN_SYSTEMS,
|
||||
COMPONENT_CONTRACTS,
|
||||
compileDesignSystem,
|
||||
resolveDesignSystem,
|
||||
validateDesignSystem,
|
||||
type DesignSystem,
|
||||
type DesignDiagnostic,
|
||||
type ResolveOptions,
|
||||
type DesignToken,
|
||||
} from "@wrnexus/design-system";
|
||||
import { checkDecisionLocks, validateLocks, type DecisionLock } from "./decisions.ts";
|
||||
|
||||
export interface StudioRevision {
|
||||
source: string;
|
||||
label: string;
|
||||
}
|
||||
export interface StudioStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
export interface ImportReport {
|
||||
system?: DesignSystem;
|
||||
preserved: string[];
|
||||
unsupported: string[];
|
||||
ambiguous: string[];
|
||||
diagnostics: DesignDiagnostic[];
|
||||
}
|
||||
const limit = 2_000_000;
|
||||
const key = "wrnexus.design-studio.v1";
|
||||
const json = (value: unknown) => JSON.stringify(value, null, 2);
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
const diagnostic = (code: string, message: string): DesignDiagnostic => ({
|
||||
code,
|
||||
path: "$",
|
||||
message,
|
||||
});
|
||||
function parse(source: string): unknown {
|
||||
if (source.length > limit) throw Error("Document exceeds the 2 MB import limit.");
|
||||
return JSON.parse(source);
|
||||
}
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === "object" && !Array.isArray(value);
|
||||
const safeName = (name: string) =>
|
||||
/^[A-Za-z][\w.-]*$/.test(name) &&
|
||||
!name.split(/[.-]/).some((part) => ["__proto__", "constructor", "prototype"].includes(part));
|
||||
|
||||
/** Deliberate subset: canonical JSON, literal legacy themes and grouped exchange tokens. No code/assets execute. */
|
||||
export function importDesign(
|
||||
source: string,
|
||||
format: "canonical" | "legacy" | "tokens" = "canonical",
|
||||
): ImportReport {
|
||||
const report: ImportReport = { preserved: [], unsupported: [], ambiguous: [], diagnostics: [] };
|
||||
try {
|
||||
const input = parse(source);
|
||||
if (format === "canonical") {
|
||||
report.diagnostics = validateDesignSystem(input);
|
||||
if (!report.diagnostics.length) {
|
||||
report.system = clone(input as DesignSystem);
|
||||
report.preserved.push("Complete canonical document");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
if (!record(input)) throw Error("Import must be a JSON object.");
|
||||
const system: DesignSystem = {
|
||||
schemaVersion: 1,
|
||||
id: "imported",
|
||||
name: "Imported design",
|
||||
version: "1.0.0",
|
||||
tokens: {},
|
||||
};
|
||||
if (format === "legacy") {
|
||||
const baseline = CORE_DESIGN_SYSTEMS.square;
|
||||
const bindings = new Map(
|
||||
Object.entries(baseline.tokens)
|
||||
.filter(([, t]) => t.legacy)
|
||||
.map(([name, t]) => [t.legacy!, { name, type: t.type }]),
|
||||
);
|
||||
if (!record(input.themes))
|
||||
throw Error("Legacy JSON requires a themes object with literal token values.");
|
||||
system.modes = {};
|
||||
for (const [mode, values] of Object.entries(input.themes)) {
|
||||
if (!safeName(mode)) throw Error("Invalid legacy mode name.");
|
||||
if (!record(values)) {
|
||||
report.unsupported.push(`themes.${mode}`);
|
||||
continue;
|
||||
}
|
||||
const tokens: Record<string, DesignToken> = {};
|
||||
for (const [suffix, value] of Object.entries(values)) {
|
||||
const binding = bindings.get(suffix);
|
||||
if (!binding || (typeof value !== "string" && typeof value !== "number")) {
|
||||
report.unsupported.push(`themes.${mode}.${suffix}`);
|
||||
continue;
|
||||
}
|
||||
tokens[binding.name] = { type: binding.type, value, legacy: suffix };
|
||||
report.preserved.push(`themes.${mode}.${suffix}`);
|
||||
}
|
||||
system.modes[mode] = tokens;
|
||||
}
|
||||
system.defaultMode =
|
||||
typeof input.default === "string" ? input.default : Object.keys(system.modes)[0];
|
||||
for (const field of Object.keys(input))
|
||||
if (!["themes", "default"].includes(field)) report.unsupported.push(field);
|
||||
} else {
|
||||
const types: Record<string, DesignToken["type"]> = {
|
||||
color: "color",
|
||||
dimension: "dimension",
|
||||
number: "number",
|
||||
fontFamily: "fontFamily",
|
||||
fontWeight: "fontWeight",
|
||||
duration: "duration",
|
||||
};
|
||||
const walk = (
|
||||
group: Record<string, unknown>,
|
||||
prefix: string,
|
||||
inheritedType?: string,
|
||||
depth = 0,
|
||||
) => {
|
||||
if (depth > 32) throw Error("Exchange token nesting exceeds 32 levels.");
|
||||
for (const [name, value] of Object.entries(group)) {
|
||||
if (name.startsWith("$")) {
|
||||
if (!["$type", "$description"].includes(name))
|
||||
report.unsupported.push(prefix ? `${prefix}.${name}` : name);
|
||||
continue;
|
||||
}
|
||||
const path = prefix ? `${prefix}.${name}` : name;
|
||||
if (!safeName(path)) throw Error("Invalid exchange token path.");
|
||||
if (!record(value)) {
|
||||
report.unsupported.push(path);
|
||||
continue;
|
||||
}
|
||||
const type = typeof value.$type === "string" ? value.$type : inheritedType;
|
||||
if ("$value" in value) {
|
||||
const raw = value.$value;
|
||||
if (!type || !types[type] || (typeof raw !== "string" && typeof raw !== "number")) {
|
||||
report.unsupported.push(path);
|
||||
continue;
|
||||
}
|
||||
const alias = typeof raw === "string" && /^\{[\w.-]+\}$/.test(raw);
|
||||
system.tokens[path] = {
|
||||
type: types[type]!,
|
||||
value: alias ? { ref: (raw as string).slice(1, -1) } : (raw as string | number),
|
||||
};
|
||||
report.preserved.push(path);
|
||||
report.ambiguous.push(
|
||||
`${path}: no WrNexus semantic binding; map a legacy suffix in Foundations before applying.`,
|
||||
);
|
||||
for (const field of Object.keys(value))
|
||||
if (!["$type", "$value", "$description"].includes(field))
|
||||
report.unsupported.push(`${path}.${field}`);
|
||||
if (typeof value.$description === "string")
|
||||
system.tokens[path]!.description = value.$description;
|
||||
} else walk(value, path, type, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(input, "", typeof input.$type === "string" ? input.$type : undefined);
|
||||
}
|
||||
if (!report.preserved.length) throw Error("No supported tokens found.");
|
||||
report.diagnostics = validateDesignSystem(system);
|
||||
if (!report.diagnostics.length) report.system = system;
|
||||
} catch (error) {
|
||||
report.diagnostics = [diagnostic("WRN-STUDIO-IMPORT", (error as Error).message)];
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function reviewDesign(
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
path = "$",
|
||||
): Array<{ path: string; before: unknown; after: unknown }> {
|
||||
if (JSON.stringify(before) === JSON.stringify(after)) return [];
|
||||
if (!record(before) || !record(after))
|
||||
return [{ path, before: clone(before), after: clone(after) }];
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
||||
.sort()
|
||||
.flatMap((name) => reviewDesign(before[name], after[name], `${path}.${name}`));
|
||||
}
|
||||
export function diffDesign(before: unknown, after: unknown, path = "$"): string[] {
|
||||
return reviewDesign(before, after, path).map((entry) => entry.path);
|
||||
}
|
||||
|
||||
export function exportDesign(system: DesignSystem, options: ResolveOptions = {}) {
|
||||
const diagnostics = validateDesignSystem(system, options);
|
||||
if (diagnostics.length) throw Error(diagnostics.map((d) => `${d.path}: ${d.message}`).join("\n"));
|
||||
const compiled = compileDesignSystem(system, options);
|
||||
const theme = {
|
||||
default: options.mode ?? system.defaultMode,
|
||||
designSystem: { system, ...options },
|
||||
};
|
||||
const exchange = Object.fromEntries(
|
||||
Object.entries(compiled.resolved.tokens).map(([name, token]) => [
|
||||
name,
|
||||
{ $type: token.type, $value: token.value },
|
||||
]),
|
||||
);
|
||||
return {
|
||||
canonical: json(system),
|
||||
css: compiled.css,
|
||||
theme: json(theme),
|
||||
tokens: json(exchange),
|
||||
report: json({
|
||||
system: system.id,
|
||||
diagnostics,
|
||||
components: Object.keys(COMPONENT_CONTRACTS).length,
|
||||
recipes: Object.keys(compiled.resolved.recipes),
|
||||
limitations:
|
||||
"Only declared parts are styled. Native, provider and content-owned regions retain their documented boundaries.",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Browser-safe state machine. Raw partial edits never replace the last valid preview. */
|
||||
export class StudioSession {
|
||||
locks: DecisionLock[] = [];
|
||||
source: string;
|
||||
valid: DesignSystem;
|
||||
diagnostics: DesignDiagnostic[] = [];
|
||||
history: StudioRevision[];
|
||||
cursor = 0;
|
||||
applied: DesignSystem;
|
||||
storageError = "";
|
||||
private savedRecovery?: string;
|
||||
/** Untouched saved bytes when a future or damaged draft cannot be restored. */
|
||||
get recoverySource(): string | undefined {
|
||||
return this.savedRecovery;
|
||||
}
|
||||
constructor(
|
||||
system: DesignSystem = CORE_DESIGN_SYSTEMS.square,
|
||||
private storage?: StudioStorage,
|
||||
) {
|
||||
if (validateDesignSystem(system).length) throw Error("Initial system must validate.");
|
||||
this.valid = clone(system);
|
||||
this.applied = clone(system);
|
||||
this.source = json(system);
|
||||
this.history = [{ source: this.source, label: "Opened system" }];
|
||||
}
|
||||
restore(): boolean {
|
||||
let saved: string | null | undefined;
|
||||
try {
|
||||
saved = this.storage?.getItem(key);
|
||||
if (!saved) return false;
|
||||
if (saved.length > 8_000_000) throw Error("Saved history exceeds 8 MB.");
|
||||
const input = JSON.parse(saved);
|
||||
if (input?.storageVersion !== undefined && input.storageVersion !== 1)
|
||||
throw Error("Unsupported saved draft version; retain the original bytes.");
|
||||
if (
|
||||
!record(input) ||
|
||||
!Array.isArray(input.history) ||
|
||||
input.history.length < 1 ||
|
||||
input.history.length > 25 ||
|
||||
!Number.isInteger(input.cursor)
|
||||
)
|
||||
throw Error("Invalid local history.");
|
||||
const history = input.history as StudioRevision[];
|
||||
if (
|
||||
history.some((r) => !r || typeof r.source !== "string" || typeof r.label !== "string") ||
|
||||
(input.cursor as number) < 0 ||
|
||||
(input.cursor as number) >= history.length
|
||||
)
|
||||
throw Error("Invalid local revisions.");
|
||||
const valid = importDesign(json(input.valid));
|
||||
const applied = importDesign(json(input.applied));
|
||||
if (!valid.system || !applied.system)
|
||||
throw Error("Saved valid system could not be restored.");
|
||||
validateLocks(input.locks ?? []);
|
||||
this.locks = structuredClone((input.locks ?? []) as DecisionLock[]);
|
||||
this.valid = valid.system;
|
||||
this.applied = applied.system;
|
||||
this.history = clone(history);
|
||||
this.cursor = input.cursor as number;
|
||||
this.source = history[this.cursor]!.source;
|
||||
this.check();
|
||||
this.savedRecovery = undefined;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.savedRecovery = saved ?? undefined;
|
||||
this.storageError = `Local draft could not be restored: ${(error as Error).message}`;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private check() {
|
||||
const report = importDesign(this.source);
|
||||
this.diagnostics = report.diagnostics;
|
||||
if (report.system) {
|
||||
try {
|
||||
this.diagnostics = checkDecisionLocks(this.valid, report.system, this.locks);
|
||||
} catch (error) {
|
||||
this.diagnostics = [diagnostic("WRN-STUDIO-LOCK", (error as Error).message)];
|
||||
}
|
||||
if (!this.diagnostics.length) this.valid = report.system;
|
||||
}
|
||||
}
|
||||
persist() {
|
||||
if (this.savedRecovery !== undefined) {
|
||||
this.storageError =
|
||||
"Autosave unavailable. Saved recovery data is protected. Export recoverySource before resetting local storage.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.storage?.setItem(
|
||||
key,
|
||||
json({
|
||||
storageVersion: 1,
|
||||
history: this.history,
|
||||
cursor: this.cursor,
|
||||
valid: this.valid,
|
||||
applied: this.applied,
|
||||
locks: this.locks,
|
||||
}),
|
||||
);
|
||||
this.storageError = "";
|
||||
} catch {
|
||||
this.storageError = "Autosave unavailable or full. Export your draft to keep it.";
|
||||
}
|
||||
}
|
||||
edit(source: string, label = "Edited source") {
|
||||
if (source.length > limit) throw Error("Draft exceeds 2 MB.");
|
||||
if (source === this.source) return;
|
||||
this.source = source;
|
||||
this.history = this.history.slice(0, this.cursor + 1);
|
||||
this.history.push({ source, label });
|
||||
this.history = this.history.slice(-25);
|
||||
this.cursor = this.history.length - 1;
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
replace(system: DesignSystem, label: string) {
|
||||
this.edit(json(system), label);
|
||||
}
|
||||
setLocks(locks: DecisionLock[]) {
|
||||
validateLocks(locks);
|
||||
this.locks = structuredClone(locks);
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
undo() {
|
||||
if (this.cursor > 0) {
|
||||
this.source = this.history[--this.cursor]!.source;
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
redo() {
|
||||
if (this.cursor < this.history.length - 1) {
|
||||
this.source = this.history[++this.cursor]!.source;
|
||||
this.check();
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
mutate(change: (system: DesignSystem) => void, label: string) {
|
||||
if (this.diagnostics.length)
|
||||
throw Error("Resolve source errors before using structured editors.");
|
||||
const next = clone(this.valid);
|
||||
change(next);
|
||||
this.replace(next, label);
|
||||
}
|
||||
resolved(options: ResolveOptions = {}) {
|
||||
return resolveDesignSystem(this.valid, options);
|
||||
}
|
||||
changes() {
|
||||
return diffDesign(this.applied, this.valid);
|
||||
}
|
||||
markApplied() {
|
||||
if (this.diagnostics.length) throw Error("Resolve errors before applying.");
|
||||
this.applied = clone(this.valid);
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { guidanceHtml, guidanceCss } from "./guidance-shell.ts";
|
||||
export const studioHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WrNexus Design Studio</title><link rel="stylesheet" href="/studio.css"><script type="module" src="/studio.js"></script></head><body>
|
||||
<header><a class="brand" href="/">WrNexus <strong>Design Studio</strong></a><span id="save-state" role="status">Local workspace</span><button id="undo">Undo</button><button id="redo">Redo</button><button id="export">Export JSON</button><button id="review" class="primary">Review changes</button></header>
|
||||
<main><aside><p class="eyebrow">Workspace</p><h1>Your system.<br>Your components.</h1><p class="intro">Edit one design system and preview it on the existing component library.</p><label>Start from a preset<select id="preset"><option value="">Choose a preset</option><option value="square">Square / Industrial</option><option value="soft">Soft / Organic</option><option value="angular">Angular / Lo-Fi</option></select></label><div class="row"><button id="new">Create new</button><button id="duplicate">Duplicate</button></div><label>System name<input id="system-name"></label><label>System ID<input id="system-id"></label>
|
||||
<nav aria-label="Editors"><button data-tab="guided">Guided design</button><button data-tab="foundations" aria-current="page">Foundations</button><button data-tab="components">Components</button><button data-tab="source">Source & import</button><button data-tab="history">History</button><button data-tab="review">Review & apply</button></nav><p class="footnote">Local drafts stay in this browser. Export a file to keep a portable copy.</p></aside>
|
||||
<section class="workspace"><div class="workspace-title"><div><p class="eyebrow">Live authoring</p><h2 id="editor-title">Foundations</h2></div><span id="validation-state" class="badge">Checking</span></div><div id="diagnostics" role="status" aria-live="polite"></div>
|
||||
${guidanceHtml}
|
||||
<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="components" class="editor" hidden><div class="row"><label>Find a component<input type="search" id="component-search" placeholder="Search 104 components"></label><label>Component<select id="component"></select></label></div><div id="contract"></div><div class="row"><label>Part<select id="part"></select></label><label>Property<select id="property"></select></label></div><div class="row"><label>Recipe value<input id="recipe-value" placeholder="@shape.control or 8px"></label><button id="set-recipe">Set default</button><button id="remove-recipe">Remove default</button></div><label>Complete component recipe JSON<textarea id="recipe-source" rows="12" spellcheck="false"></textarea></label><button id="save-recipe">Save component recipe</button><p class="help">Recipe JSON supports the contract’s variants, compound conditions and states. All edits pass through engine validation.</p></section>
|
||||
<section id="source" class="editor" hidden><label>Canonical design system JSON<textarea id="source-text" rows="22" spellcheck="false"></textarea></label><button id="save-source">Update draft</button><button id="download-draft">Export raw draft</button><details open><summary>Import a design</summary><label>Format<select id="import-format"><option value="canonical">Canonical design system JSON</option><option value="legacy">WrNexus theme JSON</option><option value="tokens">Exchange tokens: literal scalar / alias subset</option></select></label><label>Choose a local JSON file<input id="import-file" type="file" accept=".json,application/json"></label><label>Or paste JSON<textarea id="import-text" rows="7" spellcheck="false"></textarea></label><button id="inspect-import">Inspect import</button><pre id="import-report"></pre><button id="accept-import" disabled>Use imported design</button></details></section>
|
||||
<section id="history" class="editor" hidden><p>Up to 25 local revisions, including incomplete source edits. Undo and redo also restore invalid drafts.</p><ol id="revisions"></ol></section>
|
||||
<section id="review-panel" class="editor" hidden><h3>Review before applying</h3><p id="target"></p><p>Apply writes a validated system, compiled CSS and theme configuration to the configured local application folder. Existing application configuration is preserved. Import the generated theme configuration to use it.</p><pre id="changes"></pre><div class="row"><button id="apply" class="primary">Apply to local folder</button><button id="rollback">Rollback last apply</button></div><h3>Portable exports</h3><div class="row"><button data-export="canonical">Canonical JSON</button><button data-export="css">Compiled CSS</button><button data-export="theme">Theme config</button><button data-export="tokens">Exchange tokens</button><button data-export="report">Compatibility report</button></div><p class="help">CSS and theme configuration are alternative integration paths. Publication and deployment are separate.</p><pre id="integration"></pre></section>
|
||||
</section><section class="preview"><div class="preview-heading"><div><p class="eyebrow">Same content, different decisions</p><h2>Preview</h2></div><button id="refresh-preview">Refresh</button></div><div class="preview-controls"><label>Mode<select id="preview-mode"></select></label><label>Density<select id="preview-density"></select></label><label>Width<select id="preview-width"><option value="100%">Responsive</option><option value="390px">390 px</option><option value="768px">768 px</option><option value="1280px">1280 px</option></select></label><label>Direction<select id="preview-direction"><option value="ltr">LTR</option><option value="rtl">RTL</option></select></label><label>Content<select id="preview-content"><option value="composed">Composed page</option><option value="catalog">Component gallery</option></select></label><label>Compare<select id="compare"><option value="">No comparison</option><option value="applied">Last applied</option><option value="square">Square</option><option value="soft">Soft</option><option value="angular">Angular</option></select></label></div><p class="help">Previews use isolated frames. Invalid drafts keep the last valid design. Gallery shows default component states; provider regions retain their own styling.</p><div class="frames"><div><p id="preview-label">Current draft</p><div class="frame-scroll"><iframe id="preview-frame" title="Current design preview" sandbox="allow-scripts"></iframe></div></div><div id="comparison" hidden><p>Comparison</p><div class="frame-scroll"><iframe id="compare-frame" title="Comparison design preview" sandbox="allow-scripts"></iframe></div></div></div></section></main><div id="message" role="status" aria-live="polite"></div></body></html>`;
|
||||
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readDesignPackage } from "@wrnexus/design-system/package";
|
||||
import { checkDecisionLocks, designRevision } from "./decisions.ts";
|
||||
import { reviewDesign, type StudioSession } from "./session.ts";
|
||||
/** Review-only upgrade planning. Adoption always revalidates bytes, draft revision and locks. */
|
||||
export async function planDesignUpgrade(session: StudioSession, source: string) {
|
||||
if (session.diagnostics.length)
|
||||
throw Error("WRN-STUDIO-UPGRADE: resolve source errors before planning an upgrade.");
|
||||
const parsed = await readDesignPackage(source);
|
||||
const next = parsed.package.system;
|
||||
if (next.id !== session.valid.id)
|
||||
throw Error(
|
||||
"WRN-STUDIO-UPGRADE: system identity changed; import a different design explicitly.",
|
||||
);
|
||||
const diagnostics = checkDecisionLocks(session.valid, next, session.locks);
|
||||
return {
|
||||
baseRevision: await designRevision(session.valid, session.locks),
|
||||
package: parsed.package,
|
||||
migrations: parsed.migrations,
|
||||
changes: reviewDesign(session.valid, next),
|
||||
diagnostics,
|
||||
canApply: !diagnostics.length,
|
||||
};
|
||||
}
|
||||
export async function applyDesignUpgrade(
|
||||
session: StudioSession,
|
||||
source: string,
|
||||
baseRevision: string,
|
||||
) {
|
||||
const plan = await planDesignUpgrade(session, source);
|
||||
if (plan.baseRevision !== baseRevision)
|
||||
throw Error("WRN-STUDIO-CONFLICT: draft or locks changed; review the upgrade again.");
|
||||
if (!plan.canApply) throw Error("WRN-STUDIO-UPGRADE-LOCK: upgrade changes locked decisions.");
|
||||
session.replace(plan.package.system, "Adopted design package " + plan.package.system.version);
|
||||
return plan;
|
||||
}
|
||||
Reference in new issue
Block a user