pmx-canvas 0.2.1 → 0.2.2

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 (45) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +260 -0
  4. package/dist/canvas/index.js +76 -76
  5. package/dist/json-render/index.js +2 -2
  6. package/dist/types/client/canvas/IntentLayer.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +10 -0
  8. package/dist/types/client/state/intent-store.d.ts +25 -0
  9. package/dist/types/json-render/server.d.ts +1 -1
  10. package/dist/types/server/index.d.ts +34 -4
  11. package/dist/types/server/intent-registry.d.ts +45 -0
  12. package/dist/types/server/operations/ops/intent.d.ts +2 -0
  13. package/dist/types/shared/ax-intent.d.ts +58 -0
  14. package/docs/mcp.md +21 -2
  15. package/docs/screenshot.png +0 -0
  16. package/package.json +1 -1
  17. package/skills/pmx-canvas/SKILL.md +197 -1305
  18. package/skills/pmx-canvas/evals/evals.json +199 -0
  19. package/skills/pmx-canvas/references/full-reference.md +1441 -0
  20. package/src/cli/index.ts +21 -4
  21. package/src/client/canvas/CanvasNode.tsx +13 -13
  22. package/src/client/canvas/CanvasViewport.tsx +2 -0
  23. package/src/client/canvas/ContextMenu.tsx +25 -19
  24. package/src/client/canvas/IntentLayer.tsx +278 -0
  25. package/src/client/nodes/ExtAppFrame.tsx +31 -22
  26. package/src/client/state/intent-bridge.ts +31 -0
  27. package/src/client/state/intent-store.ts +107 -0
  28. package/src/client/state/sse-bridge.ts +31 -0
  29. package/src/client/theme/global.css +260 -0
  30. package/src/json-render/charts/components.tsx +18 -4
  31. package/src/json-render/renderer/index.tsx +11 -2
  32. package/src/json-render/server.ts +1 -1
  33. package/src/server/index.ts +240 -158
  34. package/src/server/intent-registry.ts +324 -0
  35. package/src/server/operations/composites.ts +11 -0
  36. package/src/server/operations/index.ts +2 -0
  37. package/src/server/operations/ops/edges.ts +1 -0
  38. package/src/server/operations/ops/groups.ts +3 -0
  39. package/src/server/operations/ops/intent.ts +132 -0
  40. package/src/server/operations/ops/json-render.ts +3 -0
  41. package/src/server/operations/ops/nodes.ts +3 -0
  42. package/src/server/operations/registry.ts +68 -3
  43. package/src/server/server.ts +40 -12
  44. package/src/shared/ax-intent.ts +64 -0
  45. package/src/shared/surface.ts +5 -1
@@ -28,6 +28,8 @@ import {
28
28
  import { fetchAxSurfaceState } from './intent-bridge';
29
29
  import { invalidateTokenCache } from '../theme/tokens';
30
30
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
31
+ import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
32
+ import type { PmxAxIntent } from '../../shared/ax-intent.js';
31
33
 
32
34
  let eventSource: EventSource | null = null;
33
35
  let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
@@ -941,6 +943,32 @@ function handleAxStateChanged(): void {
941
943
  }, 150);
942
944
  }
943
945
 
946
+ // ── Ghost Cursor of Intent ────────────────────────────────────
947
+ function handleAxIntent(data: Record<string, unknown>): void {
948
+ const intent = data.intent as PmxAxIntent | undefined;
949
+ // Require a numeric `expiresAt`: the client-side TTL prune backstop
950
+ // (intent-store) compares `expiresAt <= now`, so a frame missing it would never
951
+ // be pruned if its `clear` frame were dropped. The server always sets it, so this
952
+ // only rejects a malformed frame — keeping the backstop's guarantee real.
953
+ if (
954
+ !intent
955
+ || typeof intent.id !== 'string'
956
+ || typeof intent.kind !== 'string'
957
+ || typeof intent.expiresAt !== 'number'
958
+ ) return;
959
+ upsertIntent(intent);
960
+ }
961
+
962
+ function handleAxIntentClear(data: Record<string, unknown>): void {
963
+ const id = typeof data.id === 'string' ? data.id : '';
964
+ if (!id) return;
965
+ if (data.settled === true) {
966
+ settleIntent(id, typeof data.nodeId === 'string' ? data.nodeId : undefined);
967
+ } else {
968
+ dissolveIntent(id);
969
+ }
970
+ }
971
+
944
972
  // ── SSE connection ────────────────────────────────────────────
945
973
  /** @internal — exported for testing */
946
974
  export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => void> = {
@@ -976,6 +1004,8 @@ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => v
976
1004
  'canvas-response-complete': handleCanvasResponseComplete,
977
1005
  'ax-state-changed': handleAxStateChanged,
978
1006
  'ax-event-created': handleAxStateChanged,
1007
+ 'ax-intent': handleAxIntent,
1008
+ 'ax-intent-clear': handleAxIntentClear,
979
1009
  };
980
1010
 
981
1011
  export function connectSSE(): () => void {
@@ -983,6 +1013,7 @@ export function connectSSE(): () => void {
983
1013
  ensureStatusNode();
984
1014
  hasInitialServerLayout.value = false;
985
1015
  resetAttentionBridge();
1016
+ resetIntents();
986
1017
  if (reconnectTimer) {
987
1018
  clearTimeout(reconnectTimer);
988
1019
  reconnectTimer = null;
@@ -3484,3 +3484,263 @@ button.welcome-hint:hover {
3484
3484
  .image-node-zoom-reset:hover {
3485
3485
  background: var(--c-surface-hover);
3486
3486
  }
3487
+
3488
+ /* ── Ghost Cursor of Intent ─────────────────────────────────────
3489
+ Pre-commit presence: faint placeholders for the move the agent is about to
3490
+ make. Lives inside the canvas world transform (positions are world coords).
3491
+ The layer sits above nodes so remove/edit overlays read on top of their
3492
+ target, but is pointer-transparent except the info card + veto control. */
3493
+ .intent-layer {
3494
+ display: contents;
3495
+ }
3496
+
3497
+ .intent-ghost {
3498
+ position: absolute;
3499
+ pointer-events: none;
3500
+ z-index: 100000;
3501
+ transition:
3502
+ left 480ms ease,
3503
+ top 480ms ease,
3504
+ width 480ms ease,
3505
+ height 480ms ease,
3506
+ opacity 180ms ease;
3507
+ }
3508
+
3509
+ /* create / move destination — a dashed ghost node */
3510
+ .intent-ghost-box {
3511
+ border: 1.5px dashed var(--c-accent);
3512
+ border-radius: 10px;
3513
+ background: var(--c-accent-10);
3514
+ box-shadow: 0 0 0 1px var(--c-accent-10), 0 6px 22px var(--c-accent-10);
3515
+ animation: intent-breathe 2.1s ease-in-out infinite;
3516
+ overflow: visible;
3517
+ }
3518
+
3519
+ .intent-ghost-titlebar {
3520
+ display: flex;
3521
+ align-items: center;
3522
+ gap: 6px;
3523
+ padding: 8px 10px;
3524
+ color: var(--c-accent);
3525
+ }
3526
+
3527
+ .intent-ghost-icon {
3528
+ display: inline-flex;
3529
+ align-items: center;
3530
+ color: var(--c-accent);
3531
+ }
3532
+
3533
+ .intent-ghost-badge {
3534
+ font-size: 11px;
3535
+ font-weight: 600;
3536
+ letter-spacing: 0.02em;
3537
+ text-transform: uppercase;
3538
+ color: var(--c-accent);
3539
+ opacity: 0.85;
3540
+ }
3541
+
3542
+ @keyframes intent-breathe {
3543
+ 0%, 100% { box-shadow: 0 0 0 1px var(--c-accent-10), 0 6px 22px var(--c-accent-10); }
3544
+ 50% { box-shadow: 0 0 0 1px var(--c-accent-25), 0 8px 30px var(--c-accent-25); }
3545
+ }
3546
+
3547
+ /* remove — a red crosshatch tombstone over the target */
3548
+ .intent-ghost-remove {
3549
+ border: 1.5px dashed var(--c-danger);
3550
+ border-radius: 10px;
3551
+ background-color: color-mix(in srgb, var(--c-danger) 8%, transparent);
3552
+ background-image: repeating-linear-gradient(
3553
+ 45deg,
3554
+ color-mix(in srgb, var(--c-danger) 22%, transparent) 0,
3555
+ color-mix(in srgb, var(--c-danger) 22%, transparent) 2px,
3556
+ transparent 2px,
3557
+ transparent 9px
3558
+ );
3559
+ }
3560
+
3561
+ /* edit — a shimmer bar over the target */
3562
+ .intent-ghost-edit {
3563
+ border: 1.5px dashed var(--c-accent);
3564
+ border-radius: 10px;
3565
+ background: var(--c-accent-10);
3566
+ overflow: visible;
3567
+ }
3568
+
3569
+ .intent-edit-bar {
3570
+ position: absolute;
3571
+ top: 0;
3572
+ left: 0;
3573
+ height: 3px;
3574
+ width: 100%;
3575
+ background: linear-gradient(90deg, transparent, var(--c-accent), transparent);
3576
+ animation: response-stream-pulse 1.5s ease-in-out infinite;
3577
+ }
3578
+
3579
+ /* connect — info card anchored at the bezier midpoint */
3580
+ .intent-ghost-connect {
3581
+ display: flex;
3582
+ justify-content: center;
3583
+ }
3584
+
3585
+ /* the info treatment: label + confidence chip, reason, seq, veto */
3586
+ .intent-info {
3587
+ position: absolute;
3588
+ top: 100%;
3589
+ left: 0;
3590
+ margin-top: 6px;
3591
+ display: flex;
3592
+ flex-direction: column;
3593
+ gap: 4px;
3594
+ pointer-events: auto;
3595
+ max-width: 280px;
3596
+ }
3597
+
3598
+ .intent-ghost-connect .intent-info {
3599
+ position: static;
3600
+ margin-top: 0;
3601
+ align-items: center;
3602
+ }
3603
+
3604
+ .intent-chip {
3605
+ display: inline-flex;
3606
+ align-items: center;
3607
+ gap: 6px;
3608
+ align-self: flex-start;
3609
+ padding: 3px 6px 3px 8px;
3610
+ border-radius: 999px;
3611
+ background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
3612
+ backdrop-filter: blur(10px);
3613
+ border: 1px solid var(--c-accent-25);
3614
+ box-shadow: 0 4px 14px var(--c-shadow);
3615
+ color: var(--c-text);
3616
+ font-size: 12px;
3617
+ line-height: 1.2;
3618
+ }
3619
+
3620
+ .intent-seq {
3621
+ display: inline-flex;
3622
+ align-items: center;
3623
+ justify-content: center;
3624
+ min-width: 16px;
3625
+ height: 16px;
3626
+ padding: 0 4px;
3627
+ border-radius: 999px;
3628
+ background: var(--c-accent);
3629
+ color: var(--c-bg);
3630
+ font-size: 10px;
3631
+ font-weight: 700;
3632
+ }
3633
+
3634
+ .intent-chip-icon {
3635
+ display: inline-flex;
3636
+ align-items: center;
3637
+ color: var(--c-accent);
3638
+ }
3639
+
3640
+ .intent-chip-label {
3641
+ font-weight: 600;
3642
+ white-space: nowrap;
3643
+ overflow: hidden;
3644
+ text-overflow: ellipsis;
3645
+ max-width: 180px;
3646
+ }
3647
+
3648
+ .intent-confidence {
3649
+ font-size: 10px;
3650
+ font-variant-numeric: tabular-nums;
3651
+ color: var(--c-muted);
3652
+ }
3653
+
3654
+ .intent-veto {
3655
+ display: inline-flex;
3656
+ align-items: center;
3657
+ justify-content: center;
3658
+ width: 16px;
3659
+ height: 16px;
3660
+ margin-left: 2px;
3661
+ padding: 0;
3662
+ border: none;
3663
+ border-radius: 999px;
3664
+ background: transparent;
3665
+ color: var(--c-muted);
3666
+ font-size: 11px;
3667
+ cursor: pointer;
3668
+ transition: background 120ms ease, color 120ms ease;
3669
+ }
3670
+
3671
+ .intent-veto:hover {
3672
+ background: color-mix(in srgb, var(--c-danger) 18%, transparent);
3673
+ color: var(--c-danger);
3674
+ }
3675
+
3676
+ .intent-reason {
3677
+ align-self: flex-start;
3678
+ padding: 3px 8px;
3679
+ border-radius: 7px;
3680
+ background: color-mix(in srgb, var(--c-panel-glass) 92%, transparent);
3681
+ border: 1px solid var(--c-line);
3682
+ color: var(--c-muted);
3683
+ font-size: 11px;
3684
+ line-height: 1.35;
3685
+ }
3686
+
3687
+ /* connect bezier + move trail (SVG) */
3688
+ .intent-line-layer path {
3689
+ fill: none;
3690
+ }
3691
+
3692
+ .intent-line-layer {
3693
+ z-index: 99999;
3694
+ }
3695
+
3696
+ .intent-edge {
3697
+ stroke-width: 2;
3698
+ stroke-dasharray: 6 5;
3699
+ animation: intent-dash 0.9s linear infinite;
3700
+ }
3701
+
3702
+ .intent-edge.type-flow { stroke: var(--c-accent); }
3703
+ .intent-edge.type-depends-on { stroke: var(--c-warn); }
3704
+ .intent-edge.type-relation { stroke: var(--c-muted); }
3705
+ .intent-edge.type-references { stroke: var(--c-dim); }
3706
+
3707
+ .intent-trail {
3708
+ stroke: var(--c-accent);
3709
+ stroke-width: 2;
3710
+ stroke-dasharray: 5 5;
3711
+ animation: intent-dash 0.9s linear infinite;
3712
+ }
3713
+
3714
+ .intent-arrow-head {
3715
+ fill: var(--c-accent);
3716
+ }
3717
+
3718
+ @keyframes intent-dash {
3719
+ to { stroke-dashoffset: -22; }
3720
+ }
3721
+
3722
+ /* settle — the ghost becomes real, then clears */
3723
+ .intent-ghost.is-settling {
3724
+ animation: intent-settle 480ms ease forwards;
3725
+ }
3726
+
3727
+ @keyframes intent-settle {
3728
+ 0% { transform: scale(1); }
3729
+ 45% { transform: scale(1.04); border-style: solid; opacity: 1; }
3730
+ 100% { transform: scale(1); opacity: 0; }
3731
+ }
3732
+
3733
+ /* dissolve — abandoned / vetoed / expired */
3734
+ .intent-ghost.is-dissolving {
3735
+ animation: intent-dissolve 320ms ease forwards;
3736
+ }
3737
+
3738
+ @keyframes intent-dissolve {
3739
+ to { transform: scale(0.96); opacity: 0; filter: blur(2px); }
3740
+ }
3741
+
3742
+ /* keep ghosts calm while a node is being dragged */
3743
+ html.is-node-dragging .intent-ghost,
3744
+ html.is-node-dragging .intent-line-layer {
3745
+ opacity: 0.5;
3746
+ }
@@ -118,6 +118,11 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
118
118
  const [autoHeight, setAutoHeight] = useState(fallbackHeight);
119
119
  const [autoWidth, setAutoWidth] = useState(0);
120
120
 
121
+ // Standalone "Open as site" tab (#65): fill the full browser viewport — there is no
122
+ // card chrome below the chart, so drop the ~44px reserve and use a larger floor.
123
+ const isSite = typeof window !== 'undefined'
124
+ && (window as { __PMX_CANVAS_JSON_RENDER_DISPLAY__?: string }).__PMX_CANVAS_JSON_RENDER_DISPLAY__ === 'site';
125
+
121
126
  useEffect(() => {
122
127
  const frame = frameRef.current;
123
128
  if (!frame) return;
@@ -136,7 +141,11 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
136
141
  // across node sizes). rect.top already accounts for everything above. With
137
142
  // too small a reserve a filled chart spills ~17px past the viewport and the
138
143
  // iframe document shows a needless scrollbar.
139
- const available = Math.max(220, Math.round(window.innerHeight - rect.top - 44));
144
+ // Keep the ~44px reserve in BOTH modes — it covers the chart frame's own
145
+ // non-plot chrome (title + .pmx-chart padding), which exists in site mode too.
146
+ // Dropping it pushed the frame past the viewport and reintroduced a scrollbar.
147
+ // Site mode differs only in the floor (300 vs 220) and the fill selection below.
148
+ const available = Math.max(isSite ? 300 : 220, Math.round(window.innerHeight - rect.top - 44));
140
149
  const nextWidth = Math.round(rect.width);
141
150
  // Dead-band: ignore sub-threshold churn so a stray re-measure (e.g. a
142
151
  // scrollbar toggling) can't ping-pong state and repaint.
@@ -162,9 +171,14 @@ export function useChartFrameHeight(explicitHeight: number | null | undefined, f
162
171
  // content-fit (strictSize / user-resized nodes), it fills the frame down as before.
163
172
  const fitContent = typeof window !== 'undefined'
164
173
  && (window as { __PMX_CANVAS_FIT_CONTENT__?: boolean }).__PMX_CANVAS_FIT_CONTENT__ === true;
165
- const height = fitContent
166
- ? (typeof explicitHeight === 'number' ? explicitHeight : fallbackHeight)
167
- : (typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight);
174
+ // Site mode (#65): fill the viewport (autoHeight), ignoring an explicit/configured
175
+ // chart height that would otherwise cap it to a shallow card. Content-fit is off in
176
+ // site mode (the server skips it), so site never takes the intrinsic-height branch.
177
+ const height = isSite
178
+ ? autoHeight
179
+ : fitContent
180
+ ? (typeof explicitHeight === 'number' ? explicitHeight : fallbackHeight)
181
+ : (typeof explicitHeight === 'number' ? Math.min(explicitHeight, autoHeight) : autoHeight);
168
182
  return {
169
183
  frameRef,
170
184
  height,
@@ -180,8 +180,15 @@ function App() {
180
180
  ? { ...(spec.state ?? {}), ax: axState }
181
181
  : spec.state ?? undefined;
182
182
 
183
+ // Standalone "Open as site" tab (#65): fill the browser viewport instead of the
184
+ // in-canvas card height. The chart child flex-grows; useChartFrameHeight measures
185
+ // the full viewport in this mode. Embedded/expanded keep the padded min-height box.
186
+ const isSite = window.__PMX_CANVAS_JSON_RENDER_DISPLAY__ === 'site';
187
+ const containerStyle = isSite
188
+ ? { display: 'flex', flexDirection: 'column' as const, height: '100dvh', minHeight: '100dvh', padding: 0, boxSizing: 'border-box' as const }
189
+ : { minHeight: '100vh', padding: 16, boxSizing: 'border-box' as const };
183
190
  return (
184
- <div style={{ minHeight: '100vh', padding: 16, boxSizing: 'border-box' }}>
191
+ <div style={containerStyle}>
185
192
  <JSONUIProvider
186
193
  registry={registry}
187
194
  initialState={initialState}
@@ -189,7 +196,9 @@ function App() {
189
196
  handlers={buildAxHandlers()}
190
197
  >
191
198
  <AxStateSync />
192
- <Renderer spec={spec} registry={registry} loading={false} />
199
+ <div style={isSite ? { flex: 1, minHeight: 0 } : undefined}>
200
+ <Renderer spec={spec} registry={registry} loading={false} />
201
+ </div>
193
202
  {window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ ? (
194
203
  <JsonRenderDevtools position="right" />
195
204
  ) : null}
@@ -940,7 +940,7 @@ export async function buildJsonRenderViewerHtml(options: {
940
940
  title: string;
941
941
  spec: JsonRenderSpec;
942
942
  theme?: 'dark' | 'light' | 'high-contrast';
943
- display?: 'expanded';
943
+ display?: 'expanded' | 'site';
944
944
  devtools?: boolean;
945
945
  nodeId?: string;
946
946
  axToken?: string;