pmx-canvas 0.3.4 → 0.4.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 (39) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/Readme.md +11 -3
  3. package/dist/canvas/index.js +67 -67
  4. package/dist/types/client/index.d.ts +1 -1
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  6. package/dist/types/client/state/node-factory.d.ts +29 -0
  7. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  8. package/dist/types/server/canvas-db.d.ts +0 -1
  9. package/dist/types/server/canvas-state.d.ts +14 -15
  10. package/dist/types/server/operations/composites.d.ts +6 -21
  11. package/dist/types/server/server.d.ts +1 -0
  12. package/docs/cli.md +5 -0
  13. package/docs/environment.md +2 -2
  14. package/docs/http-api.md +25 -1
  15. package/docs/mcp.md +21 -30
  16. package/docs/plans/plan-009-tech-debt-backlog.md +13 -12
  17. package/docs/screenshot.png +0 -0
  18. package/docs/tech-debt-assessment-2026-06.md +5 -5
  19. package/package.json +7 -7
  20. package/skills/pmx-canvas/SKILL.md +16 -6
  21. package/skills/pmx-canvas/evals/evals.json +2 -2
  22. package/skills/pmx-canvas/references/full-reference.md +17 -15
  23. package/src/cli/commands/history.ts +1 -1
  24. package/src/cli/commands/nodes.ts +31 -1
  25. package/src/cli/index.ts +8 -2
  26. package/src/cli/shared.ts +1 -0
  27. package/src/client/index.tsx +2 -0
  28. package/src/client/nodes/ExtAppFrame.tsx +17 -2
  29. package/src/client/state/node-factory.ts +72 -0
  30. package/src/client/state/sse-bridge.ts +209 -86
  31. package/src/client/uuid-polyfill.ts +14 -0
  32. package/src/server/canvas-db.ts +0 -8
  33. package/src/server/canvas-schema.ts +2 -8
  34. package/src/server/canvas-state.ts +51 -151
  35. package/src/server/index.ts +3 -9
  36. package/src/server/operations/composites.ts +24 -21
  37. package/src/server/operations/ops/snapshots.ts +6 -7
  38. package/src/server/operations/registry.ts +4 -1
  39. package/src/server/server.ts +170 -74
@@ -172,7 +172,7 @@
172
172
  "id": 7,
173
173
  "name": "snapshot-before-changes",
174
174
  "prompt": "I want to reorganize the canvas to explore a different approach, but I don't want to lose what's there now. Can you save the current state, clear it, and set up a fresh workspace for the new approach? If it doesn't work out I want to go back.",
175
- "expected_output": "Takes a named snapshot of current state, then clears the canvas. Mentions that canvas_restore can bring back the old state. Does NOT just clear without snapshotting.",
175
+ "expected_output": "Takes a named snapshot of current state, then clears the canvas. Mentions that canvas_snapshot { action: \"restore\" } can bring back the old state. Does NOT just clear without snapshotting.",
176
176
  "assertions": [
177
177
  {
178
178
  "name": "takes-snapshot",
@@ -186,7 +186,7 @@
186
186
  },
187
187
  {
188
188
  "name": "mentions-restore",
189
- "description": "Tells the user how to restore (canvas_restore) if they want to go back",
189
+ "description": "Tells the user how to restore (canvas_snapshot action restore) if they want to go back",
190
190
  "type": "output_check"
191
191
  }
192
192
  ]
@@ -73,7 +73,7 @@ section below.
73
73
  3. **Read before write** — `canvas_query { action: "search", query }` (or `pmx-canvas search`)
74
74
  to find existing nodes; avoid duplicates. Use `canvas_query { action: "layout" }` only when
75
75
  you need the full board.
76
- 4. **Snapshot before destructive work** — `canvas_snapshot { name }` before clear/major
76
+ 4. **Snapshot before destructive work** — `canvas_snapshot { action: "save", name }` before clear/major
77
77
  reorg; restore if needed.
78
78
  5. **Signal then mutate (default behavior)** — signal with `canvas_intent { action: "signal", … }`
79
79
  to telegraph the move before nearly every mutation, then create with the right composite: `canvas_node` (markdown/status/file/webpage/html
@@ -315,6 +315,7 @@ pmx-canvas spatial
315
315
  - `group create|add|remove` — manage groups
316
316
  - `clear --yes` — destructive clear with explicit confirmation
317
317
  - `validate spec` — validate json-render specs and graph payloads without creating nodes
318
+ - `node update` — returns a compact `{ ok, id, nodeId, position, size, updatedFields }` envelope by default; pass `--full` for the complete node payload. Full ext-app/mcp-app payloads embed the bundled app HTML and tool results (hundreds of KB — a no-op ext-app resize once returned ~940 KB), so in agent turns keep the default envelope and read content via `node get --summary`
318
319
  - `web-artifact build` — build bundled React/Tailwind HTML artifacts; use `--deps` for extra packages and `--include-logs` only when raw logs are useful. The build pipeline runs bash scripts (POSIX-only) — it is not supported on native Windows; already-built artifacts render everywhere
319
320
  - `external-app add --kind excalidraw` — create the hosted Excalidraw preset; response includes `id` and `nodeId` aliases for the same canvas node
320
321
  - `serve status|stop` — inspect and stop daemonized servers started with `serve --daemon`
@@ -429,7 +430,7 @@ single-purpose tools behind an `action` (and, for `canvas_ax_gate`, a `kind`) di
429
430
  > registered — use the composites instead. The authoritative legacy→composite mapping table lives
430
431
  > in [`docs/mcp.md`](../../../docs/mcp.md) — this skill does not re-enumerate the removed names.
431
432
 
432
- ### The 15 composites
433
+ ### The 16 composites
433
434
 
434
435
  | Composite | `action` values | What it does |
435
436
  |-----------|-----------------|--------------|
@@ -438,6 +439,7 @@ single-purpose tools behind an `action` (and, for `canvas_ax_gate`, a `kind`) di
438
439
  | `canvas_edge` | `add` · `remove` | Connect / disconnect nodes |
439
440
  | `canvas_group` | `create` · `add` · `ungroup` | Manage spatial group containers |
440
441
  | `canvas_history` | `undo` · `redo` | Time travel through the mutation ring buffer |
442
+ | `canvas_snapshot` | `save` · `list` · `restore` · `delete` · `gc` · `diff` | Named snapshots: save/list/restore/delete, garbage-collect old ones, diff current canvas vs a snapshot (`diff` takes `snapshot`, not `id`) |
441
443
  | `canvas_view` | `arrange` · `focus` · `fit` · `clear` · `remove-annotation` | Auto-arrange, pan-to-node, fit viewport, clear the board, delete a human-drawn annotation by id |
442
444
  | `canvas_query` | `search` · `layout` · `validate` | Find nodes by keyword, read full layout, or **`validate`** the board for node collisions / group-containment / dangling edges |
443
445
  | `canvas_app` | `open-mcp-app` · `diagram` · `build-artifact` | Hosted MCP apps, the Excalidraw diagram preset, and bundled web artifacts (folds `canvas_open_mcp_app` / `canvas_add_diagram` / `canvas_build_web_artifact`) |
@@ -505,10 +507,9 @@ investigation board" intent, then run the batch with that linked `intentId` (use
505
507
 
506
508
  These stay separate by design (trust-boundary, firehose, execution-intent, or not-yet-consolidated
507
509
  surfaces): `canvas_batch`, `canvas_pin_nodes`, `canvas_screenshot`, `canvas_ax_interaction`,
508
- `canvas_ingest_activity`, `canvas_invoke_command`, and the snapshot tools (`canvas_snapshot`,
509
- `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`, `canvas_gc_snapshots`,
510
- `canvas_diff` — deprecated pending a `canvas_snapshot` composite in v0.4; the name collides with
511
- the current save-snapshot tool, so the composite cannot land additively).
510
+ `canvas_ingest_activity`, `canvas_invoke_command`. Snapshots moved into the `canvas_snapshot`
511
+ composite in v0.4.0 (actions `save | list | restore | delete | gc | diff`); the 6 legacy snapshot
512
+ standalones were removed after their deprecated 0.3.x window.
512
513
 
513
514
  `canvas_batch` supports exactly these ops: `node.add`, `node.update`, `node.remove`, `graph.add`,
514
515
  `edge.add`, `edge.remove`, `group.create`, `group.add`, `group.remove`, `pin.set`/`pin.add`/
@@ -650,7 +651,7 @@ ID extraction for mixed tool responses:
650
651
 
651
652
  **`canvas_pin_nodes`** (standalone) — Set, add, or remove pinned context nodes. Use `{ nodeIds: [...] }` — the field is `nodeIds`, not `ids`.
652
653
 
653
- **`canvas_diff`** (standalone) — Compare current canvas state with a saved snapshot. Requires `{ snapshot: "<snapshot-id-or-name>" }`; there is no implicit previous-snapshot default.
654
+ **`canvas_snapshot { action: "diff" }`** — Compare current canvas state with a saved snapshot. Requires `{ snapshot: "<snapshot-id-or-name>" }`; there is no implicit previous-snapshot default.
654
655
 
655
656
  **`canvas_render { action: "describe-schema" }`** — Inspect the running server's create schemas and
656
657
  canonical examples. Use before generating structured payloads when you need the authoritative current
@@ -766,13 +767,14 @@ ranked matches with content snippets. Use instead of parsing the full layout to
766
767
  ### History & Snapshots
767
768
 
768
769
  - **`canvas_history { action: "undo" }`** / **`canvas_history { action: "redo" }`** — step the mutation ring buffer.
769
- - **`canvas_snapshot`** (standalone) — save a named snapshot; `name` required. Returns `{ ok, id, snapshot }` (flat `id` aliases `snapshot.id`).
770
- - **`canvas_restore`** (standalone) — restore from a snapshot `id`.
771
- - **`canvas_diff`** (standalone) — compare current canvas against a saved snapshot (added/removed/modified nodes & edges).
770
+ - **`canvas_snapshot { action: "save", name }`** — save a named snapshot. Returns `{ ok, id, snapshot }` (flat `id` aliases `snapshot.id`).
771
+ - **`canvas_snapshot { action: "list" }`** / **`{ action: "gc", keep }`** / **`{ action: "delete", id }`** — manage saved snapshots.
772
+ - **`canvas_snapshot { action: "restore", id }`** — restore from a snapshot.
773
+ - **`canvas_snapshot { action: "diff", snapshot }`** — compare current canvas against a saved snapshot (added/removed/modified nodes & edges). Note the field is `snapshot` (id or name), not `id`.
772
774
 
773
775
  ### Canvas Management
774
776
 
775
- **`canvas_view { action: "clear" }`** — Remove all nodes and edges. **Always `canvas_snapshot` first** —
777
+ **`canvas_view { action: "clear" }`** — Remove all nodes and edges. **Always `canvas_snapshot { action: "save" }` first** —
776
778
  this is irreversible without a prior snapshot.
777
779
 
778
780
  ### Browser Automation (WebView)
@@ -1405,19 +1407,19 @@ Monitor ongoing processes:
1405
1407
 
1406
1408
  Show two states side by side for the human to compare:
1407
1409
 
1408
- 1. Take a snapshot before changes: `canvas_snapshot` with name "before-X"
1410
+ 1. Take a snapshot before changes: `canvas_snapshot { action: "save" }` with name "before-X"
1409
1411
  2. Make changes to the canvas
1410
- 3. Use `canvas_diff` to show what changed
1412
+ 3. Use `canvas_snapshot { action: "diff", snapshot }` to show what changed
1411
1413
  4. Or: create two groups ("Before" and "After") with corresponding nodes
1412
1414
 
1413
1415
  ### Save and Start Fresh
1414
1416
 
1415
1417
  When the human wants to explore a different approach without losing current work:
1416
1418
 
1417
- 1. **First**, save the current state: `canvas_snapshot` with a descriptive name
1419
+ 1. **First**, save the current state: `canvas_snapshot { action: "save" }` with a descriptive name
1418
1420
  2. **Then** clear: `canvas_view { action: "clear" }` (never clear without snapshotting first)
1419
1421
  3. Set up the new workspace with initial nodes
1420
- 4. Tell the human the snapshot name and that `canvas_restore` can bring everything back
1422
+ 4. Tell the human the snapshot name and that `canvas_snapshot { action: "restore" }` can bring everything back
1421
1423
 
1422
1424
  ## Best Practices
1423
1425
 
@@ -193,7 +193,7 @@ cmd('snapshot delete', 'Delete a saved snapshot', ['pmx-canvas snapshot delete <
193
193
  });
194
194
 
195
195
  async function runSnapshotDiff(args: string[]): Promise<void> {
196
- const { positional, flags } = parseFlags(args);
196
+ const { positional } = parseFlags(args);
197
197
  const snapshot = positional[0];
198
198
  if (!snapshot) die('Missing snapshot ID or name', 'pmx-canvas snapshot diff <snapshot-id-or-name>');
199
199
  const result = await invokeOperation('snapshot.diff', { id: snapshot });
@@ -830,6 +830,7 @@ cmd(
830
830
  'pmx-canvas node update <node-id> --dock-position right',
831
831
  'pmx-canvas node update <node-id> --dock-position none # undock back to the canvas',
832
832
  'pmx-canvas node update <node-id> --lock-arrange',
833
+ 'pmx-canvas node update <node-id> --width 840 --full # full node payload instead of the compact envelope',
833
834
  ],
834
835
  async (args) => {
835
836
  const { positional, flags } = parseFlags(args);
@@ -932,7 +933,36 @@ cmd(
932
933
  }
933
934
 
934
935
  const result = await invokeOperation('node.update', { id, ...body });
935
- output(result);
936
+ if (flags.full || flags.verbose) {
937
+ output(result);
938
+ return;
939
+ }
940
+ // Finding R (0.3.4 report): the raw update result echoes the FULL node —
941
+ // ~940 KB for an ext-app (bundled html + tool result) — which can consume a
942
+ // large share of an agent turn for a simple resize. Default to a compact
943
+ // envelope; --full restores the complete response.
944
+ const updatedNode = isRecord(result) && isRecord(result.node) ? result.node : undefined;
945
+ // Raw geometry flags also land in the body alongside their structured
946
+ // position/size forms (the graph-patch builder copies them through); report
947
+ // only the canonical field once.
948
+ const structuredDupes: Record<string, string> = {
949
+ width: 'size',
950
+ height: 'size',
951
+ nodeHeight: 'size',
952
+ x: 'position',
953
+ y: 'position',
954
+ };
955
+ const updatedFields = Object.keys(body)
956
+ .filter((key) => !(structuredDupes[key] && body[structuredDupes[key]] !== undefined))
957
+ .sort();
958
+ output({
959
+ ok: isRecord(result) && result.ok !== undefined ? result.ok : true,
960
+ id,
961
+ nodeId: id,
962
+ ...(updatedNode && isRecord(updatedNode.position) ? { position: updatedNode.position } : {}),
963
+ ...(updatedNode && isRecord(updatedNode.size) ? { size: updatedNode.size } : {}),
964
+ updatedFields,
965
+ });
936
966
  },
937
967
  );
938
968
 
package/src/cli/index.ts CHANGED
@@ -163,7 +163,10 @@ function runMcpServerProcess(): Promise<void> {
163
163
  const serveSubcommand = firstArg === 'serve' ? (args[1] ?? '') : '';
164
164
 
165
165
  if (firstArg === 'serve' && (serveSubcommand === 'status' || serveSubcommand === 'stop')) {
166
- const port = parseInt(readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? '4313');
166
+ const port = parseInt(
167
+ readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
168
+ 10,
169
+ );
167
170
  const daemonLogFile = resolve(readOption('log-file') ?? `.pmx-canvas/daemon-${port}.log`);
168
171
  const daemonPidFile = resolve(readOption('pid-file') ?? `.pmx-canvas/daemon-${port}.pid`);
169
172
  const daemonWaitMs = readNumberOption('wait-ms') ?? 10_000;
@@ -208,7 +211,10 @@ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
208
211
  await runMcpServerProcess();
209
212
  } else {
210
213
  // "serve" is also accessible via flags (backward compat)
211
- const port = parseInt(readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? '4313');
214
+ const port = parseInt(
215
+ readOption('port') ?? process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? '4313',
216
+ 10,
217
+ );
212
218
  const demo = hasFlag('demo');
213
219
  const noOpen = hasFlag('no-open');
214
220
  const daemon = hasFlag('daemon');
package/src/cli/shared.ts CHANGED
@@ -147,6 +147,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
147
147
  'json',
148
148
  'compact',
149
149
  'verbose',
150
+ 'full',
150
151
  'include-logs',
151
152
  'no-pan',
152
153
  'schema',
@@ -1,3 +1,5 @@
1
+ // Must run before any module that mints per-mount nonces at import/render time.
2
+ import './uuid-polyfill';
1
3
  import { render } from 'preact';
2
4
  import { App } from './App';
3
5
 
@@ -253,6 +253,19 @@ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: ${JS
253
253
  </script>`;
254
254
  }
255
255
 
256
+ /**
257
+ * Base styles injected into every ext-app document (Finding Q, 0.3.4 report):
258
+ * apps aspect-fit their drawn surface to the frame width, and on narrow/tall
259
+ * tiles the leftover region shows the app document's own body background —
260
+ * pure black for the hosted Excalidraw bundle, in every engine. Making the
261
+ * app document transparent lets the host iframe's theme background
262
+ * (var(--c-panel)) show through the letterbox instead. Background only — app
263
+ * content, its drawn scene, and its own foreground styling are untouched.
264
+ */
265
+ export function buildExtAppSurfaceBaseStyles(): string {
266
+ return '<style data-pmx-canvas-ext-app-base>html, body { background: transparent !important; }</style>';
267
+ }
268
+
256
269
  export function injectExtAppAxBridgeScript(html: string, axBridgeScript: string): string {
257
270
  if (!axBridgeScript) return html;
258
271
  const headMatch = /<head\b[^>]*>/i.exec(html);
@@ -359,7 +372,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
359
372
  const bootBeaconScript =
360
373
  typeof html === 'string' && html.length > 0 ? buildExtAppBootBeaconScript(axToken, nodeId) : '';
361
374
  const iframeDocument = useIframeDocument(
362
- injectExtAppAxBridgeScript(html ?? '', bootBeaconScript + axBridgeScript),
375
+ injectExtAppAxBridgeScript(html ?? '', buildExtAppSurfaceBaseStyles() + bootBeaconScript + axBridgeScript),
363
376
  iframeSandbox,
364
377
  );
365
378
 
@@ -688,7 +701,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
688
701
 
689
702
  bridge.onsandboxready = async () => {
690
703
  await bridge.sendSandboxResourceReady({
691
- html,
704
+ // Same Finding Q base styles for the sandbox-proxy path: the inner
705
+ // resource document is the one that letterboxes on narrow tiles.
706
+ html: injectExtAppAxBridgeScript(html, buildExtAppSurfaceBaseStyles()),
692
707
  sandbox: DEFAULT_EXT_APP_SANDBOX,
693
708
  ...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
694
709
  ...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
@@ -0,0 +1,72 @@
1
+ import type { CanvasNodeState } from '../types';
2
+
3
+ // ── Client node-creation defaults (plan-009 H5, client half) ──
4
+ // Single source of truth for the frame (position/size/zIndex/flags) a NEW
5
+ // client-created node gets when its creating event carries no explicit
6
+ // geometry. Extracted from sse-bridge, which kept one `makeNode` helper plus
7
+ // three inline node literals (ext-app open, prompt, response) duplicating
8
+ // these defaults.
9
+ //
10
+ // Divergences found during extraction — documented, NOT silently unified:
11
+ // - `canvas-store.ts` holds no node-creation defaults today. Human-created
12
+ // nodes go through `createNodeFromClient` (intent-bridge → POST
13
+ // /api/canvas/node) and get the SERVER's `defaultNodeSize`
14
+ // (src/server/operations/ops/nodes.ts), which differs from this table for
15
+ // most types (e.g. its fallback is 360x200). The server half of H5
16
+ // (`syncEventToCanvasState`, `ensureDefaultDockedNodes`) is deliberately
17
+ // deferred — do not "fix" that mismatch from this file.
18
+ // - The server seeds `status-main`/`context-main` docked AND collapsed
19
+ // (`ensureDefaultDockedNodes` in src/server/server.ts); the SSE bridge
20
+ // creates the same ids expanded, with `context-main` undocked. Client
21
+ // behavior is preserved as-is.
22
+ // - sse-bridge's prompt literal hardcoded `height: 400` beside a table entry
23
+ // of the same value; both now read the table (no rendering change).
24
+
25
+ export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y: number; w: number; h: number }> &
26
+ Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
27
+ status: { x: 40, y: 80, w: 300, h: 120 },
28
+ markdown: { x: 380, y: 80, w: 720, h: 600 },
29
+ context: { x: 1130, y: 80, w: 320, h: 400 },
30
+ 'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
31
+ webpage: { x: 380, y: 80, w: 520, h: 420 },
32
+ 'json-render': { x: 380, y: 720, w: 840, h: 620 },
33
+ graph: { x: 380, y: 720, w: 760, h: 520 },
34
+ ledger: { x: 1130, y: 520, w: 320, h: 280 },
35
+ trace: { x: 40, y: 900, w: 200, h: 56 },
36
+ file: { x: 380, y: 80, w: 720, h: 600 },
37
+ image: { x: 380, y: 80, w: 720, h: 520 },
38
+ html: { x: 380, y: 80, w: 720, h: 640 },
39
+ group: { x: 220, y: 60, w: 840, h: 560 },
40
+ prompt: { x: 380, y: 1260, w: 520, h: 400 },
41
+ response: { x: 380, y: 1480, w: 720, h: 400 },
42
+ };
43
+
44
+ /**
45
+ * Build a canvas node with the shared client defaults. `position`/`size`
46
+ * default to the type's DEFAULT_POSITIONS entry; callers that compute
47
+ * placement (auto-placement, event-supplied geometry) pass explicit values.
48
+ * Status nodes sit at zIndex 0 (background chrome), everything else at 1.
49
+ */
50
+ export function makeNodeState(
51
+ id: string,
52
+ type: CanvasNodeState['type'],
53
+ data: Record<string, unknown>,
54
+ options: {
55
+ position?: { x: number; y: number };
56
+ size?: { width: number; height: number };
57
+ dockPosition?: 'left' | 'right' | null;
58
+ } = {},
59
+ ): CanvasNodeState {
60
+ const defaults = DEFAULT_POSITIONS[type];
61
+ return {
62
+ id,
63
+ type,
64
+ position: options.position ?? { x: defaults.x, y: defaults.y },
65
+ size: options.size ?? { width: defaults.w, height: defaults.h },
66
+ zIndex: type === 'status' ? 0 : 1,
67
+ collapsed: false,
68
+ pinned: false,
69
+ dockPosition: options.dockPosition ?? null,
70
+ data,
71
+ };
72
+ }