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.
- package/CHANGELOG.md +36 -0
- package/dist/canvas/index.js +49 -49
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +108 -108
- package/dist/types/json-render/charts/components.d.ts +10 -0
- package/dist/types/json-render/directives.d.ts +11 -1
- package/dist/types/server/canvas-operations.d.ts +8 -0
- package/dist/types/server/index.d.ts +6 -1
- package/docs/sdk.md +4 -4
- package/package.json +1 -1
- package/src/cli/agent.ts +22 -1
- package/src/client/nodes/ExtAppFrame.tsx +25 -0
- package/src/json-render/charts/components.tsx +31 -6
- package/src/json-render/charts/tufte-components.tsx +17 -5
- package/src/json-render/directives.ts +37 -2
- package/src/json-render/renderer/index.css +6 -1
- package/src/json-render/server.ts +19 -1
- package/src/mcp/canvas-access.ts +3 -1
- package/src/mcp/server.ts +2 -2
- package/src/server/canvas-operations.ts +16 -2
- package/src/server/canvas-schema.ts +1 -0
- package/src/server/index.ts +25 -5
- package/src/server/server.ts +23 -3
- package/src/server/web-artifacts.ts +31 -5
|
@@ -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
|
-
}):
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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) =>
|
|
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:
|
|
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) {
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -212,7 +212,9 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
async addNode(input: AddNodeInput): Promise<string> {
|
|
215
|
-
|
|
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
|
|
768
|
-
bundleScriptPath: z.string().optional().describe('Optional
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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' },
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
}):
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
-
|
|
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: {
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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
|
: {}),
|