nebulosity 1.0.0 → 1.1.0

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 (180) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +11 -11
  3. package/.next/diagnostics/route-bundle-stats.json +10 -10
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +5 -5
  6. package/.next/server/app/_global-error/page/build-manifest.json +3 -3
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page/build-manifest.json +3 -3
  14. package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
  15. package/.next/server/app/_not-found/page.js +3 -2
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +27 -20
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
  21. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/server/app/index.html +1 -1
  23. package/.next/server/app/index.rsc +29 -23
  24. package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
  25. package/.next/server/app/index.segments/_full.segment.rsc +29 -23
  26. package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
  27. package/.next/server/app/page/build-manifest.json +3 -3
  28. package/.next/server/app/page/next-font-manifest.json +2 -3
  29. package/.next/server/app/page.js +4 -4
  30. package/.next/server/app/page_client-reference-manifest.js +1 -1
  31. package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
  32. package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
  33. package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
  34. package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
  35. package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
  36. package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
  37. package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
  38. package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
  39. package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
  40. package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
  41. package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
  42. package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
  43. package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
  44. package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
  45. package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
  46. package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
  47. package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
  48. package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
  49. package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
  50. package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
  51. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
  52. package/.next/server/middleware-build-manifest.js +11 -11
  53. package/.next/server/next-font-manifest.js +1 -1
  54. package/.next/server/next-font-manifest.json +4 -6
  55. package/.next/server/pages/404.html +1 -1
  56. package/.next/server/pages/500.html +1 -1
  57. package/.next/server/preview-props.json +3 -3
  58. package/.next/server/server-reference-manifest.js +1 -1
  59. package/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
  61. package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
  62. package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
  63. package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
  64. package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
  65. package/.next/static/chunks/3w00e1w2125pp.js +11 -0
  66. package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
  67. package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
  68. package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
  69. package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
  70. package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
  71. package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
  72. package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
  73. package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
  74. package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
  75. package/.next/trace +1 -1
  76. package/.next/trace-build +1 -1
  77. package/README.md +13 -4
  78. package/app/globals.css +91 -54
  79. package/app/layout.tsx +20 -11
  80. package/app/map/BackButton.tsx +5 -5
  81. package/app/map/BoxActions.tsx +20 -12
  82. package/app/map/Breadcrumb.tsx +132 -46
  83. package/app/map/DetailPanel.tsx +408 -297
  84. package/app/map/Explorer.tsx +268 -0
  85. package/app/map/Header.tsx +105 -0
  86. package/app/map/HelpDialog.tsx +37 -34
  87. package/app/map/Legend.tsx +19 -75
  88. package/app/map/LineCard.tsx +187 -0
  89. package/app/map/MapBox.tsx +73 -38
  90. package/app/map/MapCanvas.tsx +344 -115
  91. package/app/map/MapToolbar.tsx +377 -0
  92. package/app/map/MapView.tsx +217 -213
  93. package/app/map/RepoMenu.tsx +13 -12
  94. package/app/map/RoutedEdge.tsx +48 -16
  95. package/app/map/SearchPalette.tsx +6 -6
  96. package/app/map/SourceViewer.tsx +19 -17
  97. package/app/map/StatusBar.tsx +166 -0
  98. package/app/map/embed.ts +22 -2
  99. package/app/map/emphasis.ts +94 -8
  100. package/app/map/explorerRows.ts +85 -0
  101. package/app/map/flowNodes.ts +12 -7
  102. package/app/map/format.ts +2 -2
  103. package/app/map/history.ts +4 -3
  104. package/app/map/inspector.ts +113 -0
  105. package/app/map/labels.ts +66 -7
  106. package/app/map/narrow.ts +165 -85
  107. package/app/map/opening.ts +16 -0
  108. package/app/map/portals.ts +2 -2
  109. package/app/map/routes.ts +1060 -55
  110. package/app/map/runs.ts +108 -0
  111. package/app/map/shell.ts +91 -0
  112. package/app/map/shortcuts.ts +2 -1
  113. package/app/map/summaries.ts +41 -5
  114. package/app/map/text.ts +9 -1
  115. package/app/map/theme.ts +197 -0
  116. package/app/map/useDismiss.ts +20 -6
  117. package/app/map/useEditorBridge.ts +12 -1
  118. package/app/map/useThemeKind.ts +28 -0
  119. package/app/map/useWindowSize.ts +37 -0
  120. package/app/map/viewport.ts +134 -16
  121. package/app/map/weights.ts +80 -0
  122. package/app/page.tsx +3 -5
  123. package/lib/background/child.ts +2 -0
  124. package/lib/background/service.ts +26 -10
  125. package/lib/contracts.ts +25 -2
  126. package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
  127. package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
  128. package/lib/db/migrations/meta/_journal.json +7 -0
  129. package/lib/db/schema.ts +2 -1
  130. package/lib/db/store/map.ts +6 -0
  131. package/lib/db/store/summaries.ts +14 -8
  132. package/lib/engine/describe.ts +2 -0
  133. package/lib/index/summarize/budget.ts +5 -2
  134. package/lib/index/summarize/claude.ts +19 -4
  135. package/lib/index/summarize/compare.ts +2 -2
  136. package/lib/index/summarize/plan.ts +73 -13
  137. package/lib/index/summarize/preview.ts +12 -9
  138. package/lib/index/summarize/prompts.ts +122 -25
  139. package/lib/index/summarize/run.ts +48 -18
  140. package/lib/index/summarize/snapshot.ts +7 -2
  141. package/lib/index/summarize/status.ts +3 -2
  142. package/lib/tools/text.ts +3 -2
  143. package/mcp/server.ts +1 -1
  144. package/package.json +4 -2
  145. package/scripts/commands/summarize-command.ts +27 -9
  146. package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
  147. package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
  148. package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
  149. package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
  150. package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
  151. package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
  152. package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
  153. package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
  154. package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
  155. package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
  156. package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
  157. package/.next/static/chunks/10u7p2gj02qay.js +0 -1
  158. package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
  159. package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
  160. package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
  161. package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
  162. package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
  163. package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
  164. package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
  165. package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
  166. package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
  167. package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
  168. package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
  169. package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
  170. package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
  171. package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
  172. package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
  173. package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
  174. package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
  175. package/app/map/ActivityBar.tsx +0 -81
  176. package/app/map/StatusPill.tsx +0 -93
  177. package/app/map/TopBar.tsx +0 -84
  178. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
  179. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
  180. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_ssgManifest.js +0 -0
@@ -0,0 +1,377 @@
1
+ "use client";
2
+
3
+ import { useCallback, useId, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
4
+ import { useReactFlow, useStoreApi } from "@xyflow/react";
5
+ import { IconCheck, IconChevronLeft, IconChevronRight, IconDots } from "@tabler/icons-react";
6
+ import type { LevelResponse } from "@/lib/contracts";
7
+ import { formatCount } from "./format";
8
+ import { heaviestLink } from "./labels";
9
+ import type { BoxNode } from "./MapBox";
10
+ import { placePopover, usePlaceOnResize } from "./popover";
11
+ import { pageNote } from "./portals";
12
+ import type { RoutedEdgeType } from "./RoutedEdge";
13
+ import { SIDE_COLOR, weakLinesHidden, type LineDirection } from "./emphasis";
14
+ import { useDismiss } from "./useDismiss";
15
+ import { boundsOf, fitViewport, linesBounds } from "./viewport";
16
+ import { arrowHead } from "./weights";
17
+
18
+ type MapToolbarProps = {
19
+ level: LevelResponse;
20
+ strongOnly: boolean;
21
+ onStrongOnly: (on: boolean) => void;
22
+ /** A busy level draws lines on hover only (emphasis.ts), unless all are asked for. */
23
+ busy: boolean;
24
+ showAllLines: boolean;
25
+ onShowAllLines: (all: boolean) => void;
26
+ onTurnPage: (page: number) => void;
27
+ /** The canvas's width, which the toolbar's steps go by (shell.ts). */
28
+ canvasWidth: number;
29
+ /** Folded into the header's row in a short window (shell.ts): only the page control, Fit and the … menu show. */
30
+ inHeader: boolean;
31
+ /** A window under 480 px wide: folded into the header, the toolbar is only its … menu, the page control and Fit in it. */
32
+ narrow: boolean;
33
+ /** Help goes into the … menu where the header has no help button (VS Code's panel, a phone). */
34
+ helpInMenu: boolean;
35
+ onHelp: () => void;
36
+ /** Search goes into the … menu where the header has no room left for its icon (a short window under 360 px). */
37
+ searchInMenu: boolean;
38
+ onSearch: () => void;
39
+ /** A box is selected, so a side of its lines can be picked: All, Uses and Used by are greyed out until one is. */
40
+ hasSelection: boolean;
41
+ /** Which side of the selection's lines is highlighted (emphasis.ts). */
42
+ direction: LineDirection;
43
+ onDirection: (direction: LineDirection) => void;
44
+ /** The sides of the selection with no line on the page, greyed out (emphasis.ts's sidesWithoutLines). */
45
+ emptySides: readonly LineDirection[];
46
+ };
47
+
48
+ const MENU_WIDTH = 220;
49
+ /**
50
+ * The least canvas the side picker keeps its place in the bar at, before it goes into the … menu, and beside a big
51
+ * folder's page control, which with it left the busy note 16 px in VS Code's panel at 700 px (check-layout).
52
+ */
53
+ const SIDES_IN_BAR = 600;
54
+ const SIDES_IN_BAR_PAGED = 760;
55
+
56
+ /**
57
+ * The three sides, in the inspector's own words, each with its title and, for Uses and Used by, the color of the
58
+ * underline its tab gets in the inspector, which is its lines' color on the Map.
59
+ */
60
+ const SIDES: { id: LineDirection; label: string; title: string; mark?: string; dot: string }[] = [
61
+ { id: "all", label: "All", title: "Highlight every line of the selected box", dot: "bg-text" },
62
+ { id: "uses", label: "Uses", title: "Highlight only its lines to what it uses, the rest in gray", mark: SIDE_COLOR.uses.fill, dot: SIDE_COLOR.uses.fill },
63
+ { id: "usedBy", label: "Used by", title: "Highlight only its lines from what uses it, the rest in gray", mark: SIDE_COLOR.usedBy.fill, dot: SIDE_COLOR.usedBy.fill },
64
+ ];
65
+ const NO_SELECTION = "Select a box to choose which of its lines are highlighted";
66
+ const EMPTY_SIDE: Record<LineDirection, string> = {
67
+ all: "",
68
+ uses: "The selected box uses nothing on this page",
69
+ usedBy: "Nothing on this page uses the selected box",
70
+ };
71
+ const BUTTON = "grid h-6 min-w-[26px] shrink-0 place-items-center rounded-[5px] border border-border px-2 text-[12.5px] text-text-2 hover:border-text-3 hover:text-text disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-2";
72
+
73
+ /** The checkbox of Strong links only, drawn in the accent when on. */
74
+ function Check({ on }: { on: boolean }){
75
+ return (
76
+ <span aria-hidden className={`grid h-3.5 w-3.5 shrink-0 place-items-center rounded-[3px] ${on ? "bg-accent text-on-accent" : "border border-border"}`}>
77
+ {on && <IconCheck size={11} stroke={3} />}
78
+ </span>
79
+ );
80
+ }
81
+
82
+ /**
83
+ * The toolbar over the canvas (the Workbench redesign, ADR-070), 36 px. On its left a finding about the level, its
84
+ * heaviest line and how much of the level's imports it carries (on a busy level, that its lines show on hover, with
85
+ * Show all lines). On its right a big folder's page control, All, Uses and Used by for the selected box's lines (the
86
+ * owner's pick A2, 2026-10-29, greyed out until a box is selected so nothing moves on a click), Strong links only, and
87
+ * zoom − + Fit.
88
+ *
89
+ * As the canvas narrows the toolbar gives way in a fixed order and nothing hides off its edge (the owner, 2026-10-27):
90
+ * the finding truncates (its title has the whole of it); All, Uses and Used by move into a … menu at its end under 600
91
+ * (760 beside a big folder's page control);
92
+ * the finding goes under a 500 px canvas; Strong links only moves into the menu under 470, then − and + under 330; the
93
+ * page control says "2 / 5" under 400 and moves into the menu under 260. Over the canvas Fit always stays. In a short
94
+ * window the toolbar folds into the header's row, keeping the page control, Fit and the menu, and in a window under
95
+ * 480 px wide only the menu, which then holds the page control and Fit too (the breadcrumb needs the room), and under
96
+ * 360 the search.
97
+ */
98
+ export default function MapToolbar({ level, strongOnly, onStrongOnly, busy, showAllLines, onShowAllLines, onTurnPage, canvasWidth, inHeader, narrow, helpInMenu, onHelp, searchInMenu, onSearch, hasSelection, direction, onDirection, emptySides }: MapToolbarProps){
99
+ const flow = useReactFlow<BoxNode, RoutedEdgeType>();
100
+ const store = useStoreApi<BoxNode, RoutedEdgeType>();
101
+ // Fit: the whole level with room around it, never past 100% (VISUAL_REFRESH.md §4), and its drawn lines too unless
102
+ // the boxes would shrink past 85% (viewport.ts). React Flow's fitView frames only the boxes, and a lane beside them
103
+ // ran off under the inspector. A hidden line isn't handed to React Flow (MapCanvas), so these are the lines on screen
104
+ const fit = () => {
105
+ const boxes = boundsOf(new Map(flow.getNodes().map((node) => [node.id, node.position])));
106
+ const lines = linesBounds(flow.getEdges().flatMap(({ data }) => data
107
+ ? [{ points: data.points, width: data.width, headHalf: arrowHead(data.headWidth ?? data.width).half }]
108
+ : []));
109
+ const { width, height } = store.getState();
110
+ void flow.setViewport(fitViewport(boxes, lines, { width, height }), { duration: 0 });
111
+ };
112
+ const strongInBar = !inHeader && canvasWidth >= 470;
113
+ const zoomInBar = !inHeader && canvasWidth >= 330;
114
+ const findingShown = !inHeader && canvasWidth >= 500;
115
+ // a big folder's page control, until even "2 / 5" and its arrows leave no room for Fit and the menu
116
+ const paged = level.pageCount > 1;
117
+ const sidesInBar = !inHeader && canvasWidth >= (paged ? SIDES_IN_BAR_PAGED : SIDES_IN_BAR);
118
+ const menuOnly = inHeader && narrow;
119
+ const pageInBar = paged && canvasWidth >= 260 && !menuOnly;
120
+ const finding = heaviestLink(level);
121
+ // on a page where every line carries 2% of the heaviest's imports (any page whose heaviest is under 50), Strong
122
+ // links only hides nothing, so it's greyed out with the reason in its title rather than a box that does nothing
123
+ // (the owner, 2026-10-29, on VS Code's build). Greyed, not gone, so the toolbar keeps its shape from page to page
124
+ const nothingWeak = weakLinesHidden(level.edges, null) === 0;
125
+ const strongTitle = nothingWeak
126
+ ? "Every line on this page carries at least 2% of the heaviest line's imports, so there's nothing to hide"
127
+ : "Draw only the lines with at least 2% of the heaviest line's imports, and every line of the box you select or hover";
128
+
129
+ const menu: MenuEntry[] = [];
130
+ if (searchInMenu) menu.push({ label: "Search files and concepts", act: onSearch });
131
+ if (paged && !pageInBar){
132
+ menu.push(
133
+ { label: `Previous page (${level.page + 1} of ${level.pageCount})`, disabled: level.page === 0, act: () => onTurnPage(level.page - 1) },
134
+ { label: "Next page", disabled: level.page >= level.pageCount - 1, act: () => onTurnPage(level.page + 1) },
135
+ );
136
+ }
137
+ if (menuOnly) menu.push({ label: "Show the whole level", act: fit });
138
+ // folded into the menu, the three sides keep the bar's order and titles, under a heading that says whose lines
139
+ if (!sidesInBar){
140
+ menu.push({
141
+ group: "Lines of the selected box",
142
+ items: SIDES.map((side) => {
143
+ const empty = emptySides.includes(side.id);
144
+ return {
145
+ label: side.label, radio: true, on: direction === side.id, dot: side.dot,
146
+ disabled: !hasSelection || empty,
147
+ title: !hasSelection ? NO_SELECTION : empty ? EMPTY_SIDE[side.id] : side.title,
148
+ act: () => onDirection(side.id),
149
+ };
150
+ }),
151
+ });
152
+ }
153
+ if (!strongInBar) menu.push({ label: "Strong links only", on: strongOnly, disabled: nothingWeak, title: strongTitle, act: () => onStrongOnly(!strongOnly) });
154
+ if (busy && (inHeader || !findingShown)) menu.push({ label: "Show all lines", on: showAllLines, act: () => onShowAllLines(!showAllLines) });
155
+ if (!zoomInBar) menu.push({ label: "Zoom in", act: () => void flow.zoomIn({ duration: 0 }) }, { label: "Zoom out", act: () => void flow.zoomOut({ duration: 0 }) });
156
+ if (helpInMenu) menu.push({ label: "How to use the Map", act: onHelp });
157
+
158
+ const controls: ReactNode = (
159
+ <div className="flex shrink-0 items-center gap-1.5">
160
+ {pageInBar && <PageControl level={level} onTurn={onTurnPage} short={inHeader || canvasWidth < 400} />}
161
+ {sidesInBar && <SidePicker direction={direction} onDirection={onDirection} hasSelection={hasSelection} emptySides={emptySides} />}
162
+ {strongInBar && (
163
+ <label title={strongTitle} className={`mr-1 flex items-center gap-[7px] rounded-[4px] text-[13px] text-text-2 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-focus ${nothingWeak ? "cursor-default opacity-40" : "cursor-pointer hover:text-text"}`}>
164
+ <input type="checkbox" data-testid="strong-only" checked={strongOnly} disabled={nothingWeak} onChange={(e) => onStrongOnly(e.target.checked)} className="sr-only" />
165
+ <Check on={strongOnly} />
166
+ Strong links only
167
+ </label>
168
+ )}
169
+ {zoomInBar && (
170
+ <>
171
+ <button type="button" onClick={() => void flow.zoomOut({ duration: 0 })} aria-label="Zoom out" title="Zoom out" className={BUTTON}>−</button>
172
+ <button type="button" onClick={() => void flow.zoomIn({ duration: 0 })} aria-label="Zoom in" title="Zoom in" className={BUTTON}>+</button>
173
+ </>
174
+ )}
175
+ {!menuOnly && <button type="button" data-testid="fit" onClick={fit} title="Show the whole level" className={BUTTON}>Fit</button>}
176
+ {menu.length > 0 && <MoreMenu items={menu} />}
177
+ </div>
178
+ );
179
+ if (inHeader) return controls;
180
+
181
+ return (
182
+ <div data-testid="toolbar" className="flex h-9 shrink-0 items-center justify-between gap-3 border-b border-divider pl-3.5 pr-2 text-[13px]">
183
+ <div className="flex min-w-0 items-center gap-2 text-text-2">
184
+ {findingShown && (busy
185
+ ? (
186
+ <span data-testid="lines-note" className="flex min-w-0 items-center gap-2">
187
+ {/* the title starts with the words, so a note cut short can still be read whole */}
188
+ <span className="truncate" title={showAllLines ? `All ${formatCount(level.edges.length)} lines shown` : "A busy folder, so lines show when you hover a box, or select it"}>{showAllLines ? `All ${formatCount(level.edges.length)} lines shown` : "A busy folder, so lines show when you hover a box"}</span>
189
+ <button type="button" data-testid="lines-toggle" onClick={() => onShowAllLines(!showAllLines)} className="shrink-0 text-text underline decoration-text-3 underline-offset-2 hover:decoration-text">
190
+ {showAllLines ? "Show on hover only" : "Show all lines"}
191
+ </button>
192
+ </span>
193
+ )
194
+ : finding && (
195
+ <span data-testid="finding" className="truncate" title={`Heaviest link is ${finding.from} → ${finding.to}, ${formatCount(finding.count)} of ${formatCount(finding.total)} imports between these boxes`}>
196
+ Heaviest link is <span className="font-mono text-[12.5px] text-text">{finding.from} → {finding.to}</span>, {formatCount(finding.count)} of {formatCount(finding.total)} imports
197
+ </span>
198
+ ))}
199
+ </div>
200
+ {controls}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Which page of a big folder is on screen, with a button either side (← and → turn it too). "Page 2 of 5", or "2 / 5"
207
+ * where room is short, the whole note in its title.
208
+ */
209
+ function PageControl({ level, onTurn, short }: { level: LevelResponse; onTurn: (page: number) => void; short: boolean }){
210
+ const step = (side: "left" | "right") => {
211
+ const next = side === "left" ? level.page - 1 : level.page + 1;
212
+ const Icon = side === "left" ? IconChevronLeft : IconChevronRight;
213
+ return (
214
+ <button
215
+ type="button"
216
+ data-testid={`page-${side === "left" ? "previous" : "next"}`}
217
+ disabled={next < 0 || next >= level.pageCount}
218
+ onClick={() => onTurn(next)}
219
+ aria-label={`${side === "left" ? "Previous" : "Next"} page`}
220
+ title={`${side === "left" ? "Previous" : "Next"} page (${side === "left" ? "←" : "→"})`}
221
+ className={`${BUTTON} px-1`}
222
+ >
223
+ <Icon aria-hidden size={14} stroke={2} />
224
+ </button>
225
+ );
226
+ };
227
+ return (
228
+ <span className="mr-1 flex items-center gap-1">
229
+ {step("left")}
230
+ <span data-testid="page-note" title={pageNote(level)} className="whitespace-nowrap px-1 text-[12.5px] text-text-2">
231
+ {short ? `${level.page + 1} / ${level.pageCount}` : `Page ${level.page + 1} of ${level.pageCount}`}
232
+ </span>
233
+ {step("right")}
234
+ </span>
235
+ );
236
+ }
237
+
238
+ /**
239
+ * All, Uses and Used by: which side of the selected box's lines is highlighted, the other drawn gray (emphasis.ts). A
240
+ * radio group, so Tab lands only on the side picked and the arrow keys, Home and End move between the sides, skipping
241
+ * one that can't be picked. Greyed out with nothing selected, and a side with no line on the page greyed out on its
242
+ * own, each saying why in its title. The side picked is filled, Uses underlined in the accent and Used by in gold, as
243
+ * their tabs are in the inspector.
244
+ */
245
+ function SidePicker({ direction, onDirection, hasSelection, emptySides }: Pick<MapToolbarProps, "direction" | "onDirection" | "hasSelection" | "emptySides">){
246
+ const open = SIDES.filter((side) => hasSelection && !emptySides.includes(side.id));
247
+ const onKeyDown = (event: KeyboardEvent<HTMLSpanElement>): void => {
248
+ const at = open.findIndex((side) => side.id === direction);
249
+ const next = event.key === "ArrowRight" || event.key === "ArrowDown" ? open[(at + 1) % open.length]
250
+ : event.key === "ArrowLeft" || event.key === "ArrowUp" ? open[(at - 1 + open.length) % open.length]
251
+ : event.key === "Home" ? open[0]
252
+ : event.key === "End" ? open[open.length - 1]
253
+ : undefined;
254
+ if (next === undefined) return;
255
+ // the Map's own ← and → turn the page: these keys are the picker's while one of its sides has the focus
256
+ event.preventDefault();
257
+ event.stopPropagation();
258
+ onDirection(next.id);
259
+ event.currentTarget.querySelector<HTMLButtonElement>(`[data-side="${next.id}"]`)?.focus();
260
+ };
261
+ return (
262
+ <span
263
+ role="radiogroup"
264
+ aria-label="Lines of the selected box"
265
+ data-testid="sides"
266
+ title={hasSelection ? undefined : NO_SELECTION}
267
+ onKeyDown={onKeyDown}
268
+ className={`mr-1.5 flex h-6 shrink-0 items-stretch rounded-[5px] border border-border p-px text-[12.5px] ${hasSelection ? "" : "opacity-40"}`}
269
+ >
270
+ {SIDES.map((side) => {
271
+ const on = side.id === direction;
272
+ const empty = emptySides.includes(side.id);
273
+ return (
274
+ <button
275
+ key={side.id}
276
+ type="button"
277
+ role="radio"
278
+ aria-checked={on}
279
+ data-side={side.id}
280
+ disabled={!hasSelection || empty}
281
+ tabIndex={on ? 0 : -1}
282
+ title={!hasSelection ? NO_SELECTION : empty ? EMPTY_SIDE[side.id] : side.title}
283
+ onClick={() => onDirection(side.id)}
284
+ className={`relative whitespace-nowrap rounded-[4px] px-2 leading-none disabled:cursor-default ${on ? "bg-node text-text" : "text-text-2 hover:text-text disabled:hover:text-text-2"} ${empty && hasSelection ? "opacity-40" : ""}`}
285
+ >
286
+ {side.label}
287
+ {on && hasSelection && side.mark && <span aria-hidden className={`absolute inset-x-1.5 bottom-0 h-0.5 rounded-[1px] ${side.mark}`} />}
288
+ </button>
289
+ );
290
+ })}
291
+ </span>
292
+ );
293
+ }
294
+
295
+ /** A row of the … menu: a plain item, a checkbox (on), or one of a group's radios (radio). */
296
+ type MenuItem = { label: string; on?: boolean; radio?: boolean; dot?: string; disabled?: boolean; title?: string; act: () => void };
297
+ /** Items under a heading that names them, like All, Uses and Used by under "Lines of the selected box". */
298
+ type MenuGroup = { group: string; items: MenuItem[] };
299
+ type MenuEntry = MenuItem | MenuGroup;
300
+
301
+ /** A menu radio's circle, filled with its side's color when picked. */
302
+ function Radio({ on, dot }: { on: boolean; dot: string }){
303
+ return (
304
+ <span aria-hidden className="grid h-3.5 w-3.5 shrink-0 place-items-center rounded-full border border-border">
305
+ {on && <span className={`h-1.5 w-1.5 rounded-full ${dot}`} />}
306
+ </span>
307
+ );
308
+ }
309
+
310
+ /** The … menu at the toolbar's end, holding whatever the bar has no room for, placed inside the window as it opens. */
311
+ function MoreMenu({ items }: { items: MenuEntry[] }){
312
+ const [open, setOpen] = useState(false);
313
+ const [place, setPlace] = useState<CSSProperties>({ right: 0, width: MENU_WIDTH });
314
+ const ref = useDismiss<HTMLDivElement>(open, () => setOpen(false));
315
+ const placeMenu = useCallback(() => {
316
+ if (ref.current) setPlace(placePopover(ref.current, MENU_WIDTH, "end"));
317
+ }, [ref]);
318
+ usePlaceOnResize(open, placeMenu);
319
+ const groupId = useId();
320
+ // a plain item closes the menu, a checkbox keeps it open to show what it did, and a radio closes it so the lines it
321
+ // picked are in view (Escape would let go of the selection with it: MapView's keys hear it too)
322
+ const row = (item: MenuItem) => (
323
+ <button
324
+ key={item.label}
325
+ type="button"
326
+ role={item.radio ? "menuitemradio" : item.on === undefined ? "menuitem" : "menuitemcheckbox"}
327
+ aria-checked={item.on}
328
+ disabled={item.disabled}
329
+ title={item.title}
330
+ onClick={() => {
331
+ item.act();
332
+ if (item.on === undefined || item.radio) setOpen(false);
333
+ }}
334
+ className="flex w-full items-center gap-2 rounded-[4px] px-2.5 py-1.5 text-left text-text-2 hover:bg-node hover:text-text disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-text-2"
335
+ >
336
+ {item.radio ? <Radio on={item.on ?? false} dot={item.dot ?? "bg-text"} /> : item.on !== undefined && <Check on={item.on} />}
337
+ {/* a narrow window narrows the menu, and a label cut short is whole in its title */}
338
+ <span title={item.label} className="truncate">{item.label}</span>
339
+ </button>
340
+ );
341
+ return (
342
+ <div ref={ref} className="relative">
343
+ <button
344
+ type="button"
345
+ data-testid="toolbar-more"
346
+ aria-label="More"
347
+ title="More"
348
+ aria-expanded={open}
349
+ onClick={() => {
350
+ if (!open) placeMenu();
351
+ setOpen((o) => !o);
352
+ }}
353
+ className={`${BUTTON} px-1`}
354
+ >
355
+ <IconDots aria-hidden size={14} stroke={2} />
356
+ </button>
357
+ {open && (
358
+ <div role="menu" data-testid="toolbar-menu" data-covers style={place} className="absolute top-8 z-30 overflow-y-auto rounded-md border border-border bg-panel p-1 text-[13px]">
359
+ {items.map((entry, i) => "group" in entry
360
+ ? (
361
+ // a hairline sets it apart from the rows around it, where there are any
362
+ <div
363
+ key={entry.group}
364
+ role="group"
365
+ aria-labelledby={`${groupId}-${i}`}
366
+ className={`border-divider ${i > 0 ? "mt-1 border-t pt-1" : ""} ${i < items.length - 1 ? "mb-1 border-b pb-1" : ""}`}
367
+ >
368
+ <p id={`${groupId}-${i}`} title={entry.group} className="truncate px-2.5 pb-0.5 pt-1 text-[11.5px] text-text-3">{entry.group}</p>
369
+ {entry.items.map(row)}
370
+ </div>
371
+ )
372
+ : row(entry))}
373
+ </div>
374
+ )}
375
+ </div>
376
+ );
377
+ }