pmx-canvas 0.1.16 → 0.1.18

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 (41) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/Readme.md +14 -7
  3. package/dist/canvas/global.css +25 -0
  4. package/dist/canvas/index.js +72 -72
  5. package/dist/types/client/canvas/AnnotationLayer.d.ts +4 -0
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -1
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/dist/types/client/icons.d.ts +4 -0
  9. package/dist/types/client/state/canvas-store.d.ts +16 -1
  10. package/dist/types/client/types.d.ts +20 -0
  11. package/dist/types/mcp/canvas-access.d.ts +1 -0
  12. package/dist/types/server/canvas-serialization.d.ts +25 -1
  13. package/dist/types/server/canvas-state.d.ts +27 -1
  14. package/dist/types/server/index.d.ts +7 -2
  15. package/dist/types/server/mutation-history.d.ts +1 -1
  16. package/dist/types/server/spatial-analysis.d.ts +11 -2
  17. package/package.json +1 -1
  18. package/skills/pmx-canvas/SKILL.md +19 -0
  19. package/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +145 -0
  20. package/src/cli/agent.ts +6 -0
  21. package/src/client/App.tsx +60 -3
  22. package/src/client/canvas/AnnotationLayer.tsx +28 -0
  23. package/src/client/canvas/CanvasViewport.tsx +169 -10
  24. package/src/client/canvas/ContextPinBar.tsx +2 -1
  25. package/src/client/canvas/use-pan-zoom.ts +10 -5
  26. package/src/client/icons.tsx +22 -0
  27. package/src/client/state/canvas-store.ts +52 -2
  28. package/src/client/state/sse-bridge.ts +35 -1
  29. package/src/client/theme/global.css +25 -0
  30. package/src/client/types.ts +17 -0
  31. package/src/mcp/canvas-access.ts +10 -0
  32. package/src/mcp/server.ts +43 -6
  33. package/src/server/canvas-schema.ts +25 -0
  34. package/src/server/canvas-serialization.ts +117 -1
  35. package/src/server/canvas-state.ts +74 -2
  36. package/src/server/diagram-presets.ts +54 -19
  37. package/src/server/index.ts +20 -3
  38. package/src/server/mutation-history.ts +2 -0
  39. package/src/server/server.ts +77 -2
  40. package/src/server/spatial-analysis.ts +46 -1
  41. package/src/shared/semantic-attention.ts +4 -2
@@ -0,0 +1,4 @@
1
+ import type { CanvasAnnotation } from '../types';
2
+ export declare function AnnotationLayer({ annotations }: {
3
+ annotations: CanvasAnnotation[];
4
+ }): import("preact/src").JSX.Element | null;
@@ -1,8 +1,11 @@
1
1
  import type { CanvasNodeState } from '../types';
2
+ type AnnotationTool = 'pen' | 'eraser' | null;
2
3
  interface CanvasViewportProps {
3
4
  onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
4
5
  onCanvasContextMenu?: (e: MouseEvent, canvasX: number, canvasY: number) => void;
6
+ annotationMode?: boolean;
7
+ annotationTool?: AnnotationTool;
5
8
  }
6
9
  export declare function getRenderableWorldNodes(allNodes: Iterable<CanvasNodeState>, focusedNodeId: string | null): CanvasNodeState[];
7
- export declare function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: CanvasViewportProps): import("preact/src").JSX.Element;
10
+ export declare function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotationMode, annotationTool }: CanvasViewportProps): import("preact/src").JSX.Element;
8
11
  export {};
@@ -4,6 +4,7 @@ interface PanZoomOptions {
4
4
  viewport: Signal<ViewportState>;
5
5
  onViewportChange: (v: ViewportState) => void;
6
6
  onViewportCommit: (v: ViewportState) => void;
7
+ disabled?: boolean;
7
8
  }
8
9
  /**
9
10
  * Hook that wires up pan/zoom interactions on a container element.
@@ -12,5 +13,5 @@ interface PanZoomOptions {
12
13
  * - Pointer drag on background: pan
13
14
  * - Pinch (touch): zoom
14
15
  */
15
- export declare function usePanZoom({ viewport, onViewportChange, onViewportCommit }: PanZoomOptions): import("preact/src").RefObject<HTMLDivElement>;
16
+ export declare function usePanZoom({ viewport, onViewportChange, onViewportCommit, disabled }: PanZoomOptions): import("preact/src").RefObject<HTMLDivElement>;
16
17
  export {};
@@ -19,6 +19,10 @@ export declare function IconMinimap(p: IconProps): JSX.Element;
19
19
  export declare function IconSun(p: IconProps): JSX.Element;
20
20
  /** Crescent moon */
21
21
  export declare function IconMoon(p: IconProps): JSX.Element;
22
+ /** Pen stroke — canvas annotation mode */
23
+ export declare function IconPen(p: IconProps): JSX.Element;
24
+ /** Eraser — remove canvas annotations */
25
+ export declare function IconEraser(p: IconProps): JSX.Element;
22
26
  /** Camera — snapshots */
23
27
  export declare function IconSnapshot(p: IconProps): JSX.Element;
24
28
  /** Bullseye — trace toggle */
@@ -1,7 +1,8 @@
1
- import { type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
1
+ import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
2
2
  export declare const viewport: import("@preact/signals-core").Signal<ViewportState>;
3
3
  export declare const nodes: import("@preact/signals-core").Signal<Map<string, CanvasNodeState>>;
4
4
  export declare const edges: import("@preact/signals-core").Signal<Map<string, CanvasEdge>>;
5
+ export declare const annotations: import("@preact/signals-core").Signal<Map<string, CanvasAnnotation>>;
5
6
  export declare const activeNodeId: import("@preact/signals-core").Signal<string | null>;
6
7
  export declare const connectionStatus: import("@preact/signals-core").Signal<ConnectionStatus>;
7
8
  export declare const sessionId: import("@preact/signals-core").Signal<string>;
@@ -41,6 +42,19 @@ export declare function removeNode(id: string): void;
41
42
  export declare function addEdge(edge: CanvasEdge): void;
42
43
  export declare function removeEdge(id: string): void;
43
44
  export declare function removeEdgesForNode(nodeId: string): void;
45
+ export declare function addAnnotation(annotation: CanvasAnnotation): void;
46
+ export declare function removeAnnotation(id: string): void;
47
+ export declare function createAnnotationFromClient(input: {
48
+ points: CanvasAnnotation['points'];
49
+ color: string;
50
+ width: number;
51
+ label?: string;
52
+ }): Promise<{
53
+ ok: boolean;
54
+ }>;
55
+ export declare function removeAnnotationFromClient(id: string): Promise<{
56
+ ok: boolean;
57
+ }>;
44
58
  export declare function resizeNode(id: string, size: {
45
59
  width: number;
46
60
  height: number;
@@ -56,6 +70,7 @@ export declare function replaceViewport(next: ViewportState): void;
56
70
  export declare function commitViewport(next: ViewportState): void;
57
71
  export declare function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'edges'> & {
58
72
  viewport?: ViewportState;
73
+ annotations?: CanvasAnnotation[];
59
74
  }, options?: {
60
75
  applyViewport?: boolean;
61
76
  }): void;
@@ -29,6 +29,25 @@ export interface CanvasEdge {
29
29
  style?: 'solid' | 'dashed' | 'dotted';
30
30
  animated?: boolean;
31
31
  }
32
+ export interface CanvasAnnotationPoint {
33
+ x: number;
34
+ y: number;
35
+ }
36
+ export interface CanvasAnnotation {
37
+ id: string;
38
+ type: 'freehand';
39
+ points: CanvasAnnotationPoint[];
40
+ bounds: {
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ };
46
+ color: string;
47
+ width: number;
48
+ label?: string;
49
+ createdAt: string;
50
+ }
32
51
  export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
33
52
  export declare const TYPE_LABELS: Record<CanvasNodeState['type'], string>;
34
53
  /** Node types that support the full-viewport expand/focus overlay. */
@@ -40,4 +59,5 @@ export interface CanvasLayout {
40
59
  viewport: ViewportState;
41
60
  nodes: CanvasNodeState[];
42
61
  edges: CanvasEdge[];
62
+ annotations?: CanvasAnnotation[];
43
63
  }
@@ -54,6 +54,7 @@ export interface CanvasAccess {
54
54
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
55
55
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
56
56
  removeNode(id: string): Promise<void>;
57
+ removeAnnotation(id: string): Promise<boolean>;
57
58
  addEdge(input: AddEdgeInput): Promise<string>;
58
59
  removeEdge(id: string): Promise<void>;
59
60
  createGroup(input: CreateGroupInput): Promise<string>;
@@ -1,4 +1,4 @@
1
- import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
1
+ import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
2
2
  import { type CanvasNodeProvenance } from './canvas-provenance.js';
3
3
  export interface SerializedCanvasNode extends CanvasNodeState {
4
4
  kind: string;
@@ -11,16 +11,40 @@ export interface SerializedCanvasNode extends CanvasNodeState {
11
11
  export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
12
12
  nodes: SerializedCanvasNode[];
13
13
  }
14
+ export interface CanvasAnnotationSummary {
15
+ id: string;
16
+ type: CanvasAnnotation['type'];
17
+ bounds: CanvasAnnotation['bounds'];
18
+ color: string;
19
+ width: number;
20
+ pointCount: number;
21
+ label: string | null;
22
+ createdAt: string;
23
+ }
24
+ export interface CanvasAnnotationContextSummary {
25
+ id: string;
26
+ label: string | null;
27
+ bounds: CanvasAnnotation['bounds'];
28
+ targetNodeIds: string[];
29
+ targetNodeTitles: string[];
30
+ target: string;
31
+ }
14
32
  export declare function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string;
15
33
  export declare function getCanvasNodeTitle(node: CanvasNodeState): string | null;
16
34
  export declare function getCanvasNodeContent(node: CanvasNodeState): string | null;
17
35
  export declare function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode;
36
+ export declare function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode;
18
37
  export declare function serializeCanvasNodeWithBlobSummaries(node: CanvasNodeState): SerializedCanvasNode;
19
38
  export declare function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLayout;
39
+ export declare function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout;
20
40
  export declare function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout;
41
+ export declare function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary;
42
+ export declare function summarizeCanvasAnnotationForContext(annotation: CanvasAnnotation, nodes: CanvasNodeState[]): CanvasAnnotationContextSummary;
21
43
  export interface CanvasSummary {
22
44
  totalNodes: number;
23
45
  totalEdges: number;
46
+ totalAnnotations: number;
47
+ annotations: CanvasAnnotationContextSummary[];
24
48
  nodesByType: Record<string, number>;
25
49
  pinnedCount: number;
26
50
  pinnedTitles: string[];
@@ -22,6 +22,7 @@ interface PersistedCanvasState {
22
22
  viewport: ViewportState;
23
23
  nodes: CanvasNodeState[];
24
24
  edges: CanvasEdge[];
25
+ annotations?: CanvasAnnotation[];
25
26
  contextPins: string[];
26
27
  }
27
28
  interface LoadFromDiskOptions {
@@ -81,10 +82,30 @@ export interface CanvasEdge {
81
82
  style?: 'solid' | 'dashed' | 'dotted';
82
83
  animated?: boolean;
83
84
  }
85
+ export interface CanvasAnnotationPoint {
86
+ x: number;
87
+ y: number;
88
+ }
89
+ export interface CanvasAnnotation {
90
+ id: string;
91
+ type: 'freehand';
92
+ points: CanvasAnnotationPoint[];
93
+ bounds: {
94
+ x: number;
95
+ y: number;
96
+ width: number;
97
+ height: number;
98
+ };
99
+ color: string;
100
+ width: number;
101
+ label?: string;
102
+ createdAt: string;
103
+ }
84
104
  export interface CanvasLayout {
85
105
  viewport: ViewportState;
86
106
  nodes: CanvasNodeState[];
87
107
  edges: CanvasEdge[];
108
+ annotations: CanvasAnnotation[];
88
109
  }
89
110
  export interface CanvasNodeUpdate {
90
111
  id: string;
@@ -101,7 +122,7 @@ export interface CanvasNodeUpdate {
101
122
  }
102
123
  export type CanvasChangeType = 'pins' | 'nodes';
103
124
  export interface MutationRecordInfo {
104
- operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
125
+ operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
105
126
  description: string;
106
127
  forward: () => void;
107
128
  inverse: () => void;
@@ -114,6 +135,7 @@ interface GroupNodesOptions {
114
135
  declare class CanvasStateManager {
115
136
  private nodes;
116
137
  private edges;
138
+ private annotations;
117
139
  private _viewport;
118
140
  private _contextPinnedNodeIds;
119
141
  private _workspaceRoot;
@@ -185,6 +207,7 @@ declare class CanvasStateManager {
185
207
  name: string;
186
208
  nodes: CanvasNodeState[];
187
209
  edges: CanvasEdge[];
210
+ annotations: CanvasAnnotation[];
188
211
  } | null;
189
212
  /** Delete a snapshot. */
190
213
  deleteSnapshot(id: string): boolean;
@@ -200,6 +223,9 @@ declare class CanvasStateManager {
200
223
  removeEdge(id: string): boolean;
201
224
  getEdges(): CanvasEdge[];
202
225
  getEdgesForNode(nodeId: string): CanvasEdge[];
226
+ addAnnotation(annotation: CanvasAnnotation): void;
227
+ removeAnnotation(id: string): boolean;
228
+ getAnnotations(): CanvasAnnotation[];
203
229
  private removeEdgesForNode;
204
230
  getLayout(): CanvasLayout;
205
231
  getLayoutForPersistence(): CanvasLayout;
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import type { CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
2
+ import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
3
3
  import { searchNodes } from './spatial-analysis.js';
4
4
  import { diffLayouts } from './mutation-history.js';
5
5
  import { fitCanvasView, gcCanvasSnapshots, listCanvasSnapshots } from './canvas-operations.js';
@@ -69,6 +69,11 @@ export declare class PmxCanvas extends EventEmitter {
69
69
  style?: CanvasEdge['style'];
70
70
  animated?: boolean;
71
71
  }): string;
72
+ addAnnotation(input: Omit<CanvasAnnotation, 'id' | 'createdAt'> & {
73
+ id?: string;
74
+ createdAt?: string;
75
+ }): string;
76
+ removeAnnotation(id: string): boolean;
72
77
  removeEdge(id: string): void;
73
78
  /**
74
79
  * Create a group node and optionally add child nodes to it.
@@ -263,7 +268,7 @@ export type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from '.
263
268
  export type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus, PrimaryWorkbenchCanvasPromptRequest, PrimaryWorkbenchIntent, } from './server.js';
264
269
  export { emitPrimaryWorkbenchEvent, consumePrimaryWorkbenchIntents, setPrimaryWorkbenchAutoOpenEnabled, setPrimaryWorkbenchCanvasPromptHandler, startCanvasServer, stopCanvasServer, getCanvasServerPort, openUrlInExternalBrowser, getCanvasAutomationWebViewStatus, startCanvasAutomationWebView, stopCanvasAutomationWebView, evaluateCanvasAutomationWebView, resizeCanvasAutomationWebView, screenshotCanvasAutomationWebView, } from './server.js';
265
270
  export { canvasState } from './canvas-state.js';
266
- export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
271
+ export type { CanvasAnnotation, CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
267
272
  export { findOpenCanvasPosition } from './placement.js';
268
273
  export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
269
274
  export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
@@ -12,7 +12,7 @@
12
12
  * - _replaying flag prevents undo/redo from recording new entries
13
13
  */
14
14
  import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
15
- export type MutationOp = 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'clear' | 'arrange' | 'restoreSnapshot' | 'setPins' | 'batch' | 'viewport' | 'groupNodes' | 'ungroupNodes';
15
+ export type MutationOp = 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'arrange' | 'restoreSnapshot' | 'setPins' | 'batch' | 'viewport' | 'groupNodes' | 'ungroupNodes';
16
16
  export interface MutationEntry {
17
17
  id: string;
18
18
  timestamp: string;
@@ -9,7 +9,7 @@
9
9
  * actually real for agents. Instead of raw x/y coordinates, agents get
10
10
  * semantic clusters, ordered context, and implicit human intent.
11
11
  */
12
- import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
12
+ import type { CanvasAnnotation, CanvasNodeState, CanvasEdge } from './canvas-state.js';
13
13
  export interface SpatialCluster {
14
14
  /** Auto-generated cluster ID */
15
15
  id: string;
@@ -45,6 +45,14 @@ export interface NodeSpatialInfo {
45
45
  /** Reading order index (top-left to bottom-right) */
46
46
  readingOrder: number;
47
47
  }
48
+ export interface SpatialAnnotationContext {
49
+ id: string;
50
+ label: string | null;
51
+ bounds: CanvasAnnotation['bounds'];
52
+ targetNodeIds: string[];
53
+ targetNodeTitles: string[];
54
+ target: string;
55
+ }
48
56
  export interface SpatialContext {
49
57
  /** Total nodes on canvas */
50
58
  totalNodes: number;
@@ -58,6 +66,7 @@ export interface SpatialContext {
58
66
  pinnedNodeTitle: string | null;
59
67
  neighbors: SpatialNeighbor[];
60
68
  }[];
69
+ annotations: SpatialAnnotationContext[];
61
70
  }
62
71
  /**
63
72
  * Detect proximity clusters using single-linkage clustering.
@@ -84,4 +93,4 @@ export declare function searchNodes(nodes: CanvasNodeState[], query: string): {
84
93
  /**
85
94
  * Build the complete spatial context for the canvas.
86
95
  */
87
- export declare function buildSpatialContext(nodes: CanvasNodeState[], _edges: CanvasEdge[], pinnedIds: Set<string>): SpatialContext;
96
+ export declare function buildSpatialContext(nodes: CanvasNodeState[], _edges: CanvasEdge[], pinnedIds: Set<string>, annotations?: CanvasAnnotation[]): SpatialContext;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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",
@@ -302,6 +302,10 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
302
302
  - `id` (required): node to remove
303
303
  - Clean up nodes that are no longer relevant
304
304
 
305
+ **`canvas_remove_annotation`** — Remove a human-drawn annotation
306
+ - `id` (required): annotation to remove
307
+ - Use when context gives you the annotation ID; use WebView first if you need to identify a mark by shape or location.
308
+
305
309
  **`canvas_get_node`** — Get a single node's full data
306
310
  - `id` (required): node to retrieve
307
311
 
@@ -564,6 +568,11 @@ tools below operate on the live canvas state.
564
568
  Useful workbench selectors:
565
569
  - Nodes: `.canvas-node`, `.canvas-node.active`, `.canvas-node.context-pinned`, `.canvas-node.group-node`
566
570
  - Node internals: `.node-title`, `.node-titlebar`, `.node-body`, `.node-type-badge`, `.node-controls`
571
+ - Annotations: `.annotation-layer path` renders human-drawn freehand ink. Use WebView
572
+ to inspect or screenshot annotation shapes; MCP/context resources only expose compact
573
+ annotation target summaries, not the raw visual shape. Humans can remove marks with
574
+ the eraser toolbar button; agents can remove a known annotation ID with
575
+ `canvas_remove_annotation`.
567
576
  - Canvas chrome: `.hud-layer`, `.canvas-toolbar`, `.connection-dot`, `.canvas-bootstrap-card`
568
577
  - Nodes do not expose stable `data-node-id` attributes. Use `canvas_get_layout`, `canvas_search`, or MCP resource data for exact node IDs.
569
578
 
@@ -610,6 +619,11 @@ canvas_webview_stop();
610
619
  geometric `graph` node would feel too rigid
611
620
  - Prefer labeled shapes (`"label": { "text": "..." }` on rectangle/ellipse/diamond) over
612
621
  separate text elements — fewer tokens and auto-centered
622
+ - Do not use separate `text` elements with `containerId`/`boundElements` to place centered text
623
+ inside shapes. The hosted SVG preview does not auto-position those; PMX normalizes imported
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.
613
627
  - Prefer the pastel fill palette in the Excalidraw `read_me` (light blue/green/orange/...) for
614
628
  a consistent look across diagrams
615
629
 
@@ -691,6 +705,11 @@ The `canvas://spatial-context` resource reveals how the human has organized info
691
705
  This implies sequence or priority.
692
706
  - **Pinned neighborhoods** — For each pinned node, nearby unpinned nodes are listed. These
693
707
  are the human's implicit context — things they consider related to what they pinned.
708
+ - **Annotations** — Human-drawn markup is summarized by target/bounds only, e.g. an
709
+ annotation over a node or empty canvas region. Use WebView (`canvas_webview_start` +
710
+ `canvas_evaluate`/`canvas_screenshot`) when you need to see whether the mark is an
711
+ arrow, line, circle, or other drawn shape. Remove known annotations with
712
+ `canvas_remove_annotation`; otherwise use WebView to identify the mark first.
694
713
  - **Board density matters** — On a dense board, spatial context can still read like one large
695
714
  gallery unless groups and spacing separate the major regions clearly.
696
715
 
@@ -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
@@ -1027,6 +1027,7 @@ cmd('node add', 'Add a node to the canvas', [
1027
1027
  'pmx-canvas node add --type status --title "Build" --content "passing"',
1028
1028
  'pmx-canvas node add --type file --content "src/index.ts"',
1029
1029
  'pmx-canvas node add --type webpage --url "https://example.com/docs"',
1030
+ 'pmx-canvas node add --type html --title "Widget" --content "<main>Hello</main>"',
1030
1031
  'pmx-canvas node add --type markdown --title "Note" --x 100 --y 200',
1031
1032
  'pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json',
1032
1033
  'pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
@@ -1069,6 +1070,9 @@ cmd('node add', 'Add a node to the canvas', [
1069
1070
  body.url = webpageUrl;
1070
1071
  } else if (type === 'image' && imagePath && !flags.content) {
1071
1072
  body.content = imagePath;
1073
+ } else if (type === 'html') {
1074
+ const html = getStringFlag(flags, 'html') ?? getStringFlag(flags, 'content');
1075
+ if (html !== undefined) body.html = html;
1072
1076
  } else if (flags.content) {
1073
1077
  body.content = flags.content;
1074
1078
  }
@@ -1090,6 +1094,8 @@ cmd('node add', 'Add a node to the canvas', [
1090
1094
  if (flags.stdin) {
1091
1095
  if (type === 'webpage') {
1092
1096
  body.url = await readStdin();
1097
+ } else if (type === 'html') {
1098
+ body.html = await readStdin();
1093
1099
  } else {
1094
1100
  body.content = await readStdin();
1095
1101
  }