Files
WRNexusJS/packages/design-studio/src/session.ts
T

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();
}
}