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.
- package/.github/extensions/pmx-canvas/extension.mjs +5 -2
- package/CHANGELOG.md +61 -0
- package/Readme.md +34 -12
- package/dist/canvas/global.css +406 -0
- package/dist/canvas/index.js +84 -84
- package/dist/canvas/surface-theme.css +160 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/theme-override.d.ts +27 -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/cli.md +1 -1
- package/docs/environment.md +2 -1
- package/docs/http-api.md +15 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +5 -3
- package/docs/screenshot.png +0 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +19 -3
- package/skills/pmx-canvas/references/full-reference.md +3 -3
- package/src/cli/index.ts +3 -2
- package/src/client/App.tsx +208 -53
- package/src/client/canvas/CommandPalette.tsx +4 -0
- package/src/client/nodes/ExtAppFrame.tsx +18 -5
- package/src/client/nodes/McpAppNode.tsx +10 -2
- package/src/client/state/iframe-mode.ts +16 -1
- package/src/client/state/sse-bridge.ts +13 -4
- package/src/client/state/theme-override.ts +83 -0
- package/src/client/theme/global.css +406 -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 +15 -2
- package/src/server/server.ts +35 -1
- 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
|
-
|
|
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
|
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,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.
|
|
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 (
|
|
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;
|
|
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`). |
|
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.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).
|
|
254
|
-
|
|
255
|
-
|
|
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`,
|
|
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
|
|
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
|
@@ -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
|
-
|
|
285
|
+
{/* Secondary controls collapse into the More menu on narrow screens. */}
|
|
286
|
+
<span class="toolbar-collapsible">
|
|
287
|
+
<div class="separator" />
|
|
217
288
|
|
|
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`}
|
|
289
|
+
<ToolbarHint
|
|
290
|
+
label="Arrange layout"
|
|
291
|
+
detail={edgeCount > 0 ? 'Graph-aware layout for connected nodes' : 'Grid layout for loose nodes'}
|
|
254
292
|
>
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
<
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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:
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
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
|
|
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}`}
|