pmx-canvas 0.3.4 → 0.4.1

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 (51) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/Readme.md +16 -3
  3. package/dist/canvas/index.js +80 -80
  4. package/dist/types/client/index.d.ts +1 -1
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
  6. package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
  7. package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
  8. package/dist/types/client/state/canvas-store.d.ts +1 -0
  9. package/dist/types/client/state/iframe-mode.d.ts +25 -0
  10. package/dist/types/client/state/node-factory.d.ts +29 -0
  11. package/dist/types/client/uuid-polyfill.d.ts +0 -0
  12. package/dist/types/server/canvas-db.d.ts +0 -1
  13. package/dist/types/server/canvas-state.d.ts +14 -15
  14. package/dist/types/server/operations/composites.d.ts +6 -21
  15. package/dist/types/server/server.d.ts +1 -0
  16. package/docs/RELEASE.md +10 -4
  17. package/docs/cli.md +5 -0
  18. package/docs/environment.md +2 -2
  19. package/docs/http-api.md +47 -1
  20. package/docs/mcp.md +21 -30
  21. package/docs/plans/plan-009-tech-debt-backlog.md +22 -13
  22. package/docs/screenshot.png +0 -0
  23. package/docs/tech-debt-assessment-2026-06.md +5 -5
  24. package/package.json +7 -7
  25. package/skills/pmx-canvas/SKILL.md +36 -10
  26. package/skills/pmx-canvas/evals/evals.json +2 -2
  27. package/skills/pmx-canvas/references/full-reference.md +17 -15
  28. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  29. package/src/cli/commands/history.ts +1 -1
  30. package/src/cli/commands/nodes.ts +31 -1
  31. package/src/cli/index.ts +8 -2
  32. package/src/cli/shared.ts +1 -0
  33. package/src/client/index.tsx +7 -0
  34. package/src/client/nodes/ExtAppFrame.tsx +50 -2
  35. package/src/client/nodes/HtmlNode.tsx +5 -1
  36. package/src/client/nodes/McpAppNode.tsx +5 -1
  37. package/src/client/nodes/iframe-document-url.ts +62 -9
  38. package/src/client/nodes/use-surface-frame.ts +53 -0
  39. package/src/client/state/canvas-store.ts +5 -0
  40. package/src/client/state/iframe-mode.ts +103 -0
  41. package/src/client/state/node-factory.ts +72 -0
  42. package/src/client/state/sse-bridge.ts +212 -86
  43. package/src/client/uuid-polyfill.ts +14 -0
  44. package/src/server/canvas-db.ts +0 -8
  45. package/src/server/canvas-schema.ts +2 -8
  46. package/src/server/canvas-state.ts +51 -151
  47. package/src/server/index.ts +3 -9
  48. package/src/server/operations/composites.ts +24 -21
  49. package/src/server/operations/ops/snapshots.ts +6 -7
  50. package/src/server/operations/registry.ts +4 -1
  51. package/src/server/server.ts +194 -75
@@ -1,4 +1,7 @@
1
- import { useEffect, useMemo, useState } from 'preact/hooks';
1
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
+ import { workbenchConnectionEpoch } from '../state/canvas-store';
3
+ import { iframeMode } from '../state/iframe-mode';
4
+ import { surfaceContentHash } from './surface-url';
2
5
 
3
6
  interface FrameDocumentCreateResponse {
4
7
  ok: boolean;
@@ -37,13 +40,20 @@ export async function createIframeDocumentUrl(html: string, sandbox: string): Pr
37
40
  export function useIframeDocument(
38
41
  html: string,
39
42
  sandbox: string,
40
- ): { attributes: { src?: string }; ready: boolean; key: string } {
43
+ ): { attributes: { src?: string; srcdoc?: string }; ready: boolean; key: string } {
44
+ // Boot-wide embed probe (state/iframe-mode.ts): srcdoc mode means src-URL
45
+ // iframes are blocked here (nested-iframe hosts like Amp orb portals), so the
46
+ // frame-documents round-trip would produce a URL the browser refuses to load.
47
+ const mode = iframeMode.value;
41
48
  const [src, setSrc] = useState<string | null>(null);
49
+ const [remintNonce, setRemintNonce] = useState(0);
50
+ const mintEpochRef = useRef(0);
42
51
 
43
52
  useEffect(() => {
44
53
  setSrc(null);
45
- if (!html) return;
54
+ if (!html || mode !== 'src') return;
46
55
  let cancelled = false;
56
+ mintEpochRef.current = workbenchConnectionEpoch.value;
47
57
  void createIframeDocumentUrl(html, sandbox)
48
58
  .then((url) => {
49
59
  if (!cancelled) setSrc(url);
@@ -54,14 +64,57 @@ export function useIframeDocument(
54
64
  return () => {
55
65
  cancelled = true;
56
66
  };
57
- }, [html, sandbox]);
67
+ }, [html, sandbox, mode, remintNonce]);
58
68
 
59
- return useMemo(
60
- () => ({
69
+ // Finding S: frame documents live in server memory, so a daemon restart turns
70
+ // this mount's URL into a 404 while the panel keeps showing the dead frame.
71
+ // Every reconnect frame (SSE reconnect or poll snapshot reset) revalidates the
72
+ // minted URL and re-mints a fresh document when the server no longer has it —
73
+ // no full workbench reload required.
74
+ const epoch = workbenchConnectionEpoch.value;
75
+ useEffect(() => {
76
+ if (mode !== 'src' || epoch === mintEpochRef.current) return;
77
+ if (!src) {
78
+ // A previous mint failed outright (e.g. the daemon flapped down between
79
+ // the HEAD 404 and the re-mint POST). A fresh connection epoch means the
80
+ // server is reachable again — retry the mint instead of stranding the
81
+ // tile until a manual reload.
82
+ setRemintNonce((n) => n + 1);
83
+ return;
84
+ }
85
+ let cancelled = false;
86
+ void fetch(src, { method: 'HEAD' })
87
+ .then((res) => {
88
+ if (cancelled) return;
89
+ if (res.ok) {
90
+ mintEpochRef.current = epoch;
91
+ return;
92
+ }
93
+ setRemintNonce((n) => n + 1);
94
+ })
95
+ .catch(() => {
96
+ // Transient network failure — the next reconnect epoch retries.
97
+ });
98
+ return () => {
99
+ cancelled = true;
100
+ };
101
+ }, [epoch, src, mode]);
102
+
103
+ return useMemo(() => {
104
+ if (mode === 'srcdoc' && html) {
105
+ // The document already lives client-side, so serve it inline. Consumers
106
+ // always render the iframe `sandbox` attribute, which supplies the
107
+ // sandboxing the frame-document CSP header would have.
108
+ return {
109
+ attributes: { srcdoc: html },
110
+ ready: true,
111
+ key: `srcdoc-${surfaceContentHash(html)}`,
112
+ };
113
+ }
114
+ return {
61
115
  attributes: src ? { src } : {},
62
116
  ready: Boolean(src),
63
117
  key: src ?? '',
64
- }),
65
- [src],
66
- );
118
+ };
119
+ }, [src, mode, html]);
67
120
  }
@@ -0,0 +1,53 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import { iframeMode } from '../state/iframe-mode';
3
+
4
+ function isSameOriginUrl(url: string): boolean {
5
+ try {
6
+ return new URL(url, window.location.origin).origin === window.location.origin;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Attributes for a surface iframe, honoring the boot-wide iframe mode
14
+ * (state/iframe-mode.ts): `src` by default, fetch() + `srcdoc` when src-URL
15
+ * iframes are blocked by the embedding context (nested-iframe hosts like Amp
16
+ * orb portals). Cross-origin URLs always stay `src` — they cannot be fetched
17
+ * from here. While the probe is pending, same-origin frames stay empty rather
18
+ * than loading a src that may show the broken placeholder; a failed srcdoc
19
+ * fetch falls back to `src` (no worse than before).
20
+ */
21
+ export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string } {
22
+ const mode = iframeMode.value;
23
+ const [doc, setDoc] = useState<{ url: string; html: string } | null>(null);
24
+ const sameOrigin = Boolean(url) && isSameOriginUrl(url);
25
+ const wantsSrcdoc = mode === 'srcdoc' && sameOrigin;
26
+
27
+ useEffect(() => {
28
+ if (!wantsSrcdoc) return;
29
+ let cancelled = false;
30
+ void fetch(url, { credentials: 'same-origin' })
31
+ .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
32
+ .then((html) => {
33
+ if (!cancelled) setDoc({ url, html });
34
+ })
35
+ .catch((error) => {
36
+ console.error('[surface-frame] srcdoc fetch failed, falling back to src:', url, error);
37
+ // Empty html marks the src fallback below.
38
+ if (!cancelled) setDoc({ url, html: '' });
39
+ });
40
+ return () => {
41
+ cancelled = true;
42
+ };
43
+ }, [wantsSrcdoc, url]);
44
+
45
+ if (!url) return {};
46
+ if (!wantsSrcdoc) {
47
+ return mode === null && sameOrigin ? {} : { src: url };
48
+ }
49
+ // Keep the previous document visible while a newer URL is being fetched —
50
+ // same UX as src mode, where the old document stays until the new one loads.
51
+ if (doc) return doc.html ? { srcdoc: doc.html } : { src: doc.url };
52
+ return {};
53
+ }
@@ -22,6 +22,11 @@ export const edges = signal<Map<string, CanvasEdge>>(new Map());
22
22
  export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
23
23
  export const activeNodeId = signal<string | null>(null);
24
24
  export const connectionStatus = signal<ConnectionStatus>('connecting');
25
+ // Bumped on every processed `connected` frame (SSE reconnects and poll snapshot
26
+ // resets alike). Consumers holding server-minted URLs revalidate on change —
27
+ // a server restart invalidates in-memory resources like frame documents
28
+ // (Finding S) while the browser keeps the stale URLs alive.
29
+ export const workbenchConnectionEpoch = signal<number>(0);
25
30
  export const sessionId = signal<string>('');
26
31
  export const traceEnabled = signal<boolean>(false);
27
32
  export const canvasTheme = signal<string>('dark');
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Nested-iframe embedding detection (Amp orb portals).
3
+ *
4
+ * When the canvas page itself runs inside an iframe (ampcode.com embeds the
5
+ * portal URL on *.onamp.dev), Chrome refuses to load child iframes from `src`
6
+ * URLs — even same-origin relative ones — leaving every iframe-backed node a
7
+ * gray "refused to connect" placeholder. Inline documents (`srcdoc`) still
8
+ * render. This module probes the real behavior once per boot: a hidden 1px
9
+ * iframe navigates to the tiny /api/canvas/iframe-probe document; if `load`
10
+ * fires the host is normal (`src` mode), otherwise same-origin surfaces fall
11
+ * back to fetch() + `srcdoc` (see nodes/use-surface-frame.ts and
12
+ * nodes/iframe-document-url.ts).
13
+ *
14
+ * `src` stays the default everywhere it works: URL-loaded surfaces keep real
15
+ * document URLs, and the frame-document transport exists precisely because
16
+ * attribute-sized srcdoc documents were the worse default (v0.1.24). srcdoc is
17
+ * the blocked-embed fallback, not the new normal.
18
+ *
19
+ * Force a mode with `?iframe-mode=srcdoc` (or `=src`) for debugging.
20
+ */
21
+ import { signal } from '@preact/signals';
22
+
23
+ export type IframeMode = 'src' | 'srcdoc';
24
+
25
+ export const IFRAME_PROBE_PATH = '/api/canvas/iframe-probe';
26
+ const PROBE_TIMEOUT_MS = 3000;
27
+
28
+ /** Boot-wide iframe transport mode; null until the probe (or override) resolves. */
29
+ export const iframeMode = signal<IframeMode | null>(null);
30
+
31
+ export function forcedIframeMode(): IframeMode | null {
32
+ const value = new URLSearchParams(window.location.search).get('iframe-mode');
33
+ return value === 'srcdoc' || value === 'src' ? value : null;
34
+ }
35
+
36
+ /**
37
+ * Probe whether `src`-URL iframes load in this embedding context. Resolves true
38
+ * when the hidden probe iframe fires `load`; false on error or timeout. The
39
+ * blocked-portal case shows its placeholder without reliably firing either
40
+ * event, so the timeout is the real signal there — and a false negative is
41
+ * safe, because srcdoc rendering works in normal hosts too.
42
+ */
43
+ export function probeSrcIframes(timeoutMs = PROBE_TIMEOUT_MS): Promise<boolean> {
44
+ return new Promise((resolve) => {
45
+ const test = document.createElement('iframe');
46
+ test.setAttribute('data-pmx-iframe-probe', '');
47
+ test.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;border:none';
48
+ test.setAttribute('sandbox', 'allow-scripts');
49
+ const done = (ok: boolean) => {
50
+ window.clearTimeout(timer);
51
+ test.remove();
52
+ resolve(ok);
53
+ };
54
+ const timer = window.setTimeout(() => done(false), timeoutMs);
55
+ test.addEventListener('load', () => done(true));
56
+ test.addEventListener('error', () => done(false));
57
+ test.src = IFRAME_PROBE_PATH;
58
+ (document.body ?? document.documentElement).appendChild(test);
59
+ });
60
+ }
61
+
62
+ let pending: Promise<IframeMode> | null = null;
63
+
64
+ function isEmbeddedDocument(): boolean {
65
+ try {
66
+ return window.self !== window.top;
67
+ } catch {
68
+ return true;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Resolve the boot-wide iframe mode once; all surface hooks share the result.
74
+ * The blocked-src condition only exists when the canvas page itself runs
75
+ * inside an iframe, so top-level documents resolve `src` synchronously — the
76
+ * normal path pays zero probe latency before surfaces mount. Only embedded
77
+ * documents run the probe (`embedded` is overridable for tests).
78
+ */
79
+ export function resolveIframeMode(opts: { embedded?: boolean } = {}): Promise<IframeMode> {
80
+ if (iframeMode.value) return Promise.resolve(iframeMode.value);
81
+ if (pending) return pending;
82
+ const forced = forcedIframeMode();
83
+ if (forced) {
84
+ iframeMode.value = forced;
85
+ return Promise.resolve(forced);
86
+ }
87
+ if (!(opts.embedded ?? isEmbeddedDocument())) {
88
+ iframeMode.value = 'src';
89
+ return Promise.resolve('src');
90
+ }
91
+ pending = probeSrcIframes().then((srcWorks) => {
92
+ const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
93
+ iframeMode.value = mode;
94
+ return mode;
95
+ });
96
+ return pending;
97
+ }
98
+
99
+ /** Test hook: clear the memoized probe so each test starts unresolved. */
100
+ export function resetIframeModeForTests(): void {
101
+ iframeMode.value = null;
102
+ pending = null;
103
+ }
@@ -0,0 +1,72 @@
1
+ import type { CanvasNodeState } from '../types';
2
+
3
+ // ── Client node-creation defaults (plan-009 H5, client half) ──
4
+ // Single source of truth for the frame (position/size/zIndex/flags) a NEW
5
+ // client-created node gets when its creating event carries no explicit
6
+ // geometry. Extracted from sse-bridge, which kept one `makeNode` helper plus
7
+ // three inline node literals (ext-app open, prompt, response) duplicating
8
+ // these defaults.
9
+ //
10
+ // Divergences found during extraction — documented, NOT silently unified:
11
+ // - `canvas-store.ts` holds no node-creation defaults today. Human-created
12
+ // nodes go through `createNodeFromClient` (intent-bridge → POST
13
+ // /api/canvas/node) and get the SERVER's `defaultNodeSize`
14
+ // (src/server/operations/ops/nodes.ts), which differs from this table for
15
+ // most types (e.g. its fallback is 360x200). The server half of H5
16
+ // (`syncEventToCanvasState`, `ensureDefaultDockedNodes`) is deliberately
17
+ // deferred — do not "fix" that mismatch from this file.
18
+ // - The server seeds `status-main`/`context-main` docked AND collapsed
19
+ // (`ensureDefaultDockedNodes` in src/server/server.ts); the SSE bridge
20
+ // creates the same ids expanded, with `context-main` undocked. Client
21
+ // behavior is preserved as-is.
22
+ // - sse-bridge's prompt literal hardcoded `height: 400` beside a table entry
23
+ // of the same value; both now read the table (no rendering change).
24
+
25
+ export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y: number; w: number; h: number }> &
26
+ Record<'prompt' | 'response', { x: number; y: number; w: number; h: number }> = {
27
+ status: { x: 40, y: 80, w: 300, h: 120 },
28
+ markdown: { x: 380, y: 80, w: 720, h: 600 },
29
+ context: { x: 1130, y: 80, w: 320, h: 400 },
30
+ 'mcp-app': { x: 380, y: 720, w: 960, h: 600 },
31
+ webpage: { x: 380, y: 80, w: 520, h: 420 },
32
+ 'json-render': { x: 380, y: 720, w: 840, h: 620 },
33
+ graph: { x: 380, y: 720, w: 760, h: 520 },
34
+ ledger: { x: 1130, y: 520, w: 320, h: 280 },
35
+ trace: { x: 40, y: 900, w: 200, h: 56 },
36
+ file: { x: 380, y: 80, w: 720, h: 600 },
37
+ image: { x: 380, y: 80, w: 720, h: 520 },
38
+ html: { x: 380, y: 80, w: 720, h: 640 },
39
+ group: { x: 220, y: 60, w: 840, h: 560 },
40
+ prompt: { x: 380, y: 1260, w: 520, h: 400 },
41
+ response: { x: 380, y: 1480, w: 720, h: 400 },
42
+ };
43
+
44
+ /**
45
+ * Build a canvas node with the shared client defaults. `position`/`size`
46
+ * default to the type's DEFAULT_POSITIONS entry; callers that compute
47
+ * placement (auto-placement, event-supplied geometry) pass explicit values.
48
+ * Status nodes sit at zIndex 0 (background chrome), everything else at 1.
49
+ */
50
+ export function makeNodeState(
51
+ id: string,
52
+ type: CanvasNodeState['type'],
53
+ data: Record<string, unknown>,
54
+ options: {
55
+ position?: { x: number; y: number };
56
+ size?: { width: number; height: number };
57
+ dockPosition?: 'left' | 'right' | null;
58
+ } = {},
59
+ ): CanvasNodeState {
60
+ const defaults = DEFAULT_POSITIONS[type];
61
+ return {
62
+ id,
63
+ type,
64
+ position: options.position ?? { x: defaults.x, y: defaults.y },
65
+ size: options.size ?? { width: defaults.w, height: defaults.h },
66
+ zIndex: type === 'status' ? 0 : 1,
67
+ collapsed: false,
68
+ pinned: false,
69
+ dockPosition: options.dockPosition ?? null,
70
+ data,
71
+ };
72
+ }