pmx-canvas 0.6.3 → 0.6.5

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 (85) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/Readme.md +27 -6
  3. package/dist/canvas/global.css +90 -4
  4. package/dist/canvas/index.js +93 -91
  5. package/dist/canvas/mermaid-entry.js +258 -258
  6. package/dist/types/cli/agent.d.ts +1 -0
  7. package/dist/types/cli/commands/record.d.ts +12 -0
  8. package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
  9. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  10. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  11. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  12. package/dist/types/client/state/presentation.d.ts +3 -0
  13. package/dist/types/client/types.d.ts +1 -1
  14. package/dist/types/json-render/catalog.d.ts +2 -2
  15. package/dist/types/json-render/server.d.ts +2 -0
  16. package/dist/types/server/canvas-db.d.ts +2 -0
  17. package/dist/types/server/canvas-operations.d.ts +22 -0
  18. package/dist/types/server/canvas-schema.d.ts +1 -0
  19. package/dist/types/server/canvas-state.d.ts +6 -0
  20. package/dist/types/server/canvas-validation.d.ts +3 -0
  21. package/dist/types/server/html-surface.d.ts +1 -1
  22. package/dist/types/server/index.d.ts +4 -0
  23. package/dist/types/server/operations/ops/app.d.ts +2 -0
  24. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  25. package/dist/types/server/operations/ops/nodes.d.ts +3 -1
  26. package/dist/types/server/web-artifacts.d.ts +3 -0
  27. package/dist/types/shared/tour.d.ts +72 -0
  28. package/docs/cli.md +96 -2
  29. package/docs/http-api.md +79 -4
  30. package/docs/mcp.md +25 -1
  31. package/docs/node-types.md +123 -1
  32. package/docs/product-context-vision-2026-09.md +1 -1
  33. package/docs/product-vision-2026-09.md +135 -42
  34. package/docs/product-vision-review-2026-09.md +277 -0
  35. package/package.json +1 -1
  36. package/skills/pmx-canvas/SKILL.md +19 -4
  37. package/skills/pmx-canvas/evals/evals.json +46 -0
  38. package/skills/pmx-canvas/references/full-reference.md +1 -1
  39. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  40. package/src/cli/agent.ts +5 -0
  41. package/src/cli/commands/nodes.ts +10 -0
  42. package/src/cli/commands/record.ts +218 -0
  43. package/src/cli/index.ts +2 -0
  44. package/src/client/App.tsx +7 -1
  45. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  46. package/src/client/canvas/CanvasNode.tsx +8 -0
  47. package/src/client/canvas/FeedbackDialog.tsx +86 -0
  48. package/src/client/canvas/IntentLayer.tsx +9 -2
  49. package/src/client/canvas/Presentation.tsx +108 -0
  50. package/src/client/canvas/ToolRail.tsx +225 -189
  51. package/src/client/canvas/TopBar.tsx +28 -2
  52. package/src/client/canvas/use-node-drag.ts +8 -3
  53. package/src/client/mermaid-entry.ts +39 -0
  54. package/src/client/nodes/HtmlNode.tsx +12 -13
  55. package/src/client/nodes/McpAppNode.tsx +105 -13
  56. package/src/client/nodes/MermaidNode.tsx +3 -2
  57. package/src/client/state/intent-store.ts +5 -1
  58. package/src/client/state/presentation.ts +7 -0
  59. package/src/client/state/session-store.ts +3 -0
  60. package/src/client/state/sse-bridge.ts +3 -0
  61. package/src/client/theme/global.css +90 -4
  62. package/src/json-render/server.ts +29 -0
  63. package/src/server/agent-presence.ts +15 -9
  64. package/src/server/canvas-db.ts +15 -0
  65. package/src/server/canvas-operations.ts +115 -22
  66. package/src/server/canvas-schema.ts +10 -0
  67. package/src/server/canvas-state.ts +39 -4
  68. package/src/server/canvas-validation.ts +9 -1
  69. package/src/server/demo-state.json +11 -7
  70. package/src/server/html-primitives.ts +199 -91
  71. package/src/server/html-surface.ts +8 -3
  72. package/src/server/index.ts +12 -0
  73. package/src/server/operations/composites.ts +3 -1
  74. package/src/server/operations/ops/app.ts +10 -0
  75. package/src/server/operations/ops/batch.ts +12 -3
  76. package/src/server/operations/ops/groups.ts +14 -7
  77. package/src/server/operations/ops/json-render.ts +37 -5
  78. package/src/server/operations/ops/nodes.ts +42 -16
  79. package/src/server/operations/ops/viewport.ts +32 -0
  80. package/src/server/operations/registry.ts +6 -0
  81. package/src/server/scope-fence.ts +1 -1
  82. package/src/server/server.ts +9 -6
  83. package/src/server/web-artifacts.ts +9 -2
  84. package/src/server/workboard.ts +3 -3
  85. package/src/shared/tour.ts +87 -0
@@ -33,6 +33,7 @@ import {
33
33
  emptyStreamingSpec,
34
34
  GRAPH_NODE_SIZE,
35
35
  inferJsonRenderNodeTitle,
36
+ jsonRenderFormWarnings,
36
37
  JSON_RENDER_NODE_SIZE,
37
38
  normalizeAndValidateJsonRenderSpec,
38
39
  type GraphNodeInput,
@@ -118,6 +119,29 @@ export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
118
119
  export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
119
120
  export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
120
121
 
122
+ export interface CanvasSizeAdjustment {
123
+ requested: { width: number | null; height: number | null };
124
+ applied: { width: number; height: number };
125
+ reason: 'defaulted' | 'clamped-to-minimum' | 'defaulted-and-clamped-to-minimum' | 'fit-to-children';
126
+ }
127
+
128
+ export function describeCreateSize(
129
+ requestedWidth: number | undefined,
130
+ requestedHeight: number | undefined,
131
+ resolvedWidth: number,
132
+ resolvedHeight: number,
133
+ applied: { width: number; height: number },
134
+ ): CanvasSizeAdjustment | undefined {
135
+ const defaulted = requestedWidth === undefined || requestedHeight === undefined;
136
+ const clamped = applied.width !== resolvedWidth || applied.height !== resolvedHeight;
137
+ if (!defaulted && !clamped) return undefined;
138
+ return {
139
+ requested: { width: requestedWidth ?? null, height: requestedHeight ?? null },
140
+ applied,
141
+ reason: defaulted && clamped ? 'defaulted-and-clamped-to-minimum' : clamped ? 'clamped-to-minimum' : 'defaulted',
142
+ };
143
+ }
144
+
121
145
  // Per-type minimum creation sizes + clamp live in canvas-validation.ts
122
146
  // (NODE_MIN_CREATE_SIZES / clampCreateNodeSize) — shared with the validate
123
147
  // surface's sizeWarnings.
@@ -923,17 +947,15 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
923
947
  id: string;
924
948
  node: CanvasNodeState;
925
949
  needsCodeGraphRecompute: boolean;
950
+ sizeAdjustment?: CanvasSizeAdjustment;
926
951
  } {
927
952
  if (input.type === 'json-render' || input.type === 'graph') {
928
953
  throw new Error(`Use the dedicated ${input.type} node APIs for structured viewer nodes.`);
929
954
  }
930
955
 
931
- const { width, height } = clampCreateNodeSize(
932
- input.type,
933
- input.width ?? input.defaultWidth ?? 720,
934
- input.height ?? input.defaultHeight ?? 600,
935
- input.strictSize,
936
- );
956
+ const resolvedWidth = input.width ?? input.defaultWidth ?? 720;
957
+ const resolvedHeight = input.height ?? input.defaultHeight ?? 600;
958
+ const { width, height } = clampCreateNodeSize(input.type, resolvedWidth, resolvedHeight, input.strictSize);
937
959
  const position =
938
960
  input.x !== undefined && input.y !== undefined
939
961
  ? { x: input.x, y: input.y }
@@ -959,7 +981,16 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
959
981
  watchFileForNode(id, filePath);
960
982
  }
961
983
 
962
- return { id, node: storedNode, needsCodeGraphRecompute: input.type === 'file' };
984
+ const sizeAdjustment = describeCreateSize(input.width, input.height, resolvedWidth, resolvedHeight, {
985
+ width,
986
+ height,
987
+ });
988
+ return {
989
+ id,
990
+ node: storedNode,
991
+ needsCodeGraphRecompute: input.type === 'file',
992
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
993
+ };
963
994
  }
964
995
 
965
996
  export function resolveCanvasNode(nodeRef: CanvasNodeLookupInput):
@@ -1415,7 +1446,8 @@ export function saveCanvasSnapshotWithReuse(name: string): { snapshot: CanvasSna
1415
1446
  diff.removedNodes.length === 0 &&
1416
1447
  diff.modifiedNodes.length === 0 &&
1417
1448
  diff.addedEdges.length === 0 &&
1418
- diff.removedEdges.length === 0;
1449
+ diff.removedEdges.length === 0 &&
1450
+ JSON.stringify(newestData.tour) === JSON.stringify(canvasState.getTour());
1419
1451
  if (identical) return { snapshot: newest, reused: true };
1420
1452
  }
1421
1453
  }
@@ -1508,7 +1540,11 @@ export function removeCanvasEdge(id: string): { removed: boolean } {
1508
1540
  return { removed: canvasState.removeEdge(id) };
1509
1541
  }
1510
1542
 
1511
- export function createCanvasGroup(input: CanvasCreateGroupInput): { id: string; node: CanvasNodeState } {
1543
+ export function createCanvasGroup(input: CanvasCreateGroupInput): {
1544
+ id: string;
1545
+ node: CanvasNodeState;
1546
+ sizeAdjustment?: CanvasSizeAdjustment;
1547
+ } {
1512
1548
  let x = input.x;
1513
1549
  let y = input.y;
1514
1550
  let width = input.width ?? 600;
@@ -1566,22 +1602,42 @@ export function createCanvasGroup(input: CanvasCreateGroupInput): { id: string;
1566
1602
  if (!node) {
1567
1603
  throw new Error(`Group "${id}" was not created.`);
1568
1604
  }
1569
- return { id, node };
1605
+ const adjusted = input.width !== node.size.width || input.height !== node.size.height;
1606
+ const fitted = childIds.length > 0 && ((input.x === undefined && input.y === undefined) || !explicitFrame);
1607
+ return {
1608
+ id,
1609
+ node,
1610
+ ...(adjusted
1611
+ ? {
1612
+ sizeAdjustment: {
1613
+ requested: { width: input.width ?? null, height: input.height ?? null },
1614
+ applied: node.size,
1615
+ reason: fitted ? ('fit-to-children' as const) : ('defaulted' as const),
1616
+ },
1617
+ }
1618
+ : {}),
1619
+ };
1570
1620
  }
1571
1621
 
1572
1622
  export function groupCanvasNodes(
1573
1623
  groupId: string,
1574
1624
  childIds: string[],
1575
1625
  options: { childLayout?: CanvasArrangeMode } = {},
1576
- ): { ok: boolean } {
1626
+ ): { ok: boolean; sizeAdjustment?: CanvasSizeAdjustment } {
1627
+ const before = canvasState.getNode(groupId)?.size;
1628
+ const ok = canvasState.groupNodes(groupId, childIds, {
1629
+ // Preserve existing child positions unless an explicit layout is asked
1630
+ // for — matching createCanvasGroup and the batch group.add path. Without
1631
+ // this, grouping silently auto-packs the nodes into a grid.
1632
+ preservePositions: options.childLayout === undefined,
1633
+ ...(options.childLayout ? { layout: options.childLayout } : {}),
1634
+ });
1635
+ const applied = canvasState.getNode(groupId)?.size;
1577
1636
  return {
1578
- ok: canvasState.groupNodes(groupId, childIds, {
1579
- // Preserve existing child positions unless an explicit layout is asked
1580
- // for — matching createCanvasGroup and the batch group.add path. Without
1581
- // this, grouping silently auto-packs the nodes into a grid.
1582
- preservePositions: options.childLayout === undefined,
1583
- ...(options.childLayout ? { layout: options.childLayout } : {}),
1584
- }),
1637
+ ok,
1638
+ ...(before && applied && (before.width !== applied.width || before.height !== applied.height)
1639
+ ? { sizeAdjustment: { requested: before, applied, reason: 'fit-to-children' as const } }
1640
+ : {}),
1585
1641
  };
1586
1642
  }
1587
1643
 
@@ -1600,6 +1656,8 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
1600
1656
  url: string;
1601
1657
  spec: JsonRenderSpec;
1602
1658
  node: CanvasNodeState;
1659
+ warnings: string[];
1660
+ sizeAdjustment?: CanvasSizeAdjustment;
1603
1661
  } {
1604
1662
  const spec = normalizeAndValidateJsonRenderSpec(input.spec);
1605
1663
  const { width, height } = clampCreateNodeSize(
@@ -1628,7 +1686,24 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
1628
1686
  };
1629
1687
 
1630
1688
  canvasState.addJsonRenderNode(node);
1631
- return { id, url: String(node.data.url), spec, node };
1689
+ const sizeAdjustment = describeCreateSize(
1690
+ input.width,
1691
+ input.height,
1692
+ input.width ?? JSON_RENDER_NODE_SIZE.width,
1693
+ input.height ?? JSON_RENDER_NODE_SIZE.height,
1694
+ {
1695
+ width,
1696
+ height,
1697
+ },
1698
+ );
1699
+ return {
1700
+ id,
1701
+ url: String(node.data.url),
1702
+ spec,
1703
+ node,
1704
+ warnings: jsonRenderFormWarnings(spec),
1705
+ ...(sizeAdjustment ? { sizeAdjustment } : {}),
1706
+ };
1632
1707
  }
1633
1708
 
1634
1709
  /**
@@ -1643,7 +1718,7 @@ export function createCanvasStreamingJsonRenderNode(input: {
1643
1718
  width?: number;
1644
1719
  height?: number;
1645
1720
  strictSize?: boolean;
1646
- }): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
1721
+ }): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState; sizeAdjustment?: CanvasSizeAdjustment } {
1647
1722
  const spec = emptyStreamingSpec();
1648
1723
  const { width, height } = clampCreateNodeSize(
1649
1724
  'json-render',
@@ -1673,7 +1748,17 @@ export function createCanvasStreamingJsonRenderNode(input: {
1673
1748
  };
1674
1749
 
1675
1750
  canvasState.addJsonRenderNode(node);
1676
- return { id, url: String(node.data.url), spec, node };
1751
+ const sizeAdjustment = describeCreateSize(
1752
+ input.width,
1753
+ input.height,
1754
+ input.width ?? JSON_RENDER_NODE_SIZE.width,
1755
+ input.height ?? JSON_RENDER_NODE_SIZE.height,
1756
+ {
1757
+ width,
1758
+ height,
1759
+ },
1760
+ );
1761
+ return { id, url: String(node.data.url), spec, node, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
1677
1762
  }
1678
1763
 
1679
1764
  /**
@@ -1717,6 +1802,7 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
1717
1802
  url: string;
1718
1803
  spec: JsonRenderSpec;
1719
1804
  node: CanvasNodeState;
1805
+ sizeAdjustment?: CanvasSizeAdjustment;
1720
1806
  } {
1721
1807
  const title = input.title?.trim() || 'Graph';
1722
1808
  const spec = buildGraphSpec(input);
@@ -1747,7 +1833,14 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
1747
1833
  };
1748
1834
 
1749
1835
  canvasState.addGraphNode(node);
1750
- return { id, url: String(node.data.url), spec, node };
1836
+ const sizeAdjustment = describeCreateSize(
1837
+ input.width,
1838
+ input.heightPx,
1839
+ input.width ?? GRAPH_NODE_SIZE.width,
1840
+ input.heightPx ?? GRAPH_NODE_SIZE.height,
1841
+ { width, height },
1842
+ );
1843
+ return { id, url: String(node.data.url), spec, node, ...(sizeAdjustment ? { sizeAdjustment } : {}) };
1751
1844
  }
1752
1845
 
1753
1846
  /**
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { describeJsonRenderCatalog, type JsonRenderComponentDescriptor } from '../json-render/catalog.js';
4
4
  import {
5
5
  buildGraphSpec,
6
+ jsonRenderFormWarnings,
6
7
  normalizeAndValidateJsonRenderSpec,
7
8
  normalizeGraphType,
8
9
  type GraphNodeInput,
@@ -38,6 +39,7 @@ export interface StructuredValidationResult {
38
39
  ok: true;
39
40
  type: 'json-render' | 'graph' | 'html-primitive';
40
41
  normalizedSpec?: JsonRenderSpec;
42
+ warnings?: string[];
41
43
  normalizedPrimitive?: {
42
44
  kind: string;
43
45
  title: string;
@@ -235,6 +237,13 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
235
237
  fields: [
236
238
  { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
237
239
  { name: 'content', type: 'string', required: false, description: 'Mermaid diagram source text.' },
240
+ {
241
+ name: 'data.fit',
242
+ type: '"contain" | "none"',
243
+ required: false,
244
+ description:
245
+ 'Default contain: shrink overflowing diagrams proportionally, without enlarging. none: 100% with scrolling. Open as site always uses 100%. CLI: --fit contain|none.',
246
+ },
238
247
  ],
239
248
  example: {
240
249
  type: 'mermaid',
@@ -1013,6 +1022,7 @@ export function validateStructuredCanvasPayload(input: {
1013
1022
  ok: true,
1014
1023
  type: 'json-render',
1015
1024
  normalizedSpec,
1025
+ warnings: jsonRenderFormWarnings(normalizedSpec),
1016
1026
  summary: {
1017
1027
  root: normalizedSpec.root,
1018
1028
  elementCount: Object.keys(normalizedSpec.elements).length,
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { createHash } from 'node:crypto';
16
+ import { tourSchema, type Tour } from '../shared/tour.js';
16
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
17
18
  import { isAbsolute, join, dirname, relative } from 'node:path';
18
19
  import { gzipSync, gunzipSync } from 'node:zlib';
@@ -223,6 +224,7 @@ export interface CanvasAnnotation {
223
224
  }
224
225
 
225
226
  export interface CanvasLayout {
227
+ tour?: Tour;
226
228
  viewport: ViewportState;
227
229
  theme: CanvasTheme;
228
230
  nodes: CanvasNodeState[];
@@ -333,6 +335,7 @@ class CanvasStateManager {
333
335
  private edges = new Map<string, CanvasEdge>();
334
336
  private annotations = new Map<string, CanvasAnnotation>();
335
337
  private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
338
+ private _tour: Tour | undefined;
336
339
  private _theme: CanvasTheme = 'dark';
337
340
  private _contextPinnedNodeIds = new Set<string>();
338
341
  private _workspaceRoot = process.cwd();
@@ -899,6 +902,7 @@ class CanvasStateManager {
899
902
  const payload = this.externalizePersistedStateBlobs({
900
903
  version: 1,
901
904
  theme: this._theme,
905
+ tour: this._tour,
902
906
  viewport: this._viewport,
903
907
  nodes: Array.from(this.nodes.values()),
904
908
  edges: Array.from(this.edges.values()),
@@ -957,6 +961,7 @@ class CanvasStateManager {
957
961
  }
958
962
 
959
963
  private applyPersistedState(state: PersistedCanvasState): void {
964
+ this._tour = state.tour ? structuredClone(state.tour) : undefined;
960
965
  this.nodes.clear();
961
966
  this.edges.clear();
962
967
  this.annotations.clear();
@@ -1091,7 +1096,7 @@ class CanvasStateManager {
1091
1096
  contextPins: Array.from(this._contextPinnedNodeIds),
1092
1097
  ax: this.getAxState(),
1093
1098
  });
1094
- saveSnapshotToDB(this._db, snapshot, payload);
1099
+ saveSnapshotToDB(this._db, snapshot, { ...payload, tour: this._tour });
1095
1100
  snapshot.nodeCount = payload.nodes.length;
1096
1101
  snapshot.edgeCount = payload.edges.length;
1097
1102
  return snapshot;
@@ -1176,6 +1181,7 @@ class CanvasStateManager {
1176
1181
 
1177
1182
  const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
1178
1183
  version: 1,
1184
+ tour: this._tour,
1179
1185
  theme: this._theme,
1180
1186
  viewport: structuredClone(this._viewport),
1181
1187
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
@@ -1186,6 +1192,7 @@ class CanvasStateManager {
1186
1192
  });
1187
1193
  const nextState: PersistedCanvasState = {
1188
1194
  version: 1,
1195
+ tour: resolved.state.tour,
1189
1196
  theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
1190
1197
  viewport: structuredClone(resolved.state.viewport),
1191
1198
  nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
@@ -1233,9 +1240,13 @@ class CanvasStateManager {
1233
1240
  }
1234
1241
 
1235
1242
  /** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
1236
- getSnapshotData(
1237
- idOrName: string,
1238
- ): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[]; annotations: CanvasAnnotation[] } | null {
1243
+ getSnapshotData(idOrName: string): {
1244
+ name: string;
1245
+ nodes: CanvasNodeState[];
1246
+ edges: CanvasEdge[];
1247
+ annotations: CanvasAnnotation[];
1248
+ tour?: Tour;
1249
+ } | null {
1239
1250
  const resolved = this.readResolvedSnapshot(idOrName);
1240
1251
  if (!resolved) return null;
1241
1252
  const state = {
@@ -1246,6 +1257,7 @@ class CanvasStateManager {
1246
1257
  };
1247
1258
  return {
1248
1259
  name: resolved.snapshot.name,
1260
+ ...(state.tour ? { tour: structuredClone(state.tour) } : {}),
1249
1261
  nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
1250
1262
  edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
1251
1263
  annotations: Array.isArray(state.annotations)
@@ -1620,6 +1632,7 @@ class CanvasStateManager {
1620
1632
 
1621
1633
  getLayout(): CanvasLayout {
1622
1634
  return {
1635
+ tour: this.getTour(),
1623
1636
  viewport: structuredClone(this._viewport),
1624
1637
  theme: this._theme,
1625
1638
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.nodeForRead(node))),
@@ -1630,6 +1643,7 @@ class CanvasStateManager {
1630
1643
 
1631
1644
  getLayoutForPersistence(): CanvasLayout {
1632
1645
  return {
1646
+ tour: this.getTour(),
1633
1647
  viewport: structuredClone(this._viewport),
1634
1648
  theme: this._theme,
1635
1649
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
@@ -1638,6 +1652,24 @@ class CanvasStateManager {
1638
1652
  };
1639
1653
  }
1640
1654
 
1655
+ getTour(): Tour | undefined {
1656
+ return this._tour ? structuredClone(this._tour) : undefined;
1657
+ }
1658
+
1659
+ setTour(tour: Tour | null): void {
1660
+ const previous = this.getTour();
1661
+ this._tour = tour === null ? undefined : tourSchema.parse(tour);
1662
+ const next = this.getTour();
1663
+ this.scheduleSave();
1664
+ this.notifyChange('nodes');
1665
+ this.recordMutation({
1666
+ operationType: 'viewport',
1667
+ description: 'Updated board tour',
1668
+ forward: this.suppressed(() => this.setTour(next ?? null)),
1669
+ inverse: this.suppressed(() => this.setTour(previous ?? null)),
1670
+ });
1671
+ }
1672
+
1641
1673
  applyUpdates(updates: CanvasNodeUpdate[], options: ApplyUpdatesOptions = {}): { applied: number; skipped: number } {
1642
1674
  let applied = 0;
1643
1675
  let skipped = 0;
@@ -2246,6 +2278,8 @@ class CanvasStateManager {
2246
2278
  }
2247
2279
 
2248
2280
  clear(): void {
2281
+ const oldTour = this.getTour();
2282
+ this._tour = undefined;
2249
2283
  const oldNodes = Array.from(this.nodes.values()).map((n) => structuredClone(n));
2250
2284
  const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
2251
2285
  const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
@@ -2274,6 +2308,7 @@ class CanvasStateManager {
2274
2308
  for (const e of oldEdges) this.addEdge(structuredClone(e));
2275
2309
  for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
2276
2310
  this.setContextPins(oldPins);
2311
+ this.setTour(oldTour ?? null);
2277
2312
  this.ax.applyPersistedAx(oldAxState);
2278
2313
  this.setViewport(oldViewport);
2279
2314
  this.notifyChange('ax');
@@ -36,6 +36,8 @@ export interface CanvasValidationResult {
36
36
  collisions: CanvasValidationPair[];
37
37
  containments: CanvasContainmentIssue[];
38
38
  containmentViolations: CanvasContainmentIssue[];
39
+ /** Group frames overlapping nodes that are not members (advisory). */
40
+ groupFrameOverlaps: CanvasValidationPair[];
39
41
  missingEdgeEndpoints: Array<{ edgeId: string; from: string; to: string }>;
40
42
  /** Nodes below their type's readable minimum (advisory — does not fail `ok`). */
41
43
  sizeWarnings: CanvasSizeWarning[];
@@ -45,6 +47,7 @@ export interface CanvasValidationResult {
45
47
  collisions: number;
46
48
  containments: number;
47
49
  containmentViolations: number;
50
+ groupFrameOverlaps: number;
48
51
  missingEdgeEndpoints: number;
49
52
  sizeWarnings: number;
50
53
  };
@@ -97,6 +100,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
97
100
  const collisions: CanvasValidationPair[] = [];
98
101
  const containments: CanvasContainmentIssue[] = [];
99
102
  const containmentViolations: CanvasContainmentIssue[] = [];
103
+ const groupFrameOverlaps: CanvasValidationPair[] = [];
100
104
 
101
105
  for (let i = 0; i < layout.nodes.length; i++) {
102
106
  const a = layout.nodes[i]!;
@@ -113,7 +117,9 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
113
117
  continue;
114
118
  }
115
119
 
116
- collisions.push(pair(a, b));
120
+ const overlap = pair(a, b);
121
+ collisions.push(overlap);
122
+ if (a.type === 'group' || b.type === 'group') groupFrameOverlaps.push(overlap);
117
123
  }
118
124
  }
119
125
 
@@ -150,6 +156,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
150
156
  collisions,
151
157
  containments,
152
158
  containmentViolations,
159
+ groupFrameOverlaps,
153
160
  missingEdgeEndpoints,
154
161
  sizeWarnings,
155
162
  summary: {
@@ -158,6 +165,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
158
165
  collisions: collisions.length,
159
166
  containments: containments.length,
160
167
  containmentViolations: containmentViolations.length,
168
+ groupFrameOverlaps: groupFrameOverlaps.length,
161
169
  missingEdgeEndpoints: missingEdgeEndpoints.length,
162
170
  sizeWarnings: sizeWarnings.length,
163
171
  },