cursedbelt 5.45.0 → 5.45.1

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 (139) hide show
  1. package/dist/react/canvas-hud/CanvasMinimap.d.ts +32 -0
  2. package/dist/react/canvas-hud/CanvasMinimap.d.ts.map +1 -0
  3. package/dist/react/canvas-hud/CanvasMinimap.js +32 -0
  4. package/dist/react/canvas-hud/CanvasMinimap.js.map +1 -0
  5. package/dist/react/canvas-hud/CanvasNodeMenu.d.ts +34 -0
  6. package/dist/react/canvas-hud/CanvasNodeMenu.d.ts.map +1 -0
  7. package/dist/react/canvas-hud/CanvasNodeMenu.js +21 -0
  8. package/dist/react/canvas-hud/CanvasNodeMenu.js.map +1 -0
  9. package/dist/react/canvas-hud/CanvasSearchField.d.ts +27 -0
  10. package/dist/react/canvas-hud/CanvasSearchField.d.ts.map +1 -0
  11. package/dist/react/canvas-hud/CanvasSearchField.js +35 -0
  12. package/dist/react/canvas-hud/CanvasSearchField.js.map +1 -0
  13. package/dist/react/canvas-hud/HudControls.d.ts +43 -0
  14. package/dist/react/canvas-hud/HudControls.d.ts.map +1 -0
  15. package/dist/react/canvas-hud/HudControls.js +22 -0
  16. package/dist/react/canvas-hud/HudControls.js.map +1 -0
  17. package/dist/react/canvas-hud/index.d.ts +13 -0
  18. package/dist/react/canvas-hud/index.d.ts.map +1 -0
  19. package/dist/react/canvas-hud/index.js +7 -0
  20. package/dist/react/canvas-hud/index.js.map +1 -0
  21. package/dist/react/canvas-hud/useCanvasWheel.d.ts +41 -0
  22. package/dist/react/canvas-hud/useCanvasWheel.d.ts.map +1 -0
  23. package/dist/react/canvas-hud/useCanvasWheel.js +65 -0
  24. package/dist/react/canvas-hud/useCanvasWheel.js.map +1 -0
  25. package/dist/react/canvas-hud/viewport.d.ts +68 -0
  26. package/dist/react/canvas-hud/viewport.d.ts.map +1 -0
  27. package/dist/react/canvas-hud/viewport.js +77 -0
  28. package/dist/react/canvas-hud/viewport.js.map +1 -0
  29. package/dist/react/components/AppShell.d.ts.map +1 -1
  30. package/dist/react/components/AppShell.js +23 -3
  31. package/dist/react/components/AppShell.js.map +1 -1
  32. package/dist/react/components/AsyncPanel.d.ts.map +1 -1
  33. package/dist/react/components/AsyncPanel.js +1 -1
  34. package/dist/react/components/AsyncPanel.js.map +1 -1
  35. package/dist/react/components/Checkbox.d.ts.map +1 -1
  36. package/dist/react/components/Checkbox.js +7 -2
  37. package/dist/react/components/Checkbox.js.map +1 -1
  38. package/dist/react/components/Field.d.ts.map +1 -1
  39. package/dist/react/components/Field.js +4 -1
  40. package/dist/react/components/Field.js.map +1 -1
  41. package/dist/react/components/PageGuideButton.d.ts +1 -1
  42. package/dist/react/components/PageGuideButton.d.ts.map +1 -1
  43. package/dist/react/components/PageGuideButton.js +45 -6
  44. package/dist/react/components/PageGuideButton.js.map +1 -1
  45. package/dist/react/components/PageGuidePopover.d.ts +14 -0
  46. package/dist/react/components/PageGuidePopover.d.ts.map +1 -0
  47. package/dist/react/components/PageGuidePopover.js +25 -0
  48. package/dist/react/components/PageGuidePopover.js.map +1 -0
  49. package/dist/react/components/TagsInput.d.ts.map +1 -1
  50. package/dist/react/components/TagsInput.js +3 -1
  51. package/dist/react/components/TagsInput.js.map +1 -1
  52. package/dist/react/folder-tree/FolderPicker.d.ts.map +1 -1
  53. package/dist/react/folder-tree/FolderPicker.js +5 -1
  54. package/dist/react/folder-tree/FolderPicker.js.map +1 -1
  55. package/dist/react/icons.d.ts +28 -0
  56. package/dist/react/icons.d.ts.map +1 -1
  57. package/dist/react/icons.js +28 -0
  58. package/dist/react/icons.js.map +1 -1
  59. package/dist/react/index.d.ts +1 -1
  60. package/dist/react/index.d.ts.map +1 -1
  61. package/dist/react/index.js +1 -1
  62. package/dist/react/index.js.map +1 -1
  63. package/dist/react/lib/whenIdle.d.ts +7 -0
  64. package/dist/react/lib/whenIdle.d.ts.map +1 -0
  65. package/dist/react/lib/whenIdle.js +14 -0
  66. package/dist/react/lib/whenIdle.js.map +1 -0
  67. package/dist/react/media/DenseGalleryShell.d.ts +7 -1
  68. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  69. package/dist/react/media/DenseGalleryShell.js +3 -3
  70. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  71. package/dist/react/media/denseGallery.css +91 -0
  72. package/dist/react/media-gallery/MediaGallery.d.ts +5 -2
  73. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  74. package/dist/react/media-gallery/MediaGallery.js +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  76. package/dist/styles-areas/auth.css +2 -2
  77. package/dist/styles-areas/calendar.css +5 -6
  78. package/dist/styles-areas/canvas-hud.css +30 -0
  79. package/dist/styles-areas/core.css +5 -5
  80. package/dist/styles-areas/folder-tree.css +6 -6
  81. package/dist/styles-areas/layout-engine.css +4 -4
  82. package/dist/styles-areas/media-gallery.css +7 -6
  83. package/dist/styles-areas/nav.css +3 -3
  84. package/dist/styles-areas/palette.css +1 -1
  85. package/dist/styles-areas/rich-text.css +2 -2
  86. package/dist/styles-areas/sharing.css +4 -4
  87. package/dist/styles-areas/test-report.css +2 -2
  88. package/dist/styles-utilities.css +24 -3
  89. package/package.json +8 -1
  90. package/scripts/generateAreaStyles.ts +3 -1
  91. package/scripts/styleAreas.ts +88 -6
  92. package/src/bundlerOnlyEntries.spec.ts +25 -8
  93. package/src/react/canvas-hud/CanvasMinimap.tsx +88 -0
  94. package/src/react/canvas-hud/CanvasNodeMenu.tsx +91 -0
  95. package/src/react/canvas-hud/CanvasSearchField.tsx +91 -0
  96. package/src/react/canvas-hud/HudControls.spec.tsx +160 -0
  97. package/src/react/canvas-hud/HudControls.tsx +128 -0
  98. package/src/react/canvas-hud/index.ts +33 -0
  99. package/src/react/canvas-hud/useCanvasWheel.ts +99 -0
  100. package/src/react/canvas-hud/viewport.spec.ts +105 -0
  101. package/src/react/canvas-hud/viewport.ts +124 -0
  102. package/src/react/components/AppShell.tsx +23 -4
  103. package/src/react/components/AsyncPanel.spec.tsx +10 -0
  104. package/src/react/components/AsyncPanel.tsx +3 -1
  105. package/src/react/components/Checkbox.spec.tsx +18 -0
  106. package/src/react/components/Checkbox.tsx +8 -5
  107. package/src/react/components/Field.spec.tsx +3 -2
  108. package/src/react/components/Field.tsx +7 -4
  109. package/src/react/components/Field.width.spec.tsx +3 -3
  110. package/src/react/components/PageGuideButton.spec.tsx +39 -0
  111. package/src/react/components/PageGuideButton.tsx +91 -23
  112. package/src/react/components/PageGuidePopover.tsx +52 -0
  113. package/src/react/components/TagsInput.spec.tsx +8 -0
  114. package/src/react/components/TagsInput.tsx +3 -1
  115. package/src/react/components/entryChunks.graph.spec.ts +89 -0
  116. package/src/react/folder-tree/FolderPicker.spec.tsx +8 -0
  117. package/src/react/folder-tree/FolderPicker.tsx +4 -1
  118. package/src/react/icons.spec.ts +21 -1
  119. package/src/react/icons.tsx +135 -1
  120. package/src/react/index.ts +10 -0
  121. package/src/react/lib/whenIdle.ts +13 -0
  122. package/src/react/media/DenseGalleryShell.tsx +20 -4
  123. package/src/react/media/denseGallery.css +91 -0
  124. package/src/react/media-gallery/MediaGallery.spec.tsx +26 -0
  125. package/src/react/media-gallery/MediaGallery.tsx +10 -3
  126. package/src/styles-areas/auth.css +2 -2
  127. package/src/styles-areas/calendar.css +5 -6
  128. package/src/styles-areas/canvas-hud.css +30 -0
  129. package/src/styles-areas/core.css +5 -5
  130. package/src/styles-areas/folder-tree.css +6 -6
  131. package/src/styles-areas/layout-engine.css +4 -4
  132. package/src/styles-areas/media-gallery.css +7 -6
  133. package/src/styles-areas/nav.css +3 -3
  134. package/src/styles-areas/palette.css +1 -1
  135. package/src/styles-areas/rich-text.css +2 -2
  136. package/src/styles-areas/sharing.css +4 -4
  137. package/src/styles-areas/test-report.css +2 -2
  138. package/src/styles-utilities.css +24 -3
  139. package/src/stylesAreas.spec.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.45.0",
3
+ "version": "5.45.1",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -141,6 +141,12 @@
141
141
  "source": "./src/react/components/Button.tsx",
142
142
  "import": "./dist/react/components/Button.js"
143
143
  },
144
+ "./react/canvas-hud": {
145
+ "types": "./dist/react/canvas-hud/index.d.ts",
146
+ "bun": "./src/react/canvas-hud/index.ts",
147
+ "source": "./src/react/canvas-hud/index.ts",
148
+ "import": "./dist/react/canvas-hud/index.js"
149
+ },
144
150
  "./react/charts": {
145
151
  "types": "./dist/react/charts/index.d.ts",
146
152
  "bun": "./src/react/charts/index.ts",
@@ -537,6 +543,7 @@
537
543
  "./styles-areas/auth.css": "./src/styles-areas/auth.css",
538
544
  "./styles-areas/calendar.css": "./src/styles-areas/calendar.css",
539
545
  "./styles-areas/chart-table.css": "./src/styles-areas/chart-table.css",
546
+ "./styles-areas/canvas-hud.css": "./src/styles-areas/canvas-hud.css",
540
547
  "./styles-areas/charts.css": "./src/styles-areas/charts.css",
541
548
  "./styles-areas/chat.css": "./src/styles-areas/chat.css",
542
549
  "./styles-areas/clipboard.css": "./src/styles-areas/clipboard.css",
@@ -91,8 +91,10 @@ import {
91
91
  type Area,
92
92
  CORE,
93
93
  areaClosures,
94
+ buildLazyGraph,
94
95
  candidatesIn,
95
96
  coverageFault,
97
+ lazyImportFault,
96
98
  transitiveNeeds,
97
99
  } from './styleAreas';
98
100
 
@@ -274,7 +276,7 @@ export const buildAreaStyles = async (): Promise<BuiltArea[]> => {
274
276
  );
275
277
  }
276
278
  const { closures } = await areaClosures();
277
- const fault = coverageFault(closures);
279
+ const fault = coverageFault(closures) ?? lazyImportFault(closures, await buildLazyGraph());
278
280
  if (fault) throw new Error(fault);
279
281
 
280
282
  const coreClosure = closures.get(CORE) as Set<string>;
@@ -179,6 +179,7 @@ export const AREAS: readonly Area[] = [
179
179
  'components/Modal.tsx',
180
180
  'components/OnlineDot.tsx',
181
181
  'components/Page.tsx',
182
+ 'components/PageGuidePopover.tsx',
182
183
  'components/PanelSeparator.tsx',
183
184
  'components/PathText.tsx',
184
185
  'components/Popover.tsx',
@@ -220,6 +221,7 @@ export const AREAS: readonly Area[] = [
220
221
  roots: ['components/auth', 'AuthGate.tsx'],
221
222
  },
222
223
  { name: 'calendar', why: 'the date-picker family: a calendar grid is a whole layout nothing else shares', roots: ['components/calendar'] },
224
+ { name: 'canvas-hud', why: 'the pan-and-zoom canvas HUD — zoom keys, minimap, find pill, node menu', roots: ['canvas-hud'] },
223
225
  { name: 'charts', why: 'the recharts wrappers — an app with no dashboard renders none of them', roots: ['charts'] },
224
226
  {
225
227
  name: 'chart-table',
@@ -274,7 +276,12 @@ export const AREAS: readonly Area[] = [
274
276
  },
275
277
  { name: 'file-tree', why: 'the flat (non-nested) file list and its row chrome', roots: ['file-tree'] },
276
278
  { name: 'filter-rail', why: 'the faceted filter rail', roots: ['filter-rail'] },
277
- { name: 'folder-tree', why: 'the nested folder tree', roots: ['folder-tree'] },
279
+ {
280
+ name: 'folder-tree',
281
+ why: 'the nested folder tree',
282
+ // The breadcrumb's menus and pivot are `import()`ed by `nav/BreadcrumbTrail.tsx` (lazyImportFault).
283
+ roots: ['folder-tree', 'nav/BreadcrumbMenus.tsx', 'nav/BreadcrumbPivot.tsx'],
284
+ },
278
285
  { name: 'keep-awake', why: 'the keep-awake toggle and its badge', roots: ['keep-awake'] },
279
286
  { name: 'layout-engine', why: 'the persisted pane/grid layout engine — apps with a fixed layout render none of it', roots: ['layoutEngine'] },
280
287
  { name: 'markdown', why: 'the rendered-markdown document', roots: ['markdown', 'markdown/MarkdownDoc.tsx'] },
@@ -292,12 +299,33 @@ export const AREAS: readonly Area[] = [
292
299
  // two entries up: the gallery reaches it through `lazyWithReload(() => import(…))`, and the
293
300
  // closure walk reads static specifiers only — so a lazily-imported dialog is an orphan
294
301
  // unless it is its own root. The coverage gate is what says so, by name, at build time.
295
- roots: ['media-gallery', 'media-gallery/MediaMetaEditor.tsx', 'components/Lightbox.tsx'],
302
+ // The viewers it opens are `import()`ed too, so they are named (lazyImportFault): a gallery
303
+ // app that does not import `media`, `pdf-viewer` or `workbook-viewer` still opens them styled.
304
+ roots: [
305
+ 'media-gallery',
306
+ 'media-gallery/MediaMetaEditor.tsx',
307
+ 'components/Lightbox.tsx',
308
+ 'markdown/MarkdownDoc.tsx',
309
+ 'media/FrameGrabber.tsx',
310
+ 'media/VideoChapterEditor.tsx',
311
+ 'media/VideoCutEditor.tsx',
312
+ 'media/VideoPlayer.tsx',
313
+ 'pdf-viewer/PdfViewer.tsx',
314
+ 'workbook-viewer/WorkbookViewer.tsx',
315
+ ],
296
316
  },
297
317
  {
298
318
  name: 'nav',
299
319
  why: 'the AppShell chrome — the command palette, the nav rail, the place bar and the app menus',
300
- roots: ['nav', 'nav/BreadcrumbMenus.tsx', 'nav/SideNavCustomize.tsx', 'components/AppShell.tsx'],
320
+ // `palette/CommandPalette.tsx` is named for media-gallery's reason above: AppShell reaches its
321
+ // ⌘K palette through `import()` since forge task 2533, and the walk follows static imports only.
322
+ roots: [
323
+ 'nav',
324
+ 'nav/BreadcrumbMenus.tsx',
325
+ 'nav/SideNavCustomize.tsx',
326
+ 'components/AppShell.tsx',
327
+ 'palette/CommandPalette.tsx',
328
+ ],
301
329
  },
302
330
  { name: 'notifications', why: 'the notification centre and its feed', roots: ['notifications'] },
303
331
  {
@@ -312,7 +340,7 @@ export const AREAS: readonly Area[] = [
312
340
  'components/AddItemsMenu.tsx',
313
341
  ],
314
342
  },
315
- { name: 'palette', why: 'the theme/colour palette editor', roots: ['palette'] },
343
+ { name: 'palette', why: 'the ⌘K command palette and its hotkey/source hooks', roots: ['palette'] },
316
344
  { name: 'pdf-viewer', why: 'the lazy PDF viewer (an optional peer already)', roots: ['pdf-viewer'] },
317
345
  { name: 'problem-detail', why: 'the RFC-7807 problem renderer', roots: ['problem-detail'] },
318
346
  { name: 'rich-text', why: 'the TipTap editor and its toolbar', roots: ['rich-text'] },
@@ -412,8 +440,8 @@ export const nonModuleFiles = (): string[] => {
412
440
  };
413
441
 
414
442
  /**
415
- * Relative import specifiers in a module — `from '…'`, bare `import '…'` and
416
- * `import('…')` alike.
443
+ * Relative import specifiers in a module — `from '…'` and bare `import '…'`. NOT `import('…')`:
444
+ * a lazily-imported module is an orphan unless an area names it as a root (see `media-gallery`).
417
445
  *
418
446
  * A regex and not the TypeScript AST on purpose: this runs over 700 modules on
419
447
  * every build, the only thing it has to understand is a string literal after a
@@ -537,6 +565,60 @@ export const coverageFault = (closures: Map<string, Set<string>>, areas: readonl
537
565
  );
538
566
  };
539
567
 
568
+ /** `import('./x')` specifiers — the edges the static walk above deliberately does not follow. */
569
+ const DYNAMIC_SPECIFIER = /\bimport\s*\(\s*['"](\.[^'"]*)['"]/g;
570
+
571
+ /** Module → the modules it `import()`s lazily, inside `src/react`. */
572
+ export const buildLazyGraph = async (): Promise<Map<string, string[]>> => {
573
+ const graph = new Map<string, string[]>();
574
+ await Promise.all(
575
+ allModules().map(async (file) => {
576
+ const out = new Set<string>();
577
+ for (const match of (await Bun.file(file).text()).matchAll(DYNAMIC_SPECIFIER)) {
578
+ const target = resolveRelative(file, match[1] as string);
579
+ if (target?.startsWith(`${REACT_DIR}/`) && !SPEC.test(target)) out.add(target);
580
+ }
581
+ if (out.size > 0) graph.set(file, [...out]);
582
+ }),
583
+ );
584
+ return graph;
585
+ };
586
+
587
+ /**
588
+ * 🔴 A module an area reaches LAZILY is styled by that area too (forge task 2533).
589
+ *
590
+ * The walk follows static imports only, so when a component moves a body behind `import()` —
591
+ * AppShell's ⌘K palette, to take it off every app's entry chunk — that body leaves the area's
592
+ * closure. `coverageFault` stays quiet as long as SOME area still reaches it, and the area that
593
+ * renders it regenerates without its utilities: an app that imports `nav` but not `palette`
594
+ * would open an unstyled palette. Every lazy target must be in the importing area's own closure,
595
+ * in `core`, or in an area it `needs` — usually by naming it as a root, as `media-gallery` does.
596
+ */
597
+ export const lazyImportFault = (
598
+ closures: Map<string, Set<string>>,
599
+ lazyGraph: Map<string, string[]>,
600
+ areas: readonly Area[] = AREAS,
601
+ ): string | null => {
602
+ const faults: string[] = [];
603
+ const rel = (f: string) => f.replace(`${REACT_DIR}/`, '');
604
+ for (const area of areas) {
605
+ const own = closures.get(area.name) ?? new Set<string>();
606
+ const styled = new Set(own);
607
+ for (const name of [CORE, ...transitiveNeeds(area.name, areas)]) for (const f of closures.get(name) ?? []) styled.add(f);
608
+ for (const file of own) {
609
+ for (const target of lazyGraph.get(file) ?? []) {
610
+ if (!styled.has(target)) faults.push(`${area.name}: ${rel(file)} → import('${rel(target)}')`);
611
+ }
612
+ }
613
+ }
614
+ if (faults.length === 0) return null;
615
+ return (
616
+ `${faults.length} lazy import(s) leave their area, so the module they load is unstyled in an app that\n` +
617
+ "imports that area alone. Name each target in the area's `roots` in scripts/styleAreas.ts:\n " +
618
+ faults.sort().join('\n ')
619
+ );
620
+ };
621
+
540
622
  /**
541
623
  * 🔴 `needs` names areas that exist, never itself, and never a cycle.
542
624
  *
@@ -38,12 +38,9 @@ const isDirectoryImport = (specifier: string): boolean => {
38
38
 
39
39
  const SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
40
40
 
41
- async function measure(): Promise<Record<Reason, string[]>> {
42
- const entries = Object.entries(pkg.exports as Record<string, unknown>).flatMap(([subpath, target]) => {
43
- const source = (target as { source?: string }).source;
44
- return source && /\.tsx?$/.test(source) ? [[subpath, source.replace(/^\.\//, '')] as const] : [];
45
- });
46
- const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-bundler-only-${process.pid}`;
41
+ /** One `Bun.build` over `entries`: which of them import a stylesheet, and which reach a directory import. */
42
+ async function bundle(entries: readonly (readonly [string, string])[], pass: number): Promise<Record<Reason, string[]>> {
43
+ const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-bundler-only-${process.pid}-${pass}`;
47
44
  try {
48
45
  const result = await Bun.build({
49
46
  entrypoints: entries.map(([, source]) => `${REPO}${source}`),
@@ -62,14 +59,34 @@ async function measure(): Promise<Record<Reason, string[]>> {
62
59
  if (emitted.has(js.replace(/\.js$/, '.css')) || specifiers.some((s) => s.endsWith('.css'))) out.css.push(subpath);
63
60
  if (specifiers.some((s) => !s.startsWith('.') && isDirectoryImport(s))) out.directoryImport.push(subpath);
64
61
  }
65
- out.css.sort();
66
- out.directoryImport.sort();
67
62
  return out;
68
63
  } finally {
69
64
  rmSync(outdir, { recursive: true, force: true });
70
65
  }
71
66
  }
72
67
 
68
+ async function measure(): Promise<Record<Reason, string[]>> {
69
+ const entries = Object.entries(pkg.exports as Record<string, unknown>).flatMap(([subpath, target]) => {
70
+ const source = (target as { source?: string }).source;
71
+ return source && /\.tsx?$/.test(source) ? [[subpath, source.replace(/^\.\//, '')] as const] : [];
72
+ });
73
+ const first = await bundle(entries, 0);
74
+ const css = new Set(first.css);
75
+ /*
76
+ * 🔴 One build emits a stylesheet that several entries import ONCE, beside the first of them
77
+ * (`canvas-hud` and `search-input` both import `searchInput.css`; only one got the sibling, so
78
+ * `search-input` read as stale — task 2401). So build again without the entries already
79
+ * counted, until a pass finds no new one: each pass hands a shared sheet to the next importer.
80
+ */
81
+ for (let pass = 1; ; pass++) {
82
+ const rest = entries.filter(([subpath]) => !css.has(subpath));
83
+ const found = (await bundle(rest, pass)).css;
84
+ if (found.length === 0) break;
85
+ for (const subpath of found) css.add(subpath);
86
+ }
87
+ return { css: [...css].sort(), directoryImport: [...first.directoryImport].sort() };
88
+ }
89
+
73
90
  describe('bundlerOnly', () => {
74
91
  it('declares exactly the subpaths that import a stylesheet, and exactly those that reach a directory import', async () => {
75
92
  const measured = await measure();
@@ -0,0 +1,88 @@
1
+ import type { MouseEvent, ReactNode } from 'react';
2
+ import { cn } from '../lib/cn.js';
3
+ import { type CanvasBounds, type CanvasSize, type CanvasView, visibleBounds } from './viewport.js';
4
+
5
+ export interface CanvasMinimapProps {
6
+ /** The content's extent in WORLD units — what the thumbnail shows. */
7
+ bounds: CanvasBounds;
8
+ view: CanvasView;
9
+ /** The canvas viewport in screen pixels, for the visible window. */
10
+ viewport: CanvasSize;
11
+ /** A click on the thumbnail, in WORLD units — the canvas centres there. */
12
+ onJump?: (point: { x: number; y: number }) => void;
13
+ /** The thumbnail's longest side in pixels. Default 168. */
14
+ size?: number;
15
+ /** Drawn inside the SVG in WORLD units, under the window: dots, zones, edges. */
16
+ children?: ReactNode;
17
+ /** A one-line caption under the map. Default "Tap to jump" when `onJump` is set. */
18
+ hint?: ReactNode;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * CNV-3's minimap — the whole content as a low-opacity thumbnail with the visible window drawn
24
+ * on it. What a canvas draws on it (a dot per node, a heat zone per cluster) is `children` in its
25
+ * own world coordinates; the frame, the scaling and the window are this component's.
26
+ *
27
+ * The SVG is `aria-hidden`: the jump is a pointer shortcut over content that is already
28
+ * reachable by keyboard on the canvas itself, and a parallel interface over unlabeled dots would
29
+ * be a second, worse way to do what Tab already does (family's reasoning, kept).
30
+ */
31
+ export function CanvasMinimap({
32
+ bounds,
33
+ view,
34
+ viewport,
35
+ onJump,
36
+ size = 168,
37
+ children,
38
+ hint,
39
+ className,
40
+ }: CanvasMinimapProps) {
41
+ if (bounds.width <= 0 || bounds.height <= 0 || viewport.width <= 0 || view.scale <= 0) return null;
42
+ const ratio = Math.min(size / bounds.width, (size * 0.62) / bounds.height);
43
+ const width = Math.max(1, bounds.width * ratio);
44
+ const height = Math.max(1, bounds.height * ratio);
45
+ const seen = visibleBounds(view, viewport);
46
+ const jump = (event: MouseEvent<SVGSVGElement>) => {
47
+ if (!onJump) return;
48
+ const rect = event.currentTarget.getBoundingClientRect();
49
+ onJump({
50
+ x: bounds.x + (event.clientX - rect.left) / ratio,
51
+ y: bounds.y + (event.clientY - rect.top) / ratio,
52
+ });
53
+ };
54
+ const caption = hint === undefined ? (onJump ? 'Tap to jump' : null) : hint;
55
+ return (
56
+ <div
57
+ data-testid='canvas-minimap'
58
+ className={cn(
59
+ 'pointer-events-auto flex flex-col items-center gap-1 rounded-lg border border-border/60 bg-background/70 p-2 shadow-raised backdrop-blur-md',
60
+ className,
61
+ )}
62
+ >
63
+ <svg
64
+ width={width}
65
+ height={height}
66
+ viewBox={`${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`}
67
+ onClick={jump}
68
+ aria-hidden='true'
69
+ className={cn('overflow-hidden', onJump && 'cursor-pointer')}
70
+ >
71
+ {children}
72
+ <rect
73
+ data-testid='canvas-minimap-window'
74
+ x={seen.x}
75
+ y={seen.y}
76
+ width={seen.width}
77
+ height={seen.height}
78
+ fill='var(--accent)'
79
+ fillOpacity={0.12}
80
+ stroke='var(--accent)'
81
+ strokeWidth={1.5 / ratio}
82
+ rx={3 / ratio}
83
+ />
84
+ </svg>
85
+ {caption ? <span className='text-caption text-muted-foreground'>{caption}</span> : null}
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,91 @@
1
+ import type { PointerEvent, ReactNode } from 'react';
2
+ import { Button } from '../components/Button.js';
3
+ import { Popover } from '../components/Popover.js';
4
+ import { cn } from '../lib/cn.js';
5
+
6
+ export interface CanvasNodeMenuItem {
7
+ label: string;
8
+ onSelect: () => void;
9
+ /** Drawn disabled. The section's `note` is where the reason goes. */
10
+ disabled?: boolean;
11
+ destructive?: boolean;
12
+ }
13
+
14
+ export interface CanvasNodeMenuSection {
15
+ heading?: string;
16
+ items: CanvasNodeMenuItem[];
17
+ /** One quiet line under the rows: what they change, or why they cannot. */
18
+ note?: ReactNode;
19
+ }
20
+
21
+ export interface CanvasNodeMenuProps {
22
+ trigger: ReactNode;
23
+ open: boolean;
24
+ onOpenChange: (open: boolean) => void;
25
+ sections: CanvasNodeMenuSection[];
26
+ align?: 'start' | 'center' | 'end';
27
+ side?: 'top' | 'right' | 'bottom' | 'left';
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * A node's menu on a canvas — a `Popover`, deliberately not a `DropdownMenu`.
33
+ *
34
+ * A menu takes the arrow keys for its own rows; on a canvas they belong to the CANVAS, which
35
+ * moves its cursor to the next node and closes this (family's branch menu, the owner's ask of
36
+ * 2026-09-24: *"I want it to disappear if I click off of it or use arrows to move to another
37
+ * person"*). So the rows are Tab-reachable buttons, the keys bubble, and an outside press or
38
+ * Escape closes it. Pointer-downs stop here so pressing a row never starts a pan.
39
+ */
40
+ export function CanvasNodeMenu({
41
+ trigger,
42
+ open,
43
+ onOpenChange,
44
+ sections,
45
+ align = 'start',
46
+ side = 'bottom',
47
+ className,
48
+ }: CanvasNodeMenuProps) {
49
+ const stop = (event: PointerEvent) => event.stopPropagation();
50
+ return (
51
+ <Popover
52
+ open={open}
53
+ onOpenChange={onOpenChange}
54
+ align={align}
55
+ side={side}
56
+ className={cn('w-64 p-1', className)}
57
+ trigger={trigger}
58
+ >
59
+ <div role='menu' onPointerDown={stop} className='flex flex-col'>
60
+ {sections.map((section, sectionIndex) => (
61
+ <div
62
+ // biome-ignore lint/suspicious/noArrayIndexKey: sections are a static list in the caller's order
63
+ key={sectionIndex}
64
+ className={cn('flex flex-col py-1', sectionIndex > 0 && 'border-t border-border')}
65
+ >
66
+ {section.heading ? <p className='cb-meta-label px-2 pt-1 pb-1.5'>{section.heading}</p> : null}
67
+ {section.items.map((item) => (
68
+ <Button
69
+ key={item.label}
70
+ role='menuitem'
71
+ variant={item.destructive ? 'destructive' : 'ghost'}
72
+ size='sm'
73
+ tooltip={item.label}
74
+ tooltipMobile='off'
75
+ disabled={item.disabled}
76
+ className='w-full justify-start'
77
+ onClick={(event) => {
78
+ event.stopPropagation();
79
+ item.onSelect();
80
+ }}
81
+ >
82
+ {item.label}
83
+ </Button>
84
+ ))}
85
+ {section.note ? <p className='px-2 pt-1 text-caption text-muted-foreground'>{section.note}</p> : null}
86
+ </div>
87
+ ))}
88
+ </div>
89
+ </Popover>
90
+ );
91
+ }
@@ -0,0 +1,91 @@
1
+ import type { KeyboardEvent } from 'react';
2
+ import { HudPill } from '../components/HudPill.js';
3
+ import { Input } from '../components/Input.js';
4
+ import { IconSearch } from '../icons.js';
5
+ import { cn } from '../lib/cn.js';
6
+ import { HudButton } from './HudControls.js';
7
+
8
+ export interface CanvasSearchFieldProps {
9
+ value: string;
10
+ onChange: (value: string) => void;
11
+ /** Step to the next (1) or previous (-1) match — Enter and Shift+Enter, and the ‹ › keys. */
12
+ onStep?: (delta: 1 | -1) => void;
13
+ /** How many matches the query has, and which one is current (0-based). */
14
+ matches?: number;
15
+ index?: number;
16
+ placeholder?: string;
17
+ 'aria-label'?: string;
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * The find box that floats over a canvas, and the one that does not steal the canvas's keys.
23
+ *
24
+ * 🔴 A canvas binds the arrow keys on an ancestor of this field. Without stopping them here,
25
+ * typing a name and pressing ← to fix a typo walked the reader to a cousin instead of moving
26
+ * the caret (family, 2026-09). Every key stops at the field; Enter and Shift+Enter step through
27
+ * the matches, the convention of every find-in-page; Escape empties the box rather than blurring.
28
+ *
29
+ * It is the belt's `Input` drawn borderless, so the pill around it is the field — and NOT
30
+ * `SearchInput`: a find box over a canvas matches names, never wildcards or regexes, and the
31
+ * syntax parser and its badge were 5.6 kB gzip on family's first paint for nothing (task 2401).
32
+ * A ✕ empties it too, because a phone has no Escape key.
33
+ */
34
+ export function CanvasSearchField({
35
+ value,
36
+ onChange,
37
+ onStep,
38
+ matches = 0,
39
+ index = 0,
40
+ placeholder = 'Find…',
41
+ 'aria-label': ariaLabel,
42
+ className,
43
+ }: CanvasSearchFieldProps) {
44
+ const searching = value.trim().length > 0;
45
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
46
+ if (event.key === 'Enter' && onStep) {
47
+ event.preventDefault();
48
+ onStep(event.shiftKey ? -1 : 1);
49
+ }
50
+ if (event.key === 'Escape' && value !== '') {
51
+ event.preventDefault();
52
+ onChange('');
53
+ }
54
+ event.stopPropagation();
55
+ };
56
+ return (
57
+ <HudPill className={cn('gap-0.5 rounded-lg py-0.5 pr-1 pl-0', className)}>
58
+ <IconSearch size={16} className='ml-2 shrink-0 text-muted-foreground' />
59
+ <Input
60
+ type='search'
61
+ size='sm'
62
+ value={value}
63
+ onChange={(event) => onChange(event.target.value)}
64
+ placeholder={placeholder}
65
+ aria-label={ariaLabel ?? placeholder}
66
+ onKeyDown={onKeyDown}
67
+ className='w-40 border-transparent bg-transparent pl-1 shadow-none focus-visible:border-transparent focus-visible:ring-0 [&::-webkit-search-cancel-button]:hidden'
68
+ />
69
+ {searching ? (
70
+ <span className='min-w-10 text-center text-xs text-muted-foreground tabular-nums' aria-live='polite'>
71
+ {matches === 0 ? 'none' : `${index + 1}/${matches}`}
72
+ </span>
73
+ ) : null}
74
+ {onStep && matches > 1 ? (
75
+ <>
76
+ <HudButton label='Previous match — Shift+Enter' onClick={() => onStep(-1)}>
77
+ <span aria-hidden>‹</span>
78
+ </HudButton>
79
+ <HudButton label='Next match — Enter' onClick={() => onStep(1)}>
80
+ <span aria-hidden>›</span>
81
+ </HudButton>
82
+ </>
83
+ ) : null}
84
+ {searching ? (
85
+ <HudButton label='Clear the search — Escape' onClick={() => onChange('')}>
86
+ <span aria-hidden>×</span>
87
+ </HudButton>
88
+ ) : null}
89
+ </HudPill>
90
+ );
91
+ }