@portel/photon 1.20.1 → 1.21.0

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 (106) hide show
  1. package/README.md +5 -5
  2. package/dist/ag-ui/adapter.d.ts.map +1 -1
  3. package/dist/ag-ui/adapter.js +25 -0
  4. package/dist/ag-ui/adapter.js.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  6. package/dist/auto-ui/beam/routes/api-browse.js +8 -49
  7. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  8. package/dist/auto-ui/beam.d.ts.map +1 -1
  9. package/dist/auto-ui/beam.js +23 -31
  10. package/dist/auto-ui/beam.js.map +1 -1
  11. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  12. package/dist/auto-ui/bridge/index.js +107 -11
  13. package/dist/auto-ui/bridge/index.js.map +1 -1
  14. package/dist/auto-ui/bridge/renderers.d.ts +14 -0
  15. package/dist/auto-ui/bridge/renderers.d.ts.map +1 -1
  16. package/dist/auto-ui/bridge/renderers.js +680 -57
  17. package/dist/auto-ui/bridge/renderers.js.map +1 -1
  18. package/dist/auto-ui/frontend/index.html +3 -3
  19. package/dist/auto-ui/frontend/pure-view.html +19 -19
  20. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  21. package/dist/auto-ui/streamable-http-transport.js +29 -0
  22. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  23. package/dist/auto-ui/ui-resolver.d.ts +25 -0
  24. package/dist/auto-ui/ui-resolver.d.ts.map +1 -0
  25. package/dist/auto-ui/ui-resolver.js +95 -0
  26. package/dist/auto-ui/ui-resolver.js.map +1 -0
  27. package/dist/beam-form.bundle.js +7 -7
  28. package/dist/beam-form.bundle.js.map +1 -1
  29. package/dist/beam.bundle.js +905 -185
  30. package/dist/beam.bundle.js.map +4 -4
  31. package/dist/cli/commands/build.d.ts.map +1 -1
  32. package/dist/cli/commands/build.js +9 -5
  33. package/dist/cli/commands/build.js.map +1 -1
  34. package/dist/cli/commands/init.d.ts.map +1 -1
  35. package/dist/cli/commands/init.js +90 -50
  36. package/dist/cli/commands/init.js.map +1 -1
  37. package/dist/cli/commands/publish.d.ts +14 -0
  38. package/dist/cli/commands/publish.d.ts.map +1 -0
  39. package/dist/cli/commands/publish.js +126 -0
  40. package/dist/cli/commands/publish.js.map +1 -0
  41. package/dist/cli/commands/run.d.ts.map +1 -1
  42. package/dist/cli/commands/run.js +2 -0
  43. package/dist/cli/commands/run.js.map +1 -1
  44. package/dist/cli/index.d.ts.map +1 -1
  45. package/dist/cli/index.js +3 -0
  46. package/dist/cli/index.js.map +1 -1
  47. package/dist/context.d.ts +6 -0
  48. package/dist/context.d.ts.map +1 -1
  49. package/dist/context.js +17 -5
  50. package/dist/context.js.map +1 -1
  51. package/dist/daemon/client.d.ts +9 -1
  52. package/dist/daemon/client.d.ts.map +1 -1
  53. package/dist/daemon/client.js +54 -1
  54. package/dist/daemon/client.js.map +1 -1
  55. package/dist/daemon/manager.d.ts +3 -0
  56. package/dist/daemon/manager.d.ts.map +1 -1
  57. package/dist/daemon/manager.js +88 -38
  58. package/dist/daemon/manager.js.map +1 -1
  59. package/dist/daemon/ownership.d.ts +12 -0
  60. package/dist/daemon/ownership.d.ts.map +1 -0
  61. package/dist/daemon/ownership.js +55 -0
  62. package/dist/daemon/ownership.js.map +1 -0
  63. package/dist/daemon/protocol.d.ts +3 -1
  64. package/dist/daemon/protocol.d.ts.map +1 -1
  65. package/dist/daemon/protocol.js +14 -2
  66. package/dist/daemon/protocol.js.map +1 -1
  67. package/dist/daemon/server.js +549 -83
  68. package/dist/daemon/server.js.map +1 -1
  69. package/dist/daemon/session-manager.d.ts +9 -1
  70. package/dist/daemon/session-manager.d.ts.map +1 -1
  71. package/dist/daemon/session-manager.js +54 -1
  72. package/dist/daemon/session-manager.js.map +1 -1
  73. package/dist/daemon/worker-manager.d.ts +12 -0
  74. package/dist/daemon/worker-manager.d.ts.map +1 -1
  75. package/dist/daemon/worker-manager.js +89 -6
  76. package/dist/daemon/worker-manager.js.map +1 -1
  77. package/dist/loader.d.ts +3 -9
  78. package/dist/loader.d.ts.map +1 -1
  79. package/dist/loader.js +168 -113
  80. package/dist/loader.js.map +1 -1
  81. package/dist/photon-cli-runner.d.ts.map +1 -1
  82. package/dist/photon-cli-runner.js +26 -2
  83. package/dist/photon-cli-runner.js.map +1 -1
  84. package/dist/photons/canvas/ui/canvas.photon.html +1493 -0
  85. package/dist/photons/canvas.photon.d.ts +400 -0
  86. package/dist/photons/canvas.photon.d.ts.map +1 -0
  87. package/dist/photons/canvas.photon.js +662 -0
  88. package/dist/photons/canvas.photon.js.map +1 -0
  89. package/dist/photons/canvas.photon.ts +814 -0
  90. package/dist/photons/publish.photon.d.ts +97 -0
  91. package/dist/photons/publish.photon.d.ts.map +1 -0
  92. package/dist/photons/publish.photon.js +569 -0
  93. package/dist/photons/publish.photon.js.map +1 -0
  94. package/dist/photons/publish.photon.ts +683 -0
  95. package/dist/photons/ui/canvas.photon.html +624 -0
  96. package/dist/resource-server.d.ts.map +1 -1
  97. package/dist/resource-server.js +7 -1
  98. package/dist/resource-server.js.map +1 -1
  99. package/dist/shared-utils.d.ts.map +1 -1
  100. package/dist/shared-utils.js +2 -2
  101. package/dist/shared-utils.js.map +1 -1
  102. package/dist/tsx-compiler.d.ts +23 -0
  103. package/dist/tsx-compiler.d.ts.map +1 -0
  104. package/dist/tsx-compiler.js +221 -0
  105. package/dist/tsx-compiler.js.map +1 -0
  106. package/package.json +7 -7
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Canvas — co-creative scene graph
3
+ *
4
+ * Shared canvas where AI agents and humans collaboratively place,
5
+ * move, resize, and update rendered elements. Either party can start,
6
+ * either can edit, control passes back and forth fluidly.
7
+ *
8
+ * Each element renders using the runtime's 50+ format renderers
9
+ * (metric, chart:bar, table, gauge, timeline, etc.).
10
+ *
11
+ * @description Co-creative canvas for AI and humans
12
+ * @icon 🎨
13
+ * @stateful
14
+ */
15
+ export default class Canvas {
16
+ // Injected by runtime — declared for capability detection
17
+ emit!: (data: any) => void;
18
+ formats!: Record<string, { data: string; example: unknown }>;
19
+ memory!: {
20
+ get<T>(key: string, scope?: string): Promise<T | null>;
21
+ set<T>(key: string, value: T, scope?: string): Promise<void>;
22
+ delete(key: string, scope?: string): Promise<boolean>;
23
+ keys(scope?: string): Promise<string[]>;
24
+ };
25
+
26
+ /** Scene graph: element ID → element */
27
+ private _scene: Record<
28
+ string,
29
+ {
30
+ id: string;
31
+ format: string;
32
+ data: any;
33
+ x: number;
34
+ y: number;
35
+ w: number;
36
+ h: number;
37
+ z: number;
38
+ label?: string;
39
+ locked?: string; // agent name that locked this element
40
+ createdBy?: string;
41
+ updatedAt: number;
42
+ }
43
+ > = {};
44
+
45
+ private _nextZ = 1;
46
+ private _loaded = false;
47
+
48
+ /** Turn state: who has control */
49
+ private _turn: {
50
+ agent: string;
51
+ message?: string;
52
+ since: number;
53
+ } = { agent: 'human', since: Date.now() };
54
+
55
+ /** Max snapshots to keep per canvas */
56
+ private static MAX_SNAPSHOTS = 100;
57
+
58
+ /** Load scene from persistent storage (or from fork data) */
59
+ private async _load() {
60
+ if (this._loaded) return;
61
+ this._loaded = true;
62
+ try {
63
+ const saved = await this.memory.get<{
64
+ scene: Record<string, any>;
65
+ nextZ: number;
66
+ turn?: { agent: string; message?: string; since: number };
67
+ }>('scene');
68
+ if (saved) {
69
+ this._scene = saved.scene || {};
70
+ this._nextZ = saved.nextZ || 1;
71
+ if (saved.turn) this._turn = saved.turn;
72
+ }
73
+ } catch {
74
+ // First run or corrupted — start fresh
75
+ }
76
+
77
+ // Check for fork data in global scope (written by another instance's fork())
78
+ if (Object.keys(this._scene).length === 0) {
79
+ try {
80
+ const globalKeys = await this.memory.keys('global');
81
+ for (const key of globalKeys) {
82
+ // Match fork data addressed to this instance
83
+ if (key.startsWith('canvas-fork:')) {
84
+ const forkData = await this.memory.get<{
85
+ scene: Record<string, any>;
86
+ nextZ: number;
87
+ turn: { agent: string; since: number };
88
+ }>(key, 'global');
89
+ if (forkData && forkData.scene) {
90
+ this._scene = forkData.scene;
91
+ this._nextZ = forkData.nextZ || 1;
92
+ this._turn = forkData.turn || this._turn;
93
+ await this.memory.delete(key, 'global'); // consume
94
+ await this._save('forked');
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ } catch {
100
+ // Fork check failed — continue with empty scene
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Save scene to persistent storage + auto-snapshot */
106
+ private async _save(action?: string) {
107
+ await this.memory.set('scene', {
108
+ scene: this._scene,
109
+ nextZ: this._nextZ,
110
+ turn: this._turn,
111
+ });
112
+
113
+ // Auto-snapshot: record every mutation
114
+ const timeline =
115
+ (await this.memory.get<
116
+ Array<{
117
+ ts: number;
118
+ action: string;
119
+ scene: Record<string, any>;
120
+ elementCount: number;
121
+ }>
122
+ >('timeline')) || [];
123
+
124
+ timeline.push({
125
+ ts: Date.now(),
126
+ action: action || 'edit',
127
+ scene: JSON.parse(JSON.stringify(this._scene)),
128
+ elementCount: Object.keys(this._scene).length,
129
+ });
130
+
131
+ // Trim old snapshots
132
+ if (timeline.length > Canvas.MAX_SNAPSHOTS) {
133
+ timeline.splice(0, timeline.length - Canvas.MAX_SNAPSHOTS);
134
+ }
135
+
136
+ await this.memory.set('timeline', timeline);
137
+ }
138
+
139
+ /**
140
+ * Open the canvas
141
+ * @ui canvas
142
+ * @readOnly
143
+ */
144
+ async main() {
145
+ await this._load();
146
+ return { elements: Object.values(this._scene), turn: this._turn };
147
+ }
148
+
149
+ /**
150
+ * Place or update an element on the canvas.
151
+ * Creates if new, merges if exists — only provided fields change.
152
+ *
153
+ * @param id Element identifier
154
+ * @param format Renderer format (metric, chart:bar, table, gauge, etc.)
155
+ * @param data Data matching the format spec
156
+ * @param x Horizontal position in pixels
157
+ * @param y Vertical position in pixels
158
+ * @param w Width in pixels
159
+ * @param h Height in pixels
160
+ * @param z Z-order layer (higher = on top)
161
+ * @param label Human-readable label shown on the element
162
+ */
163
+ async put({
164
+ id,
165
+ format,
166
+ data,
167
+ x,
168
+ y,
169
+ w,
170
+ h,
171
+ z,
172
+ label,
173
+ }: {
174
+ id: string;
175
+ format?: string;
176
+ data?: any;
177
+ x?: number;
178
+ y?: number;
179
+ w?: number;
180
+ h?: number;
181
+ z?: number;
182
+ label?: string;
183
+ }) {
184
+ await this._load();
185
+ const existing = this._scene[id];
186
+ const element = {
187
+ id,
188
+ format: format ?? existing?.format ?? 'card',
189
+ data: data !== undefined ? data : existing?.data,
190
+ x: x ?? existing?.x ?? 50,
191
+ y: y ?? existing?.y ?? 50,
192
+ w: w ?? existing?.w ?? 300,
193
+ h: h ?? existing?.h ?? 200,
194
+ z: z ?? existing?.z ?? this._nextZ++,
195
+ label: label ?? existing?.label,
196
+ createdBy: existing?.createdBy ?? 'ai',
197
+ updatedAt: Date.now(),
198
+ };
199
+ this._scene[id] = element;
200
+ const action = existing ? `update ${id}` : `add ${id}`;
201
+ await this._save(action);
202
+
203
+ // Emit scene change — flows through SSE → bridge → onEmit
204
+ this.emit({
205
+ emit: 'scene:put',
206
+ element,
207
+ });
208
+
209
+ return element;
210
+ }
211
+
212
+ /**
213
+ * Remove an element from the canvas
214
+ * @param id Element identifier to remove
215
+ */
216
+ async remove({ id }: { id: string }) {
217
+ await this._load();
218
+ const existed = id in this._scene;
219
+ delete this._scene[id];
220
+ await this._save(`remove ${id}`);
221
+
222
+ this.emit({
223
+ emit: 'scene:remove',
224
+ id,
225
+ });
226
+
227
+ return { removed: existed, id };
228
+ }
229
+
230
+ /**
231
+ * Clear all elements from the canvas
232
+ * @destructive
233
+ */
234
+ async clear() {
235
+ await this._load();
236
+ const count = Object.keys(this._scene).length;
237
+ this._scene = {};
238
+ this._nextZ = 1;
239
+ await this._save('clear');
240
+
241
+ this.emit({
242
+ emit: 'scene:clear',
243
+ });
244
+
245
+ return { cleared: count };
246
+ }
247
+
248
+ /**
249
+ * Get the full scene graph — all elements with positions, sizes, and data
250
+ * @readOnly
251
+ */
252
+ async scene() {
253
+ await this._load();
254
+ return {
255
+ elements: Object.values(this._scene),
256
+ count: Object.keys(this._scene).length,
257
+ turn: this._turn,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Pass control to another agent or back to the human.
263
+ * The recipient sees a status banner with the optional message.
264
+ *
265
+ * @param to Who gets control next (e.g. 'human', 'ai', agent name)
266
+ * @param message Optional message explaining what to do next
267
+ */
268
+ async pass({ to, message }: { to: string; message?: string }) {
269
+ await this._load();
270
+ this._turn = { agent: to, message, since: Date.now() };
271
+ await this._save();
272
+
273
+ this.emit({
274
+ emit: 'turn:change',
275
+ turn: this._turn,
276
+ });
277
+
278
+ return this._turn;
279
+ }
280
+
281
+ /**
282
+ * Lock an element so only the specified agent can modify it.
283
+ *
284
+ * @param id Element to lock
285
+ * @param agent Agent name claiming the lock
286
+ */
287
+ async lock({ id, agent }: { id: string; agent: string }) {
288
+ await this._load();
289
+ const el = this._scene[id];
290
+ if (!el) return { error: 'Element not found', id };
291
+ if (el.locked && el.locked !== agent) {
292
+ return { error: `Locked by ${el.locked}`, id };
293
+ }
294
+ el.locked = agent;
295
+ el.updatedAt = Date.now();
296
+ await this._save(`lock ${id}`);
297
+
298
+ this.emit({ emit: 'scene:put', element: el });
299
+ return el;
300
+ }
301
+
302
+ /**
303
+ * Unlock an element, allowing anyone to modify it.
304
+ *
305
+ * @param id Element to unlock
306
+ */
307
+ async unlock({ id }: { id: string }) {
308
+ await this._load();
309
+ const el = this._scene[id];
310
+ if (!el) return { error: 'Element not found', id };
311
+ delete el.locked;
312
+ el.updatedAt = Date.now();
313
+ await this._save(`unlock ${id}`);
314
+
315
+ this.emit({ emit: 'scene:put', element: el });
316
+ return el;
317
+ }
318
+
319
+ /**
320
+ * Describe the current canvas layout in natural language.
321
+ * Useful for AI agents to understand spatial arrangement without a screenshot.
322
+ * @readOnly
323
+ */
324
+ async describe() {
325
+ await this._load();
326
+ const els = Object.values(this._scene);
327
+ if (els.length === 0) {
328
+ return {
329
+ description: 'The canvas is empty.',
330
+ count: 0,
331
+ };
332
+ }
333
+
334
+ // Sort by z-order (back to front)
335
+ const sorted = [...els].sort((a, b) => a.z - b.z);
336
+
337
+ // Compute canvas bounds
338
+ const maxX = Math.max(...els.map((e) => e.x + e.w));
339
+ const maxY = Math.max(...els.map((e) => e.y + e.h));
340
+
341
+ // Describe each element with spatial context
342
+ const descriptions = sorted.map((el) => {
343
+ const cx = el.x + el.w / 2;
344
+ const cy = el.y + el.h / 2;
345
+ const hPos = cx < maxX * 0.33 ? 'left' : cx > maxX * 0.66 ? 'right' : 'center';
346
+ const vPos = cy < maxY * 0.33 ? 'top' : cy > maxY * 0.66 ? 'bottom' : 'middle';
347
+ const pos = vPos === 'middle' && hPos === 'center' ? 'center' : `${vPos}-${hPos}`;
348
+ const size = `${el.w}x${el.h}`;
349
+ const lock = el.locked ? ` [locked by ${el.locked}]` : '';
350
+ const label = el.label || el.id;
351
+ return `- "${label}" (${el.format}, ${size}) at ${pos}, placed by ${el.createdBy || 'unknown'}${lock}`;
352
+ });
353
+
354
+ // Detect spatial patterns
355
+ const patterns: string[] = [];
356
+ const xGroups = this._groupBy(els, (e) => Math.round(e.y / 50) * 50);
357
+ const rows = Object.values(xGroups).filter((g) => g.length > 1);
358
+ if (rows.length > 0) {
359
+ patterns.push(`${rows.length} row(s) of aligned elements`);
360
+ }
361
+ const yGroups = this._groupBy(els, (e) => Math.round(e.x / 50) * 50);
362
+ const cols = Object.values(yGroups).filter((g) => g.length > 1);
363
+ if (cols.length > 0) {
364
+ patterns.push(`${cols.length} column(s) of aligned elements`);
365
+ }
366
+
367
+ const summary =
368
+ `Canvas has ${els.length} element(s) spanning ${maxX}x${maxY}px.` +
369
+ (patterns.length > 0 ? ' Layout: ' + patterns.join(', ') + '.' : '') +
370
+ ` Turn: ${this._turn.agent}.`;
371
+
372
+ return {
373
+ description: summary,
374
+ elements: descriptions,
375
+ bounds: { width: maxX, height: maxY },
376
+ turn: this._turn,
377
+ count: els.length,
378
+ };
379
+ }
380
+
381
+ private _groupBy<T>(items: T[], keyFn: (item: T) => number): Record<number, T[]> {
382
+ const groups: Record<number, T[]> = {};
383
+ for (const item of items) {
384
+ const key = keyFn(item);
385
+ if (!groups[key]) groups[key] = [];
386
+ groups[key].push(item);
387
+ }
388
+ return groups;
389
+ }
390
+
391
+ /**
392
+ * Request a screenshot from the canvas UI.
393
+ * Returns the latest captured screenshot as a base64 data URL.
394
+ * The UI captures the screenshot and sends it back via the capture method.
395
+ * @readOnly
396
+ */
397
+ async screenshot() {
398
+ await this._load();
399
+
400
+ // Emit request — client will capture and call 'capture' with the data
401
+ this.emit({ emit: 'canvas:screenshot-request' });
402
+
403
+ // Return the last captured screenshot if available
404
+ const last = await this.memory.get<string>('last-screenshot');
405
+ return {
406
+ available: !!last,
407
+ dataUrl: last || null,
408
+ hint: last
409
+ ? 'Screenshot available. Pass the dataUrl to a multimodal model.'
410
+ : 'Screenshot requested. Call screenshot() again after a moment to retrieve it.',
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Store a screenshot captured by the canvas UI.
416
+ * Called by the client after a screenshot-request event.
417
+ * @internal
418
+ * @param dataUrl Base64 data URL of the captured image
419
+ */
420
+ async capture({ dataUrl }: { dataUrl: string }) {
421
+ await this.memory.set('last-screenshot', dataUrl);
422
+ return { stored: true };
423
+ }
424
+
425
+ /**
426
+ * Register a custom component that can be used as a format on the canvas.
427
+ * Once registered, use the component name as the format in put().
428
+ *
429
+ * @param name Component name (used as format value in put)
430
+ * @param html HTML template string. Use {{key}} placeholders for data binding.
431
+ * @param defaults Default data values for the component
432
+ */
433
+ async registerComponent({
434
+ name,
435
+ html,
436
+ defaults,
437
+ }: {
438
+ name: string;
439
+ html: string;
440
+ defaults?: Record<string, any>;
441
+ }) {
442
+ await this._load();
443
+ const components = (await this.memory.get<Record<string, any>>('components')) || {};
444
+ components[name] = { html, defaults: defaults || {} };
445
+ await this.memory.set('components', components);
446
+
447
+ this.emit({
448
+ emit: 'component:registered',
449
+ name,
450
+ html,
451
+ defaults: defaults || {},
452
+ });
453
+
454
+ return { registered: name };
455
+ }
456
+
457
+ /**
458
+ * List registered custom components with their templates
459
+ * @readOnly
460
+ */
461
+ async listComponents() {
462
+ const components = (await this.memory.get<Record<string, any>>('components')) || {};
463
+ return Object.entries(components).map(([name, spec]: [string, any]) => ({
464
+ name,
465
+ html: spec.html,
466
+ defaults: spec.defaults || {},
467
+ }));
468
+ }
469
+
470
+ /**
471
+ * View the canvas timeline — a history of every change.
472
+ * Each entry has a timestamp, action label, and element count.
473
+ * @readOnly
474
+ */
475
+ async history() {
476
+ const timeline =
477
+ (await this.memory.get<Array<{ ts: number; action: string; elementCount: number }>>(
478
+ 'timeline'
479
+ )) || [];
480
+
481
+ return timeline.map((entry, i) => ({
482
+ index: i,
483
+ time: new Date(entry.ts).toISOString(),
484
+ action: entry.action,
485
+ elements: entry.elementCount,
486
+ }));
487
+ }
488
+
489
+ /**
490
+ * Get the full timeline with scene data for animated playback.
491
+ * Each frame contains the complete scene state so the client can
492
+ * compute Magic Move transitions between consecutive frames.
493
+ * @readOnly
494
+ * @internal
495
+ */
496
+ async playback() {
497
+ const timeline =
498
+ (await this.memory.get<
499
+ Array<{ ts: number; action: string; scene: Record<string, any>; elementCount: number }>
500
+ >('timeline')) || [];
501
+
502
+ return timeline.map((entry, i) => ({
503
+ index: i,
504
+ action: entry.action,
505
+ elements: Object.values(entry.scene),
506
+ }));
507
+ }
508
+
509
+ /**
510
+ * Save a named checkpoint at the current state.
511
+ * Checkpoints appear in the timeline with a label for easy reference.
512
+ *
513
+ * @param label Name for this checkpoint (e.g. 'before reorganizing')
514
+ */
515
+ async checkpoint({ label }: { label: string }) {
516
+ await this._load();
517
+ await this._save(`checkpoint: ${label}`);
518
+
519
+ this.emit({
520
+ emit: 'timeline:checkpoint',
521
+ label,
522
+ ts: Date.now(),
523
+ });
524
+
525
+ return { checkpointed: label, elements: Object.keys(this._scene).length };
526
+ }
527
+
528
+ /**
529
+ * Restore the canvas to a previous point in the timeline.
530
+ * Replaces the current scene with the snapshot at that index.
531
+ *
532
+ * @param index Timeline index to restore (from history())
533
+ */
534
+ async restore({ index }: { index: number }) {
535
+ const timeline =
536
+ (await this.memory.get<
537
+ Array<{ ts: number; action: string; scene: Record<string, any>; elementCount: number }>
538
+ >('timeline')) || [];
539
+
540
+ if (index < 0 || index >= timeline.length) {
541
+ return { error: `Invalid index ${index}. Timeline has ${timeline.length} entries.` };
542
+ }
543
+
544
+ const snapshot = timeline[index];
545
+ this._scene = JSON.parse(JSON.stringify(snapshot.scene));
546
+ this._nextZ = Math.max(0, ...Object.values(this._scene).map((e: any) => e.z || 0)) + 1;
547
+ await this._save(`restore to #${index} (${snapshot.action})`);
548
+
549
+ // Emit full scene refresh
550
+ this.emit({
551
+ emit: 'scene:restore',
552
+ elements: Object.values(this._scene),
553
+ });
554
+
555
+ return {
556
+ restored: index,
557
+ action: snapshot.action,
558
+ time: new Date(snapshot.ts).toISOString(),
559
+ elements: Object.keys(this._scene).length,
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Fork the canvas at a timeline point into a new instance.
565
+ * Creates a new canvas instance with the scene from that snapshot.
566
+ *
567
+ * @param name New instance name (e.g. 'dashboard-v2')
568
+ * @param index Timeline index to fork from (defaults to current state)
569
+ */
570
+ async fork({ name, index }: { name: string; index?: number }) {
571
+ await this._load();
572
+
573
+ let forkScene: Record<string, any>;
574
+ let forkAction: string;
575
+
576
+ if (index !== undefined) {
577
+ const timeline =
578
+ (await this.memory.get<Array<{ ts: number; action: string; scene: Record<string, any> }>>(
579
+ 'timeline'
580
+ )) || [];
581
+
582
+ if (index < 0 || index >= timeline.length) {
583
+ return { error: `Invalid index ${index}. Timeline has ${timeline.length} entries.` };
584
+ }
585
+ forkScene = timeline[index].scene;
586
+ forkAction = `forked from #${index}`;
587
+ } else {
588
+ forkScene = this._scene;
589
+ forkAction = 'forked from current';
590
+ }
591
+
592
+ // Store fork data in global scope so the target instance can find it
593
+ const forkData = {
594
+ scene: JSON.parse(JSON.stringify(forkScene)),
595
+ nextZ: Math.max(0, ...Object.values(forkScene).map((e: any) => e.z || 0)) + 1,
596
+ turn: { agent: 'human', since: Date.now() },
597
+ };
598
+
599
+ await this.memory.set(`canvas-fork:${name}`, forkData, 'global');
600
+
601
+ this.emit({
602
+ emit: 'timeline:fork',
603
+ name,
604
+ elements: Object.keys(forkScene).length,
605
+ });
606
+
607
+ return {
608
+ forked: name,
609
+ from: forkAction,
610
+ elements: Object.keys(forkScene).length,
611
+ hint: `Open canvas/${name} to use the forked canvas`,
612
+ };
613
+ }
614
+
615
+ /**
616
+ * Export the current canvas as a standalone photon.
617
+ * Compiles the scene graph into a .photon.ts (data methods) and
618
+ * .photon.html (CSS grid layout with format renderers).
619
+ *
620
+ * @param name Photon name for the exported file (e.g. 'my-dashboard')
621
+ * @param description One-line description of the exported photon
622
+ * @readOnly
623
+ */
624
+ async export({ name, description }: { name: string; description?: string }) {
625
+ await this._load();
626
+ const els = Object.values(this._scene);
627
+ if (els.length === 0) {
628
+ return { error: 'Canvas is empty — nothing to export' };
629
+ }
630
+
631
+ const desc = description || `Exported from canvas on ${new Date().toISOString().split('T')[0]}`;
632
+ const sorted = [...els].sort((a, b) => a.z - b.z);
633
+
634
+ // ── Detect layout: rows and columns ──
635
+ const rows = this._detectRows(sorted);
636
+
637
+ // ── Generate .photon.ts ──
638
+ const methodEntries = sorted.map((el) => {
639
+ const safeName = el.id.replace(/[^a-zA-Z0-9]/g, '_');
640
+ const dataStr = JSON.stringify(el.data, null, 4);
641
+ return [
642
+ ` /**`,
643
+ ` * ${el.label || el.id}`,
644
+ ` * @format ${el.format}`,
645
+ ` * @readOnly`,
646
+ ` */`,
647
+ ` ${safeName}() {`,
648
+ ` return ${dataStr};`,
649
+ ` }`,
650
+ ].join('\n');
651
+ });
652
+
653
+ const tsFile = [
654
+ `/**`,
655
+ ` * ${name}`,
656
+ ` *`,
657
+ ` * ${desc}`,
658
+ ` *`,
659
+ ` * @description ${desc}`,
660
+ ` * @ui main`,
661
+ ` */`,
662
+ `export default class ${this._toPascalCase(name)} {`,
663
+ ` /**`,
664
+ ` * Dashboard view`,
665
+ ` * @ui main`,
666
+ ` * @readOnly`,
667
+ ` */`,
668
+ ` main() {`,
669
+ ` return {`,
670
+ ...sorted.map((el) => {
671
+ const safeName = el.id.replace(/[^a-zA-Z0-9]/g, '_');
672
+ return ` ${safeName}: this.${safeName}(),`;
673
+ }),
674
+ ` };`,
675
+ ` }`,
676
+ ``,
677
+ ...methodEntries,
678
+ `}`,
679
+ ``,
680
+ ].join('\n');
681
+
682
+ // ── Generate .photon.html with CSS grid ──
683
+ const gridCells = rows
684
+ .map((row, ri) =>
685
+ row
686
+ .map((el) => {
687
+ const safeName = el.id.replace(/[^a-zA-Z0-9]/g, '_');
688
+ return [
689
+ ` <div class="cell" data-method="${safeName}" data-format="${el.format}">`,
690
+ ` <div class="cell-label">${el.label || el.id}</div>`,
691
+ ` <div class="cell-body" id="${safeName}"></div>`,
692
+ ` </div>`,
693
+ ].join('\n');
694
+ })
695
+ .join('\n')
696
+ )
697
+ .join('\n');
698
+
699
+ // Compute grid template from row structure
700
+ const maxCols = Math.max(...rows.map((r) => r.length));
701
+ const colTemplate = `repeat(${maxCols}, 1fr)`;
702
+ const rowTemplate = rows.map(() => 'auto').join(' ');
703
+
704
+ const htmlFile = [
705
+ `<style>`,
706
+ ` * { box-sizing: border-box; margin: 0; padding: 0; }`,
707
+ ` body {`,
708
+ ` font-family: var(--font-family-sans, -apple-system, BlinkMacSystemFont, sans-serif);`,
709
+ ` background: var(--color-surface, #1a1b26);`,
710
+ ` color: var(--color-on-surface, #e6e6e6);`,
711
+ ` padding: 16px;`,
712
+ ` }`,
713
+ ` .grid {`,
714
+ ` display: grid;`,
715
+ ` grid-template-columns: ${colTemplate};`,
716
+ ` grid-template-rows: ${rowTemplate};`,
717
+ ` gap: 12px;`,
718
+ ` }`,
719
+ ` .cell {`,
720
+ ` background: var(--color-surface-container, #1e2030);`,
721
+ ` border: 1px solid var(--color-outline-variant, #333);`,
722
+ ` border-radius: 8px;`,
723
+ ` padding: 12px;`,
724
+ ` min-height: 120px;`,
725
+ ` }`,
726
+ ` .cell-label {`,
727
+ ` font-size: 11px;`,
728
+ ` color: var(--color-on-surface-muted, #999);`,
729
+ ` margin-bottom: 8px;`,
730
+ ` text-transform: uppercase;`,
731
+ ` letter-spacing: 0.5px;`,
732
+ ` }`,
733
+ ` .cell-body { min-height: 0; }`,
734
+ `</style>`,
735
+ ``,
736
+ `<div class="grid">`,
737
+ gridCells,
738
+ `</div>`,
739
+ ``,
740
+ `<script>`,
741
+ `(function() {`,
742
+ ` window.photon.onResult(function(result) {`,
743
+ ` if (!result) return;`,
744
+ ` var cells = document.querySelectorAll('.cell');`,
745
+ ` for (var i = 0; i < cells.length; i++) {`,
746
+ ` var method = cells[i].getAttribute('data-method');`,
747
+ ` var format = cells[i].getAttribute('data-format');`,
748
+ ` var body = cells[i].querySelector('.cell-body');`,
749
+ ` if (method && result[method] && body) {`,
750
+ ` window.photon.render(body, result[method], format);`,
751
+ ` }`,
752
+ ` }`,
753
+ ` });`,
754
+ `})();`,
755
+ `</script>`,
756
+ ].join('\n');
757
+
758
+ return {
759
+ name,
760
+ files: {
761
+ [`${name}.photon.ts`]: tsFile,
762
+ [`${name}/ui/main.html`]: htmlFile,
763
+ },
764
+ elements: sorted.length,
765
+ grid: `${maxCols} columns x ${rows.length} rows`,
766
+ };
767
+ }
768
+
769
+ private _detectRows(
770
+ els: Array<{ x: number; y: number; w: number; h: number; [k: string]: any }>
771
+ ): Array<Array<(typeof els)[number]>> {
772
+ if (els.length === 0) return [];
773
+ // Group elements into rows by y-proximity (within 50px = same row)
774
+ const sorted = [...els].sort((a, b) => a.y - b.y || a.x - b.x);
775
+ const rows: Array<Array<(typeof els)[number]>> = [];
776
+ let currentRow: Array<(typeof els)[number]> = [sorted[0]];
777
+ let rowY = sorted[0].y;
778
+
779
+ for (let i = 1; i < sorted.length; i++) {
780
+ if (Math.abs(sorted[i].y - rowY) < 50) {
781
+ currentRow.push(sorted[i]);
782
+ } else {
783
+ rows.push(currentRow.sort((a, b) => a.x - b.x));
784
+ currentRow = [sorted[i]];
785
+ rowY = sorted[i].y;
786
+ }
787
+ }
788
+ rows.push(currentRow.sort((a, b) => a.x - b.x));
789
+ return rows;
790
+ }
791
+
792
+ private _toPascalCase(str: string): string {
793
+ return str
794
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
795
+ .split(' ')
796
+ .filter(Boolean)
797
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
798
+ .join('');
799
+ }
800
+
801
+ /**
802
+ * List all available render formats with expected data shapes
803
+ * @readOnly
804
+ * @format table
805
+ */
806
+ listFormats() {
807
+ const catalog = this.formats;
808
+ if (!catalog || typeof catalog !== 'object') return [];
809
+ return Object.entries(catalog).map(([name, spec]: [string, any]) => ({
810
+ format: name,
811
+ data: spec.data,
812
+ }));
813
+ }
814
+ }