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,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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user