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
@@ -10,8 +10,8 @@ import type { MapEdge } from "@/lib/contracts";
10
10
  export type BoxTone = "normal" | "selected" | "linked" | "preview" | "faded";
11
11
 
12
12
  /**
13
- * How a line is drawn: the selection's in purple, the hovered box's brighter, the rest quieter while something is
14
- * selected. On a busy level the rest aren't drawn at all ("hidden").
13
+ * How a line is drawn: the selection's in purple or gold (lineLook), the hovered box's brighter, the rest quieter while
14
+ * something is selected. On a busy level the rest aren't drawn at all ("hidden").
15
15
  */
16
16
  export type EdgeTone = "normal" | "selected" | "preview" | "faded" | "hidden";
17
17
 
@@ -24,14 +24,89 @@ export const BUSY_LINES = 60;
24
24
 
25
25
  export const isBusyLevel = (lineCount: number): boolean => lineCount > BUSY_LINES;
26
26
 
27
+ /**
28
+ * "Strong links only" (the Workbench redesign, ADR-070, on unless turned off): a line is strong when it carries at least
29
+ * STRONG_SHARE of the imports of the page's heaviest line. The weaker ones stay hidden unless they touch the selection,
30
+ * the hovered box, or a box Claude Code or the editor has lit. There's no floor of 3 imports, as the spec had: in a
31
+ * folder of files every line is 1 or 2 imports, and the floor hid them all (the owner's pick, 2026-10-27). So a page
32
+ * whose heaviest line carries under 50 imports hides nothing.
33
+ */
34
+ export const STRONG_SHARE = 0.02;
35
+
36
+ /** The fewest imports a strong line carries on a page whose heaviest line carries `maxImports`. */
37
+ export const strongThreshold = (maxImports: number): number => STRONG_SHARE * maxImports;
38
+
39
+ /** How many lines Strong links only hides on this page with `selected` selected and `pinned` lit, before any hover. */
40
+ export function weakLinesHidden(edges: readonly MapEdge[], selected: string | null, pinned: ReadonlySet<string> = new Set()): number {
41
+ const threshold = strongThreshold(Math.max(0, ...edges.map((e) => e.count)));
42
+ const touches = (edge: MapEdge, box: string): boolean => edge.source === box || edge.target === box;
43
+ return edges.filter((e) => e.source !== e.target && e.count < threshold
44
+ && !(selected !== null && touches(e, selected)) && !pinned.has(e.source) && !pinned.has(e.target)).length;
45
+ }
46
+
47
+ /**
48
+ * Each side of the selection's one color: its lines' color on the Map (lineLook), and the same for everything else that
49
+ * stands for that side, the inspector's tab underline and bars and the toolbar's mark (the owner, 2026-10-29). Written
50
+ * out as whole class names, so Tailwind finds them.
51
+ */
52
+ export const SIDE_COLOR = {
53
+ uses: { line: "var(--edge-strong)", fill: "bg-edge-strong", underline: "after:bg-edge-strong" },
54
+ usedBy: { line: "var(--used-by-edge)", fill: "bg-used-by-edge", underline: "after:bg-used-by-edge" },
55
+ } as const;
56
+
57
+ /**
58
+ * Each line's color and layer by its tone (the Workbench redesign, ADR-070), its width going by its imports whatever the
59
+ * tone: the selection's lines out (what it uses) in the logo's spoke color and its lines in (what uses it) in gold, so a
60
+ * glance tells the two apart (the owner, 2026-10-28, gold since 2026-10-29), the hovered box's a step brighter in both directions, and the rest
61
+ * quiet, so the boxes lead and a dense level reads as structure until you point at a box (the owner, 2026-10-27: src/vs
62
+ * was "so messy and unreadable" with every line in the bright gray). Its arrowhead is the line's own color. A higher
63
+ * layer is drawn later, over the quieter lines it crosses or shares a run with.
64
+ */
65
+ export function lineLook(tone: Exclude<EdgeTone, "hidden">, edge: Pick<MapEdge, "source" | "target">, selected: string | null): { color: string; layer: number } {
66
+ if (tone === "selected") return { color: edge.target === selected ? SIDE_COLOR.usedBy.line : SIDE_COLOR.uses.line, layer: 2 };
67
+ if (tone === "preview") return { color: "var(--text-2)", layer: 1 };
68
+ return { color: "var(--edge-dim)", layer: 0 };
69
+ }
70
+
27
71
  /** One line's id, the same one React Flow gets. */
28
72
  export const edgeKey = (edge: Pick<MapEdge, "source" | "target">): string => `${edge.source}->${edge.target}`;
29
73
 
30
- /** The boxes joined to `box` by a line, in either direction. */
31
- function neighbours(edges: readonly MapEdge[], box: string | null): Set<string> {
74
+ /**
75
+ * Which of the selected box's lines are coloured (the owner, 2026-10-29): all of them, only its lines out to what it
76
+ * uses ("uses"), or only its lines in from what uses it ("usedBy"), in the inspector's own words. There's no choice of
77
+ * none. The other side isn't hidden but drawn gray like the rest of the page ("not completely gone"), so every line of
78
+ * the selection stays drawn whichever is picked, and picking moves no line and no count.
79
+ */
80
+ export type LineDirection = "all" | "uses" | "usedBy";
81
+
82
+ /** Whether `edge` is one of `box`'s lines on the side `direction` colours. */
83
+ const onSide = (edge: Pick<MapEdge, "source" | "target">, box: string, direction: LineDirection): boolean =>
84
+ direction === "uses" ? edge.source === box
85
+ : direction === "usedBy" ? edge.target === box
86
+ : edge.source === box || edge.target === box;
87
+
88
+ /** The selected box's lines that `direction` colours, in the level's order: the explorer lights the boxes at their ends. */
89
+ export function selectionLines(edges: readonly MapEdge[], selected: string | null, direction: LineDirection): MapEdge[] {
90
+ return selected === null ? [] : edges.filter((edge) => onSide(edge, selected, direction));
91
+ }
92
+
93
+ /**
94
+ * The sides of `selected` with no line to another box on the page, which can't be picked: they'd colour nothing (src
95
+ * on VS Code's top page uses nothing there). None with nothing selected.
96
+ */
97
+ export function sidesWithoutLines(edges: readonly MapEdge[], selected: string | null): LineDirection[] {
98
+ if (selected === null) return [];
99
+ const out = edges.some((e) => e.source === selected && e.target !== selected);
100
+ const into = edges.some((e) => e.target === selected && e.source !== selected);
101
+ return [...(out ? [] : ["uses" as const]), ...(into ? [] : ["usedBy" as const])];
102
+ }
103
+
104
+ /** The boxes joined to `box` by a line on the side `direction` colours (by default either). */
105
+ function neighbours(edges: readonly MapEdge[], box: string | null, direction: LineDirection = "all"): Set<string> {
32
106
  const found = new Set<string>();
33
107
  if (box === null) return found;
34
108
  for (const edge of edges){
109
+ if (!onSide(edge, box, direction)) continue;
35
110
  if (edge.source === box) found.add(edge.target);
36
111
  else if (edge.target === box) found.add(edge.source);
37
112
  }
@@ -43,18 +118,23 @@ function neighbours(edges: readonly MapEdge[], box: string | null): Set<string>
43
118
  * apart from React Flow. The selection always wins over the hover: a box linked to the selection stays linked when a
44
119
  * hovered box also touches it. A selection or hover that isn't one of this level's boxes counts as none. With
45
120
  * `quietLines` (a busy level), only the selection's and the hovered box's lines are drawn; the boxes are toned as usual.
121
+ * With `strongOnly`, a weak line (strongThreshold) is hidden too, unless it touches the selection, the hovered box or a
122
+ * `pinned` box (Claude Code's or the editor's highlights), whose lines always show. A `direction` other than "all"
123
+ * colours only that side of the selection's lines: the other side is toned as a line of the rest would be, faded or the
124
+ * hover's, but never hidden, and a box joined to the selection only by it fades.
46
125
  */
47
126
  export function mapEmphasis(
48
127
  boxes: readonly string[],
49
128
  edges: readonly MapEdge[],
50
129
  selected: string | null,
51
130
  hovered: string | null,
52
- options: { quietLines?: boolean } = {},
131
+ options: { quietLines?: boolean; strongOnly?: boolean; pinned?: ReadonlySet<string>; direction?: LineDirection } = {},
53
132
  ): { boxes: Map<string, BoxTone>; edges: Map<string, EdgeTone> } {
54
133
  const onLevel = new Set(boxes);
55
134
  const chosen = selected !== null && onLevel.has(selected) ? selected : null;
56
135
  const pointed = hovered !== null && onLevel.has(hovered) && hovered !== chosen ? hovered : null;
57
- const linked = neighbours(edges, chosen);
136
+ const direction = options.direction ?? "all";
137
+ const linked = neighbours(edges, chosen, direction);
58
138
  const previewed = neighbours(edges, pointed);
59
139
 
60
140
  const boxTones = new Map<string, BoxTone>();
@@ -65,12 +145,18 @@ export function mapEmphasis(
65
145
  else boxTones.set(box, chosen === null ? "normal" : "faded");
66
146
  }
67
147
 
148
+ const threshold = strongThreshold(Math.max(0, ...edges.map((e) => e.count)));
149
+ const pinned = options.pinned ?? new Set<string>();
68
150
  const edgeTones = new Map<string, EdgeTone>();
69
151
  for (const edge of edges){
70
152
  const touches = (box: string | null): boolean => box !== null && (edge.source === box || edge.target === box);
71
- if (touches(chosen)) edgeTones.set(edgeKey(edge), "selected");
153
+ const lit = pinned.has(edge.source) || pinned.has(edge.target);
154
+ if (chosen !== null && onSide(edge, chosen, direction)) edgeTones.set(edgeKey(edge), "selected");
155
+ // the side not picked stays drawn, whatever hides the rest, so picking a side never makes a line appear or go
156
+ else if (touches(chosen)) edgeTones.set(edgeKey(edge), touches(pointed) ? "preview" : "faded");
72
157
  else if (touches(pointed)) edgeTones.set(edgeKey(edge), "preview");
73
- else if (options.quietLines) edgeTones.set(edgeKey(edge), "hidden");
158
+ else if (options.quietLines && !lit) edgeTones.set(edgeKey(edge), "hidden");
159
+ else if (options.strongOnly && edge.count < threshold && !lit) edgeTones.set(edgeKey(edge), "hidden");
74
160
  else edgeTones.set(edgeKey(edge), chosen === null ? "normal" : "faded");
75
161
  }
76
162
 
@@ -0,0 +1,85 @@
1
+ import type { LevelResponse, MapNode } from "@/lib/contracts";
2
+ import { formatCount } from "./format";
3
+ import { maxFilesOf } from "./weights";
4
+
5
+ /**
6
+ * What the explorer lists (the Workbench redesign, ADR-070): the boxes of the level on screen as rows, folders first by
7
+ * how many files they hold, then the files by name, each folder with a bar for its size. Doorways to other pages are
8
+ * boxes on the Map but not rows, since they stand for boxes rather than being any. A folder drawn a page at a time says
9
+ * which page this is. Pure, so the order and the scale are tested alone (explorer.test.ts).
10
+ */
11
+
12
+ /** A folder's bar, 3 px for the smallest up to 36 px, its column's width, for the page's largest. */
13
+ export const BAR_MIN = 3;
14
+ export const BAR_MAX = 36;
15
+
16
+ /**
17
+ * A folder's bar on the same log scale as its dot on the Map (weights.ts's dotSize), so VS Code's src (10,280 files)
18
+ * and a folder of 4 both show: 36 × log10(files + 1) / log10(largest + 1). Never under 3 px, so an empty folder still
19
+ * has a mark, and never past its 36 px column.
20
+ */
21
+ export function barWidth(files: number, maxFiles: number): number {
22
+ if (maxFiles <= 0) return BAR_MIN;
23
+ const width = (BAR_MAX * Math.log10(Math.max(0, files) + 1)) / Math.log10(maxFiles + 1);
24
+ return Math.min(BAR_MAX, Math.max(BAR_MIN, width));
25
+ }
26
+
27
+ /**
28
+ * One row. A folder's carries its files, its bar's width in px, and what a screen reader says for it, "src, 10,280
29
+ * files", since the count alone has no unit. A file's is its name.
30
+ */
31
+ export type ExplorerRow =
32
+ | { kind: "folder"; path: string; name: string; files: number; bar: number; label: string }
33
+ | { kind: "file"; path: string; name: string };
34
+
35
+ /** How a row is drawn: the selection, a box joined to it by a drawn line, or neither. */
36
+ export type RowTone = "selected" | "linked" | "plain";
37
+
38
+ export function rowTone(path: string, selected: string | null, linked: ReadonlySet<string>): RowTone {
39
+ if (path === selected) return "selected";
40
+ return linked.has(path) ? "linked" : "plain";
41
+ }
42
+
43
+ /**
44
+ * Names in the order VS Code's own explorer gives them, so the list reads the way the person already knows: case
45
+ * mixed together and numbers by value (file2 before file10). Two names that differ only in case keep a fixed order.
46
+ */
47
+ const COLLATOR = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
48
+ const byName = (a: MapNode, b: MapNode): number => COLLATOR.compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
49
+
50
+ const files = (n: number): string => `${formatCount(n)} ${n === 1 ? "file" : "files"}`;
51
+
52
+ /** The level's rows: folders by files, the largest first and ties by name, then the files by name. No doorways. */
53
+ export function explorerRows(nodes: readonly MapNode[]): ExplorerRow[] {
54
+ const maxFiles = maxFilesOf(nodes);
55
+ const folders = nodes.filter((n) => n.kind === "folder").sort((a, b) => b.fileCount - a.fileCount || byName(a, b));
56
+ const loose = nodes.filter((n) => n.kind === "file").sort(byName);
57
+ return [
58
+ ...folders.map((n): ExplorerRow => ({
59
+ kind: "folder", path: n.path, name: n.name, files: n.fileCount, bar: barWidth(n.fileCount, maxFiles), label: `${n.name}, ${files(n.fileCount)}`,
60
+ })),
61
+ ...loose.map((n): ExplorerRow => ({ kind: "file", path: n.path, name: n.name })),
62
+ ];
63
+ }
64
+
65
+ /** The header's count of the rows, "13 items" or "1 item". */
66
+ export function itemCount(rows: number): string {
67
+ return `${formatCount(rows)} ${rows === 1 ? "item" : "items"}`;
68
+ }
69
+
70
+ /**
71
+ * The quiet line under a folder drawn a page at a time, "Page 2 of 5, 44 of 182 folders and files": which page, and how
72
+ * many of the folder's boxes it draws (the rest are on the other pages). Null when the folder fits on one page.
73
+ */
74
+ export function pageLine(level: Pick<LevelResponse, "page" | "pageCount" | "hiddenCount">, drawn: number): string | null {
75
+ if (level.pageCount <= 1) return null;
76
+ return `Page ${level.page + 1} of ${level.pageCount}, ${formatCount(drawn)} of ${formatCount(drawn + level.hiddenCount)} folders and files`;
77
+ }
78
+
79
+ export type ExplorerView = { rows: ExplorerRow[]; count: string; pageLine: string | null };
80
+
81
+ /** Everything the explorer draws for a level. */
82
+ export function explorerView(level: Pick<LevelResponse, "nodes" | "page" | "pageCount" | "hiddenCount">): ExplorerView {
83
+ const rows = explorerRows(level.nodes);
84
+ return { rows, count: itemCount(rows.length), pageLine: pageLine(level, rows.length) };
85
+ }
@@ -1,4 +1,4 @@
1
- import { Position, type EdgeMarker, type NodeHandle } from "@xyflow/react";
1
+ import { Position, type NodeHandle } from "@xyflow/react";
2
2
  import type { MapNode } from "@/lib/contracts";
3
3
  import { BOX_HEIGHT, BOX_WIDTH, type Position as Point } from "./levelLayout";
4
4
  import type { BoxData, BoxNode } from "./MapBox";
@@ -56,16 +56,21 @@ export function reuseUnchanged<T extends { id: string }>(previous: readonly T[],
56
56
  return unchanged ? (previous as T[]) : out;
57
57
  }
58
58
 
59
- /** Whether two box nodes draw the same: the same box, place, tone, ring, pulse and doorway caption. */
59
+ /** Whether two box nodes draw the same: the same box, place, tone, ring, pulse, dot and doorway caption. */
60
60
  export function sameBoxNode(a: BoxNode, b: BoxNode): boolean {
61
61
  return a.position.x === b.position.x && a.position.y === b.position.y
62
62
  && a.data.box === b.data.box && a.data.tone === b.data.tone && a.data.agent === b.data.agent
63
- && a.data.pulseKey === b.data.pulseKey && a.data.portal === b.data.portal;
63
+ && a.data.pulseKey === b.data.pulseKey && a.data.dot === b.data.dot && a.data.portal === b.data.portal;
64
64
  }
65
65
 
66
- /** Whether two lines draw the same: the same ends, route, label, stroke and arrowhead. */
66
+ /**
67
+ * Whether two lines draw the same: the same ends, route, width, head, color, count and count's place. The place is
68
+ * compared by value, since a count placed again where it was comes back as a new point. A hover draws nothing on a
69
+ * line (its words are the canvas's card), so it leaves every line as it was.
70
+ */
67
71
  export function sameRoutedEdge(a: RoutedEdgeType, b: RoutedEdgeType): boolean {
68
- return a.source === b.source && a.target === b.target && a.data?.points === b.data?.points && a.label === b.label
69
- && a.style?.stroke === b.style?.stroke && a.style?.strokeWidth === b.style?.strokeWidth && a.style?.opacity === b.style?.opacity
70
- && (a.markerEnd as EdgeMarker | undefined)?.color === (b.markerEnd as EdgeMarker | undefined)?.color;
72
+ return a.source === b.source && a.target === b.target && a.data?.points === b.data?.points
73
+ && a.data?.width === b.data?.width && a.data?.headWidth === b.data?.headWidth && a.data?.color === b.data?.color
74
+ && a.data?.count === b.data?.count && a.data?.countStrong === b.data?.countStrong
75
+ && a.data?.labelAt?.x === b.data?.labelAt?.x && a.data?.labelAt?.y === b.data?.labelAt?.y;
71
76
  }
package/app/map/format.ts CHANGED
@@ -38,9 +38,9 @@ const ORDER: readonly StageName[] = ["structure", "summaries", "embeddings"];
38
38
 
39
39
  const progress = (stage: StageStatus): string => (stage.total > 0 ? ` ${formatCount(stage.done)} / ${formatCount(stage.total)}` : "");
40
40
 
41
- /** One stage as the status menu lists it: "Summaries 12,340 / 18,637 · ready". */
41
+ /** One stage as the status menu lists it: "Summaries 12,340 / 18,637, ready". */
42
42
  export function stageLabel(name: StageName, stage: StageStatus): string {
43
- return `${TITLES[name]}${progress(stage)} · ${stage.state}`;
43
+ return `${TITLES[name]}${progress(stage)}, ${stage.state}`;
44
44
  }
45
45
 
46
46
  export type Tone = "ok" | "busy" | "warn" | "off";
@@ -51,9 +51,10 @@ export function followStep(step: "none" | "replace" | "push", mode: "push" | "re
51
51
 
52
52
  /**
53
53
  * How many of the Map's own steps lie behind a history entry: the `mapDepth` the Map writes into `history.state` with
54
- * each push (and keeps with each rewrite). VS Code's panel has no browser Back, so the Map's own Back control is enabled
55
- * only above 0: going back past the Map's first entry would take the webview off the Map (owner review, 2026-10-25).
56
- * An entry the Map didn't write, a fresh load's or another page's, is 0, and so is anything that isn't a whole number.
54
+ * each push (and keeps with each rewrite). The Map's own Back control, in VS Code's panel (owner review, 2026-10-25) and
55
+ * in the browser (2026-10-29), is enabled only above 0: going back past the Map's first entry would take the webview off
56
+ * the Map, or a browser tab to the page before it. An entry the Map didn't write, a fresh load's or another page's, is
57
+ * 0, and so is anything that isn't a whole number.
57
58
  */
58
59
  export function historyDepth(state: unknown): number {
59
60
  const depth = typeof state === "object" && state !== null ? (state as { mapDepth?: unknown }).mapDepth : undefined;
@@ -0,0 +1,113 @@
1
+ import type { DescribeResponse, LevelResponse, MapNode } from "@/lib/contracts";
2
+ import type { GitHubSource } from "./api";
3
+ import { formatCompact, formatCount } from "./format";
4
+ import { nameOf } from "./paths";
5
+
6
+ /**
7
+ * The inspector's words and numbers (the Workbench redesign, 2026-10-27): pure, so DetailPanel's rules are tested on
8
+ * their own. The owner's copy rule holds for everything here that reaches the screen, so no colons, semicolons or
9
+ * middle dots, and commas where the old panel had " · ".
10
+ */
11
+
12
+ /** The inspector's tabs: what it uses, what uses it, and what's inside (a folder's contents, a file's declarations). */
13
+ export type InspectorTab = "uses" | "usedBy" | "inside";
14
+
15
+ /**
16
+ * The tab a panel opens on. Used by when anything uses the target, since that's what the arrows into the selected box
17
+ * show, else Uses when it uses anything, else what's inside. A folder nothing links to opens on its contents.
18
+ */
19
+ export function defaultTab(target: Pick<DescribeResponse, "uses" | "usedBy">): InspectorTab {
20
+ if (target.usedBy.length > 0) return "usedBy";
21
+ if (target.uses.length > 0) return "uses";
22
+ return "inside";
23
+ }
24
+
25
+ /** A row's bar never gets narrower than this share of the row, in percent, so a count of 1 next to 665 still shows. */
26
+ export const MIN_BAR = 1.5;
27
+
28
+ /**
29
+ * A row's bar, as a percent of the row's width: its count over the largest count in the same tab (the mockup's
30
+ * `.irow .b`). The largest row's bar is the whole row.
31
+ */
32
+ export function barWidth(count: number, largest: number): number {
33
+ if (largest <= 0) return MIN_BAR;
34
+ return Math.min(100, Math.max(MIN_BAR, (100 * count) / largest));
35
+ }
36
+
37
+ /** The largest count among a tab's rows (0 for none): every row's bar is measured against it. */
38
+ export function largestCount(rows: readonly { count: number }[]): number {
39
+ return rows.reduce((most, row) => Math.max(most, row.count), 0);
40
+ }
41
+
42
+ type Kind = DescribeResponse["kind"];
43
+
44
+ /**
45
+ * The target's kind before describe() answers, so the name doesn't gain its slash a moment after the panel opens: the
46
+ * repo, the folder on screen, or a box on the level. Undefined when the level doesn't say (a path found by search).
47
+ */
48
+ export function kindBeforeDescribe(targetPath: string, level: Pick<LevelResponse, "path" | "nodes">): Kind | undefined {
49
+ if (targetPath === "") return "repo";
50
+ if (targetPath === level.path) return "folder";
51
+ const kind = level.nodes.find((n) => n.path === targetPath)?.kind;
52
+ return kind === "folder" || kind === "file" ? kind : undefined;
53
+ }
54
+
55
+ /**
56
+ * What the inspector calls its target: a folder's name with a trailing slash ("src/"), as the code font writes a
57
+ * folder, the repo's own name, or a file's name. While the kind isn't known yet the name shows bare.
58
+ */
59
+ export function inspectorName(targetPath: string, repoRoot: string, kind: Kind | undefined): string {
60
+ const name = nameOf(targetPath, repoRoot);
61
+ return kind === "folder" ? `${name}/` : name;
62
+ }
63
+
64
+ /**
65
+ * The name's hover title: the whole path, which the name alone doesn't show (a folder's with its slash, the repo's
66
+ * root). It always holds the name itself, so a name cut off with an ellipsis can be read in full (check-layout's (c)).
67
+ */
68
+ export function inspectorTitle(targetPath: string, repoRoot: string, kind: Kind | undefined): string {
69
+ if (targetPath === "") return repoRoot;
70
+ return kind === "folder" ? `${targetPath}/` : targetPath;
71
+ }
72
+
73
+ /** The word for a count: "line" for 1, "lines" for any other. */
74
+ const word = (n: number, one: string, many: string): string => (n === 1 ? one : many);
75
+
76
+ /**
77
+ * Under the name: "10,280 files, 3.5M lines", or a file's "120 lines", then what changed since it was summarized
78
+ * ("10,280 files, 3.5M lines, 12 changed since summarized").
79
+ */
80
+ export function sizeLine(target: Pick<DescribeResponse, "kind" | "stats" | "stalePaths">): string {
81
+ const { fileCount, lineCount } = target.stats;
82
+ const lines = `${formatCompact(lineCount)} ${word(lineCount, "line", "lines")}`;
83
+ const size = target.kind === "file" ? lines : `${formatCount(fileCount)} ${word(fileCount, "file", "files")}, ${lines}`;
84
+ return target.stalePaths > 0 ? `${size}, ${formatCount(target.stalePaths)} changed since summarized` : size;
85
+ }
86
+
87
+ /** A row's hover title in Uses and Used by: "src/vs/base, 12 imports". */
88
+ export function importsTitle(targetPath: string, count: number): string {
89
+ return `${targetPath}, ${formatCount(count)} ${word(count, "import", "imports")}`;
90
+ }
91
+
92
+ /**
93
+ * A Contents row's hover title: its path, a folder's file count, and its one-line summary on a line of its own
94
+ * ("src/vs/base, 1,204 files", then "Shared utilities…").
95
+ */
96
+ export function childTitle(child: Pick<MapNode, "path" | "kind" | "fileCount" | "tagline">): string {
97
+ const where = child.kind === "folder" ? `${child.path}, ${formatCount(child.fileCount)} ${word(child.fileCount, "file", "files")}` : child.path;
98
+ return child.tagline ? `${where}\n${child.tagline}` : where;
99
+ }
100
+
101
+ /** A declaration's place, right of its name: "function, line 272", the line as an editor writes it (no comma). */
102
+ export function declarationPlace(symbol: { kind: string; startLine: number }): string {
103
+ return `${symbol.kind}, line ${symbol.startLine}`;
104
+ }
105
+
106
+ /**
107
+ * A folder (or the repo, "") on GitHub at the commit the demo's index was built from (ADR-049). links.ts's githubLink
108
+ * is a file's blob link, and a folder is a tree.
109
+ */
110
+ export function githubFolderLink(source: GitHubSource, folderPath: string): string {
111
+ const root = `https://github.com/${source.owner}/${source.name}/tree/${source.commit}`;
112
+ return folderPath === "" ? root : `${root}/${folderPath.split("/").map(encodeURIComponent).join("/")}`;
113
+ }
package/app/map/labels.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { DescribeResponse, MapNode } from "@/lib/contracts";
1
+ import type { DescribeResponse, MapEdge, MapNode } from "@/lib/contracts";
2
+ import { edgeKey } from "./emphasis";
2
3
  import { formatCount } from "./format";
3
4
 
4
5
  /**
@@ -14,12 +15,57 @@ function subject(box: MapNode): string {
14
15
 
15
16
  const capitalized = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1);
16
17
 
17
- /** A line's hover label: "editor uses base · 12 imports" (the arrow runs from the user to the used). */
18
+ /** A line's sentence in its parts, so the hover card can cut a long name short and keep the rest whole. */
19
+ export type LineParts = { who: string; verb: string; whom: string; imports: string };
20
+
21
+ /** lineLabel's parts: "editor", "uses", "base" and "12 imports". */
22
+ export function lineParts(source: MapNode, target: MapNode, count: number): LineParts {
23
+ return {
24
+ who: source.kind === "portal" ? capitalized(subject(source)) : subject(source),
25
+ verb: source.kind === "portal" ? "use" : "uses",
26
+ whom: subject(target),
27
+ imports: `${formatCount(count)} import${count === 1 ? "" : "s"}`,
28
+ };
29
+ }
30
+
31
+ /** A line's hover label: "editor uses base, 12 imports" (the arrow runs from the user to the used). */
18
32
  export function lineLabel(source: MapNode, target: MapNode, count: number): string {
19
- const imports = `${formatCount(count)} import${count === 1 ? "" : "s"}`;
20
- const verb = source.kind === "portal" ? "use" : "uses";
21
- const who = source.kind === "portal" ? capitalized(subject(source)) : subject(source);
22
- return `${who} ${verb} ${subject(target)} · ${imports}`;
33
+ const { who, verb, whom, imports } = lineParts(source, target, count);
34
+ return `${who} ${verb} ${whom}, ${imports}`;
35
+ }
36
+
37
+ /** One line in a run's list: the box at its far end (or both ends, where the run shares neither) and its imports. */
38
+ export type RunRow = { id: string; name: string; count: number };
39
+
40
+ /**
41
+ * What the hover card says about the lines under the pointer (the owner, 2026-10-29: on merged lines one line's
42
+ * sentence didn't say which line it meant). One line: its sentence. Lines into one box: "Into src", then each by the
43
+ * box it comes from; out of one box: "From build", then each by the box it goes to. Lines that share neither end (no
44
+ * route merges them, but a list never guesses): "Lines here", each as "build → src". The heaviest first, then by name.
45
+ */
46
+ export type RunWords =
47
+ | { kind: "one"; id: string; parts: LineParts; sentence: string }
48
+ | { kind: "run"; title: string; rows: RunRow[] };
49
+
50
+ export function runWords(lines: readonly MapEdge[], byPath: ReadonlyMap<string, MapNode>): RunWords {
51
+ // every line on a level has both its boxes there
52
+ const box = (path: string): MapNode => byPath.get(path)!;
53
+ if (lines.length === 1){
54
+ const [line] = lines;
55
+ return { kind: "one", id: edgeKey(line), parts: lineParts(box(line.source), box(line.target), line.count), sentence: lineLabel(box(line.source), box(line.target), line.count) };
56
+ }
57
+ // a row starts with its name, so a doorway's "boxes on Page 2" takes a capital
58
+ const name = (path: string): string => box(path).kind === "portal" ? capitalized(subject(box(path))) : box(path).name;
59
+ const into = lines.every((l) => l.target === lines[0].target);
60
+ const from = !into && lines.every((l) => l.source === lines[0].source);
61
+ const title = into ? `Into ${subject(box(lines[0].target))}` : from ? `From ${subject(box(lines[0].source))}` : "Lines here";
62
+ const rows = lines.map((l) => ({
63
+ id: edgeKey(l),
64
+ name: into ? name(l.source) : from ? name(l.target) : `${subject(box(l.source))} → ${subject(box(l.target))}`,
65
+ count: l.count,
66
+ }));
67
+ rows.sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) || (a.id < b.id ? -1 : 1));
68
+ return { kind: "run", title, rows };
23
69
  }
24
70
 
25
71
  /** A box's hover title: its path, and its one-line summary when there is one. */
@@ -30,5 +76,18 @@ export function boxTitle(box: Pick<MapNode, "path" | "name" | "tagline">): strin
30
76
 
31
77
  /** The detail panel's words for a file with no declarations: a file over the size cap had them left unread (ADR-053). */
32
78
  export function noDeclarationsText(file: Pick<DescribeResponse, "importsOnly">): string {
33
- return file.importsOnly ? "Not read (over the 1 MB size cap; imports only)." : "None found.";
79
+ return file.importsOnly ? "Not read, since it's over the 1 MB size cap. Only its imports were." : "None found.";
80
+ }
81
+
82
+ /**
83
+ * The toolbar's one-line finding about a level (the Workbench redesign, ADR-070): its heaviest line, by the two boxes'
84
+ * names, with how many of the level's imports it carries, "Heaviest link is extensions → src, 665 of 708 imports".
85
+ * Null for a level with no lines. Ties go to the first by source, then target, so a level always says the same.
86
+ */
87
+ export function heaviestLink(level: { nodes: readonly MapNode[]; edges: readonly { source: string; target: string; count: number }[] }): { from: string; to: string; count: number; total: number } | null {
88
+ const lines = level.edges.filter((e) => e.source !== e.target);
89
+ if (lines.length === 0) return null;
90
+ const heaviest = [...lines].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))[0];
91
+ const name = (path: string): string => level.nodes.find((n) => n.path === path)?.name ?? path;
92
+ return { from: name(heaviest.source), to: name(heaviest.target), count: heaviest.count, total: lines.reduce((sum, e) => sum + e.count, 0) };
34
93
  }