pmx-canvas 0.7.2 → 0.7.3

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/Readme.md +25 -11
  3. package/dist/canvas/global.css +123 -40
  4. package/dist/canvas/index.js +84 -84
  5. package/dist/json-render/index.js +55 -55
  6. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  7. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  8. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  9. package/dist/types/client/state/canvas-store.d.ts +2 -0
  10. package/dist/types/client/state/intent-bridge.d.ts +1 -1
  11. package/dist/types/json-render/renderer/index.d.ts +2 -1
  12. package/dist/types/json-render/server.d.ts +2 -0
  13. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  14. package/docs/ax-host-adapter-contract.md +13 -2
  15. package/package.json +1 -1
  16. package/skills/pmx-canvas/SKILL.md +23 -11
  17. package/skills/pmx-canvas/evals/evals.json +69 -0
  18. package/skills/pmx-canvas/references/full-reference.md +3 -3
  19. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  20. package/skills/pmx-canvas-testing/SKILL.md +49 -0
  21. package/src/client/canvas/CanvasNode.tsx +200 -195
  22. package/src/client/canvas/CanvasViewport.tsx +6 -0
  23. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  24. package/src/client/canvas/CommandBar.tsx +25 -23
  25. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  26. package/src/client/canvas/ToolRail.tsx +46 -99
  27. package/src/client/nodes/McpAppNode.tsx +16 -0
  28. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  29. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  30. package/src/client/state/canvas-store.ts +3 -0
  31. package/src/client/state/intent-bridge.ts +14 -2
  32. package/src/client/theme/global.css +123 -40
  33. package/src/json-render/renderer/index.tsx +58 -38
  34. package/src/json-render/server.ts +8 -1
  35. package/src/server/server.ts +3 -1
  36. package/src/shared/ax-surface-protocol.ts +3 -0
@@ -572,9 +572,23 @@ body,
572
572
  box-shadow: 0 4px 24px var(--c-shadow);
573
573
  display: flex;
574
574
  flex-direction: column;
575
- overflow: hidden;
575
+ overflow: visible;
576
576
  transition: box-shadow 0.15s ease;
577
- contain: layout style paint;
577
+ contain: layout style;
578
+ }
579
+
580
+ /* Clip card content, not the connection handles centered on its border. */
581
+ .node-content {
582
+ display: flex;
583
+ flex-direction: column;
584
+ flex: 1;
585
+ min-height: 0;
586
+ border-radius: inherit;
587
+ overflow: hidden;
588
+ contain: paint;
589
+ }
590
+ .group-node > .node-content {
591
+ display: contents;
578
592
  }
579
593
 
580
594
  .canvas-node:hover {
@@ -610,34 +624,42 @@ body,
610
624
  /* ── Connection port handles (drag-to-connect) ───────────── */
611
625
  .node-port {
612
626
  position: absolute;
613
- width: 10px;
614
- height: 10px;
615
- border-radius: 50%;
616
- background: var(--c-accent);
617
- border: 2px solid var(--c-panel);
627
+ /* The visible dot stays compact; its grab area stays usable at every zoom. */
628
+ width: calc(32px * var(--node-port-scale, 1));
629
+ height: calc(32px * var(--node-port-scale, 1));
630
+ display: grid;
631
+ place-items: center;
618
632
  opacity: 0;
619
633
  cursor: crosshair;
620
634
  z-index: 10;
621
- transition: opacity 0.15s, transform 0.15s;
622
- transform: translate(-50%, -50%) scale(0.6);
635
+ touch-action: none;
636
+ transition: opacity 0.15s;
623
637
  pointer-events: auto;
624
638
  }
625
- .canvas-node:hover .node-port {
639
+ .node-port::after {
640
+ content: '';
641
+ width: calc(6px * var(--node-port-scale, 1));
642
+ height: calc(6px * var(--node-port-scale, 1));
643
+ border-radius: 50%;
644
+ background: var(--c-accent);
645
+ border: calc(2px * var(--node-port-scale, 1)) solid var(--c-panel);
646
+ box-shadow: 0 0 0 calc(1px * var(--node-port-scale, 1)) var(--c-accent);
647
+ }
648
+ .canvas-node:hover .node-port,
649
+ .canvas-node.selected .node-port,
650
+ .canvas-node:focus-within .node-port {
626
651
  opacity: 1;
627
- transform: translate(-50%, -50%) scale(1);
628
652
  }
629
- .node-port:hover {
653
+ .node-port:hover::after {
630
654
  background: var(--c-accent-hover);
631
- transform: translate(-50%, -50%) scale(1.3);
632
655
  }
633
- .node-port-top { top: 0; left: 50%; }
634
- .node-port-bottom { bottom: 0; left: 50%; transform: translate(-50%, 50%) scale(0.6); }
635
- .node-port-left { left: 0; top: 50%; }
636
- .node-port-right { right: 0; top: 50%; transform: translate(50%, -50%) scale(0.6); }
637
- .canvas-node:hover .node-port-bottom { transform: translate(-50%, 50%) scale(1); }
638
- .canvas-node:hover .node-port-right { transform: translate(50%, -50%) scale(1); }
639
- .node-port-bottom:hover { transform: translate(-50%, 50%) scale(1.3); }
640
- .node-port-right:hover { transform: translate(50%, -50%) scale(1.3); }
656
+ /* Bias vertical grab areas outward so they do not steal title/footer clicks. */
657
+ .node-port-top { top: 0; left: 50%; transform: translate(-50%, -75%); }
658
+ .node-port-bottom { bottom: 0; left: 50%; transform: translate(-50%, 75%); }
659
+ .node-port-top::after { transform: translateY(calc(8px * var(--node-port-scale, 1))); }
660
+ .node-port-bottom::after { transform: translateY(calc(-8px * var(--node-port-scale, 1))); }
661
+ .node-port-left { left: 0; top: 50%; transform: translate(-50%, -50%); }
662
+ .node-port-right { right: 0; top: 50%; transform: translate(50%, -50%); }
641
663
 
642
664
  .canvas-node .node-titlebar {
643
665
  display: flex;
@@ -2001,6 +2023,7 @@ body,
2001
2023
  color: var(--c-dim);
2002
2024
  }
2003
2025
  .command-bar-composer {
2026
+ position: relative;
2004
2027
  display: flex;
2005
2028
  align-items: center;
2006
2029
  gap: 10px;
@@ -2012,24 +2035,72 @@ body,
2012
2035
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
2013
2036
  }
2014
2037
  .command-bar-target {
2015
- appearance: none;
2016
- -webkit-appearance: none;
2017
2038
  max-width: 148px;
2018
- padding: 4px 18px 4px 8px;
2019
- border: 1px solid var(--c-line);
2020
- border-radius: 7px;
2021
- background: color-mix(in srgb, var(--c-panel) 80%, transparent)
2022
- url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath d='M1 1l3 3 3-3' fill='none' stroke='%237a8699' stroke-width='1.4'/%3E%3C/svg%3E")
2023
- no-repeat right 6px center;
2024
- color: var(--c-text-soft);
2025
- font: 12px/1.3 var(--mono);
2026
- cursor: pointer;
2027
2039
  flex-shrink: 0;
2028
2040
  }
2029
- .command-bar-target:hover,
2030
- .command-bar-target:focus-visible {
2031
- border-color: var(--c-accent-25);
2041
+ .choice-menu {
2042
+ position: relative;
2043
+ min-width: 0;
2044
+ }
2045
+ .choice-menu-trigger {
2046
+ display: flex;
2047
+ align-items: center;
2048
+ justify-content: space-between;
2049
+ gap: 10px;
2050
+ width: 100%;
2051
+ min-height: 36px;
2052
+ padding: 8px 10px;
2053
+ border: 1px solid color-mix(in srgb, var(--c-line) 65%, var(--c-muted));
2054
+ border-radius: 8px;
2055
+ background: var(--c-panel-overlay);
2032
2056
  color: var(--c-text);
2057
+ font: 12px/1.35 var(--font);
2058
+ cursor: pointer;
2059
+ }
2060
+ .choice-menu-trigger > span {
2061
+ overflow: hidden;
2062
+ text-overflow: ellipsis;
2063
+ white-space: nowrap;
2064
+ }
2065
+ .choice-menu-trigger svg { flex-shrink: 0; }
2066
+ .choice-menu-trigger:hover,
2067
+ .choice-menu-trigger[aria-expanded='true'] { border-color: var(--c-accent); }
2068
+ .choice-menu-trigger:focus-visible,
2069
+ .choice-menu-options button:focus-visible {
2070
+ outline: 2px solid var(--c-accent);
2071
+ outline-offset: 2px;
2072
+ }
2073
+ .toolbar-menu.choice-menu-options {
2074
+ top: calc(100% + 6px);
2075
+ left: 0;
2076
+ right: auto;
2077
+ min-width: 100%;
2078
+ width: max-content;
2079
+ max-width: min(320px, calc(100vw - 64px));
2080
+ max-height: min(280px, 40vh);
2081
+ overflow-y: auto;
2082
+ z-index: 20;
2083
+ }
2084
+ .toolbar-menu.choice-menu-options.opens-above {
2085
+ top: auto;
2086
+ bottom: calc(100% + 6px);
2087
+ }
2088
+ .command-bar-target.choice-menu {
2089
+ position: static;
2090
+ }
2091
+ .command-bar-target .choice-menu-options {
2092
+ /* Anchor to the composer, not its inset trigger, to clear the session rail. */
2093
+ min-width: min(200px, 100%);
2094
+ max-width: 100%;
2095
+ }
2096
+ .choice-menu-options .toolbar-menu-item {
2097
+ min-height: 36px;
2098
+ white-space: normal;
2099
+ overflow-wrap: anywhere;
2100
+ }
2101
+ .choice-menu-options button:disabled {
2102
+ opacity: 0.5;
2103
+ cursor: not-allowed;
2033
2104
  }
2034
2105
 
2035
2106
  .command-bar-icon {
@@ -4498,13 +4569,24 @@ html.is-node-resizing .ext-app-preview-catcher {
4498
4569
  }
4499
4570
  .text-prompt-cancel,
4500
4571
  .text-prompt-confirm {
4501
- padding: 5px 12px;
4572
+ min-height: 36px;
4573
+ padding: 8px 14px;
4502
4574
  border-radius: 7px;
4503
- border: 1px solid var(--c-line);
4504
- background: none;
4505
- color: var(--c-text-soft);
4575
+ border: 1px solid color-mix(in srgb, var(--c-line) 65%, var(--c-muted));
4576
+ background: var(--c-panel-overlay);
4577
+ color: var(--c-text);
4506
4578
  cursor: pointer;
4507
4579
  font-size: 12px;
4580
+ font-weight: 600;
4581
+ }
4582
+ .text-prompt-cancel:hover {
4583
+ background: var(--c-accent-10);
4584
+ border-color: var(--c-muted);
4585
+ }
4586
+ .text-prompt-cancel:focus-visible,
4587
+ .text-prompt-confirm:focus-visible {
4588
+ outline: 2px solid var(--c-accent);
4589
+ outline-offset: 2px;
4508
4590
  }
4509
4591
  .text-prompt-confirm {
4510
4592
  background: var(--c-accent);
@@ -4524,7 +4606,8 @@ html.is-node-resizing .ext-app-preview-catcher {
4524
4606
  overflow-y: auto;
4525
4607
  color: var(--c-text);
4526
4608
  }
4527
- .feedback-dialog label {
4609
+ .feedback-dialog label,
4610
+ .feedback-field {
4528
4611
  display: flex;
4529
4612
  flex-direction: column;
4530
4613
  gap: 6px;
@@ -7,11 +7,14 @@
7
7
  * from an inline global injected by the server-side viewer route.
8
8
  */
9
9
 
10
- import type { Spec } from '@json-render/core';
11
- import { AX_SURFACE_EMIT_SOURCE, HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
12
- import { useEffect } from 'react';
10
+ import { createStateStore, type Spec, type StateStore } from '@json-render/core';
11
+ import {
12
+ AX_SURFACE_EMIT_SOURCE,
13
+ HTML_SURFACE_PUSH_SOURCE,
14
+ JSON_VIEWER_STATE_SOURCE,
15
+ } from '../../shared/ax-surface-protocol.js';
13
16
  import { createRoot } from 'react-dom/client';
14
- import { defineRegistry, JSONUIProvider, Renderer, useStateBinding } from '@json-render/react';
17
+ import { defineRegistry, JSONUIProvider, Renderer } from '@json-render/react';
15
18
  import { shadcnComponents } from '@json-render/shadcn';
16
19
  import { catalog } from '../catalog';
17
20
  import { chartComponents } from '../charts/components';
@@ -106,29 +109,10 @@ declare global {
106
109
  __PMX_CANVAS_JSON_RENDER_NODE_ID__?: string;
107
110
  __PMX_CANVAS_AX_TOKEN__?: string;
108
111
  __PMX_CANVAS_AX_STATE__?: unknown;
112
+ __PMX_CANVAS_UI_STATE_TOKEN__?: string;
109
113
  }
110
114
  }
111
115
 
112
- // Read-side AX bridge for json-render: keeps the spec-bound `/ax` state live as
113
- // the parent canvas pushes nonce-validated `ax-update` messages, so a declarative
114
- // board ({ "$state": "/ax/workItems" }) reflects the work queue in real time.
115
- function AxStateSync() {
116
- const [, setAx] = useStateBinding<unknown>('ax');
117
- useEffect(() => {
118
- const token = window.__PMX_CANVAS_AX_TOKEN__;
119
- if (!token) return undefined;
120
- function onMessage(event: MessageEvent) {
121
- const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
122
- if (!m || m.source !== HTML_SURFACE_PUSH_SOURCE || m.type !== 'ax-update' || m.token !== token) return;
123
- window.__PMX_CANVAS_AX_STATE__ = m.state;
124
- setAx(m.state);
125
- }
126
- window.addEventListener('message', onMessage);
127
- return () => window.removeEventListener('message', onMessage);
128
- }, [setAx]);
129
- return null;
130
- }
131
-
132
116
  // AX interaction types a json-render spec can bind actions to. When an action
133
117
  // named like one of these fires, we forward it to the parent canvas (which
134
118
  // validates + submits through the capability-gated endpoint). Convention-based
@@ -188,7 +172,7 @@ function applyTheme(theme: unknown): void {
188
172
  document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark';
189
173
  }
190
174
 
191
- function App() {
175
+ function App({ store }: { store: StateStore }) {
192
176
  const spec = window.__PMX_CANVAS_JSON_RENDER_SPEC__;
193
177
 
194
178
  if (!spec) {
@@ -208,11 +192,6 @@ function App() {
208
192
  );
209
193
  }
210
194
 
211
- // Seed AX state under a reserved `/ax` key so specs can bind { "$state": "/ax/workItems" }.
212
- const axState = window.__PMX_CANVAS_AX_STATE__;
213
- const initialState =
214
- axState !== undefined && axState !== null ? { ...(spec.state ?? {}), ax: axState } : (spec.state ?? undefined);
215
-
216
195
  // Standalone "Open as site" tab (#65): fill the browser viewport instead of the
217
196
  // in-canvas card height. The chart child flex-grows; useChartFrameHeight measures
218
197
  // the full viewport in this mode. Embedded/expanded keep the padded min-height box.
@@ -229,13 +208,7 @@ function App() {
229
208
  : { minHeight: '100vh', padding: 16, boxSizing: 'border-box' as const };
230
209
  return (
231
210
  <div style={containerStyle}>
232
- <JSONUIProvider
233
- registry={registry}
234
- initialState={initialState}
235
- directives={pmxCanvasDirectives}
236
- handlers={buildAxHandlers()}
237
- >
238
- <AxStateSync />
211
+ <JSONUIProvider registry={registry} store={store} directives={pmxCanvasDirectives} handlers={buildAxHandlers()}>
239
212
  <div style={isSite ? { flex: 1, minHeight: 0 } : undefined}>
240
213
  <Renderer spec={spec} registry={registry} loading={false} />
241
214
  </div>
@@ -248,5 +221,52 @@ function App() {
248
221
  const root = document.getElementById('root');
249
222
  if (root) {
250
223
  syncPreferredTheme();
251
- createRoot(root).render(<App />);
224
+ const stateToken = window.__PMX_CANVAS_UI_STATE_TOKEN__;
225
+ let store: StateStore | undefined;
226
+ const mount = (restored: Record<string, unknown> | null) => {
227
+ if (store) return;
228
+ const seed = restored ?? window.__PMX_CANVAS_JSON_RENDER_SPEC__?.state ?? {};
229
+ const ax = window.__PMX_CANVAS_AX_STATE__;
230
+ store = createStateStore(ax != null ? { ...seed, ax } : seed);
231
+ if (stateToken) {
232
+ // subscribe fires synchronously at the input/action write, not in a React
233
+ // effect that can be lost on unmount. Never mirror the server-owned /ax.
234
+ store.subscribe(() => {
235
+ const { ax: _ax, ...state } = store!.getSnapshot();
236
+ window.parent.postMessage(
237
+ {
238
+ source: JSON_VIEWER_STATE_SOURCE,
239
+ type: 'snapshot',
240
+ token: stateToken,
241
+ nodeId: window.__PMX_CANVAS_JSON_RENDER_NODE_ID__,
242
+ state,
243
+ },
244
+ '*',
245
+ );
246
+ });
247
+ }
248
+ createRoot(root).render(<App store={store} />);
249
+ };
250
+ // Install before document load. The parent supplies a snapshot (or null) in
251
+ // onLoad; restore BEFORE mounting so watchers don't replay past user actions.
252
+ // Standalone viewers have no state token and render immediately.
253
+ window.addEventListener('message', (event: MessageEvent) => {
254
+ if (event.source !== window.parent) return;
255
+ const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
256
+ if (!m) return;
257
+ if (m.source === JSON_VIEWER_STATE_SOURCE && m.type === 'restore' && stateToken && m.token === stateToken) {
258
+ if (m.state === null || (typeof m.state === 'object' && !Array.isArray(m.state)))
259
+ mount(m.state as Record<string, unknown> | null);
260
+ }
261
+ if (
262
+ m.source === HTML_SURFACE_PUSH_SOURCE &&
263
+ m.type === 'ax-update' &&
264
+ window.__PMX_CANVAS_AX_TOKEN__ &&
265
+ m.token === window.__PMX_CANVAS_AX_TOKEN__
266
+ ) {
267
+ window.__PMX_CANVAS_AX_STATE__ = m.state;
268
+ store?.set('/ax', m.state);
269
+ }
270
+ });
271
+ if (!stateToken) mount(null);
252
272
  }
@@ -979,6 +979,8 @@ export async function buildJsonRenderViewerHtml(options: {
979
979
  nodeId?: string;
980
980
  axToken?: string;
981
981
  axState?: unknown;
982
+ /** Independent nonce for tab-local runtime state across focus transitions. */
983
+ stateToken?: string;
982
984
  /** Nonce for the content-height reporter so the node can grow to fit the chart. */
983
985
  frameToken?: string;
984
986
  /** When true, charts render at their natural (intrinsic) height instead of
@@ -995,9 +997,14 @@ export async function buildJsonRenderViewerHtml(options: {
995
997
  ...(options.theme ? [`window.__PMX_CANVAS_JSON_RENDER_THEME__ = ${JSON.stringify(options.theme)};`] : []),
996
998
  ...(options.display ? [`window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ = ${JSON.stringify(options.display)};`] : []),
997
999
  ...(options.devtools ? ['window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ = true;'] : []),
1000
+ ...(options.nodeId
1001
+ ? [`window.__PMX_CANVAS_JSON_RENDER_NODE_ID__ = ${JSON.stringify(sanitizeAxValue(options.nodeId))};`]
1002
+ : []),
1003
+ ...(options.stateToken
1004
+ ? [`window.__PMX_CANVAS_UI_STATE_TOKEN__ = ${JSON.stringify(sanitizeAxValue(options.stateToken))};`]
1005
+ : []),
998
1006
  ...(options.nodeId && options.axToken
999
1007
  ? [
1000
- `window.__PMX_CANVAS_JSON_RENDER_NODE_ID__ = ${JSON.stringify(sanitizeAxValue(options.nodeId))};`,
1001
1008
  `window.__PMX_CANVAS_AX_TOKEN__ = ${JSON.stringify(sanitizeAxValue(options.axToken))};`,
1002
1009
  // Read-side AX state: seed for initial render + bound under /ax for specs.
1003
1010
  `window.__PMX_CANVAS_AX_STATE__ = ${JSON.stringify(options.axState ?? null).replace(/</g, '\\u003c')};`,
@@ -1737,10 +1737,12 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
1737
1737
  const html = await buildJsonRenderViewerHtml({
1738
1738
  title,
1739
1739
  spec,
1740
+ nodeId,
1741
+ stateToken: url.searchParams.get('stateToken') ?? undefined,
1740
1742
  ...(theme ? { theme } : {}),
1741
1743
  ...(display ? { display } : {}),
1742
1744
  ...(devtoolsEnabled ? { devtools: true } : {}),
1743
- ...(axToken ? { nodeId, axToken } : {}),
1745
+ ...(axToken ? { axToken } : {}),
1744
1746
  // Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
1745
1747
  ...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
1746
1748
  // Content-fit: report natural height (charts render intrinsic) so the node grows.
@@ -18,6 +18,9 @@ export const AX_SURFACE_ACK_SOURCE = 'pmx-canvas-ax-ack';
18
18
  /** Parent→surface pushes (theme updates, live AX state) into html/viewer iframes. */
19
19
  export const HTML_SURFACE_PUSH_SOURCE = 'pmx-canvas-html-node';
20
20
 
21
+ /** Tab-local JSON viewer state handoff, independent of AX capabilities. */
22
+ export const JSON_VIEWER_STATE_SOURCE = 'pmx-canvas-json-state';
23
+
21
24
  /** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
22
25
  export const EXT_APP_BOOT_BEACON_SOURCE = 'pmx-canvas-ext-app-alive';
23
26