pmx-canvas 0.1.14 → 0.1.15

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 (50) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +129 -79
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  7. package/dist/types/client/state/canvas-store.d.ts +5 -1
  8. package/dist/types/client/state/intent-bridge.d.ts +3 -1
  9. package/dist/types/client/types.d.ts +2 -2
  10. package/dist/types/json-render/catalog.d.ts +1 -1
  11. package/dist/types/mcp/canvas-access.d.ts +7 -1
  12. package/dist/types/server/agent-context.d.ts +1 -0
  13. package/dist/types/server/canvas-operations.d.ts +4 -2
  14. package/dist/types/server/canvas-provenance.d.ts +1 -1
  15. package/dist/types/server/canvas-serialization.d.ts +3 -0
  16. package/dist/types/server/canvas-state.d.ts +51 -4
  17. package/dist/types/server/demo.d.ts +5 -0
  18. package/dist/types/server/index.d.ts +13 -3
  19. package/dist/types/server/web-artifacts.d.ts +18 -0
  20. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  21. package/package.json +1 -1
  22. package/skills/pmx-canvas/SKILL.md +43 -0
  23. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  24. package/src/cli/agent.ts +52 -5
  25. package/src/cli/index.ts +2 -23
  26. package/src/client/canvas/AttentionHistory.tsx +14 -1
  27. package/src/client/canvas/CanvasNode.tsx +1 -1
  28. package/src/client/canvas/CanvasViewport.tsx +3 -0
  29. package/src/client/canvas/DockedNode.tsx +110 -12
  30. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
  31. package/src/client/canvas/Minimap.tsx +1 -0
  32. package/src/client/icons.tsx +1 -0
  33. package/src/client/nodes/HtmlNode.tsx +151 -0
  34. package/src/client/state/canvas-store.ts +24 -2
  35. package/src/client/state/intent-bridge.ts +4 -3
  36. package/src/client/state/sse-bridge.ts +1 -0
  37. package/src/client/theme/global.css +141 -0
  38. package/src/client/types.ts +3 -0
  39. package/src/mcp/canvas-access.ts +34 -7
  40. package/src/mcp/server.ts +178 -25
  41. package/src/server/agent-context.ts +50 -3
  42. package/src/server/canvas-operations.ts +20 -3
  43. package/src/server/canvas-provenance.ts +2 -1
  44. package/src/server/canvas-serialization.ts +38 -13
  45. package/src/server/canvas-state.ts +305 -34
  46. package/src/server/demo.ts +792 -0
  47. package/src/server/index.ts +33 -3
  48. package/src/server/server.ts +74 -13
  49. package/src/server/web-artifacts.ts +116 -3
  50. package/src/shared/canvas-node-kind.ts +14 -0
@@ -2,6 +2,7 @@ import { ContextNode } from '../nodes/ContextNode';
2
2
  import { LedgerNode } from '../nodes/LedgerNode';
3
3
  import { StatusNode } from '../nodes/StatusNode';
4
4
  import { StatusSummary } from '../nodes/StatusSummary';
5
+ import { attentionHistoryOpen, closeAttentionHistory } from '../state/attention-store';
5
6
  import { toggleCollapsed, undockNode } from '../state/canvas-store';
6
7
  import { TYPE_LABELS } from '../types';
7
8
  import type { CanvasNodeState } from '../types';
@@ -19,20 +20,119 @@ function renderDockedContent(node: CanvasNodeState) {
19
20
  }
20
21
  }
21
22
 
23
+ function getContextItemCount(node: CanvasNodeState): number {
24
+ const cards = Array.isArray(node.data.cards) ? (node.data.cards as unknown[]) : [];
25
+ const auxTabs = Array.isArray(node.data.auxTabs) ? (node.data.auxTabs as unknown[]) : [];
26
+ return cards.length + auxTabs.length;
27
+ }
28
+
29
+ function ContextDockedNode({ node }: { node: CanvasNodeState }) {
30
+ const count = getContextItemCount(node);
31
+ const hasItems = count > 0;
32
+ const collapsed = node.collapsed === true;
33
+
34
+ const expand = () => {
35
+ // Mutual exclusion with the Updates panel — only one side panel open at a
36
+ // time. They share the same right-edge anchor, so opening both at once
37
+ // would visually collide.
38
+ closeAttentionHistory();
39
+ toggleCollapsed(node.id);
40
+ };
41
+
42
+ // Hide the collapsed Context pill while the Updates side panel is open.
43
+ // Mutual exclusion guarantees both panels can't be expanded simultaneously,
44
+ // but the pill itself would otherwise sit beneath/beside the Updates panel
45
+ // at the same right edge — better to hide until Updates is closed.
46
+ if (collapsed && attentionHistoryOpen.value) return null;
47
+
48
+ if (collapsed) {
49
+ return (
50
+ <button
51
+ type="button"
52
+ class="context-dock-tab"
53
+ data-docked-node="true"
54
+ onClick={expand}
55
+ aria-label={hasItems ? `Context — ${count} item${count === 1 ? '' : 's'}` : 'Context'}
56
+ title={hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Agent context'}
57
+ >
58
+ <svg
59
+ width="14"
60
+ height="14"
61
+ viewBox="0 0 16 16"
62
+ fill="none"
63
+ stroke="currentColor"
64
+ stroke-width="1.5"
65
+ stroke-linecap="round"
66
+ stroke-linejoin="round"
67
+ aria-hidden="true"
68
+ >
69
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
70
+ <line x1="1.5" y1="6" x2="14.5" y2="6" />
71
+ <circle cx="4" cy="4.25" r="0.6" fill="currentColor" stroke="none" />
72
+ </svg>
73
+ <span class="context-dock-tab-label">Context</span>
74
+ {hasItems && (
75
+ <span class="context-dock-tab-badge" aria-hidden="true">
76
+ {count > 99 ? '99+' : count}
77
+ </span>
78
+ )}
79
+ </button>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <aside class="context-dock-panel" data-docked-node="true" aria-label="Agent context">
85
+ <div class="context-dock-header">
86
+ <div class="context-dock-header-text">
87
+ <span class="context-dock-title">Context</span>
88
+ <span class="context-dock-subtitle">
89
+ {hasItems ? `${count} item${count === 1 ? '' : 's'} in agent context` : 'Active agent context'}
90
+ </span>
91
+ </div>
92
+ <div class="context-dock-controls">
93
+ <button
94
+ type="button"
95
+ class="context-dock-icon-button"
96
+ onClick={(e) => {
97
+ e.stopPropagation();
98
+ undockNode(node.id);
99
+ }}
100
+ aria-label="Undock to canvas"
101
+ title="Undock to canvas"
102
+ >
103
+ {'\u2299'}
104
+ </button>
105
+ <button
106
+ type="button"
107
+ class="context-dock-icon-button"
108
+ onClick={(e) => {
109
+ e.stopPropagation();
110
+ toggleCollapsed(node.id);
111
+ }}
112
+ aria-label="Collapse context panel"
113
+ title="Collapse"
114
+ >
115
+ ×
116
+ </button>
117
+ </div>
118
+ </div>
119
+ <div class="context-dock-body">
120
+ <ContextNode node={node} />
121
+ </div>
122
+ </aside>
123
+ );
124
+ }
125
+
22
126
  export function DockedNode({ node }: { node: CanvasNodeState }) {
127
+ if (node.type === 'context') {
128
+ return <ContextDockedNode node={node} />;
129
+ }
130
+
23
131
  return (
24
- <div class="docked-node">
132
+ <div class="docked-node" data-docked-node="true">
25
133
  <div class="docked-node-header">
26
134
  <span class="node-type-badge">{TYPE_LABELS[node.type] ?? node.type}</span>
27
135
  {node.type === 'status' && node.collapsed && <StatusSummary node={node} />}
28
- {node.type === 'context' && node.collapsed && (
29
- <span style={{ fontSize: '11px', color: 'var(--c-muted)' }}>
30
- Active Agent Context
31
- {typeof node.data.utilization === 'number' && (
32
- <> · {Math.round(Number(node.data.utilization) * 100)}%</>
33
- )}
34
- </span>
35
- )}
36
136
  <div class="docked-node-controls">
37
137
  <button
38
138
  type="button"
@@ -57,9 +157,7 @@ export function DockedNode({ node }: { node: CanvasNodeState }) {
57
157
  </div>
58
158
  </div>
59
159
  {!node.collapsed && (
60
- <div class={`docked-node-body${node.type === 'context' ? ' context-body' : ''}`}>
61
- {renderDockedContent(node)}
62
- </div>
160
+ <div class="docked-node-body">{renderDockedContent(node)}</div>
63
161
  )}
64
162
  </div>
65
163
  );
@@ -7,6 +7,7 @@ import { McpAppNode } from '../nodes/McpAppNode';
7
7
  import { StatusNode } from '../nodes/StatusNode';
8
8
  import { ImageNode } from '../nodes/ImageNode';
9
9
  import { WebpageNode } from '../nodes/WebpageNode';
10
+ import { HtmlNode } from '../nodes/HtmlNode';
10
11
  import { PromptNode } from '../nodes/PromptNode';
11
12
  import { ResponseNode } from '../nodes/ResponseNode';
12
13
  import { TraceNode } from '../nodes/TraceNode';
@@ -49,6 +50,8 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
49
50
  return <FileNode node={node} expanded={expanded} />;
50
51
  case 'image':
51
52
  return <ImageNode node={node} expanded={expanded} />;
53
+ case 'html':
54
+ return <HtmlNode node={node} expanded={expanded} />;
52
55
  default:
53
56
  return <div>Unknown node type</div>;
54
57
  }
@@ -63,6 +66,8 @@ function getNodeTextContent(node: CanvasNodeState): string {
63
66
  return (node.data.fileContent as string) || '';
64
67
  case 'webpage':
65
68
  return (node.data.content as string) || '';
69
+ case 'html':
70
+ return (node.data.html as string) || (node.data.content as string) || '';
66
71
  case 'json-render':
67
72
  case 'graph':
68
73
  return JSON.stringify(node.data.spec ?? node.data.graphConfig ?? {}, null, 2);
@@ -37,6 +37,7 @@ function getNodeColors(): Record<CanvasNodeState['type'], string> {
37
37
  trace: t.purple,
38
38
  file: t.accent,
39
39
  image: t.ok,
40
+ html: t.warn,
40
41
  group: t.dim,
41
42
  };
42
43
  }
@@ -419,6 +419,7 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
419
419
  case 'ext-app': return IconNodeExtApp;
420
420
  case 'json-render': return IconNodeJsonRender;
421
421
  case 'graph': return IconNodeGraph;
422
+ case 'html': return IconNodeWebpage;
422
423
  default: return IconNodeMarkdown;
423
424
  }
424
425
  }
@@ -0,0 +1,151 @@
1
+ import { useMemo } from 'preact/hooks';
2
+ import { getCanvasTokens } from '../theme/tokens';
3
+ import type { CanvasNodeState } from '../types';
4
+
5
+ /**
6
+ * Strip characters that could break out of a CSS custom-property value context
7
+ * before interpolating into a `<style>` block. The expected token shape is a
8
+ * CSS color (`#abc`, `rgb(...)`) or font-family list, neither of which needs
9
+ * `<`, `>`, `{`, `}`, `;`, or backticks. Defense-in-depth against a future
10
+ * scenario where theme tokens become runtime-editable.
11
+ */
12
+ function sanitizeCssTokenValue(value: string): string {
13
+ return value.replace(/[<>{};`\\]/g, '').trim();
14
+ }
15
+
16
+ /**
17
+ * Build a `<style>` block that exposes canvas theme tokens to the iframe under
18
+ * both the canonical `--c-*` names and common `--color-*` aliases. Also sets
19
+ * sensible body defaults (font, bg, color) so authored HTML inherits the look.
20
+ */
21
+ function buildThemeStyleBlock(): string {
22
+ const raw = getCanvasTokens();
23
+ const t = {
24
+ bg: sanitizeCssTokenValue(raw.bg),
25
+ panel: sanitizeCssTokenValue(raw.panel),
26
+ panelSoft: sanitizeCssTokenValue(raw.panelSoft),
27
+ line: sanitizeCssTokenValue(raw.line),
28
+ text: sanitizeCssTokenValue(raw.text),
29
+ textSoft: sanitizeCssTokenValue(raw.textSoft),
30
+ muted: sanitizeCssTokenValue(raw.muted),
31
+ dim: sanitizeCssTokenValue(raw.dim),
32
+ accent: sanitizeCssTokenValue(raw.accent),
33
+ ok: sanitizeCssTokenValue(raw.ok),
34
+ warn: sanitizeCssTokenValue(raw.warn),
35
+ warnAlt: sanitizeCssTokenValue(raw.warnAlt),
36
+ danger: sanitizeCssTokenValue(raw.danger),
37
+ purple: sanitizeCssTokenValue(raw.purple),
38
+ font: sanitizeCssTokenValue(raw.font),
39
+ mono: sanitizeCssTokenValue(raw.mono),
40
+ };
41
+ return `
42
+ :root {
43
+ --c-bg: ${t.bg};
44
+ --c-panel: ${t.panel};
45
+ --c-panel-soft: ${t.panelSoft};
46
+ --c-line: ${t.line};
47
+ --c-text: ${t.text};
48
+ --c-text-soft: ${t.textSoft};
49
+ --c-muted: ${t.muted};
50
+ --c-dim: ${t.dim};
51
+ --c-accent: ${t.accent};
52
+ --c-ok: ${t.ok};
53
+ --c-warn: ${t.warn};
54
+ --c-warn-alt: ${t.warnAlt};
55
+ --c-danger: ${t.danger};
56
+ --c-purple: ${t.purple};
57
+
58
+ /* Common aliases authored HTML might use. */
59
+ --color-bg: ${t.bg};
60
+ --color-panel: ${t.panel};
61
+ --color-surface: ${t.panelSoft};
62
+ --color-border: ${t.line};
63
+ --color-text: ${t.text};
64
+ --color-text-primary: ${t.text};
65
+ --color-text-secondary: ${t.textSoft};
66
+ --color-text-muted: ${t.muted};
67
+ --color-text-dim: ${t.dim};
68
+ --color-accent: ${t.accent};
69
+ --color-success: ${t.ok};
70
+ --color-warning: ${t.warn};
71
+ --color-danger: ${t.danger};
72
+
73
+ --font: ${t.font};
74
+ --font-sans: ${t.font};
75
+ --font-mono: ${t.mono};
76
+
77
+ color-scheme: dark light;
78
+ }
79
+ html, body {
80
+ margin: 0;
81
+ padding: 0;
82
+ background: ${t.bg};
83
+ color: ${t.text};
84
+ font-family: ${t.font || 'system-ui, sans-serif'};
85
+ font-size: 14px;
86
+ line-height: 1.5;
87
+ }
88
+ body { padding: 16px; box-sizing: border-box; }
89
+ a { color: ${t.accent}; }
90
+ `;
91
+ }
92
+
93
+ /**
94
+ * Inject the theme style block into the user-supplied HTML. If the document has
95
+ * a `<head>`, inject at the top of head; otherwise wrap the content in a full
96
+ * document. Returns a complete HTML string suitable for `srcdoc`.
97
+ */
98
+ function buildSrcDoc(userHtml: string): string {
99
+ const styleBlock = `<style data-pmx-canvas-theme>${buildThemeStyleBlock()}</style>`;
100
+ const trimmed = userHtml.trim();
101
+ const isFullDoc = /<html[\s>]/i.test(trimmed);
102
+ if (isFullDoc) {
103
+ if (/<head[\s>]/i.test(trimmed)) {
104
+ return trimmed.replace(/<head([^>]*)>/i, `<head$1>${styleBlock}`);
105
+ }
106
+ // Has <html> but no <head> — inject one.
107
+ return trimmed.replace(/<html([^>]*)>/i, `<html$1><head>${styleBlock}</head>`);
108
+ }
109
+ // Fragment — wrap in full document.
110
+ return `<!doctype html><html><head><meta charset="utf-8">${styleBlock}</head><body>${userHtml}</body></html>`;
111
+ }
112
+
113
+ export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
114
+ const html = typeof node.data.html === 'string'
115
+ ? node.data.html
116
+ : typeof node.data.content === 'string'
117
+ ? node.data.content
118
+ : '';
119
+ const srcDoc = useMemo(() => (html ? buildSrcDoc(html) : ''), [html]);
120
+
121
+ if (!html) {
122
+ return (
123
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
124
+ No HTML content set
125
+ </div>
126
+ );
127
+ }
128
+
129
+ // SECURITY: sandbox is intentionally `allow-scripts` ONLY. Do NOT add
130
+ // `allow-same-origin` (would grant the iframe access to parent localStorage
131
+ // and credentialed requests to the canvas origin), `allow-top-navigation`
132
+ // (would let scripts redirect the parent window), or `allow-forms` (would
133
+ // let the iframe POST back to the host). The whole html-node tier assumes
134
+ // arbitrary author code runs inside this exact sandbox.
135
+ return (
136
+ <iframe
137
+ title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
138
+ sandbox="allow-scripts"
139
+ srcdoc={srcDoc}
140
+ style={{
141
+ width: '100%',
142
+ height: '100%',
143
+ minHeight: expanded ? '70vh' : '300px',
144
+ border: 'none',
145
+ background: 'var(--c-bg)',
146
+ borderRadius: '6px',
147
+ display: 'block',
148
+ }}
149
+ />
150
+ );
151
+ }
@@ -277,6 +277,28 @@ export function toggleCollapsed(id: string): void {
277
277
  updateNode(id, { collapsed: !existing.collapsed });
278
278
  }
279
279
 
280
+ // Collapse every docked context node. Used to enforce mutual exclusion between
281
+ // the Context side panel and the Updates side panel (they share the same
282
+ // right-edge anchor and would otherwise visually collide).
283
+ export function collapseDockedContextNodes(): void {
284
+ for (const node of nodes.value.values()) {
285
+ if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
286
+ updateNode(node.id, { collapsed: true });
287
+ }
288
+ }
289
+ }
290
+
291
+ // True iff at least one docked context node is currently expanded. Used by the
292
+ // Updates pill to hide itself while the Context panel is open.
293
+ export const hasOpenDockedContextPanel = computed(() => {
294
+ for (const node of nodes.value.values()) {
295
+ if (node.type === 'context' && node.dockPosition === 'right' && !node.collapsed) {
296
+ return true;
297
+ }
298
+ }
299
+ return false;
300
+ });
301
+
280
302
  export function dockNode(id: string, position: 'left' | 'right'): void {
281
303
  const existing = nodes.value.get(id);
282
304
  if (!existing) return;
@@ -411,7 +433,7 @@ export function cancelViewportAnimation(): void {
411
433
  // ── Persistence ───────────────────────────────────────────────
412
434
  const STORAGE_KEY = 'pmx-canvas-layout';
413
435
 
414
- export function persistLayout(): void {
436
+ export function persistLayout(options: { recordHistory?: boolean } = {}): void {
415
437
  try {
416
438
  const allNodes = Array.from(nodes.value.values());
417
439
  const nodeUpdates = allNodes.map((n) => ({
@@ -444,7 +466,7 @@ export function persistLayout(): void {
444
466
  contextPinnedNodeIds: Array.from(contextPinnedNodeIds.value),
445
467
  };
446
468
  localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
447
- void pushCanvasUpdate(nodeUpdates);
469
+ void pushCanvasUpdate(nodeUpdates, options);
448
470
  } catch (error) {
449
471
  logCanvasStoreError('persistLayout', error);
450
472
  }
@@ -101,7 +101,7 @@ export async function openWorkbenchFile(path: string): Promise<{ ok: boolean }>
101
101
 
102
102
  /** Fetch canvas state from server. */
103
103
  export async function fetchCanvasState(): Promise<Record<string, unknown>> {
104
- return requestJson('fetchCanvasState', '/api/canvas/state', {});
104
+ return requestJson('fetchCanvasState', '/api/canvas/state?includeBlobs=true', {});
105
105
  }
106
106
 
107
107
  /** Fetch available slash commands for prompt completion. */
@@ -148,11 +148,12 @@ export async function pushCanvasUpdate(
148
148
  collapsed?: boolean;
149
149
  dockPosition?: 'left' | 'right' | null;
150
150
  }>,
151
+ options: { recordHistory?: boolean } = {},
151
152
  ): Promise<void> {
152
153
  await requestBestEffort('pushCanvasUpdate', '/api/canvas/update', {
153
154
  method: 'POST',
154
155
  headers: { 'Content-Type': 'application/json' },
155
- body: JSON.stringify({ updates }),
156
+ body: JSON.stringify({ updates, ...(options.recordHistory === false ? { recordHistory: false } : {}) }),
156
157
  });
157
158
  }
158
159
 
@@ -286,7 +287,7 @@ export interface CanvasSnapshotInfo {
286
287
  }
287
288
 
288
289
  export async function listSnapshots(): Promise<CanvasSnapshotInfo[]> {
289
- return requestJson<CanvasSnapshotInfo[]>('listSnapshots', '/api/canvas/snapshots', []);
290
+ return requestJson<CanvasSnapshotInfo[]>('listSnapshots', '/api/canvas/snapshots?all=true', []);
290
291
  }
291
292
 
292
293
  export async function saveSnapshot(name: string): Promise<{ ok: boolean; snapshot?: CanvasSnapshotInfo }> {
@@ -83,6 +83,7 @@ const DEFAULT_POSITIONS: Record<
83
83
  trace: { x: 40, y: 900, w: 200, h: 56 },
84
84
  file: { x: 380, y: 80, w: 720, h: 600 },
85
85
  image: { x: 380, y: 80, w: 720, h: 520 },
86
+ html: { x: 380, y: 80, w: 720, h: 640 },
86
87
  group: { x: 220, y: 60, w: 840, h: 560 },
87
88
  prompt: { x: 380, y: 1260, w: 520, h: 400 },
88
89
  response: { x: 380, y: 1480, w: 720, h: 400 },
@@ -1844,6 +1844,147 @@ body,
1844
1844
  max-width: 200px;
1845
1845
  }
1846
1846
 
1847
+ /* Context dock — collapsed pill mirrors Updates pill, sits above it */
1848
+ .context-dock-tab {
1849
+ position: fixed;
1850
+ top: 92px;
1851
+ right: 0;
1852
+ display: flex;
1853
+ align-items: center;
1854
+ gap: 8px;
1855
+ padding: 8px 12px 8px 14px;
1856
+ background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
1857
+ backdrop-filter: blur(16px);
1858
+ border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
1859
+ border-right: 0;
1860
+ border-radius: 14px 0 0 14px;
1861
+ box-shadow: 0 12px 36px var(--c-shadow);
1862
+ color: var(--c-text);
1863
+ cursor: pointer;
1864
+ font: inherit;
1865
+ font-size: 11px;
1866
+ font-weight: 600;
1867
+ letter-spacing: 0.08em;
1868
+ text-transform: uppercase;
1869
+ z-index: 60;
1870
+ }
1871
+
1872
+ .context-dock-tab:hover {
1873
+ border-color: color-mix(in srgb, var(--c-accent) 40%, var(--c-line) 60%);
1874
+ color: var(--c-accent);
1875
+ }
1876
+
1877
+ .context-dock-tab svg {
1878
+ display: block;
1879
+ color: var(--c-accent);
1880
+ flex-shrink: 0;
1881
+ }
1882
+
1883
+ .context-dock-tab-label {
1884
+ white-space: nowrap;
1885
+ }
1886
+
1887
+ .context-dock-tab-badge {
1888
+ min-width: 18px;
1889
+ height: 18px;
1890
+ padding: 0 5px;
1891
+ display: inline-flex;
1892
+ align-items: center;
1893
+ justify-content: center;
1894
+ border-radius: 9px;
1895
+ background: var(--c-accent);
1896
+ color: var(--c-contrast-fg);
1897
+ font-size: 10px;
1898
+ font-weight: 700;
1899
+ letter-spacing: 0;
1900
+ text-transform: none;
1901
+ }
1902
+
1903
+ /* Context dock — expanded panel anchored top-right edge.
1904
+ Mutually exclusive with the Updates panel (see DockedNode.tsx and
1905
+ AttentionHistory.tsx) — opening one collapses the other, so they can both
1906
+ share the same right: 18px anchor without overlapping. */
1907
+ .context-dock-panel {
1908
+ position: fixed;
1909
+ top: 92px;
1910
+ right: 18px;
1911
+ width: min(360px, calc(100vw - 24px));
1912
+ max-height: calc(100vh - 110px);
1913
+ display: flex;
1914
+ flex-direction: column;
1915
+ gap: 10px;
1916
+ padding: 14px;
1917
+ background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
1918
+ backdrop-filter: blur(16px);
1919
+ border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
1920
+ border-radius: 18px;
1921
+ box-shadow: 0 14px 36px var(--c-shadow), 0 0 0 1px color-mix(in srgb, var(--c-accent) 8%, transparent);
1922
+ z-index: 10001;
1923
+ overflow: hidden;
1924
+ }
1925
+
1926
+ .context-dock-header {
1927
+ display: flex;
1928
+ align-items: flex-start;
1929
+ justify-content: space-between;
1930
+ gap: 8px;
1931
+ padding: 2px 2px 4px;
1932
+ flex-shrink: 0;
1933
+ }
1934
+
1935
+ .context-dock-header-text {
1936
+ display: flex;
1937
+ flex-direction: column;
1938
+ gap: 2px;
1939
+ min-width: 0;
1940
+ }
1941
+
1942
+ .context-dock-title {
1943
+ font-size: 12px;
1944
+ font-weight: 700;
1945
+ letter-spacing: 0.08em;
1946
+ text-transform: uppercase;
1947
+ color: var(--c-text);
1948
+ }
1949
+
1950
+ .context-dock-subtitle {
1951
+ font-size: 11px;
1952
+ color: var(--c-dim);
1953
+ }
1954
+
1955
+ .context-dock-controls {
1956
+ display: flex;
1957
+ gap: 4px;
1958
+ flex-shrink: 0;
1959
+ }
1960
+
1961
+ .context-dock-icon-button {
1962
+ width: 22px;
1963
+ height: 22px;
1964
+ border: 0;
1965
+ border-radius: 6px;
1966
+ background: transparent;
1967
+ color: var(--c-dim);
1968
+ font-size: 16px;
1969
+ line-height: 1;
1970
+ cursor: pointer;
1971
+ display: grid;
1972
+ place-items: center;
1973
+ padding: 0;
1974
+ }
1975
+
1976
+ .context-dock-icon-button:hover {
1977
+ color: var(--c-text);
1978
+ background: var(--c-surface-hover);
1979
+ }
1980
+
1981
+ .context-dock-body {
1982
+ flex: 1 1 auto;
1983
+ min-height: 0;
1984
+ overflow-y: auto;
1985
+ padding-right: 2px;
1986
+ }
1987
+
1847
1988
  .attention-history {
1848
1989
  position: fixed;
1849
1990
  top: 146px;
@@ -20,6 +20,7 @@ export interface CanvasNodeState {
20
20
  | 'trace'
21
21
  | 'file'
22
22
  | 'image'
23
+ | 'html'
23
24
  | 'group';
24
25
  position: { x: number; y: number };
25
26
  size: { width: number; height: number };
@@ -58,6 +59,7 @@ export const TYPE_LABELS: Record<CanvasNodeState['type'], string> = {
58
59
  trace: 'TRACE',
59
60
  file: 'FILE',
60
61
  image: 'IMG',
62
+ html: 'HTML',
61
63
  group: 'GROUP',
62
64
  };
63
65
 
@@ -72,6 +74,7 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
72
74
  'ledger',
73
75
  'file',
74
76
  'image',
77
+ 'html',
75
78
  ]);
76
79
 
77
80
  export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';