pmx-canvas 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/Readme.md +9 -4
- package/dist/canvas/global.css +397 -0
- package/dist/canvas/index.js +89 -89
- package/dist/canvas/surface-theme.css +160 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
- package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/iframe-mode.d.ts +26 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/server/canvas-db.d.ts +2 -1
- package/dist/types/server/html-surface.d.ts +8 -1
- package/dist/types/shared/themes.d.ts +27 -0
- package/docs/RELEASE.md +10 -4
- package/docs/cli.md +1 -1
- package/docs/environment.md +2 -1
- package/docs/http-api.md +29 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +9 -1
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +32 -4
- package/skills/pmx-canvas/references/full-reference.md +3 -3
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/index.ts +3 -2
- package/src/client/App.tsx +201 -53
- package/src/client/index.tsx +5 -0
- package/src/client/nodes/ExtAppFrame.tsx +36 -1
- package/src/client/nodes/HtmlNode.tsx +5 -1
- package/src/client/nodes/McpAppNode.tsx +9 -2
- package/src/client/nodes/iframe-document-url.ts +62 -9
- package/src/client/nodes/use-surface-frame.ts +53 -0
- package/src/client/state/canvas-store.ts +5 -0
- package/src/client/state/iframe-mode.ts +118 -0
- package/src/client/state/sse-bridge.ts +8 -2
- package/src/client/theme/global.css +397 -0
- package/src/client/theme/surface-theme.css +160 -0
- package/src/client/types.ts +3 -1
- package/src/server/canvas-db.ts +3 -2
- package/src/server/html-surface.ts +17 -3
- package/src/server/operations/ops/canvas-wire.ts +6 -1
- package/src/server/server.ts +59 -2
- package/src/shared/themes.ts +58 -0
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
package/docs/environment.md
CHANGED
|
@@ -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"
|
|
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`). |
|
package/docs/screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.4.
|
|
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).
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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`,
|
|
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 **
|
|
424
|
-
|
|
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 &&
|
|
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)
|
package/src/client/App.tsx
CHANGED
|
@@ -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
|
-
|
|
281
|
+
{/* Secondary controls collapse into the More menu on narrow screens. */}
|
|
282
|
+
<span class="toolbar-collapsible">
|
|
283
|
+
<div class="separator" />
|
|
217
284
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
<
|
|
267
|
-
|
|
268
|
-
|
|
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
|
}
|
package/src/client/index.tsx
CHANGED
|
@@ -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) {
|