pmx-canvas 0.4.1 → 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.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Canonical canvas theme registry — single source of truth for every layer
3
+ * that validates, stores, or renders a theme name (client shell, HTML
4
+ * surfaces, json-render viewer, CLI/env plumbing, persistence).
5
+ *
6
+ * Adding a theme: add the name here, author its variable blocks in
7
+ * src/client/theme/global.css AND src/client/theme/surface-theme.css (guarded
8
+ * by tests/unit/surface-theme-tokens.test.ts), and give it meta below. The
9
+ * theme picker, server validation, and surface documents pick it up from this
10
+ * list.
11
+ */
12
+ export declare const CANVAS_THEMES: readonly ["dark", "light", "high-contrast", "midnight", "sepia", "arctic", "ember", "forest"];
13
+ export type CanvasThemeName = (typeof CANVAS_THEMES)[number];
14
+ export declare function isCanvasTheme(value: unknown): value is CanvasThemeName;
15
+ export declare function normalizeCanvasThemeName(value: unknown, fallback?: CanvasThemeName): CanvasThemeName;
16
+ export interface CanvasThemeMeta {
17
+ /** Human label shown in the theme picker. */
18
+ label: string;
19
+ /** Whether embedded viewers that only know dark/light should treat it as light. */
20
+ scheme: 'dark' | 'light';
21
+ /** Picker swatch colors — MUST match --c-bg / --c-accent in global.css. */
22
+ swatchBg: string;
23
+ swatchAccent: string;
24
+ }
25
+ export declare const CANVAS_THEME_META: Record<CanvasThemeName, CanvasThemeMeta>;
26
+ /** Collapse a theme to the dark/light scheme embedded dark|light-only viewers understand. */
27
+ export declare function canvasThemeScheme(value: unknown): 'dark' | 'light';
package/docs/cli.md CHANGED
@@ -20,7 +20,7 @@ pmx-canvas # Start canvas, open browser
20
20
  pmx-canvas --demo # Start with the saved dashboard demo board
21
21
  pmx-canvas --port=8080 # Custom port
22
22
  pmx-canvas --no-open # Headless (for agents/CI)
23
- pmx-canvas --theme=light # dark | light | high-contrast
23
+ pmx-canvas --theme=light # dark | light | high-contrast | midnight | sepia | arctic | ember | forest
24
24
  pmx-canvas --mcp # Run as MCP server (stdio)
25
25
  pmx-canvas --webview-automation # Start headless Bun.WebView session
26
26
  pmx-canvas open # Open the current workbench in a browser
@@ -25,7 +25,8 @@ optional; defaults are listed.
25
25
 
26
26
  | Variable | Read by | Effect |
27
27
  |---|---|---|
28
- | `PMX_CANVAS_THEME` | server | Startup theme: `dark` (default), `light`, `high-contrast`. |
28
+ | `PMX_CANVAS_THEME` | server | Startup theme: `dark` (default), `light`, `high-contrast`, `midnight`, `sepia`, `arctic`, `ember`, `forest`. |
29
+ | `AMP_ORB` | server | Set by Amp orb services. When present, the served canvas page tells the browser it is running in an orb, which skips the iframe embed probe and forces `srcdoc` surface rendering when embedded (the orb portal blocks `src`-URL child iframes and the probe is unreliable there). |
29
30
  | `PMX_CANVAS_DISABLE_BROWSER_OPEN` | server | `1` suppresses auto-opening the browser (used by tests/CI). |
30
31
  | `PMX_CANVAS_DIST` | server | Explicit client bundle directory to serve instead of the packaged `dist/canvas`. |
31
32
  | `PMX_CANVAS_WEBVIEW_TIMEOUT_MS` | server | Startup timeout for the Bun.WebView automation session. |
package/docs/http-api.md CHANGED
@@ -363,6 +363,13 @@ If that iframe never loads, the client switches every same-origin surface
363
363
  fetched cross-origin. Force a mode with `/workbench?iframe-mode=srcdoc` (or
364
364
  `iframe-mode=src`).
365
365
 
366
+ In Amp orbs the probe itself is unreliable (a tiny probe iframe can load even
367
+ though node-sized ones will not), so when the server runs with `AMP_ORB` set
368
+ (orb services always do) it stamps `window.__PMX_AMP_ORB` into the page and
369
+ the embedded client forces `srcdoc` without probing. HTML surface documents
370
+ also inline their theme stylesheet, so srcdoc-rendered surfaces stay styled
371
+ without any subresource load.
372
+
366
373
  `/api/canvas/frame-documents/:id` also answers `HEAD`, which the browser uses
367
374
  after every reconnect to detect frame documents lost to a server restart and
368
375
  re-mint them automatically (Finding S).
@@ -66,13 +66,13 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
66
66
  |---|---|
67
67
  | Windows web-artifacts | The artifact build pipeline is bash-script-backed (POSIX-only); `tests/unit/web-artifacts.test.ts` self-skips on win32 and docs now state the boundary. Decide: port the build scripts to a cross-platform runner, or keep the documented "build on POSIX, render anywhere" stance. |
68
68
  | Composite parity no longer machine-checked | Since v0.3.0 removed the legacy standalones, the byte-identical composite-vs-legacy guarantee has no head-to-head test — regressions in `buildInput`/`formatResult` reuse surface only via output-shape assertions. Accepted at review; revisit if a composite drifts. |
69
- | Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap", inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. The 0.4.0 Copilot report keeps Finding N open (residual black after settled recovery; enlarge/close still the reliable unstick) — 0.4.1 ships a cheaper mimic first (post-settle WebKit repaint nudge, `repaint-nudge` trail event). If the next Copilot cycle still fails with the nudge in place, THIS contingency is the next step. |
69
+ | Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap", inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. 0.4.1 shipped the cheaper mimic (post-settle WebKit repaint nudge, `repaint-nudge` trail event) and the 0.4.1 Copilot report confirms the nudge RUNS but residual black tiles still appear (Diamond on first-load settle, Ellipse after restart settle) — the contingency's trigger condition is now met. This portal-layer mount is the designated next Finding N step; scope it as its own project (drag/zoom sync, z-order, clipping, minimap interplay), not a patch-release bolt-on. |
70
70
 
71
71
  ## Added post-0.4.0 (2026-08-04 report cycle)
72
72
 
73
73
  | Item | What's open |
74
74
  |---|---|
75
- | Finding S — frame documents vs server restart | **Done (2026-08-04):** the browser revalidates minted frame-document URLs on every reconnect epoch (`workbenchConnectionEpoch`, HEAD probe) and re-mints against the new process when the store is gone; `/api/canvas/frame-documents/:id` answers HEAD. Confirmed-fix pending the next Copilot/Codex kill-respawn cycle. Server-side persistence of frame docs deliberately NOT done — they are client-derived and re-mintable. |
75
+ | Finding S — frame documents vs server restart | **Done + field-confirmed (0.4.1 reports, both hosts):** the browser revalidates minted frame-document URLs on every reconnect epoch (`workbenchConnectionEpoch`, HEAD probe) and re-mints against the new process when the store is gone; `/api/canvas/frame-documents/:id` answers HEAD. Copilot: IDs fully rotated, all post-restart 200, no frame-not-found tiles. Codex: re-mint without workbench reload. Server-side persistence deliberately NOT done — frames are client-derived and re-mintable. |
76
76
  | Amp portal nested-iframe embedding | **Done (2026-08-04):** boot-time embed probe (`/api/canvas/iframe-probe`) + fetch()+srcdoc fallback for all same-origin surfaces (`useSurfaceFrame`, `useIframeDocument` srcdoc short-circuit), `?iframe-mode` override, e2e-forced-mode coverage. External (cross-origin) app URLs cannot be inlined — documented limitation. |
77
77
  | Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; re-verify next cycle. |
78
78
  | Registry-migration dead-code sweep | **Done (2026-08-03):** removed the orphaned `describeOp`/`describeGateOp` (composites.ts, dead since the v0.3.0 registration filter) and the `compactNodePayload`/`compactLayoutPayload`/`wantsFullPayload` duplicates in `mcp/server.ts` (live copies are in `ops/nodes.ts`). |
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Spatial canvas workbench for coding agents \u2014 infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -99,6 +99,10 @@ json-render, a graph, or an HTML primitive is sufficient.
99
99
 
100
100
  ## Current MCP Composites
101
101
 
102
+ The live MCP surface is **22 tools**: the 16 composites below plus 6 standalones (v0.4.x —
103
+ this table is kept in sync with the server's composite registry; `tools/list` on a fresh
104
+ `pmx-canvas --mcp` is always authoritative).
105
+
102
106
  | Composite | Actions |
103
107
  |-----------|---------|
104
108
  | `canvas_node` | `add`, `get`, `update`, `remove` |
@@ -171,6 +175,12 @@ Use the visible workbench when the human is actively curating layout:
171
175
  - Drop files or URLs to create matching nodes.
172
176
  - Double-click markdown to edit inline.
173
177
  - Use toolbar snapshots before experiments and restore only after confirmation.
178
+ - The toolbar carries view controls (fit, reset, zoom), arrange, minimap, a theme picker (eight
179
+ themes: dark, light, high-contrast, midnight, sepia, arctic, ember, forest), snapshots, trace,
180
+ the annotation tools, search (Cmd+K), and shortcuts. On narrow viewports (≤900px — most
181
+ embedded host panes and mobile) the secondary controls collapse into a single "⋯" More menu at
182
+ the end of the bar; every action is still there as a labeled row, including the theme list. If
183
+ an automation script can't find a toolbar button at panel width, open the More menu first.
174
184
 
175
185
  After changing files under `src/client/`, rebuild with `bun run build` before manual browser
176
186
  verification.
@@ -250,9 +260,11 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
250
260
  iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
251
261
  with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
252
262
  same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
253
- documents). External app URLs cannot be inlined (cross-origin) and may stay blocked in such
254
- hosts. Force a mode with `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when
255
- diagnosing.
263
+ documents). In Amp orbs specifically, the server sees `AMP_ORB` in its environment and stamps
264
+ the page so the embedded client skips the (there-unreliable) probe and goes straight to
265
+ `srcdoc`; HTML surfaces also inline their theme stylesheet so they render styled. External app
266
+ URLs cannot be inlined (cross-origin) and may stay blocked in such hosts. Force a mode with
267
+ `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when diagnosing.
256
268
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
257
269
  reflow on a live window resize in a normal browser. Some single-tab host browsers historically
258
270
  didn't deliver live-resize events (current Codex builds do — the 0.4.0 retest saw a standalone
@@ -132,7 +132,7 @@ pmx-canvas --theme=light # Light theme
132
132
  pmx-canvas --version # Print installed version and exit
133
133
  ```
134
134
 
135
- `--theme` accepts `dark` (default), `light`, or `high-contrast`. Same value can be set via
135
+ `--theme` accepts `dark` (default), `light`, `high-contrast`, `midnight`, `sepia`, `arctic`, `ember`, or `forest` (0.4.x; the browser toolbar has a matching theme picker). Same value can be set via
136
136
  the `PMX_CANVAS_THEME` environment variable, or toggled live in the browser toolbar.
137
137
 
138
138
  Start the canvas once per session, then reuse it. Use `--no-open` when running as an agent — the
@@ -420,8 +420,8 @@ When you do set a color (group/graph), use this palette consistently to convey m
420
420
 
421
421
  ## MCP Tools Reference
422
422
 
423
- PMX Canvas exposes **15 action-discriminated composites** (the whole recommended surface) plus a
424
- set of first-class standalones. The composites fold the older
423
+ PMX Canvas exposes **16 action-discriminated composites** (the whole recommended surface) plus
424
+ **6 first-class standalones** — 22 tools total (v0.4.x). The composites fold the older
425
425
  single-purpose tools behind an `action` (and, for `canvas_ax_gate`, a `kind`) discriminator —
426
426
  **field names are unchanged**; only the tool name + the `action`/`kind` selector differ.
427
427
 
package/src/cli/index.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { showServeStatus, startDaemonMode, stopServeDaemon } from './daemon.js';
7
7
  import { runAgentCli } from './agent.js';
8
+ import { isCanvasTheme } from '../shared/themes.js';
8
9
  import { createCanvas } from '../server/index.js';
9
10
  import { seedDemoCanvas } from '../server/demo.js';
10
11
  import { ensureDefaultDockedNodes } from '../server/server.js';
@@ -231,7 +232,7 @@ if (AGENT_COMMANDS.has(routedCommand) && routedCommand !== 'serve') {
231
232
  const daemonWaitMs = readNumberOption('wait-ms') ?? 10_000;
232
233
  const webviewBackendOption: 'chrome' | 'webkit' | undefined =
233
234
  webviewBackend === 'chrome' || webviewBackend === 'webkit' ? webviewBackend : undefined;
234
- if (themeArg && ['dark', 'light', 'high-contrast'].includes(themeArg)) {
235
+ if (themeArg && isCanvasTheme(themeArg)) {
235
236
  process.env.PMX_CANVAS_THEME = themeArg;
236
237
  }
237
238
  const help = hasFlag('help') || args.includes('-h');
@@ -252,7 +253,7 @@ Server options:
252
253
  --log-file=PATH Daemon log file (default: ./.pmx-canvas/daemon-${port}.log)
253
254
  --pid-file=PATH Optional daemon PID file (default: ./.pmx-canvas/daemon-${port}.pid)
254
255
  --wait-ms=MS Health-check wait budget for daemon mode (default: 10000)
255
- --theme=THEME Theme: dark (default), light, high-contrast
256
+ --theme=THEME Theme: dark (default), light, high-contrast, midnight, sepia, arctic, ember, forest
256
257
  --webview-automation Start a headless Bun.WebView automation session for /workbench
257
258
  --webview-backend=BACKEND Bun.WebView backend: chrome or webkit
258
259
  --webview-width=PX Automation WebView width (default: 1280)
@@ -61,6 +61,13 @@ import {
61
61
  } from './icons';
62
62
  import { invalidateTokenCache } from './theme/tokens';
63
63
  import { MOD_KEY } from './utils/platform';
64
+ import {
65
+ CANVAS_THEMES,
66
+ CANVAS_THEME_META,
67
+ type CanvasThemeName,
68
+ canvasThemeScheme,
69
+ normalizeCanvasThemeName,
70
+ } from '../shared/themes.js';
64
71
 
65
72
  function logAppError(action: string, error: unknown): void {
66
73
  console.error(`[app] ${action} failed`, error);
@@ -153,8 +160,66 @@ function Toolbar({
153
160
  ].join(' · ')
154
161
  : 'Syncing canvas…';
155
162
 
163
+ // Popover state: the theme picker (desktop toolbar) and the mobile "More"
164
+ // menu share one open-slot so at most one popover shows at a time.
165
+ const [openMenu, setOpenMenu] = useState<null | 'theme' | 'more'>(null);
166
+ const groupRef = useRef<HTMLDivElement>(null);
167
+
168
+ useEffect(() => {
169
+ if (!openMenu) return;
170
+ const onPointerDown = (e: PointerEvent) => {
171
+ if (groupRef.current && e.target instanceof Node && !groupRef.current.contains(e.target)) {
172
+ setOpenMenu(null);
173
+ }
174
+ };
175
+ const onKeyDown = (e: KeyboardEvent) => {
176
+ if (e.key === 'Escape') setOpenMenu(null);
177
+ };
178
+ document.addEventListener('pointerdown', onPointerDown);
179
+ document.addEventListener('keydown', onKeyDown);
180
+ return () => {
181
+ document.removeEventListener('pointerdown', onPointerDown);
182
+ document.removeEventListener('keydown', onKeyDown);
183
+ };
184
+ }, [openMenu]);
185
+
186
+ const applyTheme = (next: CanvasThemeName) => {
187
+ document.documentElement.setAttribute('data-theme', next);
188
+ invalidateTokenCache();
189
+ canvasTheme.value = next;
190
+ void saveCanvasTheme(next);
191
+ setOpenMenu(null);
192
+ };
193
+
194
+ const menuAction = (fn: () => void) => () => {
195
+ fn();
196
+ setOpenMenu(null);
197
+ };
198
+
199
+ const activeTheme = normalizeCanvasThemeName(canvasTheme.value);
200
+ const themeMenuItems = CANVAS_THEMES.map((name) => (
201
+ <button
202
+ key={name}
203
+ type="button"
204
+ role="menuitemradio"
205
+ aria-checked={activeTheme === name}
206
+ class={`toolbar-menu-item${activeTheme === name ? ' active' : ''}`}
207
+ onClick={() => applyTheme(name)}
208
+ >
209
+ <span class="theme-swatch" style={{ background: CANVAS_THEME_META[name].swatchBg }}>
210
+ <span class="theme-swatch-dot" style={{ background: CANVAS_THEME_META[name].swatchAccent }} />
211
+ </span>
212
+ <span>{CANVAS_THEME_META[name].label}</span>
213
+ {activeTheme === name && (
214
+ <span class="toolbar-menu-check" aria-hidden="true">
215
+ ✓
216
+ </span>
217
+ )}
218
+ </button>
219
+ ));
220
+
156
221
  return (
157
- <div class="toolbar-group">
222
+ <div class="toolbar-group" ref={groupRef}>
158
223
  {/* ── Navigation Bar ──────────────────────────────────── */}
159
224
  <div class="canvas-toolbar">
160
225
  <ToolbarHint label="PMX Canvas" detail="Focus Field · spatial workbench for coding agents" align="start">
@@ -213,63 +278,81 @@ function Toolbar({
213
278
  {Math.round(v.scale * 100)}%
214
279
  </span>
215
280
 
216
- <div class="separator" />
281
+ {/* Secondary controls collapse into the More menu on narrow screens. */}
282
+ <span class="toolbar-collapsible">
283
+ <div class="separator" />
217
284
 
218
- <ToolbarHint
219
- label="Arrange layout"
220
- detail={edgeCount > 0 ? 'Graph-aware layout for connected nodes' : 'Grid layout for loose nodes'}
221
- >
222
- <button
223
- type="button"
224
- onClick={() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange())}
225
- aria-label="Arrange layout"
226
- >
227
- <IconArrange />
228
- </button>
229
- </ToolbarHint>
230
- <ToolbarHint label={minimapVisible ? 'Hide minimap' : 'Show minimap'} detail="Quickly navigate large canvases">
231
- <button
232
- type="button"
233
- onClick={onToggleMinimap}
234
- aria-label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
235
- style={{ color: minimapVisible ? 'var(--c-accent)' : undefined }}
236
- >
237
- <IconMinimap />
238
- </button>
239
- </ToolbarHint>
240
- <ToolbarHint
241
- label={`Switch to ${canvasTheme.value === 'dark' ? 'light' : 'dark'} theme`}
242
- detail={`Current theme: ${canvasTheme.value}`}
243
- >
244
- <button
245
- type="button"
246
- onClick={() => {
247
- const next = canvasTheme.value === 'dark' ? 'light' : 'dark';
248
- document.documentElement.setAttribute('data-theme', next);
249
- invalidateTokenCache();
250
- canvasTheme.value = next;
251
- void saveCanvasTheme(next);
252
- }}
253
- aria-label={`Switch to ${canvasTheme.value === 'dark' ? 'light' : 'dark'} theme`}
285
+ <ToolbarHint
286
+ label="Arrange layout"
287
+ detail={edgeCount > 0 ? 'Graph-aware layout for connected nodes' : 'Grid layout for loose nodes'}
254
288
  >
255
- {canvasTheme.value === 'dark' ? <IconSun /> : <IconMoon />}
256
- </button>
257
- </ToolbarHint>
258
- <ToolbarHint label="Snapshots" detail="Capture and restore canvas states" align="end">
259
- <button
260
- ref={snapshotBtnRef}
261
- type="button"
262
- onClick={onToggleSnapshot}
263
- aria-label="Snapshots"
264
- style={{ color: snapshotOpen ? 'var(--c-accent)' : undefined }}
289
+ <button
290
+ type="button"
291
+ onClick={() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange())}
292
+ aria-label="Arrange layout"
293
+ >
294
+ <IconArrange />
295
+ </button>
296
+ </ToolbarHint>
297
+ <ToolbarHint
298
+ label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
299
+ detail="Quickly navigate large canvases"
265
300
  >
266
- <IconSnapshot />
267
- </button>
268
- </ToolbarHint>
301
+ <button
302
+ type="button"
303
+ onClick={onToggleMinimap}
304
+ aria-label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
305
+ style={{ color: minimapVisible ? 'var(--c-accent)' : undefined }}
306
+ >
307
+ <IconMinimap />
308
+ </button>
309
+ </ToolbarHint>
310
+ <ToolbarHint label="Theme" detail={`Current theme: ${CANVAS_THEME_META[activeTheme].label}`}>
311
+ <button
312
+ type="button"
313
+ onClick={() => setOpenMenu(openMenu === 'theme' ? null : 'theme')}
314
+ aria-label="Choose theme"
315
+ aria-haspopup="menu"
316
+ aria-expanded={openMenu === 'theme'}
317
+ style={{ color: openMenu === 'theme' ? 'var(--c-accent)' : undefined }}
318
+ >
319
+ {canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
320
+ </button>
321
+ </ToolbarHint>
322
+ <ToolbarHint label="Snapshots" detail="Capture and restore canvas states" align="end">
323
+ <button
324
+ ref={snapshotBtnRef}
325
+ type="button"
326
+ onClick={onToggleSnapshot}
327
+ aria-label="Snapshots"
328
+ style={{ color: snapshotOpen ? 'var(--c-accent)' : undefined }}
329
+ >
330
+ <IconSnapshot />
331
+ </button>
332
+ </ToolbarHint>
333
+ </span>
334
+
335
+ {/* Narrow screens: everything collapsed lives in this single menu. */}
336
+ <span class="toolbar-more">
337
+ <ToolbarHint label="More" detail="All canvas actions" align="end">
338
+ <button
339
+ type="button"
340
+ onClick={() => setOpenMenu(openMenu === 'more' ? null : 'more')}
341
+ aria-label="More actions"
342
+ aria-haspopup="menu"
343
+ aria-expanded={openMenu === 'more'}
344
+ style={{ color: openMenu === 'more' ? 'var(--c-accent)' : undefined }}
345
+ >
346
+ <span class="toolbar-more-glyph" aria-hidden="true">
347
+ ⋯
348
+ </span>
349
+ </button>
350
+ </ToolbarHint>
351
+ </span>
269
352
  </div>
270
353
 
271
- {/* ── Action Bar ──────────────────────────────────────── */}
272
- <div class="canvas-toolbar">
354
+ {/* ── Action Bar (hidden on narrow screens — lives in the More menu) ── */}
355
+ <div class="canvas-toolbar toolbar-actions-bar">
273
356
  <ToolbarHint
274
357
  label={isTraceOn ? 'Disable trace' : 'Enable trace'}
275
358
  detail={isTraceOn ? 'Stop collecting new trace nodes' : 'Capture agent execution on the canvas'}
@@ -360,6 +443,71 @@ function Toolbar({
360
443
  {countsLabel}
361
444
  </span>
362
445
  </div>
446
+
447
+ {openMenu === 'theme' && (
448
+ <div class="toolbar-menu" role="menu" aria-label="Theme">
449
+ {themeMenuItems}
450
+ </div>
451
+ )}
452
+ {openMenu === 'more' && (
453
+ <div class="toolbar-menu" role="menu" aria-label="Canvas actions">
454
+ <button
455
+ type="button"
456
+ class="toolbar-menu-item"
457
+ onClick={menuAction(() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange()))}
458
+ >
459
+ <IconArrange />
460
+ <span>Arrange layout</span>
461
+ </button>
462
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleMinimap)}>
463
+ <IconMinimap />
464
+ <span>{minimapVisible ? 'Hide minimap' : 'Show minimap'}</span>
465
+ </button>
466
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleSnapshot)}>
467
+ <IconSnapshot />
468
+ <span>Snapshots</span>
469
+ </button>
470
+ <div class="toolbar-menu-separator" />
471
+ <button
472
+ type="button"
473
+ class="toolbar-menu-item"
474
+ onClick={menuAction(() => sendIntent('trace-toggle', { enabled: !isTraceOn }))}
475
+ >
476
+ <IconTrace />
477
+ <span>{isTraceOn ? 'Disable trace' : 'Enable trace'}</span>
478
+ </button>
479
+ {(isTraceOn || traceNodeCount > 0) && (
480
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(() => sendIntent('trace-clear'))}>
481
+ <IconClearTrace />
482
+ <span>Clear trace</span>
483
+ </button>
484
+ )}
485
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationMode)}>
486
+ <IconPen />
487
+ <span>{annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}</span>
488
+ </button>
489
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationEraser)}>
490
+ <IconEraser />
491
+ <span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}</span>
492
+ </button>
493
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleTextAnnotation)}>
494
+ <IconTextAnnotation />
495
+ <span>{annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}</span>
496
+ </button>
497
+ <div class="toolbar-menu-separator" />
498
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenPalette)}>
499
+ <IconSearch />
500
+ <span>Search nodes and actions</span>
501
+ </button>
502
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenShortcuts)}>
503
+ <IconShortcuts />
504
+ <span>Keyboard shortcuts</span>
505
+ </button>
506
+ <div class="toolbar-menu-separator" />
507
+ <div class="toolbar-menu-heading">Theme</div>
508
+ {themeMenuItems}
509
+ </div>
510
+ )}
363
511
  </div>
364
512
  );
365
513
  }
@@ -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;
@@ -1,5 +1,6 @@
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';
@@ -20,7 +21,9 @@ function withViewerParams(
20
21
  if (!url) return url;
21
22
  try {
22
23
  const resolved = new URL(url, window.location.origin);
23
- 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));
24
27
  if (expanded) resolved.searchParams.set('display', 'expanded');
25
28
  // Streaming json-render nodes bump specVersion as patches accumulate; including
26
29
  // it in the src reloads the iframe so it re-reads the latest accumulated spec.
@@ -69,6 +69,17 @@ function isEmbeddedDocument(): boolean {
69
69
  }
70
70
  }
71
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
+
72
83
  /**
73
84
  * Resolve the boot-wide iframe mode once; all surface hooks share the result.
74
85
  * The blocked-src condition only exists when the canvas page itself runs
@@ -76,7 +87,7 @@ function isEmbeddedDocument(): boolean {
76
87
  * normal path pays zero probe latency before surfaces mount. Only embedded
77
88
  * documents run the probe (`embedded` is overridable for tests).
78
89
  */
79
- export function resolveIframeMode(opts: { embedded?: boolean } = {}): Promise<IframeMode> {
90
+ export function resolveIframeMode(opts: { embedded?: boolean; ampOrb?: boolean } = {}): Promise<IframeMode> {
80
91
  if (iframeMode.value) return Promise.resolve(iframeMode.value);
81
92
  if (pending) return pending;
82
93
  const forced = forcedIframeMode();
@@ -88,6 +99,10 @@ export function resolveIframeMode(opts: { embedded?: boolean } = {}): Promise<If
88
99
  iframeMode.value = 'src';
89
100
  return Promise.resolve('src');
90
101
  }
102
+ if (opts.ampOrb ?? isAmpOrbHost()) {
103
+ iframeMode.value = 'srcdoc';
104
+ return Promise.resolve('srcdoc');
105
+ }
91
106
  pending = probeSrcIframes().then((srcWorks) => {
92
107
  const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
93
108
  iframeMode.value = mode;
@@ -31,6 +31,7 @@ import { invalidateTokenCache } from '../theme/tokens';
31
31
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
32
32
  import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
33
33
  import type { PmxAxIntent } from '../../shared/ax-intent.js';
34
+ import { isCanvasTheme } from '../../shared/themes.js';
34
35
 
35
36
  let eventSource: EventSource | null = null;
36
37
  let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
@@ -275,8 +276,10 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
275
276
  }
276
277
 
277
278
  function applyCanvasTheme(theme: string): void {
278
- const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
279
- 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;
280
283
  document.documentElement.setAttribute('data-theme', theme);
281
284
  invalidateTokenCache();
282
285
  if (canvasTheme.value !== theme) canvasTheme.value = theme;