pmx-canvas 0.4.1 → 0.4.3

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 (37) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +5 -2
  2. package/CHANGELOG.md +61 -0
  3. package/Readme.md +34 -12
  4. package/dist/canvas/global.css +406 -0
  5. package/dist/canvas/index.js +84 -84
  6. package/dist/canvas/surface-theme.css +160 -0
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  9. package/dist/types/client/state/theme-override.d.ts +27 -0
  10. package/dist/types/client/types.d.ts +2 -1
  11. package/dist/types/server/canvas-db.d.ts +2 -1
  12. package/dist/types/server/html-surface.d.ts +8 -1
  13. package/dist/types/shared/themes.d.ts +27 -0
  14. package/docs/cli.md +1 -1
  15. package/docs/environment.md +2 -1
  16. package/docs/http-api.md +15 -0
  17. package/docs/plans/plan-009-tech-debt-backlog.md +5 -3
  18. package/docs/screenshot.png +0 -0
  19. package/package.json +3 -3
  20. package/skills/pmx-canvas/SKILL.md +19 -3
  21. package/skills/pmx-canvas/references/full-reference.md +3 -3
  22. package/src/cli/index.ts +3 -2
  23. package/src/client/App.tsx +208 -53
  24. package/src/client/canvas/CommandPalette.tsx +4 -0
  25. package/src/client/nodes/ExtAppFrame.tsx +18 -5
  26. package/src/client/nodes/McpAppNode.tsx +10 -2
  27. package/src/client/state/iframe-mode.ts +16 -1
  28. package/src/client/state/sse-bridge.ts +13 -4
  29. package/src/client/state/theme-override.ts +83 -0
  30. package/src/client/theme/global.css +406 -0
  31. package/src/client/theme/surface-theme.css +160 -0
  32. package/src/client/types.ts +3 -1
  33. package/src/server/canvas-db.ts +3 -2
  34. package/src/server/html-surface.ts +17 -3
  35. package/src/server/operations/ops/canvas-wire.ts +15 -2
  36. package/src/server/server.ts +35 -1
  37. package/src/shared/themes.ts +58 -0
@@ -14,7 +14,8 @@
14
14
  * toggling the attribute (the theme bridge below) — no CSS payload over
15
15
  * postMessage required.
16
16
  */
17
- export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
17
+ import { type CanvasThemeName } from '../shared/themes.js';
18
+ export type SurfaceTheme = CanvasThemeName;
18
19
  /** Path the surface document links for its theme tokens (served from dist/canvas). */
19
20
  export declare const SURFACE_THEME_STYLESHEET = "/canvas/surface-theme.css";
20
21
  /** CSP sandbox tokens for an `html`/`html-primitive` surface — scripts only, opaque origin. */
@@ -77,6 +78,12 @@ export interface HtmlSurfaceOptions {
77
78
  axState?: unknown;
78
79
  /** Nonce for the content-height reporter (lets the node grow to fit content). */
79
80
  contentHeightToken?: string;
81
+ /**
82
+ * Contents of surface-theme.css to inline as a <style> instead of the
83
+ * same-origin <link> — required for srcdoc-rendered surfaces (nested-iframe
84
+ * hosts) where the stylesheet subresource may not load.
85
+ */
86
+ inlineThemeCss?: string;
80
87
  }
81
88
  /**
82
89
  * Wrap author HTML into a complete, themed standalone document. Accepts either a
@@ -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
@@ -345,6 +345,14 @@ produces no event within 3 seconds of connecting, it switches to polling.
345
345
  Force a transport with `/workbench?transport=poll` (or `transport=sse` to
346
346
  disable the fallback).
347
347
 
348
+ ## Session theme override (host-default theming)
349
+
350
+ `/workbench?theme=<name>` gives that browser session its own theme without
351
+ touching the server-global theme other clients see — embedding hosts use it to
352
+ match their chrome (the bundled Copilot extension opens `?theme=light`).
353
+ `?theme=auto` follows the host's `prefers-color-scheme` live. Picking a theme
354
+ from the toolbar ends the override and saves globally as usual.
355
+
348
356
  ## Iframe embed probe (nested-iframe hosts)
349
357
 
350
358
  Some hosts embed the canvas page itself inside an iframe (the Amp orb portal
@@ -363,6 +371,13 @@ If that iframe never loads, the client switches every same-origin surface
363
371
  fetched cross-origin. Force a mode with `/workbench?iframe-mode=srcdoc` (or
364
372
  `iframe-mode=src`).
365
373
 
374
+ In Amp orbs the probe itself is unreliable (a tiny probe iframe can load even
375
+ though node-sized ones will not), so when the server runs with `AMP_ORB` set
376
+ (orb services always do) it stamps `window.__PMX_AMP_ORB` into the page and
377
+ the embedded client forces `srcdoc` without probing. HTML surface documents
378
+ also inline their theme stylesheet, so srcdoc-rendered surfaces stay styled
379
+ without any subresource load.
380
+
366
381
  `/api/canvas/frame-documents/:id` also answers `HEAD`, which the browser uses
367
382
  after every reconnect to detect frame documents lost to a server restart and
368
383
  re-mint them automatically (Finding S).
@@ -66,13 +66,15 @@ 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
- | 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. |
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; 0.4.2 cycle saw the legacy tools gone from the host list after refresh. |
78
+ | Finding T — theme/More menu mis-anchored | **Done (2026-08-05):** menus moved inside `.toolbar-menu-anchor` wrappers around their triggers (the group-level `position:relative` + `right:0` anchored them ~212px right of the button, both hosts). Anchor-geometry e2e regression tests added. |
79
+ | Finding U — sticky `-32001` session-error banner over rendering content | **Mitigated (2026-08-05), root cause open:** when an ext-app session errors but the cached document still renders, the banner is now a soft degraded-interactivity notice instead of a red alarm. The underlying stressed-reconnect MCP timeout (long-lived tiles through theme churn + daemon respawn) is not clean-reproducible — investigate session re-establishment retry/backoff if the next cycle still hits it. |
78
80
  | 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.3",
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",
@@ -43,9 +43,9 @@
43
43
  "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
44
44
  "lint": "biome check .",
45
45
  "lint:fix": "biome check --write .",
46
- "test": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
46
+ "test": "if [ -d tests/unit ]; then PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
47
47
  "test:unit": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun test tests/unit",
48
- "test:client": "bun test --preload ./tests/client/setup.ts tests/client",
48
+ "test:client": "if [ -d tests/client ]; then bun test --preload ./tests/client/setup.ts tests/client; else echo 'tests/ is not shipped in the npm package — clone the repo to run the suite'; fi",
49
49
  "test:coverage": "PMX_MCP_APP_HOST_STATE_FILE=/tmp/pmx-canvas-test-app-host-state.json bun test tests/unit --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage && bun scripts/check-coverage.ts",
50
50
  "test:web-canvas": "PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bun run build && PMX_CANVAS_DISABLE_BROWSER_OPEN=1 bash scripts/run-playwright.sh",
51
51
  "test:e2e": "bun run test:web-canvas",
@@ -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,16 @@ 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.
184
+ - Embedding hosts can open `/workbench?theme=<name>` (or `?theme=auto` to follow the host's
185
+ light/dark appearance) for a session-local default theme that never changes the server-global
186
+ theme other clients see; an explicit pick from the theme menu ends the override. The bundled
187
+ Copilot extension opens its panel with `?theme=light`.
174
188
 
175
189
  After changing files under `src/client/`, rebuild with `bun run build` before manual browser
176
190
  verification.
@@ -250,9 +264,11 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
250
264
  iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
251
265
  with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
252
266
  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.
267
+ documents). In Amp orbs specifically, the server sees `AMP_ORB` in its environment and stamps
268
+ the page so the embedded client skips the (there-unreliable) probe and goes straight to
269
+ `srcdoc`; HTML surfaces also inline their theme stylesheet so they render styled. External app
270
+ URLs cannot be inlined (cross-origin) and may stay blocked in such hosts. Force a mode with
271
+ `/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when diagnosing.
256
272
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
257
273
  reflow on a live window resize in a normal browser. Some single-tab host browsers historically
258
274
  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)
@@ -59,8 +59,16 @@ import {
59
59
  IconZoomIn,
60
60
  IconZoomOut,
61
61
  } from './icons';
62
+ import { clearThemeOverride } from './state/theme-override';
62
63
  import { invalidateTokenCache } from './theme/tokens';
63
64
  import { MOD_KEY } from './utils/platform';
65
+ import {
66
+ CANVAS_THEMES,
67
+ CANVAS_THEME_META,
68
+ type CanvasThemeName,
69
+ canvasThemeScheme,
70
+ normalizeCanvasThemeName,
71
+ } from '../shared/themes.js';
64
72
 
65
73
  function logAppError(action: string, error: unknown): void {
66
74
  console.error(`[app] ${action} failed`, error);
@@ -153,8 +161,69 @@ function Toolbar({
153
161
  ].join(' · ')
154
162
  : 'Syncing canvas…';
155
163
 
164
+ // Popover state: the theme picker (desktop toolbar) and the mobile "More"
165
+ // menu share one open-slot so at most one popover shows at a time.
166
+ const [openMenu, setOpenMenu] = useState<null | 'theme' | 'more'>(null);
167
+ const groupRef = useRef<HTMLDivElement>(null);
168
+
169
+ useEffect(() => {
170
+ if (!openMenu) return;
171
+ const onPointerDown = (e: PointerEvent) => {
172
+ if (groupRef.current && e.target instanceof Node && !groupRef.current.contains(e.target)) {
173
+ setOpenMenu(null);
174
+ }
175
+ };
176
+ const onKeyDown = (e: KeyboardEvent) => {
177
+ if (e.key === 'Escape') setOpenMenu(null);
178
+ };
179
+ document.addEventListener('pointerdown', onPointerDown);
180
+ document.addEventListener('keydown', onKeyDown);
181
+ return () => {
182
+ document.removeEventListener('pointerdown', onPointerDown);
183
+ document.removeEventListener('keydown', onKeyDown);
184
+ };
185
+ }, [openMenu]);
186
+
187
+ const applyTheme = (next: CanvasThemeName) => {
188
+ // An explicit pick ends any ?theme= session override (host default) and
189
+ // returns this client to the shared server-global theme it just saved.
190
+ clearThemeOverride();
191
+ document.documentElement.setAttribute('data-theme', next);
192
+ invalidateTokenCache();
193
+ canvasTheme.value = next;
194
+ void saveCanvasTheme(next);
195
+ setOpenMenu(null);
196
+ };
197
+
198
+ const menuAction = (fn: () => void) => () => {
199
+ fn();
200
+ setOpenMenu(null);
201
+ };
202
+
203
+ const activeTheme = normalizeCanvasThemeName(canvasTheme.value);
204
+ const themeMenuItems = CANVAS_THEMES.map((name) => (
205
+ <button
206
+ key={name}
207
+ type="button"
208
+ role="menuitemradio"
209
+ aria-checked={activeTheme === name}
210
+ class={`toolbar-menu-item${activeTheme === name ? ' active' : ''}`}
211
+ onClick={() => applyTheme(name)}
212
+ >
213
+ <span class="theme-swatch" style={{ background: CANVAS_THEME_META[name].swatchBg }}>
214
+ <span class="theme-swatch-dot" style={{ background: CANVAS_THEME_META[name].swatchAccent }} />
215
+ </span>
216
+ <span>{CANVAS_THEME_META[name].label}</span>
217
+ {activeTheme === name && (
218
+ <span class="toolbar-menu-check" aria-hidden="true">
219
+ ✓
220
+ </span>
221
+ )}
222
+ </button>
223
+ ));
224
+
156
225
  return (
157
- <div class="toolbar-group">
226
+ <div class="toolbar-group" ref={groupRef}>
158
227
  {/* ── Navigation Bar ──────────────────────────────────── */}
159
228
  <div class="canvas-toolbar">
160
229
  <ToolbarHint label="PMX Canvas" detail="Focus Field · spatial workbench for coding agents" align="start">
@@ -213,63 +282,149 @@ function Toolbar({
213
282
  {Math.round(v.scale * 100)}%
214
283
  </span>
215
284
 
216
- <div class="separator" />
285
+ {/* Secondary controls collapse into the More menu on narrow screens. */}
286
+ <span class="toolbar-collapsible">
287
+ <div class="separator" />
217
288
 
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`}
289
+ <ToolbarHint
290
+ label="Arrange layout"
291
+ detail={edgeCount > 0 ? 'Graph-aware layout for connected nodes' : 'Grid layout for loose nodes'}
254
292
  >
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 }}
293
+ <button
294
+ type="button"
295
+ onClick={() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange())}
296
+ aria-label="Arrange layout"
297
+ >
298
+ <IconArrange />
299
+ </button>
300
+ </ToolbarHint>
301
+ <ToolbarHint
302
+ label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
303
+ detail="Quickly navigate large canvases"
265
304
  >
266
- <IconSnapshot />
267
- </button>
268
- </ToolbarHint>
305
+ <button
306
+ type="button"
307
+ onClick={onToggleMinimap}
308
+ aria-label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
309
+ style={{ color: minimapVisible ? 'var(--c-accent)' : undefined }}
310
+ >
311
+ <IconMinimap />
312
+ </button>
313
+ </ToolbarHint>
314
+ {/* Anchor wrapper: the theme menu must open under THIS control, not at
315
+ the toolbar-group's right edge (0.4.2 report Finding T). */}
316
+ <span class="toolbar-menu-anchor">
317
+ <ToolbarHint label="Theme" detail={`Current theme: ${CANVAS_THEME_META[activeTheme].label}`}>
318
+ <button
319
+ type="button"
320
+ onClick={() => setOpenMenu(openMenu === 'theme' ? null : 'theme')}
321
+ aria-label="Choose theme"
322
+ aria-haspopup="menu"
323
+ aria-expanded={openMenu === 'theme'}
324
+ style={{ color: openMenu === 'theme' ? 'var(--c-accent)' : undefined }}
325
+ >
326
+ {canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
327
+ </button>
328
+ </ToolbarHint>
329
+ {openMenu === 'theme' && (
330
+ <div class="toolbar-menu" role="menu" aria-label="Theme">
331
+ {themeMenuItems}
332
+ </div>
333
+ )}
334
+ </span>
335
+ <ToolbarHint label="Snapshots" detail="Capture and restore canvas states" align="end">
336
+ <button
337
+ ref={snapshotBtnRef}
338
+ type="button"
339
+ onClick={onToggleSnapshot}
340
+ aria-label="Snapshots"
341
+ style={{ color: snapshotOpen ? 'var(--c-accent)' : undefined }}
342
+ >
343
+ <IconSnapshot />
344
+ </button>
345
+ </ToolbarHint>
346
+ </span>
347
+
348
+ {/* Narrow screens: everything collapsed lives in this single menu. */}
349
+ <span class="toolbar-more toolbar-menu-anchor">
350
+ <ToolbarHint label="More" detail="All canvas actions" align="end">
351
+ <button
352
+ type="button"
353
+ onClick={() => setOpenMenu(openMenu === 'more' ? null : 'more')}
354
+ aria-label="More actions"
355
+ aria-haspopup="menu"
356
+ aria-expanded={openMenu === 'more'}
357
+ style={{ color: openMenu === 'more' ? 'var(--c-accent)' : undefined }}
358
+ >
359
+ <span class="toolbar-more-glyph" aria-hidden="true">
360
+ ⋯
361
+ </span>
362
+ </button>
363
+ </ToolbarHint>
364
+ {openMenu === 'more' && (
365
+ <div class="toolbar-menu" role="menu" aria-label="Canvas actions">
366
+ <button
367
+ type="button"
368
+ class="toolbar-menu-item"
369
+ onClick={menuAction(() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange()))}
370
+ >
371
+ <IconArrange />
372
+ <span>Arrange layout</span>
373
+ </button>
374
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleMinimap)}>
375
+ <IconMinimap />
376
+ <span>{minimapVisible ? 'Hide minimap' : 'Show minimap'}</span>
377
+ </button>
378
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleSnapshot)}>
379
+ <IconSnapshot />
380
+ <span>Snapshots</span>
381
+ </button>
382
+ <div class="toolbar-menu-separator" />
383
+ <button
384
+ type="button"
385
+ class="toolbar-menu-item"
386
+ onClick={menuAction(() => sendIntent('trace-toggle', { enabled: !isTraceOn }))}
387
+ >
388
+ <IconTrace />
389
+ <span>{isTraceOn ? 'Disable trace' : 'Enable trace'}</span>
390
+ </button>
391
+ {(isTraceOn || traceNodeCount > 0) && (
392
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(() => sendIntent('trace-clear'))}>
393
+ <IconClearTrace />
394
+ <span>Clear trace</span>
395
+ </button>
396
+ )}
397
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationMode)}>
398
+ <IconPen />
399
+ <span>{annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}</span>
400
+ </button>
401
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleAnnotationEraser)}>
402
+ <IconEraser />
403
+ <span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}</span>
404
+ </button>
405
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onToggleTextAnnotation)}>
406
+ <IconTextAnnotation />
407
+ <span>{annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}</span>
408
+ </button>
409
+ <div class="toolbar-menu-separator" />
410
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenPalette)}>
411
+ <IconSearch />
412
+ <span>Search nodes and actions</span>
413
+ </button>
414
+ <button type="button" class="toolbar-menu-item" onClick={menuAction(onOpenShortcuts)}>
415
+ <IconShortcuts />
416
+ <span>Keyboard shortcuts</span>
417
+ </button>
418
+ <div class="toolbar-menu-separator" />
419
+ <div class="toolbar-menu-heading">Theme</div>
420
+ {themeMenuItems}
421
+ </div>
422
+ )}
423
+ </span>
269
424
  </div>
270
425
 
271
- {/* ── Action Bar ──────────────────────────────────────── */}
272
- <div class="canvas-toolbar">
426
+ {/* ── Action Bar (hidden on narrow screens — lives in the More menu) ── */}
427
+ <div class="canvas-toolbar toolbar-actions-bar">
273
428
  <ToolbarHint
274
429
  label={isTraceOn ? 'Disable trace' : 'Enable trace'}
275
430
  detail={isTraceOn ? 'Stop collecting new trace nodes' : 'Capture agent execution on the canvas'}
@@ -11,6 +11,7 @@ import {
11
11
  import { createNodeFromClient, saveCanvasTheme } from '../state/intent-bridge';
12
12
  import { TYPE_LABELS, type CanvasNodeState } from '../types';
13
13
  import { invalidateTokenCache } from '../theme/tokens';
14
+ import { clearThemeOverride } from '../state/theme-override';
14
15
 
15
16
  import { MOD_KEY } from '../utils/platform';
16
17
 
@@ -174,6 +175,9 @@ export function CommandPalette({ onClose, onToggleMinimap }: { onClose: () => vo
174
175
  badge: 'THEME',
175
176
  action: () => {
176
177
  const next = canvasTheme.value === 'dark' ? 'light' : 'dark';
178
+ // An explicit pick ends any ?theme= session override, same as the
179
+ // toolbar picker — otherwise the session stays sticky-overridden.
180
+ clearThemeOverride();
177
181
  document.documentElement.setAttribute('data-theme', next);
178
182
  invalidateTokenCache();
179
183
  canvasTheme.value = next;
@@ -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,15 +521,24 @@ 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;
527
529
  latestToolResultRef.current = toolResult;
528
530
 
531
+ // Finding U (0.4.2 report): a stressed reconnect can leave sessionStatus
532
+ // 'error' (e.g. "MCP error -32001: Request timed out") while the cached app
533
+ // document still renders fine below. When content IS available, present the
534
+ // error as a degraded-interactivity notice, not a full-alarm banner over a
535
+ // working diagram.
536
+ const sessionErrorWithContent = sessionStatus === 'error' && typeof html === 'string' && html.length > 0;
529
537
  const sessionUnavailableMessage =
530
538
  sessionStatus === 'error'
531
- ? (sessionError ?? 'Saved app session is unavailable. Reopen the app to restore interactivity.')
539
+ ? sessionErrorWithContent
540
+ ? `Live session unavailable — showing saved content. Reopen the app to restore interactivity.${sessionError ? ` (${sessionError})` : ''}`
541
+ : (sessionError ?? 'Saved app session is unavailable. Reopen the app to restore interactivity.')
532
542
  : 'Reconnecting saved app session...';
533
543
 
534
544
  const flushToolResult = (bridge: AppBridge | null): Promise<void> | null => {
@@ -1038,9 +1048,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1038
1048
  style={{
1039
1049
  padding: '6px 10px',
1040
1050
  fontSize: '11px',
1041
- background: sessionStatus === 'error' ? 'var(--c-danger-12)' : 'var(--c-warn-10)',
1042
- color: sessionStatus === 'error' ? 'var(--c-danger)' : 'var(--c-warn)',
1043
- borderBottom: `1px solid ${sessionStatus === 'error' ? 'var(--c-danger-12)' : 'var(--c-warn-15)'}`,
1051
+ background:
1052
+ sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-10)',
1053
+ color: sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger)' : 'var(--c-warn)',
1054
+ borderBottom: `1px solid ${
1055
+ sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-15)'
1056
+ }`,
1044
1057
  }}
1045
1058
  >
1046
1059
  {sessionUnavailableMessage}
@@ -1,7 +1,9 @@
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';
6
+ import { iframeMode } from '../state/iframe-mode';
5
7
  import { shouldContentFitIframeNode } from '../canvas/auto-fit';
6
8
  import { ExtAppFrame } from './ExtAppFrame';
7
9
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
@@ -20,7 +22,9 @@ function withViewerParams(
20
22
  if (!url) return url;
21
23
  try {
22
24
  const resolved = new URL(url, window.location.origin);
23
- resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
25
+ // The json-render viewer + artifacts understand dark/light — collapse named
26
+ // themes (sepia → light, midnight/arctic/ember/forest → dark) to a scheme.
27
+ resolved.searchParams.set('theme', canvasThemeScheme(canvasTheme.value));
24
28
  if (expanded) resolved.searchParams.set('display', 'expanded');
25
29
  // Streaming json-render nodes bump specVersion as patches accumulate; including
26
30
  // it in the src reloads the iframe so it re-reads the latest accumulated spec.
@@ -182,7 +186,11 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
182
186
  class="mcp-app-frame"
183
187
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
184
188
  allow="clipboard-read; clipboard-write"
185
- loading="lazy"
189
+ // Lazy loading only helps src mode (defers the HTTP request for
190
+ // off-screen nodes). In srcdoc mode the content is already inline —
191
+ // lazy would just delay PAINTING until the node scrolls near the
192
+ // viewport, which reads as "blank until opened" (Amp portal report).
193
+ loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
186
194
  onLoad={pushAxState}
187
195
  style={{ flex: 1, minHeight: 0, width: '100%' }}
188
196
  title={`MCP App: ${sourceServer}`}