pmx-canvas 0.7.3 → 0.8.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 (244) hide show
  1. package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  2. package/.github/extensions/pmx-canvas/extension.mjs +25 -172
  3. package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
  4. package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
  5. package/.github/extensions/pmx-canvas/runtime.json +3 -0
  6. package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
  7. package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
  8. package/.github/extensions/pmx-canvas/tools.json +2560 -0
  9. package/CHANGELOG.md +64 -0
  10. package/Readme.md +21 -7
  11. package/dist/canvas/fonts/OFL.txt +93 -0
  12. package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  13. package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  14. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  15. package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  16. package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  17. package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  18. package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  19. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
  20. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
  21. package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
  22. package/dist/canvas/global.css +726 -674
  23. package/dist/canvas/index.js +90 -88
  24. package/dist/canvas/mcp-app.html +7879 -0
  25. package/dist/canvas/mermaid-entry.js +1 -1
  26. package/dist/canvas/surface-theme.css +151 -46
  27. package/dist/json-render/index.css +81 -1
  28. package/dist/json-render/index.js +1 -1
  29. package/dist/types/cli/commands/record.d.ts +1 -1
  30. package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
  31. package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
  32. package/dist/types/client/icons.d.ts +1 -2
  33. package/dist/types/client/mcp-app/index.d.ts +1 -0
  34. package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
  35. package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
  36. package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
  37. package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
  38. package/dist/types/client/state/attention-store.d.ts +0 -5
  39. package/dist/types/client/state/context-status-store.d.ts +53 -0
  40. package/dist/types/client/state/intent-bridge.d.ts +3 -0
  41. package/dist/types/client/state/near-pin-store.d.ts +16 -0
  42. package/dist/types/client/state/presence-store.d.ts +5 -0
  43. package/dist/types/client/state/session-store.d.ts +33 -3
  44. package/dist/types/client/state/workbench-transport.d.ts +16 -0
  45. package/dist/types/client/theme/tokens.d.ts +1 -3
  46. package/dist/types/client/types.d.ts +9 -0
  47. package/dist/types/json-render/server.d.ts +1 -1
  48. package/dist/types/mcp/app-server.d.ts +3 -0
  49. package/dist/types/mcp/canvas-access.d.ts +1 -1
  50. package/dist/types/mcp/canvas-app.d.ts +4 -0
  51. package/dist/types/mcp/workbench-app.d.ts +4 -0
  52. package/dist/types/server/agent-presence.d.ts +1 -1
  53. package/dist/types/server/ax-state.d.ts +1 -1
  54. package/dist/types/server/canvas-db.d.ts +8 -0
  55. package/dist/types/server/canvas-geometry.d.ts +25 -0
  56. package/dist/types/server/canvas-operations.d.ts +1 -11
  57. package/dist/types/server/canvas-state.d.ts +25 -3
  58. package/dist/types/server/context-brief.d.ts +8 -1
  59. package/dist/types/server/context-reads.d.ts +30 -7
  60. package/dist/types/server/index.d.ts +6 -1
  61. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  62. package/dist/types/server/operations/http.d.ts +2 -0
  63. package/dist/types/server/operations/invoker.d.ts +4 -2
  64. package/dist/types/server/operations/ops/nodes.d.ts +0 -6
  65. package/dist/types/server/server.d.ts +1 -0
  66. package/dist/types/server/spatial-analysis.d.ts +2 -0
  67. package/dist/types/shared/agent-context.d.ts +22 -0
  68. package/dist/types/shared/agent-presence.d.ts +3 -10
  69. package/dist/types/shared/canvas-app.d.ts +54 -0
  70. package/dist/types/shared/themes.d.ts +1 -1
  71. package/docs/RELEASE.md +7 -3
  72. package/docs/agent-plugin.md +123 -0
  73. package/docs/ax-state-contract.md +40 -0
  74. package/docs/cli.md +7 -2
  75. package/docs/design/AgentContext.dc.html +186 -0
  76. package/docs/design/Before.dc.html +208 -0
  77. package/docs/design/BoardPins.dc.html +151 -0
  78. package/docs/design/Chrome.dc.html +211 -0
  79. package/docs/design/Context.dc.html +239 -0
  80. package/docs/design/Flows.dc.html +181 -0
  81. package/docs/design/Home.dc.html +209 -0
  82. package/docs/design/HomeGraph.dc.html +211 -0
  83. package/docs/design/HomeMap.dc.html +117 -0
  84. package/docs/design/HomeTeam.dc.html +176 -0
  85. package/docs/design/Light.dc.html +28 -0
  86. package/docs/design/Main.dc.html +280 -0
  87. package/docs/design/NearPin.dc.html +260 -0
  88. package/docs/design/Pane600.dc.html +120 -0
  89. package/docs/design/Relations.dc.html +297 -0
  90. package/docs/design/Themes.dc.html +121 -0
  91. package/docs/design/TokenSystem.dc.html +136 -0
  92. package/docs/design/Tokens.dc.html +163 -0
  93. package/docs/design/Types1.dc.html +177 -0
  94. package/docs/design/Types2.dc.html +226 -0
  95. package/docs/design/Types3.dc.html +195 -0
  96. package/docs/design/Uses.dc.html +152 -0
  97. package/docs/design/Vision.dc.html +116 -0
  98. package/docs/design/Zoom.dc.html +82 -0
  99. package/docs/design/canvas.json +274 -0
  100. package/docs/design.md +217 -0
  101. package/docs/environment.md +1 -1
  102. package/docs/http-api.md +86 -16
  103. package/docs/mcp.md +202 -4
  104. package/docs/node-types.md +34 -10
  105. package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
  106. package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
  107. package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
  108. package/docs/product-vision-2026-09.md +47 -7
  109. package/docs/screenshot.png +0 -0
  110. package/docs/sdk.md +1 -0
  111. package/package.json +10 -4
  112. package/plugins/pmx-canvas/LICENSE +21 -0
  113. package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
  114. package/plugins/pmx-canvas/assets/preview.png +0 -0
  115. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
  116. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
  117. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
  118. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
  119. package/plugins/pmx-canvas/mcp.json +12 -0
  120. package/plugins/pmx-canvas/plugin.json +24 -0
  121. package/plugins/pmx-canvas/runtime.json +3 -0
  122. package/plugins/pmx-canvas/runtime.mjs +194 -0
  123. package/plugins/pmx-canvas/start-mcp.mjs +51 -0
  124. package/plugins/pmx-canvas/tools.json +2560 -0
  125. package/skills/pmx-canvas/SKILL.md +29 -9
  126. package/skills/pmx-canvas/evals/evals.json +62 -0
  127. package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
  128. package/skills/pmx-canvas/references/full-reference.md +11 -7
  129. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
  130. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  131. package/skills/pmx-canvas-testing/SKILL.md +5 -4
  132. package/src/cli/commands/copilot.ts +5 -0
  133. package/src/cli/commands/nodes.ts +3 -2
  134. package/src/cli/commands/view.ts +1 -1
  135. package/src/cli/index.ts +10 -4
  136. package/src/client/App.tsx +6 -1
  137. package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
  138. package/src/client/canvas/AnnotationLayer.tsx +1 -1
  139. package/src/client/canvas/CanvasNode.tsx +32 -49
  140. package/src/client/canvas/CanvasViewport.tsx +0 -2
  141. package/src/client/canvas/CommandPalette.tsx +3 -2
  142. package/src/client/canvas/ContextMenu.tsx +11 -0
  143. package/src/client/canvas/EdgeLayer.tsx +1 -1
  144. package/src/client/canvas/ExportDialog.tsx +33 -6
  145. package/src/client/canvas/IntentLayer.tsx +2 -2
  146. package/src/client/canvas/Minimap.tsx +12 -7
  147. package/src/client/canvas/NodeContextMark.tsx +156 -0
  148. package/src/client/canvas/SessionPanel.tsx +4 -7
  149. package/src/client/canvas/SessionReceipt.tsx +114 -17
  150. package/src/client/canvas/ShortcutOverlay.tsx +6 -1
  151. package/src/client/canvas/SnapshotPanel.tsx +11 -2
  152. package/src/client/canvas/ToolRail.tsx +12 -9
  153. package/src/client/canvas/TopBar.tsx +16 -11
  154. package/src/client/icons.tsx +2 -12
  155. package/src/client/mcp-app/index.tsx +137 -0
  156. package/src/client/mcp-app/style.css +10 -0
  157. package/src/client/nodes/AttachmentNode.tsx +8 -1
  158. package/src/client/nodes/AxStepControls.tsx +1 -1
  159. package/src/client/nodes/ContextNode.tsx +9 -9
  160. package/src/client/nodes/ExtAppFrame.tsx +13 -8
  161. package/src/client/nodes/FileNode.tsx +8 -5
  162. package/src/client/nodes/HtmlNode.tsx +12 -2
  163. package/src/client/nodes/ImageNode.tsx +4 -2
  164. package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
  165. package/src/client/nodes/LedgerNode.tsx +2 -2
  166. package/src/client/nodes/MarkdownNode.tsx +44 -9
  167. package/src/client/nodes/McpAppNode.tsx +6 -4
  168. package/src/client/nodes/PromptNode.tsx +5 -17
  169. package/src/client/nodes/ResponseNode.tsx +3 -15
  170. package/src/client/nodes/StatusNode.tsx +2 -2
  171. package/src/client/nodes/StatusSummary.tsx +3 -1
  172. package/src/client/nodes/TraceNode.tsx +6 -4
  173. package/src/client/nodes/WebpageNode.tsx +1 -1
  174. package/src/client/nodes/ax-node-actions.ts +2 -2
  175. package/src/client/nodes/iframe-document-url.ts +3 -2
  176. package/src/client/nodes/markdown-sanitize.ts +10 -0
  177. package/src/client/nodes/surface-url.ts +10 -0
  178. package/src/client/nodes/use-surface-frame.ts +3 -1
  179. package/src/client/nodes/use-workbench-asset.ts +26 -0
  180. package/src/client/state/attention-bridge.ts +0 -8
  181. package/src/client/state/attention-store.ts +0 -17
  182. package/src/client/state/canvas-store.ts +4 -2
  183. package/src/client/state/context-status-store.ts +66 -0
  184. package/src/client/state/human-store.ts +2 -0
  185. package/src/client/state/iframe-mode.ts +5 -0
  186. package/src/client/state/intent-bridge.ts +10 -4
  187. package/src/client/state/near-pin-store.ts +53 -0
  188. package/src/client/state/presence-store.ts +5 -10
  189. package/src/client/state/session-store.ts +106 -9
  190. package/src/client/state/sse-bridge.ts +26 -7
  191. package/src/client/state/theme-override.ts +1 -1
  192. package/src/client/state/workbench-transport.ts +36 -0
  193. package/src/client/theme/global.css +726 -674
  194. package/src/client/theme/surface-theme.css +151 -46
  195. package/src/client/theme/tokens.ts +7 -11
  196. package/src/client/types.ts +10 -0
  197. package/src/json-render/renderer/index.css +6 -1
  198. package/src/json-render/renderer/index.tsx +0 -1
  199. package/src/json-render/server.ts +71 -14
  200. package/src/mcp/app-server.ts +64 -0
  201. package/src/mcp/canvas-access.ts +14 -11
  202. package/src/mcp/canvas-app.ts +318 -0
  203. package/src/mcp/server.ts +1 -1
  204. package/src/mcp/workbench-app.ts +211 -0
  205. package/src/server/agent-presence.ts +45 -6
  206. package/src/server/ax-context.ts +1 -1
  207. package/src/server/ax-state.ts +1 -1
  208. package/src/server/board-export.ts +5 -2
  209. package/src/server/canvas-db.ts +46 -12
  210. package/src/server/canvas-geometry.ts +64 -0
  211. package/src/server/canvas-operations.ts +7 -25
  212. package/src/server/canvas-state.ts +105 -15
  213. package/src/server/canvas-validation.ts +19 -4
  214. package/src/server/context-brief.ts +39 -2
  215. package/src/server/context-reads.ts +87 -15
  216. package/src/server/demo-state.json +31 -31
  217. package/src/server/html-surface.ts +4 -5
  218. package/src/server/index.ts +41 -5
  219. package/src/server/mcp-app-runtime.ts +11 -0
  220. package/src/server/operations/composites.ts +4 -3
  221. package/src/server/operations/http.ts +4 -4
  222. package/src/server/operations/invoker.ts +13 -4
  223. package/src/server/operations/mcp.ts +6 -0
  224. package/src/server/operations/ops/ax-read.ts +1 -1
  225. package/src/server/operations/ops/ax-timeline.ts +37 -0
  226. package/src/server/operations/ops/boards.ts +1 -1
  227. package/src/server/operations/ops/canvas-wire.ts +3 -6
  228. package/src/server/operations/ops/intent.ts +31 -2
  229. package/src/server/operations/ops/json-render.ts +6 -2
  230. package/src/server/operations/ops/nodes.ts +8 -76
  231. package/src/server/operations/ops/query.ts +8 -2
  232. package/src/server/operations/ops/snapshots.ts +4 -1
  233. package/src/server/operations/ops/viewport.ts +29 -5
  234. package/src/server/operations/registry.ts +41 -5
  235. package/src/server/scope-fence.ts +55 -10
  236. package/src/server/server.ts +83 -5
  237. package/src/server/spatial-analysis.ts +2 -2
  238. package/src/server/workboard.ts +6 -1
  239. package/src/{server → shared}/agent-context.ts +2 -2
  240. package/src/shared/agent-presence.ts +3 -15
  241. package/src/shared/canvas-app.ts +62 -0
  242. package/src/shared/themes.ts +5 -5
  243. package/src/client/canvas/FocusFieldLayer.tsx +0 -116
  244. package/src/client/canvas/kind-colors.ts +0 -23
@@ -3,6 +3,7 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
4
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
5
5
  import { IconClose } from '../icons';
6
+ import { workbenchFetch } from '../state/workbench-transport';
6
7
  import {
7
8
  AX_SURFACE_ACK_SOURCE,
8
9
  AX_SURFACE_EMIT_SOURCE,
@@ -41,7 +42,7 @@ interface ExtAppHostDimensionsTarget {
41
42
  }
42
43
 
43
44
  async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
44
- const response = await fetch(url, {
45
+ const response = await workbenchFetch(url, {
45
46
  method: 'POST',
46
47
  headers: { 'Content-Type': 'application/json' },
47
48
  body: JSON.stringify(body),
@@ -150,7 +151,7 @@ function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: st
150
151
  extAppReportTimer = null;
151
152
  const entries = extAppReportQueue.splice(0);
152
153
  if (entries.length === 0) return;
153
- void fetch('/api/canvas/debug/ext-app-recovery', {
154
+ void workbenchFetch('/api/canvas/debug/ext-app-recovery', {
154
155
  method: 'POST',
155
156
  headers: { 'Content-Type': 'application/json' },
156
157
  body: JSON.stringify({ entries }),
@@ -1383,10 +1384,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1383
1384
  padding: '6px 10px',
1384
1385
  fontSize: '11px',
1385
1386
  background:
1386
- sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-10)',
1387
+ sessionStatus === 'error' && !sessionErrorWithContent
1388
+ ? 'color-mix(in srgb, var(--c-danger) 12%, transparent)'
1389
+ : 'color-mix(in srgb, var(--c-warn) 10%, transparent)',
1387
1390
  color: sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger)' : 'var(--c-warn)',
1388
1391
  borderBottom: `1px solid ${
1389
- sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-15)'
1392
+ sessionStatus === 'error' && !sessionErrorWithContent
1393
+ ? 'color-mix(in srgb, var(--c-danger) 12%, transparent)'
1394
+ : 'color-mix(in srgb, var(--c-warn) 15%, transparent)'
1390
1395
  }`,
1391
1396
  }}
1392
1397
  >
@@ -1398,9 +1403,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1398
1403
  style={{
1399
1404
  padding: '6px 10px',
1400
1405
  fontSize: '11px',
1401
- background: 'var(--c-danger-12)',
1406
+ background: 'color-mix(in srgb, var(--c-danger) 12%, transparent)',
1402
1407
  color: 'var(--c-danger)',
1403
- borderBottom: '1px solid var(--c-danger-12)',
1408
+ borderBottom: '1px solid color-mix(in srgb, var(--c-danger) 12%, transparent)',
1404
1409
  display: 'flex',
1405
1410
  alignItems: 'center',
1406
1411
  gap: '6px',
@@ -1417,7 +1422,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1417
1422
  }}
1418
1423
  style={{
1419
1424
  background: 'var(--c-surface-hover)',
1420
- border: '1px solid var(--c-danger-12)',
1425
+ border: '1px solid color-mix(in srgb, var(--c-danger) 12%, transparent)',
1421
1426
  borderRadius: '3px',
1422
1427
  color: 'var(--c-danger)',
1423
1428
  cursor: 'pointer',
@@ -1512,7 +1517,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1512
1517
  }}
1513
1518
  style={{
1514
1519
  background: 'var(--c-surface-hover)',
1515
- border: '1px solid var(--c-warn-15)',
1520
+ border: '1px solid color-mix(in srgb, var(--c-warn) 15%, transparent)',
1516
1521
  borderRadius: '4px',
1517
1522
  color: 'var(--c-warn)',
1518
1523
  cursor: 'pointer',
@@ -7,6 +7,7 @@ import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
7
7
  import type { CanvasNodeState } from '../types';
8
8
  import { runNodeAxInteraction } from './ax-node-actions';
9
9
  import { AttachmentNode } from './AttachmentNode';
10
+ import { useWorkbenchAsset } from './use-workbench-asset';
10
11
 
11
12
  /** Guess a language label from a file extension for display. */
12
13
  function langFromPath(path: string): string {
@@ -180,9 +181,11 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
180
181
  const parsed = parseDelimitedText(content, delimiter);
181
182
  return parsed.header.length >= 2 ? parsed : null;
182
183
  }, [content, delimiter, servesBytes]);
183
- const bytesUrl = `/api/canvas/file-bytes?nodeId=${encodeURIComponent(node.id)}${
184
- bytesVersion ? `&v=${bytesVersion}` : ''
185
- }`;
184
+ const bytesUrl = useWorkbenchAsset(
185
+ servesBytes
186
+ ? `/api/canvas/file-bytes?nodeId=${encodeURIComponent(node.id)}${bytesVersion ? `&v=${bytesVersion}` : ''}`
187
+ : '',
188
+ );
186
189
  // Amp orb portals block src-URL child iframes and force srcdoc, which cannot
187
190
  // inline a PDF — offer the raw URL instead of a dead frame.
188
191
  const pdfFrameBlocked = iframeMode.value === 'srcdoc';
@@ -204,7 +207,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
204
207
  display: 'flex',
205
208
  flexDirection: 'column',
206
209
  height: '100%',
207
- fontFamily: 'var(--mono)',
210
+ fontFamily: 'var(--font-code)',
208
211
  fontSize: expanded ? '13px' : '11px',
209
212
  }}
210
213
  >
@@ -223,7 +226,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
223
226
  style={{
224
227
  fontSize: '9px',
225
228
  padding: '1px 5px',
226
- background: 'var(--c-accent-12)',
229
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
227
230
  color: 'var(--c-accent)',
228
231
  borderRadius: '3px',
229
232
  fontWeight: 600,
@@ -12,6 +12,14 @@ export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
12
12
  return node.type === 'html' && node.data.presentation === true;
13
13
  }
14
14
 
15
+ export function htmlAxCapabilitySignature(value: unknown): string {
16
+ const capabilities = value as { enabled?: unknown; allowed?: unknown } | undefined;
17
+ if (capabilities?.enabled !== true) return 'disabled';
18
+ if (!Array.isArray(capabilities.allowed)) return 'enabled:default';
19
+ const allowed = [...new Set(capabilities.allowed.filter((item): item is string => typeof item === 'string'))].sort();
20
+ return allowed.length > 0 ? `enabled:${allowed.join(',')}` : 'disabled';
21
+ }
22
+
15
23
  export function HtmlNode({
16
24
  node,
17
25
  expanded = false,
@@ -27,10 +35,12 @@ export function HtmlNode({
27
35
  }) {
28
36
  const iframeRef = useRef<HTMLIFrameElement>(null);
29
37
  const theme = canvasTheme.value;
38
+ const axCapabilitySignature = htmlAxCapabilitySignature(node.data.axCapabilities);
30
39
  // Stable per-mount nonce that authorizes parent → iframe theme-update messages.
31
40
  const themeToken = useMemo(() => `theme-${crypto.randomUUID()}`, []);
32
- // Per-mount nonce authorizing iframe → parent AX emits (Phase 3 HTML bridge).
33
- const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
41
+ // Rotate the nonce when capabilities change: both the URL and injected srcdoc
42
+ // change, rebootstrapping the bridge and rejecting emits from the old document.
43
+ const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, [axCapabilitySignature]);
34
44
  // Per-mount nonce for the content-height reporter (node grows to fit content).
35
45
  const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
36
46
  const html =
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { IconRefresh } from '../icons';
3
3
  import type { CanvasNodeState } from '../types';
4
4
  import { getImageNodeWarnings } from './image-warnings';
5
+ import { useWorkbenchAsset } from './use-workbench-asset';
5
6
 
6
7
  /**
7
8
  * Image node renderer.
@@ -15,10 +16,11 @@ export function ImageNode({ node, expanded = false }: { node: CanvasNodeState; e
15
16
  const warnings = getImageNodeWarnings(node);
16
17
 
17
18
  // Determine the image source URL
18
- const imageSrc =
19
+ const imageSrc = useWorkbenchAsset(
19
20
  src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://')
20
21
  ? src
21
- : `/api/canvas/image/${node.id}`;
22
+ : `/api/canvas/image/${node.id}`,
23
+ );
22
24
 
23
25
  const [loaded, setLoaded] = useState(false);
24
26
  const [error, setError] = useState(false);
@@ -3,6 +3,7 @@ import TurndownService from 'turndown';
3
3
  import { useCallback, useEffect, useRef } from 'preact/hooks';
4
4
  import { InlineFormatBar } from './InlineFormatBar';
5
5
  import { promptAndInsertLink } from './inline-editor-commands';
6
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
6
7
 
7
8
  let _turndown: TurndownService | null = null;
8
9
  function getTurndown(): TurndownService {
@@ -45,7 +46,7 @@ export function InlineMarkdownEditor({
45
46
  useEffect(() => {
46
47
  const el = rootRef.current;
47
48
  if (!el) return;
48
- el.innerHTML = initialHtmlRef.current;
49
+ el.innerHTML = sanitizeMarkdownHtml(initialHtmlRef.current);
49
50
  // GFM task checkboxes are live in the editor too: marked emits them
50
51
  // `disabled`, which also swallows clicks in the expanded view.
51
52
  for (const box of el.querySelectorAll('input[type="checkbox"][disabled]')) {
@@ -34,7 +34,7 @@ export function LedgerNode({ node }: { node: CanvasNodeState }) {
34
34
  padding: '3px 0',
35
35
  borderBottom: i < lines.length - 1 ? '1px solid rgba(45,55,90,0.3)' : 'none',
36
36
  color: 'var(--c-text)',
37
- fontFamily: 'var(--mono)',
37
+ fontFamily: 'var(--font-code)',
38
38
  fontSize: '11px',
39
39
  whiteSpace: 'pre-wrap',
40
40
  wordBreak: 'break-word',
@@ -62,7 +62,7 @@ export function LedgerNode({ node }: { node: CanvasNodeState }) {
62
62
  <span
63
63
  style={{
64
64
  color: 'var(--c-text)',
65
- fontFamily: 'var(--mono)',
65
+ fontFamily: 'var(--font-code)',
66
66
  fontSize: '11px',
67
67
  textAlign: 'right',
68
68
  wordBreak: 'break-word',
@@ -1,13 +1,16 @@
1
1
  import type { JSX } from 'preact';
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
3
  import { IconArrowLeft } from '../icons';
4
- import { expandNode, updateNodeData } from '../state/canvas-store';
4
+ import { expandNode, nodes, updateNodeData } from '../state/canvas-store';
5
5
  import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
6
6
  import type { CanvasNodeState } from '../types';
7
7
  import { activeBoard, setBoardReadme } from '../state/boards-store';
8
8
  import { MdFormatBar } from './MdFormatBar';
9
9
  import { handleFormatShortcut, handleTab } from './md-format';
10
10
  import { InlineMarkdownEditor } from './InlineMarkdownEditor';
11
+ import { isHostedWorkbench } from '../state/workbench-transport';
12
+ import { activeBoardId } from '../state/boards-store';
13
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
11
14
 
12
15
  function RenderedMarkdown({
13
16
  html,
@@ -37,7 +40,7 @@ function RenderedMarkdown({
37
40
  container.replaceChildren();
38
41
  if (!html) return;
39
42
  const template = document.createElement('template');
40
- template.innerHTML = html;
43
+ template.innerHTML = sanitizeMarkdownHtml(html);
41
44
  container.append(template.content.cloneNode(true));
42
45
  if (onTaskToggleRef.current) {
43
46
  for (const box of container.querySelectorAll('input[type="checkbox"][disabled]')) {
@@ -98,9 +101,14 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
98
101
  // debounced save without capturing stale closures.
99
102
  const latestMdRef = useRef<string>('');
100
103
  const persistFnRef = useRef<((md: string) => Promise<void>) | null>(null);
104
+ const dirtyRef = useRef(false);
105
+ const editBase = useRef({ boardId: activeBoardId.value, revision: node.contentRevision ?? 0 });
106
+ const saveQueue = useRef(Promise.resolve());
101
107
  const reviewActive = node.data.reviewActive as boolean | undefined;
102
108
 
103
109
  useEffect(() => {
110
+ if (isHostedWorkbench() && dirtyRef.current) return;
111
+ editBase.current = { boardId: activeBoardId.value, revision: node.contentRevision ?? 0 };
104
112
  let cancelled = false;
105
113
  (async () => {
106
114
  let raw: string;
@@ -116,7 +124,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
116
124
  }
117
125
  setContent(raw);
118
126
  const html = await renderMarkdown(raw);
119
- if (cancelled) return;
127
+ if (cancelled || (isHostedWorkbench() && dirtyRef.current)) return;
120
128
  setRendered(html);
121
129
  setLoaded(true);
122
130
  updateNodeData(node.id, { content: raw, rendered: html });
@@ -128,6 +136,8 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
128
136
 
129
137
  const handleInput = useCallback(async (e: Event) => {
130
138
  const value = (e.target as HTMLTextAreaElement).value;
139
+ dirtyRef.current = true;
140
+ latestMdRef.current = value;
131
141
  setContent(value);
132
142
  setDirty(true);
133
143
  const html = await renderMarkdown(value);
@@ -136,6 +146,32 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
136
146
 
137
147
  const persistContent = useCallback(
138
148
  async (newContent: string) => {
149
+ if (isHostedWorkbench()) {
150
+ // Serialize autosaves against the captured source revision, not whatever
151
+ // newer revision a background poll has delivered while typing.
152
+ saveQueue.current = saveQueue.current.then(async () => {
153
+ setSaving(true);
154
+ const result = await updateNodeFromClient(node.id, {
155
+ content: newContent,
156
+ expectedBoardId: editBase.current.boardId,
157
+ expectedContentRevision: editBase.current.revision,
158
+ });
159
+ setSaving(false);
160
+ if (!result.ok) return; // Refusal toast is shown by the request helper; keep the draft.
161
+ editBase.current.revision = result.contentRevision ?? editBase.current.revision;
162
+ const current = nodes.value.get(node.id);
163
+ if (current)
164
+ nodes.value = new Map(nodes.value).set(node.id, { ...current, contentRevision: editBase.current.revision });
165
+ if (latestMdRef.current !== newContent) return;
166
+ dirtyRef.current = false;
167
+ setDirty(false);
168
+ const html = await renderMarkdown(newContent);
169
+ setRendered(html);
170
+ updateNodeData(node.id, { content: newContent, rendered: html });
171
+ });
172
+ await saveQueue.current;
173
+ return;
174
+ }
139
175
  if (!path) {
140
176
  const html = await renderMarkdown(newContent);
141
177
  setRendered(html);
@@ -191,6 +227,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
191
227
  // don't hit the backend on every letter.
192
228
  const handleInlineChange = useCallback(
193
229
  (md: string) => {
230
+ dirtyRef.current = true;
194
231
  setContent(md);
195
232
  setDirty(true);
196
233
  latestMdRef.current = md;
@@ -208,6 +245,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
208
245
  // write twice with slightly different content.
209
246
  const handleInlineSave = useCallback(
210
247
  (md: string) => {
248
+ dirtyRef.current = true;
211
249
  setContent(md);
212
250
  latestMdRef.current = md;
213
251
  if (persistTimerRef.current !== null) {
@@ -236,9 +274,9 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
236
274
  style={{
237
275
  padding: '4px 8px',
238
276
  fontSize: '10px',
239
- background: 'var(--c-ok-10)',
277
+ background: 'color-mix(in srgb, var(--c-ok) 10%, transparent)',
240
278
  color: 'var(--c-ok)',
241
- borderBottom: '1px solid var(--c-ok-20)',
279
+ borderBottom: '1px solid color-mix(in srgb, var(--c-ok) 20%, transparent)',
242
280
  textTransform: 'uppercase',
243
281
  letterSpacing: '0.05em',
244
282
  fontWeight: 600,
@@ -274,9 +312,6 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
274
312
  <textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
275
313
  <MdFormatBar textareaRef={textareaRef} />
276
314
  </div>
277
- {/* D5/H4: Trust boundary — rendered HTML comes from server-side marked()
278
- on the user's own markdown files, served only on 127.0.0.1. No DOMPurify
279
- needed for this localhost-only rendering of user-owned content. */}
280
315
  <RenderedMarkdown html={rendered} className="md-preview" />
281
316
  </div>
282
317
  </div>
@@ -322,7 +357,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
322
357
  style={{
323
358
  padding: '4px 10px',
324
359
  fontSize: '11px',
325
- background: dirty ? 'var(--c-accent-25)' : 'var(--c-input-bg)',
360
+ background: dirty ? 'color-mix(in srgb, var(--c-accent) 25%, transparent)' : 'var(--c-input-bg)',
326
361
  border: `1px solid ${dirty ? 'var(--c-accent)' : 'var(--c-line)'}`,
327
362
  borderRadius: '6px',
328
363
  color: dirty ? 'var(--c-text)' : 'var(--c-dim)',
@@ -11,6 +11,7 @@ import { useAxSurfaceBridge } from './use-ax-surface-bridge';
11
11
  import { useIframeContentHeight } from './use-iframe-content-height';
12
12
  import { useSurfaceFrame } from './use-surface-frame';
13
13
  import { restoreViewerRuntimeState } from './viewer-runtime-state';
14
+ import { isHostedWorkbench } from '../state/workbench-transport';
14
15
 
15
16
  function withViewerParams(
16
17
  url: string,
@@ -24,7 +25,8 @@ function withViewerParams(
24
25
  ): string {
25
26
  if (!url) return url;
26
27
  try {
27
- const resolved = new URL(url, window.location.origin);
28
+ const hosted = isHostedWorkbench() && url.startsWith('/');
29
+ const resolved = new URL(url, hosted ? 'https://pmx.invalid' : window.location.origin);
28
30
  // The json-render viewer + artifacts understand dark/light — collapse named
29
31
  // themes (sepia → light, midnight/arctic/ember/forest → dark) to a scheme.
30
32
  resolved.searchParams.set('theme', canvasThemeScheme(canvasTheme.value));
@@ -41,7 +43,7 @@ function withViewerParams(
41
43
  if (frameToken) resolved.searchParams.set('frameToken', frameToken);
42
44
  if (fitContent) resolved.searchParams.set('fit', 'content');
43
45
  if (stateToken) resolved.searchParams.set('stateToken', stateToken);
44
- return resolved.toString();
46
+ return hosted ? `${resolved.pathname}${resolved.search}` : resolved.toString();
45
47
  } catch {
46
48
  return url;
47
49
  }
@@ -283,9 +285,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
283
285
  style={{
284
286
  padding: '4px 8px',
285
287
  fontSize: '10px',
286
- background: 'var(--c-warn-10)',
288
+ background: 'color-mix(in srgb, var(--c-warn) 10%, transparent)',
287
289
  color: 'var(--c-warn)',
288
- borderBottom: '1px solid var(--c-warn-15)',
290
+ borderBottom: '1px solid color-mix(in srgb, var(--c-warn) 15%, transparent)',
289
291
  }}
290
292
  >
291
293
  Unverified domain
@@ -3,6 +3,7 @@ import { IconClose } from '../icons';
3
3
  import { updateNodeData } from '../state/canvas-store';
4
4
  import { fetchSlashCommands, renderMarkdown, submitCanvasPrompt, submitThreadReply } from '../state/intent-bridge';
5
5
  import type { CanvasNodeState } from '../types';
6
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
6
7
 
7
8
  // Cached slash commands — fetched once on first use.
8
9
  let cachedCommands: Array<{ name: string; description: string }> | null = null;
@@ -27,19 +28,6 @@ interface ThreadTurn {
27
28
  status?: string;
28
29
  }
29
30
 
30
- /** Strip dangerous HTML from rendered markdown to prevent XSS. */
31
- function sanitizeHtml(html: string): string {
32
- return html
33
- .replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
34
- .replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
35
- .replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
36
- .replace(/<embed[\s>][\s\S]*?(?:\/>|<\/embed>)/gi, '')
37
- .replace(/<link[\s>][\s\S]*?(?:\/>|<\/link>)/gi, '')
38
- .replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
39
- .replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
40
- .replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
41
- }
42
-
43
31
  function RenderedMarkdown({ html }: { html: string }) {
44
32
  const containerRef = useRef<HTMLDivElement>(null);
45
33
 
@@ -50,7 +38,7 @@ function RenderedMarkdown({ html }: { html: string }) {
50
38
  if (!html) return;
51
39
 
52
40
  const template = document.createElement('template');
53
- template.innerHTML = html;
41
+ template.innerHTML = sanitizeMarkdownHtml(html);
54
42
  container.append(template.content.cloneNode(true));
55
43
  }, [html]);
56
44
 
@@ -181,7 +169,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
181
169
  Promise.all(
182
170
  assistantTurns.map(async ({ turn, index }) => {
183
171
  const html = await renderMarkdown(turn.text);
184
- return { index, html: sanitizeHtml(html) };
172
+ return { index, html: sanitizeMarkdownHtml(html) };
185
173
  }),
186
174
  ).then((results) => {
187
175
  if (cancelled) return;
@@ -328,7 +316,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
328
316
  border: '1px solid var(--c-line)',
329
317
  borderRadius: 'var(--radius-sm)',
330
318
  color: 'var(--c-text)',
331
- fontFamily: 'var(--font)',
319
+ fontFamily: 'var(--font-ui)',
332
320
  fontSize: '13px',
333
321
  lineHeight: '1.5',
334
322
  padding: '8px 10px',
@@ -529,7 +517,7 @@ export function PromptNode({ node, expanded = false }: { node: CanvasNodeState;
529
517
  border: '1px solid var(--c-line)',
530
518
  borderRadius: 'var(--radius-sm)',
531
519
  color: 'var(--c-text)',
532
- fontFamily: 'var(--font)',
520
+ fontFamily: 'var(--font-ui)',
533
521
  fontSize: '12px',
534
522
  lineHeight: '1.5',
535
523
  padding: '6px 8px',
@@ -1,19 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { renderMarkdown, submitCanvasPrompt } from '../state/intent-bridge';
3
3
  import type { CanvasNodeState } from '../types';
4
-
5
- /** Strip dangerous HTML from rendered markdown to prevent XSS. */
6
- function sanitizeHtml(html: string): string {
7
- return html
8
- .replace(/<script[\s>][\s\S]*?<\/script>/gi, '')
9
- .replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, '')
10
- .replace(/<object[\s>][\s\S]*?<\/object>/gi, '')
11
- .replace(/<embed[\s>][\s\S]*?(?:\/>|<\/embed>)/gi, '')
12
- .replace(/<link[\s>][\s\S]*?(?:\/>|<\/link>)/gi, '')
13
- .replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
14
- .replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href="#"')
15
- .replace(/href\s*=\s*'javascript:[^']*'/gi, "href='#'");
16
- }
4
+ import { sanitizeMarkdownHtml } from './markdown-sanitize';
17
5
 
18
6
  function RenderedMarkdown({ html }: { html: string }) {
19
7
  const containerRef = useRef<HTMLDivElement>(null);
@@ -25,7 +13,7 @@ function RenderedMarkdown({ html }: { html: string }) {
25
13
  if (!html) return;
26
14
 
27
15
  const template = document.createElement('template');
28
- template.innerHTML = html;
16
+ template.innerHTML = sanitizeMarkdownHtml(html);
29
17
  container.append(template.content.cloneNode(true));
30
18
  }, [html]);
31
19
 
@@ -48,7 +36,7 @@ export function ResponseNode({ node, expanded = false }: { node: CanvasNodeState
48
36
  }
49
37
  let cancelled = false;
50
38
  renderMarkdown(content).then((html) => {
51
- if (!cancelled) setRendered(sanitizeHtml(html));
39
+ if (!cancelled) setRendered(sanitizeMarkdownHtml(html));
52
40
  });
53
41
  return () => {
54
42
  cancelled = true;
@@ -64,9 +64,9 @@ export function StatusNode({ node }: { node: CanvasNodeState }) {
64
64
 
65
65
  {/* Active tool */}
66
66
  {activeTool && (
67
- <div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--c-warn)' }}>
67
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--c-agent)' }}>
68
68
  <span style={{ fontSize: '10px' }}>⚙</span>
69
- <span style={{ fontFamily: 'var(--mono)', fontSize: '11px' }}>{activeTool}</span>
69
+ <span style={{ fontFamily: 'var(--font-code)', fontSize: '11px' }}>{activeTool}</span>
70
70
  </div>
71
71
  )}
72
72
 
@@ -23,7 +23,9 @@ export function StatusSummary({ node }: { node: CanvasNodeState }) {
23
23
  />
24
24
  <span style={{ color: phaseColor, fontSize: '10px', textTransform: 'uppercase', fontWeight: 600 }}>{phase}</span>
25
25
  {activeTool && (
26
- <span style={{ color: 'var(--c-warn)', fontSize: '10px', fontFamily: 'var(--mono)' }}>⚙ {activeTool}</span>
26
+ <span style={{ color: 'var(--c-agent)', fontSize: '10px', fontFamily: 'var(--font-code)' }}>
27
+ ⚙ {activeTool}
28
+ </span>
27
29
  )}
28
30
  {subagent && subagent.state !== 'completed' && (
29
31
  <span style={{ color: 'var(--c-subagent)', fontSize: '10px' }}>⠉ {subagent.name}</span>
@@ -1,10 +1,12 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
  import { buildTraceDisplayModel } from './trace-model';
3
3
 
4
+ // Categories carry no colour (docs/design.md, "Kinds carry no colour"); only a
5
+ // subagent's work takes its meaning colour.
4
6
  const CATEGORY_COLORS: Record<string, string> = {
5
- mcp: 'var(--c-accent)',
6
- file: 'var(--c-warn)',
7
- subagent: 'var(--c-purple)',
7
+ mcp: 'var(--c-muted)',
8
+ file: 'var(--c-muted)',
9
+ subagent: 'var(--c-subagent)',
8
10
  other: 'var(--c-muted)',
9
11
  };
10
12
 
@@ -68,7 +70,7 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
68
70
  >
69
71
  <div
70
72
  style={{
71
- fontFamily: 'var(--mono)',
73
+ fontFamily: 'var(--font-code)',
72
74
  fontSize: '11px',
73
75
  fontWeight: 600,
74
76
  color: catColor,
@@ -56,7 +56,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
56
56
  return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No webpage URL set</div>;
57
57
  }
58
58
 
59
- const statusTone = status === 'ready' ? 'var(--c-ok)' : status === 'error' ? 'var(--c-danger)' : 'var(--c-warn)';
59
+ const statusTone = status === 'ready' ? 'var(--c-ok)' : status === 'error' ? 'var(--c-danger)' : 'var(--c-muted)';
60
60
 
61
61
  return (
62
62
  <div style={{ display: 'flex', flexDirection: 'column', gap: '12px', height: '100%' }}>
@@ -27,8 +27,8 @@ export async function runNodeAxInteraction(
27
27
  export const axNodeActionButtonStyle = {
28
28
  padding: '3px 8px',
29
29
  fontSize: '10px',
30
- background: 'var(--c-accent-12)',
31
- border: '1px solid var(--c-accent-25)',
30
+ background: 'color-mix(in srgb, var(--c-accent) 12%, transparent)',
31
+ border: '1px solid color-mix(in srgb, var(--c-accent) 25%, transparent)',
32
32
  borderRadius: '4px',
33
33
  color: 'var(--c-text-soft)',
34
34
  cursor: 'pointer',
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { workbenchConnectionEpoch } from '../state/canvas-store';
3
3
  import { iframeMode } from '../state/iframe-mode';
4
4
  import { surfaceContentHash } from './surface-url';
5
+ import { workbenchFetch } from '../state/workbench-transport';
5
6
 
6
7
  interface FrameDocumentCreateResponse {
7
8
  ok: boolean;
@@ -21,7 +22,7 @@ function isFrameDocumentCreateResponse(value: unknown): value is FrameDocumentCr
21
22
  }
22
23
 
23
24
  export async function createIframeDocumentUrl(html: string, sandbox: string): Promise<string> {
24
- const response = await fetch('/api/canvas/frame-documents', {
25
+ const response = await workbenchFetch('/api/canvas/frame-documents', {
25
26
  method: 'POST',
26
27
  headers: { 'Content-Type': 'application/json' },
27
28
  body: JSON.stringify({ html, sandbox }),
@@ -83,7 +84,7 @@ export function useIframeDocument(
83
84
  return;
84
85
  }
85
86
  let cancelled = false;
86
- void fetch(src, { method: 'HEAD' })
87
+ void workbenchFetch(src, { method: 'HEAD' })
87
88
  .then((res) => {
88
89
  if (cancelled) return;
89
90
  if (res.ok) {
@@ -0,0 +1,10 @@
1
+ import DOMPurify from 'dompurify';
2
+
3
+ /** Sanitize rendered Markdown before it enters the privileged workbench DOM. */
4
+ export function sanitizeMarkdownHtml(html: string): string {
5
+ return DOMPurify.sanitize(html, {
6
+ // Markdown does not need these active document surfaces. Keep regular
7
+ // inputs so marked's GFM task checkboxes remain interactive.
8
+ FORBID_TAGS: ['form', 'svg', 'math'],
9
+ });
10
+ }
@@ -2,6 +2,8 @@ import { canvasTheme } from '../state/canvas-store';
2
2
  import { openNodeInSystemBrowserRequest } from '../state/intent-bridge';
3
3
  import { canOpenNodeAsSurface } from '../../shared/surface.js';
4
4
  import type { CanvasNodeState } from '../types';
5
+ import { isHostedWorkbench } from '../state/workbench-transport';
6
+ import { showToast } from '../state/attention-bridge';
5
7
 
6
8
  /**
7
9
  * Stable content hash (djb2) used to cache-bust the surface iframe `src` when a
@@ -52,6 +54,14 @@ export function canOpenAsSite(node: CanvasNodeState): boolean {
52
54
  * and headless/disabled-browser environments.
53
55
  */
54
56
  export async function openNodeAsSite(node: CanvasNodeState): Promise<void> {
57
+ if (isHostedWorkbench()) {
58
+ showToast(
59
+ 'context',
60
+ 'Open in focus mode',
61
+ 'This surface has no public URL. Use Expand to view it here, or export the board to download it.',
62
+ );
63
+ return;
64
+ }
55
65
  const url = nodeSurfaceUrl(node.id);
56
66
  const res = await openNodeInSystemBrowserRequest(node.id, url);
57
67
  if (!res.opened) window.open(url, '_blank', 'noopener');
@@ -1,7 +1,9 @@
1
1
  import { useEffect, useState } from 'preact/hooks';
2
2
  import { iframeMode } from '../state/iframe-mode';
3
+ import { isHostedWorkbench, workbenchFetch } from '../state/workbench-transport';
3
4
 
4
5
  function isSameOriginUrl(url: string): boolean {
6
+ if (isHostedWorkbench()) return url.startsWith('/') && !url.startsWith('//');
5
7
  try {
6
8
  return new URL(url, window.location.origin).origin === window.location.origin;
7
9
  } catch {
@@ -29,7 +31,7 @@ export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string }
29
31
  let cancelled = false;
30
32
  // Ask the server to inline bundle assets (the mermaid renderer): hosts
31
33
  // that force srcdoc mode block sub-frame subresource requests too.
32
- void fetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
34
+ void workbenchFetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
33
35
  .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
34
36
  .then((html) => {
35
37
  if (!cancelled) setDoc({ url, html });