pmx-canvas 0.1.26 → 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.
Files changed (64) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +191 -0
  2. package/CHANGELOG.md +110 -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 +17 -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 +33 -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 +53 -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 +120 -3
  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 +23 -5
  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 +302 -3
  40. package/src/cli/index.ts +2 -1
  41. package/src/client/nodes/ExtAppFrame.tsx +48 -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 +127 -15
  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 +395 -0
  48. package/src/json-render/charts/tufte-definitions.ts +128 -0
  49. package/src/json-render/directives.ts +64 -0
  50. package/src/json-render/renderer/index.css +107 -1
  51. package/src/json-render/renderer/index.tsx +33 -0
  52. package/src/json-render/server.ts +275 -5
  53. package/src/mcp/canvas-access.ts +264 -1
  54. package/src/mcp/server.ts +498 -9
  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 +123 -2
  59. package/src/server/canvas-schema.ts +27 -3
  60. package/src/server/canvas-state.ts +349 -2
  61. package/src/server/index.ts +259 -7
  62. package/src/server/mutation-history.ts +6 -0
  63. package/src/server/server.ts +442 -5
  64. package/src/server/web-artifacts.ts +31 -5
package/src/cli/agent.ts CHANGED
@@ -11,7 +11,9 @@
11
11
  * - --yes for destructive actions, --dry-run for preview
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync } from 'node:fs';
14
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
15
17
  import { openUrlInExternalBrowser, wrapCanvasAutomationScript } from '../server/server.js';
16
18
  import { DEFAULT_EXCALIDRAW_ELEMENTS } from '../server/diagram-presets.js';
17
19
  import {
@@ -166,6 +168,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
166
168
  'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run', 'all',
167
169
  'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact',
168
170
  'verbose', 'include-logs', 'no-pan', 'schema', 'example', 'examples', 'strict-size', 'scroll-overflow',
171
+ 'report', 'canvas', 'hooks', 'tools', 'session-messaging', 'permissions', 'files', 'ui-prompts',
169
172
  ]);
170
173
  for (let i = 0; i < args.length; i++) {
171
174
  const arg = args[i];
@@ -183,7 +186,12 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
183
186
  }
184
187
  }
185
188
  } else if (arg.startsWith('-') && arg.length === 2) {
186
- flags[arg.slice(1)] = true;
189
+ const key = arg.slice(1);
190
+ if (!BOOL_FLAGS.has(key) && i + 1 < args.length && !args[i + 1].startsWith('-')) {
191
+ flags[key] = args[++i];
192
+ } else {
193
+ flags[key] = true;
194
+ }
187
195
  } else {
188
196
  positional.push(arg);
189
197
  }
@@ -797,7 +805,28 @@ async function buildWebArtifactRequestBody(
797
805
  }
798
806
 
799
807
  async function runWebArtifactBuildCommand(flags: Record<string, string | true>): Promise<void> {
800
- const result = await api('POST', '/api/canvas/web-artifact', await buildWebArtifactRequestBody(flags), { allowErrorJson: true });
808
+ const body = await buildWebArtifactRequestBody(flags);
809
+ // The build (init + dependency install + bundle) runs server-side and only
810
+ // returns a single HTTP response on completion, which can take minutes on a
811
+ // cold workspace. With no output an agent's tool wait expires before the node
812
+ // appears and the build looks hung. Emit a default-on heartbeat to stderr
813
+ // while the request is in flight — stdout (output) and the JSON response body
814
+ // stay untouched, so anything parsing stdout is unaffected.
815
+ const startedMs = Date.now();
816
+ process.stderr.write(
817
+ `[web-artifact] building "${String(body.title)}" — init + install + bundle (this can take a few minutes)...\n`,
818
+ );
819
+ const heartbeat = setInterval(() => {
820
+ const elapsedSeconds = Math.round((Date.now() - startedMs) / 1000);
821
+ process.stderr.write(`[web-artifact] still building... ${elapsedSeconds}s elapsed\n`);
822
+ }, 10_000);
823
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
824
+ let result: unknown;
825
+ try {
826
+ result = await api('POST', '/api/canvas/web-artifact', body, { allowErrorJson: true });
827
+ } finally {
828
+ clearInterval(heartbeat);
829
+ }
801
830
  output(result);
802
831
  if (isRecord(result) && result.ok === false) {
803
832
  process.exit(1);
@@ -1102,6 +1131,7 @@ cmd('node add', 'Add a node to the canvas', [
1102
1131
  'pmx-canvas node add --type file --content "src/index.ts"',
1103
1132
  'pmx-canvas node add --type webpage --url "https://example.com/docs"',
1104
1133
  'pmx-canvas node add --type html --title "Widget" --content "<main>Hello</main>"',
1134
+ 'pmx-canvas node add --type html --title "Showcase" --content ./report.html (a .html path is read from disk; otherwise --content is raw HTML)',
1105
1135
  'pmx-canvas node add --type html --primitive choice-grid --data-file ./options.json --title "Options"',
1106
1136
  'pmx-canvas node add --type markdown --title "Note" --x 100 --y 200',
1107
1137
  'pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json',
@@ -1801,6 +1831,275 @@ cmd('ax focus', 'Set or clear PMX AX focus without moving the viewport', [
1801
1831
  output(await api('POST', '/api/canvas/ax/focus', { nodeIds, source: 'cli' }));
1802
1832
  });
1803
1833
 
1834
+ cmd('ax event add', 'Record a normalized AX timeline event', [
1835
+ 'pmx-canvas ax event add --kind tool-start --summary "ran tests"',
1836
+ 'pmx-canvas ax event add --kind failure --summary "build broke" --detail "..." node1 node2',
1837
+ ], async (args) => {
1838
+ const { positional, flags } = parseFlags(args);
1839
+ if (flags.help || flags.h) return showCommandHelp('ax event add');
1840
+
1841
+ const kind = requireFlag(flags, 'kind', 'pmx-canvas ax event add --kind <kind> --summary <text>');
1842
+ const summary = requireFlag(flags, 'summary', 'pmx-canvas ax event add --kind <kind> --summary <text>');
1843
+ const detail = getStringFlag(flags, 'detail');
1844
+
1845
+ output(await api('POST', '/api/canvas/ax/event', {
1846
+ kind,
1847
+ summary,
1848
+ ...(detail ? { detail } : {}),
1849
+ ...(positional.length > 0 ? { nodeIds: positional } : {}),
1850
+ source: 'cli',
1851
+ }));
1852
+ });
1853
+
1854
+ cmd('ax steer', 'Send a steering message to the active agent session', [
1855
+ 'pmx-canvas ax steer "focus on the failing test first"',
1856
+ 'pmx-canvas ax steer --message "stop and re-plan"',
1857
+ ], async (args) => {
1858
+ const { positional, flags } = parseFlags(args);
1859
+ if (flags.help || flags.h) return showCommandHelp('ax steer');
1860
+
1861
+ const message = getStringFlag(flags, 'message') ?? positional.join(' ').trim();
1862
+ if (!message) {
1863
+ die('Missing steering message', 'pmx-canvas ax steer <message>');
1864
+ }
1865
+
1866
+ output(await api('POST', '/api/canvas/ax/steer', { message, source: 'cli' }));
1867
+ });
1868
+
1869
+ cmd('ax timeline', 'Read the bounded AX timeline (events, evidence, steering)', [
1870
+ 'pmx-canvas ax timeline',
1871
+ 'pmx-canvas ax timeline --limit 100',
1872
+ ], async (args) => {
1873
+ const { flags } = parseFlags(args);
1874
+ if (flags.help || flags.h) return showCommandHelp('ax timeline');
1875
+
1876
+ const limit = optionalNumberFlag(flags, 'limit', 'pmx-canvas ax timeline --limit <n>');
1877
+ output(await api('GET', `/api/canvas/ax/timeline${limit ? `?limit=${limit}` : ''}`));
1878
+ });
1879
+
1880
+ cmd('ax work add', 'Add a canvas-bound AX work item', [
1881
+ 'pmx-canvas ax work add --title "Wire up auth" --status in-progress',
1882
+ 'pmx-canvas ax work add --title "Review API" node1 node2',
1883
+ ], async (args) => {
1884
+ const { positional, flags } = parseFlags(args);
1885
+ if (flags.help || flags.h) return showCommandHelp('ax work add');
1886
+
1887
+ const title = requireFlag(flags, 'title', 'pmx-canvas ax work add --title <text>');
1888
+ const status = getStringFlag(flags, 'status');
1889
+ const detail = getStringFlag(flags, 'detail');
1890
+
1891
+ output(await api('POST', '/api/canvas/ax/work', {
1892
+ title,
1893
+ ...(status ? { status } : {}),
1894
+ ...(detail ? { detail } : {}),
1895
+ ...(positional.length > 0 ? { nodeIds: positional } : {}),
1896
+ source: 'cli',
1897
+ }));
1898
+ });
1899
+
1900
+ cmd('ax work update', 'Update a canvas-bound AX work item by ID', [
1901
+ 'pmx-canvas ax work update <id> --status done',
1902
+ 'pmx-canvas ax work update <id> --title "New title" --detail "..."',
1903
+ ], async (args) => {
1904
+ const { positional, flags } = parseFlags(args);
1905
+ if (flags.help || flags.h) return showCommandHelp('ax work update');
1906
+
1907
+ const id = positional[0];
1908
+ if (!id) die('Missing work item ID', 'pmx-canvas ax work update <id> --status <status>');
1909
+ const title = getStringFlag(flags, 'title');
1910
+ const status = getStringFlag(flags, 'status');
1911
+ const detail = getStringFlag(flags, 'detail');
1912
+
1913
+ output(await api('PATCH', `/api/canvas/ax/work/${encodeURIComponent(id)}`, {
1914
+ ...(title ? { title } : {}),
1915
+ ...(status ? { status } : {}),
1916
+ ...(detail ? { detail } : {}),
1917
+ ...(positional.length > 1 ? { nodeIds: positional.slice(1) } : {}),
1918
+ source: 'cli',
1919
+ }));
1920
+ });
1921
+
1922
+ cmd('ax work list', 'List canvas-bound AX work items', [
1923
+ 'pmx-canvas ax work list',
1924
+ ], async (args) => {
1925
+ const { flags } = parseFlags(args);
1926
+ if (flags.help || flags.h) return showCommandHelp('ax work list');
1927
+
1928
+ output(await api('GET', '/api/canvas/ax/work'));
1929
+ });
1930
+
1931
+ cmd('ax approval request', 'Request a canvas-bound AX approval gate', [
1932
+ 'pmx-canvas ax approval request --title "Deploy to prod"',
1933
+ 'pmx-canvas ax approval request --title "Drop table" --action db.drop node1',
1934
+ ], async (args) => {
1935
+ const { positional, flags } = parseFlags(args);
1936
+ if (flags.help || flags.h) return showCommandHelp('ax approval request');
1937
+
1938
+ const title = requireFlag(flags, 'title', 'pmx-canvas ax approval request --title <text>');
1939
+ const detail = getStringFlag(flags, 'detail');
1940
+ const action = getStringFlag(flags, 'action');
1941
+
1942
+ output(await api('POST', '/api/canvas/ax/approval', {
1943
+ title,
1944
+ ...(detail ? { detail } : {}),
1945
+ ...(action ? { action } : {}),
1946
+ ...(positional.length > 0 ? { nodeIds: positional } : {}),
1947
+ source: 'cli',
1948
+ }));
1949
+ });
1950
+
1951
+ cmd('ax approval resolve', 'Resolve a pending AX approval gate by ID', [
1952
+ 'pmx-canvas ax approval resolve <id> --decision approved',
1953
+ 'pmx-canvas ax approval resolve <id> --decision rejected --resolution "too risky"',
1954
+ ], async (args) => {
1955
+ const { positional, flags } = parseFlags(args);
1956
+ if (flags.help || flags.h) return showCommandHelp('ax approval resolve');
1957
+
1958
+ const id = positional[0];
1959
+ if (!id) die('Missing approval gate ID', 'pmx-canvas ax approval resolve <id> --decision <approved|rejected>');
1960
+ const decision = requireFlag(flags, 'decision', 'pmx-canvas ax approval resolve <id> --decision <approved|rejected>');
1961
+ if (decision !== 'approved' && decision !== 'rejected') {
1962
+ die('Invalid decision', 'pmx-canvas ax approval resolve <id> --decision <approved|rejected>');
1963
+ }
1964
+ const resolution = getStringFlag(flags, 'resolution');
1965
+
1966
+ output(await api('POST', `/api/canvas/ax/approval/${encodeURIComponent(id)}/resolve`, {
1967
+ decision,
1968
+ ...(resolution ? { resolution } : {}),
1969
+ source: 'cli',
1970
+ }));
1971
+ });
1972
+
1973
+ cmd('ax approval list', 'List canvas-bound AX approval gates', [
1974
+ 'pmx-canvas ax approval list',
1975
+ ], async (args) => {
1976
+ const { flags } = parseFlags(args);
1977
+ if (flags.help || flags.h) return showCommandHelp('ax approval list');
1978
+
1979
+ output(await api('GET', '/api/canvas/ax/approval'));
1980
+ });
1981
+
1982
+ cmd('ax evidence add', 'Record an AX evidence item on the timeline', [
1983
+ 'pmx-canvas ax evidence add --kind test-output --title "unit pass" --body "..."',
1984
+ 'pmx-canvas ax evidence add --kind screenshot --title "before" --ref /tmp/before.png node1',
1985
+ ], async (args) => {
1986
+ const { positional, flags } = parseFlags(args);
1987
+ if (flags.help || flags.h) return showCommandHelp('ax evidence add');
1988
+
1989
+ const kind = requireFlag(flags, 'kind', 'pmx-canvas ax evidence add --kind <kind> --title <text>');
1990
+ const title = requireFlag(flags, 'title', 'pmx-canvas ax evidence add --kind <kind> --title <text>');
1991
+ const body = getStringFlag(flags, 'body');
1992
+ const ref = getStringFlag(flags, 'ref');
1993
+
1994
+ output(await api('POST', '/api/canvas/ax/evidence', {
1995
+ kind,
1996
+ title,
1997
+ ...(body ? { body } : {}),
1998
+ ...(ref ? { ref } : {}),
1999
+ ...(positional.length > 0 ? { nodeIds: positional } : {}),
2000
+ source: 'cli',
2001
+ }));
2002
+ });
2003
+
2004
+ cmd('ax review add', 'Add a canvas-bound AX review annotation', [
2005
+ 'pmx-canvas ax review add --body "needs a test" --node node1',
2006
+ 'pmx-canvas ax review add --body "off-by-one" --kind finding --severity error --file src/x.ts',
2007
+ ], async (args) => {
2008
+ const { flags } = parseFlags(args);
2009
+ if (flags.help || flags.h) return showCommandHelp('ax review add');
2010
+
2011
+ const body = requireFlag(flags, 'body', 'pmx-canvas ax review add --body <text>');
2012
+ const kind = getStringFlag(flags, 'kind');
2013
+ const severity = getStringFlag(flags, 'severity');
2014
+ const anchorType = getStringFlag(flags, 'anchor');
2015
+ const nodeId = getStringFlag(flags, 'node');
2016
+ const file = getStringFlag(flags, 'file');
2017
+ const author = getStringFlag(flags, 'author');
2018
+
2019
+ output(await api('POST', '/api/canvas/ax/review', {
2020
+ body,
2021
+ ...(kind ? { kind } : {}),
2022
+ ...(severity ? { severity } : {}),
2023
+ ...(anchorType ? { anchorType } : {}),
2024
+ ...(nodeId ? { nodeId } : {}),
2025
+ ...(file ? { file } : {}),
2026
+ ...(author ? { author } : {}),
2027
+ source: 'cli',
2028
+ }));
2029
+ });
2030
+
2031
+ cmd('ax review list', 'List canvas-bound AX review annotations', [
2032
+ 'pmx-canvas ax review list',
2033
+ ], async (args) => {
2034
+ const { flags } = parseFlags(args);
2035
+ if (flags.help || flags.h) return showCommandHelp('ax review list');
2036
+
2037
+ output(await api('GET', '/api/canvas/ax/review'));
2038
+ });
2039
+
2040
+ cmd('ax host report', 'Report host/session capability to the canvas', [
2041
+ 'pmx-canvas ax host report --host copilot --canvas --tools --session-messaging',
2042
+ 'pmx-canvas ax host report --host codex --canvas --files',
2043
+ ], async (args) => {
2044
+ const { flags } = parseFlags(args);
2045
+ if (flags.help || flags.h) return showCommandHelp('ax host report');
2046
+
2047
+ const host = getStringFlag(flags, 'host');
2048
+
2049
+ output(await api('PUT', '/api/canvas/ax/host-capability', {
2050
+ ...(host ? { host } : {}),
2051
+ canvas: flags.canvas === true,
2052
+ hooks: flags.hooks === true,
2053
+ tools: flags.tools === true,
2054
+ sessionMessaging: flags['session-messaging'] === true,
2055
+ permissions: flags.permissions === true,
2056
+ files: flags.files === true,
2057
+ uiPrompts: flags['ui-prompts'] === true,
2058
+ source: 'cli',
2059
+ }));
2060
+ });
2061
+
2062
+ cmd('ax host status', 'Read the reported host/session capability', [
2063
+ 'pmx-canvas ax host status',
2064
+ ], async (args) => {
2065
+ const { flags } = parseFlags(args);
2066
+ if (flags.help || flags.h) return showCommandHelp('ax host status');
2067
+
2068
+ output(await api('GET', '/api/canvas/ax/host-capability'));
2069
+ });
2070
+
2071
+ // ── copilot install-extension ────────────────────────────────
2072
+ cmd('copilot install-extension', 'Install the bundled GitHub Copilot extension adapter', [
2073
+ 'pmx-canvas copilot install-extension --dry-run',
2074
+ 'pmx-canvas copilot install-extension --target .github/extensions/pmx-canvas/extension.mjs --yes',
2075
+ ], async (args) => {
2076
+ const { flags } = parseFlags(args);
2077
+ if (flags.help || flags.h) return showCommandHelp('copilot install-extension');
2078
+
2079
+ const sourcePath = fileURLToPath(new URL('../../.github/extensions/pmx-canvas/extension.mjs', import.meta.url));
2080
+ if (!existsSync(sourcePath)) {
2081
+ die('Bundled Copilot extension adapter not found.', `Expected at ${sourcePath}`);
2082
+ }
2083
+
2084
+ const targetPath = getStringFlag(flags, 'target')
2085
+ ?? join(process.cwd(), '.github', 'extensions', 'pmx-canvas', 'extension.mjs');
2086
+ const dryRun = flags['dry-run'] === true;
2087
+ const targetExists = existsSync(targetPath);
2088
+
2089
+ if (dryRun) {
2090
+ output({ ok: true, dryRun: true, sourcePath, targetPath, targetExists, wrote: false });
2091
+ return;
2092
+ }
2093
+
2094
+ if (targetExists && flags.yes !== true) {
2095
+ die('Target already exists. Re-run with --yes to overwrite.', `Target: ${targetPath}`);
2096
+ }
2097
+
2098
+ mkdirSync(dirname(targetPath), { recursive: true });
2099
+ copyFileSync(sourcePath, targetPath);
2100
+ output({ ok: true, dryRun: false, sourcePath, targetPath, wrote: true });
2101
+ });
2102
+
1804
2103
  // ── undo ─────────────────────────────────────────────────────
1805
2104
  cmd('undo', 'Undo the last canvas mutation', [
1806
2105
  'pmx-canvas undo',
package/src/cli/index.ts CHANGED
@@ -32,7 +32,7 @@ if (args.includes('--version') || args.includes('-v')) {
32
32
  const AGENT_COMMANDS = new Set([
33
33
  'node', 'edge', 'json-render', 'search', 'layout', 'status', 'arrange', 'focus',
34
34
  'fit', 'screenshot', 'pin', 'ax', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
35
- 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'diagram', 'graph', 'html', 'batch', 'validate', 'serve',
35
+ 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'diagram', 'graph', 'html', 'batch', 'validate', 'serve', 'copilot',
36
36
  ]);
37
37
 
38
38
  const firstArg = args[0] ?? '';
@@ -563,6 +563,7 @@ Examples:
563
563
  pmx-canvas validate Check layout collisions
564
564
  pmx-canvas watch --events context-pin,move-end Watch semantic deltas
565
565
  pmx-canvas clear --dry-run Preview destructive op
566
+ pmx-canvas copilot install-extension --dry-run Preview Copilot adapter install
566
567
  `);
567
568
  process.exit(0);
568
569
  }
@@ -8,8 +8,12 @@ import {
8
8
  collapseExpandedNode,
9
9
  expandNode,
10
10
  expandedNodeId,
11
+ nodes,
12
+ persistLayout,
13
+ resizeNode,
11
14
  } from '../state/canvas-store';
12
15
  import type { CanvasNodeState } from '../types';
16
+ import { AUTO_FIT_TITLEBAR_HEIGHT } from '../canvas/auto-fit';
13
17
  import { useIframeDocument } from './iframe-document-url';
14
18
 
15
19
  type McpUiTheme = 'light' | 'dark';
@@ -120,6 +124,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
120
124
  const iframeRef = useRef<HTMLIFrameElement>(null);
121
125
  const bridgeRef = useRef<AppBridge | null>(null);
122
126
  const transportRef = useRef<PostMessageTransport | null>(null);
127
+ const sizePersistTimerRef = useRef<number | null>(null);
123
128
  const latestToolInputRef = useRef<Record<string, unknown>>({});
124
129
  const latestToolResultRef = useRef<CallToolResult | undefined>(undefined);
125
130
  const toolResultSentRef = useRef(false);
@@ -216,6 +221,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
216
221
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
217
222
  let hostContextResizeObserver: ResizeObserver | null = null;
218
223
  let hostContextRaf: number | null = null;
224
+ let readyNudgeRaf: number | null = null;
219
225
  toolResultSentRef.current = false;
220
226
  lastSentToolResultRef.current = undefined;
221
227
  toolResultSendingRef.current = null;
@@ -261,6 +267,24 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
261
267
  });
262
268
  };
263
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
+
264
288
  const bridge = new AppBridge(
265
289
  null,
266
290
  { name: 'PMX Canvas', version: '1.0.0' },
@@ -283,7 +307,20 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
283
307
  width: node.size.width,
284
308
  height: maxHeight,
285
309
  });
286
- iframe.style.height = `${resolveExtAppInlineFrameHeight(height, hostDimensions.height)}px`;
310
+ const inlineFrameHeight = resolveExtAppInlineFrameHeight(height, hostDimensions.height);
311
+ iframe.style.height = `${inlineFrameHeight}px`;
312
+ const currentSize = nodes.value.get(nodeId)?.size ?? node.size;
313
+ const nodeHeight = Math.max(currentSize.height, inlineFrameHeight + AUTO_FIT_TITLEBAR_HEIGHT);
314
+ if (Math.abs(nodeHeight - currentSize.height) > 8) {
315
+ resizeNode(nodeId, { width: currentSize.width, height: nodeHeight });
316
+ if (sizePersistTimerRef.current !== null) {
317
+ window.clearTimeout(sizePersistTimerRef.current);
318
+ }
319
+ sizePersistTimerRef.current = window.setTimeout(() => {
320
+ persistLayout({ recordHistory: false });
321
+ sizePersistTimerRef.current = null;
322
+ }, 0);
323
+ }
287
324
  }
288
325
  return {};
289
326
  };
@@ -386,6 +423,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
386
423
  void Promise.resolve(bridge.sendHostContextChange(buildHostContext(isExpanded ? 'fullscreen' : 'inline')))
387
424
  .then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined))
388
425
  .then(() => flushToolResult(bridge))
426
+ .then(() => nudgeHostContextAfterLayout())
389
427
  .catch((err) => {
390
428
  const msg = err instanceof Error ? err.message : String(err);
391
429
  setError(`Bridge bootstrap failed: ${msg}`);
@@ -410,6 +448,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
410
448
  }
411
449
  setStatus(bootstrapToolResult ? 'done' : 'ready');
412
450
  setError(null);
451
+ nudgeHostContextAfterLayout();
413
452
  })
414
453
  .catch((err) => {
415
454
  const msg = err instanceof Error ? err.message : String(err);
@@ -460,10 +499,18 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
460
499
  cancelAnimationFrame(hostContextRaf);
461
500
  hostContextRaf = null;
462
501
  }
502
+ if (readyNudgeRaf !== null) {
503
+ cancelAnimationFrame(readyNudgeRaf);
504
+ readyNudgeRaf = null;
505
+ }
463
506
  bridgeReadyRef.current = false;
464
507
  toolResultSendingRef.current = null;
465
508
  themeUnsubRef.current?.();
466
509
  themeUnsubRef.current = null;
510
+ if (sizePersistTimerRef.current !== null) {
511
+ window.clearTimeout(sizePersistTimerRef.current);
512
+ sizePersistTimerRef.current = null;
513
+ }
467
514
  bridgeRef.current = null;
468
515
  if (transportRef.current) {
469
516
  transportRef.current.close().catch((closeError) => {
@@ -2,12 +2,15 @@ import type { CanvasNodeState } from '../types';
2
2
  import { canvasTheme } from '../state/canvas-store';
3
3
  import { ExtAppFrame } from './ExtAppFrame';
4
4
 
5
- function withViewerParams(url: string, expanded: boolean): string {
5
+ function withViewerParams(url: string, expanded: boolean, specVersion?: number): string {
6
6
  if (!url) return url;
7
7
  try {
8
8
  const resolved = new URL(url, window.location.origin);
9
9
  resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
10
10
  if (expanded) resolved.searchParams.set('display', 'expanded');
11
+ // Streaming json-render nodes bump specVersion as patches accumulate; including
12
+ // it in the src reloads the iframe so it re-reads the latest accumulated spec.
13
+ if (typeof specVersion === 'number') resolved.searchParams.set('v', String(specVersion));
11
14
  return resolved.toString();
12
15
  } catch {
13
16
  return url;
@@ -31,7 +34,8 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
31
34
  return <ExtAppFrame node={node} expanded={expanded} />;
32
35
  }
33
36
 
34
- const url = withViewerParams((node.data.url as string) || '', expanded);
37
+ const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
38
+ const url = withViewerParams((node.data.url as string) || '', expanded, specVersion);
35
39
  const sourceServer = (node.data.sourceServer as string) || '';
36
40
  const hostMode = (node.data.hostMode as string) || 'hosted';
37
41
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -12,8 +12,11 @@ import { schema } from './schema.js';
12
12
  import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
13
13
  import { chartComponentDefinitions } from './charts/definitions';
14
14
  import { extraChartComponentDefinitions } from './charts/extra-definitions';
15
+ import { tufteChartComponentDefinitions } from './charts/tufte-definitions';
16
+ import { isDynamicPropValue } from './directives.js';
15
17
 
16
18
  const badgeDefinition = shadcnComponentDefinitions.Badge;
19
+ const buttonDefinition = shadcnComponentDefinitions.Button;
17
20
 
18
21
  export const allComponentDefinitions = {
19
22
  ...shadcnComponentDefinitions,
@@ -23,8 +26,18 @@ export const allComponentDefinitions = {
23
26
  variant: z.enum(['default', 'secondary', 'destructive', 'outline', 'success', 'info', 'warning', 'error', 'danger']).nullable(),
24
27
  }),
25
28
  },
29
+ Button: {
30
+ ...buttonDefinition,
31
+ props: buttonDefinition.props.extend({
32
+ variant: z.enum(['primary', 'secondary', 'destructive', 'danger', 'outline', 'ghost', 'success']).nullable(),
33
+ }),
34
+ description:
35
+ 'Clickable button. Bind on.press for handler. Choose variant by intent — at most ONE primary per view (the main call to action); use secondary for supporting actions; destructive (alias danger) for delete/remove/irreversible actions; outline/ghost for low-emphasis or toolbar actions; success to confirm a positive result. Omitting variant renders primary, so reserve the default for the single main action and set an explicit variant on every other button.',
36
+ example: { label: 'Save changes', variant: 'primary' },
37
+ },
26
38
  ...chartComponentDefinitions,
27
39
  ...extraChartComponentDefinitions,
40
+ ...tufteChartComponentDefinitions,
28
41
  };
29
42
 
30
43
  export const catalog = defineCatalog(schema as never, {
@@ -241,8 +254,9 @@ export function validateShadcnElementProps(spec: unknown): JsonRenderValidationR
241
254
  const definition = allComponentDefinitions[element.type as keyof typeof allComponentDefinitions];
242
255
  if (!definition || !hasSafeParse(definition.props)) continue;
243
256
 
257
+ const elementProps = asRecord(element.props) ?? {};
244
258
  const parsed = definition.props.safeParse(
245
- normalizePropsForSchema(definition.props, asRecord(element.props) ?? {}),
259
+ normalizePropsForSchema(definition.props, elementProps),
246
260
  );
247
261
  if (parsed.success) continue;
248
262
 
@@ -250,6 +264,13 @@ export function validateShadcnElementProps(spec: unknown): JsonRenderValidationR
250
264
  const issuePath = Array.isArray(issue.path)
251
265
  ? issue.path.map((segment) => (typeof segment === 'symbol' ? String(segment) : segment))
252
266
  : [];
267
+ // A prop holding a `$`-keyed dynamic expression ($state/$item/$format/…)
268
+ // resolves to its real type at render time, so the static prop schema
269
+ // cannot type-check it — drop issues that point at such a prop.
270
+ const propKey = issuePath[0];
271
+ if (typeof propKey === 'string' && isDynamicPropValue(elementProps[propKey])) {
272
+ continue;
273
+ }
253
274
  issues.push({
254
275
  path: ['elements', elementKey, 'props', ...issuePath],
255
276
  message: issue.message ?? 'invalid value',