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
@@ -1,11 +1,19 @@
1
+ import type { MapEdge } from "@/lib/contracts";
2
+ import { edgeKey } from "./emphasis";
1
3
  import { BOX_HEIGHT, BOX_WIDTH, FIT_PADDING, type Position } from "./levelLayout";
4
+ import type { Point } from "./routes";
5
+ import { arrowHead, boxHeadWidths, lineWidth, maxImportsOf } from "./weights";
2
6
 
3
7
  /**
4
8
  * Where a level opens (owner, 2026-10-13: "when you first open it, it should be a readable size"). A level is fitted to
5
9
  * the canvas as before, unless fitting it whole would shrink it past MIN_READABLE_ZOOM. Then it opens as wide as the
6
10
  * canvas allows (less SIDE_ROOM each side), between that floor and 100%, from its top, centred across, and the rest is
7
11
  * a scroll or a drag away: the floor alone left wide empty margins on busy levels (an outside critique, owner's pick).
8
- * The zoom controls' fit button still shows the whole level, and zooming out by hand still works (MapCanvas's minZoom).
12
+ * The toolbar's Fit still shows the whole level, and zooming out by hand still works (MapCanvas's minZoom).
13
+ *
14
+ * Both frame the drawn lines too (the owner, 2026-10-28): a lane beside the boxes ran off under the inspector. The
15
+ * boxes win, so framing the lines never shrinks them past LINES_FRAME_LEAST of their own fit, nor a level that opens
16
+ * fitted past MIN_READABLE_ZOOM. Lines cut off after a pan or a zoom are the person's doing, and stay.
9
17
  */
10
18
 
11
19
  /** Box names are 15 px, so at 0.75 they're 11.25 px: the smallest the Map opens a level at. */
@@ -22,9 +30,18 @@ export const TOP_INSET = 64;
22
30
  * opens VS Code's contrib, page 2, at 83% on a 1907 px screen instead of 75%.
23
31
  */
24
32
  export const SIDE_ROOM = 72;
33
+ /** The room framed past the drawn lines' extent, so the outermost lane doesn't run along the canvas's edge. */
34
+ export const LINES_MARGIN = 16;
35
+ /**
36
+ * The least of the boxes' own fit that framing the lines may zoom out to: past it the boxes are framed alone and the
37
+ * outermost lines run off. Over VS Code, Fit then frames the lines on every page at rest, and with every line drawn
38
+ * keeps the boxes' fit on the densest few only (the merged-lines plan, fit4-final.log).
39
+ */
40
+ export const LINES_FRAME_LEAST = 0.85;
25
41
 
26
42
  export type Bounds = { x: number; y: number; width: number; height: number };
27
43
  export type Viewport = { x: number; y: number; zoom: number };
44
+ type Canvas = { width: number; height: number };
28
45
 
29
46
  /** The rectangle every box of a level covers; positions are top-left corners (levelLayout.ts). */
30
47
  export function boundsOf(positions: ReadonlyMap<string, Position>): Bounds {
@@ -39,30 +56,131 @@ export function boundsOf(positions: ReadonlyMap<string, Position>): Bounds {
39
56
  return { x: left, y: top, width: right - left, height: bottom - top };
40
57
  }
41
58
 
59
+ /**
60
+ * How far drawn lines reach: each line's corners grown by half its width, and its head's half-width either side of its
61
+ * tip. Every route ends going down into its box's top (RoutedEdge.tsx), so the head reaches across there, not past it.
62
+ * Null when no line has a route.
63
+ */
64
+ export function linesBounds(lines: readonly { points: readonly Point[]; width: number; headHalf: number }[]): Bounds | null {
65
+ let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
66
+ for (const { points, width, headHalf } of lines){
67
+ if (points.length === 0) continue;
68
+ for (const { x, y } of points){
69
+ left = Math.min(left, x - width / 2);
70
+ top = Math.min(top, y - width / 2);
71
+ right = Math.max(right, x + width / 2);
72
+ bottom = Math.max(bottom, y + width / 2);
73
+ }
74
+ const tip = points[points.length - 1];
75
+ left = Math.min(left, tip.x - headHalf);
76
+ right = Math.max(right, tip.x + headHalf);
77
+ }
78
+ return left === Infinity ? null : { x: left, y: top, width: right - left, height: bottom - top };
79
+ }
80
+
81
+ /**
82
+ * The routed lines' reach (linesBounds), each as wide as MapCanvas draws it and under its box's one head (weights.ts),
83
+ * so what's framed is what's on screen.
84
+ */
85
+ export function drawnLinesBounds(routes: ReadonlyMap<string, readonly Point[]>, edges: readonly MapEdge[]): Bounds | null {
86
+ const max = maxImportsOf(edges);
87
+ const heads = boxHeadWidths(edges);
88
+ const byKey = new Map(edges.map((e) => [edgeKey(e), e]));
89
+ return linesBounds([...routes].flatMap(([key, points]) => {
90
+ const edge = byKey.get(key);
91
+ if (edge === undefined) return [];
92
+ const width = lineWidth(edge.count, max);
93
+ return [{ points, width, headHalf: arrowHead(heads.get(edge.target) ?? width).half }];
94
+ }));
95
+ }
96
+
97
+ /** What a fit frames, by its middle and its size. */
98
+ type Frame = { middle: Point; width: number; height: number };
99
+
100
+ /**
101
+ * One side to side (or top to bottom) span of a frame, from the boxes' middle, as React Flow centres its fit: half the
102
+ * boxes and FIT_PADDING's share each way, or as far as the lines and LINES_MARGIN reach past that. Worked out from the
103
+ * middle, so lines inside the boxes' frame leave it exactly as it was.
104
+ */
105
+ function span(start: number, size: number, lines?: { start: number; size: number }): { middle: number; size: number } {
106
+ const middle = start + size / 2;
107
+ const boxes = size / 2 + (size * FIT_PADDING) / 2;
108
+ if (lines === undefined) return { middle, size: 2 * boxes };
109
+ const before = Math.max(boxes, middle - (lines.start - LINES_MARGIN));
110
+ const after = Math.max(boxes, lines.start + lines.size + LINES_MARGIN - middle);
111
+ return { middle: middle + (after - before) / 2, size: before + after };
112
+ }
113
+
114
+ /** The boxes as React Flow's fit frames them, and with `lines`, as far as they reach past them too. */
115
+ function frameOf(boxes: Bounds, lines?: Bounds): Frame {
116
+ const across = span(boxes.x, boxes.width, lines && { start: lines.x, size: lines.width });
117
+ const down = span(boxes.y, boxes.height, lines && { start: lines.y, size: lines.height });
118
+ return { middle: { x: across.middle, y: down.middle }, width: across.size, height: down.size };
119
+ }
120
+
121
+ const zoomFor = (frame: Frame, room: Canvas): number => Math.min(MAX_FIT_ZOOM, room.width / frame.width, room.height / frame.height);
122
+
123
+ /** The boxes' frame, grown to the lines when that keeps LINES_FRAME_LEAST of the boxes' own fit (the boxes win). */
124
+ function framed(boxes: Bounds, lines: Bounds | null, room: Canvas): Frame {
125
+ const own = frameOf(boxes);
126
+ if (lines === null) return own;
127
+ const both = frameOf(boxes, lines);
128
+ return zoomFor(both, room) >= LINES_FRAME_LEAST * zoomFor(own, room) ? both : own;
129
+ }
130
+
131
+ /**
132
+ * The toolbar's Fit: the level's boxes and its drawn lines (linesBounds) centred in the canvas, never past 100%, with
133
+ * no readable floor, since the person asked to see it whole. React Flow's own fit frames the boxes alone, and floors
134
+ * its padding to whole pixels, so a level without lines fits within a pixel of it.
135
+ */
136
+ export function fitViewport(boxes: Bounds, lines: Bounds | null, canvas: Canvas): Viewport {
137
+ if (boxes.width === 0 || boxes.height === 0 || canvas.width === 0 || canvas.height === 0){
138
+ return { x: canvas.width / 2 - (boxes.x + boxes.width / 2), y: canvas.height / 2 - (boxes.y + boxes.height / 2), zoom: 1 };
139
+ }
140
+ const frame = framed(boxes, lines, canvas);
141
+ const zoom = zoomFor(frame, canvas);
142
+ return { x: canvas.width / 2 - frame.middle.x * zoom, y: canvas.height / 2 - frame.middle.y * zoom, zoom };
143
+ }
144
+
42
145
  /**
43
146
  * The viewport a level opens at: React Flow's fit (the same zoom and centring, with FIT_PADDING) when that's readable,
44
147
  * otherwise as wide as the canvas allows, from the level's top, never under MIN_READABLE_ZOOM or over MAX_FIT_ZOOM.
45
148
  * `topBand` keeps a fitted level below a strip at the top: on a paged level the page pill sits there, and a level
46
- * centred in the whole canvas could open with its top-right box under the pill (deferred minor). Pure, so the rule is
47
- * tested apart from React Flow.
149
+ * centred in the whole canvas could open with its top-right box under the pill (deferred minor). `lines` is the drawn
150
+ * lines' reach (drawnLinesBounds), framed as Fit frames it while the fit stays readable, and across a level that opens
151
+ * from its top. Pure, so the rule is tested apart from React Flow.
48
152
  */
49
- export function initialViewport(bounds: Bounds, canvas: { width: number; height: number }, topBand = 0): Viewport {
153
+ export function initialViewport(bounds: Bounds, canvas: Canvas, topBand = 0, lines: Bounds | null = null): Viewport {
50
154
  const below = canvas.height - topBand;
51
- const centre = (zoom: number): Viewport => ({
52
- x: canvas.width / 2 - (bounds.x + bounds.width / 2) * zoom,
53
- y: topBand + below / 2 - (bounds.y + bounds.height / 2) * zoom,
155
+ const centre = (frame: Frame, zoom: number): Viewport => ({
156
+ x: canvas.width / 2 - frame.middle.x * zoom,
157
+ y: topBand + below / 2 - frame.middle.y * zoom,
54
158
  zoom,
55
159
  });
56
- if (bounds.width === 0 || bounds.height === 0 || canvas.width === 0 || canvas.height === 0) return centre(1);
160
+ if (bounds.width === 0 || bounds.height === 0 || canvas.width === 0 || canvas.height === 0) return centre(frameOf(bounds), 1);
57
161
 
58
- const fit = Math.min(
59
- MAX_FIT_ZOOM,
60
- canvas.width / (bounds.width * (1 + FIT_PADDING)),
61
- below / (bounds.height * (1 + FIT_PADDING)),
62
- );
63
- if (fit >= MIN_READABLE_ZOOM) return centre(fit);
162
+ const room = { width: canvas.width, height: below };
163
+ const own = frameOf(bounds);
164
+ const fit = zoomFor(own, room);
165
+ if (fit >= MIN_READABLE_ZOOM){
166
+ // the lines may not take a page that fits readably under the floor: then it keeps the boxes' view
167
+ const frame = framed(bounds, lines, room);
168
+ const zoom = zoomFor(frame, room);
169
+ return zoom >= MIN_READABLE_ZOOM ? centre(frame, zoom) : centre(own, fit);
170
+ }
64
171
 
65
172
  // a tall level reads top to bottom like a page, so its width decides the zoom
66
- const zoom = Math.min(MAX_FIT_ZOOM, Math.max(MIN_READABLE_ZOOM, (canvas.width - 2 * SIDE_ROOM) / bounds.width));
67
- return { x: canvas.width / 2 - (bounds.x + bounds.width / 2) * zoom, y: TOP_INSET - bounds.y * zoom, zoom };
173
+ const wide = (width: number): number => Math.min(MAX_FIT_ZOOM, Math.max(MIN_READABLE_ZOOM, (canvas.width - 2 * SIDE_ROOM) / width));
174
+ let zoom = wide(bounds.width);
175
+ let middle = bounds.x + bounds.width / 2;
176
+ if (lines !== null){
177
+ // the boxes and the lines across, SIDE_ROOM their margin, unless that shrinks the boxes past LINES_FRAME_LEAST
178
+ const left = Math.min(bounds.x, lines.x);
179
+ const right = Math.max(bounds.x + bounds.width, lines.x + lines.width);
180
+ if (wide(right - left) >= LINES_FRAME_LEAST * zoom){
181
+ zoom = wide(right - left);
182
+ middle = (left + right) / 2;
183
+ }
184
+ }
185
+ return { x: canvas.width / 2 - middle * zoom, y: TOP_INSET - bounds.y * zoom, zoom };
68
186
  }
@@ -0,0 +1,80 @@
1
+ import type { MapEdge, MapNode } from "@/lib/contracts";
2
+
3
+ /**
4
+ * What the Map draws bigger when there's more of it (the Workbench redesign, ADR-070): a folder's dot by how many files
5
+ * it holds, a line's width by how many imports it carries, and a box's arrowhead by the heaviest line into it, since
6
+ * its lines share one (boxHeadWidths). Each is a log scale against the largest on the page, so VS Code's src (10,280
7
+ * files) and a folder of 3 both read. Pure, so the scales are tested alone.
8
+ */
9
+
10
+ /** A folder's dot, 6 px across for an empty one up to 18 px for the page's largest. */
11
+ export const DOT_MIN = 6;
12
+ export const DOT_MAX = 18;
13
+
14
+ export function dotSize(files: number, maxFiles: number): number {
15
+ if (maxFiles <= 0) return DOT_MIN;
16
+ return DOT_MIN + ((DOT_MAX - DOT_MIN) * Math.log10(Math.max(0, files) + 1)) / Math.log10(maxFiles + 1);
17
+ }
18
+
19
+ /**
20
+ * A line, 1 px for a single import up to 6 px for the page's heaviest. The spec's 10 px made every strong line of a dense
21
+ * level a 7 to 10 px tube, too alike to tell apart (the owner, 2026-10-27, on src/vs).
22
+ */
23
+ export const LINE_MIN = 1;
24
+ export const LINE_MAX = 6;
25
+
26
+ export function lineWidth(imports: number, maxImports: number): number {
27
+ if (maxImports <= 1 || imports <= 1) return LINE_MIN;
28
+ return LINE_MIN + ((LINE_MAX - LINE_MIN) * Math.log(imports)) / Math.log(maxImports);
29
+ }
30
+
31
+ /**
32
+ * A filled arrowhead wider than its line by a margin on each side, so it still stands out on the thickest line (the
33
+ * owner, 2026-10-27: the spec's small open chevron disappeared on thick lines). `half` is half its width, `length` how
34
+ * far it reaches back from its tip, both in px.
35
+ */
36
+ export function arrowHead(width: number): { half: number; length: number } {
37
+ return { half: Math.max(4.5, 0.9 * width + 3), length: Math.max(7, 1.2 * width + 5) };
38
+ }
39
+
40
+ /** The largest folder on a page, by files: what its dots are scaled against. Doorways stand for other pages, so they don't count. */
41
+ export const maxFilesOf = (nodes: readonly MapNode[]): number => Math.max(0, ...nodes.filter((n) => n.kind === "folder").map((n) => n.fileCount));
42
+
43
+ /** The heaviest line on a page, by imports. */
44
+ export const maxImportsOf = (edges: readonly MapEdge[]): number => Math.max(0, ...edges.map((e) => e.count));
45
+
46
+ /**
47
+ * The width each box's one arrowhead is sized for (arrowHead), by box. The lines into a box share its head, so it's
48
+ * sized for the heaviest of them on the page, drawn or not, and never grows when a hover draws a heavier one (the
49
+ * owner, 2026-10-28). A line from a box to itself isn't drawn, so it doesn't count.
50
+ */
51
+ export function boxHeadWidths(edges: readonly MapEdge[]): Map<string, number> {
52
+ const max = maxImportsOf(edges);
53
+ const heads = new Map<string, number>();
54
+ for (const e of edges){
55
+ if (e.source !== e.target) heads.set(e.target, Math.max(heads.get(e.target) ?? 0, lineWidth(e.count, max)));
56
+ }
57
+ return heads;
58
+ }
59
+
60
+ /** How many of the page's heaviest lines carry their count, whatever is selected. */
61
+ export const LABELLED_LINES = 3;
62
+
63
+ /**
64
+ * The lines whose count is written on them: the page's three heaviest (ties by source, then target, so the same page
65
+ * always labels the same ones) and every line of the selected box. A line that carries one import goes without, on any
66
+ * page: a busy box's lines were covered in 1s, the thinnest line already says it, and the inspector and a line's hover
67
+ * sentence give it in words (the owner, 2026-10-28). Keys are "source->target", as React Flow's ids. A count is drawn
68
+ * only where it fits clear of everything else (routes.ts's placeCounts).
69
+ */
70
+ export function labelledLines(edges: readonly MapEdge[], selected: string | null): Set<string> {
71
+ const key = (e: MapEdge): string => `${e.source}->${e.target}`;
72
+ const counted = edges.filter((e) => e.count > 1);
73
+ const heaviest = counted
74
+ .filter((e) => e.source !== e.target)
75
+ .sort((a, b) => b.count - a.count || (a.source < b.source ? -1 : a.source > b.source ? 1 : 0) || (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
76
+ .slice(0, LABELLED_LINES)
77
+ .map(key);
78
+ const ofSelected = selected === null ? [] : counted.filter((e) => e.source === selected || e.target === selected).map(key);
79
+ return new Set([...heaviest, ...ofSelected]);
80
+ }
package/app/page.tsx CHANGED
@@ -1,13 +1,11 @@
1
1
  import MapView from "./map/MapView";
2
- import { MIN_HEIGHT, MIN_WIDTH } from "./map/narrow";
3
2
 
4
3
  export default function Home(){
5
4
  // A server component: a flat canvas (VISUAL_REFRESH.md §3). The Map loads its data in the browser, through app/map/api.ts.
6
- // It fills the window down to the floor (narrow.ts); below it, it keeps that size and the page scrolls. It fills the
7
- // body's column rather than 100dvh: a sideways scrollbar takes height from the window, and 100dvh would then scroll
8
- // down by the scrollbar's height too.
5
+ // It fills the window at any size (app/map/shell.ts); in a window shorter than its least height it keeps that height
6
+ // and the page scrolls down. Nothing in it is wider than the window, so the page never scrolls sideways.
9
7
  return (
10
- <div data-testid="map-root" style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }} className="relative w-full flex-1 overflow-hidden bg-canvas">
8
+ <div data-testid="map-root" className="relative flex w-full flex-1 flex-col bg-canvas">
11
9
  <MapView />
12
10
  </div>
13
11
  );
@@ -2,6 +2,7 @@ import type { IndexEventMap, SummarizeResponse } from "../contracts";
2
2
  import type { StoredSummary } from "../db/store/summaries";
3
3
  import { errorResponseFor } from "../engine/errors";
4
4
  import type { IndexResult } from "../index/pipeline";
5
+ import { writtenInOlderStyle } from "../index/summarize/prompts";
5
6
  import type { ChildMessage, ParentMessage } from "./protocol";
6
7
  import type { BackgroundService } from "./service";
7
8
 
@@ -13,6 +14,7 @@ export function toSummarizeResponse(row: StoredSummary): SummarizeResponse {
13
14
  ...(row.tagline ? { tagline: row.tagline, summary: row.summary ?? "" } : {}),
14
15
  summaryStatus: row.status,
15
16
  ...(row.status === "failed" && row.error ? { summaryError: row.error } : {}),
17
+ ...(writtenInOlderStyle(row) ? { summaryOlderStyle: true as const } : {}),
16
18
  };
17
19
  }
18
20
 
@@ -12,6 +12,7 @@ import { configuredSummaryModels, SummaryModelsError, type SummaryModelsResoluti
12
12
  import { DEFAULT_BUDGET_USD, runSummaries, StructureNotReadyError, summarizeNow } from "../index/summarize/run";
13
13
  import type { PlannedTarget } from "../index/summarize/plan";
14
14
  import { snapshotOf } from "../index/summarize/pricing";
15
+ import { writtenInOlderStyle } from "../index/summarize/prompts";
15
16
  import { refreshSummaryStatus } from "../index/summarize/status";
16
17
  import type { Summarizer } from "../index/summarize/types";
17
18
  import { applyFileChanges } from "../index/update";
@@ -200,20 +201,24 @@ export function createBackgroundService(deps: BackgroundDeps): BackgroundService
200
201
  return (repoId === null ? null : approvedBudgetUsd(db, repoId)) ?? DEFAULT_BUDGET_USD;
201
202
  }
202
203
 
204
+ /** The repo's stored summaries by targetKey, read once per job as it starts, so every check the job makes reads the same rows. */
205
+ function storedRows(repo: string): Map<string, StoredSummary> {
206
+ const db = getDb();
207
+ const repoId = findRepository(db, repoKey(repo));
208
+ return new Map(repoId === null ? [] : loadSummaries(db, repoId).map((s) => [targetKey(s.kind, s.path), s]));
209
+ }
210
+
203
211
  /**
204
212
  * What an automatic job (a refresh after a save, the sweep) may send without asking (ADR-066, R17): a target whose
205
213
  * stored text the model now in use for its kind wrote (so it's out of date only because its inputs changed), or one
206
214
  * no model has written yet. Text another model wrote waits for `nebulosity summarize`'s estimate and yes, "Summarize
207
215
  * now", or a panel being opened: redoing it is a model change, and only a yes pays for that. A dated snapshot the API
208
216
  * reported (`<model>-20261001`) counts as its model, and nothing else does: claude-sonnet-5-5 isn't claude-sonnet-5
209
- * (snapshotOf). Read once per job, from the rows as the job starts.
217
+ * (snapshotOf). `rows` are the job's storedRows.
210
218
  */
211
- function sentUnasked(repo: string): (target: PlannedTarget) => boolean {
212
- const db = getDb();
213
- const repoId = findRepository(db, repoKey(repo));
214
- const writtenBy = new Map(repoId === null ? [] : loadSummaries(db, repoId).map((s) => [targetKey(s.kind, s.path), s.model]));
219
+ function sentUnasked(rows: ReadonlyMap<string, StoredSummary>): (target: PlannedTarget) => boolean {
215
220
  return (target) => {
216
- const model = writtenBy.get(targetKey(target.kind, target.path));
221
+ const model = rows.get(targetKey(target.kind, target.path))?.model;
217
222
  return !model || model === "template" || model === target.model || snapshotOf(model) === target.model;
218
223
  };
219
224
  }
@@ -276,7 +281,7 @@ export function createBackgroundService(deps: BackgroundDeps): BackgroundService
276
281
  }
277
282
 
278
283
  const wanted = new Set(paths.map((p) => targetKey("file", p)));
279
- const unasked = sentUnasked(repo);
284
+ const unasked = sentUnasked(storedRows(repo));
280
285
  // The content whose quiet minute is up: each file's input hash as this run first plans it. A file saved again
281
286
  // mid-refresh (structure updates don't wait for refreshes) plans with a new hash and isn't sent again here: its
282
287
  // update restarted its own minute. Otherwise every save during a wave would be paid for at once.
@@ -318,7 +323,8 @@ export function createBackgroundService(deps: BackgroundDeps): BackgroundService
318
323
  /**
319
324
  * Out-of-date folders whose children are settled, deepest first, at most SWEEP_MAX_FOLDERS (PIVOT_SPEC.md §5.6): only
320
325
  * those the model now in use wrote, or that no model has written (sentUnasked). After a folder or repo model change
321
- * the rest wait for a yes, instead of being redone 20 every 10 minutes with no estimate (R17).
326
+ * the rest wait for a yes, instead of being redone 20 every 10 minutes with no estimate (R17). A folder or the repo
327
+ * an older prompt version wrote waits too, to be opened or redone by `nebulosity summarize` (Q2 B).
322
328
  */
323
329
  async function runSweep(): Promise<void> {
324
330
  const repo = root;
@@ -332,18 +338,28 @@ export function createBackgroundService(deps: BackgroundDeps): BackgroundService
332
338
  return;
333
339
  }
334
340
 
335
- const unasked = sentUnasked(repo);
341
+ const rows = storedRows(repo);
342
+ const unasked = sentUnasked(rows);
343
+ const olderStyle = (target: PlannedTarget): boolean => {
344
+ const row = rows.get(targetKey(target.kind, target.path));
345
+ return row !== undefined && writtenInOlderStyle(row);
346
+ };
336
347
  try{
337
348
  const result = await runSummaries(repo, {
338
349
  summarizer: deps.summarizer,
339
350
  models,
340
351
  transport: "direct",
341
352
  refreshStaleFolders: true,
353
+ // such a folder plans lazy, so it never keeps the folder above it waiting (Q2 B, the select below)
354
+ olderFoldersWaitToBeOpened: true,
342
355
  wait: false,
343
356
  budgetUsd: INCREMENTAL_BUDGET_USD,
344
357
  budgetScope: "run",
345
358
  repoBudgetUsd: approvedBudget(repo),
346
- select: (target) => target.kind !== "file" && unasked(target),
359
+ // Q2 B: a folder or the repo an older prompt wrote is left to be opened, even once its inputs change (an
360
+ // edit below it, or a child a panel restyled), so the sweep never restyles the folders above an edit
361
+ // unasked. The refresh after a save keeps redoing an edited file whatever prompt wrote it (P1).
362
+ select: (target) => target.kind !== "file" && unasked(target) && !olderStyle(target),
347
363
  maxTargets: SWEEP_MAX_FOLDERS,
348
364
  onProgress: (message) => log(`sweep: ${message}`),
349
365
  });
package/lib/contracts.ts CHANGED
@@ -132,6 +132,8 @@ export type DescribeResponse = {
132
132
  summaryStatus: SummaryStatus;
133
133
  /** Why the last attempt failed, when summaryStatus is "failed". */
134
134
  summaryError?: string;
135
+ /** A model wrote this summary with an older version of the prompt (ADR-034, amended 2026-10-29). It reads in the older style, one block that may count importers, until it's rewritten. */
136
+ summaryOlderStyle?: true;
135
137
  /** For a folder or file, the docs linked to it or the folders above it; for the repo, the docs to read first (ADR-054). Left out when none. */
136
138
  docs?: DocPointer[];
137
139
  stats: { fileCount: number; lineCount: number };
@@ -189,6 +191,8 @@ export type SummarizeResponse = {
189
191
  summaryStatus: SummaryStatus;
190
192
  /** Why it failed, when summaryStatus is "failed". */
191
193
  summaryError?: string;
194
+ /** A model wrote this summary with an older version of the prompt (ADR-034, amended 2026-10-29). It reads in the older style, one block that may count importers, until it's rewritten. */
195
+ summaryOlderStyle?: true;
192
196
  };
193
197
 
194
198
  /** Where a repo was cloned from: its origin remote's owner and name ("microsoft" / "vscode") — docs/API_CONTRACTS.md §15 */
@@ -220,13 +224,32 @@ export type BridgeToMap =
220
224
  /** A deliberate jump: focus on the path, pushing a history entry. */
221
225
  | { type: "nebulosity.show"; path: string }
222
226
  | { type: "nebulosity.highlight"; paths: string[] }
223
- | { type: "nebulosity.clear" };
227
+ | { type: "nebulosity.clear" }
228
+ /**
229
+ * The editor's color theme, sent by the webview itself after the handshake and again whenever the theme changes, so
230
+ * the Map takes its grays from it (the Workbench redesign, ADR-070). Added without a version change: an older Map
231
+ * ignores it, and an older webview never sends it, so the Map keeps its own palette.
232
+ */
233
+ | { type: "nebulosity.theme"; kind: ThemeKind; colors: BridgeThemeColors };
234
+
235
+ /** Which kind of theme VS Code has on: its webview's `vscode-light`, `vscode-dark` and two high contrast kinds. */
236
+ export type ThemeKind = "dark" | "light" | "hc" | "hc-light";
237
+
238
+ /**
239
+ * A theme's colors as the webview reads them from VS Code's `--vscode-*` variables, raw CSS colors (`#1f1f1f`,
240
+ * `rgba(…)`): editor.background, sideBar.background, a border (the high contrast border, else a widget's, the
241
+ * panel's or the side bar's), foreground, descriptionForeground and focusBorder. A theme may leave out the optional ones.
242
+ */
243
+ export type BridgeThemeColors = { editor: string; side: string; fg: string; border?: string; desc?: string; focus?: string };
224
244
 
225
245
  /** Messages from the Map to the webview — docs/API_CONTRACTS.md §20. */
226
246
  export type BridgeFromMap =
227
247
  /** The answer to hello. */
228
248
  | { type: "nebulosity.ready"; version: 1 }
229
- /** A file box or a declaration was picked. */
249
+ /**
250
+ * Open the file in the editor: Open in VS Code (the source viewer's at its highlighted line), a double-clicked file
251
+ * box, or a narrow window's Open beside it. A click on a box only selects it (amended 2026-10-29).
252
+ */
230
253
  | { type: "nebulosity.open"; path: string; line?: number }
231
254
  /** A new level was drawn, the selection changed on the level drawn, or a `ready` was answered while a level is on screen (the extension's test and benchmarks read it). */
232
255
  | { type: "nebulosity.drawn"; path: string; page: number; selected: string | null };
@@ -0,0 +1 @@
1
+ ALTER TABLE `summaries` ADD `prompt_version` integer;