pmx-canvas 0.4.0 → 0.4.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 (43) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/Readme.md +9 -4
  3. package/dist/canvas/global.css +397 -0
  4. package/dist/canvas/index.js +89 -89
  5. package/dist/canvas/surface-theme.css +160 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
  8. package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
  9. package/dist/types/client/state/canvas-store.d.ts +1 -0
  10. package/dist/types/client/state/iframe-mode.d.ts +26 -0
  11. package/dist/types/client/types.d.ts +2 -1
  12. package/dist/types/server/canvas-db.d.ts +2 -1
  13. package/dist/types/server/html-surface.d.ts +8 -1
  14. package/dist/types/shared/themes.d.ts +27 -0
  15. package/docs/RELEASE.md +10 -4
  16. package/docs/cli.md +1 -1
  17. package/docs/environment.md +2 -1
  18. package/docs/http-api.md +29 -0
  19. package/docs/plans/plan-009-tech-debt-backlog.md +9 -1
  20. package/docs/screenshot.png +0 -0
  21. package/package.json +1 -1
  22. package/skills/pmx-canvas/SKILL.md +32 -4
  23. package/skills/pmx-canvas/references/full-reference.md +3 -3
  24. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  25. package/src/cli/index.ts +3 -2
  26. package/src/client/App.tsx +201 -53
  27. package/src/client/index.tsx +5 -0
  28. package/src/client/nodes/ExtAppFrame.tsx +36 -1
  29. package/src/client/nodes/HtmlNode.tsx +5 -1
  30. package/src/client/nodes/McpAppNode.tsx +9 -2
  31. package/src/client/nodes/iframe-document-url.ts +62 -9
  32. package/src/client/nodes/use-surface-frame.ts +53 -0
  33. package/src/client/state/canvas-store.ts +5 -0
  34. package/src/client/state/iframe-mode.ts +118 -0
  35. package/src/client/state/sse-bridge.ts +8 -2
  36. package/src/client/theme/global.css +397 -0
  37. package/src/client/theme/surface-theme.css +160 -0
  38. package/src/client/types.ts +3 -1
  39. package/src/server/canvas-db.ts +3 -2
  40. package/src/server/html-surface.ts +17 -3
  41. package/src/server/operations/ops/canvas-wire.ts +6 -1
  42. package/src/server/server.ts +59 -2
  43. package/src/shared/themes.ts +58 -0
@@ -9,6 +9,7 @@ import {
9
9
  } from '../../shared/ax-surface-protocol.js';
10
10
  import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
11
11
  import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
12
+ import { canvasThemeScheme } from '../../shared/themes.js';
12
13
  import {
13
14
  canvasTheme,
14
15
  collapseExpandedNode,
@@ -520,7 +521,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
520
521
  [],
521
522
  );
522
523
 
523
- const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
524
+ // MCP apps only understand dark/light — collapse named themes to their scheme.
525
+ const toMcpTheme = (theme: string): McpUiTheme => canvasThemeScheme(theme);
524
526
  const isExpanded = expanded || expandedNodeId.value === nodeId;
525
527
 
526
528
  latestToolInputRef.current = toolInput;
@@ -579,6 +581,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
579
581
  if (!iframe) return;
580
582
  let disposed = false;
581
583
  let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
584
+ let repaintNudgeTimer: ReturnType<typeof setTimeout> | null = null;
582
585
  let hostContextResizeObserver: ResizeObserver | null = null;
583
586
  let hostContextRaf: number | null = null;
584
587
  let readyNudgeRaf: number | null = null;
@@ -654,6 +657,32 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
654
657
  });
655
658
  };
656
659
 
660
+ // Finding N best-effort: WKWebView can keep compositing a stale (black)
661
+ // texture for the doubly-nested app iframe even after a genuine settled
662
+ // boot — the user-proven unstick is enlarge+close, i.e. a layout-size
663
+ // change. Mimic the cheap half of that after the scene draw: bounce the
664
+ // iframe's height by 1px for two frames so WebKit must re-rasterize the
665
+ // layer. No reload, no app state loss; a healthy frame repaints
666
+ // identically. Scheduled past the remount queue's settle pause so it
667
+ // lands after the app has actually drawn.
668
+ const scheduleWebkitRepaintNudge = () => {
669
+ if (!isWebKitOnlyHost(navigator.userAgent)) return;
670
+ if (repaintNudgeTimer) clearTimeout(repaintNudgeTimer);
671
+ repaintNudgeTimer = setTimeout(() => {
672
+ repaintNudgeTimer = null;
673
+ const frame = iframeRef.current;
674
+ if (disposed || !frame) return;
675
+ const previous = frame.style.height;
676
+ frame.style.height = 'calc(100% - 1px)';
677
+ requestAnimationFrame(() => {
678
+ requestAnimationFrame(() => {
679
+ frame.style.height = previous;
680
+ extAppRecoveryLog(nodeId, 'repaint-nudge');
681
+ });
682
+ });
683
+ }, WEBKIT_REMOUNT_SETTLE_MS + 200);
684
+ };
685
+
657
686
  const bridge = new AppBridge(
658
687
  null,
659
688
  { name: 'PMX Canvas', version: '1.0.0' },
@@ -805,6 +834,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
805
834
  extAppRecoveryLog(nodeId, 'settled');
806
835
  for (const waiter of bootWaitersRef.current.splice(0)) waiter();
807
836
  nudgeHostContextAfterLayout();
837
+ scheduleWebkitRepaintNudge();
808
838
  })
809
839
  .catch((err) => {
810
840
  const msg = err instanceof Error ? err.message : String(err);
@@ -831,6 +861,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
831
861
  setStatus(bootstrapToolResult ? 'done' : 'ready');
832
862
  setError(null);
833
863
  nudgeHostContextAfterLayout();
864
+ scheduleWebkitRepaintNudge();
834
865
  })
835
866
  .catch((err) => {
836
867
  const msg = err instanceof Error ? err.message : String(err);
@@ -878,6 +909,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
878
909
  return () => {
879
910
  disposed = true;
880
911
  clearFallbackTimer();
912
+ if (repaintNudgeTimer) {
913
+ clearTimeout(repaintNudgeTimer);
914
+ repaintNudgeTimer = null;
915
+ }
881
916
  hostContextResizeObserver?.disconnect();
882
917
  hostContextResizeObserver = null;
883
918
  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={{
@@ -1,11 +1,13 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
2
  import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
3
+ import { canvasThemeScheme } from '../../shared/themes.js';
3
4
  import type { CanvasNodeState } from '../types';
4
5
  import { axSurfaceState, canvasTheme } from '../state/canvas-store';
5
6
  import { shouldContentFitIframeNode } from '../canvas/auto-fit';
6
7
  import { ExtAppFrame } from './ExtAppFrame';
7
8
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
8
9
  import { useIframeContentHeight } from './use-iframe-content-height';
10
+ import { useSurfaceFrame } from './use-surface-frame';
9
11
 
10
12
  function withViewerParams(
11
13
  url: string,
@@ -19,7 +21,9 @@ function withViewerParams(
19
21
  if (!url) return url;
20
22
  try {
21
23
  const resolved = new URL(url, window.location.origin);
22
- resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
24
+ // The json-render viewer + artifacts understand dark/light — collapse named
25
+ // themes (sepia → light, midnight/arctic/ember/forest → dark) to a scheme.
26
+ resolved.searchParams.set('theme', canvasThemeScheme(canvasTheme.value));
23
27
  if (expanded) resolved.searchParams.set('display', 'expanded');
24
28
  // Streaming json-render nodes bump specVersion as patches accumulate; including
25
29
  // it in the src reloads the iframe so it re-reads the latest accumulated spec.
@@ -117,6 +121,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
117
121
  frameToken || undefined,
118
122
  contentFit,
119
123
  );
124
+ // src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
125
+ // External viewer URLs always stay src — the hook passes them through.
126
+ const surfaceFrame = useSurfaceFrame(url);
120
127
  const sourceServer = (node.data.sourceServer as string) || '';
121
128
  const hostMode = (node.data.hostMode as string) || 'hosted';
122
129
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -174,7 +181,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
174
181
  app/host RPC and broader capabilities. */}
175
182
  <iframe
176
183
  ref={iframeRef}
177
- src={url}
184
+ {...surfaceFrame}
178
185
  class="mcp-app-frame"
179
186
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
180
187
  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,118 @@
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
+ * Amp orb services run with AMP_ORB=1 and the server stamps that into the page
74
+ * as window.__PMX_AMP_ORB (see canvasSpaHtml). The orb portal's nested-iframe
75
+ * embed blocks src-URL child iframes, but the probe is unreliable there — a
76
+ * tiny probe iframe sometimes loads even though node-sized ones will not — so
77
+ * a known orb host must skip the probe and go straight to srcdoc.
78
+ */
79
+ function isAmpOrbHost(): boolean {
80
+ return (window as Window & { __PMX_AMP_ORB?: unknown }).__PMX_AMP_ORB === true;
81
+ }
82
+
83
+ /**
84
+ * Resolve the boot-wide iframe mode once; all surface hooks share the result.
85
+ * The blocked-src condition only exists when the canvas page itself runs
86
+ * inside an iframe, so top-level documents resolve `src` synchronously — the
87
+ * normal path pays zero probe latency before surfaces mount. Only embedded
88
+ * documents run the probe (`embedded` is overridable for tests).
89
+ */
90
+ export function resolveIframeMode(opts: { embedded?: boolean; ampOrb?: boolean } = {}): Promise<IframeMode> {
91
+ if (iframeMode.value) return Promise.resolve(iframeMode.value);
92
+ if (pending) return pending;
93
+ const forced = forcedIframeMode();
94
+ if (forced) {
95
+ iframeMode.value = forced;
96
+ return Promise.resolve(forced);
97
+ }
98
+ if (!(opts.embedded ?? isEmbeddedDocument())) {
99
+ iframeMode.value = 'src';
100
+ return Promise.resolve('src');
101
+ }
102
+ if (opts.ampOrb ?? isAmpOrbHost()) {
103
+ iframeMode.value = 'srcdoc';
104
+ return Promise.resolve('srcdoc');
105
+ }
106
+ pending = probeSrcIframes().then((srcWorks) => {
107
+ const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
108
+ iframeMode.value = mode;
109
+ return mode;
110
+ });
111
+ return pending;
112
+ }
113
+
114
+ /** Test hook: clear the memoized probe so each test starts unresolved. */
115
+ export function resetIframeModeForTests(): void {
116
+ iframeMode.value = null;
117
+ pending = null;
118
+ }
@@ -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';
@@ -30,6 +31,7 @@ import { invalidateTokenCache } from '../theme/tokens';
30
31
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
31
32
  import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
32
33
  import type { PmxAxIntent } from '../../shared/ax-intent.js';
34
+ import { isCanvasTheme } from '../../shared/themes.js';
33
35
 
34
36
  let eventSource: EventSource | null = null;
35
37
  let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
@@ -274,8 +276,10 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
274
276
  }
275
277
 
276
278
  function applyCanvasTheme(theme: string): void {
277
- const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
278
- if (!valid) return;
279
+ // Registry-driven: this is the ONLY path a server-side theme reaches the
280
+ // browser (connected snapshot + theme-changed events) — a hardcoded subset
281
+ // here silently drops themes on reload and breaks cross-tab sync.
282
+ if (!isCanvasTheme(theme)) return;
279
283
  document.documentElement.setAttribute('data-theme', theme);
280
284
  invalidateTokenCache();
281
285
  if (canvasTheme.value !== theme) canvasTheme.value = theme;
@@ -393,6 +397,8 @@ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation |
393
397
  function handleConnected(data: Record<string, unknown>): void {
394
398
  sessionId.value = (data.sessionId as string) || '';
395
399
  connectionStatus.value = 'connected';
400
+ // Reconnect marker for holders of server-minted URLs (Finding S).
401
+ workbenchConnectionEpoch.value += 1;
396
402
  if (typeof data.theme === 'string') {
397
403
  applyCanvasTheme(data.theme);
398
404
  }