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
package/app/map/narrow.ts CHANGED
@@ -1,36 +1,9 @@
1
1
  /**
2
- * The Map at any window size (VISUAL_REFRESH.md, "Narrow windows (2026-10-24)"). At 1100 × 700 and up nothing here
3
- * changes how the Map looks. Narrower, each surface gives up secondary text first; below the floor the Map stops
4
- * shrinking and the page scrolls instead, with the theme's thin scrollbars. Pure, so each rule is tested on its own.
2
+ * The Map's small placements at any window size (VISUAL_REFRESH.md, "Narrow windows"): a selected box's actions, a
3
+ * folded breadcrumb, a popover kept inside the window, and the error line's form. The layout itself, what gives way as
4
+ * the window narrows or shortens, is shell.ts's (the Workbench redesign, ADR-070). Pure, so each rule is tested alone.
5
5
  */
6
6
 
7
- import { TOP_INSET } from "./viewport";
8
-
9
- /**
10
- * The floor: the smallest the Map lays itself out at. Narrower or shorter, the page scrolls sideways or down.
11
- * 360 px wide holds the top bar's essentials (logo, repo, search, help, status dot) and a canvas as wide as the floor
12
- * beside nothing. 320 px tall leaves a 200 px canvas under the bars (48 + 40 + 32 px), which stacks a paged level's
13
- * pill (16 + 44 px from the top) over the zoom controls (104 px, 15 px from the bottom) with about 20 px between them.
14
- */
15
- export const MIN_WIDTH = 360;
16
- export const MIN_HEIGHT = 320;
17
-
18
- /** The details panel's width, when there's room beside it (VISUAL_REFRESH.md §5). */
19
- export const PANEL_WIDTH = 400;
20
- /**
21
- * The least canvas worth keeping beside the panel: the floor's own width. With less left, the panel covers the whole
22
- * canvas (its × closes it), since a sliver of Map beside it can't be read or used.
23
- */
24
- export const MIN_CANVAS_BESIDE_PANEL = MIN_WIDTH;
25
-
26
- /**
27
- * Whether the panel covers the whole canvas in a Map area `mainWidth` px wide; beside it, it's PANEL_WIDTH wide.
28
- * `null`, before the area is measured, is the normal case: beside the canvas.
29
- */
30
- export function panelLayout(mainWidth: number | null): { covers: boolean } {
31
- return { covers: mainWidth !== null && mainWidth - PANEL_WIDTH < MIN_CANVAS_BESIDE_PANEL };
32
- }
33
-
34
7
  /**
35
8
  * Where a selected box's actions go while the panel would cover the canvas (owner review, 2026-10-25). A click then only
36
9
  * selects, so a double click still reaches the box and the Map stays in sight, and the box shows Open and Details beside
@@ -47,7 +20,9 @@ const ACTIONS_GAP = 8;
47
20
  /**
48
21
  * Where the selected box's actions go on the canvas (their top-left corner, canvas px), given the box and the actions'
49
22
  * size on screen: on actionsSide's side, 8 px off the box, centred on it, but kept 8 px inside the canvas's sides, so a
50
- * box hanging off an edge (a level opened from its top at 75%) never takes them past it. Each axis is its own rule.
23
+ * box hanging off an edge (a level opened from its top at 75%) never takes them past it. Each axis is its own rule. A
24
+ * box out of sight above or below (selected by a search pick or Follow in a short window, or panned away) keeps them 8 px
25
+ * inside the canvas's top or bottom, since they stand in for the inspector, which is always in sight (2026-10-29).
51
26
  */
52
27
  export function actionsPlace(
53
28
  box: { left: number; top: number; width: number; height: number },
@@ -55,27 +30,13 @@ export function actionsPlace(
55
30
  canvasWidth: number,
56
31
  canvasHeight: number,
57
32
  ): { left: number; top: number } {
58
- const top = actionsSide(box.top, box.height, canvasHeight) === "bottom" ? box.top + box.height + ACTIONS_GAP : box.top - ACTIONS_GAP - actions.height;
33
+ const beside = actionsSide(box.top, box.height, canvasHeight) === "bottom" ? box.top + box.height + ACTIONS_GAP : box.top - ACTIONS_GAP - actions.height;
34
+ const top = Math.max(Math.min(beside, canvasHeight - ACTIONS_GAP - actions.height), ACTIONS_GAP);
59
35
  const centred = box.left + box.width / 2 - actions.width / 2;
60
36
  const left = Math.min(Math.max(centred, ACTIONS_GAP), canvasWidth - ACTIONS_GAP - actions.width);
61
37
  return { left, top };
62
38
  }
63
39
 
64
- /**
65
- * How much the page pill says, by the canvas width it sits in (`null`: not measured yet, the normal case):
66
- * "Page 2 of 5 · 44 of 182 folders and files", "Page 2 of 5", or "2 / 5". The whole note is about 350 px, 370 at
67
- * most with four-digit counts; with its 16 px inset on the right and as much again on its left that's about 400 px,
68
- * and 440 leaves room for a wider font.
69
- */
70
- export type PillForm = "full" | "page" | "numbers";
71
- const FULL_PILL_ROOM = 440;
72
- const PAGE_PILL_ROOM = 240;
73
-
74
- export function pillForm(room: number | null): PillForm {
75
- if (room === null || room >= FULL_PILL_ROOM) return "full";
76
- return room >= PAGE_PILL_ROOM ? "page" : "numbers";
77
- }
78
-
79
40
  /**
80
41
  * The first breadcrumb step to show so the trail fits in `available` px: the steps before it fold into one "…" step
81
42
  * (`ellipsis` px), so the trail truncates from its start and the current folder stays. `widths` are the steps' natural
@@ -95,6 +56,151 @@ export function trailStart(widths: readonly number[], available: number, ellipsi
95
56
  return last;
96
57
  }
97
58
 
59
+ /** A rectangle on the canvas, in canvas px. */
60
+ export type CanvasRect = { left: number; top: number; width: number; height: number };
61
+
62
+ /** The least room the card keeps from the canvas's sides. */
63
+ export const CARD_EDGE = 8;
64
+ /**
65
+ * The tail a card points at its line with, a comic's speech bubble's (the owner, 2026-10-29): this wide at its base and
66
+ * this long, one size whatever the line, its tip TAIL_GAP short of the line's edge, its middle TAIL_INSET along the
67
+ * card's side from the corner nearest the line, so the card keeps the same distance from the spot it's about.
68
+ */
69
+ export const TAIL_BASE = 12;
70
+ export const TAIL_LENGTH = 7;
71
+ const TAIL_GAP = 1;
72
+ const TAIL_INSET = 16;
73
+ /** The least a tail's middle keeps from the card's corners, its base clear of their 6 px rounding. */
74
+ const TAIL_END = 12;
75
+ /**
76
+ * The bit of line right at the spot, each way from it, which the card covers in no window it fits, as wide as the
77
+ * heaviest line is drawn.
78
+ */
79
+ const CARD_SPOT = 6;
80
+
81
+ /** Which side of the card its tail comes out of. */
82
+ export type TailSide = "top" | "bottom" | "left" | "right";
83
+ /**
84
+ * A card's place (its top-left corner, canvas px), and its tail: the side it comes out of and how far along that side
85
+ * its middle is, from the card's left or top. No tail where the card couldn't keep its distance from the spot.
86
+ * `rises` says the card runs up from its tail, so a pinned card grows up from it.
87
+ */
88
+ export type BubblePlace = { left: number; top: number; tail: { side: TailSide; at: number } | null; rises: boolean };
89
+
90
+ const overlaps = (a: CanvasRect, b: CanvasRect): boolean =>
91
+ a.left < b.left + b.width && b.left < a.left + a.width && a.top < b.top + b.height && b.top < a.top + a.height;
92
+
93
+ /** How far the card's side keeps from the middle of a line `half` px wide each side, its tail between them. */
94
+ const tailReach = (half: number): number => half + TAIL_GAP + TAIL_LENGTH;
95
+
96
+ /**
97
+ * Where the card about the lines under the pointer goes, pointing at `spot`, the point of the line nearest the pointer
98
+ * (canvas px), with its tail across the line, never along it. Beside a line that runs up and down there (`upright`),
99
+ * the tail comes out of the card's side, and over or under a level one out of its bottom or top, so the card never
100
+ * lies on the line it's about. `half` is half the line's width on screen. The places are tried above and left first
101
+ * (the owner's pick of 2026-10-29, closest to the pointer's tip, where nothing of the pointer is, and clear of the box
102
+ * a line runs down into): left of an upright line rising from the tail, then right of it, then left and right going
103
+ * down; over a level line reaching left from the tail, then right, then under it; then each of those slid along the
104
+ * line, its tail still on the spot, to fit the canvas near its edge. The first inside the canvas, CARD_EDGE in from its
105
+ * sides, and clear of `line` (the bit of line pointed at and its arrowhead, as rectangles as wide
106
+ * as they're drawn) and of `clear` (what's over the canvas: a drawer, a box's actions, the banner) wins, then the first
107
+ * inside and clear of the rest that keeps off the line right at the spot. Where none fits, the first moved inside the
108
+ * canvas that's clear, then the first moved inside that keeps off the spot, then just over or under the spot (a canvas
109
+ * too narrow for the card beside an upright line), then the first moved inside: the card never leaves the canvas, and
110
+ * moved, it can't keep its tail.
111
+ */
112
+ export function bubblePlace(
113
+ spot: { x: number; y: number },
114
+ upright: boolean,
115
+ half: number,
116
+ card: { width: number; height: number },
117
+ canvas: { width: number; height: number },
118
+ line: readonly CanvasRect[],
119
+ clear: readonly CanvasRect[],
120
+ ): BubblePlace {
121
+ const { width, height } = card;
122
+ const reach = tailReach(half);
123
+ const tries: BubblePlace[] = upright ? [
124
+ { left: spot.x - reach - width, top: spot.y + TAIL_INSET - height, tail: { side: "right", at: height - TAIL_INSET }, rises: true },
125
+ { left: spot.x + reach, top: spot.y + TAIL_INSET - height, tail: { side: "left", at: height - TAIL_INSET }, rises: true },
126
+ { left: spot.x - reach - width, top: spot.y - TAIL_INSET, tail: { side: "right", at: TAIL_INSET }, rises: false },
127
+ { left: spot.x + reach, top: spot.y - TAIL_INSET, tail: { side: "left", at: TAIL_INSET }, rises: false },
128
+ ] : [
129
+ { left: spot.x + TAIL_INSET - width, top: spot.y - reach - height, tail: { side: "bottom", at: width - TAIL_INSET }, rises: true },
130
+ { left: spot.x - TAIL_INSET, top: spot.y - reach - height, tail: { side: "bottom", at: TAIL_INSET }, rises: true },
131
+ { left: spot.x + TAIL_INSET - width, top: spot.y + reach, tail: { side: "top", at: width - TAIL_INSET }, rises: false },
132
+ { left: spot.x - TAIL_INSET, top: spot.y + reach, tail: { side: "top", at: TAIL_INSET }, rises: false },
133
+ ];
134
+ // near the canvas's edge, each place also slides along the line, inside the canvas, with its tail still on the spot,
135
+ // as far as the tail keeps TAIL_END from the card's corners: the tail stays one size
136
+ const slid = tries.map((at): BubblePlace => {
137
+ const along = upright
138
+ ? Math.max(CARD_EDGE, Math.min(at.top, canvas.height - CARD_EDGE - height))
139
+ : Math.max(CARD_EDGE, Math.min(at.left, canvas.width - CARD_EDGE - width));
140
+ const tailAt = (upright ? spot.y : spot.x) - along;
141
+ if (at.tail === null || tailAt < TAIL_END || tailAt > (upright ? height : width) - TAIL_END) return at;
142
+ return { ...at, ...(upright ? { top: along } : { left: along }), tail: { side: at.tail.side, at: tailAt } };
143
+ });
144
+ const placed = [...tries, ...slid];
145
+ const box = (at: { left: number; top: number }): CanvasRect => ({ ...at, width, height });
146
+ const inside = (at: BubblePlace): boolean =>
147
+ at.left >= CARD_EDGE && at.top >= CARD_EDGE && at.left + width <= canvas.width - CARD_EDGE && at.top + height <= canvas.height - CARD_EDGE;
148
+ const free = (at: { left: number; top: number }): boolean => [...line, ...clear].every((r) => !overlaps(box(at), r));
149
+ const near = { left: spot.x - CARD_SPOT, top: spot.y - CARD_SPOT, width: 2 * CARD_SPOT, height: 2 * CARD_SPOT };
150
+ const offSpot = (at: { left: number; top: number }): boolean => !overlaps(box(at), near) && clear.every((r) => !overlaps(box(at), r));
151
+ const movedIn = tries.map((at): BubblePlace => ({ ...insideCanvas(at, card, canvas), tail: null, rises: at.rises }));
152
+ const overUnder = [spot.y - CARD_SPOT - 1 - height, spot.y + CARD_SPOT + 1]
153
+ .map((top): BubblePlace => ({ ...insideCanvas({ left: spot.x - width / 2, top }, card, canvas), tail: null, rises: top < spot.y }));
154
+ return placed.find((at) => inside(at) && free(at)) ?? placed.find((at) => inside(at) && offSpot(at))
155
+ ?? movedIn.find(free) ?? [...movedIn, ...overUnder].find(offSpot) ?? movedIn[0];
156
+ }
157
+
158
+ /** Whether a card `cardWidth` wide fits beside an upright line at `spotX`, its tail between them, on either side. */
159
+ const fitsBeside = (spotX: number, half: number, cardWidth: number, canvasWidth: number): boolean =>
160
+ Math.max(spotX - tailReach(half) - CARD_EDGE, canvasWidth - CARD_EDGE - spotX - tailReach(half)) >= cardWidth;
161
+
162
+ /**
163
+ * A card's place moved inside the canvas, CARD_EDGE in from its sides, the least it takes: a pinned card the wheel's
164
+ * zoom took to the canvas's edge stays in sight (LineCard), as a card with no place that fits does.
165
+ */
166
+ export function insideCanvas(at: { left: number; top: number }, card: { width: number; height: number }, canvas: { width: number; height: number }): { left: number; top: number } {
167
+ return {
168
+ left: Math.max(CARD_EDGE, Math.min(at.left, canvas.width - CARD_EDGE - card.width)),
169
+ top: Math.max(CARD_EDGE, Math.min(at.top, canvas.height - CARD_EDGE - card.height)),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The height a card `cardWidth` wide has for itself on `canvas`, pointing at `spot` (bubblePlace): over or under a level
175
+ * line, past its tail, whichever has more, or beside an upright one the canvas's height inside its edges, the card
176
+ * sliding along the line to fit. Where the card can't be beside an upright line, too wide for the canvas there or the
177
+ * spot within TAIL_END of the canvas's top or bottom, it's the room just over or under the spot. So a card that fits it
178
+ * stays off the line at the spot in any window, and keeps its tail wherever it can.
179
+ */
180
+ export function bubbleRoom(spot: { x: number; y: number }, upright: boolean, half: number, cardWidth: number, canvas: { width: number; height: number }): number {
181
+ if (!upright) return Math.max(spot.y - tailReach(half) - CARD_EDGE, canvas.height - CARD_EDGE - spot.y - tailReach(half));
182
+ const tailFits = spot.y >= CARD_EDGE + TAIL_END && spot.y <= canvas.height - CARD_EDGE - TAIL_END;
183
+ return fitsBeside(spot.x, half, cardWidth, canvas.width) && tailFits
184
+ ? canvas.height - 2 * CARD_EDGE
185
+ : Math.max(spot.y - CARD_SPOT - 1 - CARD_EDGE, canvas.height - CARD_EDGE - spot.y - CARD_SPOT - 1);
186
+ }
187
+
188
+ /** The most lines the hover card lists before "and N more" (the owner, 2026-10-29), and each row's height (leading-[18px]). */
189
+ export const CARD_ROWS = 5;
190
+ export const CARD_ROW = 18;
191
+ /** The card's border and padding, top and bottom together (border and py-1). */
192
+ export const CARD_FRAME = 10;
193
+
194
+ /**
195
+ * How many of a run's `total` lines the hover card lists in `room` px of height: CARD_ROWS, or all of them where one
196
+ * more would only replace the "and 1 more" row, but no more than fit under the title with the "and N more" row.
197
+ */
198
+ export function hoverCardRows(total: number, room: number): number {
199
+ let rows = total <= CARD_ROWS + 1 ? total : CARD_ROWS;
200
+ while (rows > 0 && CARD_FRAME + CARD_ROW * (1 + rows + (rows < total ? 1 : 0)) > room) rows--;
201
+ return rows;
202
+ }
203
+
98
204
  /** The space a popover keeps from the Map's edges. */
99
205
  const POPOVER_GUTTER = 16;
100
206
 
@@ -117,52 +223,26 @@ export function fitPopover(
117
223
  }
118
224
 
119
225
  /*
120
- * The Map's error line (a failed jump, a level that wouldn't load), over the canvas beside the panel. Its place at
121
- * normal sizes is the one it always had: 24 px down, in the page pill's band, centred, now over the canvas you can
122
- * see rather than under an open panel, and keeping clear of the pill. Where that band can't hold it, it's a solid card
123
- * in the canvas's top-left corner instead, clear of the pill and the zoom controls. Over a panel that covers the
124
- * canvas it's the panel's own last row (DetailPanel). It never takes a click.
226
+ * The Map's error line (a failed jump, a level that wouldn't load), over the canvas: 24 px down, centred, and where
227
+ * that can't hold it in two lines, a solid card in the canvas's top-left corner. Over an inspector that covers the
228
+ * canvas it's the inspector's own last row (DetailPanel). It never takes a click.
125
229
  */
126
230
 
127
- /** The overlays' inset from the canvas's edges (the pill's right-4, the legend's left-4 and bottom-4). */
128
- const OVERLAY_INSET = 16;
129
- /** The gap the banner keeps from the pill and the legend. */
130
- const OVERLAY_GAP = 8;
131
- /** React Flow's zoom controls: 26 px wide, 15 px in from the canvas's right edge. */
132
- const ZOOM_COLUMN = 15 + 26;
133
- /** The banner's distance from the canvas's top: as a line in the pill's band (top-6), and as a card (top-4). */
231
+ /** The card's inset from the canvas's edges. */
232
+ const CARD_INSET = 16;
233
+ /** The line's distance from the canvas's top (top-6), and how tall it may grow before it becomes the card. */
134
234
  export const BANNER_TOP = 24;
135
- export const CARD_TOP = OVERLAY_INSET;
235
+ const BANNER_BAND = 64;
136
236
 
137
237
  export type BannerForm = "band" | "card";
138
238
 
139
239
  /**
140
- * Which form the banner takes, by the height it has as a line in the band (`null`: not measured yet, the normal case):
141
- * the band while it ends inside the pill's 64 px band, where a paged level's boxes don't open (two lines), else the card.
240
+ * Which form the banner takes, by the height it has as a line (`null`: not measured yet, the normal case): the line
241
+ * while it ends inside the canvas's top 64 px (two lines), else the card.
142
242
  */
143
243
  export function bannerForm(bandHeight: number | null): BannerForm {
144
- return bandHeight === null || BANNER_TOP + bandHeight <= TOP_INSET ? "band" : "card";
244
+ return bandHeight === null || BANNER_TOP + bandHeight <= BANNER_BAND ? "band" : "card";
145
245
  }
146
246
 
147
- /**
148
- * The room the line keeps on its right for the pill, past the band's own 16 px padding: the pill's width and a gap,
149
- * and as much on its left, so it stays centred over the canvas while it can (`null`: no pill, or not measured yet).
150
- */
151
- export function bandClear(pillWidth: number | null): number {
152
- return pillWidth === null ? 0 : pillWidth + OVERLAY_GAP;
153
- }
154
-
155
- /** How far the card's right edge keeps from the canvas's right edge: past the pill, and always past the zoom controls. */
156
- export function cardClear(pillWidth: number | null): number {
157
- return Math.max(ZOOM_COLUMN + OVERLAY_GAP, pillWidth === null ? 0 : OVERLAY_INSET + pillWidth + OVERLAY_GAP);
158
- }
159
-
160
- /**
161
- * The open legend's greatest height on a canvas `canvasHeight` px tall (`0`: not measured yet, then no cap). It sits
162
- * 16 px from the bottom and stops short of what's above it: 16 px from the top, a paged level's pill band, or the
163
- * banner's bottom (`bannerBottom`, 0 with none) and a gap. Its rows scroll inside.
164
- */
165
- export function legendMaxHeight(canvasHeight: number, pillBand: number, bannerBottom: number): number | undefined {
166
- if (canvasHeight <= 0) return undefined;
167
- return canvasHeight - OVERLAY_INSET - Math.max(OVERLAY_INSET, pillBand, bannerBottom > 0 ? bannerBottom + OVERLAY_GAP : 0);
168
- }
247
+ /** How far the card keeps from the canvas's sides, together: its inset on the left and as much on the right. */
248
+ export const CARD_CLEAR = 2 * CARD_INSET;
@@ -0,0 +1,16 @@
1
+ import type { MapNode } from "@/lib/contracts";
2
+
3
+ /** What opening a box does: a folder opens on the Map, a doorway turns to its page, a file opens in the editor. */
4
+ export type BoxOpening = { type: "folder"; path: string } | { type: "page"; page: number } | { type: "editor"; path: string };
5
+
6
+ /**
7
+ * What opening `box` does, by a double-click on it or the Open beside it in a narrow window (BoxActions); null when it
8
+ * has nothing to open. Inside VS Code's panel (`inEditor`) a click only selects a file, and opening it is how it reaches
9
+ * the editor (the owner, 2026-10-29, ADR-062 amended); a browser tab has no editor to open it in. The merged doorway
10
+ * stands for several pages, so it stays selected for its panel to pick one.
11
+ */
12
+ export function openingOf(box: MapNode, inEditor: boolean): BoxOpening | null {
13
+ if (box.kind === "folder") return { type: "folder", path: box.path };
14
+ if (box.kind === "portal") return box.page === undefined ? null : { type: "page", page: box.page };
15
+ return inEditor ? { type: "editor", path: box.path } : null;
16
+ }
@@ -29,11 +29,11 @@ export function portalTitle(box: Pick<MapNode, "page" | "pages">): string {
29
29
  }
30
30
 
31
31
  /**
32
- * Under a doorway's name: "44 folders and files · 6 imports" (the owner's wording; "boxes" read as jargon). Imports, as a
32
+ * Under a doorway's name: "44 folders and files, 6 imports" (the owner's wording; "boxes" read as jargon). Imports, as a
33
33
  * line's hover sentence counts them; "links" read as a count of lines (deferred minor, 2026-10-13).
34
34
  */
35
35
  export function portalCaption(box: Pick<MapNode, "hiddenCount">, imports: number): string {
36
- return `${plural(box.hiddenCount ?? 0, "folder or file", "folders and files")} · ${plural(imports, "import", "imports")}`;
36
+ return `${plural(box.hiddenCount ?? 0, "folder or file", "folders and files")}, ${plural(imports, "import", "imports")}`;
37
37
  }
38
38
 
39
39
  /**