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 = (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 => !!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 = {}; 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 = { color: "color", dimension: "dimension", number: "number", fontFamily: "fontFamily", fontWeight: "fontWeight", duration: "duration", }; const walk = ( group: Record, 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(); } }