@divlet/core 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AGENTS.md +3 -2
  2. package/BACKLOG.md +4 -3
  3. package/CHANGELOG.md +17 -2
  4. package/README.md +16 -6
  5. package/dist/core/lib/acceptance.d.ts +18 -0
  6. package/dist/core/lib/acceptance.js +29 -0
  7. package/dist/core/lib/assets.js +1 -1
  8. package/dist/core/lib/browser-board.d.ts +41 -0
  9. package/dist/core/lib/browser-board.js +203 -0
  10. package/dist/core/lib/browser-mcp.d.ts +131 -0
  11. package/dist/core/lib/browser-mcp.js +448 -0
  12. package/dist/core/lib/browser-media-type.d.ts +4 -0
  13. package/dist/core/lib/browser-media-type.js +7 -0
  14. package/dist/core/lib/browser-runtime.d.ts +598 -0
  15. package/dist/core/lib/browser-runtime.js +674 -0
  16. package/dist/core/lib/codec.js +2 -1
  17. package/dist/core/lib/contract.d.ts +1 -152
  18. package/dist/core/lib/contract.js +3 -359
  19. package/dist/core/lib/diff.js +1 -1
  20. package/dist/core/lib/prepare.d.ts +27 -0
  21. package/dist/core/lib/prepare.js +41 -0
  22. package/dist/core/lib/project.js +3 -3
  23. package/dist/core/lib/render.d.ts +2 -9
  24. package/dist/core/lib/render.js +10 -35
  25. package/dist/core/lib/runtime.d.ts +1 -1
  26. package/dist/core/lib/runtime.js +1 -21
  27. package/dist/core/lib/tools.d.ts +154 -0
  28. package/dist/core/lib/tools.js +364 -0
  29. package/dist/core/lib/validation.d.ts +5 -0
  30. package/dist/core/lib/validation.js +21 -0
  31. package/dist/skills/divlet/SKILL.md +7 -1
  32. package/docs/README.md +2 -1
  33. package/docs/agents.md +11 -1
  34. package/docs/architecture.md +15 -7
  35. package/docs/board.md +1 -1
  36. package/docs/browser-mcp.md +108 -0
  37. package/docs/browser-runtime.md +6 -4
  38. package/docs/publishing.md +54 -5
  39. package/docs/testing.md +16 -3
  40. package/examples/README.md +2 -2
  41. package/examples/landing/landing.divlet +1 -1
  42. package/package.json +24 -5
  43. package/skills/divlet/SKILL.md +7 -1
@@ -0,0 +1,674 @@
1
+ /*
2
+ * Owns: a browser-local project writer — one in-memory accepted state for a `.divlet` project opened in a page, with the same
3
+ * tool-facing behaviour as the Node runtime for reading, frame/project patches, structural edits, frame lifecycle, work marks
4
+ * and guarded Undo/Redo. Excludes: filesystem, watcher, HTTP, static output, fonts, embedded asset bytes, Markdown sites and
5
+ * agent conversation; those stay with the local Node host and are reported as absent capabilities, never emulated.
6
+ *
7
+ * Validation, patch application, operations, component expansion, rendering checks, diffs, acceptance rules and the history
8
+ * line are the shared kernel modules the Node runtime uses. The orchestration below mirrors `runtime.js` step for step;
9
+ * `core/tests/browser-runtime.test.js` runs the same tool sequences through both writers and compares their results.
10
+ * Persistence is a host callback that receives the serialized project after each accepted change.
11
+ */
12
+ import { applyDocumentPatch, colourNames, inspectDocument, validateDocument, DivletError, LIMITS } from "./model.js";
13
+ import { diffDocuments, matchesPatch, summarizeChange, touchedNodeIds } from "./diff.js";
14
+ import { operationPatch } from "./ops.js";
15
+ import { HistoryLine } from "./history.js";
16
+ import { readProject, writeProject, patchSettings, listComponents, instancesOf, expandFrame } from "./project.js";
17
+ import { exportHtml } from "./codec.js";
18
+ import { prepareFrameWith } from "./prepare.js";
19
+ import { SETTINGS_FIELDS, sameDocument, sameSettings, settingsPatch, assertExpectedVersion, assertExpectedRender } from "./acceptance.js";
20
+ import { sameJson } from "./validation.js";
21
+ import { runOnRuntime, toolList } from "./tools.js";
22
+ const HISTORY_LIMIT = 500;
23
+ const UNDO_LIMIT = 200;
24
+ const capped = (list, limit = 50) => list.slice(0, limit);
25
+ const clone = (value) => structuredClone(value);
26
+ function fail(code, message, details) { throw new DivletError(code, message, details); }
27
+ const randomId = () => [...globalThis.crypto.getRandomValues(new Uint8Array(12))].map(byte => byte.toString(16).padStart(2, '0')).join('');
28
+ /** Tools this writer runs. `open_divlet` belongs to hosts that can open files; `create_frame` is a hidden host route. */
29
+ export const BROWSER_TOOLS = Object.freeze(['read_divlet', 'patch_divlet', 'edit_divlet', 'frame_divlet', 'undo_divlet', 'mark_divlet']);
30
+ export class BrowserRuntime {
31
+ #historyLine = new HistoryLine(randomId);
32
+ #listeners = new Set();
33
+ #queue = Promise.resolve();
34
+ #persist;
35
+ #compiler;
36
+ #stateKey = '';
37
+ #capabilities;
38
+ file;
39
+ projectId;
40
+ settings;
41
+ documents = new Map();
42
+ diagnostics = new Map();
43
+ observations = new Map();
44
+ selection = { ids: [], revision: 0 };
45
+ revision = 0;
46
+ stateVersion = 0;
47
+ renderSeq = 0;
48
+ history = [];
49
+ seq = 0;
50
+ undoStack = [];
51
+ redoStack = [];
52
+ groupCounter = 0;
53
+ work = [];
54
+ /** `conversation: false`: this host relays no board requests; agents reach it through its MCP tools. */
55
+ agent = { connected: false, busy: false, activity: null, since: null, queued: 0, conversation: false };
56
+ closed = false;
57
+ url = '';
58
+ constructor(options) {
59
+ this.file = options.file;
60
+ this.projectId = options.projectId;
61
+ this.#compiler = options.compiler;
62
+ this.#persist = options.persist;
63
+ this.#capabilities = options.capabilities ?? {};
64
+ }
65
+ /** Validate and render a project source; any invalid frame refuses the whole open (there is no last working state yet). */
66
+ static async open(options) {
67
+ const runtime = new BrowserRuntime(options);
68
+ const source = typeof options.source === 'string' ? JSON.parse(options.source) : options.source;
69
+ const { settings, frames } = readProject(source);
70
+ const lookup = new Map(frames.map(frame => [frame.id, frame]));
71
+ const prepared = await runtime.prepareAll(frames.map(frame => frame.id), lookup, settings);
72
+ runtime.settings = settings;
73
+ runtime.documents = new Map([...prepared].map(([id, item]) => [id, runtime.entryFor(item)]));
74
+ runtime.revision = 1; // as the Node host after loading its source
75
+ return runtime;
76
+ }
77
+ /* ---- events (the board's read session subscribes here) ----------------------------------------- */
78
+ subscribe(listener) { this.#listeners.add(listener); return () => { this.#listeners.delete(listener); }; }
79
+ #emit(type, value) { for (const listener of [...this.#listeners]) {
80
+ try {
81
+ listener(type, value);
82
+ }
83
+ catch { /* a view cannot break the writer */ }
84
+ } }
85
+ publish() { this.#emit('state', this.info()); }
86
+ newGroup() { return `g${++this.groupCounter}`; }
87
+ enqueue(task) {
88
+ if (this.closed)
89
+ return Promise.reject(new DivletError('CLOSED', 'The divlet runtime has closed.'));
90
+ const result = this.#queue.then(() => { if (this.closed)
91
+ fail('CLOSED', 'The divlet runtime has closed.'); return task(); });
92
+ this.#queue = result.catch(() => { });
93
+ return result;
94
+ }
95
+ /* ---- state ---------------------------------------------------------------------------------------- */
96
+ usedBy(id, entries = this.documents) {
97
+ return [...entries.values()].filter(entry => entry.document.id !== id && entry.uses.some(key => key.startsWith(`${id}/`))).map(entry => entry.document.id);
98
+ }
99
+ describe(entry) {
100
+ const { document } = entry;
101
+ return {
102
+ id: document.id, name: document.name, version: document.version, render: entry.render, savedVersion: entry.savedVersion,
103
+ artboard: document.artboard, metadata: document.metadata,
104
+ components: Object.values(document.nodes).filter((node) => node.type === 'element' && node.reusable).map(node => node.id),
105
+ instances: Object.values(document.nodes).filter(node => node.type === 'ref').length,
106
+ uses: entry.uses, usedBy: this.usedBy(document.id),
107
+ };
108
+ }
109
+ historyHead() {
110
+ return { seq: this.seq, pointId: this.#historyLine.currentId, navigation: true, canUndo: this.undoStack.length > 0, canRedo: this.redoStack.length > 0, undoLabel: this.undoStack.at(-1)?.label ?? null, redoLabel: this.redoStack.at(-1)?.label ?? null };
111
+ }
112
+ info() {
113
+ const state = {
114
+ projectId: this.projectId,
115
+ url: this.url,
116
+ revision: this.revision,
117
+ project: {
118
+ file: this.file, format: 'divlet',
119
+ capabilities: { properties: true, settings: false, frames: true, saveAs: false, ...this.#capabilities },
120
+ name: this.settings.name, version: this.settings.version, tokens: this.settings.tokens, css: this.settings.css, metadata: this.settings.metadata, outputDir: this.settings.outputDir, resources: [],
121
+ },
122
+ documents: [...this.documents.values()].map(entry => this.describe(entry)),
123
+ diagnostics: [...this.diagnostics.values()],
124
+ selection: clone(this.selection),
125
+ history: this.historyHead(),
126
+ clients: 0,
127
+ agent: { ...this.agent },
128
+ work: clone(this.work),
129
+ site: null,
130
+ clientPlugins: [],
131
+ };
132
+ const key = JSON.stringify(state);
133
+ if (key !== this.#stateKey) {
134
+ this.#stateKey = key;
135
+ this.stateVersion++;
136
+ }
137
+ return { ...state, stateVersion: this.stateVersion };
138
+ }
139
+ getEntry(id) {
140
+ const entry = typeof id === 'string' ? this.documents.get(id) : undefined;
141
+ if (!entry)
142
+ fail('NOT_FOUND', `Unknown frame: ${id}. read_divlet view:project lists the frames.`);
143
+ return entry;
144
+ }
145
+ components() {
146
+ const frames = [...this.documents.values()].map(entry => entry.document);
147
+ const usage = new Map();
148
+ for (const frame of frames)
149
+ for (const instance of instancesOf(frame)) {
150
+ const key = `${instance.frame}/${instance.node}`;
151
+ if (!usage.has(key))
152
+ usage.set(key, []);
153
+ usage.get(key).push(`${frame.id}/${instance.id}`);
154
+ }
155
+ return listComponents(frames).map(component => ({ ...component, instances: usage.get(component.ref) ?? [] }));
156
+ }
157
+ readTimeline() { return this.#historyLine.snapshot(this.undoStack, this.redoStack); }
158
+ readHistory({ since = 0, limit = 50, documentId, actor } = {}) {
159
+ if (!Number.isInteger(since) || since < 0 || !Number.isInteger(limit) || limit < 1 || limit > 500)
160
+ fail('INVALID_QUERY', 'since must be ≥ 0 and limit 1–500.');
161
+ let changes = this.history.filter(change => change.seq > since && (!documentId || change.documentId === documentId) && (!actor || change.actor === actor));
162
+ const truncated = changes.length > limit;
163
+ changes = changes.slice(-limit);
164
+ return { ...this.historyHead(), timeline: this.readTimeline(), truncated, changes: clone(changes) };
165
+ }
166
+ read({ view = 'project', documentId, ids, query, depth, limit, route, since } = {}) {
167
+ if (view === 'project')
168
+ return this.info();
169
+ if (view === 'components')
170
+ return { projectVersion: this.settings.version, components: this.components() };
171
+ if (view === 'history')
172
+ return this.readHistory({ since: since ?? undefined, limit: limit ?? undefined, documentId });
173
+ if (view === 'selection')
174
+ return { projectRevision: this.revision, selection: clone(this.selection) };
175
+ if (view === 'diagnostics')
176
+ return { revision: this.revision, diagnostics: [...this.diagnostics.values()] };
177
+ if (view === 'site') {
178
+ void route;
179
+ fail('NO_SITE', 'This project has no Markdown site in the browser host.');
180
+ }
181
+ const entry = this.getEntry(documentId || this.selection.ids[0] || this.documents.keys().next().value);
182
+ if (view === 'document' || view === 'frame')
183
+ return clone(entry.document);
184
+ if (view === 'nodes')
185
+ return { documentId: entry.document.id, version: entry.document.version, nodes: inspectDocument(entry.document, { ids, query, depth, limit }, { limits: LIMITS }) };
186
+ if (view === 'preview')
187
+ return { documentId: entry.document.id, version: entry.document.version, observation: clone(this.observations.get(entry.document.id) || null) };
188
+ fail('INVALID_VIEW', `Unknown read view: ${view}`);
189
+ }
190
+ /** The preview page of one frame (no bridge: the composition injects its own preview channel). */
191
+ render(id) {
192
+ const entry = this.getEntry(id);
193
+ return { documentId: id, version: entry.document.version, render: entry.render, html: entry.html };
194
+ }
195
+ /** Clean standalone HTML of a frame or of some of its nodes, as the Node host exports it (no embedded assets here). */
196
+ exportDocument(id, { nodeId, nodeIds, expectedVersion, expectedProjectVersion } = {}) {
197
+ const entry = this.getEntry(id);
198
+ assertExpectedVersion(expectedVersion, entry.document);
199
+ assertExpectedVersion(expectedProjectVersion, this.settings, 'project');
200
+ const expanded = expandFrame(entry.document, frameId => this.documents.get(frameId)?.document, this.settings).document;
201
+ if (nodeId || nodeIds !== undefined) {
202
+ const ids = nodeIds ?? [nodeId];
203
+ if (!Array.isArray(ids) || !ids.length || ids.length > 100 || ids.some(node => typeof node !== 'string' || !Object.hasOwn(entry.document.nodes, node)))
204
+ fail('NOT_FOUND', 'Export requires existing node IDs in one frame.');
205
+ return { documentId: id, version: entry.document.version, ...(nodeId ? { nodeId } : { nodeIds: ids }), html: ids.map((item) => exportHtml(expanded, { nodeId: item, limits: LIMITS })).join('\n') };
206
+ }
207
+ const compiled = /<style data-divlet-compiled>([\s\S]*?)<\/style>/.exec(entry.html)?.[1] ?? '';
208
+ return { documentId: id, version: entry.document.version, html: exportHtml(expanded, { compiledCss: compiled, limits: LIMITS }), fileName: `${id}.html` };
209
+ }
210
+ /** The serialized project, as the Node host would write it to its `.divlet` file. */
211
+ source() { return writeProject(this.settings, [...this.documents.values()].map(entry => entry.document)); }
212
+ /* ---- preparation ------------------------------------------------------------------------------- */
213
+ prepare(input, { frames, settings = this.settings } = {}) {
214
+ return prepareFrameWith(input, { lookup: id => frames ? frames.get(id) : this.documents.get(id)?.document, settings, limits: LIMITS, compiler: this.#compiler });
215
+ }
216
+ entryFor(prepared, previous) {
217
+ return { ...(previous || {}), ...prepared, savedVersion: prepared.document.version, render: previous && previous.html === prepared.html ? previous.render : ++this.renderSeq };
218
+ }
219
+ dependentsOf(ids, entries = this.documents) {
220
+ const out = new Set();
221
+ for (const id of ids)
222
+ for (const other of this.usedBy(id, entries))
223
+ if (!ids.includes(other))
224
+ out.add(other);
225
+ return [...out];
226
+ }
227
+ async prepareAll(ids, frames, settings) {
228
+ const prepared = new Map();
229
+ for (const id of ids) {
230
+ try {
231
+ prepared.set(id, await this.prepare(frames.get(id), { frames, settings }));
232
+ }
233
+ catch (error) {
234
+ throw new DivletError(error.code || 'INVALID_DOCUMENT', `Frame ${id}: ${error.message}`, error.details);
235
+ }
236
+ }
237
+ return prepared;
238
+ }
239
+ /** Persist the candidate through the host. A failing store keeps the accepted state and reports a diagnostic. */
240
+ async persist(next, settings) {
241
+ const text = writeProject(settings, [...next.values()].map(entry => entry.document));
242
+ if (!this.#persist)
243
+ return;
244
+ try {
245
+ await this.#persist(text);
246
+ this.diagnostics.delete('storage');
247
+ }
248
+ catch (error) {
249
+ this.diagnostics.set('storage', { path: 'storage', code: 'STORAGE_FAILED', message: `The browser could not store this project: ${error?.message || error}`, at: new Date().toISOString() });
250
+ }
251
+ }
252
+ /* ---- history ------------------------------------------------------------------------------------ */
253
+ record({ actor = 'agent', kind = 'patch', documentId, name, fromVersion, toVersion, group, label, from, to, text }) {
254
+ const summary = from && to ? summarizeChange(from, to) : { fields: [], artboard: {}, nodes: { added: [], removed: [], changed: {} }, touched: 0, text: kind };
255
+ if (text)
256
+ summary.text = text;
257
+ const change = {
258
+ seq: ++this.seq,
259
+ at: new Date().toISOString(),
260
+ actor, kind, documentId, name: name ?? to?.name ?? from?.name, fromVersion, toVersion,
261
+ group: group ?? null, label: label ?? null,
262
+ summary: summary.text,
263
+ fields: summary.fields,
264
+ artboard: summary.artboard,
265
+ nodes: { added: capped(summary.nodes.added), removed: capped(summary.nodes.removed), changed: Object.fromEntries(Object.entries(summary.nodes.changed).slice(0, 50)) },
266
+ touched: capped(touchedNodeIds(summary)),
267
+ };
268
+ if (documentId && this.documents.has(documentId))
269
+ change.render = this.documents.get(documentId).render;
270
+ this.history.push(change);
271
+ if (this.history.length > HISTORY_LIMIT)
272
+ this.history.splice(0, this.history.length - HISTORY_LIMIT);
273
+ this.#emit('change', change);
274
+ return change;
275
+ }
276
+ pushUndo(entry, { seq = this.seq, firstSeq = seq } = {}) {
277
+ const top = this.undoStack.at(-1);
278
+ const merged = !!entry.group && top?.group === entry.group;
279
+ if (merged) {
280
+ top.items.push(...entry.items);
281
+ if (entry.label)
282
+ top.label = entry.label;
283
+ }
284
+ else
285
+ this.undoStack.push(entry);
286
+ if (this.undoStack.length > UNDO_LIMIT)
287
+ this.undoStack.shift();
288
+ this.redoStack.length = 0;
289
+ const accepted = merged ? top : entry;
290
+ this.#historyLine.accept(accepted, { seq, firstSeq, label: accepted.label }, merged);
291
+ }
292
+ /* ---- writes ------------------------------------------------------------------------------------- */
293
+ create({ document, actor = 'agent', group, label, undoable = true }) {
294
+ return this.enqueue(() => this._create({ document, actor, group, label, undoable }));
295
+ }
296
+ async _create({ document, actor, group, label, undoable, kind = 'create', text }) {
297
+ const candidate = validateDocument(document);
298
+ if (this.documents.has(candidate.id))
299
+ fail('ALREADY_EXISTS', `Frame ${candidate.id} already exists.`);
300
+ const frames = new Map([...this.documents].map(([id, entry]) => [id, entry.document]));
301
+ frames.set(candidate.id, candidate);
302
+ const prepared = await this.prepare(candidate, { frames });
303
+ const id = prepared.document.id;
304
+ const next = new Map(this.documents);
305
+ next.set(id, this.entryFor(prepared));
306
+ await this.persist(next, this.settings);
307
+ this.documents = next;
308
+ this.revision++;
309
+ const entry = next.get(id);
310
+ const count = Object.keys(entry.document.nodes).length;
311
+ const change = this.record({ actor, kind, documentId: id, toVersion: entry.document.version, group, label, to: entry.document, from: { ...entry.document, nodes: {} }, text: text ?? `${kind === 'create' ? 'created' : 'restored'} “${entry.document.name}” · ${count} node${count === 1 ? '' : 's'}` });
312
+ if (undoable)
313
+ this.pushUndo({ group: group ?? this.newGroup(), label: label ?? `create ${entry.document.name}`, items: [{ type: 'create', documentId: id, index: next.size - 1, document: clone(entry.document) }] }, { seq: change.seq });
314
+ else
315
+ this.#historyLine.reset({ seq: change.seq, label: change.summary });
316
+ this.publish();
317
+ return { documentId: id, version: entry.document.version, render: entry.render, revision: this.revision, seq: change.seq, saved: true };
318
+ }
319
+ async _remove(documentId, { actor, group, label, expectedVersion, undoable = true, kind = 'delete' }) {
320
+ const entry = this.getEntry(documentId);
321
+ assertExpectedVersion(expectedVersion, entry.document);
322
+ const users = this.usedBy(documentId);
323
+ if (users.length)
324
+ fail('COMPONENT_IN_USE', `“${entry.document.name}” has components used by ${users.join(', ')}. Detach or replace those instances first.`, { usedBy: users });
325
+ const index = [...this.documents.keys()].indexOf(documentId);
326
+ const next = new Map(this.documents);
327
+ next.delete(documentId);
328
+ await this.persist(next, this.settings);
329
+ this.documents = next;
330
+ this.work = this.work.filter(item => item.documentId !== documentId);
331
+ this.observations.delete(documentId);
332
+ this.selection = { ...this.selection, ids: this.selection.ids.filter(id => id !== documentId), revision: this.selection.revision + 1 };
333
+ delete this.selection.selectedNodes;
334
+ this.revision++;
335
+ const change = this.record({ actor, kind, documentId, name: entry.document.name, fromVersion: entry.document.version, group, label, from: entry.document, to: { ...entry.document, nodes: {} }, text: `removed “${entry.document.name}”` });
336
+ if (undoable)
337
+ this.pushUndo({ group: group ?? this.newGroup(), label: label ?? `delete ${entry.document.name}`, items: [{ type: 'delete', documentId, index, document: clone(entry.document) }] }, { seq: change.seq });
338
+ else
339
+ this.#historyLine.reset({ seq: change.seq, label: change.summary });
340
+ this.publish();
341
+ return { documentId, seq: change.seq, revision: this.revision };
342
+ }
343
+ patch({ documentId, expectedVersion, expectedRender, patch, actor = 'agent', group, label, undoable = true }) {
344
+ return this.enqueue(() => this._patch({ documentId, expectedVersion, expectedRender, patch, actor, group, label, undoable }));
345
+ }
346
+ async _patch({ documentId, expectedVersion, expectedRender, patch, actor = 'agent', group, label, undoable = true, kind = 'patch' }) {
347
+ const previous = this.getEntry(documentId);
348
+ if (!Number.isInteger(expectedVersion) || expectedVersion !== previous.document.version)
349
+ fail('REVISION_CONFLICT', `Expected frame version ${previous.document.version}; received ${expectedVersion}. Read the frame again and rebuild the change.`, { actualVersion: previous.document.version });
350
+ assertExpectedRender(expectedRender, previous);
351
+ const candidate = applyDocumentPatch(previous.document, patch, { colourNames: colourNames(this.settings.tokens, previous.document.tokens) });
352
+ candidate.version = previous.document.version + 1;
353
+ if (sameDocument(candidate, previous.document)) {
354
+ return { documentId, version: previous.document.version, render: previous.render, revision: this.revision, seq: null, summary: 'no change', changedNodeIds: [], rerendered: [], saved: false };
355
+ }
356
+ const frames = new Map([...this.documents].map(([id, entry]) => [id, entry.document]));
357
+ frames.set(documentId, candidate);
358
+ const dependents = this.dependentsOf([documentId]);
359
+ const prepared = await this.prepareAll([documentId, ...dependents], frames, this.settings);
360
+ const next = new Map(this.documents);
361
+ for (const [id, item] of prepared)
362
+ next.set(id, this.entryFor(item, this.documents.get(id)));
363
+ await this.persist(next, this.settings);
364
+ this.documents = next;
365
+ const entry = next.get(documentId);
366
+ this.revision++;
367
+ const change = this.record({ actor, kind, documentId, fromVersion: previous.document.version, toVersion: entry.document.version, group, label, from: previous.document, to: entry.document });
368
+ if (undoable && change.summary !== 'no change') {
369
+ this.pushUndo({ group: group ?? this.newGroup(), label: label ?? change.summary, items: [{ type: 'patch', documentId, inverse: diffDocuments(entry.document, previous.document), forward: diffDocuments(previous.document, entry.document) }] }, { seq: change.seq });
370
+ }
371
+ else
372
+ this.#historyLine.reset({ seq: change.seq, label: change.summary });
373
+ this.publish();
374
+ const nodeIds = new Set([...Object.keys(previous.document.nodes), ...Object.keys(entry.document.nodes)]);
375
+ const changedNodeIds = [...nodeIds].filter(id => !sameJson(previous.document.nodes[id], entry.document.nodes[id]));
376
+ const dead = changedNodeIds.filter(id => entry.unknown[id]?.length).map(id => `#${id}: ${entry.unknown[id].join(' ')}`);
377
+ const warnings = dead.length ? [`Classes this browser host does not style (no compiled utility, no css rule): ${dead.slice(0, 8).join('; ')}${dead.length > 8 ? ` +${dead.length - 8} nodes` : ''}. Utility compilation needs the local divlet host; use the frame css or inline style here.`] : [];
378
+ return { documentId, version: entry.document.version, render: entry.render, revision: this.revision, seq: change.seq, summary: change.summary, changedNodeIds, rerendered: dependents, saved: true, ...(warnings.length ? { warnings } : {}) };
379
+ }
380
+ patchProject({ expectedVersion, patch, actor = 'agent', group, label, undoable = true }) {
381
+ return this.enqueue(() => this._patchProject({ expectedVersion, patch, actor, group, label, undoable }));
382
+ }
383
+ async _patchProject({ expectedVersion, patch, actor = 'agent', group, label, undoable = true, kind = 'patch' }) {
384
+ if (!Number.isInteger(expectedVersion) || expectedVersion !== this.settings.version)
385
+ fail('REVISION_CONFLICT', `Expected project version ${this.settings.version}; received ${expectedVersion}. Read the project again.`, { actualVersion: this.settings.version });
386
+ if (patch && typeof patch === 'object' && Object.hasOwn(patch, 'assets') && patch.assets !== null)
387
+ fail('ASSETS_UNAVAILABLE', 'Embedded assets need the local divlet host: this browser build does not decode project asset bytes.');
388
+ const previous = this.settings;
389
+ const settings = { ...patchSettings(previous, patch), version: previous.version + 1 };
390
+ const frames = new Map([...this.documents].map(([id, entry]) => [id, entry.document]));
391
+ const prepared = await this.prepareAll([...frames.keys()], frames, settings);
392
+ const next = new Map(this.documents);
393
+ for (const [id, item] of prepared)
394
+ next.set(id, this.entryFor(item, this.documents.get(id)));
395
+ await this.persist(next, settings);
396
+ this.documents = next;
397
+ this.settings = settings;
398
+ this.revision++;
399
+ const fields = SETTINGS_FIELDS.filter(key => !sameJson(previous[key], settings[key]));
400
+ const change = this.record({ actor, kind, documentId: null, name: settings.name, group, label, text: fields.length ? `project ${fields.join(', ')}` : 'no change' });
401
+ if (undoable && fields.length)
402
+ this.pushUndo({ group: group ?? this.newGroup(), label: label ?? `project ${fields.join(', ')}`, items: [{ type: 'project', inverse: settingsPatch(settings, previous), forward: settingsPatch(previous, settings) }] }, { seq: change.seq });
403
+ else if (fields.length)
404
+ this.#historyLine.reset({ seq: change.seq, label: change.summary });
405
+ this.publish();
406
+ return { project: true, version: settings.version, revision: this.revision, seq: change.seq, summary: change.summary, saved: true };
407
+ }
408
+ edit({ documentId, expectedVersion, expectedRender, op, actor = 'user', group, label }) {
409
+ return this.enqueue(async () => {
410
+ const entry = this.getEntry(documentId);
411
+ if (expectedVersion !== entry.document.version)
412
+ fail('REVISION_CONFLICT', `Expected frame version ${entry.document.version}; received ${expectedVersion}. Read the frame again.`, { actualVersion: entry.document.version });
413
+ const { patch, ids, removed } = operationPatch(entry.document, op, { frame: id => this.documents.get(id)?.document, usedBy: this.usedBy(documentId) });
414
+ const subject = (op.ids ?? [op.id ?? ids?.[0]]).filter(Boolean);
415
+ const verb = { remove: 'delete', duplicate: 'duplicate', move: 'move', insert: 'add', text: 'edit text of', wrap: 'wrap', unwrap: 'unwrap', detach: 'detach', rename: 'rename' }[op.type] ?? op.type;
416
+ const result = await this._patch({ documentId, expectedVersion, expectedRender, patch, actor, group, label: label ?? `${verb} ${subject.slice(0, 3).join(', ')}${subject.length > 3 ? ` +${subject.length - 3}` : ''}`.trim() });
417
+ return { ...result, ids: ids ?? [], removed: removed ?? [] };
418
+ });
419
+ }
420
+ freeSpot(rect, gap = 80, extra = []) {
421
+ const others = [...this.documents.values()].map(entry => entry.document.artboard).concat(extra);
422
+ let next = { ...rect };
423
+ for (let attempt = 0; attempt < 500; attempt++) {
424
+ const hit = others.find(o => next.x < o.x + o.width + gap && next.x + next.width + gap > o.x && next.y < o.y + o.height + gap && next.y + next.height + gap > o.y);
425
+ if (!hit)
426
+ return next;
427
+ next = { ...next, x: hit.x + hit.width + gap };
428
+ }
429
+ return next;
430
+ }
431
+ createArtboard({ name, width, height, x, y, from, expectedVersion, actor = 'user', group, label } = {}) {
432
+ return this.enqueue(async () => {
433
+ const taken = new Set(this.documents.keys());
434
+ const slug = String(name || (from ? `${this.getEntry(from).document.name} copy` : 'artboard')).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^[^a-z]+/, '') || 'artboard';
435
+ let id = slug;
436
+ for (let n = 2; taken.has(id); n++)
437
+ id = `${slug}-${n}`;
438
+ let document;
439
+ if (from) {
440
+ assertExpectedVersion(expectedVersion, this.getEntry(from).document, 'duplicated frame');
441
+ const source = clone(this.getEntry(from).document);
442
+ const proposed = { ...source.artboard, x: x ?? source.artboard.x + source.artboard.width + 80, y: y ?? source.artboard.y };
443
+ for (const node of Object.values(source.nodes)) {
444
+ if (node.type === 'element' && node.reusable)
445
+ delete node.reusable;
446
+ if (node.type === 'ref' && !node.ref.includes('/'))
447
+ node.ref = `${from}/${node.ref}`;
448
+ }
449
+ if (width != null)
450
+ proposed.width = width;
451
+ if (height != null)
452
+ proposed.height = height;
453
+ document = { ...source, id, name: name || `${source.name} copy`, version: 1, artboard: x == null && y == null ? this.freeSpot(proposed) : proposed };
454
+ }
455
+ else {
456
+ const rightmost = [...this.documents.values()].reduce((max, entry) => Math.max(max, entry.document.artboard.x + entry.document.artboard.width), -Infinity);
457
+ const top = [...this.documents.values()].reduce((min, entry) => Math.min(min, entry.document.artboard.y), Infinity);
458
+ document = {
459
+ schemaVersion: 1, id, name: name || 'Untitled', version: 1,
460
+ artboard: x != null && y != null ? { x, y, width: width ?? 1280, height: height ?? 800 } : this.freeSpot({ x: x ?? (Number.isFinite(rightmost) ? rightmost + 80 : 80), y: y ?? (Number.isFinite(top) ? top : 100), width: width ?? 1280, height: height ?? 800 }),
461
+ rootId: 'root', tokens: {}, css: '', metadata: {},
462
+ nodes: { root: { id: 'root', type: 'element', tag: 'main', className: 'min-h-screen p-8', attrs: {}, style: {}, metadata: {}, children: [] } },
463
+ };
464
+ }
465
+ return this._create({ document, actor, group, label: label ?? `create ${document.name}`, undoable: true });
466
+ });
467
+ }
468
+ removeDocument({ documentId, expectedVersion, actor = 'user', group, label }) {
469
+ return this.enqueue(() => this._remove(documentId, { expectedVersion, actor, group, label }));
470
+ }
471
+ async _applyEntry(entry, direction, actor) {
472
+ const undoing = direction === 'undo';
473
+ const items = undoing ? [...entry.items].reverse() : entry.items;
474
+ const previous = this.documents;
475
+ const previousSettings = this.settings;
476
+ let frames = new Map([...previous].map(([id, item]) => [id, item.document]));
477
+ let settings = previousSettings;
478
+ let prepared;
479
+ try {
480
+ for (const item of items) {
481
+ const change = undoing ? item.inverse : item.forward;
482
+ const expected = undoing ? item.forward : item.inverse;
483
+ if (item.type === 'patch') {
484
+ const current = frames.get(item.documentId);
485
+ if (!current || !matchesPatch(current, expected, change))
486
+ fail('CANNOT_UNDO', 'Later changes touched the same content.');
487
+ frames.set(item.documentId, { ...applyDocumentPatch(current, change), version: current.version + 1 });
488
+ }
489
+ else if (item.type === 'project') {
490
+ if (!matchesPatch({ ...settings, outputDir: settings.outputDir ?? undefined }, expected, change))
491
+ fail('CANNOT_UNDO', 'Later changes touched the same project fields.');
492
+ settings = patchSettings(settings, change);
493
+ }
494
+ else if ((item.type === 'create') === undoing) {
495
+ const current = frames.get(item.documentId);
496
+ if (!current || !sameDocument(current, item.document))
497
+ fail('CANNOT_UNDO', `Frame ${item.documentId} has changed since this step.`);
498
+ frames.delete(item.documentId);
499
+ }
500
+ else {
501
+ if (frames.has(item.documentId))
502
+ fail('CANNOT_UNDO', `Frame ${item.documentId} already exists.`);
503
+ const ordered = [...frames];
504
+ ordered.splice(item.index ?? ordered.length, 0, [item.documentId, item.document]);
505
+ frames = new Map(ordered);
506
+ }
507
+ }
508
+ for (const [id, frame] of frames) {
509
+ const current = previous.get(id)?.document;
510
+ if (current)
511
+ frames.set(id, sameDocument(current, frame) ? current : { ...frame, version: current.version + 1 });
512
+ }
513
+ settings = { ...settings, version: previousSettings.version + (sameSettings(previousSettings, settings) ? 0 : 1) };
514
+ prepared = await this.prepareAll([...frames.keys()], frames, settings);
515
+ }
516
+ catch (error) {
517
+ fail('CANNOT_UNDO', `This step no longer applies cleanly (${error.message}).`);
518
+ }
519
+ const next = new Map([...prepared].map(([id, item]) => [id, this.entryFor(item, previous.get(id))]));
520
+ const removed = [...previous.keys()].filter(id => !next.has(id));
521
+ await this.persist(next, settings);
522
+ this.documents = next;
523
+ this.settings = settings;
524
+ this.work = this.work.filter(item => next.has(item.documentId));
525
+ for (const id of removed)
526
+ this.observations.delete(id);
527
+ if (removed.length) {
528
+ this.selection = { ...this.selection, ids: this.selection.ids.filter(id => next.has(id)), revision: this.selection.revision + 1 };
529
+ if (this.selection.selectedNodes && !next.has(this.selection.selectedNodes.documentId))
530
+ delete this.selection.selectedNodes;
531
+ }
532
+ this.revision++;
533
+ const group = this.newGroup();
534
+ const meta = { actor, group, kind: direction, label: entry.label };
535
+ if (!sameSettings(previousSettings, settings))
536
+ this.record({ ...meta, documentId: null, name: settings.name, text: `project ${SETTINGS_FIELDS.filter(key => !sameJson(previousSettings[key], settings[key])).join(', ')}` });
537
+ for (const [id, current] of next) {
538
+ const from = previous.get(id)?.document;
539
+ const to = current.document;
540
+ if (from && sameDocument(from, to))
541
+ continue;
542
+ this.record({ ...meta, documentId: id, fromVersion: from?.version, toVersion: to.version, from: from ?? { ...to, nodes: {} }, to });
543
+ }
544
+ for (const id of removed) {
545
+ const from = previous.get(id).document;
546
+ this.record({ ...meta, documentId: id, name: from.name, fromVersion: from.version, from, to: { ...from, nodes: {} }, text: `removed “${from.name}”` });
547
+ }
548
+ }
549
+ async _replay(from, to, direction, { documentId, actor = 'user', steps = 1 } = {}) {
550
+ if (!Number.isInteger(steps) || steps < 1 || steps > UNDO_LIMIT)
551
+ fail('INVALID_QUERY', `steps must be an integer from 1 to ${UNDO_LIMIT}.`);
552
+ const indexes = [];
553
+ for (let i = from.length - 1; i >= 0 && indexes.length < steps; i--)
554
+ if (!documentId || from[i].items.some((item) => item.documentId === documentId || item.documentIds?.includes(documentId)))
555
+ indexes.push(i);
556
+ if (indexes.length < steps)
557
+ fail('NOTHING_TO_UNDO', steps === 1 ? `Nothing to ${direction}.` : `Not enough history to ${direction} ${steps} steps.`);
558
+ const entries = indexes.map(index => from[index]);
559
+ const label = steps === 1 ? entries[0].label : `${direction} ${steps} steps`;
560
+ const seq = this.seq;
561
+ const ordered = direction === 'undo' ? [...entries].reverse() : entries;
562
+ await this._applyEntry({ label, items: ordered.flatMap(entry => entry.items) }, direction, actor);
563
+ for (const index of indexes)
564
+ from.splice(index, 1);
565
+ to.push(...entries);
566
+ this.#historyLine.move(entries, direction, { seq: this.seq > seq ? this.seq : null, firstSeq: this.seq > seq ? seq + 1 : null, label });
567
+ this.publish();
568
+ return { ...this.historyHead(), label };
569
+ }
570
+ navigateHistory(options) {
571
+ return this.enqueue(async () => {
572
+ const { pointId, expectedPoint, actor = 'user' } = options ?? {};
573
+ if (typeof pointId !== 'string' || !pointId || typeof expectedPoint !== 'string' || !expectedPoint)
574
+ fail('INVALID_QUERY', 'pointId and expectedPoint must be nonempty state point IDs.');
575
+ const timeline = this.readTimeline();
576
+ if (expectedPoint !== timeline.current)
577
+ fail('HISTORY_CONFLICT', 'History changed since it was read. Read the current timeline before navigating.');
578
+ const index = timeline.points.findIndex(point => point.id === pointId);
579
+ if (index < 0)
580
+ fail('HISTORY_POINT_UNAVAILABLE', 'That state point is no longer on the retained current history segment.');
581
+ const delta = index - timeline.index;
582
+ if (!delta)
583
+ return { ...this.historyHead(), changed: false };
584
+ const direction = delta < 0 ? 'undo' : 'redo';
585
+ const from = delta < 0 ? this.undoStack : this.redoStack, to = delta < 0 ? this.redoStack : this.undoStack;
586
+ return { ...await this._replay(from, to, direction, { actor, steps: Math.abs(delta) }), changed: true };
587
+ });
588
+ }
589
+ undo(options) { return this.enqueue(() => this._replay(this.undoStack, this.redoStack, 'undo', options)); }
590
+ redo(options) { return this.enqueue(() => this._replay(this.redoStack, this.undoStack, 'redo', options)); }
591
+ /* ---- view state ------------------------------------------------------------------------------- */
592
+ markWork({ documentId, nodeIds = [], note, add = false } = {}) {
593
+ const entry = this.getEntry(documentId);
594
+ const ids = [...new Set((Array.isArray(nodeIds) ? nodeIds : [nodeIds]).filter((id) => typeof id === 'string'))].slice(0, 50);
595
+ const unknown = ids.filter(id => !Object.hasOwn(entry.document.nodes, id));
596
+ if (unknown.length)
597
+ fail('NOT_FOUND', `Frame ${documentId} has no node ${unknown.slice(0, 5).join(', ')}. Read the frame for node IDs, or omit nodeIds to mark the whole frame.`);
598
+ const text = typeof note === 'string' ? note.trim().slice(0, 120) : '';
599
+ const area = { documentId, nodeIds: ids, ...(text ? { note: text } : {}), at: new Date().toISOString() };
600
+ const kept = add ? this.work.filter(item => !(item.documentId === documentId && sameJson(item.nodeIds, ids))) : [];
601
+ this.work = [...kept, area].slice(-10);
602
+ this.publish();
603
+ return { marked: area, work: this.work.length };
604
+ }
605
+ clearWork({ documentId } = {}) {
606
+ const before = this.work.length;
607
+ this.work = documentId ? this.work.filter(item => item.documentId !== documentId) : [];
608
+ if (this.work.length !== before)
609
+ this.publish();
610
+ return { cleared: before - this.work.length };
611
+ }
612
+ setSelection({ ids, nodes }) {
613
+ if (!Array.isArray(ids) || ids.length > 200 || ids.some(id => typeof id !== 'string'))
614
+ fail('INVALID_SELECTION', 'Selection must be a list of document IDs.');
615
+ const next = { ids: [...new Set(ids.filter((id) => this.documents.has(id)))], revision: this.selection.revision + 1 };
616
+ if (nodes) {
617
+ if (!Array.isArray(nodes.nodeIds) || nodes.nodeIds.length > 100 || nodes.nodeIds.some((id) => typeof id !== 'string'))
618
+ fail('INVALID_SELECTION', 'Selected nodes must be a list of node IDs.');
619
+ const entry = this.documents.get(nodes.documentId);
620
+ const alive = entry && next.ids.length === 1 && next.ids[0] === nodes.documentId ? nodes.nodeIds.filter((id) => entry.document.nodes[id]) : [];
621
+ if (alive.length)
622
+ next.selectedNodes = { documentId: nodes.documentId, nodeIds: [...new Set(alive)], version: entry.document.version };
623
+ }
624
+ this.selection = next;
625
+ this.publish();
626
+ return this.info();
627
+ }
628
+ setObservations({ documentId, version, nodes, contentHeight, scrollHeight }) {
629
+ const entry = this.getEntry(documentId);
630
+ if (version !== entry.render)
631
+ fail('STALE_PREVIEW', 'Ignoring observations from an older render of this frame.');
632
+ if (!Array.isArray(nodes) || nodes.length > 300)
633
+ fail('INVALID_OBSERVATIONS', 'Expected at most 300 observed nodes.');
634
+ const clean = nodes.map((node) => {
635
+ if (!node || !entry.document.nodes[node.id] || !node.rect || ['x', 'y', 'width', 'height'].some(key => !Number.isFinite(node.rect[key]) || Math.abs(node.rect[key]) > 1e7) || node.rect.width < 0 || node.rect.height < 0)
636
+ fail('INVALID_OBSERVATIONS', 'Invalid observed node geometry.');
637
+ return { id: node.id, rect: { x: node.rect.x, y: node.rect.y, width: node.rect.width, height: node.rect.height }, overflowX: !!node.overflowX, overflowY: !!node.overflowY };
638
+ });
639
+ const page = {};
640
+ for (const [key, value] of Object.entries({ contentHeight, scrollHeight }))
641
+ if (Number.isFinite(value) && value >= 0 && value < 1e7)
642
+ page[key] = Math.round(value);
643
+ this.observations.set(documentId, { documentId, version: entry.document.version, render: entry.render, viewport: { width: entry.document.artboard.width, height: entry.document.artboard.height }, ...page, nodes: clean, receivedAt: new Date().toISOString() });
644
+ return { ok: true };
645
+ }
646
+ /**
647
+ * What the board shows about an agent working through this page. Unlike the Node host, an idle agent keeps its work marks:
648
+ * stateless MCP calls carry no turn boundary, so marks end only by `mark_divlet { clear: true }` or replacement.
649
+ */
650
+ setAgent(patch = {}) {
651
+ const next = { ...this.agent, ...patch };
652
+ if (next.busy && !this.agent.busy)
653
+ next.since = new Date().toISOString();
654
+ if (!next.busy) {
655
+ next.since = null;
656
+ next.activity = null;
657
+ next.queued = 0;
658
+ }
659
+ if (sameJson(next, this.agent))
660
+ return this.agent;
661
+ this.agent = next;
662
+ this.publish();
663
+ return this.agent;
664
+ }
665
+ /** Run one shared contract tool against this writer (the same `run` Pi, MCP and the CLI use). */
666
+ async callTool(name, args) {
667
+ if (!BROWSER_TOOLS.includes(name))
668
+ fail('UNKNOWN_TOOL', `The browser host does not run ${name}.`);
669
+ return runOnRuntime(name, args, this);
670
+ }
671
+ /** The shared tool descriptions this writer runs. */
672
+ tools() { return toolList().filter(tool => BROWSER_TOOLS.includes(tool.name)); }
673
+ async close() { await this.#queue; this.closed = true; this.#listeners.clear(); }
674
+ }