pmx-canvas 0.6.2 → 0.6.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 (39) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/Readme.md +30 -3
  3. package/dist/canvas/global.css +32 -0
  4. package/dist/canvas/index.js +68 -66
  5. package/dist/json-render/index.js +57 -57
  6. package/dist/types/client/canvas/FeedbackDialog.d.ts +4 -0
  7. package/dist/types/client/canvas/auto-fit.d.ts +3 -0
  8. package/dist/types/client/nodes/use-iframe-content-height.d.ts +3 -3
  9. package/dist/types/client/state/human-store.d.ts +1 -0
  10. package/dist/types/json-render/catalog.d.ts +20 -20
  11. package/dist/types/json-render/schema.d.ts +40 -0
  12. package/dist/types/mcp/skills.d.ts +13 -0
  13. package/dist/types/server/bundled-skills.d.ts +32 -4
  14. package/docs/cli.md +7 -2
  15. package/docs/http-api.md +5 -1
  16. package/docs/mcp.md +35 -1
  17. package/docs/node-types.md +43 -1
  18. package/docs/product-context-vision-2026-09.md +17 -6
  19. package/docs/product-vision-2026-09.md +136 -41
  20. package/docs/product-vision-review-2026-09.md +277 -0
  21. package/package.json +11 -11
  22. package/skills/json-render-mcp/SKILL.md +118 -2
  23. package/skills/pmx-canvas/SKILL.md +43 -13
  24. package/skills/pmx-canvas/evals/evals.json +46 -0
  25. package/skills/pmx-canvas/references/installing-pmx-canvas.md +5 -5
  26. package/src/client/canvas/FeedbackDialog.tsx +86 -0
  27. package/src/client/canvas/ToolRail.tsx +225 -189
  28. package/src/client/canvas/auto-fit.ts +20 -1
  29. package/src/client/nodes/use-iframe-content-height.ts +44 -24
  30. package/src/client/state/human-store.ts +6 -6
  31. package/src/client/theme/global.css +32 -0
  32. package/src/json-render/catalog.ts +6 -0
  33. package/src/json-render/renderer/index.tsx +17 -0
  34. package/src/json-render/schema.ts +4 -0
  35. package/src/mcp/server.ts +9 -4
  36. package/src/mcp/skills.ts +47 -0
  37. package/src/server/bundled-skills.ts +92 -53
  38. package/src/server/operations/ops/nodes.ts +43 -11
  39. package/src/shared/content-height-reporter.ts +4 -2
@@ -4,7 +4,7 @@ Use this reference when the `pmx-canvas` skill is installed but the `pmx-canvas`
4
4
 
5
5
  ## Prerequisites and version
6
6
 
7
- The examples pin **PMX Canvas 0.6.1**, which requires **Bun >=1.4.2** even when
7
+ The examples pin **PMX Canvas 0.6.4**, which requires **Bun >=1.4.2** even when
8
8
  installed through npm: the CLI runs through Bun, not Node. Check `bun --version`
9
9
  first. If missing or older, follow the [Bun installation instructions](https://bun.sh/docs/installation)
10
10
  with the user's authorization. Ensure Bun is on the MCP host's PATH as well as the
@@ -17,11 +17,11 @@ in `SKILL.md`. Do not pair newer checkout guidance with an older runtime unknowi
17
17
  ## Install from npm
18
18
 
19
19
  ```bash
20
- npm install -g pmx-canvas@0.6.1
20
+ npm install -g pmx-canvas@0.6.4
21
21
  pmx-canvas --version
22
22
  ```
23
23
 
24
- Without a global install, use `bunx pmx-canvas@0.6.1` in place of `pmx-canvas` in
24
+ Without a global install, use `bunx pmx-canvas@0.6.4` in place of `pmx-canvas` in
25
25
  the commands below. Do not use unpinned `bunx pmx-canvas` for a version-specific check.
26
26
 
27
27
  ## Choose the workspace and target
@@ -73,7 +73,7 @@ bun run build
73
73
  bun run src/cli/index.ts --version
74
74
  ```
75
75
 
76
- This uses the checked-out source, not necessarily the published 0.6.1 package. Follow
76
+ This uses the checked-out source, not necessarily the published package. Follow
77
77
  that checkout's `package.json` Bun requirement and bundled skill. Set the same root/port
78
78
  environment above; substitute `bun run /absolute/path/to/pmx-canvas/src/cli/index.ts`
79
79
  for `pmx-canvas` in startup and verification commands. The target workspace can be
@@ -91,7 +91,7 @@ discards empty env values, remove `PMX_CANVAS_URL` from its launch environment i
91
91
  "mcpServers": {
92
92
  "canvas": {
93
93
  "command": "bunx",
94
- "args": ["pmx-canvas@0.6.1", "--mcp"],
94
+ "args": ["pmx-canvas@0.6.4", "--mcp"],
95
95
  "env": {
96
96
  "PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
97
97
  "PMX_CANVAS_PORT": "14313",
@@ -0,0 +1,86 @@
1
+ import { useRef, useState } from 'preact/hooks';
2
+ import { useFocusTrap } from './use-focus-trap';
3
+
4
+ /** Draft only: GitHub owns authentication and the final public submission. */
5
+ export function FeedbackDialog({ onClose }: { onClose: () => void }) {
6
+ const ref = useRef<HTMLFormElement>(null);
7
+ const [kind, setKind] = useState('Bug report');
8
+ const [title, setTitle] = useState('');
9
+ const [description, setDescription] = useState('');
10
+ useFocusTrap(ref, true);
11
+
12
+ return (
13
+ <div class="text-prompt-backdrop feedback-backdrop" onPointerDown={onClose}>
14
+ <form
15
+ ref={ref}
16
+ class="text-prompt feedback-dialog"
17
+ role="dialog"
18
+ aria-modal="true"
19
+ aria-labelledby="feedback-title"
20
+ aria-describedby="feedback-privacy"
21
+ action="https://github.com/pskoett/pmx-canvas/issues/new"
22
+ method="get"
23
+ target="_blank"
24
+ rel="noopener noreferrer"
25
+ onPointerDown={(e) => e.stopPropagation()}
26
+ onKeyDown={(e) => {
27
+ e.stopPropagation();
28
+ if (e.key === 'Escape') {
29
+ e.preventDefault();
30
+ onClose();
31
+ }
32
+ }}
33
+ >
34
+ <div id="feedback-title" class="text-prompt-title">
35
+ Bug and feedback
36
+ </div>
37
+ <p id="feedback-privacy" class="feedback-note">
38
+ Opens a draft issue in pskoett/pmx-canvas. Review and submit it on GitHub using your account. Issues are
39
+ public. Only what you enter below is included; no board contents, workspace paths, screenshots, or logs are
40
+ attached.
41
+ </p>
42
+ <label>
43
+ Feedback type
44
+ <select class="text-prompt-input" value={kind} onChange={(e) => setKind(e.currentTarget.value)}>
45
+ <option>Bug report</option>
46
+ <option>Feature request</option>
47
+ <option>General feedback</option>
48
+ </select>
49
+ </label>
50
+ <label>
51
+ Title
52
+ <input
53
+ class="text-prompt-input"
54
+ name="title"
55
+ required
56
+ maxLength={120}
57
+ value={title}
58
+ onInput={(e) => setTitle(e.currentTarget.value)}
59
+ placeholder="A short summary"
60
+ />
61
+ </label>
62
+ <label>
63
+ Description
64
+ <textarea
65
+ class="text-prompt-input"
66
+ required
67
+ maxLength={1200}
68
+ rows={6}
69
+ value={description}
70
+ onInput={(e) => setDescription(e.currentTarget.value)}
71
+ placeholder="What happened, what you expected, and steps to reproduce — or describe your idea."
72
+ />
73
+ </label>
74
+ <input type="hidden" name="body" value={`## ${kind}\n\n${description.trim()}`} />
75
+ <div class="text-prompt-actions">
76
+ <button type="button" class="text-prompt-cancel" onClick={onClose}>
77
+ Cancel
78
+ </button>
79
+ <button type="submit" class="text-prompt-confirm" disabled={!title.trim() || !description.trim()}>
80
+ Continue on GitHub ↗
81
+ </button>
82
+ </div>
83
+ </form>
84
+ </div>
85
+ );
86
+ }
@@ -34,6 +34,7 @@ import {
34
34
  import { saveCanvasTheme } from '../state/intent-bridge';
35
35
  import { createNodeInView } from './create-in-view';
36
36
  import { askText } from './TextPrompt';
37
+ import { FeedbackDialog } from './FeedbackDialog';
37
38
  import { clearThemeOverride } from '../state/theme-override';
38
39
  import { invalidateTokenCache } from '../theme/tokens';
39
40
  import type { AnnotationTool } from '../types';
@@ -170,6 +171,7 @@ export function ToolRail({
170
171
  const isTraceOn = traceEnabled.value;
171
172
  const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
172
173
  const edgeCount = edges.value.size;
174
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
173
175
  const [openMenu, setOpenMenu] = useState<null | 'theme' | 'annotate'>(null);
174
176
  const [menuAnchor, setMenuAnchor] = useState<{ top: number; bottom: number; right: number } | null>(null);
175
177
  const railRef = useRef<HTMLDivElement>(null);
@@ -236,212 +238,246 @@ export function ToolRail({
236
238
  };
237
239
 
238
240
  return (
239
- <div class="tool-rail" ref={railRef} role="toolbar" aria-label="Canvas tools" aria-orientation="vertical">
240
- <span class="rail-brand" title="PMX Canvas" aria-label="PMX Canvas">
241
- <IconLogo size={22} />
242
- </span>
243
-
244
- <div class="rail-divider" />
241
+ <>
242
+ <div class="tool-rail" ref={railRef} role="toolbar" aria-label="Canvas tools" aria-orientation="vertical">
243
+ <span class="rail-brand" title="PMX Canvas" aria-label="PMX Canvas">
244
+ <IconLogo size={22} />
245
+ </span>
245
246
 
246
- <RailButton label="Select" shortcut="V" active={tool === 'select'} onClick={() => (canvasTool.value = 'select')}>
247
- <IconCursorTool />
248
- </RailButton>
249
- <RailButton label="Pan" shortcut="Space" active={tool === 'pan'} onClick={() => (canvasTool.value = 'pan')}>
250
- <IconHandTool />
251
- </RailButton>
252
- <RailButton
253
- label="Connect"
254
- shortcut="C"
255
- active={tool === 'connect'}
256
- onClick={() => (canvasTool.value = 'connect')}
257
- >
258
- <svg
259
- width="15"
260
- height="15"
261
- viewBox="0 0 16 16"
262
- fill="none"
263
- stroke="currentColor"
264
- stroke-width="1.5"
265
- stroke-linecap="round"
266
- aria-hidden="true"
247
+ <RailButton
248
+ label="Bug and feedback"
249
+ menuOpen={feedbackOpen}
250
+ onClick={() => {
251
+ setOpenMenu(null);
252
+ setFeedbackOpen(true);
253
+ }}
267
254
  >
268
- <circle cx="4" cy="4" r="2" />
269
- <circle cx="12" cy="12" r="2" />
270
- <path d="M5.5 5.5 C 8 8, 8 8, 10.5 10.5" />
271
- </svg>
272
- </RailButton>
255
+ <svg
256
+ width="16"
257
+ height="16"
258
+ viewBox="0 0 16 16"
259
+ fill="none"
260
+ stroke="currentColor"
261
+ stroke-width="1.5"
262
+ stroke-linecap="round"
263
+ stroke-linejoin="round"
264
+ aria-hidden="true"
265
+ >
266
+ <rect x="4.5" y="5" width="7" height="9" rx="3.5" />
267
+ <path d="M6 5V4a2 2 0 0 1 4 0v1M8 8v6M2 4l2.5 2M14 4l-2.5 2M1.5 9h3M11.5 9h3M2 14l2.5-2M14 14l-2.5-2" />
268
+ </svg>
269
+ </RailButton>
273
270
 
274
- <div class="rail-divider" />
271
+ <div class="rail-divider" />
275
272
 
276
- <RailButton
277
- label="Markdown note"
278
- shortcut="M"
279
- onClick={() =>
280
- void createNodeInView({ type: 'markdown', title: 'New note', width: 520, height: 360 }).catch((error) =>
281
- logRailError('create markdown', error),
282
- )
283
- }
284
- >
285
- <IconNodeMarkdown size={15} />
286
- </RailButton>
287
- <RailButton label="Image" shortcut="I" onClick={() => promptedCreate('image')}>
288
- <IconNodeImage size={15} />
289
- </RailButton>
290
- <RailButton label="File" shortcut="Shift+F" onClick={() => promptedCreate('file')}>
291
- <IconNodeFile size={15} />
292
- </RailButton>
293
- <RailButton label="Webpage" shortcut="W" onClick={() => promptedCreate('webpage')}>
294
- <IconNodeWebpage size={15} />
295
- </RailButton>
296
- <RailButton
297
- label="HTML surface"
298
- shortcut="H"
299
- onClick={() =>
300
- void createNodeInView({ type: 'html', title: 'HTML surface' }).catch((error) =>
301
- logRailError('create html', error),
302
- )
303
- }
304
- >
305
- <IconNodeHtml />
306
- </RailButton>
307
- <RailButton
308
- label="Group"
309
- shortcut="G"
310
- onClick={() =>
311
- void createNodeInView({ type: 'group', title: 'Group' }).catch((error) => logRailError('create group', error))
312
- }
313
- >
314
- <IconNodeGroup size={15} />
315
- </RailButton>
316
- <span class="toolbar-menu-anchor">
317
273
  <RailButton
318
- label="Annotate"
319
- shortcut="A"
320
- detail="Draw · Text note · Eraser"
321
- active={annotationTool !== null}
322
- menuOpen={openMenu === 'annotate'}
323
- onClick={toggleMenu('annotate')}
274
+ label="Select"
275
+ shortcut="V"
276
+ active={tool === 'select'}
277
+ onClick={() => (canvasTool.value = 'select')}
324
278
  >
325
- {annotationTool === 'eraser' ? (
326
- <IconEraser />
327
- ) : annotationTool === 'text' ? (
328
- <IconTextAnnotation />
329
- ) : (
330
- <IconPen />
331
- )}
279
+ <IconCursorTool />
280
+ </RailButton>
281
+ <RailButton label="Pan" shortcut="Space" active={tool === 'pan'} onClick={() => (canvasTool.value = 'pan')}>
282
+ <IconHandTool />
283
+ </RailButton>
284
+ <RailButton
285
+ label="Connect"
286
+ shortcut="C"
287
+ active={tool === 'connect'}
288
+ onClick={() => (canvasTool.value = 'connect')}
289
+ >
290
+ <svg
291
+ width="15"
292
+ height="15"
293
+ viewBox="0 0 16 16"
294
+ fill="none"
295
+ stroke="currentColor"
296
+ stroke-width="1.5"
297
+ stroke-linecap="round"
298
+ aria-hidden="true"
299
+ >
300
+ <circle cx="4" cy="4" r="2" />
301
+ <circle cx="12" cy="12" r="2" />
302
+ <path d="M5.5 5.5 C 8 8, 8 8, 10.5 10.5" />
303
+ </svg>
332
304
  </RailButton>
333
- {openMenu === 'annotate' && (
334
- <div class="toolbar-menu" style={sideMenuStyle(false)} role="menu" aria-label="Annotate">
335
- <button
336
- type="button"
337
- class={`toolbar-menu-item${annotationTool === 'pen' ? ' active' : ''}`}
338
- onClick={() => {
339
- onSetAnnotationTool(annotationTool === 'pen' ? null : 'pen');
340
- setOpenMenu(null);
341
- }}
342
- >
343
- <IconPen />
344
- <span>{annotationTool === 'pen' ? 'Stop annotating' : 'Draw (A)'}</span>
345
- </button>
346
- <button
347
- type="button"
348
- class={`toolbar-menu-item${annotationTool === 'text' ? ' active' : ''}`}
349
- onClick={() => {
350
- onSetAnnotationTool(annotationTool === 'text' ? null : 'text');
351
- setOpenMenu(null);
352
- }}
353
- >
354
- <IconTextAnnotation />
355
- <span>{annotationTool === 'text' ? 'Stop text notes' : 'Text note'}</span>
356
- </button>
357
- <button
358
- type="button"
359
- class={`toolbar-menu-item${annotationTool === 'eraser' ? ' active' : ''}`}
360
- onClick={() => {
361
- onSetAnnotationTool(annotationTool === 'eraser' ? null : 'eraser');
362
- setOpenMenu(null);
363
- }}
364
- >
365
- <IconEraser />
366
- <span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Eraser'}</span>
367
- </button>
368
- </div>
369
- )}
370
- </span>
371
305
 
372
- <div class="rail-spacer" />
306
+ <div class="rail-divider" />
373
307
 
374
- <RailButton label="Search & commands" shortcut={modChord('K')} onClick={onOpenPalette}>
375
- <IconSearch />
376
- </RailButton>
377
- <RailButton
378
- label="Arrange"
379
- detail={edgeCount > 0 ? 'graph-aware' : 'grid'}
380
- onClick={() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange())}
381
- >
382
- <IconArrange />
383
- </RailButton>
384
- <RailButton
385
- label={isTraceOn ? 'Disable trace' : 'Enable trace'}
386
- active={isTraceOn}
387
- onClick={() => sendIntent('trace-toggle', { enabled: !isTraceOn })}
388
- >
389
- <IconTrace />
390
- </RailButton>
391
- {(isTraceOn || traceNodeCount > 0) && (
392
- <RailButton label="Clear trace" onClick={() => sendIntent('trace-clear')}>
393
- <IconClearTrace />
308
+ <RailButton
309
+ label="Markdown note"
310
+ shortcut="M"
311
+ onClick={() =>
312
+ void createNodeInView({ type: 'markdown', title: 'New note', width: 520, height: 360 }).catch((error) =>
313
+ logRailError('create markdown', error),
314
+ )
315
+ }
316
+ >
317
+ <IconNodeMarkdown size={15} />
318
+ </RailButton>
319
+ <RailButton label="Image" shortcut="I" onClick={() => promptedCreate('image')}>
320
+ <IconNodeImage size={15} />
321
+ </RailButton>
322
+ <RailButton label="File" shortcut="Shift+F" onClick={() => promptedCreate('file')}>
323
+ <IconNodeFile size={15} />
324
+ </RailButton>
325
+ <RailButton label="Webpage" shortcut="W" onClick={() => promptedCreate('webpage')}>
326
+ <IconNodeWebpage size={15} />
327
+ </RailButton>
328
+ <RailButton
329
+ label="HTML surface"
330
+ shortcut="H"
331
+ onClick={() =>
332
+ void createNodeInView({ type: 'html', title: 'HTML surface' }).catch((error) =>
333
+ logRailError('create html', error),
334
+ )
335
+ }
336
+ >
337
+ <IconNodeHtml />
394
338
  </RailButton>
395
- )}
396
- <RailButton
397
- label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
398
- active={minimapVisible}
399
- onClick={onToggleMinimap}
400
- >
401
- <IconMinimap />
402
- </RailButton>
403
- <RailButton label="Snapshots" active={snapshotOpen} onClick={onToggleSnapshot} btnRef={snapshotBtnRef}>
404
- <IconSnapshot />
405
- </RailButton>
406
- <span class="toolbar-menu-anchor">
407
339
  <RailButton
408
- label="Theme"
409
- detail={CANVAS_THEME_META[activeTheme].label}
410
- menuOpen={openMenu === 'theme'}
411
- ariaLabel="Choose theme"
412
- active={openMenu === 'theme'}
413
- onClick={toggleMenu('theme')}
340
+ label="Group"
341
+ shortcut="G"
342
+ onClick={() =>
343
+ void createNodeInView({ type: 'group', title: 'Group' }).catch((error) =>
344
+ logRailError('create group', error),
345
+ )
346
+ }
414
347
  >
415
- {canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
348
+ <IconNodeGroup size={15} />
416
349
  </RailButton>
417
- {openMenu === 'theme' && (
418
- <div class="toolbar-menu" style={sideMenuStyle(true)} role="menu" aria-label="Theme">
419
- {CANVAS_THEMES.map((name) => (
350
+ <span class="toolbar-menu-anchor">
351
+ <RailButton
352
+ label="Annotate"
353
+ shortcut="A"
354
+ detail="Draw · Text note · Eraser"
355
+ active={annotationTool !== null}
356
+ menuOpen={openMenu === 'annotate'}
357
+ onClick={toggleMenu('annotate')}
358
+ >
359
+ {annotationTool === 'eraser' ? (
360
+ <IconEraser />
361
+ ) : annotationTool === 'text' ? (
362
+ <IconTextAnnotation />
363
+ ) : (
364
+ <IconPen />
365
+ )}
366
+ </RailButton>
367
+ {openMenu === 'annotate' && (
368
+ <div class="toolbar-menu" style={sideMenuStyle(false)} role="menu" aria-label="Annotate">
420
369
  <button
421
- key={name}
422
370
  type="button"
423
- role="menuitemradio"
424
- aria-checked={activeTheme === name}
425
- class={`toolbar-menu-item${activeTheme === name ? ' active' : ''}`}
426
- onClick={() => applyTheme(name)}
371
+ class={`toolbar-menu-item${annotationTool === 'pen' ? ' active' : ''}`}
372
+ onClick={() => {
373
+ onSetAnnotationTool(annotationTool === 'pen' ? null : 'pen');
374
+ setOpenMenu(null);
375
+ }}
427
376
  >
428
- <span class="theme-swatch" style={{ background: CANVAS_THEME_META[name].swatchBg }}>
429
- <span class="theme-swatch-dot" style={{ background: CANVAS_THEME_META[name].swatchAccent }} />
430
- </span>
431
- <span>{CANVAS_THEME_META[name].label}</span>
432
- {activeTheme === name && (
433
- <span class="toolbar-menu-check" aria-hidden="true">
434
- ✓
435
- </span>
436
- )}
377
+ <IconPen />
378
+ <span>{annotationTool === 'pen' ? 'Stop annotating' : 'Draw (A)'}</span>
379
+ </button>
380
+ <button
381
+ type="button"
382
+ class={`toolbar-menu-item${annotationTool === 'text' ? ' active' : ''}`}
383
+ onClick={() => {
384
+ onSetAnnotationTool(annotationTool === 'text' ? null : 'text');
385
+ setOpenMenu(null);
386
+ }}
387
+ >
388
+ <IconTextAnnotation />
389
+ <span>{annotationTool === 'text' ? 'Stop text notes' : 'Text note'}</span>
437
390
  </button>
438
- ))}
439
- </div>
391
+ <button
392
+ type="button"
393
+ class={`toolbar-menu-item${annotationTool === 'eraser' ? ' active' : ''}`}
394
+ onClick={() => {
395
+ onSetAnnotationTool(annotationTool === 'eraser' ? null : 'eraser');
396
+ setOpenMenu(null);
397
+ }}
398
+ >
399
+ <IconEraser />
400
+ <span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Eraser'}</span>
401
+ </button>
402
+ </div>
403
+ )}
404
+ </span>
405
+
406
+ <div class="rail-spacer" />
407
+
408
+ <RailButton label="Search & commands" shortcut={modChord('K')} onClick={onOpenPalette}>
409
+ <IconSearch />
410
+ </RailButton>
411
+ <RailButton
412
+ label="Arrange"
413
+ detail={edgeCount > 0 ? 'graph-aware' : 'grid'}
414
+ onClick={() => (edgeCount > 0 ? forceDirectedArrange() : autoArrange())}
415
+ >
416
+ <IconArrange />
417
+ </RailButton>
418
+ <RailButton
419
+ label={isTraceOn ? 'Disable trace' : 'Enable trace'}
420
+ active={isTraceOn}
421
+ onClick={() => sendIntent('trace-toggle', { enabled: !isTraceOn })}
422
+ >
423
+ <IconTrace />
424
+ </RailButton>
425
+ {(isTraceOn || traceNodeCount > 0) && (
426
+ <RailButton label="Clear trace" onClick={() => sendIntent('trace-clear')}>
427
+ <IconClearTrace />
428
+ </RailButton>
440
429
  )}
441
- </span>
442
- <RailButton label="Shortcuts" shortcut="?" onClick={onOpenShortcuts}>
443
- <IconShortcuts />
444
- </RailButton>
445
- </div>
430
+ <RailButton
431
+ label={minimapVisible ? 'Hide minimap' : 'Show minimap'}
432
+ active={minimapVisible}
433
+ onClick={onToggleMinimap}
434
+ >
435
+ <IconMinimap />
436
+ </RailButton>
437
+ <RailButton label="Snapshots" active={snapshotOpen} onClick={onToggleSnapshot} btnRef={snapshotBtnRef}>
438
+ <IconSnapshot />
439
+ </RailButton>
440
+ <span class="toolbar-menu-anchor">
441
+ <RailButton
442
+ label="Theme"
443
+ detail={CANVAS_THEME_META[activeTheme].label}
444
+ menuOpen={openMenu === 'theme'}
445
+ ariaLabel="Choose theme"
446
+ active={openMenu === 'theme'}
447
+ onClick={toggleMenu('theme')}
448
+ >
449
+ {canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
450
+ </RailButton>
451
+ {openMenu === 'theme' && (
452
+ <div class="toolbar-menu" style={sideMenuStyle(true)} role="menu" aria-label="Theme">
453
+ {CANVAS_THEMES.map((name) => (
454
+ <button
455
+ key={name}
456
+ type="button"
457
+ role="menuitemradio"
458
+ aria-checked={activeTheme === name}
459
+ class={`toolbar-menu-item${activeTheme === name ? ' active' : ''}`}
460
+ onClick={() => applyTheme(name)}
461
+ >
462
+ <span class="theme-swatch" style={{ background: CANVAS_THEME_META[name].swatchBg }}>
463
+ <span class="theme-swatch-dot" style={{ background: CANVAS_THEME_META[name].swatchAccent }} />
464
+ </span>
465
+ <span>{CANVAS_THEME_META[name].label}</span>
466
+ {activeTheme === name && (
467
+ <span class="toolbar-menu-check" aria-hidden="true">
468
+ ✓
469
+ </span>
470
+ )}
471
+ </button>
472
+ ))}
473
+ </div>
474
+ )}
475
+ </span>
476
+ <RailButton label="Shortcuts" shortcut="?" onClick={onOpenShortcuts}>
477
+ <IconShortcuts />
478
+ </RailButton>
479
+ </div>
480
+ {feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
481
+ </>
446
482
  );
447
483
  }
@@ -1,5 +1,6 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
  import { nodeMinSize } from '../../shared/node-sizes.js';
3
+ import { findBlocker } from '../../shared/placement.js';
3
4
 
4
5
  export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
5
6
  export const AUTO_FIT_MAX_HEIGHT = 600;
@@ -81,7 +82,25 @@ export function shouldContentFitIframeNode(node: CanvasNodeState): boolean {
81
82
  * capped at the iframe ceiling.
82
83
  */
83
84
  export function computeContentGrowHeight(node: CanvasNodeState, contentHeight: number): number | null {
84
- if (!shouldContentFitIframeNode(node) || contentHeight <= 0) return null;
85
+ if (!shouldContentFitIframeNode(node) || !Number.isFinite(contentHeight) || contentHeight <= 0) return null;
85
86
  const want = Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT + AUTO_FIT_BODY_PADDING, AUTO_FIT_MAX_HEIGHT_IFRAME);
86
87
  return want > node.size.height + 8 ? want : null;
87
88
  }
89
+
90
+ /** Keep an automatically growing card clear without moving its neighbors or
91
+ * changing its column. Groups and screen-pinned cards retain authored placement. */
92
+ export function contentFitPosition(
93
+ node: CanvasNodeState,
94
+ height: number,
95
+ allNodes: CanvasNodeState[],
96
+ ): CanvasNodeState['position'] {
97
+ if (height <= node.size.height || node.pinned || node.data.parentGroup) return node.position;
98
+ const obstacles = allNodes.filter((other) => other.id !== node.id && !other.pinned && other.type !== 'group');
99
+ const position = { ...node.position };
100
+ let blocker = findBlocker(position, node.size.width, height, obstacles, 24);
101
+ while (blocker) {
102
+ position.y = blocker.position.y + blocker.size.height + 24;
103
+ blocker = findBlocker(position, node.size.width, height, obstacles, 24);
104
+ }
105
+ return position;
106
+ }