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.
Files changed (37) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +5 -2
  2. package/CHANGELOG.md +61 -0
  3. package/Readme.md +34 -12
  4. package/dist/canvas/global.css +406 -0
  5. package/dist/canvas/index.js +84 -84
  6. package/dist/canvas/surface-theme.css +160 -0
  7. package/dist/json-render/index.css +1 -1
  8. package/dist/types/client/state/iframe-mode.d.ts +1 -0
  9. package/dist/types/client/state/theme-override.d.ts +27 -0
  10. package/dist/types/client/types.d.ts +2 -1
  11. package/dist/types/server/canvas-db.d.ts +2 -1
  12. package/dist/types/server/html-surface.d.ts +8 -1
  13. package/dist/types/shared/themes.d.ts +27 -0
  14. package/docs/cli.md +1 -1
  15. package/docs/environment.md +2 -1
  16. package/docs/http-api.md +15 -0
  17. package/docs/plans/plan-009-tech-debt-backlog.md +5 -3
  18. package/docs/screenshot.png +0 -0
  19. package/package.json +3 -3
  20. package/skills/pmx-canvas/SKILL.md +19 -3
  21. package/skills/pmx-canvas/references/full-reference.md +3 -3
  22. package/src/cli/index.ts +3 -2
  23. package/src/client/App.tsx +208 -53
  24. package/src/client/canvas/CommandPalette.tsx +4 -0
  25. package/src/client/nodes/ExtAppFrame.tsx +18 -5
  26. package/src/client/nodes/McpAppNode.tsx +10 -2
  27. package/src/client/state/iframe-mode.ts +16 -1
  28. package/src/client/state/sse-bridge.ts +13 -4
  29. package/src/client/state/theme-override.ts +83 -0
  30. package/src/client/theme/global.css +406 -0
  31. package/src/client/theme/surface-theme.css +160 -0
  32. package/src/client/types.ts +3 -1
  33. package/src/server/canvas-db.ts +3 -2
  34. package/src/server/html-surface.ts +17 -3
  35. package/src/server/operations/ops/canvas-wire.ts +15 -2
  36. package/src/server/server.ts +35 -1
  37. package/src/shared/themes.ts +58 -0
@@ -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;
@@ -26,11 +26,13 @@ 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';
32
33
  import { dissolveIntent, resetIntents, settleIntent, upsertIntent } from './intent-store';
33
34
  import type { PmxAxIntent } from '../../shared/ax-intent.js';
35
+ import { isCanvasTheme } from '../../shared/themes.js';
34
36
 
35
37
  let eventSource: EventSource | null = null;
36
38
  let savedLayout: Map<string, Partial<CanvasNodeState>> | null = null;
@@ -275,8 +277,10 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
275
277
  }
276
278
 
277
279
  function applyCanvasTheme(theme: string): void {
278
- const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
279
- if (!valid) return;
280
+ // Registry-driven: this is the ONLY path a server-side theme reaches the
281
+ // browser (connected snapshot + theme-changed events) — a hardcoded subset
282
+ // here silently drops themes on reload and breaks cross-tab sync.
283
+ if (!isCanvasTheme(theme)) return;
280
284
  document.documentElement.setAttribute('data-theme', theme);
281
285
  invalidateTokenCache();
282
286
  if (canvasTheme.value !== theme) canvasTheme.value = theme;
@@ -396,7 +400,9 @@ function handleConnected(data: Record<string, unknown>): void {
396
400
  connectionStatus.value = 'connected';
397
401
  // Reconnect marker for holders of server-minted URLs (Finding S).
398
402
  workbenchConnectionEpoch.value += 1;
399
- 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()) {
400
406
  applyCanvasTheme(data.theme);
401
407
  }
402
408
  if (data.ledgerSummary) {
@@ -887,7 +893,7 @@ function handleTraceState(data: Record<string, unknown>): void {
887
893
  }
888
894
 
889
895
  function handleThemeChanged(data: Record<string, unknown>): void {
890
- if (typeof data.theme === 'string') {
896
+ if (typeof data.theme === 'string' && !themeOverrideActive()) {
891
897
  applyCanvasTheme(data.theme);
892
898
  }
893
899
  }
@@ -1079,6 +1085,9 @@ function startPollingTransport(): () => void {
1079
1085
  }
1080
1086
 
1081
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);
1082
1091
  savedLayout = restoreLayout();
1083
1092
  ensureStatusNode();
1084
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
+ }
@@ -171,6 +171,281 @@
171
171
  --c-annotation: #ffff00;
172
172
  }
173
173
 
174
+ :root[data-theme="midnight"] {
175
+ /* ── Core palette (midnight · deep indigo night) ─────────── */
176
+ --c-bg: #0A0D1C;
177
+ --c-panel: #12172E;
178
+ --c-panel-soft: #0D1224;
179
+ --c-line: #232B4A;
180
+ --c-text: #E6E9F7;
181
+ --c-text-soft: #C6CCE8;
182
+ --c-muted: #8D96BC;
183
+ --c-dim: #5B6383;
184
+ --c-accent: #8B96FF;
185
+ --c-ok: #34D399;
186
+ --c-warn: #F5C452;
187
+ --c-warn-alt: #FFB300;
188
+ --c-danger: #FF6B8A;
189
+ --c-purple: #B388FF;
190
+ --c-thinking: #B388FF;
191
+ --c-subagent: #22D3EE;
192
+ --c-input-bg: #0D1224;
193
+ --c-dot-grid: rgba(139, 150, 255, 0.08);
194
+ --c-panel-overlay: rgba(10, 13, 28, 0.6);
195
+ --c-panel-glass: rgba(18, 23, 46, 0.92);
196
+ --c-shadow: rgba(0, 0, 0, 0.3);
197
+ --c-shadow-heavy: rgba(0, 0, 0, 0.5);
198
+ --c-glow-accent: rgba(139, 150, 255, 0.15);
199
+ --c-surface-hover: rgba(255, 255, 255, 0.06);
200
+ --c-surface-subtle: rgba(255, 255, 255, 0.03);
201
+ --c-surface-th: rgba(255, 255, 255, 0.04);
202
+ --c-accent-8: rgba(139, 150, 255, 0.08);
203
+ --c-accent-10: rgba(139, 150, 255, 0.1);
204
+ --c-accent-12: rgba(139, 150, 255, 0.12);
205
+ --c-accent-15: rgba(139, 150, 255, 0.15);
206
+ --c-accent-25: rgba(139, 150, 255, 0.25);
207
+ --c-accent-30: rgba(139, 150, 255, 0.3);
208
+ --c-accent-40: rgba(139, 150, 255, 0.4);
209
+ --c-ok-10: rgba(52, 211, 153, 0.1);
210
+ --c-ok-12: rgba(52, 211, 153, 0.12);
211
+ --c-ok-20: rgba(52, 211, 153, 0.2);
212
+ --c-ok-25: rgba(52, 211, 153, 0.25);
213
+ --c-warn-10: rgba(245, 196, 82, 0.1);
214
+ --c-warn-12: rgba(245, 196, 82, 0.12);
215
+ --c-warn-15: rgba(245, 196, 82, 0.15);
216
+ --c-warn-20: rgba(245, 196, 82, 0.2);
217
+ --c-warn-22: rgba(245, 196, 82, 0.22);
218
+ --c-warn-30: rgba(245, 196, 82, 0.3);
219
+ --c-warn-60: rgba(245, 196, 82, 0.6);
220
+ --c-danger-12: rgba(255, 107, 138, 0.12);
221
+ --c-line-30: rgba(35, 43, 74, 0.3);
222
+ --c-contrast-fg: #0A0D1C;
223
+ --c-accent-hover: #A3ACFF;
224
+ --c-warn-hover: #F7D06E;
225
+ --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
226
+ --c-annotation: #EDEDF7;
227
+ }
228
+
229
+ :root[data-theme="sepia"] {
230
+ /* ── Core palette (sepia · warm amber paper) ─────────────── */
231
+ --c-bg: #F2E7D5;
232
+ --c-panel: #EBDCC2;
233
+ --c-panel-soft: #E8D8BC;
234
+ --c-line: #D3C1A0;
235
+ --c-text: #2E2416;
236
+ --c-text-soft: #554833;
237
+ --c-muted: #77664C;
238
+ --c-dim: #978870;
239
+ --c-accent: #B4632C;
240
+ --c-ok: #3E7D4F;
241
+ --c-warn: #A97B1E;
242
+ --c-warn-alt: #8F6A0D;
243
+ --c-danger: #B3402E;
244
+ --c-purple: #8456B0;
245
+ --c-thinking: #8456B0;
246
+ --c-subagent: #0E7C7B;
247
+ --c-input-bg: #F7EEDD;
248
+ --c-dot-grid: rgba(46, 36, 22, 0.08);
249
+ --c-panel-overlay: rgba(242, 231, 213, 0.88);
250
+ --c-panel-glass: rgba(235, 220, 194, 0.88);
251
+ --c-shadow: rgba(46, 36, 22, 0.08);
252
+ --c-shadow-heavy: rgba(46, 36, 22, 0.16);
253
+ --c-glow-accent: rgba(180, 99, 44, 0.12);
254
+ --c-surface-hover: rgba(46, 36, 22, 0.06);
255
+ --c-surface-subtle: rgba(46, 36, 22, 0.03);
256
+ --c-surface-th: rgba(46, 36, 22, 0.04);
257
+ --c-accent-8: rgba(180, 99, 44, 0.08);
258
+ --c-accent-10: rgba(180, 99, 44, 0.1);
259
+ --c-accent-12: rgba(180, 99, 44, 0.12);
260
+ --c-accent-15: rgba(180, 99, 44, 0.15);
261
+ --c-accent-25: rgba(180, 99, 44, 0.25);
262
+ --c-accent-30: rgba(180, 99, 44, 0.3);
263
+ --c-accent-40: rgba(180, 99, 44, 0.4);
264
+ --c-ok-10: rgba(62, 125, 79, 0.1);
265
+ --c-ok-12: rgba(62, 125, 79, 0.12);
266
+ --c-ok-20: rgba(62, 125, 79, 0.2);
267
+ --c-ok-25: rgba(62, 125, 79, 0.25);
268
+ --c-warn-10: rgba(169, 123, 30, 0.1);
269
+ --c-warn-12: rgba(169, 123, 30, 0.12);
270
+ --c-warn-15: rgba(169, 123, 30, 0.15);
271
+ --c-warn-20: rgba(169, 123, 30, 0.2);
272
+ --c-warn-22: rgba(169, 123, 30, 0.22);
273
+ --c-warn-30: rgba(169, 123, 30, 0.3);
274
+ --c-warn-60: rgba(169, 123, 30, 0.6);
275
+ --c-danger-12: rgba(179, 64, 46, 0.12);
276
+ --c-line-30: rgba(211, 193, 160, 0.5);
277
+ --c-contrast-fg: #F2E7D5;
278
+ --c-accent-hover: #C4763F;
279
+ --c-warn-hover: #BD9037;
280
+ --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(46, 36, 22, 0.02));
281
+ --c-annotation: #2E2416;
282
+ }
283
+
284
+ :root[data-theme="arctic"] {
285
+ /* ── Core palette (arctic · cool slate + ice) ────────────── */
286
+ --c-bg: #20242E;
287
+ --c-panel: #2A2F3C;
288
+ --c-panel-soft: #242936;
289
+ --c-line: #3A4152;
290
+ --c-text: #E9EDF4;
291
+ --c-text-soft: #CBD3E1;
292
+ --c-muted: #939DB1;
293
+ --c-dim: #667082;
294
+ --c-accent: #7FC8DE;
295
+ --c-ok: #74C596;
296
+ --c-warn: #E8C476;
297
+ --c-warn-alt: #E0A93F;
298
+ --c-danger: #D9707C;
299
+ --c-purple: #B48EAD;
300
+ --c-thinking: #B48EAD;
301
+ --c-subagent: #6FD6C4;
302
+ --c-input-bg: #242936;
303
+ --c-dot-grid: rgba(127, 200, 222, 0.08);
304
+ --c-panel-overlay: rgba(32, 36, 46, 0.6);
305
+ --c-panel-glass: rgba(42, 47, 60, 0.92);
306
+ --c-shadow: rgba(0, 0, 0, 0.3);
307
+ --c-shadow-heavy: rgba(0, 0, 0, 0.5);
308
+ --c-glow-accent: rgba(127, 200, 222, 0.15);
309
+ --c-surface-hover: rgba(255, 255, 255, 0.06);
310
+ --c-surface-subtle: rgba(255, 255, 255, 0.03);
311
+ --c-surface-th: rgba(255, 255, 255, 0.04);
312
+ --c-accent-8: rgba(127, 200, 222, 0.08);
313
+ --c-accent-10: rgba(127, 200, 222, 0.1);
314
+ --c-accent-12: rgba(127, 200, 222, 0.12);
315
+ --c-accent-15: rgba(127, 200, 222, 0.15);
316
+ --c-accent-25: rgba(127, 200, 222, 0.25);
317
+ --c-accent-30: rgba(127, 200, 222, 0.3);
318
+ --c-accent-40: rgba(127, 200, 222, 0.4);
319
+ --c-ok-10: rgba(116, 197, 150, 0.1);
320
+ --c-ok-12: rgba(116, 197, 150, 0.12);
321
+ --c-ok-20: rgba(116, 197, 150, 0.2);
322
+ --c-ok-25: rgba(116, 197, 150, 0.25);
323
+ --c-warn-10: rgba(232, 196, 118, 0.1);
324
+ --c-warn-12: rgba(232, 196, 118, 0.12);
325
+ --c-warn-15: rgba(232, 196, 118, 0.15);
326
+ --c-warn-20: rgba(232, 196, 118, 0.2);
327
+ --c-warn-22: rgba(232, 196, 118, 0.22);
328
+ --c-warn-30: rgba(232, 196, 118, 0.3);
329
+ --c-warn-60: rgba(232, 196, 118, 0.6);
330
+ --c-danger-12: rgba(217, 112, 124, 0.12);
331
+ --c-line-30: rgba(58, 65, 82, 0.3);
332
+ --c-contrast-fg: #20242E;
333
+ --c-accent-hover: #9AD5E7;
334
+ --c-warn-hover: #EDD194;
335
+ --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
336
+ --c-annotation: #ECEFF4;
337
+ }
338
+
339
+ :root[data-theme="ember"] {
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
+ --c-ok: #4CC38A;
351
+ --c-warn: #F5B944;
352
+ --c-warn-alt: #FFB300;
353
+ --c-danger: #F0566A;
354
+ --c-purple: #A78BFA;
355
+ --c-thinking: #A78BFA;
356
+ --c-subagent: #38BDF8;
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
+ --c-shadow: rgba(0, 0, 0, 0.3);
362
+ --c-shadow-heavy: rgba(0, 0, 0, 0.5);
363
+ --c-glow-accent: rgba(243, 78, 28, 0.15);
364
+ --c-surface-hover: rgba(255, 255, 255, 0.06);
365
+ --c-surface-subtle: rgba(255, 255, 255, 0.03);
366
+ --c-surface-th: rgba(255, 255, 255, 0.04);
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
+ --c-ok-10: rgba(76, 195, 138, 0.1);
375
+ --c-ok-12: rgba(76, 195, 138, 0.12);
376
+ --c-ok-20: rgba(76, 195, 138, 0.2);
377
+ --c-ok-25: rgba(76, 195, 138, 0.25);
378
+ --c-warn-10: rgba(245, 185, 68, 0.1);
379
+ --c-warn-12: rgba(245, 185, 68, 0.12);
380
+ --c-warn-15: rgba(245, 185, 68, 0.15);
381
+ --c-warn-20: rgba(245, 185, 68, 0.2);
382
+ --c-warn-22: rgba(245, 185, 68, 0.22);
383
+ --c-warn-30: rgba(245, 185, 68, 0.3);
384
+ --c-warn-60: rgba(245, 185, 68, 0.6);
385
+ --c-danger-12: rgba(240, 86, 106, 0.12);
386
+ --c-line-30: rgba(41, 41, 44, 0.3);
387
+ --c-contrast-fg: #0B0B0C;
388
+ --c-accent-hover: #FF6B3A;
389
+ --c-warn-hover: #F7C868;
390
+ --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
391
+ --c-annotation: #F2F0EB;
392
+ }
393
+
394
+ :root[data-theme="forest"] {
395
+ /* ── Core palette (forest · deep green) ──────────────────── */
396
+ --c-bg: #0C1712;
397
+ --c-panel: #14231B;
398
+ --c-panel-soft: #101D16;
399
+ --c-line: #26402F;
400
+ --c-text: #E4EFE7;
401
+ --c-text-soft: #C4D6CA;
402
+ --c-muted: #8BA695;
403
+ --c-dim: #5D7566;
404
+ --c-accent: #5CCF8F;
405
+ --c-ok: #74D99F;
406
+ --c-warn: #E0B84F;
407
+ --c-warn-alt: #D9A421;
408
+ --c-danger: #E57373;
409
+ --c-purple: #A88FE0;
410
+ --c-thinking: #A88FE0;
411
+ --c-subagent: #4FD8CE;
412
+ --c-input-bg: #101D16;
413
+ --c-dot-grid: rgba(92, 207, 143, 0.08);
414
+ --c-panel-overlay: rgba(12, 23, 18, 0.6);
415
+ --c-panel-glass: rgba(20, 35, 27, 0.92);
416
+ --c-shadow: rgba(0, 0, 0, 0.3);
417
+ --c-shadow-heavy: rgba(0, 0, 0, 0.5);
418
+ --c-glow-accent: rgba(92, 207, 143, 0.15);
419
+ --c-surface-hover: rgba(255, 255, 255, 0.06);
420
+ --c-surface-subtle: rgba(255, 255, 255, 0.03);
421
+ --c-surface-th: rgba(255, 255, 255, 0.04);
422
+ --c-accent-8: rgba(92, 207, 143, 0.08);
423
+ --c-accent-10: rgba(92, 207, 143, 0.1);
424
+ --c-accent-12: rgba(92, 207, 143, 0.12);
425
+ --c-accent-15: rgba(92, 207, 143, 0.15);
426
+ --c-accent-25: rgba(92, 207, 143, 0.25);
427
+ --c-accent-30: rgba(92, 207, 143, 0.3);
428
+ --c-accent-40: rgba(92, 207, 143, 0.4);
429
+ --c-ok-10: rgba(116, 217, 159, 0.1);
430
+ --c-ok-12: rgba(116, 217, 159, 0.12);
431
+ --c-ok-20: rgba(116, 217, 159, 0.2);
432
+ --c-ok-25: rgba(116, 217, 159, 0.25);
433
+ --c-warn-10: rgba(224, 184, 79, 0.1);
434
+ --c-warn-12: rgba(224, 184, 79, 0.12);
435
+ --c-warn-15: rgba(224, 184, 79, 0.15);
436
+ --c-warn-20: rgba(224, 184, 79, 0.2);
437
+ --c-warn-22: rgba(224, 184, 79, 0.22);
438
+ --c-warn-30: rgba(224, 184, 79, 0.3);
439
+ --c-warn-60: rgba(224, 184, 79, 0.6);
440
+ --c-danger-12: rgba(229, 115, 115, 0.12);
441
+ --c-line-30: rgba(38, 64, 47, 0.3);
442
+ --c-contrast-fg: #0C1712;
443
+ --c-accent-hover: #7BDAA4;
444
+ --c-warn-hover: #E8C871;
445
+ --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
446
+ --c-annotation: #EDF5EF;
447
+ }
448
+
174
449
  * {
175
450
  box-sizing: border-box;
176
451
  margin: 0;
@@ -677,10 +952,141 @@ body,
677
952
  max-width: 100%;
678
953
  }
679
954
 
955
+ /* Positioning parent for a toolbar popover menu. Every menu MUST live inside
956
+ one of these wrapped around its trigger — anchoring to a wider ancestor
957
+ opens the menu far from the button (0.4.2 report Finding T). */
958
+ .toolbar-menu-anchor {
959
+ position: relative;
960
+ display: inline-flex;
961
+ }
962
+
680
963
  .canvas-toolbar button svg {
681
964
  display: block;
682
965
  }
683
966
 
967
+ /* ── Toolbar popover menus (theme picker + narrow-screen More menu) ── */
968
+ .toolbar-menu {
969
+ position: absolute;
970
+ top: calc(100% + 8px);
971
+ right: 0;
972
+ z-index: 1200;
973
+ display: flex;
974
+ flex-direction: column;
975
+ gap: 2px;
976
+ min-width: 224px;
977
+ max-height: min(70vh, 560px);
978
+ overflow-y: auto;
979
+ padding: 6px;
980
+ background: var(--c-panel-glass);
981
+ backdrop-filter: blur(12px);
982
+ border: 1px solid var(--c-line);
983
+ border-radius: var(--radius);
984
+ box-shadow: 0 18px 48px var(--c-shadow-heavy);
985
+ }
986
+
987
+ .toolbar-menu-item {
988
+ display: flex;
989
+ align-items: center;
990
+ gap: 10px;
991
+ width: 100%;
992
+ min-height: 40px;
993
+ padding: 8px 12px;
994
+ background: none;
995
+ border: 1px solid transparent;
996
+ border-radius: var(--radius-sm);
997
+ color: var(--c-text-soft);
998
+ font: inherit;
999
+ font-size: 13px;
1000
+ text-align: left;
1001
+ cursor: pointer;
1002
+ }
1003
+
1004
+ .toolbar-menu-item:hover {
1005
+ background: var(--c-surface-hover);
1006
+ color: var(--c-text);
1007
+ border-color: var(--c-line);
1008
+ }
1009
+
1010
+ .toolbar-menu-item.active {
1011
+ color: var(--c-accent);
1012
+ }
1013
+
1014
+ .toolbar-menu-item svg {
1015
+ flex-shrink: 0;
1016
+ }
1017
+
1018
+ .toolbar-menu-check {
1019
+ margin-left: auto;
1020
+ color: var(--c-accent);
1021
+ }
1022
+
1023
+ .toolbar-menu-separator {
1024
+ height: 1px;
1025
+ margin: 4px 8px;
1026
+ background: var(--c-line);
1027
+ flex-shrink: 0;
1028
+ }
1029
+
1030
+ .toolbar-menu-heading {
1031
+ padding: 6px 12px 2px;
1032
+ font-size: 10px;
1033
+ letter-spacing: 0.08em;
1034
+ text-transform: uppercase;
1035
+ color: var(--c-dim);
1036
+ }
1037
+
1038
+ .theme-swatch {
1039
+ display: inline-flex;
1040
+ align-items: center;
1041
+ justify-content: center;
1042
+ width: 18px;
1043
+ height: 18px;
1044
+ border: 1px solid var(--c-line);
1045
+ border-radius: 50%;
1046
+ flex-shrink: 0;
1047
+ }
1048
+
1049
+ .theme-swatch-dot {
1050
+ width: 7px;
1051
+ height: 7px;
1052
+ border-radius: 50%;
1053
+ }
1054
+
1055
+ .toolbar-more-glyph {
1056
+ display: block;
1057
+ font-size: 16px;
1058
+ font-weight: 700;
1059
+ line-height: 1;
1060
+ }
1061
+
1062
+ /* Wide screens: the full two-bar toolbar; the More trigger stays hidden. */
1063
+ .toolbar-collapsible {
1064
+ display: contents;
1065
+ }
1066
+
1067
+ .toolbar-more {
1068
+ display: none;
1069
+ }
1070
+
1071
+ /* Narrow screens / embedded panels: ONE compact bar (brand, status, view
1072
+ controls) — every secondary control folds into the More menu instead of
1073
+ wrapping into a second toolbar row. */
1074
+ @media (max-width: 900px) {
1075
+ .toolbar-collapsible {
1076
+ display: none;
1077
+ }
1078
+
1079
+ .toolbar-actions-bar {
1080
+ display: none;
1081
+ }
1082
+
1083
+ .toolbar-more {
1084
+ /* inline-flex (NOT contents): this span is also the positioning anchor
1085
+ for the More menu — display:contents cannot anchor absolute children. */
1086
+ display: inline-flex;
1087
+ }
1088
+ }
1089
+
684
1090
  @media (max-width: 900px) {
685
1091
  .toolbar-tooltip {
686
1092
  min-width: 150px;