pmx-canvas 0.1.27 → 0.1.28

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[];
@@ -23,6 +23,11 @@ export declare class PmxCanvas extends EventEmitter {
23
23
  automationWebView?: boolean | CanvasAutomationWebViewOptions;
24
24
  }): Promise<void>;
25
25
  stop(): void;
26
+ /**
27
+ * Add a node to the canvas and return the created node (including its `id`,
28
+ * resolved geometry, and data). Destructure `const { id } = canvas.addNode(...)`
29
+ * or keep the whole node — both work. (Previously returned a bare id string.)
30
+ */
26
31
  addNode(input: {
27
32
  type: CanvasNodeState['type'];
28
33
  title?: string;
@@ -42,7 +47,7 @@ export declare class PmxCanvas extends EventEmitter {
42
47
  width?: number;
43
48
  height?: number;
44
49
  strictSize?: boolean;
45
- }): string;
50
+ }): CanvasNodeState;
46
51
  addWebpageNode(input: {
47
52
  title?: string;
48
53
  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.28",
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);
@@ -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?.();
@@ -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) {
@@ -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']>>> {
package/src/mcp/server.ts CHANGED
@@ -764,8 +764,8 @@ export async function startMcpServer(): Promise<void> {
764
764
  outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
765
765
  openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
766
766
  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'),
767
+ initScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
768
+ bundleScriptPath: z.string().optional().describe('Optional script path override for tests/debugging. Must resolve inside the workspace.'),
769
769
  timeoutMs: z.number().optional().describe('Optional timeout in milliseconds for init and bundle commands'),
770
770
  },
771
771
  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) };
@@ -540,6 +540,7 @@ export function describeCanvasSchema(): {
540
540
  },
541
541
  components: clone(describeJsonRenderCatalog()),
542
542
  directives: [
543
+ { name: '$state', usage: '{ "$state": "/path/to/value" } — read a value from the state model by path (one-way). Use this to bind a value by path; there is no $path directive.' },
543
544
  { name: '$format', usage: '{ "$format": "currency"|"number"|"percent"|"date", "value": <num|state-ref>, "currency"?: "USD", "locale"?, "style"?, "options"? } — Intl-formatted string' },
544
545
  { name: '$math', usage: '{ "$math": "add"|"subtract"|"multiply"|"divide"|"mod"|"min"|"max"|"round"|"floor"|"ceil"|"abs", "a": <num>, "b"?: <num> }' },
545
546
  { name: '$concat', usage: '{ "$concat": [<value>, <value>, ...] } — join values into one string' },
@@ -36,6 +36,8 @@ import {
36
36
  createCanvasStreamingJsonRenderNode,
37
37
  MARKDOWN_NODE_DEFAULT_SIZE,
38
38
  MCP_APP_NODE_DEFAULT_SIZE,
39
+ IMAGE_NODE_DEFAULT_SIZE,
40
+ LEDGER_NODE_DEFAULT_SIZE,
39
41
  applyCanvasNodeUpdates,
40
42
  arrangeCanvasNodes,
41
43
  clearCanvas,
@@ -186,6 +188,11 @@ export class PmxCanvas extends EventEmitter {
186
188
  this._server = null;
187
189
  }
188
190
 
191
+ /**
192
+ * Add a node to the canvas and return the created node (including its `id`,
193
+ * resolved geometry, and data). Destructure `const { id } = canvas.addNode(...)`
194
+ * or keep the whole node — both work. (Previously returned a bare id string.)
195
+ */
189
196
  addNode(input: {
190
197
  type: CanvasNodeState['type'];
191
198
  title?: string;
@@ -205,12 +212,12 @@ export class PmxCanvas extends EventEmitter {
205
212
  width?: number;
206
213
  height?: number;
207
214
  strictSize?: boolean;
208
- }): string {
215
+ }): CanvasNodeState {
209
216
  if (input.type === 'webpage') {
210
217
  throw new Error('Use addWebpageNode for webpage nodes so page content is fetched and cached on the server.');
211
218
  }
212
219
  if (input.type === 'group') {
213
- return this.createGroup({
220
+ const groupId = this.createGroup({
214
221
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
215
222
  childIds: input.childIds ?? input.children ?? [],
216
223
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
@@ -220,6 +227,9 @@ export class PmxCanvas extends EventEmitter {
220
227
  ...(typeof input.color === 'string' ? { color: input.color } : {}),
221
228
  ...(input.childLayout ? { childLayout: input.childLayout } : {}),
222
229
  });
230
+ const groupNode = canvasState.getNode(groupId);
231
+ if (!groupNode) throw new Error(`Group node "${groupId}" was not created.`);
232
+ return groupNode;
223
233
  }
224
234
  const { id, needsCodeGraphRecompute } = addCanvasNode({
225
235
  ...input,
@@ -227,12 +237,20 @@ export class PmxCanvas extends EventEmitter {
227
237
  ? MARKDOWN_NODE_DEFAULT_SIZE.width
228
238
  : input.type === 'mcp-app'
229
239
  ? MCP_APP_NODE_DEFAULT_SIZE.width
230
- : 360,
240
+ : input.type === 'image'
241
+ ? IMAGE_NODE_DEFAULT_SIZE.width
242
+ : input.type === 'ledger'
243
+ ? LEDGER_NODE_DEFAULT_SIZE.width
244
+ : 360,
231
245
  defaultHeight: input.type === 'markdown'
232
246
  ? MARKDOWN_NODE_DEFAULT_SIZE.height
233
247
  : input.type === 'mcp-app'
234
248
  ? MCP_APP_NODE_DEFAULT_SIZE.height
235
- : 200,
249
+ : input.type === 'image'
250
+ ? IMAGE_NODE_DEFAULT_SIZE.height
251
+ : input.type === 'ledger'
252
+ ? LEDGER_NODE_DEFAULT_SIZE.height
253
+ : 200,
236
254
  fileMode: 'path',
237
255
  ...(input.strictSize ? { strictSize: true } : {}),
238
256
  });
@@ -245,7 +263,9 @@ export class PmxCanvas extends EventEmitter {
245
263
  });
246
264
  }
247
265
 
248
- return id;
266
+ const node = canvasState.getNode(id);
267
+ if (!node) throw new Error(`Node "${id}" was not created.`);
268
+ return node;
249
269
  }
250
270
 
251
271
  async addWebpageNode(input: {
@@ -93,6 +93,8 @@ import {
93
93
  addCanvasEdge,
94
94
  MARKDOWN_NODE_DEFAULT_SIZE,
95
95
  MCP_APP_NODE_DEFAULT_SIZE,
96
+ IMAGE_NODE_DEFAULT_SIZE,
97
+ LEDGER_NODE_DEFAULT_SIZE,
96
98
  applyCanvasNodeUpdates,
97
99
  appendCanvasJsonRenderStream,
98
100
  buildStructuredNodeUpdate,
@@ -1506,7 +1508,13 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1506
1508
  if (!src || src.startsWith('data:') || src.startsWith('http')) {
1507
1509
  return responseText('Not a file-based image', 400);
1508
1510
  }
1509
- const safePath = resolve(src);
1511
+ // Contain the file read to the active workspace. `src` comes from node data,
1512
+ // which any unauthenticated local caller can set — without this guard the
1513
+ // image route serves arbitrary host files (e.g. ../../etc/passwd).
1514
+ const safePath = resolveWorkspaceArtifactPath(src);
1515
+ if (!safePath) {
1516
+ return responseText('Image path is outside the workspace', 403);
1517
+ }
1510
1518
  if (!existsSync(safePath)) {
1511
1519
  return responseText('Image file not found', 404);
1512
1520
  }
@@ -1699,14 +1707,22 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1699
1707
  ? MARKDOWN_NODE_DEFAULT_SIZE.width
1700
1708
  : type === 'mcp-app'
1701
1709
  ? MCP_APP_NODE_DEFAULT_SIZE.width
1702
- : 360,
1710
+ : type === 'image'
1711
+ ? IMAGE_NODE_DEFAULT_SIZE.width
1712
+ : type === 'ledger'
1713
+ ? LEDGER_NODE_DEFAULT_SIZE.width
1714
+ : 360,
1703
1715
  defaultHeight: type === 'html'
1704
1716
  ? 640
1705
1717
  : type === 'markdown'
1706
1718
  ? MARKDOWN_NODE_DEFAULT_SIZE.height
1707
1719
  : type === 'mcp-app'
1708
1720
  ? MCP_APP_NODE_DEFAULT_SIZE.height
1709
- : 200,
1721
+ : type === 'image'
1722
+ ? IMAGE_NODE_DEFAULT_SIZE.height
1723
+ : type === 'ledger'
1724
+ ? LEDGER_NODE_DEFAULT_SIZE.height
1725
+ : 200,
1710
1726
  fileMode: 'auto',
1711
1727
  });
1712
1728
  } catch (error) {
@@ -2072,6 +2088,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
2072
2088
  ...(typeof body.outputPath === 'string'
2073
2089
  ? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
2074
2090
  : {}),
2091
+ // Script-path overrides are honored only when contained inside the
2092
+ // workspace (enforced by resolveTrustedScriptPath in
2093
+ // executeWebArtifactBuild), so they cannot point at an arbitrary host
2094
+ // script for bash execution.
2075
2095
  ...(typeof body.initScriptPath === 'string'
2076
2096
  ? { initScriptPath: body.initScriptPath }
2077
2097
  : {}),