pmx-canvas 0.6.4 → 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 (78) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/Readme.md +7 -1
  3. package/dist/canvas/global.css +58 -4
  4. package/dist/canvas/index.js +92 -92
  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/Presentation.d.ts +13 -0
  9. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  10. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  11. package/dist/types/client/state/presentation.d.ts +3 -0
  12. package/dist/types/client/types.d.ts +1 -1
  13. package/dist/types/json-render/catalog.d.ts +2 -2
  14. package/dist/types/json-render/server.d.ts +2 -0
  15. package/dist/types/server/canvas-db.d.ts +2 -0
  16. package/dist/types/server/canvas-operations.d.ts +22 -0
  17. package/dist/types/server/canvas-schema.d.ts +1 -0
  18. package/dist/types/server/canvas-state.d.ts +6 -0
  19. package/dist/types/server/canvas-validation.d.ts +3 -0
  20. package/dist/types/server/html-surface.d.ts +1 -1
  21. package/dist/types/server/index.d.ts +4 -0
  22. package/dist/types/server/operations/ops/app.d.ts +2 -0
  23. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  24. package/dist/types/server/operations/ops/nodes.d.ts +3 -1
  25. package/dist/types/server/web-artifacts.d.ts +3 -0
  26. package/dist/types/shared/tour.d.ts +72 -0
  27. package/docs/cli.md +89 -0
  28. package/docs/http-api.md +79 -4
  29. package/docs/mcp.md +25 -1
  30. package/docs/node-types.md +80 -0
  31. package/package.json +1 -1
  32. package/skills/pmx-canvas/SKILL.md +10 -2
  33. package/skills/pmx-canvas/references/full-reference.md +1 -1
  34. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  35. package/src/cli/agent.ts +5 -0
  36. package/src/cli/commands/nodes.ts +10 -0
  37. package/src/cli/commands/record.ts +218 -0
  38. package/src/cli/index.ts +2 -0
  39. package/src/client/App.tsx +7 -1
  40. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  41. package/src/client/canvas/CanvasNode.tsx +8 -0
  42. package/src/client/canvas/IntentLayer.tsx +9 -2
  43. package/src/client/canvas/Presentation.tsx +108 -0
  44. package/src/client/canvas/TopBar.tsx +28 -2
  45. package/src/client/canvas/use-node-drag.ts +8 -3
  46. package/src/client/mermaid-entry.ts +39 -0
  47. package/src/client/nodes/HtmlNode.tsx +12 -13
  48. package/src/client/nodes/McpAppNode.tsx +105 -13
  49. package/src/client/nodes/MermaidNode.tsx +3 -2
  50. package/src/client/state/intent-store.ts +5 -1
  51. package/src/client/state/presentation.ts +7 -0
  52. package/src/client/state/session-store.ts +3 -0
  53. package/src/client/state/sse-bridge.ts +3 -0
  54. package/src/client/theme/global.css +58 -4
  55. package/src/json-render/server.ts +29 -0
  56. package/src/server/agent-presence.ts +15 -9
  57. package/src/server/canvas-db.ts +15 -0
  58. package/src/server/canvas-operations.ts +115 -22
  59. package/src/server/canvas-schema.ts +10 -0
  60. package/src/server/canvas-state.ts +39 -4
  61. package/src/server/canvas-validation.ts +9 -1
  62. package/src/server/demo-state.json +11 -7
  63. package/src/server/html-primitives.ts +199 -91
  64. package/src/server/html-surface.ts +8 -3
  65. package/src/server/index.ts +12 -0
  66. package/src/server/operations/composites.ts +3 -1
  67. package/src/server/operations/ops/app.ts +10 -0
  68. package/src/server/operations/ops/batch.ts +12 -3
  69. package/src/server/operations/ops/groups.ts +14 -7
  70. package/src/server/operations/ops/json-render.ts +37 -5
  71. package/src/server/operations/ops/nodes.ts +42 -16
  72. package/src/server/operations/ops/viewport.ts +32 -0
  73. package/src/server/operations/registry.ts +6 -0
  74. package/src/server/scope-fence.ts +1 -1
  75. package/src/server/server.ts +9 -6
  76. package/src/server/web-artifacts.ts +9 -2
  77. package/src/server/workboard.ts +3 -3
  78. package/src/shared/tour.ts +87 -0
@@ -1210,13 +1210,13 @@ body,
1210
1210
  font-size: 9px;
1211
1211
  padding: 1px 6px;
1212
1212
  }
1213
- /* Idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
1213
+ /* Parked idle cursors leave the board (user feedback, 0.5.1 cycle): with many agents
1214
1214
  attached, a wall of parked "Idle" pills covers real node content. The
1215
1215
  element stays MOUNTED — counts, twin-merge, and cursor-follow logic are
1216
1216
  untouched, and visibility (not display) keeps its layout box so position
1217
- polls still resolve — but it paints nothing; any non-idle phase fades it
1218
- back in. Placed after .is-worker so opacity: 0 wins that collision. */
1219
- .agent-cursor.phase-idle {
1217
+ polls still resolve — but it paints nothing. An explicitly reported cursor
1218
+ remains visible even while idle so cursor-only movement cannot disappear. */
1219
+ .agent-cursor.phase-idle:not(.has-explicit-cursor) {
1220
1220
  opacity: 0;
1221
1221
  visibility: hidden;
1222
1222
  transition:
@@ -4211,6 +4211,33 @@ html.is-node-resizing .ext-app-preview-catcher {
4211
4211
  }
4212
4212
 
4213
4213
  /* MCP app iframe */
4214
+ .mcp-app-frame-stack {
4215
+ position: relative;
4216
+ flex: 1;
4217
+ min-width: 0;
4218
+ min-height: 0;
4219
+ background: var(--c-panel-soft);
4220
+ overflow: hidden;
4221
+ border-radius: 0 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px);
4222
+ }
4223
+
4224
+ .mcp-app-frame-stack:has(.html-node-frame) {
4225
+ width: 100%;
4226
+ height: 100%;
4227
+ min-height: 300px;
4228
+ background: var(--c-bg);
4229
+ border-radius: 6px;
4230
+ }
4231
+
4232
+ .mcp-app-frame-stack:has(.html-node-frame-presentation) {
4233
+ min-height: 0;
4234
+ border-radius: 0;
4235
+ }
4236
+
4237
+ .mcp-app-frame-stack:has(.html-node-frame-expanded) {
4238
+ min-height: 70vh;
4239
+ }
4240
+
4214
4241
  .mcp-app-frame {
4215
4242
  width: 100%;
4216
4243
  height: 100%;
@@ -6808,3 +6835,30 @@ html.is-node-dragging .intent-ghost,
6808
6835
  html.is-node-dragging .intent-line-layer {
6809
6836
  opacity: 0.5;
6810
6837
  }
6838
+
6839
+ .present-button {
6840
+ color: var(--c-text);
6841
+ background: var(--c-bg);
6842
+ border: 1px solid var(--c-line);
6843
+ border-radius: 6px;
6844
+ padding: 4px 12px;
6845
+ cursor: pointer;
6846
+ }
6847
+ .is-presenting > :not(.app-main):not(.presentation-error),
6848
+ .is-presenting .app-main > :not(.canvas-region),
6849
+ .is-presenting .canvas-region > :not(.canvas-viewport) {
6850
+ display: none !important;
6851
+ }
6852
+ .is-presenting .canvas-viewport { pointer-events: none; }
6853
+ .presentation-error {
6854
+ position: fixed;
6855
+ bottom: 16px;
6856
+ left: 16px;
6857
+ z-index: 100;
6858
+ max-width: calc(100% - 32px);
6859
+ padding: 10px 16px;
6860
+ border: 1px solid var(--c-line);
6861
+ border-radius: 6px;
6862
+ background: var(--c-panel);
6863
+ color: var(--c-text);
6864
+ }
@@ -542,6 +542,35 @@ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpe
542
542
  return propsValidation.data as JsonRenderSpec;
543
543
  }
544
544
 
545
+ /** shadcn 0.21 ignores these props unless the renderer supplies a two-way binding. */
546
+ export function jsonRenderFormWarnings(spec: JsonRenderSpec): string[] {
547
+ const bindingOnlyProps: Record<string, string> = {
548
+ Input: 'value',
549
+ Textarea: 'value',
550
+ Select: 'value',
551
+ Radio: 'value',
552
+ Slider: 'value',
553
+ ToggleGroup: 'value',
554
+ ButtonGroup: 'selected',
555
+ Tabs: 'value',
556
+ };
557
+ const warnings: string[] = [];
558
+ for (const [key, rawElement] of Object.entries(spec.elements)) {
559
+ const element = asRecord(rawElement);
560
+ const type = typeof element?.type === 'string' ? element.type : '';
561
+ const prop = bindingOnlyProps[type];
562
+ if (!prop) continue;
563
+ const value = asRecord(element?.props)?.[prop];
564
+ if (value === undefined || value === null) continue;
565
+ const expression = asRecord(value);
566
+ if (hasString(expression?.$bindState) || hasString(expression?.$bindItem)) continue;
567
+ warnings.push(
568
+ `elements.${key}.props.${prop}: ${type} ignores an unbound ${prop} (including literal values and $state expressions). Use { "$bindState": "/form/field" } and seed spec.state.form.field with the initial value; inside a repeat use $bindItem with seeded item data.${type === 'Tabs' ? ' For an uncontrolled initial tab, use defaultValue instead.' : ''}`,
569
+ );
570
+ }
571
+ return warnings;
572
+ }
573
+
545
574
  export function normalizeGraphType(value: string): GraphChartType {
546
575
  const normalized = value.toLowerCase().replace(/[-_\s]/g, '');
547
576
  return GRAPH_TYPE_ALIASES[normalized] ?? 'LineChart';
@@ -198,6 +198,8 @@ const presenceSetSchema = z.object(PRESENCE_SET_SHAPE);
198
198
 
199
199
  interface StoredPresence extends AgentPresence {
200
200
  lastSeenMs: number;
201
+ /** Last explicit phase report; cursor/focus heartbeats must not keep a stale phase alive. */
202
+ phaseSetMs: number;
201
203
  /** Explicit phases (`thinking`) hold until the next touch; derived `tooling` decays. */
202
204
  toolingUntilMs: number | null;
203
205
  lastActivityNodeId: string | null;
@@ -381,6 +383,7 @@ export class AgentPresenceRegistry {
381
383
  contextUsage: null,
382
384
  lastSeenAt: new Date(now).toISOString(),
383
385
  lastSeenMs: now,
386
+ phaseSetMs: now,
384
387
  toolingUntilMs: null,
385
388
  startSnapshotId: null,
386
389
  };
@@ -458,10 +461,12 @@ export class AgentPresenceRegistry {
458
461
  }
459
462
  stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
460
463
  }
461
- if (wasAttached && input.attached === false) {
462
- this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
463
- // An ended session is gone — like `session-end` on the activity feed, it
464
- // must not linger as an "external writer" until the activity TTL.
464
+ if (input.attached === false) {
465
+ if (wasAttached)
466
+ this.onSessionEnd(this.publicView(stored, now), stored.startSnapshotId, input.endedBy ?? 'agent');
467
+ // An explicit detach always removes the presence. In particular, a
468
+ // worker that reports attached:false must not be refreshed into another
469
+ // full activity TTL as an external writer.
465
470
  this.presences.delete(key);
466
471
  this.dropAliasesTo(key);
467
472
  this.maybeStopSweeper();
@@ -481,6 +486,7 @@ export class AgentPresenceRegistry {
481
486
  if (input.detail !== undefined) stored.detail = input.detail;
482
487
  } else if (input.phase !== undefined) {
483
488
  stored.phase = input.phase;
489
+ stored.phaseSetMs = now;
484
490
  stored.detail = input.detail !== undefined ? input.detail : input.phase === 'tooling' ? stored.detail : null;
485
491
  stored.toolingUntilMs = input.phase === 'tooling' ? now + PRESENCE_TOOLING_SETTLE_MS : null;
486
492
  } else if (input.detail !== undefined) {
@@ -607,6 +613,7 @@ export class AgentPresenceRegistry {
607
613
  private publicView(stored: StoredPresence, now: number): AgentPresence {
608
614
  const {
609
615
  lastSeenMs: _lastSeenMs,
616
+ phaseSetMs: _phaseSetMs,
610
617
  toolingUntilMs,
611
618
  startSnapshotId: _startSnapshotId,
612
619
  lastActivityNodeId,
@@ -691,11 +698,10 @@ export class AgentPresenceRegistry {
691
698
  changed = true;
692
699
  continue;
693
700
  }
694
- // Explicit thinking that nothing refreshed settles to idle: the host set
695
- // it at turn start and never sent the turn-end idle (three "Thinking"
696
- // chips on a silent board). Any touch bumps lastSeenMs, so an agent
697
- // that IS doing canvas work keeps its thinking chip.
698
- if (presence.phase === 'thinking' && now - presence.lastSeenMs > PRESENCE_THINKING_SETTLE_MS) {
701
+ // Cursor/focus heartbeats keep the writer live, but cannot keep a stale
702
+ // explicit phase alive. Only another explicit phase report resets this
703
+ // clock; operation-derived tooling remains its own short overlay.
704
+ if (presence.phase === 'thinking' && now - presence.phaseSetMs > PRESENCE_THINKING_SETTLE_MS) {
699
705
  presence.phase = 'idle';
700
706
  presence.detail = null;
701
707
  changed = true;
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { Database } from 'bun:sqlite';
9
+ import { tourSchema, type Tour } from '../shared/tour.js';
9
10
  import { existsSync, mkdirSync } from 'node:fs';
10
11
  import { dirname } from 'node:path';
11
12
  import { gzipSync, gunzipSync } from 'node:zlib';
@@ -260,6 +261,7 @@ function parsePersistedAxState(raw: string | null | undefined): PmxAxState {
260
261
  // ── Persisted State Interface ───────────────────────────────────
261
262
 
262
263
  export interface PersistedCanvasState {
264
+ tour?: Tour;
263
265
  version: number;
264
266
  theme?: CanvasTheme;
265
267
  viewport: ViewportState;
@@ -270,6 +272,11 @@ export interface PersistedCanvasState {
270
272
  ax?: PmxAxState;
271
273
  }
272
274
 
275
+ function readTour(raw: string | undefined): Tour | undefined {
276
+ if (!raw || raw === 'null') return undefined;
277
+ return tourSchema.parse(JSON.parse(raw));
278
+ }
279
+
273
280
  // ── Database Management ─────────────────────────────────────────
274
281
 
275
282
  export function openCanvasDb(dbPath: string): Database {
@@ -394,6 +401,7 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
394
401
  WHERE value IS NOT excluded.value`,
395
402
  );
396
403
  upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
404
+ upsertMeta.run('tour', JSON.stringify(state.tour ?? null));
397
405
  upsertMeta.run('viewport_x', String(state.viewport.x));
398
406
  upsertMeta.run('viewport_y', String(state.viewport.y));
399
407
  upsertMeta.run('viewport_scale', String(state.viewport.scale));
@@ -610,6 +618,7 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
610
618
  return {
611
619
  version: 1,
612
620
  theme,
621
+ tour: readTour(db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('tour')?.value),
613
622
  viewport,
614
623
  nodes,
615
624
  edges,
@@ -632,6 +641,11 @@ export function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state:
632
641
  state.edges.length,
633
642
  ]);
634
643
 
644
+ db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
645
+ snapshot.id,
646
+ 'tour',
647
+ JSON.stringify(state.tour ?? null),
648
+ ]);
635
649
  // Insert snapshot viewport meta
636
650
  db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
637
651
  snapshot.id,
@@ -859,6 +873,7 @@ export function loadSnapshotFromDB(
859
873
  snapshot,
860
874
  state: {
861
875
  version: 1,
876
+ tour: readTour(metaMap.get('tour')),
862
877
  viewport,
863
878
  nodes,
864
879
  edges,
@@ -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');