pmx-canvas 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/canvas/global.css +397 -0
- package/dist/canvas/index.js +86 -86
- 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/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 +7 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +15 -3
- package/skills/pmx-canvas/references/full-reference.md +3 -3
- package/src/cli/index.ts +3 -2
- package/src/client/App.tsx +201 -53
- package/src/client/nodes/ExtAppFrame.tsx +3 -1
- package/src/client/nodes/McpAppNode.tsx +4 -1
- package/src/client/state/iframe-mode.ts +16 -1
- package/src/client/state/sse-bridge.ts +5 -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 +35 -1
- package/src/shared/themes.ts +58 -0
|
@@ -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
|
@@ -363,6 +363,13 @@ If that iframe never loads, the client switches every same-origin surface
|
|
|
363
363
|
fetched cross-origin. Force a mode with `/workbench?iframe-mode=srcdoc` (or
|
|
364
364
|
`iframe-mode=src`).
|
|
365
365
|
|
|
366
|
+
In Amp orbs the probe itself is unreliable (a tiny probe iframe can load even
|
|
367
|
+
though node-sized ones will not), so when the server runs with `AMP_ORB` set
|
|
368
|
+
(orb services always do) it stamps `window.__PMX_AMP_ORB` into the page and
|
|
369
|
+
the embedded client forces `srcdoc` without probing. HTML surface documents
|
|
370
|
+
also inline their theme stylesheet, so srcdoc-rendered surfaces stay styled
|
|
371
|
+
without any subresource load.
|
|
372
|
+
|
|
366
373
|
`/api/canvas/frame-documents/:id` also answers `HEAD`, which the browser uses
|
|
367
374
|
after every reconnect to detect frame documents lost to a server restart and
|
|
368
375
|
re-mint them automatically (Finding S).
|
|
@@ -66,13 +66,13 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
|
|
|
66
66
|
|---|---|
|
|
67
67
|
| Windows web-artifacts | The artifact build pipeline is bash-script-backed (POSIX-only); `tests/unit/web-artifacts.test.ts` self-skips on win32 and docs now state the boundary. Decide: port the build scripts to a cross-platform runner, or keep the documented "build on POSIX, render anywhere" stance. |
|
|
68
68
|
| Composite parity no longer machine-checked | Since v0.3.0 removed the legacy standalones, the byte-identical composite-vs-legacy guarantee has no head-to-head test — regressions in `buildInput`/`formatResult` reuse surface only via output-shape assertions. Accepted at review; revisit if a composite drifts. |
|
|
69
|
-
| Portal-layer ext-app mount (contingency) | If the maintainer sets the WebKit UX bar at "never any visible gap", inline ext-app iframes must render outside the world transform — an architectural change that reopens the parked M3.
|
|
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
77
|
| Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; re-verify next cycle. |
|
|
78
78
|
| Registry-migration dead-code sweep | **Done (2026-08-03):** removed the orphaned `describeOp`/`describeGateOp` (composites.ts, dead since the v0.3.0 registration filter) and the `compactNodePayload`/`compactLayoutPayload`/`wantsFullPayload` duplicates in `mcp/server.ts` (live copies are in `ops/nodes.ts`). |
|
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.
|
|
@@ -250,9 +260,11 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
|
|
|
250
260
|
iframe) can block child iframes from loading ANY `src` URL, breaking every iframe-backed node
|
|
251
261
|
with a gray placeholder. The canvas probes this at boot and auto-falls back to fetching
|
|
252
262
|
same-origin surfaces and rendering them inline via `srcdoc` (HTML, graph, json-render, frame
|
|
253
|
-
documents).
|
|
254
|
-
|
|
255
|
-
|
|
263
|
+
documents). In Amp orbs specifically, the server sees `AMP_ORB` in its environment and stamps
|
|
264
|
+
the page so the embedded client skips the (there-unreliable) probe and goes straight to
|
|
265
|
+
`srcdoc`; HTML surfaces also inline their theme stylesheet so they render styled. External app
|
|
266
|
+
URLs cannot be inlined (cross-origin) and may stay blocked in such hosts. Force a mode with
|
|
267
|
+
`/workbench?iframe-mode=srcdoc` (or `iframe-mode=src`) when diagnosing.
|
|
256
268
|
- Graph and json-render standalone surfaces use `display=site` and fill the browser viewport, and
|
|
257
269
|
reflow on a live window resize in a normal browser. Some single-tab host browsers historically
|
|
258
270
|
didn't deliver live-resize events (current Codex builds do — the 0.4.0 retest saw a standalone
|
|
@@ -132,7 +132,7 @@ pmx-canvas --theme=light # Light theme
|
|
|
132
132
|
pmx-canvas --version # Print installed version and exit
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
`--theme` accepts `dark` (default), `light`,
|
|
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
|
@@ -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
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '../../shared/ax-surface-protocol.js';
|
|
10
10
|
import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
|
|
11
11
|
import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
|
|
12
|
+
import { canvasThemeScheme } from '../../shared/themes.js';
|
|
12
13
|
import {
|
|
13
14
|
canvasTheme,
|
|
14
15
|
collapseExpandedNode,
|
|
@@ -520,7 +521,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
520
521
|
[],
|
|
521
522
|
);
|
|
522
523
|
|
|
523
|
-
|
|
524
|
+
// MCP apps only understand dark/light — collapse named themes to their scheme.
|
|
525
|
+
const toMcpTheme = (theme: string): McpUiTheme => canvasThemeScheme(theme);
|
|
524
526
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
525
527
|
|
|
526
528
|
latestToolInputRef.current = toolInput;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
2
2
|
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
3
|
+
import { canvasThemeScheme } from '../../shared/themes.js';
|
|
3
4
|
import type { CanvasNodeState } from '../types';
|
|
4
5
|
import { axSurfaceState, canvasTheme } from '../state/canvas-store';
|
|
5
6
|
import { shouldContentFitIframeNode } from '../canvas/auto-fit';
|
|
@@ -20,7 +21,9 @@ function withViewerParams(
|
|
|
20
21
|
if (!url) return url;
|
|
21
22
|
try {
|
|
22
23
|
const resolved = new URL(url, window.location.origin);
|
|
23
|
-
|
|
24
|
+
// The json-render viewer + artifacts understand dark/light — collapse named
|
|
25
|
+
// themes (sepia → light, midnight/arctic/ember/forest → dark) to a scheme.
|
|
26
|
+
resolved.searchParams.set('theme', canvasThemeScheme(canvasTheme.value));
|
|
24
27
|
if (expanded) resolved.searchParams.set('display', 'expanded');
|
|
25
28
|
// Streaming json-render nodes bump specVersion as patches accumulate; including
|
|
26
29
|
// it in the src reloads the iframe so it re-reads the latest accumulated spec.
|
|
@@ -69,6 +69,17 @@ function isEmbeddedDocument(): boolean {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Amp orb services run with AMP_ORB=1 and the server stamps that into the page
|
|
74
|
+
* as window.__PMX_AMP_ORB (see canvasSpaHtml). The orb portal's nested-iframe
|
|
75
|
+
* embed blocks src-URL child iframes, but the probe is unreliable there — a
|
|
76
|
+
* tiny probe iframe sometimes loads even though node-sized ones will not — so
|
|
77
|
+
* a known orb host must skip the probe and go straight to srcdoc.
|
|
78
|
+
*/
|
|
79
|
+
function isAmpOrbHost(): boolean {
|
|
80
|
+
return (window as Window & { __PMX_AMP_ORB?: unknown }).__PMX_AMP_ORB === true;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
/**
|
|
73
84
|
* Resolve the boot-wide iframe mode once; all surface hooks share the result.
|
|
74
85
|
* The blocked-src condition only exists when the canvas page itself runs
|
|
@@ -76,7 +87,7 @@ function isEmbeddedDocument(): boolean {
|
|
|
76
87
|
* normal path pays zero probe latency before surfaces mount. Only embedded
|
|
77
88
|
* documents run the probe (`embedded` is overridable for tests).
|
|
78
89
|
*/
|
|
79
|
-
export function resolveIframeMode(opts: { embedded?: boolean } = {}): Promise<IframeMode> {
|
|
90
|
+
export function resolveIframeMode(opts: { embedded?: boolean; ampOrb?: boolean } = {}): Promise<IframeMode> {
|
|
80
91
|
if (iframeMode.value) return Promise.resolve(iframeMode.value);
|
|
81
92
|
if (pending) return pending;
|
|
82
93
|
const forced = forcedIframeMode();
|
|
@@ -88,6 +99,10 @@ export function resolveIframeMode(opts: { embedded?: boolean } = {}): Promise<If
|
|
|
88
99
|
iframeMode.value = 'src';
|
|
89
100
|
return Promise.resolve('src');
|
|
90
101
|
}
|
|
102
|
+
if (opts.ampOrb ?? isAmpOrbHost()) {
|
|
103
|
+
iframeMode.value = 'srcdoc';
|
|
104
|
+
return Promise.resolve('srcdoc');
|
|
105
|
+
}
|
|
91
106
|
pending = probeSrcIframes().then((srcWorks) => {
|
|
92
107
|
const mode: IframeMode = srcWorks ? 'src' : 'srcdoc';
|
|
93
108
|
iframeMode.value = mode;
|
|
@@ -31,6 +31,7 @@ import { invalidateTokenCache } from '../theme/tokens';
|
|
|
31
31
|
import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
|
|
32
32
|
import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
|
|
33
33
|
import type { PmxAxIntent } from '../../shared/ax-intent.js';
|
|
34
|
+
import { isCanvasTheme } from '../../shared/themes.js';
|
|
34
35
|
|
|
35
36
|
let eventSource: EventSource | null = null;
|
|
36
37
|
let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
|
|
@@ -275,8 +276,10 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
|
|
|
275
276
|
}
|
|
276
277
|
|
|
277
278
|
function applyCanvasTheme(theme: string): void {
|
|
278
|
-
|
|
279
|
-
|
|
279
|
+
// Registry-driven: this is the ONLY path a server-side theme reaches the
|
|
280
|
+
// browser (connected snapshot + theme-changed events) — a hardcoded subset
|
|
281
|
+
// here silently drops themes on reload and breaks cross-tab sync.
|
|
282
|
+
if (!isCanvasTheme(theme)) return;
|
|
280
283
|
document.documentElement.setAttribute('data-theme', theme);
|
|
281
284
|
invalidateTokenCache();
|
|
282
285
|
if (canvasTheme.value !== theme) canvasTheme.value = theme;
|