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.
- package/CHANGELOG.md +68 -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 +12 -1
- package/docs/sdk.md +4 -4
- package/package.json +1 -1
- package/src/cli/agent.ts +35 -1
- package/src/client/nodes/ExtAppFrame.tsx +25 -0
- package/src/client/nodes/LedgerNode.tsx +39 -5
- 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 +37 -2
- package/src/mcp/canvas-access.ts +20 -2
- package/src/mcp/server.ts +8 -5
- package/src/server/canvas-operations.ts +16 -2
- package/src/server/canvas-schema.ts +1 -0
- package/src/server/index.ts +32 -6
- 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[];
|
|
@@ -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
|
-
}):
|
|
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,
|
|
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.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
|
|
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
|
-
//
|
|
7
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
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;
|
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']>>> {
|
|
@@ -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
|
|
768
|
-
bundleScriptPath: z.string().optional().describe('Optional
|
|
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
|
-
:
|
|
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) };
|