pmx-canvas 0.4.5 → 0.4.6

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 (65) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/Readme.md +21 -4
  3. package/dist/canvas/global.css +68 -0
  4. package/dist/canvas/index.js +91 -70
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/smoke.d.ts +1 -0
  9. package/dist/types/client/mermaid-entry.d.ts +11 -0
  10. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  11. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  12. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  13. package/dist/types/client/types.d.ts +2 -2
  14. package/dist/types/server/canvas-provenance.d.ts +1 -1
  15. package/dist/types/server/canvas-state.d.ts +10 -1
  16. package/dist/types/server/canvas-validation.d.ts +34 -1
  17. package/dist/types/server/html-surface.d.ts +8 -0
  18. package/dist/types/server/index.d.ts +5 -0
  19. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  20. package/dist/types/server/workboard.d.ts +46 -0
  21. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  22. package/docs/cli.md +12 -0
  23. package/docs/http-api.md +12 -0
  24. package/docs/mcp.md +1 -1
  25. package/docs/node-types.md +2 -0
  26. package/docs/screenshot.png +0 -0
  27. package/package.json +4 -3
  28. package/skills/pmx-canvas/SKILL.md +56 -19
  29. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  30. package/src/cli/agent.ts +2 -0
  31. package/src/cli/commands/ax.ts +28 -3
  32. package/src/cli/commands/smoke.ts +212 -0
  33. package/src/cli/index.ts +1 -0
  34. package/src/cli/shared.ts +1 -0
  35. package/src/client/canvas/CanvasViewport.tsx +6 -0
  36. package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
  37. package/src/client/canvas/Minimap.tsx +2 -0
  38. package/src/client/canvas/auto-fit.ts +2 -0
  39. package/src/client/icons.tsx +4 -0
  40. package/src/client/mermaid-entry.ts +76 -0
  41. package/src/client/nodes/DiffNode.tsx +114 -0
  42. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  43. package/src/client/nodes/MermaidNode.tsx +81 -0
  44. package/src/client/state/node-factory.ts +2 -0
  45. package/src/client/state/sse-bridge.ts +28 -0
  46. package/src/client/theme/global.css +68 -0
  47. package/src/client/types.ts +6 -0
  48. package/src/mcp/server.ts +19 -1
  49. package/src/server/ax-interaction.ts +6 -0
  50. package/src/server/canvas-operations.ts +39 -9
  51. package/src/server/canvas-provenance.ts +2 -0
  52. package/src/server/canvas-schema.ts +33 -0
  53. package/src/server/canvas-state.ts +26 -0
  54. package/src/server/canvas-validation.ts +79 -0
  55. package/src/server/html-surface.ts +11 -0
  56. package/src/server/index.ts +25 -6
  57. package/src/server/operations/composites.ts +3 -2
  58. package/src/server/operations/ops/batch.ts +8 -0
  59. package/src/server/operations/ops/json-render.ts +44 -0
  60. package/src/server/operations/ops/nodes.ts +9 -1
  61. package/src/server/operations/ops/viewport.ts +8 -1
  62. package/src/server/server.ts +48 -1
  63. package/src/server/workboard.ts +159 -0
  64. package/src/shared/ax-surface-protocol.ts +8 -0
  65. package/src/shared/surface.ts +2 -0
@@ -55,6 +55,8 @@ export const NODE_TYPES = [
55
55
  'ledger',
56
56
  'trace',
57
57
  'file',
58
+ 'diff',
59
+ 'mermaid',
58
60
  'image',
59
61
  'mcp-app',
60
62
  'webpage',
@@ -74,6 +76,10 @@ export function defaultNodeSize(type: string): { width: number; height: number }
74
76
  // a useful snippet — the generic default rendered as a content-less bar
75
77
  // (0.4.4 portal feedback).
76
78
  return { width: 520, height: 360 };
79
+ case 'diff':
80
+ return { width: 640, height: 420 };
81
+ case 'mermaid':
82
+ return { width: 640, height: 460 };
77
83
  case 'markdown':
78
84
  return MARKDOWN_NODE_DEFAULT_SIZE;
79
85
  case 'mcp-app':
@@ -783,7 +789,7 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
783
789
  mcp: {
784
790
  toolName: 'canvas_add_node',
785
791
  description:
786
- 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
792
+ 'Add a basic node to the canvas. Returns the created node with normalized title/content and rendered geometry. Supported here: markdown, status, context, ledger, trace, file, diff, mermaid, image, webpage, mcp-app, html, group. Dedicated routes: json-render -> canvas_render {action:"add-json-render"}, graph -> canvas_render {action:"add-graph"}, web-artifact -> canvas_app {action:"build-artifact"}, external apps -> canvas_app {action:"open-mcp-app"}, groups -> canvas_group {action:"create"}. Call canvas_render {action:"describe-schema"} for the full nodeTypeRouting table.',
787
793
  extraShape: {
788
794
  type: z
789
795
  .enum([
@@ -793,6 +799,8 @@ const nodeAddOperation = defineOperation<z.infer<typeof nodeAddSchema>, NodeAddR
793
799
  'ledger',
794
800
  'trace',
795
801
  'file',
802
+ 'diff',
803
+ 'mermaid',
796
804
  'image',
797
805
  'webpage',
798
806
  'mcp-app',
@@ -121,7 +121,14 @@ const focusOperation = defineOperation<z.infer<typeof focusSchema>, Record<strin
121
121
  if (!node) throw new OperationError(`Node "${nodeId}" not found.`, 404);
122
122
  const noPan = body.noPan === true;
123
123
  if (!noPan) {
124
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
124
+ // The viewport is a SCREEN-space translate: screen = world * scale + v
125
+ // (CanvasViewport's matrix(scale,0,0,scale,x,y)), the same convention
126
+ // fitCanvasView and the client's focusNode use. So park the node's
127
+ // top-left at a fixed screen-space margin — 64/96 screen px, which
128
+ // clears the 44px floating toolbar at ANY zoom (0.4.5 report Finding Z:
129
+ // the old world-space margin shrank below the toolbar at low scales).
130
+ const scale = canvasState.viewport.scale || 1;
131
+ canvasState.setViewport({ x: 64 - node.position.x * scale, y: 96 - node.position.y * scale });
125
132
  } else {
126
133
  const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
127
134
  canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
@@ -47,6 +47,7 @@ import {
47
47
  buildAxStateBridge,
48
48
  buildContentHeightReporter,
49
49
  buildHtmlSurfaceDocument,
50
+ buildMermaidSurfaceHtml,
50
51
  HTML_SURFACE_SANDBOX,
51
52
  normalizeSurfaceTheme,
52
53
  } from './html-surface.js';
@@ -1004,6 +1005,7 @@ function canvasSpaHtml(): string {
1004
1005
  // (see src/client/state/iframe-mode.ts).
1005
1006
  process.env.AMP_ORB ? '<script>window.__PMX_AMP_ORB = true;</script>' : ''
1006
1007
  }
1008
+ <script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
1007
1009
  <script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
1008
1010
  </body>
1009
1011
  </html>`;
@@ -1271,6 +1273,28 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1271
1273
 
1272
1274
  const theme = normalizeSurfaceTheme(url.searchParams.get('theme'));
1273
1275
 
1276
+ if (node.type === 'mermaid') {
1277
+ const source = typeof node.data.content === 'string' ? node.data.content : '';
1278
+ if (!source.trim()) return responseText('Mermaid node has no content', 404);
1279
+ const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id;
1280
+ // Display-only surface: same theme/height plumbing as html nodes, but no AX
1281
+ // bridge and no presentation mode — the document is our own wrapper around
1282
+ // escaped diagram source, rendered by /canvas/mermaid-entry.js.
1283
+ const doc = buildHtmlSurfaceDocument(buildMermaidSurfaceHtml(source), {
1284
+ theme,
1285
+ title: surfaceTitle,
1286
+ themeToken: url.searchParams.get('themeToken') ?? undefined,
1287
+ // Content-height reporter nonce (lets the node grow to fit the diagram).
1288
+ ...(url.searchParams.get('frameToken')
1289
+ ? { contentHeightToken: url.searchParams.get('frameToken') as string }
1290
+ : {}),
1291
+ // Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
1292
+ // are styled without depending on the <link> subresource.
1293
+ ...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
1294
+ });
1295
+ return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1296
+ }
1297
+
1274
1298
  if (node.type === 'html') {
1275
1299
  const html =
1276
1300
  typeof node.data.html === 'string'
@@ -1449,6 +1473,23 @@ function responseJson(data: unknown, status = 200): Response {
1449
1473
  });
1450
1474
  }
1451
1475
 
1476
+ // Resolved from the sibling package.json so it stays accurate through bunx,
1477
+ // global npm installs, and repo-local runs — same mechanism as --version in
1478
+ // src/cli/index.ts and the MCP server. Memoized: /health is polled.
1479
+ let cachedPackageVersion: string | null = null;
1480
+ function serverPackageVersion(): string {
1481
+ if (cachedPackageVersion !== null) return cachedPackageVersion;
1482
+ try {
1483
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8')) as {
1484
+ version?: string;
1485
+ };
1486
+ cachedPackageVersion = pkg.version ?? 'unknown';
1487
+ } catch {
1488
+ cachedPackageVersion = 'unknown';
1489
+ }
1490
+ return cachedPackageVersion;
1491
+ }
1492
+
1452
1493
  // v0.4.0 (M4): every hand-written error response is a JSON envelope
1453
1494
  // ({ ok: false, error }, same status codes) matching the registry dispatcher —
1454
1495
  // consumers no longer need to branch on text/plain vs JSON error bodies.
@@ -1799,6 +1840,9 @@ function buildWorkbenchConnectSnapshot(
1799
1840
  continuity,
1800
1841
  path: primaryWorkbenchPath,
1801
1842
  theme: canvasState.theme,
1843
+ // Lets a long-lived client detect that the server was upgraded under it
1844
+ // (stale in-memory SPA — 0.4.5 report Finding W) and reload once.
1845
+ version: serverPackageVersion(),
1802
1846
  timestamp: new Date().toISOString(),
1803
1847
  },
1804
1848
  });
@@ -3038,11 +3082,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3038
3082
  // when the pid file is stale (e.g. an adapter respawned the server
3039
3083
  // outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
3040
3084
  // surfaces silent save failures (M4): the canvas keeps serving from
3041
- // memory, but ok:false means durability is degraded.
3085
+ // memory, but ok:false means durability is degraded. `version` lets
3086
+ // `pmx-canvas smoke` verify the RUNNING server, not just the CLI —
3087
+ // version skew between the two is a known failure mode.
3042
3088
  return responseJson({
3043
3089
  ok: true,
3044
3090
  workspace: activeWorkspaceRoot,
3045
3091
  pid: process.pid,
3092
+ version: serverPackageVersion(),
3046
3093
  persistence: canvasState.persistenceHealth,
3047
3094
  });
3048
3095
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Live AX work-item board, materialized as a json-render node.
3
+ *
4
+ * `buildWorkboardSpec` is the pure spec builder (status columns of work-item
5
+ * cards); `renderWorkboard` is the operation core (create-or-rebuild the
6
+ * single `data.workboard === true` json-render node); `refreshWorkboardNodes`
7
+ * is the live-refresh hook this module registers on `canvasState` so every
8
+ * addWorkItem/updateWorkItem rebuilds the board from the fresh work-item list
9
+ * through the SAME spec/update path the operation uses.
10
+ *
11
+ * The refresh runs inside `withSuppressedRecording` so live board rebuilds do
12
+ * not spam undo/redo history (persistence and SSE emits are unaffected).
13
+ */
14
+ import type { JsonRenderSpec } from '../json-render/server.js';
15
+ import type { PmxAxWorkItem, PmxAxWorkItemStatus } from './ax-state.js';
16
+ import { buildJsonRenderNodeUpdate, createCanvasJsonRenderNode, emitCanvasLayoutUpdate } from './canvas-operations.js';
17
+ import { canvasState, type CanvasNodeState } from './canvas-state.js';
18
+
19
+ export const WORKBOARD_NODE_TITLE = 'Work Board';
20
+ export const WORKBOARD_NODE_SIZE = { width: 960, height: 560 };
21
+
22
+ const WORKBOARD_STATUS_ORDER: PmxAxWorkItemStatus[] = ['todo', 'in-progress', 'blocked', 'done', 'cancelled'];
23
+
24
+ const WORKBOARD_STATUS_LABELS: Record<PmxAxWorkItemStatus, string> = {
25
+ todo: 'To Do',
26
+ 'in-progress': 'In Progress',
27
+ blocked: 'Blocked',
28
+ done: 'Done',
29
+ cancelled: 'Cancelled',
30
+ };
31
+
32
+ /**
33
+ * Build the workboard json-render spec from a work-item list: one column per
34
+ * status (todo → in-progress → blocked → done → cancelled; empty statuses are
35
+ * omitted), each item a Card with an agentId Badge and detail Text when
36
+ * present. An empty list renders a single muted "No work items" text block.
37
+ */
38
+ export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
39
+ if (workItems.length === 0) {
40
+ return {
41
+ root: 'empty',
42
+ elements: {
43
+ empty: { type: 'Text', props: { text: 'No work items', variant: 'muted' }, children: [] },
44
+ },
45
+ };
46
+ }
47
+
48
+ const elements: Record<string, unknown> = {};
49
+ const columnIds: string[] = [];
50
+ for (const status of WORKBOARD_STATUS_ORDER) {
51
+ const items = workItems.filter((item) => item.status === status);
52
+ if (items.length === 0) continue;
53
+ const columnId = `col-${status}`;
54
+ const headerId = `${columnId}-header`;
55
+ elements[headerId] = {
56
+ type: 'Heading',
57
+ props: { level: 'h4', text: `${WORKBOARD_STATUS_LABELS[status]} (${items.length})` },
58
+ children: [],
59
+ };
60
+ const columnChildren = [headerId];
61
+ for (const item of items) {
62
+ const cardId = `item-${item.id}`;
63
+ const cardChildren: string[] = [];
64
+ if (item.agentId) {
65
+ elements[`${cardId}-agent`] = {
66
+ type: 'Badge',
67
+ props: { text: item.agentId, variant: 'secondary' },
68
+ children: [],
69
+ };
70
+ cardChildren.push(`${cardId}-agent`);
71
+ }
72
+ if (item.detail) {
73
+ elements[`${cardId}-detail`] = {
74
+ type: 'Text',
75
+ props: { text: item.detail, variant: 'muted' },
76
+ children: [],
77
+ };
78
+ cardChildren.push(`${cardId}-detail`);
79
+ }
80
+ elements[cardId] = { type: 'Card', props: { title: item.title }, children: cardChildren };
81
+ columnChildren.push(cardId);
82
+ }
83
+ elements[columnId] = {
84
+ type: 'Stack',
85
+ props: { direction: 'vertical', gap: 'sm' },
86
+ children: columnChildren,
87
+ };
88
+ columnIds.push(columnId);
89
+ }
90
+ elements.board = {
91
+ type: 'Stack',
92
+ props: { direction: 'horizontal', gap: 'md', align: 'start' },
93
+ children: columnIds,
94
+ };
95
+ return { root: 'board', elements };
96
+ }
97
+
98
+ function findWorkboardNodes(): CanvasNodeState[] {
99
+ return canvasState.getLayout().nodes.filter((node) => node.type === 'json-render' && node.data.workboard === true);
100
+ }
101
+
102
+ /** Rebuild one workboard node's spec in place (shared by the op and the live refresh). */
103
+ function applyWorkboardSpec(node: CanvasNodeState, spec: JsonRenderSpec): void {
104
+ const title = typeof node.data.title === 'string' && node.data.title ? node.data.title : WORKBOARD_NODE_TITLE;
105
+ const update = buildJsonRenderNodeUpdate(node, { title, spec });
106
+ canvasState.updateNode(node.id, { data: update.data });
107
+ }
108
+
109
+ /**
110
+ * Operation core for `render.workboard`: rebuild the existing workboard node
111
+ * in place, or create one (tagged `data.workboard: true`) when none exists.
112
+ */
113
+ export function renderWorkboard(input: { x?: number; y?: number } = {}): {
114
+ ok: true;
115
+ id: string;
116
+ created: boolean;
117
+ itemCount: number;
118
+ } {
119
+ const workItems = canvasState.getWorkItems();
120
+ const spec = buildWorkboardSpec(workItems);
121
+ const existing = findWorkboardNodes()[0];
122
+ if (existing) {
123
+ applyWorkboardSpec(existing, spec);
124
+ return { ok: true, id: existing.id, created: false, itemCount: workItems.length };
125
+ }
126
+ const created = createCanvasJsonRenderNode({
127
+ title: WORKBOARD_NODE_TITLE,
128
+ spec,
129
+ width: WORKBOARD_NODE_SIZE.width,
130
+ height: WORKBOARD_NODE_SIZE.height,
131
+ ...(input.x !== undefined ? { x: input.x } : {}),
132
+ ...(input.y !== undefined ? { y: input.y } : {}),
133
+ });
134
+ // Tag the node as THE workboard. Suppressed: the tag is part of the single
135
+ // logical "create workboard" mutation, not a second undo step.
136
+ canvasState.withSuppressedRecording(() => {
137
+ canvasState.updateNode(created.id, { data: { ...created.node.data, workboard: true } });
138
+ });
139
+ return { ok: true, id: created.id, created: true, itemCount: workItems.length };
140
+ }
141
+
142
+ /**
143
+ * Live refresh: rebuild every workboard node's spec from the current work-item
144
+ * list. Registered below as the canvasState work-item change listener. Node
145
+ * updates never touch work items, so this cannot recurse.
146
+ */
147
+ export function refreshWorkboardNodes(): void {
148
+ const nodes = findWorkboardNodes();
149
+ if (nodes.length === 0) return;
150
+ const spec = buildWorkboardSpec(canvasState.getWorkItems());
151
+ canvasState.withSuppressedRecording(() => {
152
+ for (const node of nodes) {
153
+ applyWorkboardSpec(node, spec);
154
+ }
155
+ });
156
+ emitCanvasLayoutUpdate();
157
+ }
158
+
159
+ canvasState.setWorkItemsChangedListener(refreshWorkboardNodes);
@@ -20,3 +20,11 @@ export const HTML_SURFACE_PUSH_SOURCE = 'pmx-canvas-html-node';
20
20
 
21
21
  /** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
22
22
  export const EXT_APP_BOOT_BEACON_SOURCE = 'pmx-canvas-ext-app-alive';
23
+
24
+ /**
25
+ * Parent→ext-app paint probe (Finding N): the parent asks the app document to
26
+ * answer with a double-rAF `paint-tick` beacon. A frame whose rendering
27
+ * pipeline is live answers within a frame or two; silence past the timeout is
28
+ * the paint-fail signal that drives the recovery ladder.
29
+ */
30
+ export const EXT_APP_PAINT_PROBE_SOURCE = 'pmx-canvas-ext-app-paint-probe';
@@ -17,6 +17,8 @@ export function canOpenNodeAsSurface(type: string, data: Record<string, unknown>
17
17
  (typeof data.html === 'string' && data.html.length > 0) ||
18
18
  (typeof data.content === 'string' && data.content.length > 0)
19
19
  );
20
+ case 'mermaid':
21
+ return typeof data.content === 'string' && data.content.trim().length > 0;
20
22
  case 'json-render':
21
23
  case 'graph':
22
24
  return true;