pmx-canvas 0.4.2 → 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.
@@ -218,32 +218,32 @@
218
218
  }
219
219
 
220
220
  :root[data-theme="ember"] {
221
- /* ── Core palette (ember · charcoal + vivid warm orange) ─── */
222
- --c-bg: #0E0E11;
223
- --c-panel: #17181D;
224
- --c-panel-soft: #121317;
225
- --c-line: #2A2C34;
226
- --c-text: #ECEDF0;
227
- --c-text-soft: #C9CCD4;
228
- --c-muted: #8E939F;
229
- --c-dim: #5F646E;
230
- --c-accent: #FF5C2E;
221
+ /* ── Core palette (ember · neutral charcoal + vivid orange-red) ─── */
222
+ --c-bg: #0B0B0C;
223
+ --c-panel: #161618;
224
+ --c-panel-soft: #101012;
225
+ --c-line: #29292C;
226
+ --c-text: #EDEBE6;
227
+ --c-text-soft: #CBC9C2;
228
+ --c-muted: #96948D;
229
+ --c-dim: #66655F;
230
+ --c-accent: #F34E1C;
231
231
  --c-ok: #4CC38A;
232
232
  --c-warn: #F5B944;
233
233
  --c-warn-alt: #FFB300;
234
234
  --c-danger: #F0566A;
235
235
  --c-purple: #A78BFA;
236
236
 
237
- --color-bg: #0E0E11;
238
- --color-panel: #17181D;
239
- --color-surface: #121317;
240
- --color-border: #2A2C34;
241
- --color-text: #ECEDF0;
242
- --color-text-primary: #ECEDF0;
243
- --color-text-secondary: #C9CCD4;
244
- --color-text-muted: #8E939F;
245
- --color-text-dim: #5F646E;
246
- --color-accent: #FF5C2E;
237
+ --color-bg: #0B0B0C;
238
+ --color-panel: #161618;
239
+ --color-surface: #101012;
240
+ --color-border: #29292C;
241
+ --color-text: #EDEBE6;
242
+ --color-text-primary: #EDEBE6;
243
+ --color-text-secondary: #CBC9C2;
244
+ --color-text-muted: #96948D;
245
+ --color-text-dim: #66655F;
246
+ --color-accent: #F34E1C;
247
247
  --color-success: #4CC38A;
248
248
  --color-warning: #F5B944;
249
249
  --color-danger: #F0566A;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Per-session theme override — host-default theming.
3
+ *
4
+ * Hosts that embed the workbench (the GitHub Copilot panel, the Claude Code
5
+ * desktop browser pane, ChatGPT-style app browsers, …) can open it with
6
+ * `?theme=<name>` or `?theme=auto` to give THEIR panel a fitting default
7
+ * without touching the server-global theme every other client sees. The
8
+ * override is client-local: server theme frames are ignored while it is
9
+ * active, and it ends the moment the user explicitly picks a theme from the
10
+ * picker (which then applies + saves globally as usual).
11
+ *
12
+ * `theme=auto` follows the host's prefers-color-scheme live, mapping to the
13
+ * built-in light/dark themes — so a light-mode host panel gets a light canvas
14
+ * by default and flips with the host appearance.
15
+ */
16
+ import { type CanvasThemeName } from '../../shared/themes.js';
17
+ export declare function themeOverrideActive(): boolean;
18
+ /** Test-only: reset the module state, including the page-lifetime latch. */
19
+ export declare function resetThemeOverrideForTests(): void;
20
+ /** The user picked a theme explicitly — the session override ends for good. */
21
+ export declare function clearThemeOverride(): void;
22
+ export declare function sessionThemeParam(): CanvasThemeName | 'auto' | null;
23
+ /**
24
+ * Activate the override from the URL (no-op without a valid `?theme=`).
25
+ * `apply` is the canvas theme applier (client-side only — never saved).
26
+ */
27
+ export declare function initSessionThemeOverride(apply: (theme: CanvasThemeName) => void): void;
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
@@ -74,5 +74,7 @@ The remaining items were re-analyzed with pros/cons and tiered; the maintainer a
74
74
  |---|---|
75
75
  | Finding S — frame documents vs server restart | **Done + field-confirmed (0.4.1 reports, both hosts):** the browser revalidates minted frame-document URLs on every reconnect epoch (`workbenchConnectionEpoch`, HEAD probe) and re-mints against the new process when the store is gone; `/api/canvas/frame-documents/:id` answers HEAD. Copilot: IDs fully rotated, all post-restart 200, no frame-not-found tiles. Codex: re-mint without workbench reload. Server-side persistence deliberately NOT done — frames are client-derived and re-mintable. |
76
76
  | Amp portal nested-iframe embedding | **Done (2026-08-04):** boot-time embed probe (`/api/canvas/iframe-probe`) + fetch()+srcdoc fallback for all same-origin surfaces (`useSurfaceFrame`, `useIframeDocument` srcdoc short-circuit), `?iframe-mode` override, e2e-forced-mode coverage. External (cross-origin) app URLs cannot be inlined — documented limitation. |
77
- | Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; re-verify next cycle. |
77
+ | Copilot adapter `canvas_snapshot list` descriptor lag | Host-side cached tool schema still requires `name`; fresh `--mcp` is correct. Adapter/packaging issue, not a daemon defect — nothing to change in core; 0.4.2 cycle saw the legacy tools gone from the host list after refresh. |
78
+ | Finding T — theme/More menu mis-anchored | **Done (2026-08-05):** menus moved inside `.toolbar-menu-anchor` wrappers around their triggers (the group-level `position:relative` + `right:0` anchored them ~212px right of the button, both hosts). Anchor-geometry e2e regression tests added. |
79
+ | Finding U — sticky `-32001` session-error banner over rendering content | **Mitigated (2026-08-05), root cause open:** when an ext-app session errors but the cached document still renders, the banner is now a soft degraded-interactivity notice instead of a red alarm. The underlying stressed-reconnect MCP timeout (long-lived tiles through theme churn + daemon respawn) is not clean-reproducible — investigate session re-establishment retry/backoff if the next cycle still hits it. |
78
80
  | Registry-migration dead-code sweep | **Done (2026-08-03):** removed the orphaned `describeOp`/`describeGateOp` (composites.ts, dead since the v0.3.0 registration filter) and the `compactNodePayload`/`compactLayoutPayload`/`wantsFullPayload` duplicates in `mcp/server.ts` (live copies are in `ops/nodes.ts`). |
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.4.2",
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",
@@ -181,6 +181,10 @@ Use the visible workbench when the human is actively curating layout:
181
181
  embedded host panes and mobile) the secondary controls collapse into a single "⋯" More menu at
182
182
  the end of the bar; every action is still there as a labeled row, including the theme list. If
183
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`.
184
188
 
185
189
  After changing files under `src/client/`, rebuild with `bun run build` before manual browser
186
190
  verification.
@@ -59,6 +59,7 @@ 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';
64
65
  import {
@@ -184,6 +185,9 @@ function Toolbar({
184
185
  }, [openMenu]);
185
186
 
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();
187
191
  document.documentElement.setAttribute('data-theme', next);
188
192
  invalidateTokenCache();
189
193
  canvasTheme.value = next;
@@ -307,18 +311,27 @@ function Toolbar({
307
311
  <IconMinimap />
308
312
  </button>
309
313
  </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>
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>
322
335
  <ToolbarHint label="Snapshots" detail="Capture and restore canvas states" align="end">
323
336
  <button
324
337
  ref={snapshotBtnRef}
@@ -333,7 +346,7 @@ function Toolbar({
333
346
  </span>
334
347
 
335
348
  {/* Narrow screens: everything collapsed lives in this single menu. */}
336
- <span class="toolbar-more">
349
+ <span class="toolbar-more toolbar-menu-anchor">
337
350
  <ToolbarHint label="More" detail="All canvas actions" align="end">
338
351
  <button
339
352
  type="button"
@@ -348,6 +361,65 @@ function Toolbar({
348
361
  </span>
349
362
  </button>
350
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
+ )}
351
423
  </span>
352
424
  </div>
353
425
 
@@ -443,71 +515,6 @@ function Toolbar({
443
515
  {countsLabel}
444
516
  </span>
445
517
  </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
- )}
511
518
  </div>
512
519
  );
513
520
  }
@@ -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;
@@ -528,9 +528,17 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
528
528
  latestToolInputRef.current = toolInput;
529
529
  latestToolResultRef.current = toolResult;
530
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;
531
537
  const sessionUnavailableMessage =
532
538
  sessionStatus === 'error'
533
- ? (sessionError ?? 'Saved app session is unavailable. Reopen the app to restore interactivity.')
539
+ ? sessionErrorWithContent
540
+ ? `Live session unavailable — showing saved content. Reopen the app to restore interactivity.${sessionError ? ` (${sessionError})` : ''}`
541
+ : (sessionError ?? 'Saved app session is unavailable. Reopen the app to restore interactivity.')
534
542
  : 'Reconnecting saved app session...';
535
543
 
536
544
  const flushToolResult = (bridge: AppBridge | null): Promise<void> | null => {
@@ -1040,9 +1048,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1040
1048
  style={{
1041
1049
  padding: '6px 10px',
1042
1050
  fontSize: '11px',
1043
- background: sessionStatus === 'error' ? 'var(--c-danger-12)' : 'var(--c-warn-10)',
1044
- color: sessionStatus === 'error' ? 'var(--c-danger)' : 'var(--c-warn)',
1045
- borderBottom: `1px solid ${sessionStatus === 'error' ? 'var(--c-danger-12)' : 'var(--c-warn-15)'}`,
1051
+ background:
1052
+ sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-10)',
1053
+ color: sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger)' : 'var(--c-warn)',
1054
+ borderBottom: `1px solid ${
1055
+ sessionStatus === 'error' && !sessionErrorWithContent ? 'var(--c-danger-12)' : 'var(--c-warn-15)'
1056
+ }`,
1046
1057
  }}
1047
1058
  >
1048
1059
  {sessionUnavailableMessage}
@@ -3,6 +3,7 @@ import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
3
3
  import { canvasThemeScheme } from '../../shared/themes.js';
4
4
  import type { CanvasNodeState } from '../types';
5
5
  import { axSurfaceState, canvasTheme } from '../state/canvas-store';
6
+ import { iframeMode } from '../state/iframe-mode';
6
7
  import { shouldContentFitIframeNode } from '../canvas/auto-fit';
7
8
  import { ExtAppFrame } from './ExtAppFrame';
8
9
  import { useAxSurfaceBridge } from './use-ax-surface-bridge';
@@ -185,7 +186,11 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
185
186
  class="mcp-app-frame"
186
187
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
187
188
  allow="clipboard-read; clipboard-write"
188
- loading="lazy"
189
+ // Lazy loading only helps src mode (defers the HTTP request for
190
+ // off-screen nodes). In srcdoc mode the content is already inline —
191
+ // lazy would just delay PAINTING until the node scrolls near the
192
+ // viewport, which reads as "blank until opened" (Amp portal report).
193
+ loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
189
194
  onLoad={pushAxState}
190
195
  style={{ flex: 1, minHeight: 0, width: '100%' }}
191
196
  title={`MCP App: ${sourceServer}`}
@@ -26,6 +26,7 @@ import {
26
26
  workbenchConnectionEpoch,
27
27
  } from './canvas-store';
28
28
  import { fetchAxSurfaceState } from './intent-bridge';
29
+ import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
29
30
  import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
30
31
  import { invalidateTokenCache } from '../theme/tokens';
31
32
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
@@ -399,7 +400,9 @@ function handleConnected(data: Record<string, unknown>): void {
399
400
  connectionStatus.value = 'connected';
400
401
  // Reconnect marker for holders of server-minted URLs (Finding S).
401
402
  workbenchConnectionEpoch.value += 1;
402
- if (typeof data.theme === 'string') {
403
+ // A ?theme= session override (host-default theming) wins over the
404
+ // server-global theme for THIS client only.
405
+ if (typeof data.theme === 'string' && !themeOverrideActive()) {
403
406
  applyCanvasTheme(data.theme);
404
407
  }
405
408
  if (data.ledgerSummary) {
@@ -890,7 +893,7 @@ function handleTraceState(data: Record<string, unknown>): void {
890
893
  }
891
894
 
892
895
  function handleThemeChanged(data: Record<string, unknown>): void {
893
- if (typeof data.theme === 'string') {
896
+ if (typeof data.theme === 'string' && !themeOverrideActive()) {
894
897
  applyCanvasTheme(data.theme);
895
898
  }
896
899
  }
@@ -1082,6 +1085,9 @@ function startPollingTransport(): () => void {
1082
1085
  }
1083
1086
 
1084
1087
  export function connectSSE(): () => void {
1088
+ // Host-default theming: a ?theme= param applies before the first server
1089
+ // frame so an embedding host panel never flashes the server-global theme.
1090
+ initSessionThemeOverride(applyCanvasTheme);
1085
1091
  savedLayout = restoreLayout();
1086
1092
  ensureStatusNode();
1087
1093
  hasInitialServerLayout.value = false;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Per-session theme override — host-default theming.
3
+ *
4
+ * Hosts that embed the workbench (the GitHub Copilot panel, the Claude Code
5
+ * desktop browser pane, ChatGPT-style app browsers, …) can open it with
6
+ * `?theme=<name>` or `?theme=auto` to give THEIR panel a fitting default
7
+ * without touching the server-global theme every other client sees. The
8
+ * override is client-local: server theme frames are ignored while it is
9
+ * active, and it ends the moment the user explicitly picks a theme from the
10
+ * picker (which then applies + saves globally as usual).
11
+ *
12
+ * `theme=auto` follows the host's prefers-color-scheme live, mapping to the
13
+ * built-in light/dark themes — so a light-mode host panel gets a light canvas
14
+ * by default and flips with the host appearance.
15
+ */
16
+ import { type CanvasThemeName, isCanvasTheme } from '../../shared/themes.js';
17
+
18
+ let active = false;
19
+ // Latched when the user explicitly ends the override: the URL param survives
20
+ // reconnects (connectSSE re-inits on every transport drop), but the user's
21
+ // choice must stick for the page lifetime — only a reload re-activates.
22
+ let userEnded = false;
23
+ let schemeQuery: MediaQueryList | null = null;
24
+ let schemeListener: (() => void) | null = null;
25
+
26
+ export function themeOverrideActive(): boolean {
27
+ return active;
28
+ }
29
+
30
+ /** Test-only: reset the module state, including the page-lifetime latch. */
31
+ export function resetThemeOverrideForTests(): void {
32
+ active = false;
33
+ userEnded = false;
34
+ detachSchemeListener();
35
+ }
36
+
37
+ /** The user picked a theme explicitly — the session override ends for good. */
38
+ export function clearThemeOverride(): void {
39
+ active = false;
40
+ userEnded = true;
41
+ detachSchemeListener();
42
+ }
43
+
44
+ function detachSchemeListener(): void {
45
+ if (schemeQuery && schemeListener) {
46
+ schemeQuery.removeEventListener('change', schemeListener);
47
+ }
48
+ schemeQuery = null;
49
+ schemeListener = null;
50
+ }
51
+
52
+ export function sessionThemeParam(): CanvasThemeName | 'auto' | null {
53
+ // DOM-less callers (server-side rendering of the module graph, unit tests
54
+ // that import the sse-bridge without a DOM) have no override.
55
+ if (typeof window === 'undefined' || !window.location) return null;
56
+ const value = new URLSearchParams(window.location.search).get('theme');
57
+ if (value === 'auto') return 'auto';
58
+ return isCanvasTheme(value) ? value : null;
59
+ }
60
+
61
+ /**
62
+ * Activate the override from the URL (no-op without a valid `?theme=`).
63
+ * `apply` is the canvas theme applier (client-side only — never saved).
64
+ */
65
+ export function initSessionThemeOverride(apply: (theme: CanvasThemeName) => void): void {
66
+ if (userEnded) return;
67
+ const param = sessionThemeParam();
68
+ if (!param) return;
69
+ // Re-init happens on every reconnect — replace any prior listener instead
70
+ // of accumulating one per reconnect cycle.
71
+ detachSchemeListener();
72
+ active = true;
73
+ if (param !== 'auto') {
74
+ apply(param);
75
+ return;
76
+ }
77
+ schemeQuery = window.matchMedia('(prefers-color-scheme: light)');
78
+ schemeListener = () => {
79
+ if (active && schemeQuery) apply(schemeQuery.matches ? 'light' : 'dark');
80
+ };
81
+ schemeListener();
82
+ schemeQuery.addEventListener('change', schemeListener);
83
+ }