pmx-canvas 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +191 -0
  2. package/CHANGELOG.md +74 -0
  3. package/Readme.md +74 -27
  4. package/dist/canvas/index.js +82 -82
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/json-render/index.js +944 -164
  7. package/dist/types/json-render/catalog.d.ts +195 -20
  8. package/dist/types/json-render/charts/components.d.ts +7 -0
  9. package/dist/types/json-render/charts/definitions.d.ts +13 -1
  10. package/dist/types/json-render/charts/tufte-components.d.ts +65 -0
  11. package/dist/types/json-render/charts/tufte-definitions.d.ts +164 -0
  12. package/dist/types/json-render/directives.d.ts +23 -0
  13. package/dist/types/json-render/renderer/index.d.ts +1 -0
  14. package/dist/types/json-render/server.d.ts +32 -1
  15. package/dist/types/mcp/canvas-access.d.ts +62 -0
  16. package/dist/types/server/ax-state.d.ts +170 -0
  17. package/dist/types/server/canvas-db.d.ts +17 -1
  18. package/dist/types/server/canvas-operations.d.ts +45 -0
  19. package/dist/types/server/canvas-schema.d.ts +5 -1
  20. package/dist/types/server/canvas-state.d.ts +95 -4
  21. package/dist/types/server/index.d.ts +114 -2
  22. package/dist/types/server/mutation-history.d.ts +1 -1
  23. package/docs/cli.md +42 -0
  24. package/docs/http-api.md +64 -0
  25. package/docs/mcp.md +23 -5
  26. package/docs/node-types.md +1 -1
  27. package/docs/screenshots/codex-app.png +0 -0
  28. package/docs/screenshots/github-copilot-app.png +0 -0
  29. package/docs/sdk.md +19 -1
  30. package/package.json +10 -7
  31. package/skills/control-session-orchestrator/SKILL.md +359 -0
  32. package/skills/control-session-orchestrator/evals/evals.json +75 -0
  33. package/skills/data-analysis/SKILL.md +6 -0
  34. package/skills/pmx-canvas/SKILL.md +50 -4
  35. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +6 -0
  36. package/skills/tufte-viz/SKILL.md +157 -0
  37. package/skills/tufte-viz/references/analytical-design.md +217 -0
  38. package/skills/tufte-viz/references/tufte-principles.md +147 -0
  39. package/src/cli/agent.ts +280 -2
  40. package/src/cli/index.ts +2 -1
  41. package/src/client/nodes/ExtAppFrame.tsx +23 -1
  42. package/src/client/nodes/McpAppNode.tsx +6 -2
  43. package/src/json-render/catalog.ts +22 -1
  44. package/src/json-render/charts/components.tsx +97 -10
  45. package/src/json-render/charts/definitions.ts +19 -2
  46. package/src/json-render/charts/extra-components.tsx +5 -4
  47. package/src/json-render/charts/tufte-components.tsx +383 -0
  48. package/src/json-render/charts/tufte-definitions.ts +128 -0
  49. package/src/json-render/directives.ts +29 -0
  50. package/src/json-render/renderer/index.css +101 -0
  51. package/src/json-render/renderer/index.tsx +33 -0
  52. package/src/json-render/server.ts +257 -5
  53. package/src/mcp/canvas-access.ts +261 -0
  54. package/src/mcp/server.ts +496 -7
  55. package/src/server/ax-context.ts +8 -3
  56. package/src/server/ax-state.ts +447 -0
  57. package/src/server/canvas-db.ts +184 -1
  58. package/src/server/canvas-operations.ts +107 -0
  59. package/src/server/canvas-schema.ts +26 -3
  60. package/src/server/canvas-state.ts +349 -2
  61. package/src/server/index.ts +234 -2
  62. package/src/server/mutation-history.ts +6 -0
  63. package/src/server/server.ts +419 -2
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Definitions for the Tufte primitive chart components in ./tufte-components.tsx.
3
+ *
4
+ * Kept separate from ./definitions.ts and ./extra-definitions.ts so the
5
+ * original chart catalogs stay untouched and the merge in ./catalog.ts is the
6
+ * only contact surface.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+
11
+ export const tufteChartComponentDefinitions = {
12
+ Sparkline: {
13
+ props: z.object({
14
+ title: z.string().nullable(),
15
+ data: z.array(z.record(z.string(), z.unknown())),
16
+ valueKey: z.string(),
17
+ color: z.string().nullable(),
18
+ fill: z.boolean().nullable(),
19
+ showEndDot: z.boolean().nullable(),
20
+ showMinMax: z.boolean().nullable(),
21
+ showValue: z.boolean().nullable(),
22
+ height: z.number().nullable(),
23
+ }),
24
+ description:
25
+ 'Word-sized sparkline: a single trend line with no axes, grid, or labels. Optional end dot, min/max markers, light area fill, and an inline last value. The canonical Tufte primitive for showing a trajectory in minimal space.',
26
+ example: {
27
+ title: 'Latency p95',
28
+ data: [{ t: 0, ms: 120 }, { t: 1, ms: 138 }, { t: 2, ms: 117 }, { t: 3, ms: 152 }, { t: 4, ms: 109 }],
29
+ valueKey: 'ms',
30
+ color: null,
31
+ fill: true,
32
+ showEndDot: true,
33
+ showMinMax: false,
34
+ showValue: true,
35
+ height: null,
36
+ },
37
+ },
38
+
39
+ DotPlot: {
40
+ props: z.object({
41
+ title: z.string().nullable(),
42
+ data: z.array(z.record(z.string(), z.unknown())),
43
+ labelKey: z.string(),
44
+ valueKey: z.string(),
45
+ color: z.string().nullable(),
46
+ sort: z.enum(['asc', 'desc', 'none']).nullable(),
47
+ height: z.number().nullable(),
48
+ }),
49
+ description:
50
+ 'Cleveland dot plot: categorical labels down the Y axis, one dot per category positioned by value on X. Higher data-ink ratio than a bar chart for ranked comparison. Sorts descending by default.',
51
+ example: {
52
+ title: 'Build time by package',
53
+ data: [
54
+ { pkg: 'core', seconds: 42 },
55
+ { pkg: 'client', seconds: 31 },
56
+ { pkg: 'mcp', seconds: 18 },
57
+ { pkg: 'cli', seconds: 9 },
58
+ ],
59
+ labelKey: 'pkg',
60
+ valueKey: 'seconds',
61
+ color: null,
62
+ sort: 'desc',
63
+ height: null,
64
+ },
65
+ },
66
+
67
+ BulletChart: {
68
+ props: z.object({
69
+ title: z.string().nullable(),
70
+ data: z.array(z.record(z.string(), z.unknown())),
71
+ labelKey: z.string().nullable(),
72
+ valueKey: z.string(),
73
+ targetKey: z.string().nullable(),
74
+ rangesKey: z.string().nullable(),
75
+ color: z.string().nullable(),
76
+ height: z.number().nullable(),
77
+ }),
78
+ description:
79
+ "Stephen Few's bullet graph: a measure bar against grayscale qualitative bands with a target tick and per-row scale ticks. Compact KPI-vs-target display. Provide per-row `ranges` (ascending band thresholds) and `target`.",
80
+ example: {
81
+ title: 'Quarterly KPIs vs target',
82
+ data: [
83
+ { label: 'Revenue', value: 84, target: 90, ranges: [50, 75, 100] },
84
+ { label: 'NPS', value: 67, target: 60, ranges: [40, 60, 80] },
85
+ { label: 'Uptime', value: 99, target: 99.9, ranges: [95, 99, 100] },
86
+ ],
87
+ labelKey: 'label',
88
+ valueKey: 'value',
89
+ targetKey: 'target',
90
+ rangesKey: 'ranges',
91
+ color: null,
92
+ height: null,
93
+ },
94
+ },
95
+
96
+ Slopegraph: {
97
+ props: z.object({
98
+ title: z.string().nullable(),
99
+ data: z.array(z.record(z.string(), z.unknown())),
100
+ labelKey: z.string(),
101
+ beforeKey: z.string(),
102
+ afterKey: z.string(),
103
+ beforeLabel: z.string().nullable(),
104
+ afterLabel: z.string().nullable(),
105
+ color: z.string().nullable(),
106
+ colorByDirection: z.boolean().nullable(),
107
+ height: z.number().nullable(),
108
+ }),
109
+ description:
110
+ "Tufte slopegraph: two value columns (before/after) with a connecting line per category. Lines use one neutral ink by default; set colorByDirection to accent rising lines and mute falling ones. Ideal for paired change across many items.",
111
+ example: {
112
+ title: 'Coverage before/after refactor',
113
+ data: [
114
+ { module: 'auth', before: 62, after: 81 },
115
+ { module: 'canvas', before: 74, after: 78 },
116
+ { module: 'mcp', before: 55, after: 49 },
117
+ ],
118
+ labelKey: 'module',
119
+ beforeKey: 'before',
120
+ afterKey: 'after',
121
+ beforeLabel: 'Before',
122
+ afterLabel: 'After',
123
+ color: null,
124
+ colorByDirection: null,
125
+ height: null,
126
+ },
127
+ },
128
+ } as const;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Custom json-render directives available in PMX Canvas specs.
3
+ *
4
+ * Directives let agent specs declare formatting/derivation ($format, $math,
5
+ * $concat, $count, $truncate, $pluralize, $join) instead of pre-formatting
6
+ * strings. Registered into the iframe renderer via <JSONUIProvider directives>.
7
+ */
8
+ import type { DirectiveDefinition } from '@json-render/core';
9
+ import { standardDirectives } from '@json-render/directives';
10
+
11
+ /**
12
+ * Directives enabled in the PMX Canvas viewer. `standardDirectives` covers the
13
+ * seven stateless directives. The $t i18n directive is intentionally omitted —
14
+ * it is a factory requiring locale config and PMX Canvas has no locale source.
15
+ */
16
+ export const pmxCanvasDirectives: DirectiveDefinition[] = [...standardDirectives];
17
+
18
+ /**
19
+ * True when a prop value is a render-time dynamic expression — a directive
20
+ * (`$format`/`$math`/…) or an existing binding (`$state`/`$item`/`$bindItem`/
21
+ * `$cond`/`$template`/`$computed`). These objects are resolved inside the
22
+ * renderer, so the server-side validators must leave them untouched instead of
23
+ * string-coercing them to `"[object Object]"` or rejecting them as the wrong
24
+ * primitive type.
25
+ */
26
+ export function isDynamicPropValue(value: unknown): boolean {
27
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
28
+ return Object.keys(value as Record<string, unknown>).some((key) => key.startsWith('$'));
29
+ }
@@ -204,6 +204,65 @@ button {
204
204
  color: var(--destructive);
205
205
  }
206
206
 
207
+ .pmx-button {
208
+ display: inline-flex;
209
+ align-items: center;
210
+ justify-content: center;
211
+ gap: 0.5rem;
212
+ height: 2.25rem;
213
+ padding: 0.5rem 1rem;
214
+ border: 1px solid transparent;
215
+ border-radius: var(--radius);
216
+ font-size: 0.875rem;
217
+ font-weight: 500;
218
+ line-height: 1.25rem;
219
+ white-space: nowrap;
220
+ cursor: pointer;
221
+ transition: background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
222
+ }
223
+
224
+ .pmx-button:disabled {
225
+ pointer-events: none;
226
+ opacity: 0.5;
227
+ }
228
+
229
+ .pmx-button--primary {
230
+ background: var(--primary);
231
+ color: var(--primary-foreground);
232
+ }
233
+
234
+ .pmx-button--secondary {
235
+ background: var(--secondary);
236
+ color: var(--secondary-foreground);
237
+ }
238
+
239
+ .pmx-button--destructive,
240
+ .pmx-button--danger {
241
+ background: var(--destructive);
242
+ color: var(--destructive-foreground);
243
+ }
244
+
245
+ .pmx-button--success {
246
+ background: var(--chart-2);
247
+ color: var(--primary-foreground);
248
+ }
249
+
250
+ .pmx-button--outline {
251
+ border-color: var(--border);
252
+ background: transparent;
253
+ color: var(--foreground);
254
+ }
255
+
256
+ .pmx-button--ghost {
257
+ background: transparent;
258
+ color: var(--foreground);
259
+ }
260
+
261
+ .pmx-button--ghost:hover,
262
+ .pmx-button--outline:hover {
263
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
264
+ }
265
+
207
266
  /* -- Chart components -- */
208
267
 
209
268
  .pmx-chart {
@@ -225,6 +284,48 @@ button {
225
284
  min-width: 360px;
226
285
  }
227
286
 
287
+ .pmx-chart--dot-plot,
288
+ .pmx-chart--bullet,
289
+ .pmx-chart--slopegraph {
290
+ min-width: 320px;
291
+ }
292
+
293
+ .pmx-chart--sparkline {
294
+ min-width: 160px;
295
+ padding: 0.25rem 0;
296
+ }
297
+
298
+ .pmx-chart__sparkline-row {
299
+ display: flex;
300
+ align-items: center;
301
+ gap: 0.5rem;
302
+ }
303
+
304
+ .pmx-chart__sparkline-svg {
305
+ display: block;
306
+ width: 100%;
307
+ height: 36px;
308
+ }
309
+
310
+ .pmx-chart__sparkline-value {
311
+ font-size: 0.8125rem;
312
+ font-weight: 600;
313
+ font-variant-numeric: tabular-nums;
314
+ white-space: nowrap;
315
+ }
316
+
317
+ .pmx-chart__dot-plot-svg,
318
+ .pmx-chart__bullet-svg,
319
+ .pmx-chart__slopegraph-svg {
320
+ display: block;
321
+ }
322
+
323
+ .pmx-chart--dot-plot text,
324
+ .pmx-chart--bullet text,
325
+ .pmx-chart--slopegraph text {
326
+ font-variant-numeric: tabular-nums;
327
+ }
328
+
228
329
  .pmx-chart__title {
229
330
  font-size: 0.875rem;
230
331
  font-weight: 600;
@@ -14,6 +14,9 @@ import { shadcnComponents } from '@json-render/shadcn';
14
14
  import { catalog } from '../catalog';
15
15
  import { chartComponents } from '../charts/components';
16
16
  import { extraChartComponents } from '../charts/extra-components';
17
+ import { tufteChartComponents } from '../charts/tufte-components';
18
+ import { pmxCanvasDirectives } from '../directives';
19
+ import { JsonRenderDevtools } from '@json-render/devtools-react';
17
20
 
18
21
  type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'info' | 'warning' | 'error' | 'danger';
19
22
  type BadgeProps = {
@@ -36,12 +39,37 @@ function Badge({ props }: { props: BadgeProps }) {
36
39
  );
37
40
  }
38
41
 
42
+ type ButtonVariant = 'primary' | 'secondary' | 'destructive' | 'danger' | 'outline' | 'ghost' | 'success';
43
+ type ButtonProps = {
44
+ label: string;
45
+ variant?: ButtonVariant | null;
46
+ disabled?: boolean | null;
47
+ };
48
+
49
+ function Button({ props, emit }: { props: ButtonProps; emit: (event: string) => void }) {
50
+ const resolvedVariant = props.variant ?? 'primary';
51
+ return (
52
+ <button
53
+ type="button"
54
+ data-slot="button"
55
+ data-variant={resolvedVariant}
56
+ className={`pmx-button pmx-button--${resolvedVariant}`}
57
+ disabled={props.disabled ?? false}
58
+ onClick={() => emit('press')}
59
+ >
60
+ {props.label}
61
+ </button>
62
+ );
63
+ }
64
+
39
65
  const { registry } = defineRegistry(catalog as never, {
40
66
  components: {
41
67
  ...shadcnComponents,
42
68
  Badge,
69
+ Button,
43
70
  ...chartComponents,
44
71
  ...extraChartComponents,
72
+ ...tufteChartComponents,
45
73
  } as never,
46
74
  });
47
75
 
@@ -50,6 +78,7 @@ declare global {
50
78
  __PMX_CANVAS_JSON_RENDER_SPEC__?: Spec & { state?: Record<string, unknown> };
51
79
  __PMX_CANVAS_JSON_RENDER_THEME__?: string;
52
80
  __PMX_CANVAS_JSON_RENDER_DISPLAY__?: string;
81
+ __PMX_CANVAS_JSON_RENDER_DEVTOOLS__?: boolean;
53
82
  }
54
83
  }
55
84
 
@@ -95,8 +124,12 @@ function App() {
95
124
  <JSONUIProvider
96
125
  registry={registry}
97
126
  initialState={spec.state ?? undefined}
127
+ directives={pmxCanvasDirectives}
98
128
  >
99
129
  <Renderer spec={spec} registry={registry} loading={false} />
130
+ {window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ ? (
131
+ <JsonRenderDevtools position="right" />
132
+ ) : null}
100
133
  </JSONUIProvider>
101
134
  </div>
102
135
  );
@@ -2,7 +2,9 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { buildAppHtml } from '@json-render/mcp/build-app-html';
5
+ import { applySpecPatch, parseSpecStreamLine, type Spec, type SpecStreamLine } from '@json-render/core';
5
6
  import { allComponentDefinitions, catalog, validateShadcnElementProps, type JsonRenderIssue } from './catalog.js';
7
+ import { isDynamicPropValue } from './directives.js';
6
8
 
7
9
  export interface JsonRenderSpec {
8
10
  root: string;
@@ -36,8 +38,23 @@ export interface GraphNodeInput {
36
38
  lineKey?: string;
37
39
  aggregate?: 'sum' | 'count' | 'avg';
38
40
  color?: string;
41
+ colorBy?: 'series' | 'category' | 'value' | 'none';
42
+ highlight?: number | 'max' | 'min' | null;
39
43
  barColor?: string;
40
44
  lineColor?: string;
45
+ labelKey?: string;
46
+ beforeKey?: string;
47
+ afterKey?: string;
48
+ beforeLabel?: string;
49
+ afterLabel?: string;
50
+ targetKey?: string;
51
+ rangesKey?: string;
52
+ sort?: 'asc' | 'desc' | 'none';
53
+ fill?: boolean;
54
+ showEndDot?: boolean;
55
+ showMinMax?: boolean;
56
+ showValue?: boolean;
57
+ colorByDirection?: boolean;
41
58
  height?: number;
42
59
  showLegend?: boolean;
43
60
  showLabels?: boolean;
@@ -59,7 +76,11 @@ export type GraphChartType =
59
76
  | 'ScatterChart'
60
77
  | 'RadarChart'
61
78
  | 'StackedBarChart'
62
- | 'ComposedChart';
79
+ | 'ComposedChart'
80
+ | 'Sparkline'
81
+ | 'DotPlot'
82
+ | 'BulletChart'
83
+ | 'Slopegraph';
63
84
 
64
85
  const GRAPH_TYPE_ALIASES: Record<string, GraphChartType> = {
65
86
  line: 'LineChart',
@@ -84,6 +105,15 @@ const GRAPH_TYPE_ALIASES: Record<string, GraphChartType> = {
84
105
  composedchart: 'ComposedChart',
85
106
  combo: 'ComposedChart',
86
107
  combochart: 'ComposedChart',
108
+ sparkline: 'Sparkline',
109
+ spark: 'Sparkline',
110
+ dotplot: 'DotPlot',
111
+ dot: 'DotPlot',
112
+ cleveland: 'DotPlot',
113
+ bullet: 'BulletChart',
114
+ bulletchart: 'BulletChart',
115
+ slopegraph: 'Slopegraph',
116
+ slope: 'Slopegraph',
87
117
  };
88
118
 
89
119
  const COERCIBLE_STRING_PROPS = [
@@ -316,7 +346,12 @@ function normalizeElementProps(
316
346
  const props = (stripNullishDeep(rawProps) as Record<string, unknown> | undefined) ?? {};
317
347
 
318
348
  for (const key of COERCIBLE_STRING_PROPS) {
319
- if (key in props && typeof props[key] !== 'string' && props[key] !== undefined) {
349
+ if (
350
+ key in props &&
351
+ typeof props[key] !== 'string' &&
352
+ props[key] !== undefined &&
353
+ !isDynamicPropValue(props[key])
354
+ ) {
320
355
  props[key] = String(props[key]);
321
356
  }
322
357
  }
@@ -502,6 +537,86 @@ function inferKeysFromData(data: Array<Record<string, unknown>>, exclude: string
502
537
  return Object.keys(first).filter((key) => !exclude.includes(key));
503
538
  }
504
539
 
540
+ function collectDataKeys(data: Array<Record<string, unknown>>): Set<string> {
541
+ const keys = new Set<string>();
542
+ for (const row of data) {
543
+ for (const key of Object.keys(row)) keys.add(key);
544
+ }
545
+ return keys;
546
+ }
547
+
548
+ function assertGraphDataKeys(
549
+ data: Array<Record<string, unknown>>,
550
+ chartType: GraphChartType,
551
+ chartProps: Record<string, unknown>,
552
+ ): void {
553
+ if (data.length === 0) return;
554
+ const available = collectDataKeys(data);
555
+ const required = new Set<string>();
556
+
557
+ const addString = (value: unknown): void => {
558
+ if (typeof value === 'string' && value.length > 0) required.add(value);
559
+ };
560
+ const addStringList = (value: unknown): void => {
561
+ if (!Array.isArray(value)) return;
562
+ for (const item of value) addString(item);
563
+ };
564
+
565
+ switch (chartType) {
566
+ case 'PieChart':
567
+ addString(chartProps.nameKey);
568
+ addString(chartProps.valueKey);
569
+ break;
570
+ case 'ScatterChart':
571
+ addString(chartProps.xKey);
572
+ addString(chartProps.yKey);
573
+ addString(chartProps.zKey);
574
+ break;
575
+ case 'RadarChart':
576
+ addString(chartProps.axisKey);
577
+ addStringList(chartProps.metrics);
578
+ break;
579
+ case 'StackedBarChart':
580
+ addString(chartProps.xKey);
581
+ addStringList(chartProps.series);
582
+ break;
583
+ case 'ComposedChart':
584
+ addString(chartProps.xKey);
585
+ addString(chartProps.barKey);
586
+ addString(chartProps.lineKey);
587
+ break;
588
+ case 'Sparkline':
589
+ addString(chartProps.valueKey);
590
+ break;
591
+ case 'DotPlot':
592
+ addString(chartProps.labelKey);
593
+ addString(chartProps.valueKey);
594
+ break;
595
+ case 'BulletChart':
596
+ addString(chartProps.labelKey);
597
+ addString(chartProps.valueKey);
598
+ addString(chartProps.targetKey);
599
+ addString(chartProps.rangesKey);
600
+ break;
601
+ case 'Slopegraph':
602
+ addString(chartProps.labelKey);
603
+ addString(chartProps.beforeKey);
604
+ addString(chartProps.afterKey);
605
+ break;
606
+ case 'AreaChart':
607
+ case 'BarChart':
608
+ case 'LineChart':
609
+ addString(chartProps.xKey);
610
+ addString(chartProps.yKey);
611
+ break;
612
+ }
613
+
614
+ const missing = [...required].filter((key) => !available.has(key));
615
+ if (missing.length === 0) return;
616
+ const availableList = [...available].sort().join(', ') || '(none)';
617
+ throw new Error(`Graph data key mismatch for ${chartType}: missing ${missing.join(', ')}. Available keys: ${availableList}.`);
618
+ }
619
+
505
620
  export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
506
621
  const title = input.title?.trim() || 'Graph';
507
622
  const chartType = normalizeGraphType(input.graphType);
@@ -567,18 +682,66 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
567
682
  chartProps.showLegend = input.showLegend !== false;
568
683
  break;
569
684
  }
685
+ case 'BarChart': {
686
+ const xKey = input.xKey ?? inferKeysFromData(input.data)[0] ?? 'label';
687
+ const yKey = input.yKey ?? inferKeysFromData(input.data, [xKey])[0] ?? 'value';
688
+ chartProps.xKey = xKey;
689
+ chartProps.yKey = yKey;
690
+ chartProps.aggregate = input.aggregate ?? null;
691
+ chartProps.color = input.color ?? null;
692
+ chartProps.colorBy = input.colorBy ?? 'series';
693
+ chartProps.highlight = input.highlight === undefined ? 'max' : input.highlight;
694
+ break;
695
+ }
696
+ case 'Sparkline': {
697
+ chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
698
+ chartProps.color = input.color ?? null;
699
+ chartProps.fill = input.fill ?? null;
700
+ chartProps.showEndDot = input.showEndDot ?? null;
701
+ chartProps.showMinMax = input.showMinMax ?? null;
702
+ chartProps.showValue = input.showValue ?? null;
703
+ break;
704
+ }
705
+ case 'DotPlot': {
706
+ chartProps.labelKey = input.labelKey ?? input.xKey ?? 'label';
707
+ chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
708
+ chartProps.color = input.color ?? null;
709
+ chartProps.sort = input.sort ?? null;
710
+ break;
711
+ }
712
+ case 'BulletChart': {
713
+ chartProps.labelKey = input.labelKey ?? input.xKey ?? null;
714
+ chartProps.valueKey = input.valueKey ?? input.yKey ?? 'value';
715
+ chartProps.targetKey = input.targetKey ?? null;
716
+ chartProps.rangesKey = input.rangesKey ?? null;
717
+ chartProps.color = input.color ?? null;
718
+ break;
719
+ }
720
+ case 'Slopegraph': {
721
+ chartProps.labelKey = input.labelKey ?? 'label';
722
+ chartProps.beforeKey = input.beforeKey ?? 'before';
723
+ chartProps.afterKey = input.afterKey ?? 'after';
724
+ chartProps.beforeLabel = input.beforeLabel ?? null;
725
+ chartProps.afterLabel = input.afterLabel ?? null;
726
+ chartProps.color = input.color ?? null;
727
+ chartProps.colorByDirection = input.colorByDirection ?? null;
728
+ break;
729
+ }
570
730
  case 'AreaChart':
571
731
  case 'LineChart':
572
- case 'BarChart':
573
732
  default: {
574
- chartProps.xKey = input.xKey ?? 'label';
575
- chartProps.yKey = input.yKey ?? 'value';
733
+ const xKey = input.xKey ?? inferKeysFromData(input.data)[0] ?? 'label';
734
+ const yKey = input.yKey ?? inferKeysFromData(input.data, [xKey])[0] ?? 'value';
735
+ chartProps.xKey = xKey;
736
+ chartProps.yKey = yKey;
576
737
  chartProps.aggregate = input.aggregate ?? null;
577
738
  chartProps.color = input.color ?? null;
578
739
  break;
579
740
  }
580
741
  }
581
742
 
743
+ assertGraphDataKeys(input.data, chartType, chartProps);
744
+
582
745
  return normalizeAndValidateJsonRenderSpec({
583
746
  root: 'card',
584
747
  elements: {
@@ -619,14 +782,101 @@ export function buildGraphConfig(input: GraphNodeInput): Record<string, unknown>
619
782
  ...(input.lineKey ? { lineKey: input.lineKey } : {}),
620
783
  ...(input.aggregate ? { aggregate: input.aggregate } : {}),
621
784
  ...(input.color ? { color: input.color } : {}),
785
+ ...(input.colorBy ? { colorBy: input.colorBy } : {}),
786
+ ...(input.highlight !== undefined ? { highlight: input.highlight } : {}),
622
787
  ...(input.barColor ? { barColor: input.barColor } : {}),
623
788
  ...(input.lineColor ? { lineColor: input.lineColor } : {}),
789
+ ...(input.labelKey ? { labelKey: input.labelKey } : {}),
790
+ ...(input.beforeKey ? { beforeKey: input.beforeKey } : {}),
791
+ ...(input.afterKey ? { afterKey: input.afterKey } : {}),
792
+ ...(input.beforeLabel ? { beforeLabel: input.beforeLabel } : {}),
793
+ ...(input.afterLabel ? { afterLabel: input.afterLabel } : {}),
794
+ ...(input.targetKey ? { targetKey: input.targetKey } : {}),
795
+ ...(input.rangesKey ? { rangesKey: input.rangesKey } : {}),
796
+ ...(input.sort ? { sort: input.sort } : {}),
797
+ ...(typeof input.fill === 'boolean' ? { fill: input.fill } : {}),
798
+ ...(typeof input.showEndDot === 'boolean' ? { showEndDot: input.showEndDot } : {}),
799
+ ...(typeof input.showMinMax === 'boolean' ? { showMinMax: input.showMinMax } : {}),
800
+ ...(typeof input.showValue === 'boolean' ? { showValue: input.showValue } : {}),
801
+ ...(typeof input.colorByDirection === 'boolean' ? { colorByDirection: input.colorByDirection } : {}),
624
802
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
625
803
  ...(typeof input.showLegend === 'boolean' ? { showLegend: input.showLegend } : {}),
626
804
  ...(typeof input.showLabels === 'boolean' ? { showLabels: input.showLabels } : {}),
627
805
  };
628
806
  }
629
807
 
808
+ /** The minimal spec a streaming json-render node starts from before any patches. */
809
+ export function emptyStreamingSpec(): JsonRenderSpec {
810
+ return { root: '', elements: {} };
811
+ }
812
+
813
+ const UNSAFE_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
814
+
815
+ /**
816
+ * Reject JSON-Pointer paths that would let a streamed patch reach a prototype
817
+ * chain (`__proto__`/`constructor`/`prototype`). The upstream applySpecPatch
818
+ * walks the path with plain property assignment and has no such guard, so an
819
+ * agent-supplied patch like `{op:'add',path:'/__proto__/x',value:...}` would
820
+ * otherwise pollute Object.prototype server-side. Segments are JSON-Pointer
821
+ * escaped (`~1`->`/`, `~0`->`~`); decode before comparing.
822
+ */
823
+ function isSafePatchPath(path: string): boolean {
824
+ for (const rawSegment of path.split('/')) {
825
+ const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~');
826
+ if (UNSAFE_PATCH_SEGMENTS.has(segment)) return false;
827
+ }
828
+ return true;
829
+ }
830
+
831
+ /** Accept a SpecStream item as either a raw JSONL line or a JSON-Patch object. */
832
+ function coerceStreamPatch(item: unknown): SpecStreamLine | null {
833
+ const patch = (() => {
834
+ if (typeof item === 'string') return parseSpecStreamLine(item);
835
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
836
+ const record = item as Record<string, unknown>;
837
+ if (typeof record.op === 'string' && typeof record.path === 'string') {
838
+ return item as SpecStreamLine;
839
+ }
840
+ }
841
+ return null;
842
+ })();
843
+ if (!patch || typeof patch.path !== 'string' || !isSafePatchPath(patch.path)) return null;
844
+ return patch;
845
+ }
846
+
847
+ /**
848
+ * Apply a batch of SpecStream patches to the current spec, accumulating the
849
+ * result. The canvas is the source of truth — patches are applied server-side
850
+ * and the browser only renders the current accumulated spec, so there is no
851
+ * client-side reconciliation. Tolerant by design: malformed or inapplicable
852
+ * patches are skipped and counted, never thrown, so a partial stream keeps
853
+ * building toward the final spec.
854
+ */
855
+ export function applyJsonRenderStreamPatches(
856
+ currentSpec: JsonRenderSpec,
857
+ items: unknown[],
858
+ ): { spec: JsonRenderSpec; applied: number; skipped: number } {
859
+ let spec = currentSpec as Spec;
860
+ let applied = 0;
861
+ let skipped = 0;
862
+ for (const item of items) {
863
+ const patch = coerceStreamPatch(item);
864
+ if (!patch) {
865
+ skipped++;
866
+ continue;
867
+ }
868
+ try {
869
+ spec = applySpecPatch(spec, patch);
870
+ applied++;
871
+ } catch {
872
+ // Inapplicable patch (e.g. references a not-yet-added element); skip and
873
+ // keep streaming. A later patch may add the missing target.
874
+ skipped++;
875
+ }
876
+ }
877
+ return { spec: spec as JsonRenderSpec, applied, skipped };
878
+ }
879
+
630
880
  export function createJsonRenderNodeData(
631
881
  nodeId: string,
632
882
  title: string,
@@ -649,6 +899,7 @@ export async function buildJsonRenderViewerHtml(options: {
649
899
  spec: JsonRenderSpec;
650
900
  theme?: 'dark' | 'light' | 'high-contrast';
651
901
  display?: 'expanded';
902
+ devtools?: boolean;
652
903
  }): Promise<string> {
653
904
  try {
654
905
  await ensureJsonRenderBundle();
@@ -663,6 +914,7 @@ export async function buildJsonRenderViewerHtml(options: {
663
914
  `window.__PMX_CANVAS_JSON_RENDER_SPEC__ = ${JSON.stringify(options.spec)};`,
664
915
  ...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
665
916
  ...(options.display ? [`window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ = ${JSON.stringify(options.display)};`] : []),
917
+ ...(options.devtools ? ['window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ = true;'] : []),
666
918
  jsBundle,
667
919
  ].join('\n');
668
920
  return buildAppHtml({