370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
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();
|
|
}
|
|
}
|