pmx-canvas 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +79 -61
  2. package/CHANGELOG.md +16 -1
  3. package/Readme.md +31 -6
  4. package/dist/canvas/global.css +21 -5
  5. package/dist/canvas/index.js +73 -73
  6. package/dist/types/client/canvas/ExternalWriters.d.ts +3 -3
  7. package/dist/types/client/icons.d.ts +15 -0
  8. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -1
  9. package/dist/types/client/nodes/FileNode.d.ts +0 -12
  10. package/dist/types/shared/delimited-text.d.ts +14 -0
  11. package/docs/cli.md +9 -2
  12. package/docs/http-api.md +12 -8
  13. package/docs/mcp.md +8 -1
  14. package/docs/sdk.md +2 -2
  15. package/package.json +1 -1
  16. package/skills/pmx-canvas/SKILL.md +19 -6
  17. package/skills/pmx-canvas/evals/evals.json +43 -2
  18. package/skills/pmx-canvas/references/full-reference.md +6 -1
  19. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +17 -3
  20. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  21. package/src/cli/commands/backup.ts +15 -4
  22. package/src/cli/commands/boards.ts +16 -3
  23. package/src/client/canvas/AttentionHistory.tsx +2 -1
  24. package/src/client/canvas/BoardSwitcher.tsx +3 -3
  25. package/src/client/canvas/CanvasNode.tsx +17 -8
  26. package/src/client/canvas/CommandBar.tsx +2 -2
  27. package/src/client/canvas/ContextPinBar.tsx +3 -1
  28. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -3
  29. package/src/client/canvas/ExternalWriters.tsx +4 -3
  30. package/src/client/canvas/FeedbackDialog.tsx +2 -1
  31. package/src/client/canvas/IntentLayer.tsx +2 -2
  32. package/src/client/canvas/SelectionBar.tsx +2 -2
  33. package/src/client/canvas/SessionPanel.tsx +6 -4
  34. package/src/client/canvas/SessionReceipt.tsx +2 -1
  35. package/src/client/canvas/SnapshotPanel.tsx +3 -2
  36. package/src/client/canvas/ToolRail.tsx +2 -1
  37. package/src/client/canvas/TopBar.tsx +2 -2
  38. package/src/client/icons.tsx +118 -1
  39. package/src/client/nodes/BoardNode.tsx +2 -1
  40. package/src/client/nodes/ExtAppFrame.tsx +3 -1
  41. package/src/client/nodes/FileNode.tsx +4 -78
  42. package/src/client/nodes/ImageNode.tsx +2 -1
  43. package/src/client/nodes/MarkdownNode.tsx +2 -1
  44. package/src/client/nodes/PromptNode.tsx +3 -1
  45. package/src/client/theme/global.css +21 -5
  46. package/src/mcp/server.ts +2 -2
  47. package/src/server/agent-presence.ts +17 -11
  48. package/src/server/board-export.ts +97 -32
  49. package/src/server/index.ts +9 -7
  50. package/src/server/operations/composites.ts +3 -2
  51. package/src/server/operations/ops/ax-presence.ts +7 -3
  52. package/src/server/operations/ops/boards.ts +12 -5
  53. package/src/server/operations/registry.ts +4 -13
  54. package/src/server/server.ts +10 -3
  55. package/src/shared/delimited-text.ts +75 -0
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'preact/hooks';
2
- import { IconSteer } from '../icons';
2
+ import { IconSteer, IconClose } from '../icons';
3
3
  import { contextPinnedNodeIds, nodes, toggleContextPin } from '../state/canvas-store';
4
4
  import { steerableAgents } from '../state/presence-store';
5
5
  import { sendSteering } from '../state/session-store';
@@ -72,7 +72,7 @@ export function CommandBar() {
72
72
  title="Remove from context"
73
73
  onClick={() => toggleContextPin(id)}
74
74
  >
75
- ×
75
+ <IconClose size={14} />
76
76
  </button>
77
77
  </span>
78
78
  );
@@ -1,5 +1,6 @@
1
1
  import { clearContextPins, contextPinnedNodeIds } from '../state/canvas-store';
2
2
  import { attentionHistoryOpen } from '../state/attention-store';
3
+ import { IconClose } from '../icons';
3
4
 
4
5
  export function ContextPinBar() {
5
6
  const count = contextPinnedNodeIds.value.size;
@@ -15,8 +16,9 @@ export function ContextPinBar() {
15
16
  class="context-pin-bar-btn context-pin-bar-clear"
16
17
  onClick={clearContextPins}
17
18
  title="Clear all context pins"
19
+ aria-label="Clear all context pins"
18
20
  >
19
- {'\u00d7'}
21
+ <IconClose size={14} />
20
22
  </button>
21
23
  </div>
22
24
  );
@@ -25,7 +25,7 @@ import {
25
25
  pendingExpandedNodeCloseId,
26
26
  toggleContextPin,
27
27
  } from '../state/canvas-store';
28
- import { getNodeIcon } from '../icons';
28
+ import { getNodeIcon, IconClose, IconExternalLink } from '../icons';
29
29
  import { useFocusTrap } from './use-focus-trap';
30
30
  import { TYPE_LABELS } from '../types';
31
31
  import type { CanvasNodeState } from '../types';
@@ -316,7 +316,7 @@ export function ExpandedNodeOverlay() {
316
316
  onClick={() => void openNodeAsSite(node)}
317
317
  title="Open as a full-page site in the system browser"
318
318
  >
319
- Open in tab ↗
319
+ Open in tab <IconExternalLink size={14} />
320
320
  </button>
321
321
  )}
322
322
  {canPresent && (
@@ -331,7 +331,7 @@ export function ExpandedNodeOverlay() {
331
331
  )}
332
332
  </div>
333
333
  <button type="button" class="expanded-close" onClick={handleClose} title="Close (Esc)" aria-label="Close">
334
- ×
334
+ <IconClose />
335
335
  </button>
336
336
  </div>
337
337
 
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef } from 'preact/hooks';
2
+ import { IconClose, IconExternalLink } from '../icons';
2
3
  import type { AgentPresence } from '../../shared/agent-presence.js';
3
4
  import { vetoGhostIntent } from '../state/intent-bridge';
4
5
  import { type ClientIntent, intents, removeIntent } from '../state/intent-store';
@@ -149,7 +150,7 @@ export function ActivityFeed() {
149
150
  activityFeedOpen.value = false;
150
151
  }}
151
152
  >
152
- ×
153
+ <IconClose />
153
154
  </button>
154
155
  </div>
155
156
  <div class="activity-filters">
@@ -226,7 +227,7 @@ export function ActivityFeed() {
226
227
  void startSession();
227
228
  }}
228
229
  >
229
- Start session ↗
230
+ Start session <IconExternalLink size={14} />
230
231
  </button>
231
232
  </div>
232
233
  </div>
@@ -267,7 +268,7 @@ export function WritersSheet() {
267
268
  <span class="writers-title">Connected writers</span>
268
269
  <span class="writers-board">{sessionId.value.slice(0, 12)}</span>
269
270
  <button type="button" class="activity-feed-close" aria-label="Close writers" onClick={close}>
270
- ×
271
+ <IconClose />
271
272
  </button>
272
273
  </div>
273
274
  <div class="writers-body">
@@ -1,5 +1,6 @@
1
1
  import { useRef, useState } from 'preact/hooks';
2
2
  import { useFocusTrap } from './use-focus-trap';
3
+ import { IconExternalLink } from '../icons';
3
4
 
4
5
  /** Draft only: GitHub owns authentication and the final public submission. */
5
6
  export function FeedbackDialog({ onClose }: { onClose: () => void }) {
@@ -77,7 +78,7 @@ export function FeedbackDialog({ onClose }: { onClose: () => void }) {
77
78
  Cancel
78
79
  </button>
79
80
  <button type="submit" class="text-prompt-confirm" disabled={!title.trim() || !description.trim()}>
80
- Continue on GitHub ↗
81
+ Continue on GitHub <IconExternalLink size={14} />
81
82
  </button>
82
83
  </div>
83
84
  </form>
@@ -2,7 +2,7 @@ import { useEffect } from 'preact/hooks';
2
2
  import { nodes } from '../state/canvas-store';
3
3
  import { hoveredIntentId, intents, removeIntent, type ClientIntent } from '../state/intent-store';
4
4
  import { vetoGhostIntent } from '../state/intent-bridge';
5
- import { getNodeIcon } from '../icons';
5
+ import { getNodeIcon, IconClose } from '../icons';
6
6
  import { TYPE_LABELS } from '../types';
7
7
  import type { CanvasNodeState } from '../types';
8
8
 
@@ -102,7 +102,7 @@ function GhostInfo({ intent }: { intent: ClientIntent }) {
102
102
  void vetoGhostIntent(intent);
103
103
  }}
104
104
  >
105
- ✕
105
+ <IconClose size={14} />
106
106
  </button>
107
107
  )}
108
108
  </div>
@@ -1,5 +1,5 @@
1
1
  import { useCallback } from 'preact/hooks';
2
- import { IconArrange } from '../icons';
2
+ import { IconArrange, IconClose } from '../icons';
3
3
  import { attentionHistoryOpen } from '../state/attention-store';
4
4
  import {
5
5
  addContextPins,
@@ -204,7 +204,7 @@ export function SelectionBar() {
204
204
  </BarHint>
205
205
  <BarHint label="Clear selection" shortcut="Esc" side="up">
206
206
  <button type="button" class="selection-bar-icon" onClick={clearSelection} aria-label="Clear selection">
207
- ×
207
+ <IconClose size={14} />
208
208
  </button>
209
209
  </BarHint>
210
210
  </div>
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from 'preact/hooks';
2
+ import { IconChevronRight, IconUndo } from '../icons';
2
3
  import { useNow } from './use-now';
3
4
  import { agentIdentityHue, agentPhaseLabel } from '../../shared/agent-presence.js';
4
5
  import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js';
@@ -301,7 +302,7 @@ function TimelineRow({ entry }: { entry: TimelineEntry }) {
301
302
  data-testid="timeline-undo"
302
303
  onClick={() => void undoAgentEdit(entry)}
303
304
  >
304
- ↩ undo this edit
305
+ <IconUndo size={14} /> undo this edit
305
306
  </button>
306
307
  )}
307
308
  {undone && <div class="session-timeline-undone">undone · steering sent</div>}
@@ -387,8 +388,9 @@ export function SessionPanel() {
387
388
  class="session-collapse"
388
389
  onClick={() => setManuallyOpened(false)}
389
390
  title="Collapse session panel"
391
+ aria-label="Collapse session panel"
390
392
  >
391
- ›
393
+ <IconChevronRight />
392
394
  </button>
393
395
  </header>
394
396
 
@@ -406,7 +408,7 @@ export function SessionPanel() {
406
408
  Agent edit on top: {undoable.body}
407
409
  </span>
408
410
  <button type="button" class="session-undo-btn" onClick={() => void undoAgentEdit(undoable)}>
409
- ↩ Undo
411
+ <IconUndo size={14} /> Undo
410
412
  </button>
411
413
  </div>
412
414
  );
@@ -455,7 +457,7 @@ export function SessionPanel() {
455
457
  {summary}
456
458
  </span>
457
459
  <span class={`session-section-chevron${showList ? ' is-open' : ''}`} aria-hidden="true">
458
- ›
460
+ <IconChevronRight size={14} />
459
461
  </span>
460
462
  </button>
461
463
  {showList &&
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'preact/hooks';
2
2
  import { dismissSessionReceipt, sessionReceipt } from '../state/session-store';
3
+ import { IconClose } from '../icons';
3
4
 
4
5
  /**
5
6
  * Session receipt (rail-chrome-v2 phase 5, design item 2): a dismissible card
@@ -74,7 +75,7 @@ export function SessionReceipt({ onOpenSnapshots }: { onOpenSnapshots: () => voi
74
75
  onClick={dismissSessionReceipt}
75
76
  aria-label="Dismiss receipt"
76
77
  >
77
- ×
78
+ <IconClose />
78
79
  </button>
79
80
  </div>
80
81
  <div class="session-receipt-tiles">
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconClose } from '../icons';
2
3
  import {
3
4
  type CanvasSnapshotInfo,
4
5
  deleteSnapshot,
@@ -183,7 +184,7 @@ export function SnapshotPanel({
183
184
  aria-label="Delete snapshot"
184
185
  disabled={restoringId !== null}
185
186
  >
186
- ✕
187
+ <IconClose size={14} />
187
188
  </button>
188
189
  </>
189
190
  );
@@ -195,7 +196,7 @@ export function SnapshotPanel({
195
196
  <span class="snapshot-panel-sub">snapshots + sessions</span>
196
197
  <span class="snapshot-panel-spacer" />
197
198
  <button type="button" class="snapshot-panel-close" onClick={onClose} title="Close" aria-label="Close history">
198
- ×
199
+ <IconClose />
199
200
  </button>
200
201
  </div>
201
202
 
@@ -21,6 +21,7 @@ import {
21
21
  IconSun,
22
22
  IconTextAnnotation,
23
23
  IconTrace,
24
+ IconUpload,
24
25
  } from '../icons';
25
26
  import {
26
27
  autoArrange,
@@ -337,7 +338,7 @@ export function ToolRail({
337
338
  <IconNodeFile size={15} />
338
339
  </RailButton>
339
340
  <RailButton label="Attach document" onClick={() => documentInput.current?.click()}>
340
- <span aria-hidden="true">↥</span>
341
+ <IconUpload size={15} />
341
342
  </RailButton>
342
343
  <input
343
344
  ref={documentInput}
@@ -4,7 +4,7 @@ import { BoardSwitcher } from './BoardSwitcher';
4
4
  import type { ComponentChildren } from 'preact';
5
5
  import { presenting } from '../state/presentation';
6
6
  import { useEffect, useState } from 'preact/hooks';
7
- import { IconFitAll, IconZoomIn, IconZoomOut } from '../icons';
7
+ import { IconFitAll, IconZoomIn, IconZoomOut, IconClose } from '../icons';
8
8
  import {
9
9
  animateViewport,
10
10
  connectionStatus,
@@ -121,7 +121,7 @@ function AgentChip() {
121
121
  void endSession({ source: session.source, agentId: session.agentId });
122
122
  }}
123
123
  >
124
- ×
124
+ <IconClose size={14} />
125
125
  </button>
126
126
  </span>
127
127
  </BarHint>
@@ -11,16 +11,133 @@ const defaults = {
11
11
  'stroke-width': '1.5',
12
12
  'stroke-linecap': 'round',
13
13
  'stroke-linejoin': 'round',
14
+ 'aria-hidden': 'true',
14
15
  } as const;
15
16
 
16
17
  function Icon({ size = 16, children, ...rest }: IconProps & { children: JSX.Element | JSX.Element[] }): JSX.Element {
17
18
  return (
18
- <svg width={size} height={size} viewBox="0 0 16 16" {...defaults} {...rest}>
19
+ <svg
20
+ width={size}
21
+ height={size}
22
+ viewBox="0 0 16 16"
23
+ style={{ verticalAlign: 'middle', flexShrink: 0 }}
24
+ {...defaults}
25
+ {...rest}
26
+ >
19
27
  {children}
20
28
  </svg>
21
29
  );
22
30
  }
23
31
 
32
+ export function IconClose(p: IconProps): JSX.Element {
33
+ return (
34
+ <Icon {...p}>
35
+ <path d="m4 4 8 8M12 4l-8 8" />
36
+ </Icon>
37
+ );
38
+ }
39
+
40
+ export function IconPlus(p: IconProps): JSX.Element {
41
+ return (
42
+ <Icon {...p}>
43
+ <path d="M8 2v12M2 8h12" />
44
+ </Icon>
45
+ );
46
+ }
47
+
48
+ export function IconPlusCircle(p: IconProps): JSX.Element {
49
+ return (
50
+ <Icon {...p}>
51
+ <circle cx="8" cy="8" r="6" />
52
+ <path d="M8 5v6M5 8h6" />
53
+ </Icon>
54
+ );
55
+ }
56
+
57
+ export function IconChevronRight(p: IconProps): JSX.Element {
58
+ return (
59
+ <Icon {...p}>
60
+ <polyline points="6 3 11 8 6 13" />
61
+ </Icon>
62
+ );
63
+ }
64
+
65
+ export function IconMore(p: IconProps): JSX.Element {
66
+ return (
67
+ <Icon {...p}>
68
+ <circle cx="3" cy="8" r="0.75" />
69
+ <circle cx="8" cy="8" r="0.75" />
70
+ <circle cx="13" cy="8" r="0.75" />
71
+ </Icon>
72
+ );
73
+ }
74
+
75
+ export function IconExternalLink(p: IconProps): JSX.Element {
76
+ return (
77
+ <Icon {...p}>
78
+ <path d="M9 2h5v5M14 2 7 9M6 3H2v11h11v-4" />
79
+ </Icon>
80
+ );
81
+ }
82
+
83
+ export function IconExpand(p: IconProps): JSX.Element {
84
+ return (
85
+ <Icon {...p}>
86
+ <path d="M9 2h5v5M14 2l-5 5M7 14H2V9M2 14l5-5" />
87
+ </Icon>
88
+ );
89
+ }
90
+
91
+ export function IconArrowLeft(p: IconProps): JSX.Element {
92
+ return (
93
+ <Icon {...p}>
94
+ <path d="M14 8H2m5-5-5 5 5 5" />
95
+ </Icon>
96
+ );
97
+ }
98
+
99
+ export function IconRefresh(p: IconProps): JSX.Element {
100
+ return (
101
+ <Icon {...p}>
102
+ <path d="M13.5 6A5.5 5.5 0 1 0 13 11M14 2v4h-4" />
103
+ </Icon>
104
+ );
105
+ }
106
+
107
+ export function IconUndo(p: IconProps): JSX.Element {
108
+ return (
109
+ <Icon {...p}>
110
+ <path d="M6 2 2 6l4 4M2 6h7a4 4 0 0 1 0 8" />
111
+ </Icon>
112
+ );
113
+ }
114
+
115
+ export function IconSparkle(p: IconProps): JSX.Element {
116
+ return (
117
+ <Icon {...p}>
118
+ <path d="m8 1 2 5 5 2-5 2-2 5-2-5-5-2 5-2Z" />
119
+ </Icon>
120
+ );
121
+ }
122
+
123
+ /** Upload into a tray — attach a document. */
124
+ export function IconUpload(p: IconProps): JSX.Element {
125
+ return (
126
+ <Icon {...p}>
127
+ <path d="M8 10V2M4.5 5.5 8 2l3.5 3.5M2 10v3.5h12V10" />
128
+ </Icon>
129
+ );
130
+ }
131
+
132
+ /** Dropdown disclosure, sized like the other toolbar icons. */
133
+ export function IconChevronDown(p: IconProps): JSX.Element {
134
+ return (
135
+ <Icon {...p}>
136
+ <polyline points="3 6 8 11 13 6" />
137
+ </Icon>
138
+ );
139
+ }
140
+
24
141
  /** Expand-arrows — fit all nodes */
25
142
  export function IconFitAll(p: IconProps): JSX.Element {
26
143
  return (
@@ -1,5 +1,6 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
  import { boardList, openBoard } from '../state/boards-store';
3
+ import { IconExternalLink } from '../icons';
3
4
 
4
5
  /** Native text-only cross-board link. Target identity is the durable board id. */
5
6
  export function BoardNode({ node }: { node: CanvasNodeState }) {
@@ -28,7 +29,7 @@ export function BoardNode({ node }: { node: CanvasNodeState }) {
28
29
  onClick={() => target && void openBoard(target.id)}
29
30
  >
30
31
  {target ? 'Open board' : 'Target unavailable'}
31
- {target && <span aria-hidden="true">↗</span>}
32
+ {target && <IconExternalLink size={14} />}
32
33
  </button>
33
34
  </div>
34
35
  );
@@ -2,6 +2,7 @@ import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcon
2
2
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
4
4
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
5
+ import { IconClose } from '../icons';
5
6
  import {
6
7
  AX_SURFACE_ACK_SOURCE,
7
8
  AX_SURFACE_EMIT_SOURCE,
@@ -1429,6 +1430,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1429
1430
  <button
1430
1431
  type="button"
1431
1432
  onClick={() => setError(null)}
1433
+ aria-label="Dismiss error"
1432
1434
  style={{
1433
1435
  background: 'none',
1434
1436
  border: 'none',
@@ -1438,7 +1440,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1438
1440
  padding: '0 2px',
1439
1441
  }}
1440
1442
  >
1441
- ×
1443
+ <IconClose size={14} />
1442
1444
  </button>
1443
1445
  </div>
1444
1446
  )}
@@ -1,4 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
2
+ import { IconPlusCircle, IconRefresh } from '../icons';
3
+ import { delimiterForPath, parseDelimitedText } from '../../shared/delimited-text';
2
4
  import { updateNodeData } from '../state/canvas-store';
3
5
  import { iframeMode } from '../state/iframe-mode';
4
6
  import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
@@ -44,82 +46,6 @@ function langFromPath(path: string): string {
44
46
  /** Rendered-row ceiling for delimited files — a 200k-row CSV must not lock the browser. */
45
47
  const MAX_TABLE_ROWS = 500;
46
48
 
47
- export interface DelimitedTable {
48
- header: string[];
49
- /** Rectangular: every row padded (short) or truncated (long) to the header width. */
50
- rows: string[][];
51
- }
52
-
53
- /** Column delimiter for a delimited-data path, or null when the file is not one. */
54
- function delimiterForPath(path: string): string | null {
55
- const lower = path.toLowerCase();
56
- if (lower.endsWith('.csv')) return ',';
57
- if (lower.endsWith('.tsv')) return '\t';
58
- return null;
59
- }
60
-
61
- /**
62
- * RFC4180-ish delimited-text parser. Quoted fields may contain the delimiter,
63
- * newlines, and `""` escapes; CRLF, LF and lone CR all end a record; a trailing
64
- * newline produces no phantom empty row. Ragged records are squared off against
65
- * the header width instead of throwing.
66
- */
67
- export function parseDelimitedText(text: string, delimiter: string): DelimitedTable {
68
- const records: string[][] = [];
69
- let row: string[] = [];
70
- let field = '';
71
- let quoted = false;
72
- // Tracks whether the current record consumed anything — distinguishes a real
73
- // empty-quoted record from the dangling state after a terminating newline.
74
- let started = false;
75
-
76
- for (let i = 0; i < text.length; i++) {
77
- const char = text[i];
78
- started = true;
79
- if (quoted) {
80
- if (char === '"') {
81
- if (text[i + 1] === '"') {
82
- field += '"';
83
- i++;
84
- } else {
85
- quoted = false;
86
- }
87
- } else if (char === '\r' && text[i + 1] === '\n') {
88
- field += '\n';
89
- i++;
90
- } else {
91
- field += char;
92
- }
93
- continue;
94
- }
95
- if (char === '"' && field === '') {
96
- quoted = true;
97
- } else if (char === delimiter) {
98
- row.push(field);
99
- field = '';
100
- } else if (char === '\n' || char === '\r') {
101
- if (char === '\r' && text[i + 1] === '\n') i++;
102
- row.push(field);
103
- field = '';
104
- records.push(row);
105
- row = [];
106
- started = false;
107
- } else {
108
- field += char;
109
- }
110
- }
111
- if (started) {
112
- row.push(field);
113
- records.push(row);
114
- }
115
-
116
- const header = records.shift() ?? [];
117
- return {
118
- header,
119
- rows: records.map((record) => Array.from({ length: header.length }, (_, i) => record[i] ?? '')),
120
- };
121
- }
122
-
123
49
  /** Human-readable byte size, e.g. `4.2 MB`. */
124
50
  export function formatBytes(bytes: number): string {
125
51
  if (!Number.isFinite(bytes) || bytes < 0) return '';
@@ -349,7 +275,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
349
275
  flexShrink: 0,
350
276
  }}
351
277
  >
352
- ⊕
278
+ <IconPlusCircle size={14} />
353
279
  </button>
354
280
  <button
355
281
  type="button"
@@ -365,7 +291,7 @@ function TextFileNode({ node, expanded = false }: { node: CanvasNodeState; expan
365
291
  flexShrink: 0,
366
292
  }}
367
293
  >
368
- ↻
294
+ <IconRefresh size={14} />
369
295
  </button>
370
296
  </div>
371
297
 
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconRefresh } from '../icons';
2
3
  import type { CanvasNodeState } from '../types';
3
4
  import { getImageNodeWarnings } from './image-warnings';
4
5
 
@@ -140,7 +141,7 @@ export function ImageNode({ node, expanded = false }: { node: CanvasNodeState; e
140
141
  {sizeLabel && <span>{sizeLabel}</span>}
141
142
  {zoom !== 1 && (
142
143
  <button type="button" class="image-node-zoom-reset" onClick={resetView} title="Reset zoom">
143
- {zoomPct}% ↺
144
+ {zoomPct}% <IconRefresh size={14} />
144
145
  </button>
145
146
  )}
146
147
  </span>
@@ -1,5 +1,6 @@
1
1
  import type { JSX } from 'preact';
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
+ import { IconArrowLeft } from '../icons';
3
4
  import { expandNode, updateNodeData } from '../state/canvas-store';
4
5
  import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
5
6
  import type { CanvasNodeState } from '../types';
@@ -254,7 +255,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
254
255
  <div class="md-editor-expanded" onKeyDown={handleKeyDown}>
255
256
  <div class="md-editor-toolbar">
256
257
  <button type="button" class="md-toolbar-btn" onClick={() => setSourceMode(false)}>
257
- ← Back to document
258
+ <IconArrowLeft size={14} /> Back to document
258
259
  </button>
259
260
  <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
260
261
  {path && <span class="md-toolbar-path">{path.split('/').pop()}</span>}
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { IconClose } from '../icons';
2
3
  import { updateNodeData } from '../state/canvas-store';
3
4
  import { fetchSlashCommands, renderMarkdown, submitCanvasPrompt, submitThreadReply } from '../state/intent-bridge';
4
5
  import type { CanvasNodeState } from '../types';
@@ -98,6 +99,7 @@ function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss
98
99
  <button
99
100
  type="button"
100
101
  onClick={onDismiss}
102
+ aria-label="Dismiss error"
101
103
  style={{
102
104
  background: 'none',
103
105
  border: 'none',
@@ -107,7 +109,7 @@ function ErrorBanner({ message, onDismiss }: { message: string | null; onDismiss
107
109
  padding: '0 2px',
108
110
  }}
109
111
  >
110
- {'\u00d7'}
112
+ <IconClose size={14} />
111
113
  </button>
112
114
  </div>
113
115
  );
@@ -760,6 +760,11 @@ body,
760
760
  border-radius: calc(3px * var(--node-chrome-scale, 1));
761
761
  }
762
762
 
763
+ .canvas-node .node-controls button svg {
764
+ width: 1em;
765
+ height: 1em;
766
+ }
767
+
763
768
  .canvas-node .node-controls button:hover {
764
769
  background: var(--c-surface-hover);
765
770
  color: var(--c-text);
@@ -1466,6 +1471,10 @@ body,
1466
1471
  animation: presence-pulse 1.4s ease-in-out infinite;
1467
1472
  }
1468
1473
  .session-collapse {
1474
+ display: inline-flex;
1475
+ align-items: center;
1476
+ justify-content: center;
1477
+ padding: 0;
1469
1478
  width: 26px;
1470
1479
  height: 26px;
1471
1480
  border: 1px solid transparent;
@@ -1682,8 +1691,9 @@ body,
1682
1691
  }
1683
1692
  }
1684
1693
  .session-section-chevron {
1694
+ display: inline-flex;
1695
+ flex-shrink: 0;
1685
1696
  color: var(--c-muted);
1686
- font-size: 13px;
1687
1697
  transition: transform 0.15s ease;
1688
1698
  }
1689
1699
  .session-section-chevron.is-open {
@@ -1966,8 +1976,13 @@ body,
1966
1976
  white-space: nowrap;
1967
1977
  }
1968
1978
  .command-bar-chip-unpin {
1969
- width: 14px;
1970
- height: 14px;
1979
+ display: inline-flex;
1980
+ align-items: center;
1981
+ justify-content: center;
1982
+ flex-shrink: 0;
1983
+ padding: 0;
1984
+ width: 20px;
1985
+ height: 20px;
1971
1986
  border: none;
1972
1987
  border-radius: 50%;
1973
1988
  background: none;
@@ -6896,7 +6911,7 @@ html.is-node-dragging .intent-line-layer {
6896
6911
  align-items: center;
6897
6912
  gap: 6px;
6898
6913
  max-width: 280px;
6899
- padding: 4px 8px;
6914
+ padding: 6px 8px;
6900
6915
  margin-left: -8px;
6901
6916
  background: none;
6902
6917
  border: 1px solid transparent;
@@ -6914,7 +6929,8 @@ html.is-node-dragging .intent-line-layer {
6914
6929
  white-space: nowrap;
6915
6930
  }
6916
6931
  .board-switcher-caret {
6917
- font-size: 10px;
6932
+ display: inline-flex;
6933
+ flex-shrink: 0;
6918
6934
  color: var(--c-text-soft);
6919
6935
  }
6920
6936
  .board-switcher-menu {