pmx-canvas 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/Readme.md +9 -4
  3. package/dist/canvas/global.css +397 -0
  4. package/dist/canvas/index.js +89 -89
  5. package/dist/canvas/surface-theme.css +160 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
  8. package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
  9. package/dist/types/client/state/canvas-store.d.ts +1 -0
  10. package/dist/types/client/state/iframe-mode.d.ts +26 -0
  11. package/dist/types/client/types.d.ts +2 -1
  12. package/dist/types/server/canvas-db.d.ts +2 -1
  13. package/dist/types/server/html-surface.d.ts +8 -1
  14. package/dist/types/shared/themes.d.ts +27 -0
  15. package/docs/RELEASE.md +10 -4
  16. package/docs/cli.md +1 -1
  17. package/docs/environment.md +2 -1
  18. package/docs/http-api.md +29 -0
  19. package/docs/plans/plan-009-tech-debt-backlog.md +9 -1
  20. package/docs/screenshot.png +0 -0
  21. package/package.json +1 -1
  22. package/skills/pmx-canvas/SKILL.md +32 -4
  23. package/skills/pmx-canvas/references/full-reference.md +3 -3
  24. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  25. package/src/cli/index.ts +3 -2
  26. package/src/client/App.tsx +201 -53
  27. package/src/client/index.tsx +5 -0
  28. package/src/client/nodes/ExtAppFrame.tsx +36 -1
  29. package/src/client/nodes/HtmlNode.tsx +5 -1
  30. package/src/client/nodes/McpAppNode.tsx +9 -2
  31. package/src/client/nodes/iframe-document-url.ts +62 -9
  32. package/src/client/nodes/use-surface-frame.ts +53 -0
  33. package/src/client/state/canvas-store.ts +5 -0
  34. package/src/client/state/iframe-mode.ts +118 -0
  35. package/src/client/state/sse-bridge.ts +8 -2
  36. package/src/client/theme/global.css +397 -0
  37. package/src/client/theme/surface-theme.css +160 -0
  38. package/src/client/types.ts +3 -1
  39. package/src/server/canvas-db.ts +3 -2
  40. package/src/server/html-surface.ts +17 -3
  41. package/src/server/operations/ops/canvas-wire.ts +6 -1
  42. package/src/server/server.ts +59 -2
  43. package/src/shared/themes.ts +58 -0
@@ -5,9 +5,10 @@
5
5
  * WAL-mode persistence. Replaces the previous JSON file-based approach.
6
6
  */
7
7
  import { Database } from 'bun:sqlite';
8
+ import { type CanvasThemeName } from '../shared/themes.js';
8
9
  import type { CanvasAnnotation, CanvasEdge, CanvasNodeState, CanvasSnapshot, CanvasSnapshotListOptions, ViewportState } from './canvas-state.js';
9
10
  import { type PmxAxState, type PmxAxEvent, type PmxAxEvidence, type PmxAxSteeringMessage, type PmxAxHostCapability, type PmxAxTimelineSummary } from './ax-state.js';
10
- export type CanvasTheme = 'dark' | 'light' | 'high-contrast';
11
+ export type CanvasTheme = CanvasThemeName;
11
12
  export declare function normalizeCanvasTheme(value: unknown, fallback?: CanvasTheme): CanvasTheme;
12
13
  export interface PersistedCanvasState {
13
14
  version: number;
@@ -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/RELEASE.md CHANGED
@@ -128,12 +128,18 @@ it uses `NPM_TOKEN` directly.
128
128
 
129
129
  Every release since 0.3.0 has been retested against **stale** skill mirrors:
130
130
  consumer workspaces (e.g. `personalclaude`) carry copies of the pmx-canvas
131
- skill under `.github/`, `.codex/`, `.claude/`, and `.agents/`, and testers
131
+ skills under `.github/`, `.codex/`, `.claude/`, and `.agents/`, and testers
132
132
  repeatedly had to sync them mid-cycle before results were trustworthy. After
133
133
  publishing, update the installed package in the consumer workspace and re-sync
134
- its four mirrors from
135
- `<global npm root>/pmx-canvas/skills/pmx-canvas/` so the first test pass runs
136
- against the released skill, not the previous version's guidance.
134
+ its four mirrors from `<global npm root>/pmx-canvas/skills/` so the first test
135
+ pass runs against the released skills, not the previous version's guidance.
136
+
137
+ Sync the **complete tree of every bundled skill** — `pmx-canvas` AND
138
+ `pmx-canvas-testing`, including `references/`, `evals/`, and fixtures — and
139
+ verify by diffing whole directories, not by comparing the `SKILL.md` hash
140
+ alone. The 0.3.4 and 0.4.0 cycles both hit drift where `SKILL.md` matched but
141
+ references/evals were stale, or a newly bundled skill was missing from the
142
+ mirrors entirely.
137
143
 
138
144
  ## GitHub release
139
145
 
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,35 @@ 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
+ ## Iframe embed probe (nested-iframe hosts)
349
+
350
+ Some hosts embed the canvas page itself inside an iframe (the Amp orb portal
351
+ does), and Chrome then refuses to load child iframes from `src` URLs — even
352
+ same-origin ones — so iframe-backed nodes would show a broken placeholder.
353
+ When (and only when) the canvas page detects it is embedded, it probes the
354
+ real behavior at boot by loading a hidden iframe against:
355
+
356
+ ```bash
357
+ curl http://localhost:4313/api/canvas/iframe-probe
358
+ ```
359
+
360
+ If that iframe never loads, the client switches every same-origin surface
361
+ (HTML nodes, graph/json-render viewers, frame documents) to `fetch()` +
362
+ `srcdoc` inline rendering. External app URLs stay on `src` — they cannot be
363
+ fetched cross-origin. Force a mode with `/workbench?iframe-mode=srcdoc` (or
364
+ `iframe-mode=src`).
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
+
373
+ `/api/canvas/frame-documents/:id` also answers `HEAD`, which the browser uses
374
+ after every reconnect to detect frame documents lost to a server restart and
375
+ re-mint them automatically (Finding S).
376
+
348
377
  ## Time travel
349
378
 
350
379
  ```bash
@@ -66,5 +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" (0.3.3 report leaves it open), inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3. Only scope on a failing report cycle. |
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
+
71
+ ## Added post-0.4.0 (2026-08-04 report cycle)
72
+
73
+ | Item | What's open |
74
+ |---|---|
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
+ | 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. |
70
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.0",
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.
@@ -223,12 +233,20 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
223
233
  time), with a watchdog retry for iframes that never boot and a re-arm when a panel that loaded
224
234
  hidden becomes visible. Treat the auto-recovery as best-effort, NOT guaranteed — the 0.3.2
225
235
  Copilot-host report shows tiles can still come up black in real WKWebView panels (compositing
226
- success is not observable from page JS, so retries are bounded). Keep the cautionary behavior:
236
+ success is not observable from page JS, so retries are bounded). Since 0.4.1 the canvas also
237
+ fires a post-boot **repaint nudge** under WebKit (a 1px layout bounce that forces the
238
+ compositor to re-rasterize the frame — the cheap half of the proven enlarge-close unstick;
239
+ shows as `repaint-nudge` in the recovery trail). Keep the cautionary behavior anyway:
227
240
  recovery is deterministic via **expand-then-close** of the black tile (forces a fresh mount in
228
241
  the fullscreen overlay, which always paints), or open the workbench in a normal browser
229
242
  (Chrome). Do not diagnose a healthy app session as a broken node. When reporting a black tile,
230
243
  fetch `GET /api/canvas/debug/ext-app-recovery` from the daemon and attach the trace — it shows
231
244
  what the recovery queue actually did in that panel.
245
+ - Ext-app frame documents live in server memory. Through 0.4.0, killing/restarting the daemon
246
+ while a panel stays open leaves ext-app tiles on dead frame URLs (`Frame document not found`,
247
+ 0.4.0 report Finding S) until a full workbench reload. Since 0.4.1 the browser revalidates its
248
+ frame documents on every reconnect and re-mints them against the new process automatically —
249
+ if a post-restart tile still looks blank on an older install, reload the workbench page.
232
250
  - A hosted ext-app (Excalidraw) resized NARROW/TALL (e.g. 360x529) can show its diagram in the
233
251
  upper region with the app's own dark fill below it — in every engine (0.3.4 report Finding Q).
234
252
  This letterboxing lives inside the hosted app bundle's root container, not in PMX (a body-level
@@ -238,10 +256,20 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
238
256
  - Behind proxies that buffer streaming responses (e.g. portal hosts), the workbench auto-falls
239
257
  back from SSE to a polling transport within ~3s, so the board still boots and stays live.
240
258
  Force a mode with `/workbench?transport=poll` (or `transport=sse`) when diagnosing.
259
+ - Nested-iframe embeds (e.g. the Amp orb portal renders the canvas page inside an ampcode.com
260
+ iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
261
+ with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
262
+ same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
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.
241
268
  - Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
242
- reflow on a live window resize in a normal browser. Some single-tab host browsers (e.g. the
243
- Codex in-app browser) don't deliver live-resize events, so a resized standalone chart can look
244
- stale until reload — use a system browser for separate full-page viewing.
269
+ reflow on a live window resize in a normal browser. Some single-tab host browsers historically
270
+ didn't deliver live-resize events (current Codex builds do — the 0.4.0 retest saw a standalone
271
+ graph reflow on a native viewport resize), so if a resized standalone chart looks stale,
272
+ reload it or use a system browser for separate full-page viewing.
245
273
  - Some hosts cannot automate inside sandboxed workbench iframes. Verify those interactions in a
246
274
  system browser or through server-side AX state.
247
275
  - `pmx-canvas screenshot` requires an active WebView. Start it with
@@ -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
 
@@ -71,6 +71,9 @@ Prefer extending the existing suites before inventing a one-off script.
71
71
  - Avoid brittle selectors. Prefer stable text, roles, titles, or deliberate component hooks
72
72
  - If a change spans server and client, add at least one server-side assertion and one browser or
73
73
  API-level proof
74
+ - MCP harnesses using the official SDK must wind down BOTH halves: call `client.close()` AND
75
+ `transport.close()` on the `StdioClientTransport`. Closing only the client leaves the spawned
76
+ stdio server process alive after the test exits (0.4.0 cycle finding)
74
77
 
75
78
  ## Layout And Embedded Content Checks
76
79
 
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
  }
@@ -2,6 +2,11 @@
2
2
  import './uuid-polyfill';
3
3
  import { render } from 'preact';
4
4
  import { App } from './App';
5
+ import { resolveIframeMode } from './state/iframe-mode';
6
+
7
+ // Start the embed probe before first paint so iframe-backed nodes know whether
8
+ // src-URL iframes work in this context (Amp orb portals block them).
9
+ void resolveIframeMode();
5
10
 
6
11
  const root = document.getElementById('app');
7
12
  if (root) {