pmx-canvas 0.1.17 → 0.1.19

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.
package/docs/sdk.md ADDED
@@ -0,0 +1,92 @@
1
+ # JavaScript/TypeScript SDK (Bun runtime)
2
+
3
+ The published SDK entrypoint is Bun-first. Node.js consumers should use the
4
+ [CLI](cli.md), [MCP server](mcp.md), or [HTTP API](http-api.md) instead.
5
+
6
+ ```bash
7
+ bun add pmx-canvas
8
+ ```
9
+
10
+ ## Quick example
11
+
12
+ ```ts
13
+ import { createCanvas } from 'pmx-canvas';
14
+
15
+ const canvas = createCanvas({ port: 4313 });
16
+ await canvas.start({ open: true });
17
+
18
+ // Add nodes
19
+ const n1 = canvas.addNode({ type: 'markdown', title: 'Plan', content: '# Step 1\nDo the thing.' });
20
+ const n2 = canvas.addNode({ type: 'status', title: 'Build', content: 'passing' });
21
+ const n3 = canvas.addNode({ type: 'file', content: 'src/index.ts' });
22
+
23
+ // Connect them
24
+ canvas.addEdge({ from: n1, to: n2, type: 'flow' });
25
+
26
+ // Group related nodes
27
+ canvas.createGroup({ title: 'Build Pipeline', childIds: [n1, n2] });
28
+
29
+ // Self-contained HTML in a sandboxed iframe
30
+ canvas.addHtmlNode({
31
+ title: 'Cost projection',
32
+ html: '<canvas id="c"></canvas><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><script>/* ... */</script>',
33
+ });
34
+
35
+ // Hand-drawn diagram via the Excalidraw MCP-app preset
36
+ await canvas.addDiagram({
37
+ elements: [
38
+ { type: 'rectangle', id: 'r1', x: 80, y: 80, width: 160, height: 60,
39
+ roundness: { type: 3 }, backgroundColor: '#a5d8ff', fillStyle: 'solid',
40
+ label: { text: 'Agent' } },
41
+ ],
42
+ title: 'Quick sketch',
43
+ });
44
+
45
+ // Batch-build a graph and group around it
46
+ await canvas.runBatch([
47
+ {
48
+ op: 'graph.add',
49
+ assign: 'graph',
50
+ args: {
51
+ title: 'Major wins',
52
+ graphType: 'bar',
53
+ data: [
54
+ { label: 'Docs', value: 5 },
55
+ { label: 'Tests', value: 8 },
56
+ ],
57
+ xKey: 'label',
58
+ yKey: 'value',
59
+ },
60
+ },
61
+ {
62
+ op: 'group.create',
63
+ args: {
64
+ title: 'Quarterly graphs',
65
+ childIds: ['$graph.id'],
66
+ },
67
+ },
68
+ ]);
69
+
70
+ // Arrange and inspect
71
+ canvas.arrange('grid');
72
+ console.log(canvas.validate());
73
+ console.log(canvas.getLayout());
74
+ ```
75
+
76
+ ## WebView automation
77
+
78
+ ```ts
79
+ const webview = await canvas.startAutomationWebView({ backend: 'chrome', width: 1280, height: 800 });
80
+ console.log(webview.active);
81
+ console.log(await canvas.evaluateAutomationWebView('document.title'));
82
+ await canvas.resizeAutomationWebView(1440, 900);
83
+ const screenshot = await canvas.screenshotAutomationWebView({ format: 'png' });
84
+ console.log(screenshot.byteLength);
85
+ await canvas.stopAutomationWebView();
86
+ ```
87
+
88
+ ## See also
89
+
90
+ - [Node types](node-types.md) — what each node type is for
91
+ - [HTTP API](http-api.md) — the same operations from any language
92
+ - [MCP reference](mcp.md) — the agent-facing surface
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "src/",
20
20
  "skills/",
21
+ "docs/",
21
22
  "dist/canvas/",
22
23
  "dist/json-render/",
23
24
  "dist/types/",
@@ -622,6 +622,8 @@ canvas_webview_stop();
622
622
  - Do not use separate `text` elements with `containerId`/`boundElements` to place centered text
623
623
  inside shapes. The hosted SVG preview does not auto-position those; PMX normalizes imported
624
624
  canonical bound text back into shape labels for hosted app calls.
625
+ - For detailed sizing, camera, and label-fit rules, read `references/excalidraw-diagram-authoring.md`
626
+ before creating dense diagrams.
625
627
  - Prefer the pastel fill palette in the Excalidraw `read_me` (light blue/green/orange/...) for
626
628
  a consistent look across diagrams
627
629
 
@@ -0,0 +1,145 @@
1
+ # Excalidraw Diagram Authoring
2
+
3
+ Use this guide when creating diagrams through PMX Canvas with `canvas_add_diagram` or
4
+ `pmx-canvas external-app add --kind excalidraw`.
5
+
6
+ ## Why Text Can Still Drift
7
+
8
+ PMX normalizes canonical Excalidraw bound text (`containerId` / `boundElements`) into the hosted
9
+ app's supported shape-level `label` format before calling Excalidraw. That fixes the payload
10
+ format mismatch, but it does not fix poor diagram geometry.
11
+
12
+ Text can still appear clipped or misplaced when:
13
+
14
+ - A label is too long for its shape.
15
+ - A diamond or ellipse is too small for the label's usable center area.
16
+ - The `cameraUpdate` viewport is too tight or not 4:3.
17
+ - Title, footer, or notes are placed near the camera edge.
18
+ - The caller bypasses PMX and sends raw elements directly to Excalidraw MCP.
19
+
20
+ ## Text Format Rules
21
+
22
+ For text inside a rectangle, ellipse, or diamond, use shape-level `label`:
23
+
24
+ ```json
25
+ {
26
+ "type": "rectangle",
27
+ "id": "step-a",
28
+ "x": 100,
29
+ "y": 100,
30
+ "width": 260,
31
+ "height": 90,
32
+ "label": { "text": "Step A", "fontSize": 18 }
33
+ }
34
+ ```
35
+
36
+ Do not create separate centered text elements for shape labels:
37
+
38
+ ```json
39
+ {
40
+ "type": "text",
41
+ "containerId": "step-a",
42
+ "text": "Step A"
43
+ }
44
+ ```
45
+
46
+ Standalone text is fine for titles, notes, captions, and free-floating annotations. For standalone
47
+ text, `x` is the left edge; `textAlign` does not center it on a point.
48
+
49
+ ## Label Length Rules
50
+
51
+ - Use 1-4 words inside shapes.
52
+ - Put detailed explanations in nearby standalone text annotations.
53
+ - Prefer `Bound Text` over `Pattern B: containerId+boundElements only`.
54
+ - If a label needs more than 4 words, either widen the shape or split the idea into a label plus an annotation.
55
+
56
+ ## Shape Sizing Rules
57
+
58
+ - Minimum labeled rectangle or ellipse: `180x80`.
59
+ - For 3-5 word labels: `240x90` or larger.
60
+ - For long labels: `320+` width or use an external annotation.
61
+ - Diamonds need more room than rectangles because the usable center area is smaller.
62
+ - Leave at least `30px` gap between shapes and labels/arrows.
63
+
64
+ ## Camera Rules
65
+
66
+ Always start with a `cameraUpdate` as the first element.
67
+
68
+ Use 4:3 camera sizes only:
69
+
70
+ - `400x300`
71
+ - `600x450`
72
+ - `800x600`
73
+ - `1200x900`
74
+ - `1600x1200`
75
+
76
+ Camera bounds must include the full diagram plus padding. Leave at least `80px` padding around all
77
+ visible content.
78
+
79
+ Example:
80
+
81
+ ```json
82
+ { "type": "cameraUpdate", "x": 20, "y": 0, "width": 1200, "height": 900 }
83
+ ```
84
+
85
+ If a title, footer, or rightmost label is clipped, the camera is wrong even if the elements are valid.
86
+
87
+ ## Good Pattern
88
+
89
+ ```json
90
+ [
91
+ { "type": "cameraUpdate", "x": 20, "y": 0, "width": 1200, "height": 900 },
92
+ {
93
+ "type": "rectangle",
94
+ "id": "a",
95
+ "x": 120,
96
+ "y": 160,
97
+ "width": 260,
98
+ "height": 90,
99
+ "backgroundColor": "#a5d8ff",
100
+ "fillStyle": "solid",
101
+ "label": { "text": "Short Label", "fontSize": 18 }
102
+ },
103
+ {
104
+ "type": "rectangle",
105
+ "id": "b",
106
+ "x": 520,
107
+ "y": 160,
108
+ "width": 280,
109
+ "height": 90,
110
+ "backgroundColor": "#b2f2bb",
111
+ "fillStyle": "solid",
112
+ "label": { "text": "Next Step", "fontSize": 18 }
113
+ },
114
+ {
115
+ "type": "arrow",
116
+ "id": "a-to-b",
117
+ "x": 390,
118
+ "y": 205,
119
+ "width": 110,
120
+ "height": 0,
121
+ "points": [[0, 0], [110, 0]],
122
+ "endArrowhead": "arrow",
123
+ "label": { "text": "then", "fontSize": 14 }
124
+ },
125
+ {
126
+ "type": "text",
127
+ "id": "note",
128
+ "x": 120,
129
+ "y": 290,
130
+ "text": "Longer explanation goes here, outside the shape.",
131
+ "fontSize": 16
132
+ }
133
+ ]
134
+ ```
135
+
136
+ ## Preflight Checklist
137
+
138
+ - Shape text uses `label`, not separate `text` elements.
139
+ - Shape labels are short enough to fit.
140
+ - Long explanations are outside shapes.
141
+ - The first element is a 4:3 `cameraUpdate`.
142
+ - Camera has at least `80px` padding around all visible content.
143
+ - Titles and footers are not near the camera edge.
144
+ - Arrows have explicit `points` and enough space for labels.
145
+ - Calls go through PMX (`canvas_add_diagram` or `external-app add --kind excalidraw`) unless you manually apply these rules to raw Excalidraw MCP input.
package/src/cli/agent.ts CHANGED
@@ -1698,6 +1698,7 @@ cmd('snapshot save', 'Save a named snapshot of the current canvas', [
1698
1698
  cmd('snapshot list', 'List saved snapshots', [
1699
1699
  'pmx-canvas snapshot list',
1700
1700
  'pmx-canvas snapshot list --limit 50 --query baseline',
1701
+ 'pmx-canvas snapshot list --after 2026-05-01T00:00:00Z --before 2026-05-05T00:00:00Z',
1701
1702
  'pmx-canvas snapshot list --all',
1702
1703
  ], async (args) => {
1703
1704
  const { flags } = parseFlags(args);
@@ -1706,8 +1707,12 @@ cmd('snapshot list', 'List saved snapshots', [
1706
1707
  const params = new URLSearchParams();
1707
1708
  const limit = optionalNumberFlag(flags, 'limit', 'Use a positive integer, e.g. --limit 50');
1708
1709
  const query = getStringFlag(flags, 'query', 'q');
1710
+ const before = getStringFlag(flags, 'before');
1711
+ const after = getStringFlag(flags, 'after');
1709
1712
  if (limit !== undefined) params.set('limit', String(limit));
1710
1713
  if (query) params.set('q', query);
1714
+ if (before) params.set('before', before);
1715
+ if (after) params.set('after', after);
1711
1716
  if (flags.all) params.set('all', 'true');
1712
1717
  const result = await api('GET', `/api/canvas/snapshots${params.size > 0 ? `?${params.toString()}` : ''}`);
1713
1718
  output(result);
@@ -2328,8 +2333,20 @@ function showCommandHelp(name: string): void {
2328
2333
  console.log('\nOptions:');
2329
2334
  console.log(' --limit <number> Maximum snapshots to return (default 20)');
2330
2335
  console.log(' --query <text> Case-insensitive ID/name filter');
2336
+ console.log(' --before <timestamp> Only return snapshots created at or before this ISO timestamp');
2337
+ console.log(' --after <timestamp> Only return snapshots created at or after this ISO timestamp');
2331
2338
  console.log(' --all Return all snapshots');
2332
2339
  }
2340
+ if (name === 'node update') {
2341
+ console.log('\nTrace fields:');
2342
+ console.log(' --tool-name, --toolName Trace tool or operation label');
2343
+ console.log(' --category <name> Trace category, e.g. mcp, file, subagent, other');
2344
+ console.log(' --status <status> Trace status, e.g. running, success, failed');
2345
+ console.log(' --duration <text> Trace duration badge text');
2346
+ console.log(' --result-summary, --resultSummary <text>');
2347
+ console.log(' Trace result summary');
2348
+ console.log(' --error <text> Trace error message');
2349
+ }
2333
2350
  if (name === 'snapshot gc') {
2334
2351
  console.log('\nOptions:');
2335
2352
  console.log(' --keep <number> Number of newest snapshots to keep (default 20)');
@@ -559,6 +559,8 @@ class RemoteCanvasAccess implements CanvasAccess {
559
559
  const params = new URLSearchParams();
560
560
  if (typeof options?.limit === 'number') params.set('limit', String(options.limit));
561
561
  if (options?.query) params.set('q', options.query);
562
+ if (options?.before) params.set('before', options.before);
563
+ if (options?.after) params.set('after', options.after);
562
564
  if (options?.all) params.set('all', 'true');
563
565
  const query = params.size > 0 ? `?${params.toString()}` : '';
564
566
  return await this.requestJson<SnapshotList>('GET', `/api/canvas/snapshots${query}`);
package/src/mcp/server.ts CHANGED
@@ -29,7 +29,13 @@ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './ca
29
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
30
  import { wrapCanvasAutomationScript } from '../server/server.js';
31
31
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
32
- import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode, summarizeCanvasAnnotationForContext } from '../server/canvas-serialization.js';
32
+ import {
33
+ getCanvasNodeTitle,
34
+ serializeCanvasLayoutForAgent,
35
+ serializeCanvasNode,
36
+ serializeCanvasNodeForAgent,
37
+ summarizeCanvasAnnotationForContext,
38
+ } from '../server/canvas-serialization.js';
33
39
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
34
40
 
35
41
  let canvas: CanvasAccess | null = null;
@@ -207,7 +213,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
207
213
 
208
214
  function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
209
215
  return {
210
- ...serializeCanvasLayout(layout),
216
+ ...serializeCanvasLayoutForAgent(layout),
211
217
  annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
212
218
  };
213
219
  }
@@ -240,7 +246,7 @@ async function createdNodePayload(c: CanvasAccess, id: string, options: { full?:
240
246
  if (!wantsFullPayload(options)) {
241
247
  return { ok: true, node: compactNodePayload(node), id };
242
248
  }
243
- const serialized = serializeCanvasNode(node);
249
+ const serialized = serializeCanvasNodeForAgent(node);
244
250
  return { ok: true, node: serialized, ...serialized };
245
251
  }
246
252
 
@@ -324,7 +330,7 @@ export async function startMcpServer(): Promise<void> {
324
330
  isError: true,
325
331
  };
326
332
  }
327
- const payload = wantsFullPayload(input) ? serializeCanvasNode(node) : compactNodePayload(node);
333
+ const payload = wantsFullPayload(input) ? serializeCanvasNodeForAgent(node) : compactNodePayload(node);
328
334
  return {
329
335
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
330
336
  };
@@ -1753,6 +1759,8 @@ export async function startMcpServer(): Promise<void> {
1753
1759
  {
1754
1760
  limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
1755
1761
  query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
1762
+ before: z.string().optional().describe('Only return snapshots created at or before this ISO timestamp'),
1763
+ after: z.string().optional().describe('Only return snapshots created at or after this ISO timestamp'),
1756
1764
  all: z.boolean().optional().describe('Return all snapshots instead of the default limit'),
1757
1765
  },
1758
1766
  async (input) => {
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { canvasState } from './canvas-state.js';
2
3
  import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
3
4
  import {
@@ -47,6 +48,13 @@ interface BlobSummary {
47
48
  sha256: string;
48
49
  }
49
50
 
51
+ interface ExternalMcpAppHtmlSummary {
52
+ omitted: 'external-mcp-app-html';
53
+ resourceUri: string;
54
+ bytes: number;
55
+ sha256: string;
56
+ }
57
+
50
58
  function pickString(value: unknown): string | null {
51
59
  return typeof value === 'string' && value.length > 0 ? value : null;
52
60
  }
@@ -90,6 +98,39 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
90
98
  };
91
99
  }
92
100
 
101
+ function summarizeExternalMcpAppHtml(node: SerializedCanvasNode): Record<string, unknown> {
102
+ const html = node.data.html;
103
+ const resourceUri = node.data.resourceUri;
104
+ if (
105
+ node.type !== 'mcp-app' ||
106
+ node.data.mode !== 'ext-app' ||
107
+ typeof html !== 'string' ||
108
+ html.length === 0 ||
109
+ typeof resourceUri !== 'string' ||
110
+ resourceUri.length === 0
111
+ ) {
112
+ return node.data;
113
+ }
114
+
115
+ return {
116
+ ...node.data,
117
+ html: {
118
+ omitted: 'external-mcp-app-html',
119
+ resourceUri,
120
+ bytes: Buffer.byteLength(html, 'utf-8'),
121
+ sha256: createHash('sha256').update(html).digest('hex'),
122
+ } satisfies ExternalMcpAppHtmlSummary,
123
+ };
124
+ }
125
+
126
+ export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode {
127
+ const serialized = serializeCanvasNode(node);
128
+ return {
129
+ ...serialized,
130
+ data: summarizeExternalMcpAppHtml(serialized),
131
+ };
132
+ }
133
+
93
134
  function summarizeBlobValue(value: unknown): unknown {
94
135
  if (!canvasState.isBlobReference(value)) return value;
95
136
  return {
@@ -117,6 +158,13 @@ export function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLay
117
158
  };
118
159
  }
119
160
 
161
+ export function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout {
162
+ return {
163
+ ...layout,
164
+ nodes: layout.nodes.map(serializeCanvasNodeForAgent),
165
+ };
166
+ }
167
+
120
168
  export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout {
121
169
  return {
122
170
  ...layout,
@@ -32,6 +32,12 @@ function normalizePositiveInteger(value: number | undefined): number | undefined
32
32
  return Math.floor(value);
33
33
  }
34
34
 
35
+ function normalizeSnapshotTimestamp(value: string | undefined): string | undefined {
36
+ if (!value) return undefined;
37
+ const parsed = Date.parse(value);
38
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
39
+ }
40
+
35
41
  export const PMX_CANVAS_DIR = '.pmx-canvas';
36
42
  const STATE_FILENAME = 'state.json';
37
43
  const SNAPSHOTS_SUBDIR = 'snapshots';
@@ -89,6 +95,8 @@ export interface CanvasSnapshot {
89
95
  export interface CanvasSnapshotListOptions {
90
96
  limit?: number;
91
97
  query?: string;
98
+ before?: string;
99
+ after?: string;
92
100
  all?: boolean;
93
101
  }
94
102
 
@@ -856,11 +864,16 @@ class CanvasStateManager {
856
864
  }
857
865
  }
858
866
  const query = options.query?.trim().toLowerCase();
859
- const filtered = query
860
- ? snapshots.filter((snapshot) =>
861
- snapshot.id.toLowerCase().includes(query) || snapshot.name.toLowerCase().includes(query),
862
- )
863
- : snapshots;
867
+ const before = normalizeSnapshotTimestamp(options.before);
868
+ const after = normalizeSnapshotTimestamp(options.after);
869
+ const filtered = snapshots.filter((snapshot) => {
870
+ if (query && !snapshot.id.toLowerCase().includes(query) && !snapshot.name.toLowerCase().includes(query)) {
871
+ return false;
872
+ }
873
+ if (before && snapshot.createdAt > before) return false;
874
+ if (after && snapshot.createdAt < after) return false;
875
+ return true;
876
+ });
864
877
  const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
865
878
  const limit = options.all ? undefined : (normalizePositiveInteger(options.limit) ?? 20);
866
879
  return limit === undefined ? sorted : sorted.slice(0, limit);
@@ -4145,6 +4145,8 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4145
4145
  return responseJson(listCanvasSnapshots({
4146
4146
  limit: parsePositiveIntegerParam(url.searchParams.get('limit')),
4147
4147
  query: url.searchParams.get('q') ?? url.searchParams.get('query') ?? undefined,
4148
+ before: url.searchParams.get('before') ?? undefined,
4149
+ after: url.searchParams.get('after') ?? undefined,
4148
4150
  all: url.searchParams.get('all') === 'true',
4149
4151
  }));
4150
4152
  }