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.
- package/CHANGELOG.md +51 -0
- package/Readme.md +9 -4
- package/dist/canvas/global.css +397 -0
- package/dist/canvas/index.js +89 -89
- package/dist/canvas/surface-theme.css +160 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
- package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/iframe-mode.d.ts +26 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/server/canvas-db.d.ts +2 -1
- package/dist/types/server/html-surface.d.ts +8 -1
- package/dist/types/shared/themes.d.ts +27 -0
- package/docs/RELEASE.md +10 -4
- package/docs/cli.md +1 -1
- package/docs/environment.md +2 -1
- package/docs/http-api.md +29 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +9 -1
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +32 -4
- package/skills/pmx-canvas/references/full-reference.md +3 -3
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/index.ts +3 -2
- package/src/client/App.tsx +201 -53
- package/src/client/index.tsx +5 -0
- package/src/client/nodes/ExtAppFrame.tsx +36 -1
- package/src/client/nodes/HtmlNode.tsx +5 -1
- package/src/client/nodes/McpAppNode.tsx +9 -2
- package/src/client/nodes/iframe-document-url.ts +62 -9
- package/src/client/nodes/use-surface-frame.ts +53 -0
- package/src/client/state/canvas-store.ts +5 -0
- package/src/client/state/iframe-mode.ts +118 -0
- package/src/client/state/sse-bridge.ts +8 -2
- package/src/client/theme/global.css +397 -0
- package/src/client/theme/surface-theme.css +160 -0
- package/src/client/types.ts +3 -1
- package/src/server/canvas-db.ts +3 -2
- package/src/server/html-surface.ts +17 -3
- package/src/server/operations/ops/canvas-wire.ts +6 -1
- package/src/server/server.ts +59 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
}
|