pmx-canvas 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +5 -2
  2. package/CHANGELOG.md +60 -0
  3. package/Readme.md +35 -12
  4. package/dist/canvas/global.css +92 -28
  5. package/dist/canvas/index.js +62 -62
  6. package/dist/canvas/surface-theme.css +52 -20
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/skills.d.ts +41 -0
  9. package/dist/types/client/state/sse-bridge.d.ts +1 -0
  10. package/dist/types/client/state/theme-override.d.ts +27 -0
  11. package/dist/types/shared/themes.d.ts +1 -1
  12. package/docs/RELEASE.md +14 -6
  13. package/docs/bun-webview-integration.md +2 -2
  14. package/docs/cli.md +24 -1
  15. package/docs/environment.md +2 -2
  16. package/docs/http-api.md +14 -3
  17. package/docs/plans/plan-009-tech-debt-backlog.md +5 -2
  18. package/docs/screenshot.png +0 -0
  19. package/package.json +3 -3
  20. package/skills/pmx-canvas/SKILL.md +18 -4
  21. package/skills/pmx-canvas/references/full-reference.md +1 -1
  22. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  23. package/src/cli/agent.ts +1 -0
  24. package/src/cli/commands/skills.ts +183 -0
  25. package/src/cli/index.ts +3 -1
  26. package/src/cli/shared.ts +1 -0
  27. package/src/client/App.tsx +85 -78
  28. package/src/client/canvas/CommandPalette.tsx +4 -0
  29. package/src/client/nodes/ExtAppFrame.tsx +15 -4
  30. package/src/client/nodes/McpAppNode.tsx +6 -1
  31. package/src/client/state/sse-bridge.ts +22 -5
  32. package/src/client/state/theme-override.ts +83 -0
  33. package/src/client/theme/global.css +92 -28
  34. package/src/client/theme/surface-theme.css +52 -20
  35. package/src/server/operations/ops/canvas-wire.ts +10 -2
  36. package/src/server/server.ts +13 -2
  37. package/src/shared/themes.ts +3 -1
package/src/cli/index.ts CHANGED
@@ -66,6 +66,7 @@ const AGENT_COMMANDS = new Set([
66
66
  'validate',
67
67
  'serve',
68
68
  'copilot',
69
+ 'skills',
69
70
  ]);
70
71
 
71
72
  const firstArg = args[0] ?? '';
@@ -253,7 +254,7 @@ Server options:
253
254
  --log-file=PATH Daemon log file (default: ./.pmx-canvas/daemon-${port}.log)
254
255
  --pid-file=PATH Optional daemon PID file (default: ./.pmx-canvas/daemon-${port}.pid)
255
256
  --wait-ms=MS Health-check wait budget for daemon mode (default: 10000)
256
- --theme=THEME Theme: dark (default), light, high-contrast, midnight, sepia, arctic, ember, forest
257
+ --theme=THEME Theme: dark (default), light, high-contrast, midnight, sepia, arctic, ember, forest, volt
257
258
  --webview-automation Start a headless Bun.WebView automation session for /workbench
258
259
  --webview-backend=BACKEND Bun.WebView backend: chrome or webkit
259
260
  --webview-width=PX Automation WebView width (default: 1280)
@@ -343,6 +344,7 @@ Examples:
343
344
  pmx-canvas watch --events context-pin,move-end Watch semantic deltas
344
345
  pmx-canvas clear --dry-run Preview destructive op
345
346
  pmx-canvas copilot install-extension --dry-run Preview Copilot adapter install
347
+ pmx-canvas skills sync --yes Refresh installed pmx-canvas skill copies in this workspace (--check to detect drift)
346
348
  `);
347
349
  process.exit(0);
348
350
  }
package/src/cli/shared.ts CHANGED
@@ -163,6 +163,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
163
163
  'permissions',
164
164
  'files',
165
165
  'ui-prompts',
166
+ 'check',
166
167
  ]);
167
168
  for (let i = 0; i < args.length; i++) {
168
169
  const arg = args[i];
@@ -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';
@@ -56,10 +57,17 @@ let pollSeq: number | null = null;
56
57
  let pollGeneration = 0;
57
58
  let sseFirstEventWatchdog: ReturnType<typeof setTimeout> | null = null;
58
59
 
59
- function forcedTransport(): 'poll' | 'sse' | null {
60
- if (typeof location === 'undefined') return null;
60
+ export function forcedTransport(): 'poll' | 'sse' | null {
61
+ if (typeof location === 'undefined' || typeof window === 'undefined') return null;
61
62
  const value = new URLSearchParams(location.search).get('transport');
62
- return value === 'poll' || value === 'sse' ? value : null;
63
+ if (value === 'poll' || value === 'sse') return value;
64
+ // Amp orbs: the portal proxy buffers SSE, so auto mode burns the 3s
65
+ // watchdog before falling back — and on slow proxy days that trips the
66
+ // boot modal. The server stamps the orb flag into the page (canvasSpaHtml);
67
+ // go straight to the transport that works there. `?transport=sse` above
68
+ // still overrides for diagnosis.
69
+ if ((window as Window & { __PMX_AMP_ORB?: unknown }).__PMX_AMP_ORB === true) return 'poll';
70
+ return null;
63
71
  }
64
72
 
65
73
  // Maps responseNodeId → thread prompt node ID so response deltas/completions
@@ -399,7 +407,9 @@ function handleConnected(data: Record<string, unknown>): void {
399
407
  connectionStatus.value = 'connected';
400
408
  // Reconnect marker for holders of server-minted URLs (Finding S).
401
409
  workbenchConnectionEpoch.value += 1;
402
- if (typeof data.theme === 'string') {
410
+ // A ?theme= session override (host-default theming) wins over the
411
+ // server-global theme for THIS client only.
412
+ if (typeof data.theme === 'string' && !themeOverrideActive()) {
403
413
  applyCanvasTheme(data.theme);
404
414
  }
405
415
  if (data.ledgerSummary) {
@@ -890,7 +900,7 @@ function handleTraceState(data: Record<string, unknown>): void {
890
900
  }
891
901
 
892
902
  function handleThemeChanged(data: Record<string, unknown>): void {
893
- if (typeof data.theme === 'string') {
903
+ if (typeof data.theme === 'string' && !themeOverrideActive()) {
894
904
  applyCanvasTheme(data.theme);
895
905
  }
896
906
  }
@@ -1082,6 +1092,9 @@ function startPollingTransport(): () => void {
1082
1092
  }
1083
1093
 
1084
1094
  export function connectSSE(): () => void {
1095
+ // Host-default theming: a ?theme= param applies before the first server
1096
+ // frame so an embedding host panel never flashes the server-global theme.
1097
+ initSessionThemeOverride(applyCanvasTheme);
1085
1098
  savedLayout = restoreLayout();
1086
1099
  ensureStatusNode();
1087
1100
  hasInitialServerLayout.value = false;
@@ -1118,6 +1131,10 @@ export function connectSSE(): () => void {
1118
1131
  sseFirstEventWatchdog = null;
1119
1132
  if (gotFirstEvent || eventSource !== source) return;
1120
1133
  console.warn('[sse-bridge] No SSE event within watchdog window — switching to polling transport.');
1134
+ // Give the bootstrap card a fresh deadline for the fallback transport so
1135
+ // the "did not finish booting" modal doesn't fire while polling is
1136
+ // already recovering the boot (the inline bootstrap script listens).
1137
+ window.dispatchEvent(new Event('pmx-canvas-boot-extend'));
1121
1138
  source.close();
1122
1139
  eventSource = null;
1123
1140
  startPollingTransport();
@@ -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
+ }
@@ -337,16 +337,16 @@
337
337
  }
338
338
 
339
339
  :root[data-theme="ember"] {
340
- /* ── Core palette (ember · charcoal + vivid warm orange) ─── */
341
- --c-bg: #0E0E11;
342
- --c-panel: #17181D;
343
- --c-panel-soft: #121317;
344
- --c-line: #2A2C34;
345
- --c-text: #ECEDF0;
346
- --c-text-soft: #C9CCD4;
347
- --c-muted: #8E939F;
348
- --c-dim: #5F646E;
349
- --c-accent: #FF5C2E;
340
+ /* ── Core palette (ember · neutral charcoal + vivid orange-red) ─── */
341
+ --c-bg: #0B0B0C;
342
+ --c-panel: #161618;
343
+ --c-panel-soft: #101012;
344
+ --c-line: #29292C;
345
+ --c-text: #EDEBE6;
346
+ --c-text-soft: #CBC9C2;
347
+ --c-muted: #96948D;
348
+ --c-dim: #66655F;
349
+ --c-accent: #F34E1C;
350
350
  --c-ok: #4CC38A;
351
351
  --c-warn: #F5B944;
352
352
  --c-warn-alt: #FFB300;
@@ -354,23 +354,23 @@
354
354
  --c-purple: #A78BFA;
355
355
  --c-thinking: #A78BFA;
356
356
  --c-subagent: #38BDF8;
357
- --c-input-bg: #121317;
358
- --c-dot-grid: rgba(255, 92, 46, 0.08);
359
- --c-panel-overlay: rgba(14, 14, 17, 0.6);
360
- --c-panel-glass: rgba(23, 24, 29, 0.92);
357
+ --c-input-bg: #101012;
358
+ --c-dot-grid: rgba(243, 78, 28, 0.08);
359
+ --c-panel-overlay: rgba(11, 11, 12, 0.6);
360
+ --c-panel-glass: rgba(22, 22, 24, 0.92);
361
361
  --c-shadow: rgba(0, 0, 0, 0.3);
362
362
  --c-shadow-heavy: rgba(0, 0, 0, 0.5);
363
- --c-glow-accent: rgba(255, 92, 46, 0.15);
363
+ --c-glow-accent: rgba(243, 78, 28, 0.15);
364
364
  --c-surface-hover: rgba(255, 255, 255, 0.06);
365
365
  --c-surface-subtle: rgba(255, 255, 255, 0.03);
366
366
  --c-surface-th: rgba(255, 255, 255, 0.04);
367
- --c-accent-8: rgba(255, 92, 46, 0.08);
368
- --c-accent-10: rgba(255, 92, 46, 0.1);
369
- --c-accent-12: rgba(255, 92, 46, 0.12);
370
- --c-accent-15: rgba(255, 92, 46, 0.15);
371
- --c-accent-25: rgba(255, 92, 46, 0.25);
372
- --c-accent-30: rgba(255, 92, 46, 0.3);
373
- --c-accent-40: rgba(255, 92, 46, 0.4);
367
+ --c-accent-8: rgba(243, 78, 28, 0.08);
368
+ --c-accent-10: rgba(243, 78, 28, 0.1);
369
+ --c-accent-12: rgba(243, 78, 28, 0.12);
370
+ --c-accent-15: rgba(243, 78, 28, 0.15);
371
+ --c-accent-25: rgba(243, 78, 28, 0.25);
372
+ --c-accent-30: rgba(243, 78, 28, 0.3);
373
+ --c-accent-40: rgba(243, 78, 28, 0.4);
374
374
  --c-ok-10: rgba(76, 195, 138, 0.1);
375
375
  --c-ok-12: rgba(76, 195, 138, 0.12);
376
376
  --c-ok-20: rgba(76, 195, 138, 0.2);
@@ -383,12 +383,12 @@
383
383
  --c-warn-30: rgba(245, 185, 68, 0.3);
384
384
  --c-warn-60: rgba(245, 185, 68, 0.6);
385
385
  --c-danger-12: rgba(240, 86, 106, 0.12);
386
- --c-line-30: rgba(42, 44, 52, 0.3);
387
- --c-contrast-fg: #0E0E11;
388
- --c-accent-hover: #FF7A52;
386
+ --c-line-30: rgba(41, 41, 44, 0.3);
387
+ --c-contrast-fg: #0B0B0C;
388
+ --c-accent-hover: #FF6B3A;
389
389
  --c-warn-hover: #F7C868;
390
390
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
391
- --c-annotation: #F2F2F4;
391
+ --c-annotation: #F2F0EB;
392
392
  }
393
393
 
394
394
  :root[data-theme="forest"] {
@@ -446,6 +446,61 @@
446
446
  --c-annotation: #EDF5EF;
447
447
  }
448
448
 
449
+ :root[data-theme="volt"] {
450
+ /* ── Core palette (volt · green-tinted charcoal + coral) ── */
451
+ --c-bg: #0B0D0B;
452
+ --c-panel: #171A17;
453
+ --c-panel-soft: #111311;
454
+ --c-line: #272A27;
455
+ --c-text: #F6FFF5;
456
+ --c-text-soft: #CBD3CA;
457
+ --c-muted: #9CA49C;
458
+ --c-dim: #626962;
459
+ --c-accent: #F34E3F;
460
+ --c-ok: #52C880;
461
+ --c-warn: #E4C465;
462
+ --c-warn-alt: #FFB300;
463
+ --c-danger: #D67670;
464
+ --c-purple: #BC8FB4;
465
+ --c-thinking: #BC8FB4;
466
+ --c-subagent: #6CA0E0;
467
+ --c-input-bg: #111311;
468
+ --c-dot-grid: rgba(243, 78, 63, 0.08);
469
+ --c-panel-overlay: rgba(11, 13, 11, 0.6);
470
+ --c-panel-glass: rgba(23, 26, 23, 0.92);
471
+ --c-shadow: rgba(0, 0, 0, 0.3);
472
+ --c-shadow-heavy: rgba(0, 0, 0, 0.5);
473
+ --c-glow-accent: rgba(243, 78, 63, 0.15);
474
+ --c-surface-hover: rgba(255, 255, 255, 0.06);
475
+ --c-surface-subtle: rgba(255, 255, 255, 0.03);
476
+ --c-surface-th: rgba(255, 255, 255, 0.04);
477
+ --c-accent-8: rgba(243, 78, 63, 0.08);
478
+ --c-accent-10: rgba(243, 78, 63, 0.1);
479
+ --c-accent-12: rgba(243, 78, 63, 0.12);
480
+ --c-accent-15: rgba(243, 78, 63, 0.15);
481
+ --c-accent-25: rgba(243, 78, 63, 0.25);
482
+ --c-accent-30: rgba(243, 78, 63, 0.3);
483
+ --c-accent-40: rgba(243, 78, 63, 0.4);
484
+ --c-ok-10: rgba(82, 200, 128, 0.1);
485
+ --c-ok-12: rgba(82, 200, 128, 0.12);
486
+ --c-ok-20: rgba(82, 200, 128, 0.2);
487
+ --c-ok-25: rgba(82, 200, 128, 0.25);
488
+ --c-warn-10: rgba(228, 196, 101, 0.1);
489
+ --c-warn-12: rgba(228, 196, 101, 0.12);
490
+ --c-warn-15: rgba(228, 196, 101, 0.15);
491
+ --c-warn-20: rgba(228, 196, 101, 0.2);
492
+ --c-warn-22: rgba(228, 196, 101, 0.22);
493
+ --c-warn-30: rgba(228, 196, 101, 0.3);
494
+ --c-warn-60: rgba(228, 196, 101, 0.6);
495
+ --c-danger-12: rgba(214, 118, 112, 0.12);
496
+ --c-line-30: rgba(39, 42, 39, 0.3);
497
+ --c-contrast-fg: #0B0D0B;
498
+ --c-accent-hover: #F8695B;
499
+ --c-warn-hover: #EBD087;
500
+ --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
501
+ --c-annotation: #F1F7F0;
502
+ }
503
+
449
504
  * {
450
505
  box-sizing: border-box;
451
506
  margin: 0;
@@ -944,7 +999,6 @@ body,
944
999
  }
945
1000
 
946
1001
  .toolbar-group {
947
- position: relative;
948
1002
  display: flex;
949
1003
  flex-wrap: wrap;
950
1004
  align-items: center;
@@ -953,6 +1007,14 @@ body,
953
1007
  max-width: 100%;
954
1008
  }
955
1009
 
1010
+ /* Positioning parent for a toolbar popover menu. Every menu MUST live inside
1011
+ one of these wrapped around its trigger — anchoring to a wider ancestor
1012
+ opens the menu far from the button (0.4.2 report Finding T). */
1013
+ .toolbar-menu-anchor {
1014
+ position: relative;
1015
+ display: inline-flex;
1016
+ }
1017
+
956
1018
  .canvas-toolbar button svg {
957
1019
  display: block;
958
1020
  }
@@ -1074,7 +1136,9 @@ body,
1074
1136
  }
1075
1137
 
1076
1138
  .toolbar-more {
1077
- display: contents;
1139
+ /* inline-flex (NOT contents): this span is also the positioning anchor
1140
+ for the More menu — display:contents cannot anchor absolute children. */
1141
+ display: inline-flex;
1078
1142
  }
1079
1143
  }
1080
1144