pmx-canvas 0.4.0 → 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.
@@ -2,6 +2,7 @@ export declare function createIframeDocumentUrl(html: string, sandbox: string):
2
2
  export declare function useIframeDocument(html: string, sandbox: string): {
3
3
  attributes: {
4
4
  src?: string;
5
+ srcdoc?: string;
5
6
  };
6
7
  ready: boolean;
7
8
  key: string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Attributes for a surface iframe, honoring the boot-wide iframe mode
3
+ * (state/iframe-mode.ts): `src` by default, fetch() + `srcdoc` when src-URL
4
+ * iframes are blocked by the embedding context (nested-iframe hosts like Amp
5
+ * orb portals). Cross-origin URLs always stay `src` — they cannot be fetched
6
+ * from here. While the probe is pending, same-origin frames stay empty rather
7
+ * than loading a src that may show the broken placeholder; a failed srcdoc
8
+ * fetch falls back to `src` (no worse than before).
9
+ */
10
+ export declare function useSurfaceFrame(url: string): {
11
+ src?: string;
12
+ srcdoc?: string;
13
+ };
@@ -5,6 +5,7 @@ export declare const edges: import("@preact/signals-core").Signal<Map<string, Ca
5
5
  export declare const annotations: import("@preact/signals-core").Signal<Map<string, CanvasAnnotation>>;
6
6
  export declare const activeNodeId: import("@preact/signals-core").Signal<string | null>;
7
7
  export declare const connectionStatus: import("@preact/signals-core").Signal<ConnectionStatus>;
8
+ export declare const workbenchConnectionEpoch: import("@preact/signals-core").Signal<number>;
8
9
  export declare const sessionId: import("@preact/signals-core").Signal<string>;
9
10
  export declare const traceEnabled: import("@preact/signals-core").Signal<boolean>;
10
11
  export declare const canvasTheme: import("@preact/signals-core").Signal<string>;
@@ -0,0 +1,25 @@
1
+ export type IframeMode = 'src' | 'srcdoc';
2
+ export declare const IFRAME_PROBE_PATH = "/api/canvas/iframe-probe";
3
+ /** Boot-wide iframe transport mode; null until the probe (or override) resolves. */
4
+ export declare const iframeMode: import("@preact/signals-core").Signal<IframeMode | null>;
5
+ export declare function forcedIframeMode(): IframeMode | null;
6
+ /**
7
+ * Probe whether `src`-URL iframes load in this embedding context. Resolves true
8
+ * when the hidden probe iframe fires `load`; false on error or timeout. The
9
+ * blocked-portal case shows its placeholder without reliably firing either
10
+ * event, so the timeout is the real signal there — and a false negative is
11
+ * safe, because srcdoc rendering works in normal hosts too.
12
+ */
13
+ export declare function probeSrcIframes(timeoutMs?: number): Promise<boolean>;
14
+ /**
15
+ * Resolve the boot-wide iframe mode once; all surface hooks share the result.
16
+ * The blocked-src condition only exists when the canvas page itself runs
17
+ * inside an iframe, so top-level documents resolve `src` synchronously — the
18
+ * normal path pays zero probe latency before surfaces mount. Only embedded
19
+ * documents run the probe (`embedded` is overridable for tests).
20
+ */
21
+ export declare function resolveIframeMode(opts?: {
22
+ embedded?: boolean;
23
+ }): Promise<IframeMode>;
24
+ /** Test hook: clear the memoized probe so each test starts unresolved. */
25
+ export declare function resetIframeModeForTests(): void;
package/docs/RELEASE.md CHANGED
@@ -128,12 +128,18 @@ it uses `NPM_TOKEN` directly.
128
128
 
129
129
  Every release since 0.3.0 has been retested against **stale** skill mirrors:
130
130
  consumer workspaces (e.g. `personalclaude`) carry copies of the pmx-canvas
131
- skill under `.github/`, `.codex/`, `.claude/`, and `.agents/`, and testers
131
+ skills under `.github/`, `.codex/`, `.claude/`, and `.agents/`, and testers
132
132
  repeatedly had to sync them mid-cycle before results were trustworthy. After
133
133
  publishing, update the installed package in the consumer workspace and re-sync
134
- its four mirrors from
135
- `<global npm root>/pmx-canvas/skills/pmx-canvas/` so the first test pass runs
136
- against the released skill, not the previous version's guidance.
134
+ its four mirrors from `<global npm root>/pmx-canvas/skills/` so the first test
135
+ pass runs against the released skills, not the previous version's guidance.
136
+
137
+ Sync the **complete tree of every bundled skill** — `pmx-canvas` AND
138
+ `pmx-canvas-testing`, including `references/`, `evals/`, and fixtures — and
139
+ verify by diffing whole directories, not by comparing the `SKILL.md` hash
140
+ alone. The 0.3.4 and 0.4.0 cycles both hit drift where `SKILL.md` matched but
141
+ references/evals were stale, or a newly bundled skill was missing from the
142
+ mirrors entirely.
137
143
 
138
144
  ## GitHub release
139
145
 
package/docs/http-api.md CHANGED
@@ -345,6 +345,28 @@ produces no event within 3 seconds of connecting, it switches to polling.
345
345
  Force a transport with `/workbench?transport=poll` (or `transport=sse` to
346
346
  disable the fallback).
347
347
 
348
+ ## Iframe embed probe (nested-iframe hosts)
349
+
350
+ Some hosts embed the canvas page itself inside an iframe (the Amp orb portal
351
+ does), and Chrome then refuses to load child iframes from `src` URLs — even
352
+ same-origin ones — so iframe-backed nodes would show a broken placeholder.
353
+ When (and only when) the canvas page detects it is embedded, it probes the
354
+ real behavior at boot by loading a hidden iframe against:
355
+
356
+ ```bash
357
+ curl http://localhost:4313/api/canvas/iframe-probe
358
+ ```
359
+
360
+ If that iframe never loads, the client switches every same-origin surface
361
+ (HTML nodes, graph/json-render viewers, frame documents) to `fetch()` +
362
+ `srcdoc` inline rendering. External app URLs stay on `src` — they cannot be
363
+ fetched cross-origin. Force a mode with `/workbench?iframe-mode=srcdoc` (or
364
+ `iframe-mode=src`).
365
+
366
+ `/api/canvas/frame-documents/:id` also answers `HEAD`, which the browser uses
367
+ after every reconnect to detect frame documents lost to a server restart and
368
+ re-mint them automatically (Finding S).
369
+
348
370
  ## Time travel
349
371
 
350
372
  ```bash
@@ -66,5 +66,13 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
66
66
  |---|---|
67
67
  | Windows web-artifacts | The artifact build pipeline is bash-script-backed (POSIX-only); `tests/unit/web-artifacts.test.ts` self-skips on win32 and docs now state the boundary. Decide: port the build scripts to a cross-platform runner, or keep the documented "build on POSIX, render anywhere" stance. |
68
68
  | Composite parity no longer machine-checked | Since v0.3.0 removed the legacy standalones, the byte-identical composite-vs-legacy guarantee has no head-to-head test — regressions in `buildInput`/`formatResult` reuse surface only via output-shape assertions. Accepted at review; revisit if a composite drifts. |
69
- | Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap" (0.3.3 report leaves it open), inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. Only scope on a failing report cycle. |
69
+ | Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap", inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. The 0.4.0 Copilot report keeps Finding N open (residual black after settled recovery; enlarge/close still the reliable unstick) — 0.4.1 ships a cheaper mimic first (post-settle WebKit repaint nudge, `repaint-nudge` trail event). If the next Copilot cycle still fails with the nudge in place, THIS contingency is the next step. |
70
+
71
+ ## Added post-0.4.0 (2026-08-04 report cycle)
72
+
73
+ | Item | What's open |
74
+ |---|---|
75
+ | Finding S — frame documents vs server restart | **Done (2026-08-04):** the browser revalidates minted frame-document URLs on every reconnect epoch (`workbenchConnectionEpoch`, HEAD probe) and re-mints against the new process when the store is gone; `/api/canvas/frame-documents/:id` answers HEAD. Confirmed-fix pending the next Copilot/Codex kill-respawn cycle. Server-side persistence of frame docs deliberately NOT done — they are client-derived and re-mintable. |
76
+ | Amp portal nested-iframe embedding | **Done (2026-08-04):** boot-time embed probe (`/api/canvas/iframe-probe`) + fetch()+srcdoc fallback for all same-origin surfaces (`useSurfaceFrame`, `useIframeDocument` srcdoc short-circuit), `?iframe-mode` override, e2e-forced-mode coverage. External (cross-origin) app URLs cannot be inlined — documented limitation. |
77
+ | Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; re-verify next cycle. |
70
78
  | Registry-migration dead-code sweep | **Done (2026-08-03):** removed the orphaned `describeOp`/`describeGateOp` (composites.ts, dead since the v0.3.0 registration filter) and the `compactNodePayload`/`compactLayoutPayload`/`wantsFullPayload` duplicates in `mcp/server.ts` (live copies are in `ops/nodes.ts`). |
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -223,12 +223,20 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
223
223
  time), with a watchdog retry for iframes that never boot and a re-arm when a panel that loaded
224
224
  hidden becomes visible. Treat the auto-recovery as best-effort, NOT guaranteed — the 0.3.2
225
225
  Copilot-host report shows tiles can still come up black in real WKWebView panels (compositing
226
- success is not observable from page JS, so retries are bounded). Keep the cautionary behavior:
226
+ success is not observable from page JS, so retries are bounded). Since 0.4.1 the canvas also
227
+ fires a post-boot **repaint nudge** under WebKit (a 1px layout bounce that forces the
228
+ compositor to re-rasterize the frame — the cheap half of the proven enlarge-close unstick;
229
+ shows as `repaint-nudge` in the recovery trail). Keep the cautionary behavior anyway:
227
230
  recovery is deterministic via **expand-then-close** of the black tile (forces a fresh mount in
228
231
  the fullscreen overlay, which always paints), or open the workbench in a normal browser
229
232
  (Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
230
233
  fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
231
234
  what the recovery queue actually did in that panel.
235
+ - Ext-app frame documents live in server memory. Through 0.4.0, killing/restarting the daemon
236
+ while a panel stays open leaves ext-app tiles on dead frame URLs (`Frame document not found`,
237
+ 0.4.0 report Finding S) until a full workbench reload. Since 0.4.1 the browser revalidates its
238
+ frame documents on every reconnect and re-mints them against the new process automatically —
239
+ if a post-restart tile still looks blank on an older install, reload the workbench page.
232
240
  - A hosted ext-app (Excalidraw) resized NARROW/TALL (e.g. 360x529) can show its diagram in the
233
241
  upper region with the app's own dark fill below it — in every engine (0.3.4 report Finding Q).
234
242
  This letterboxing lives inside the hosted app bundle's root container, not in PMX (a body-level
@@ -238,10 +246,18 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
238
246
  - Behind proxies that buffer streaming responses (e.g. portal hosts), the workbench auto-falls
239
247
  back from SSE to a polling transport within ~3s, so the board still boots and stays live.
240
248
  Force a mode with `/workbench?transport=poll` (or `transport=sse`) when diagnosing.
249
+ - Nested-iframe embeds (e.g. the Amp orb portal renders the canvas page inside an ampcode.com
250
+ iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
251
+ with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
252
+ same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
253
+ documents). External app URLs cannot be inlined (cross-origin) and may stay blocked in such
254
+ hosts. Force a mode with `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when
255
+ diagnosing.
241
256
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
242
- reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
243
- Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
244
- stale until reload — use a system browser for separate full-page viewing.
257
+ reflow on a live window resize in a normal browser. Some single-tab host browsers historically
258
+ didn't deliver live-resize events (current Codex builds do — the 0.4.0 retest saw a standalone
259
+ graph reflow on a native viewport resize), so if a resized standalone chart looks stale,
260
+ reload it or use a system browser for separate full-page viewing.
245
261
  - Some hosts cannot automate inside sandboxed workbench iframes. Verify those interactions in a
246
262
  system browser or through server-side AX state.
247
263
  - `pmx-canvas screenshot` requires an active WebView. Start it with
@@ -71,6 +71,9 @@ Prefer extending the existing suites before inventing a one-off script.
71
71
  - Avoid brittle selectors. Prefer stable text, roles, titles, or deliberate component hooks
72
72
  - If a change spans server and client, add at least one server-side assertion and one browser or
73
73
  API-level proof
74
+ - MCP harnesses using the official SDK must wind down BOTH halves: call `client.close()` AND
75
+ `transport.close()` on the `StdioClientTransport`. Closing only the client leaves the spawned
76
+ stdio server process alive after the test exits (0.4.0 cycle finding)
74
77
 
75
78
  ## Layout And Embedded Content Checks
76
79
 
@@ -2,6 +2,11 @@
2
2
  import './uuid-polyfill';
3
3
  import { render } from 'preact';
4
4
  import { App } from './App';
5
+ import { resolveIframeMode } from './state/iframe-mode';
6
+
7
+ // Start the embed probe before first paint so iframe-backed nodes know whether
8
+ // src-URL iframes work in this context (Amp orb portals block them).
9
+ void resolveIframeMode();
5
10
 
6
11
  const root = document.getElementById('app');
7
12
  if (root) {
@@ -579,6 +579,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
579
579
  if (!iframe) return;
580
580
  let disposed = false;
581
581
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
582
+ let repaintNudgeTimer: ReturnType<typeof setTimeout> | null = null;
582
583
  let hostContextResizeObserver: ResizeObserver | null = null;
583
584
  let hostContextRaf: number | null = null;
584
585
  let readyNudgeRaf: number | null = null;
@@ -654,6 +655,32 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
654
655
  });
655
656
  };
656
657
 
658
+ // Finding N best-effort: WKWebView can keep compositing a stale (black)
659
+ // texture for the doubly-nested app iframe even after a genuine settled
660
+ // boot — the user-proven unstick is enlarge+close, i.e. a layout-size
661
+ // change. Mimic the cheap half of that after the scene draw: bounce the
662
+ // iframe's height by 1px for two frames so WebKit must re-rasterize the
663
+ // layer. No reload, no app state loss; a healthy frame repaints
664
+ // identically. Scheduled past the remount queue's settle pause so it
665
+ // lands after the app has actually drawn.
666
+ const scheduleWebkitRepaintNudge = () => {
667
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
668
+ if (repaintNudgeTimer) clearTimeout(repaintNudgeTimer);
669
+ repaintNudgeTimer = setTimeout(() => {
670
+ repaintNudgeTimer = null;
671
+ const frame = iframeRef.current;
672
+ if (disposed || !frame) return;
673
+ const previous = frame.style.height;
674
+ frame.style.height = 'calc(100% - 1px)';
675
+ requestAnimationFrame(() => {
676
+ requestAnimationFrame(() => {
677
+ frame.style.height = previous;
678
+ extAppRecoveryLog(nodeId, 'repaint-nudge');
679
+ });
680
+ });
681
+ }, WEBKIT_REMOUNT_SETTLE_MS + 200);
682
+ };
683
+
657
684
  const bridge = new AppBridge(
658
685
  null,
659
686
  { name: 'PMX Canvas', version: '1.0.0' },
@@ -805,6 +832,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
805
832
  extAppRecoveryLog(nodeId, 'settled');
806
833
  for (const waiter of bootWaitersRef.current.splice(0)) waiter();
807
834
  nudgeHostContextAfterLayout();
835
+ scheduleWebkitRepaintNudge();
808
836
  })
809
837
  .catch((err) => {
810
838
  const msg = err instanceof Error ? err.message : String(err);
@@ -831,6 +859,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
831
859
  setStatus(bootstrapToolResult ? 'done' : 'ready');
832
860
  setError(null);
833
861
  nudgeHostContextAfterLayout();
862
+ scheduleWebkitRepaintNudge();
834
863
  })
835
864
  .catch((err) => {
836
865
  const msg = err instanceof Error ? err.message : String(err);
@@ -878,6 +907,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
878
907
  return () => {
879
908
  disposed = true;
880
909
  clearFallbackTimer();
910
+ if (repaintNudgeTimer) {
911
+ clearTimeout(repaintNudgeTimer);
912
+ repaintNudgeTimer = null;
913
+ }
881
914
  hostContextResizeObserver?.disconnect();
882
915
  hostContextResizeObserver = null;
883
916
  if (hostContextRaf !== null) {
@@ -5,6 +5,7 @@ import type { CanvasNodeState } from '../types';
5
5
  import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
6
6
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
7
7
  import { useIframeContentHeight } from './use-iframe-content-height';
8
+ import { useSurfaceFrame } from './use-surface-frame';
8
9
 
9
10
  export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
10
11
  return node.type === 'html' && node.data.presentation === true;
@@ -60,6 +61,9 @@ export function HtmlNode({
60
61
  [html, presentation, presentationExitToken, themeToken, v, node.id, axToken, frameToken],
61
62
  );
62
63
 
64
+ // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
65
+ const surfaceFrame = useSurfaceFrame(surfaceSrc);
66
+
63
67
  // Grow the node to fit the surface's reported content height (grow-only, gated).
64
68
  // Never in the expanded overlay — there the surface fills the large overlay frame.
65
69
  useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
@@ -149,7 +153,7 @@ export function HtmlNode({
149
153
  class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
150
154
  title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
151
155
  sandbox="allow-scripts"
152
- src={surfaceSrc}
156
+ {...surfaceFrame}
153
157
  tabIndex={autoFocus ? 0 : undefined}
154
158
  onLoad={handleFrameLoad}
155
159
  style={{
@@ -6,6 +6,7 @@ import { shouldContentFitIframeNode } from '../canvas/auto-fit';
6
6
  import { ExtAppFrame } from './ExtAppFrame';
7
7
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
8
8
  import { useIframeContentHeight } from './use-iframe-content-height';
9
+ import { useSurfaceFrame } from './use-surface-frame';
9
10
 
10
11
  function withViewerParams(
11
12
  url: string,
@@ -117,6 +118,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
117
118
  frameToken || undefined,
118
119
  contentFit,
119
120
  );
121
+ // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
122
+ // External viewer URLs always stay src — the hook passes them through.
123
+ const surfaceFrame = useSurfaceFrame(url);
120
124
  const sourceServer = (node.data.sourceServer as string) || '';
121
125
  const hostMode = (node.data.hostMode as string) || 'hosted';
122
126
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -174,7 +178,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
174
178
  app/host RPC and broader capabilities. */}
175
179
  <iframe
176
180
  ref={iframeRef}
177
- src={url}
181
+ {...surfaceFrame}
178
182
  class="mcp-app-frame"
179
183
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
180
184
  allow="clipboard-read; clipboard-write"
@@ -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
+ }
@@ -23,6 +23,7 @@ import {
23
23
  sessionId,
24
24
  traceEnabled,
25
25
  updateNodeData,
26
+ workbenchConnectionEpoch,
26
27
  } from './canvas-store';
27
28
  import { fetchAxSurfaceState } from './intent-bridge';
28
29
  import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
@@ -393,6 +394,8 @@ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation |
393
394
  function handleConnected(data: Record<string, unknown>): void {
394
395
  sessionId.value = (data.sessionId as string) || '';
395
396
  connectionStatus.value = 'connected';
397
+ // Reconnect marker for holders of server-minted URLs (Finding S).
398
+ workbenchConnectionEpoch.value += 1;
396
399
  if (typeof data.theme === 'string') {
397
400
  applyCanvasTheme(data.theme);
398
401
  }