pmx-canvas 0.1.27 → 0.1.29

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.
@@ -67,6 +67,16 @@ export declare function useChartFrameHeight(explicitHeight: number | null | unde
67
67
  height: number;
68
68
  width: number;
69
69
  };
70
+ /**
71
+ * Height available for the plotted SVG inside `.pmx-chart`, i.e. the measured
72
+ * frame height minus the non-plot chrome: the `.pmx-chart__title` block (~24px
73
+ * of text + margin, only when a title is shown) plus the chart's own vertical
74
+ * padding. Sizing the SVG to this — instead of the full frame height — keeps a
75
+ * filled chart's title+plot within the frame so it doesn't push a scrollbar onto
76
+ * the single iframe-document scroller. Dense charts still exceed it and scroll
77
+ * (one scrollbar, as expected).
78
+ */
79
+ export declare function chartPlotHeight(height: number, hasTitle: boolean): number;
70
80
  /** Shared wrapper for cartesian charts (Line + Bar). */
71
81
  export declare function CartesianChart({ props, children, className, }: {
72
82
  props: CartesianChartProps;
@@ -18,6 +18,16 @@ export declare const pmxCanvasDirectives: DirectiveDefinition[];
18
18
  * `$cond`/`$template`/`$computed`). These objects are resolved inside the
19
19
  * renderer, so the server-side validators must leave them untouched instead of
20
20
  * string-coercing them to `"[object Object]"` or rejecting them as the wrong
21
- * primitive type.
21
+ * primitive type. An object whose only `$`-key is unrecognized (e.g. `$path`)
22
+ * is NOT dynamic — the renderer has no directive to resolve it.
22
23
  */
23
24
  export declare function isDynamicPropValue(value: unknown): boolean;
25
+ /**
26
+ * Returns the offending key when a prop value looks like a dynamic expression
27
+ * (it has `$`-prefixed keys) but none of them is a recognized binding or
28
+ * registered directive — e.g. `{ "$path": "…" }`. Such objects pass a naive
29
+ * "has a $-key" check yet render as `"[object Object]"` because the renderer has
30
+ * no directive to resolve them. Returns null for plain values and for genuinely
31
+ * dynamic expressions.
32
+ */
33
+ export declare function findUnknownDirectiveKey(value: unknown): string | null;
@@ -64,6 +64,14 @@ export declare const MCP_APP_NODE_DEFAULT_SIZE: {
64
64
  width: number;
65
65
  height: number;
66
66
  };
67
+ export declare const IMAGE_NODE_DEFAULT_SIZE: {
68
+ width: number;
69
+ height: number;
70
+ };
71
+ export declare const LEDGER_NODE_DEFAULT_SIZE: {
72
+ width: number;
73
+ height: number;
74
+ };
67
75
  interface CanvasCreateGroupInput {
68
76
  title?: string;
69
77
  childIds?: string[];
@@ -21,8 +21,19 @@ export declare class PmxCanvas extends EventEmitter {
21
21
  start(options?: {
22
22
  open?: boolean;
23
23
  automationWebView?: boolean | CanvasAutomationWebViewOptions;
24
+ /**
25
+ * Bind a nearby free port when the preferred one is taken instead of
26
+ * failing. Default false (an explicit SDK port is honored exactly); the
27
+ * MCP auto-start opts in so a daemon already on the port can't crash it.
28
+ */
29
+ allowPortFallback?: boolean;
24
30
  }): Promise<void>;
25
31
  stop(): void;
32
+ /**
33
+ * Add a node to the canvas and return the created node (including its `id`,
34
+ * resolved geometry, and data). Destructure `const { id } = canvas.addNode(...)`
35
+ * or keep the whole node — both work. (Previously returned a bare id string.)
36
+ */
26
37
  addNode(input: {
27
38
  type: CanvasNodeState['type'];
28
39
  title?: string;
@@ -42,7 +53,7 @@ export declare class PmxCanvas extends EventEmitter {
42
53
  width?: number;
43
54
  height?: number;
44
55
  strictSize?: boolean;
45
- }): string;
56
+ }): CanvasNodeState;
46
57
  addWebpageNode(input: {
47
58
  title?: string;
48
59
  url: string;
package/docs/sdk.md CHANGED
@@ -15,16 +15,16 @@ import { createCanvas } from 'pmx-canvas';
15
15
  const canvas = createCanvas({ port: 4313 });
16
16
  await canvas.start({ open: true });
17
17
 
18
- // Add nodes
18
+ // Add nodes — addNode returns the created node (with `.id`, geometry, and data)
19
19
  const n1 = canvas.addNode({ type: 'markdown', title: 'Plan', content: '# Step 1\nDo the thing.' });
20
20
  const n2 = canvas.addNode({ type: 'status', title: 'Build', content: 'passing' });
21
21
  const n3 = canvas.addNode({ type: 'file', content: 'src/index.ts' });
22
22
 
23
- // Connect them
24
- canvas.addEdge({ from: n1, to: n2, type: 'flow' });
23
+ // Connect them (edges and groups reference node ids)
24
+ canvas.addEdge({ from: n1.id, to: n2.id, type: 'flow' });
25
25
 
26
26
  // Group related nodes
27
- canvas.createGroup({ title: 'Build Pipeline', childIds: [n1, n2] });
27
+ canvas.createGroup({ title: 'Build Pipeline', childIds: [n1.id, n3.id] });
28
28
 
29
29
  // Self-contained HTML in a sandboxed iframe
30
30
  canvas.addHtmlNode({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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",
package/src/cli/agent.ts CHANGED
@@ -805,7 +805,28 @@ async function buildWebArtifactRequestBody(
805
805
  }
806
806
 
807
807
  async function runWebArtifactBuildCommand(flags: Record<string, string | true>): Promise<void> {
808
- const result = await api('POST', '/api/canvas/web-artifact', await buildWebArtifactRequestBody(flags), { allowErrorJson: true });
808
+ const body = await buildWebArtifactRequestBody(flags);
809
+ // The build (init + dependency install + bundle) runs server-side and only
810
+ // returns a single HTTP response on completion, which can take minutes on a
811
+ // cold workspace. With no output an agent's tool wait expires before the node
812
+ // appears and the build looks hung. Emit a default-on heartbeat to stderr
813
+ // while the request is in flight — stdout (output) and the JSON response body
814
+ // stay untouched, so anything parsing stdout is unaffected.
815
+ const startedMs = Date.now();
816
+ process.stderr.write(
817
+ `[web-artifact] building "${String(body.title)}" — init + install + bundle (this can take a few minutes)...\n`,
818
+ );
819
+ const heartbeat = setInterval(() => {
820
+ const elapsedSeconds = Math.round((Date.now() - startedMs) / 1000);
821
+ process.stderr.write(`[web-artifact] still building... ${elapsedSeconds}s elapsed\n`);
822
+ }, 10_000);
823
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
824
+ let result: unknown;
825
+ try {
826
+ result = await api('POST', '/api/canvas/web-artifact', body, { allowErrorJson: true });
827
+ } finally {
828
+ clearInterval(heartbeat);
829
+ }
809
830
  output(result);
810
831
  if (isRecord(result) && result.ok === false) {
811
832
  process.exit(1);
@@ -1061,11 +1082,24 @@ const RESOURCE_COMMAND_ALIASES: Record<string, Record<string, string>> = {
1061
1082
  delete: 'remove',
1062
1083
  rm: 'remove',
1063
1084
  },
1085
+ ax: {
1086
+ // Single-subcommand AX groups: the bare verb maps to its only action so
1087
+ // `ax event` / `ax evidence` suggest the full command instead of erroring.
1088
+ event: 'event add',
1089
+ evidence: 'evidence add',
1090
+ },
1064
1091
  };
1065
1092
  const RESOURCE_SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
1066
1093
  node: {
1067
1094
  pin: 'Use the top-level pin command instead: pmx-canvas pin <node-id>',
1068
1095
  },
1096
+ ax: {
1097
+ // Multi-subcommand AX groups: point at the available actions.
1098
+ host: 'Pick an action: pmx-canvas ax host report | pmx-canvas ax host status',
1099
+ work: 'Pick an action: pmx-canvas ax work add | update | list',
1100
+ approval: 'Pick an action: pmx-canvas ax approval request | resolve | list',
1101
+ review: 'Pick an action: pmx-canvas ax review add | list',
1102
+ },
1069
1103
  };
1070
1104
 
1071
1105
  function cmd(
@@ -221,6 +221,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
221
221
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
222
222
  let hostContextResizeObserver: ResizeObserver | null = null;
223
223
  let hostContextRaf: number | null = null;
224
+ let readyNudgeRaf: number | null = null;
224
225
  toolResultSentRef.current = false;
225
226
  lastSentToolResultRef.current = undefined;
226
227
  toolResultSendingRef.current = null;
@@ -266,6 +267,24 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
266
267
  });
267
268
  };
268
269
 
270
+ // Re-deliver host context once the iframe has been laid out and painted.
271
+ // Canvas-backed widgets (e.g. Excalidraw) size their drawing surface from
272
+ // containerDimensions at first render; the single handshake-time delivery
273
+ // can land before the embedded frame has settled, leaving a black canvas
274
+ // until an expand/collapse forces a reflow. A double rAF lands after
275
+ // layout+paint, and sendHostContextChange always delivers (setHostContext
276
+ // would diff-suppress the identical context just sent at handshake).
277
+ const nudgeHostContextAfterLayout = () => {
278
+ if (readyNudgeRaf !== null) return;
279
+ readyNudgeRaf = requestAnimationFrame(() => {
280
+ readyNudgeRaf = requestAnimationFrame(() => {
281
+ readyNudgeRaf = null;
282
+ if (disposed || !bridgeReadyRef.current) return;
283
+ void bridge.sendHostContextChange?.(buildHostContext());
284
+ });
285
+ });
286
+ };
287
+
269
288
  const bridge = new AppBridge(
270
289
  null,
271
290
  { name: 'PMX Canvas', version: '1.0.0' },
@@ -404,6 +423,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
404
423
  void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
405
424
  .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
406
425
  .then(() => flushToolResult(bridge))
426
+ .then(() => nudgeHostContextAfterLayout())
407
427
  .catch((err) => {
408
428
  const msg = err instanceof Error ? err.message : String(err);
409
429
  setError(`Bridge bootstrap failed: ${msg}`);
@@ -428,6 +448,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
428
448
  }
429
449
  setStatus(bootstrapToolResult ? 'done' : 'ready');
430
450
  setError(null);
451
+ nudgeHostContextAfterLayout();
431
452
  })
432
453
  .catch((err) => {
433
454
  const msg = err instanceof Error ? err.message : String(err);
@@ -478,6 +499,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
478
499
  cancelAnimationFrame(hostContextRaf);
479
500
  hostContextRaf = null;
480
501
  }
502
+ if (readyNudgeRaf !== null) {
503
+ cancelAnimationFrame(readyNudgeRaf);
504
+ readyNudgeRaf = null;
505
+ }
481
506
  bridgeReadyRef.current = false;
482
507
  toolResultSendingRef.current = null;
483
508
  themeUnsubRef.current?.();
@@ -1,12 +1,25 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
 
3
+ // Layout/internal metadata that lives in node.data but is not ledger content.
4
+ const HIDDEN_LEDGER_KEYS = new Set(['title', '__type', 'content', 'strictSize', 'arrangeLocked']);
5
+
3
6
  export function LedgerNode({ node }: { node: CanvasNodeState }) {
4
7
  const data = node.data as Record<string, unknown>;
5
8
 
6
- // Render key-value pairs from ledger summary
7
- const entries = Object.entries(data).filter(([key]) => key !== 'title' && key !== '__type');
9
+ // Body text renders as a log: one line per entry. CLI flags frequently deliver
10
+ // a literal "\n" (backslash-n, the shell does not expand it inside quotes)
11
+ // rather than a real newline, so split on both — plus CR/CRLF — instead of
12
+ // dropping the whole string on one wrapped line.
13
+ const rawContent = typeof data.content === 'string' ? data.content : '';
14
+ const lines = rawContent
15
+ .split(/\r\n|\r|\n|\\n/)
16
+ .map((line) => line.trimEnd())
17
+ .filter((line) => line.length > 0);
18
+
19
+ // Any remaining non-internal keys render as structured key/value rows.
20
+ const entries = Object.entries(data).filter(([key]) => !HIDDEN_LEDGER_KEYS.has(key));
8
21
 
9
- if (entries.length === 0) {
22
+ if (lines.length === 0 && entries.length === 0) {
10
23
  return (
11
24
  <div style={{ color: 'var(--c-dim)', fontSize: '12px', fontStyle: 'italic' }}>No ledger data</div>
12
25
  );
@@ -14,20 +27,41 @@ export function LedgerNode({ node }: { node: CanvasNodeState }) {
14
27
 
15
28
  return (
16
29
  <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' }}>
30
+ {lines.length > 0 && (
31
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
32
+ {lines.map((line, i) => (
33
+ <div
34
+ key={i}
35
+ style={{
36
+ padding: '3px 0',
37
+ borderBottom: i < lines.length - 1 ? '1px solid rgba(45,55,90,0.3)' : 'none',
38
+ color: 'var(--c-text)',
39
+ fontFamily: 'var(--mono)',
40
+ fontSize: '11px',
41
+ whiteSpace: 'pre-wrap',
42
+ wordBreak: 'break-word',
43
+ }}
44
+ >
45
+ {line}
46
+ </div>
47
+ ))}
48
+ </div>
49
+ )}
17
50
  {entries.map(([key, value]) => (
18
51
  <div
19
52
  key={key}
20
53
  style={{
21
54
  display: 'flex',
22
55
  justifyContent: 'space-between',
56
+ gap: '8px',
23
57
  padding: '3px 0',
24
58
  borderBottom: '1px solid rgba(45,55,90,0.3)',
25
59
  }}
26
60
  >
27
- <span style={{ color: 'var(--c-muted)', fontSize: '11px' }}>
61
+ <span style={{ color: 'var(--c-muted)', fontSize: '11px', flexShrink: 0 }}>
28
62
  {key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
29
63
  </span>
30
- <span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px' }}>
64
+ <span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px', textAlign: 'right', wordBreak: 'break-word' }}>
31
65
  {typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
32
66
  </span>
33
67
  </div>
@@ -124,12 +124,24 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
124
124
 
125
125
  const updateHeight = () => {
126
126
  const rect = frame.getBoundingClientRect();
127
- const doc = document.documentElement;
128
- const currentHeight = frame.getBoundingClientRect().height;
129
- const overflow = Math.max(0, doc.scrollHeight - doc.clientHeight);
130
- const available = overflow > 0 ? currentHeight - overflow : window.innerHeight - rect.top - 24;
131
- setAutoHeight(Math.max(220, Math.round(available)));
132
- setAutoWidth(Math.round(rect.width));
127
+ // Available height runs from the frame's top to the bottom of the iframe
128
+ // viewport. It is deliberately NOT derived from the document's own scroll
129
+ // overflow: feeding the chart's own overflow back into its height creates a
130
+ // shrink -> no-overflow -> grow -> overflow feedback loop that repaints
131
+ // forever (the reported Tufte-chart flicker). When natural content exceeds
132
+ // the viewport the document simply scrolls (with a stable gutter, see
133
+ // index.css) instead of the height oscillating.
134
+ // Reserve ~44px below the frame for the chrome that sits under the chart
135
+ // inside the json-render card (card padding/margin ≈ 41px, measured stable
136
+ // across node sizes). rect.top already accounts for everything above. With
137
+ // too small a reserve a filled chart spills ~17px past the viewport and the
138
+ // iframe document shows a needless scrollbar.
139
+ const available = Math.max(220, Math.round(window.innerHeight - rect.top - 44));
140
+ const nextWidth = Math.round(rect.width);
141
+ // Dead-band: ignore sub-threshold churn so a stray re-measure (e.g. a
142
+ // scrollbar toggling) can't ping-pong state and repaint.
143
+ setAutoHeight((prev) => (Math.abs(available - prev) > 2 ? available : prev));
144
+ setAutoWidth((prev) => (Math.abs(nextWidth - prev) > 2 ? nextWidth : prev));
133
145
  };
134
146
 
135
147
  updateHeight();
@@ -150,6 +162,19 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
150
162
  };
151
163
  }
152
164
 
165
+ /**
166
+ * Height available for the plotted SVG inside `.pmx-chart`, i.e. the measured
167
+ * frame height minus the non-plot chrome: the `.pmx-chart__title` block (~24px
168
+ * of text + margin, only when a title is shown) plus the chart's own vertical
169
+ * padding. Sizing the SVG to this — instead of the full frame height — keeps a
170
+ * filled chart's title+plot within the frame so it doesn't push a scrollbar onto
171
+ * the single iframe-document scroller. Dense charts still exceed it and scroll
172
+ * (one scrollbar, as expected).
173
+ */
174
+ export function chartPlotHeight(height: number, hasTitle: boolean): number {
175
+ return Math.max(60, height - (hasTitle ? 36 : 12));
176
+ }
177
+
153
178
  /** Shared wrapper for cartesian charts (Line + Bar). */
154
179
  export function CartesianChart({
155
180
  props,
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { BaseComponentProps } from '@json-render/react';
15
- import { CHART_COLORS, useChartFrameHeight } from './components';
15
+ import { CHART_COLORS, chartPlotHeight, useChartFrameHeight } from './components';
16
16
 
17
17
  const ACCENT = CHART_COLORS[0];
18
18
  const INK = 'var(--foreground, #111)';
@@ -155,7 +155,12 @@ function ChartDotPlot({ props }: BaseComponentProps<DotPlotProps>) {
155
155
  const labelW = 140;
156
156
  const valueW = 52;
157
157
  const padX = 12;
158
- const rowH = rows.length > 0 ? Math.min(36, (height - 12) / rows.length) : 24;
158
+ // Distribute rows across the available plot height with 36px as a MINIMUM (not
159
+ // a maximum): a sparse chart fills a tall expanded card instead of staying
160
+ // tile-sized and top-aligned with whitespace below; a dense chart keeps ≥36px
161
+ // rows and scrolls cleanly (height no longer oscillates — see useChartFrameHeight).
162
+ const plotH = chartPlotHeight(height, Boolean(props.title));
163
+ const rowH = rows.length > 0 ? Math.max(36, plotH / rows.length) : 24;
159
164
  const plotLeft = labelW + padX;
160
165
 
161
166
  return (
@@ -231,7 +236,11 @@ function ChartBulletChart({ props }: BaseComponentProps<BulletChartProps>) {
231
236
  const w = width || 480;
232
237
  const labelW = 120;
233
238
  const padX = 12;
234
- const rowH = rows.length > 0 ? Math.min(56, (height - 12) / rows.length) : 48;
239
+ // Fill the available plot height with 56px as a MINIMUM per row (the bar itself
240
+ // is capped at barH below, so extra row height just adds breathing room) so a
241
+ // sparse bullet chart fills a tall expanded card instead of leaving whitespace.
242
+ const plotH = chartPlotHeight(height, Boolean(props.title));
243
+ const rowH = rows.length > 0 ? Math.max(56, plotH / rows.length) : 48;
235
244
 
236
245
  return (
237
246
  <div ref={frameRef} className="pmx-chart pmx-chart--bullet" style={{ height }}>
@@ -328,12 +337,15 @@ function ChartSlopegraph({ props }: BaseComponentProps<SlopegraphProps>) {
328
337
  // measured pixels (fallback width until the ResizeObserver reports the size).
329
338
  const w = width || 480;
330
339
  const rightX = Math.max(leftX + 40, w - rightInset);
331
- const scaleY = (v: number) => topPad + (1 - (v - min) / (max - min)) * (height - topPad - botPad);
340
+ // Size the SVG to the plot height (frame minus title/padding) so the chart
341
+ // fills without pushing a scrollbar onto the iframe document.
342
+ const plotH = chartPlotHeight(height, Boolean(props.title));
343
+ const scaleY = (v: number) => topPad + (1 - (v - min) / (max - min)) * (plotH - topPad - botPad);
332
344
 
333
345
  return (
334
346
  <div ref={frameRef} className="pmx-chart pmx-chart--slopegraph" style={{ height }}>
335
347
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
336
- <svg className="pmx-chart__slopegraph-svg" width="100%" height={height} role="img" aria-label={props.title ?? 'slopegraph'}>
348
+ <svg className="pmx-chart__slopegraph-svg" width="100%" height={plotH} role="img" aria-label={props.title ?? 'slopegraph'}>
337
349
  <text x={leftX} y={14} textAnchor="end" fontSize={12} fontWeight={600} fill={MUTED}>
338
350
  {props.beforeLabel ?? props.beforeKey}
339
351
  </text>
@@ -15,15 +15,50 @@ import { standardDirectives } from '@json-render/directives';
15
15
  */
16
16
  export const pmxCanvasDirectives: DirectiveDefinition[] = [...standardDirectives];
17
17
 
18
+ /**
19
+ * The $-prefixed keys the renderer can actually resolve: core built-in bindings
20
+ * plus the names of the directives registered above. Anything else (e.g. a
21
+ * hallucinated `$path`) is NOT a valid dynamic expression — to read a value from
22
+ * state by path the correct binding is `$state`.
23
+ */
24
+ const KNOWN_DYNAMIC_KEYS = new Set<string>([
25
+ '$state',
26
+ '$item',
27
+ '$index',
28
+ '$bindState',
29
+ '$bindItem',
30
+ '$cond',
31
+ '$computed',
32
+ '$template',
33
+ ...pmxCanvasDirectives.map((directive) => directive.name),
34
+ ]);
35
+
18
36
  /**
19
37
  * True when a prop value is a render-time dynamic expression — a directive
20
38
  * (`$format`/`$math`/…) or an existing binding (`$state`/`$item`/`$bindItem`/
21
39
  * `$cond`/`$template`/`$computed`). These objects are resolved inside the
22
40
  * renderer, so the server-side validators must leave them untouched instead of
23
41
  * string-coercing them to `"[object Object]"` or rejecting them as the wrong
24
- * primitive type.
42
+ * primitive type. An object whose only `$`-key is unrecognized (e.g. `$path`)
43
+ * is NOT dynamic — the renderer has no directive to resolve it.
25
44
  */
26
45
  export function isDynamicPropValue(value: unknown): boolean {
27
46
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
28
- return Object.keys(value as Record<string, unknown>).some((key) => key.startsWith('$'));
47
+ return Object.keys(value as Record<string, unknown>).some((key) => KNOWN_DYNAMIC_KEYS.has(key));
48
+ }
49
+
50
+ /**
51
+ * Returns the offending key when a prop value looks like a dynamic expression
52
+ * (it has `$`-prefixed keys) but none of them is a recognized binding or
53
+ * registered directive — e.g. `{ "$path": "…" }`. Such objects pass a naive
54
+ * "has a $-key" check yet render as `"[object Object]"` because the renderer has
55
+ * no directive to resolve them. Returns null for plain values and for genuinely
56
+ * dynamic expressions.
57
+ */
58
+ export function findUnknownDirectiveKey(value: unknown): string | null {
59
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
60
+ const dollarKeys = Object.keys(value as Record<string, unknown>).filter((key) => key.startsWith('$'));
61
+ if (dollarKeys.length === 0) return null;
62
+ if (dollarKeys.some((key) => KNOWN_DYNAMIC_KEYS.has(key))) return null;
63
+ return dollarKeys[0] ?? null;
29
64
  }
@@ -268,7 +268,12 @@ button {
268
268
  .pmx-chart {
269
269
  width: 100%;
270
270
  min-width: 280px;
271
- overflow-x: auto;
271
+ /* overflow:visible (not overflow-x:auto) so the chart is NOT its own scroll
272
+ container. overflow-x:auto makes overflow-y compute to auto too (CSS quirk),
273
+ which — once a chart fills the viewport — added a second nested scrollbar on
274
+ top of the iframe document's. Let the single iframe document handle any
275
+ overflow instead. */
276
+ overflow: visible;
272
277
  padding: 0.5rem 0;
273
278
  }
274
279
 
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { buildAppHtml } from '@json-render/mcp/build-app-html';
5
5
  import { applySpecPatch, parseSpecStreamLine, type Spec, type SpecStreamLine } from '@json-render/core';
6
6
  import { allComponentDefinitions, catalog, validateShadcnElementProps, type JsonRenderIssue } from './catalog.js';
7
- import { isDynamicPropValue } from './directives.js';
7
+ import { findUnknownDirectiveKey, isDynamicPropValue } from './directives.js';
8
8
 
9
9
  export interface JsonRenderSpec {
10
10
  root: string;
@@ -512,6 +512,24 @@ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpe
512
512
  throw new Error('Missing root and elements in spec. Pass a complete {root, elements} document, or a single bare component object with a type field.');
513
513
  }
514
514
 
515
+ // Reject an unrecognized $-keyed expression object in any element prop BEFORE
516
+ // normalization can string-coerce it to "[object Object]" (the reported $path
517
+ // symptom). The error names the offending key and points at $state, the
518
+ // correct path-read binding.
519
+ const elementsRecord = asRecord(specRecord.elements) ?? {};
520
+ for (const [elementKey, rawElement] of Object.entries(elementsRecord)) {
521
+ const props = asRecord(asRecord(rawElement)?.props);
522
+ if (!props) continue;
523
+ for (const [propKey, propValue] of Object.entries(props)) {
524
+ const unknownKey = findUnknownDirectiveKey(propValue);
525
+ if (unknownKey) {
526
+ throw new Error(
527
+ `Unknown directive "${unknownKey}" on elements.${elementKey}.props.${propKey}. Valid directives are $format, $math, $concat, $count, $truncate, $pluralize, $join; to read a value from state by path use { "$state": "/path" } (there is no $path directive).`,
528
+ );
529
+ }
530
+ }
531
+ }
532
+
515
533
  const normalizedSpec = normalizeSpec(specRecord);
516
534
  const validation = catalog.validate(normalizedSpec);
517
535
  if (!validation.success || !validation.data) {
@@ -545,6 +563,20 @@ function collectDataKeys(data: Array<Record<string, unknown>>): Set<string> {
545
563
  return keys;
546
564
  }
547
565
 
566
+ /**
567
+ * Pick the first candidate key that actually exists in the data. Lets a chart
568
+ * accept a conventional synonym (e.g. a bullet chart's "actual" measure as well
569
+ * as "value") without an explicit valueKey, instead of failing the data-key
570
+ * check on a reasonable guess.
571
+ */
572
+ function firstPresentDataKey(
573
+ data: Array<Record<string, unknown>>,
574
+ candidates: string[],
575
+ ): string | undefined {
576
+ const keys = collectDataKeys(data);
577
+ return candidates.find((key) => keys.has(key));
578
+ }
579
+
548
580
  function assertGraphDataKeys(
549
581
  data: Array<Record<string, unknown>>,
550
582
  chartType: GraphChartType,
@@ -711,7 +743,10 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
711
743
  }
712
744
  case 'BulletChart': {
713
745
  chartProps.labelKey = input.labelKey ?? input.xKey ?? null;
714
- chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
746
+ // The measure column is conventionally "value", but bullet charts also
747
+ // call it "actual" — accept either when no valueKey is given.
748
+ chartProps.valueKey =
749
+ input.valueKey ?? input.yKey ?? firstPresentDataKey(input.data, ['value', 'actual']) ?? 'value';
715
750
  chartProps.targetKey = input.targetKey ?? null;
716
751
  chartProps.rangesKey = input.rangesKey ?? null;
717
752
  chartProps.color = input.color ?? null;
@@ -212,7 +212,9 @@ class LocalCanvasAccess implements CanvasAccess {
212
212
  }
213
213
 
214
214
  async addNode(input: AddNodeInput): Promise<string> {
215
- return this.canvas.addNode(input);
215
+ // PmxCanvas.addNode returns the created node; the CanvasAccess contract
216
+ // (shared with the remote proxy + MCP) stays id-only.
217
+ return this.canvas.addNode(input).id;
216
218
  }
217
219
 
218
220
  async addWebpageNode(input: AddWebpageNodeInput): Promise<Awaited<ReturnType<PmxCanvas['addWebpageNode']>>> {
@@ -1061,7 +1063,23 @@ export async function createCanvasAccess(): Promise<CanvasAccess> {
1061
1063
  const remoteBaseUrl = await findExistingCanvasServer(workspaceRoot, port);
1062
1064
  if (remoteBaseUrl) return new RemoteCanvasAccess(remoteBaseUrl);
1063
1065
 
1066
+ // No same-workspace server to attach to. Allow a port fallback so a daemon
1067
+ // already holding the preferred port (e.g. one serving a *different*
1068
+ // workspace) doesn't crash this MCP/SDK session with EADDRINUSE — start our
1069
+ // own canvas on a free port instead, and explain how to share one if intended.
1064
1070
  const canvas = createCanvas({ port });
1065
- await canvas.start({ open: true });
1071
+ await canvas.start({ open: true, allowPortFallback: true });
1072
+ const boundPort = canvas.port;
1073
+ if (boundPort !== port) {
1074
+ const occupant = await readHealth(`http://127.0.0.1:${port}`);
1075
+ const occupantWorkspace =
1076
+ typeof occupant?.workspace === 'string' ? ` (serving ${occupant.workspace})` : '';
1077
+ // stderr only — stdout is the MCP stdio JSON-RPC channel.
1078
+ process.stderr.write(
1079
+ `[pmx-canvas] preferred port ${port} was in use${occupantWorkspace}; ` +
1080
+ `started this canvas on port ${boundPort} instead. To share one canvas, run the daemon ` +
1081
+ `from this workspace or set PMX_CANVAS_URL / PMX_CANVAS_PORT to point at it.\n`,
1082
+ );
1083
+ }
1066
1084
  return new LocalCanvasAccess(canvas, workspaceRoot, port);
1067
1085
  }
package/src/mcp/server.ts CHANGED
@@ -263,13 +263,16 @@ function compactBatchResult(result: { ok: boolean; results: Array<Record<string,
263
263
  }
264
264
 
265
265
  async function createdNodePayload(c: CanvasAccess, id: string, options: { full?: boolean; verbose?: boolean; includeData?: boolean } = {}): Promise<Record<string, unknown>> {
266
+ // Expose both `id` and a `nodeId` alias on every node-create response so
267
+ // agents using either key (or a cached schema) work — matching the
268
+ // external-app / web-artifact responses that already return both.
266
269
  const node = await c.getNode(id);
267
- if (!node) return { ok: true, id };
270
+ if (!node) return { ok: true, id, nodeId: id };
268
271
  if (!wantsFullPayload(options)) {
269
- return { ok: true, node: compactNodePayload(node), id };
272
+ return { ok: true, node: compactNodePayload(node), id, nodeId: id };
270
273
  }
271
274
  const serialized = serializeCanvasNodeForAgent(node);
272
- return { ok: true, node: serialized, ...serialized };
275
+ return { ok: true, node: serialized, ...serialized, nodeId: node.id };
273
276
  }
274
277
 
275
278
  function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>, pinnedIds: string[]): Record<string, unknown> {
@@ -764,8 +767,8 @@ export async function startMcpServer(): Promise<void> {
764
767
  outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
765
768
  openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
766
769
  includeLogs: z.boolean().optional().describe('Include raw build stdout/stderr in the response (default false)'),
767
- initScriptPath: z.string().optional().describe('Optional absolute script path override for tests/debugging'),
768
- bundleScriptPath: z.string().optional().describe('Optional absolute script path override for tests/debugging'),
770
+ initScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
771
+ bundleScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
769
772
  timeoutMs: z.number().optional().describe('Optional timeout in milliseconds for init and bundle commands'),
770
773
  },
771
774
  async (input) => {
@@ -107,6 +107,12 @@ interface CanvasAddNodeInput {
107
107
 
108
108
  export const MARKDOWN_NODE_DEFAULT_SIZE = { width: 640, height: 420 };
109
109
  export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
110
+ // Image and ledger nodes previously fell through to the generic 360x200 frame,
111
+ // which clipped content (a 360-wide image / log stream is cramped). Give them
112
+ // roomier defaults; height still auto-fits to content (see auto-fit.ts), so the
113
+ // width bump is the reliable lever.
114
+ export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
115
+ export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
110
116
 
111
117
  interface CanvasCreateGroupInput {
112
118
  title?: string;
@@ -1771,14 +1777,22 @@ export async function executeCanvasBatch(
1771
1777
  ? MARKDOWN_NODE_DEFAULT_SIZE.width
1772
1778
  : type === 'mcp-app'
1773
1779
  ? MCP_APP_NODE_DEFAULT_SIZE.width
1774
- : 360,
1780
+ : type === 'image'
1781
+ ? IMAGE_NODE_DEFAULT_SIZE.width
1782
+ : type === 'ledger'
1783
+ ? LEDGER_NODE_DEFAULT_SIZE.width
1784
+ : 360,
1775
1785
  defaultHeight: type === 'html'
1776
1786
  ? 640
1777
1787
  : type === 'markdown'
1778
1788
  ? MARKDOWN_NODE_DEFAULT_SIZE.height
1779
1789
  : type === 'mcp-app'
1780
1790
  ? MCP_APP_NODE_DEFAULT_SIZE.height
1781
- : 200,
1791
+ : type === 'image'
1792
+ ? IMAGE_NODE_DEFAULT_SIZE.height
1793
+ : type === 'ledger'
1794
+ ? LEDGER_NODE_DEFAULT_SIZE.height
1795
+ : 200,
1782
1796
  fileMode: 'auto',
1783
1797
  });
1784
1798
  result = { ok: true, ...serializeCreatedNode(created.node) };