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,27 +1,43 @@
1
1
  "use client";
2
2
 
3
- import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
4
- import { IconArrowLeft, IconArrowRight, IconCode, IconFile, IconFolder, IconX } from "@tabler/icons-react";
3
+ import { memo, useCallback, useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
4
+ import { IconArrowLeft, IconArrowRight, IconFile, IconFolder, IconX } from "@tabler/icons-react";
5
5
  import type { AreaCount, DescribeResponse, LevelResponse, MapNode, SummarizeResponse, SymbolInfo } from "@/lib/contracts";
6
6
  import { api, ApiError, demoGithub, type GitHubSource } from "./api";
7
- import { formatCompact, formatCount } from "./format";
7
+ import { SIDE_COLOR } from "./emphasis";
8
+ import { formatCount } from "./format";
9
+ import {
10
+ barWidth,
11
+ childTitle,
12
+ declarationPlace,
13
+ defaultTab,
14
+ githubFolderLink,
15
+ importsTitle,
16
+ inspectorName,
17
+ inspectorTitle,
18
+ kindBeforeDescribe,
19
+ largestCount,
20
+ sizeLine,
21
+ type InspectorTab,
22
+ } from "./inspector";
8
23
  import { noDeclarationsText } from "./labels";
9
24
  import { editorLink, githubLink } from "./links";
10
25
  import { DEMO } from "./mode";
11
- import { PANEL_WIDTH } from "./narrow";
12
26
  import { nameOf, shortPath } from "./paths";
13
27
  import { measureAfterPaint } from "./perf";
14
28
  import { linkedFrom, portalCaption, portalLinks } from "./portals";
15
- import { failureFor, summaryLine, wantsSummary } from "./summaries";
29
+ import { askKey, failureFor, summaryLine, summaryNotes, wantsSummary } from "./summaries";
16
30
  import SourceViewer from "./SourceViewer";
17
31
  import { inlineCode, splitSummary } from "./text";
18
32
 
19
33
  type DetailPanelProps = {
20
- /** The selection: a file, a folder, the repo, or one of the level's portals. */
34
+ /** The selection: a file, a folder, the repo, or one of the level's portals. Or, with onScreenFolder, the folder on screen. */
21
35
  path: string;
22
36
  level: LevelResponse;
23
37
  /** Moves when the index changes; the panel then refetches. */
24
38
  indexVersion: number;
39
+ /** The summaries stage is off (no API key in the Map's process), so a summary an older prompt wrote isn't asked for (P11). */
40
+ summariesOff: boolean;
25
41
  /** Go to the level holding a path and select it there. */
26
42
  onFocus: (targetPath: string) => void;
27
43
  onOpen: (folder: string) => void;
@@ -29,37 +45,53 @@ type DetailPanelProps = {
29
45
  onTurnPage: (page: number) => void;
30
46
  onClose: () => void;
31
47
  /**
32
- * Inside VS Code's panel only: a declaration opens at its line in the editor instead of the source viewer, which
33
- * "View source" still opens (design §4.4).
48
+ * Inside VS Code's panel only: "Open in VS Code", here and in the source viewer (at the line it shows), asks the
49
+ * editor over the bridge. A declaration shows in the source viewer, as in a browser tab (the owner, 2026-10-29).
34
50
  */
35
51
  onOpenInEditor?: (path: string, line?: number) => void;
36
- /**
37
- * Covering the whole canvas (narrow.ts's panelLayout): then it fills the Map's width, whatever that is as the window
38
- * resizes. Beside the canvas it's PANEL_WIDTH wide, and the canvas's controls move left by as much.
39
- */
40
- covers: boolean;
52
+ /** Where MapView put it (app/map/shell.ts's InspectorPlace, without "none"): beside the canvas, under it, over it as a drawer, or over all of it. */
53
+ placement: "beside" | "below" | "drawer" | "cover";
54
+ /** Nothing is selected and the panel describes the folder on screen: then it has no close button. */
55
+ onScreenFolder?: boolean;
41
56
  /** The Map's error line, shown as the panel's last row while the panel covers the canvas it would sit on. */
42
57
  alert?: string;
43
58
  };
44
59
 
60
+ /** Rows shown before "+N more": enough to act on without scrolling. */
61
+ const PREVIEW = 8;
62
+
63
+ /** The one primary button, Open folder (the mockup's .primary): the accent is for meaning, and this is the way in. */
64
+ const PRIMARY = "inline-flex h-7 shrink-0 items-center whitespace-nowrap rounded-[5px] bg-accent px-3 text-[13px] font-semibold text-on-accent hover:brightness-110";
65
+ /** Every other action, and the doorway's go-to-page buttons (the mockup's .secondary). */
66
+ const SECONDARY = "inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[5px] border border-border px-3 text-[13px] text-text hover:border-text-3";
67
+ /** A control inside prose: Full summary, Show less, Try again (the mockup's .more). */
68
+ const TEXT_LINK = "text-text underline decoration-text-3 underline-offset-2 hover:decoration-text";
69
+ /** "+N more" and "Show fewer" under a list, quieter than the rows they unfold. */
70
+ const QUIET = "mt-1 rounded-[5px] px-2 py-1 text-[12.5px] text-text-2 hover:text-text";
71
+
45
72
  /**
46
- * In a short window (under 480 px tall) the whole panel scrolls, its header too, so the body isn't left a few lines
47
- * under a header that never moves; the error row stays pinned to the bottom.
73
+ * The body under the name. One column that scrolls on its own, the header staying put. Under the canvas, in a panel at
74
+ * least 720 px wide, two columns side by side that each scroll (the summary and actions, then the tabs), as collapse
75
+ * option C's render at 820 × 900 has it. The queries go by the panel's own width (@container on the frame), not the
76
+ * window's, since the explorer and the canvas share the window with it.
77
+ *
78
+ * In a short window (under 480 px tall) the whole panel scrolls, its header too (SHORT_FRAME), so the body isn't left a
79
+ * few lines, or none under the Map's error row, beneath a header that never moves; the error row stays pinned to the
80
+ * bottom. Under the canvas the window is at least 640 px tall (shell.ts), so the two columns never meet this.
48
81
  */
82
+ const BODY = "min-h-0 flex-1 overflow-y-auto pb-4 [@media(height<480px)]:flex-none [@media(height<480px)]:overflow-visible";
49
83
  const SHORT_FRAME = "[@media(height<480px)]:overflow-y-auto";
50
- const SHORT_BODY = "[@media(height<480px)]:flex-none [@media(height<480px)]:overflow-visible";
51
-
52
- /** Chips and rows shown before "+N more" or "View all": enough to act on without scrolling. */
53
- const PREVIEW = 8;
54
-
55
- const KIND_LABELS: Record<DescribeResponse["kind"], string> = { repo: "Repository", folder: "Folder", file: "File" };
84
+ // one row as tall as the body (grid-rows-1 is minmax(0, 1fr)), so each column scrolls instead of the body
85
+ const BODY_TWO = "@min-[720px]:grid @min-[720px]:grid-cols-2 @min-[720px]:grid-rows-1 @min-[720px]:pb-0";
86
+ const COLUMN_TWO = "@min-[720px]:min-h-0 @min-[720px]:overflow-y-auto @min-[720px]:pb-4";
56
87
 
57
88
  /**
58
- * The details panel (VISUAL_REFRESH.md §5), top to bottom: what kind of thing it is, its name, its size, a short
59
- * summary that expands, what it depends on and what uses it (chips that focus the Map), and what's inside. A missing
60
- * or out-of-date summary is asked for once ("summarize now", PIVOT_SPEC.md §5.3), and the panel says so while it's
61
- * refreshing. A portal gets its own panel (PortalPanel). MapView remounts the panel for each selection (key), so
62
- * nothing leaks from one target to the next.
89
+ * The inspector (the Workbench redesign, 2026-10-27; mockup3.html's .insp), top to bottom: the name in the code font
90
+ * and its size, the tagline and a summary that expands, the actions that fit the target, then tabs for what it uses,
91
+ * what uses it, and what's inside, each row a name, a count and a bar. A missing or out-of-date summary, or one an older
92
+ * prompt wrote, is asked for once ("summarize now", PIVOT_SPEC.md §5.3), and the panel says so while it's refreshing.
93
+ * A portal gets its own panel (PortalPanel). MapView places it (beside, under or over the canvas) and remounts it for
94
+ * each selection (key), so nothing leaks from one target to the next.
63
95
  */
64
96
  function DetailPanel(props: DetailPanelProps){
65
97
  const portal = props.level.nodes.find((n) => n.path === props.path && n.kind === "portal");
@@ -71,54 +103,61 @@ export default memo(DetailPanel);
71
103
 
72
104
  type PanelFrameProps = {
73
105
  path: string;
74
- covers: boolean;
106
+ placement: DetailPanelProps["placement"];
107
+ onScreenFolder?: boolean;
75
108
  alert?: string;
76
- kindLabel: string;
77
109
  name: string;
110
+ /** The name's hover title: the whole path, which the name alone doesn't show. It holds the name too. */
78
111
  title: string;
79
- subtitle: string;
80
- /** Under the name: the stats line and its actions. */
81
- header?: ReactNode;
112
+ /** Under the name: the sizes, or a doorway's caption. */
113
+ meta?: string;
82
114
  onClose: () => void;
83
115
  children: ReactNode;
84
116
  };
85
117
 
86
118
  /**
87
- * The panel's frame: the kind, the name, the path under it, a close button, then the header's extras and the body, and
88
- * the Map's error line as its last row while it covers the canvas (so the row never hides the end of the body).
119
+ * The panel's frame. It fills whatever MapView put it in. The name and the close button stay put while the body under
120
+ * them scrolls, and the Map's error line is its last row while it covers the canvas (so the row never hides the end of
121
+ * the body). Under the canvas its border is on top, everywhere else on its left.
89
122
  */
90
- function PanelFrame({ path, covers, alert, kindLabel, name, title, subtitle, header, onClose, children }: PanelFrameProps){
123
+ function PanelFrame({ path, placement, onScreenFolder = false, alert, name, title, meta, onClose, children }: PanelFrameProps){
91
124
  return (
92
- <aside data-panel={path} style={{ width: covers ? "100%" : PANEL_WIDTH }} className={`pointer-events-auto absolute inset-y-0 right-0 z-10 flex flex-col border-l border-hairline bg-panel ${SHORT_FRAME}`}>
93
- <header className="border-b border-hairline px-4 py-4">
94
- <div className="flex items-start justify-between gap-3">
95
- <div className="min-w-0">
96
- <div className="text-[12px] leading-4 text-text-2">{kindLabel}</div>
97
- <h2 className="mt-1 truncate text-[18px] font-medium leading-6 text-text" title={title}>{name}</h2>
98
- <div className="truncate font-mono text-[12px] leading-4 text-text-2" title={subtitle}>{subtitle}</div>
99
- </div>
100
- <button type="button" onClick={onClose} aria-label="Close the panel" className="shrink-0 rounded-md p-1 text-text-2 hover:bg-surface hover:text-text">
125
+ <aside
126
+ data-panel={path}
127
+ data-placement={placement}
128
+ data-on-screen={onScreenFolder || undefined}
129
+ className={`@container pointer-events-auto flex h-full min-h-0 w-full min-w-0 flex-col border-divider bg-panel ${SHORT_FRAME} ${placement === "below" ? "border-t" : "border-l"}`}
130
+ >
131
+ <header className="flex shrink-0 items-start justify-between gap-2 px-4 pb-3 pt-4">
132
+ <div className="min-w-0">
133
+ <h2 className="truncate font-mono text-[15px] font-medium leading-5 text-text" title={title}>{name}</h2>
134
+ {meta && <p className="mt-0.5 text-[12.5px] leading-[18px] text-text-2">{meta}</p>}
135
+ </div>
136
+ {/* describing the folder on screen, it's part of the layout rather than something opened, so it stays */}
137
+ {!onScreenFolder && (
138
+ <button type="button" onClick={onClose} aria-label="Close the panel" className="-mr-1 shrink-0 rounded-[5px] p-1 text-text-2 hover:bg-node hover:text-text">
101
139
  <IconX aria-hidden size={16} stroke={1.75} />
102
140
  </button>
103
- </div>
104
- {header}
141
+ )}
105
142
  </header>
106
143
  {children}
107
- {/* in a short window, where the whole panel scrolls, it stays pinned over the rows scrolling under it */}
108
- {alert && <p role="alert" data-covers className="pointer-events-none sticky bottom-0 shrink-0 border-t border-hairline bg-panel px-4 py-2 text-center text-[13px] text-danger [overflow-wrap:anywhere]">{alert}</p>}
144
+ {alert && <p role="alert" data-covers className="pointer-events-none sticky bottom-0 shrink-0 border-t border-divider bg-panel px-4 py-2 text-center text-[13px] text-danger [overflow-wrap:anywhere]">{alert}</p>}
109
145
  </aside>
110
146
  );
111
147
  }
112
148
 
113
149
  /** A file, a folder or the repo: described by the server (GET /api/describe). */
114
- function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOpenInEditor, covers, alert }: DetailPanelProps){
150
+ function TargetPanel({ path, level, indexVersion, summariesOff, onFocus, onOpen, onClose, onOpenInEditor, placement, onScreenFolder, alert }: DetailPanelProps){
115
151
  const target = path;
116
152
  const [data, setData] = useState<DescribeResponse | null>(null);
117
153
  const [error, setError] = useState<ApiError | null>(null);
118
154
  const [fresh, setFresh] = useState<SummarizeResponse | null>(null);
119
155
  const [summaryError, setSummaryError] = useState<ApiError | null>(null);
120
156
  const [refreshing, setRefreshing] = useState(false);
121
- // keys already asked for: "<path>@<index version>", or the bare path once the server answered NOT_READY
157
+ // the tab on screen: picked from the first answer (defaultTab), then kept while this panel is open, refetches too
158
+ const [tab, setTab] = useState<InspectorTab | null>(null);
159
+ // keys already asked for (askKey): "<path>@<index version>" or "<path>@older", or the bare path once the server
160
+ // answered NOT_READY
122
161
  const asked = useRef(new Set<string>());
123
162
  // the demo's source links go to GitHub at the commit the index was built from (ADR-049)
124
163
  const [github, setGithub] = useState<GitHubSource | null>(null);
@@ -130,7 +169,7 @@ function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOp
130
169
  useEffect(() => {
131
170
  const controller = new AbortController();
132
171
  api.describe(target, controller.signal).then(
133
- (next) => { setData(next); setError(null); },
172
+ (next) => { setData(next); setError(null); setTab((current) => current ?? defaultTab(next)); },
134
173
  (err: unknown) => { if (err instanceof ApiError) setError(err); },
135
174
  );
136
175
  return () => controller.abort();
@@ -154,17 +193,18 @@ function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOp
154
193
  );
155
194
  };
156
195
 
157
- // "summarize now", once per target and index version: the server joins a request already running (ADR-033).
158
- // After a NOT_READY answer, once per target: the index moves with every child summary the worker writes, and each
159
- // move would otherwise ask again for a folder that still isn't ready. Reselecting it asks afresh (a new panel).
160
- // The static demo has no server to ask, so it never does (ADR-049).
196
+ // "summarize now" for a summary that's missing, out of date or in the older style (wantsSummary), once per target
197
+ // and index version, or once per panel for the older style alone (askKey): the server joins a request already
198
+ // running (ADR-033). After a NOT_READY answer, once per target: the index moves with every child summary the worker
199
+ // writes, and each move would otherwise ask again for a folder that still isn't ready. Reselecting it asks afresh
200
+ // (a new panel). The static demo has no server to ask, so it never does (ADR-049).
161
201
  useEffect(() => {
162
- if (DEMO || data === null || !wantsSummary(data.summaryStatus)) return;
163
- const key = `${data.path}@${data.indexVersion}`;
202
+ if (DEMO || data === null || !wantsSummary(data, summariesOff)) return;
203
+ const key = askKey(data);
164
204
  if (asked.current.has(data.path) || asked.current.has(key)) return;
165
205
  asked.current.add(key);
166
206
  summarize(data.path, () => asked.current.add(data.path));
167
- }, [data]);
207
+ }, [data, summariesOff]);
168
208
 
169
209
  const retry = (): void => {
170
210
  if (data === null) return;
@@ -172,53 +212,56 @@ function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOp
172
212
  summarize(data.path);
173
213
  };
174
214
 
175
- const name = nameOf(target, level.repo);
176
- const kindLabel = data ? KIND_LABELS[data.kind] : "";
215
+ const kind = data?.kind ?? kindBeforeDescribe(target, level);
177
216
  // the source viewer (owner review): closed, open on the whole file, or open on a declaration's lines
178
217
  const [viewing, setViewing] = useState<{ lines?: { start: number; end: number } } | null>(null);
179
218
  const closeViewer = useCallback(() => setViewing(null), []);
219
+ const two = placement === "below";
220
+ const failed = error && <p className="text-[13px] text-danger [overflow-wrap:anywhere]">{error.message}</p>;
180
221
 
181
222
  return (
182
223
  <PanelFrame
183
224
  path={path}
225
+ placement={placement}
226
+ onScreenFolder={onScreenFolder}
184
227
  alert={alert}
185
- covers={covers}
186
- kindLabel={kindLabel}
187
- name={name}
188
- title={target || name}
189
- subtitle={target || "(repo root)"}
190
- header={data && (
191
- <Stats
192
- data={data}
193
- github={github}
194
- onOpen={onOpen}
195
- onViewSource={() => setViewing({})}
196
- onOpenInEditor={onOpenInEditor && (() => onOpenInEditor(data.path))}
197
- />
198
- )}
228
+ name={inspectorName(target, level.repo, kind)}
229
+ title={inspectorTitle(target, level.repo, kind)}
230
+ meta={data ? sizeLine(data) : undefined}
199
231
  onClose={onClose}
200
232
  >
201
- <div className={`flex-1 space-y-6 overflow-y-auto px-4 py-4 text-[14px] leading-normal text-text ${SHORT_BODY}`}>
202
- {error && <p className="text-danger">{error.message}</p>}
203
- {!data && !error && <p className="text-text-2">Loading…</p>}
204
- {data && (
205
- <Details
206
- data={data}
207
- fresh={fresh}
208
- refreshing={refreshing}
209
- summaryError={summaryError}
210
- onRetry={retry}
211
- onFocus={onFocus}
212
- onOpen={onOpen}
213
- onViewLines={DEMO
214
- ? (start, end) => { if (github) window.open(githubLink(github, data.path, { start, end }), "_blank", "noreferrer"); }
215
- : onOpenInEditor
216
- // the real editor is right there (design §4.4)
217
- ? (start) => onOpenInEditor(data.path, start)
218
- : (start, end) => setViewing({ lines: { start, end } })}
219
- />
220
- )}
221
- </div>
233
+ {data
234
+ ? (
235
+ <div className={`${BODY} ${two ? BODY_TWO : ""}`}>
236
+ <div className={`flex flex-col gap-3 px-4 ${two ? `${COLUMN_TWO} @min-[720px]:pr-3` : ""}`}>
237
+ {failed}
238
+ <Summary data={data} fresh={fresh} refreshing={refreshing} summaryError={summaryError} onRetry={retry} />
239
+ <Actions
240
+ data={data}
241
+ github={github}
242
+ onScreenFolder={onScreenFolder ?? false}
243
+ onOpen={onOpen}
244
+ onViewSource={() => setViewing({})}
245
+ onOpenInEditor={onOpenInEditor && (() => onOpenInEditor(data.path))}
246
+ />
247
+ </div>
248
+ <div className={`px-4 pt-4 ${two ? `${COLUMN_TWO} @min-[720px]:pl-3 @min-[720px]:pt-1` : ""}`}>
249
+ <Relations
250
+ data={data}
251
+ tab={tab ?? defaultTab(data)}
252
+ onTab={setTab}
253
+ onFocus={onFocus}
254
+ onOpen={onOpen}
255
+ // inside VS Code's panel too, a declaration shows in View source, whose Open in VS Code
256
+ // opens the editor at its line: nothing but an Open moves the editor (the owner, 2026-10-29)
257
+ onViewLines={DEMO
258
+ ? (start, end) => { if (github) window.open(githubLink(github, data.path, { start, end }), "_blank", "noreferrer"); }
259
+ : (start, end) => setViewing({ lines: { start, end } })}
260
+ />
261
+ </div>
262
+ </div>
263
+ )
264
+ : <div className={`${BODY} px-4`}>{failed || <p className="text-[13px] text-text-2">Loading…</p>}</div>}
222
265
  {viewing && data?.kind === "file" && (
223
266
  <SourceViewer
224
267
  path={data.path}
@@ -235,11 +278,12 @@ type Linked = { path: string; count: number };
235
278
 
236
279
  /**
237
280
  * A portal selected: the page (or pages) behind it, a way to go there, and which boxes on this page have lines to it,
238
- * each a link that selects that box. Everything comes from the level on screen; there's nothing to fetch.
281
+ * each a row that selects that box. Everything comes from the level on screen; there's nothing to fetch.
239
282
  */
240
- function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose, covers, alert }: DetailPanelProps & { portal: MapNode }){
283
+ function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose, placement, onScreenFolder, alert }: DetailPanelProps & { portal: MapNode }){
241
284
  const links = portalLinks(level.edges, portal.path);
242
285
  const linked = linkedFrom(level, portal.path);
286
+ const largest = largestCount(linked);
243
287
  const pages = portal.page !== undefined ? [portal.page] : portal.pages ?? [];
244
288
  const byPath = new Map(level.nodes.map((n) => [n.path, n]));
245
289
 
@@ -250,27 +294,21 @@ function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose, covers
250
294
  return (
251
295
  <PanelFrame
252
296
  path={path}
297
+ placement={placement}
298
+ onScreenFolder={onScreenFolder}
253
299
  alert={alert}
254
- covers={covers}
255
- kindLabel={pages.length === 1 ? "Page" : "Pages"}
256
300
  name={portal.name}
257
- title={portal.name}
258
- subtitle={`${level.path || "(repo root)"} · page ${level.page + 1} of ${level.pageCount}`}
259
- header={<p className="mt-3 text-[13px] text-text-2">{portalCaption(portal, links)} with this page</p>}
301
+ title={`${portal.name} of ${level.path || nameOf("", level.repo)}`}
302
+ meta={`${portalCaption(portal, links)} with this page`}
260
303
  onClose={onClose}
261
304
  >
262
- <div className={`flex-1 space-y-6 overflow-y-auto px-4 py-4 text-[14px] leading-normal text-text ${SHORT_BODY}`}>
305
+ <div className={`${BODY} space-y-4 px-4`}>
263
306
  <section aria-label="Go to">
264
- {pages.length > 1 && <SectionTitle title="Pages behind this doorway" count={pages.length} />}
307
+ {pages.length > 1 && <ListHeading title="Pages behind this doorway" count={pages.length} />}
265
308
  <ul className="flex flex-wrap gap-2">
266
309
  {pages.map((p) => (
267
310
  <li key={p}>
268
- <button
269
- type="button"
270
- data-go-page={p}
271
- onClick={() => onTurnPage(p)}
272
- className="inline-flex items-center gap-1.5 rounded-md border border-control/60 px-3 py-1.5 text-[13px] text-control hover:border-control"
273
- >
311
+ <button type="button" data-go-page={p} onClick={() => onTurnPage(p)} className={SECONDARY}>
274
312
  {p < level.page && <IconArrowLeft aria-hidden size={14} stroke={1.75} />}
275
313
  {pages.length === 1 ? `Go to page ${p + 1}` : `Page ${p + 1}`}
276
314
  {p > level.page && <IconArrowRight aria-hidden size={14} stroke={1.75} />}
@@ -280,36 +318,38 @@ function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose, covers
280
318
  </ul>
281
319
  </section>
282
320
 
283
- <Rows title="Linked from this page" items={linked} empty="No lines from this page." render={({ path: boxPath, count }: Linked) => (
284
- <li key={boxPath}>
285
- <button
286
- type="button"
287
- data-linked={boxPath}
288
- title={`${boxPath}: ${formatCount(count)} import${count === 1 ? "" : "s"} with ${portal.name}`}
321
+ <section aria-label="Linked from this page">
322
+ <ListHeading title="Linked from this page" count={linked.length} />
323
+ <Preview items={linked} empty="No lines from this page." render={({ path: boxPath, count }: Linked) => (
324
+ <BarRow
325
+ key={boxPath}
326
+ linked={boxPath}
327
+ name={byPath.get(boxPath)?.name ?? boxPath}
328
+ count={count}
329
+ largest={largest}
330
+ title={`${importsTitle(boxPath, count)} with ${portal.name}`}
289
331
  onClick={() => onFocus(boxPath)}
290
- className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-surface"
291
- >
292
- <BoxIcon box={byPath.get(boxPath)} />
293
- <span className="min-w-0 flex-1 truncate">{byPath.get(boxPath)?.name ?? boxPath}</span>
294
- <span className="shrink-0 text-[12px] text-text-2">{formatCount(count)}</span>
295
- </button>
296
- </li>
297
- )} />
332
+ />
333
+ )} />
334
+ </section>
298
335
  </div>
299
336
  </PanelFrame>
300
337
  );
301
338
  }
302
339
 
303
- /** A folder's or a file's icon, as on the Map. */
304
- function BoxIcon({ box }: { box: MapNode | undefined }){
305
- const folder = box?.kind === "folder";
306
- const Icon = folder ? IconFolder : IconFile;
307
- return <Icon aria-hidden size={16} stroke={1.75} className={`shrink-0 ${folder ? "text-folder" : "text-text-2"}`} />;
340
+ /** A list's heading in the doorway's panel, drawn like a tab row with nothing to switch: the title, its count, a divider. */
341
+ function ListHeading({ title, count }: { title: string; count: number }){
342
+ return (
343
+ <h3 className="mb-2 border-b border-divider pb-2 pt-1.5 text-[13px] font-normal text-text">
344
+ {title} <span className="text-text-2">{formatCount(count)}</span>
345
+ </h3>
346
+ );
308
347
  }
309
348
 
310
- type StatsProps = {
349
+ type ActionsProps = {
311
350
  data: DescribeResponse;
312
351
  github: GitHubSource | null;
352
+ onScreenFolder: boolean;
313
353
  onOpen: (folder: string) => void;
314
354
  onViewSource: () => void;
315
355
  /** Inside VS Code's panel only: "Open in VS Code" asks the editor over the bridge, since the panel can't follow a vscode:// link. */
@@ -317,208 +357,266 @@ type StatsProps = {
317
357
  };
318
358
 
319
359
  /**
320
- * "10,276 files · 3.4M lines", what changed since it was summarized, and the one action that fits the target. For a
321
- * file, the app offers its source and the editor; the demo has neither, so it links to GitHub at the indexed commit.
360
+ * What you can do with the target, as buttons. A folder gets Open folder, the one primary button, unless it's the folder
361
+ * already on screen. For a file the app offers its source and the editor; the demo has neither, so it links to GitHub
362
+ * at the indexed commit, a folder and the repo too.
322
363
  */
323
- function Stats({ data, github, onOpen, onViewSource, onOpenInEditor }: StatsProps){
324
- const lines = `${formatCompact(data.stats.lineCount)} ${data.stats.lineCount === 1 ? "line" : "lines"}`;
325
- const size = data.kind === "file" ? lines : `${formatCount(data.stats.fileCount)} ${data.stats.fileCount === 1 ? "file" : "files"} · ${lines}`;
364
+ function Actions({ data, github, onScreenFolder, onOpen, onViewSource, onOpenInEditor }: ActionsProps){
365
+ const file = data.kind === "file";
366
+ const open = data.kind === "folder" && !onScreenFolder;
367
+ const onGithub = DEMO && github !== null;
368
+ if (!open && !onGithub && !(file && !DEMO)) return null;
369
+
326
370
  return (
327
- <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] text-text-2">
328
- <span>{size}</span>
329
- {data.stalePaths > 0 && <span>{formatCount(data.stalePaths)} changed since summarized</span>}
330
- {data.kind === "folder" && (
331
- <button type="button" onClick={() => onOpen(data.path)} className="text-control underline underline-offset-2 hover:text-text">Open folder</button>
332
- )}
333
- {data.kind === "file" && DEMO && github && (
334
- <a href={githubLink(github, data.path)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-control underline underline-offset-2 hover:text-text">
335
- <IconCode aria-hidden size={14} stroke={1.75} />View on GitHub
371
+ <div className="flex flex-wrap gap-2">
372
+ {open && <button type="button" onClick={() => onOpen(data.path)} className={PRIMARY}>Open folder</button>}
373
+ {onGithub && (
374
+ <a href={file ? githubLink(github, data.path) : githubFolderLink(github, data.path)} target="_blank" rel="noreferrer" className={SECONDARY}>
375
+ View on GitHub
336
376
  </a>
337
377
  )}
338
- {data.kind === "file" && !DEMO && (
378
+ {file && !DEMO && (
339
379
  <>
340
- <button type="button" onClick={onViewSource} className="inline-flex items-center gap-1 text-control underline underline-offset-2 hover:text-text">
341
- <IconCode aria-hidden size={14} stroke={1.75} />View source
342
- </button>
380
+ <button type="button" onClick={onViewSource} className={SECONDARY}>View source</button>
343
381
  {onOpenInEditor
344
382
  // the webview's sandbox stops a vscode:// link inside the panel, so the editor is asked instead
345
- ? <button type="button" onClick={onOpenInEditor} className="text-control underline underline-offset-2 hover:text-text">Open in VS Code</button>
346
- : <a href={editorLink(data)} className="text-control underline underline-offset-2 hover:text-text">Open in VS Code</a>}
383
+ ? <button type="button" onClick={onOpenInEditor} className={SECONDARY}>Open in VS Code</button>
384
+ : <a href={editorLink(data)} className={SECONDARY}>Open in VS Code</a>}
347
385
  </>
348
386
  )}
349
387
  </div>
350
388
  );
351
389
  }
352
390
 
353
- type DetailsProps = {
391
+ type SummaryProps = {
354
392
  data: DescribeResponse;
355
393
  fresh: SummarizeResponse | null;
356
394
  refreshing: boolean;
357
395
  summaryError: ApiError | null;
358
396
  onRetry: () => void;
359
- onFocus: (targetPath: string) => void;
360
- onOpen: (folder: string) => void;
361
- /** Opens the source viewer on a declaration's lines. */
362
- onViewLines: (start: number, end: number) => void;
363
397
  };
364
398
 
365
- function Details({ data, fresh, refreshing, summaryError, onRetry, onFocus, onOpen, onViewLines }: DetailsProps){
366
- return (
367
- <>
368
- <Summary data={data} fresh={fresh} refreshing={refreshing} summaryError={summaryError} onRetry={onRetry} />
369
-
370
- <section aria-label="Relationships" className="space-y-4">
371
- {/* the three-task test's tester took a while to find these and to match them to the arrows: say it */}
372
- <Chips title="Depends on" note="What this box uses: its arrows point to these · by number of imports" items={data.uses} empty="Imports nothing outside itself." onFocus={onFocus} />
373
- <Chips title="Used by" note="What uses this box: their arrows point here · by number of imports, test files included" items={data.usedBy} empty="Nothing outside it imports it." onFocus={onFocus} />
374
- </section>
375
-
376
- {data.children && (
377
- <Rows title="Contents" items={data.children} render={(child: MapNode) => (
378
- <ChildRow key={child.path} child={child} onFocus={onFocus} onOpen={onOpen} />
379
- )} />
380
- )}
381
-
382
- {data.symbols && (
383
- <Rows title="Declarations" items={data.symbols} empty={noDeclarationsText(data)} render={(symbol: SymbolInfo) => (
384
- <li key={`${symbol.name}:${symbol.startLine}`}>
385
- <button
386
- type="button"
387
- title={`Show ${symbol.name} in the source`}
388
- onClick={() => onViewLines(symbol.startLine, symbol.endLine)}
389
- className="flex w-full items-baseline justify-between gap-3 rounded-md px-2 py-1 text-left hover:bg-surface"
390
- >
391
- <span className={`truncate font-mono text-[13px] ${symbol.exported ? "text-text" : "text-text-2"}`}>{symbol.name}</span>
392
- <span className="shrink-0 text-[12px] text-text-2">{symbol.kind} · line {symbol.startLine}</span>
393
- </button>
394
- </li>
395
- )} />
396
- )}
397
- </>
398
- );
399
- }
400
-
401
- type SummaryProps = Pick<DetailsProps, "data" | "fresh" | "refreshing" | "summaryError" | "onRetry">;
402
-
403
- /** The tagline and the first sentence; "Read full summary" shows the rest and "Show less" folds it back. */
399
+ /**
400
+ * The tagline and the summary's lead, its first paragraph, or its first sentence when it's one block (splitSummary).
401
+ * "Full summary" at the end of it shows the rest as paragraphs, and "Show less" at the end of the last one folds it
402
+ * back. However many paragraphs, they wrap in the panel's width and scroll with its body, with no height of their own.
403
+ * Under them, quietly, whether it's refreshing, out of date, or failed (with Try again).
404
+ */
404
405
  function Summary({ data, fresh, refreshing, summaryError, onRetry }: SummaryProps){
405
406
  const [expanded, setExpanded] = useState(false);
406
407
  // a "summarize now" answer is newer than the description it was asked from
407
408
  const shown = fresh ?? data;
408
409
  const { lead, rest } = splitSummary(shown.summary ?? "");
409
410
  const failure = failureFor(shown.summaryError, summaryError);
411
+ const placeholder = shown.tagline ? "" : summaryLine(shown.summaryStatus, summaryError);
412
+ const paragraphs = lead === "" ? [] : expanded ? [lead, ...rest.split(/\n\s*\n/)] : [lead];
413
+ const { notes, testsNote } = summaryNotes(shown, data.kind, refreshing);
414
+ const hasNotes = notes.length > 0 || testsNote || failure !== null;
410
415
 
411
416
  return (
412
417
  // a long identifier or path (inline code is one unbroken word) breaks anywhere rather than run past the panel's
413
418
  // edge; wherever a line has a space to break at, it still breaks there
414
- <section aria-label="Summary" className="[overflow-wrap:anywhere]">
415
- {shown.tagline
416
- ? <p className="text-[15px] font-medium leading-snug text-title"><Rich text={shown.tagline} /></p>
417
- : <p className="text-text-2">{summaryLine(shown.summaryStatus, summaryError)}</p>}
418
- {lead && <p className="mt-2 text-text-body"><Rich text={lead} /></p>}
419
- {rest && expanded && rest.split(/\n\s*\n/).map((paragraph, i) => <p key={i} className="mt-2 text-text-body"><Rich text={paragraph} /></p>)}
420
- {/* it folds back, so a long summary doesn't push the rest of the panel away for good (owner review) */}
421
- {rest && (
422
- <button
423
- type="button"
424
- aria-expanded={expanded}
425
- onClick={() => setExpanded((open) => !open)}
426
- className="mt-2 text-[13px] text-control underline underline-offset-2 hover:text-text"
427
- >
428
- {expanded ? "Show less" : "Read full summary"}
429
- </button>
419
+ <section aria-label="Summary" className="space-y-3 [overflow-wrap:anywhere]">
420
+ {shown.tagline && <p className="text-[13px] font-semibold leading-[18px] text-text"><Rich text={shown.tagline} /></p>}
421
+ {placeholder && <p className="text-[13px] leading-[1.55] text-text-2">{placeholder}</p>}
422
+ {paragraphs.length > 0 && (
423
+ <div className="space-y-2 text-[13px] leading-[1.55] text-text-2">
424
+ {paragraphs.map((paragraph, i) => (
425
+ <p key={i}>
426
+ <Rich text={paragraph} />
427
+ {/* it folds back, so a long summary doesn't push the tabs away for good (owner review) */}
428
+ {rest && i === paragraphs.length - 1 && (
429
+ <>
430
+ {" "}
431
+ <button type="button" aria-expanded={expanded} onClick={() => setExpanded((open) => !open)} className={TEXT_LINK}>
432
+ {expanded ? "Show less" : "Full summary"}
433
+ </button>
434
+ </>
435
+ )}
436
+ </p>
437
+ ))}
438
+ </div>
439
+ )}
440
+ {hasNotes && (
441
+ <div className="space-y-1 text-[12.5px] leading-[18px] text-text-2">
442
+ {notes.map((note) => <p key={note}>{note}</p>)}
443
+ {testsNote && <p className="text-[12px] text-text-3">Import counts in this summary leave out test files.</p>}
444
+ {failure && (
445
+ <p className="text-danger">
446
+ {failure}{" "}
447
+ <button type="button" onClick={onRetry} className={TEXT_LINK}>Try again</button>
448
+ </p>
449
+ )}
450
+ </div>
430
451
  )}
431
- <div className="mt-2 space-y-1 text-[13px] text-text-2">
432
- {refreshing && shown.tagline && <p>Refreshing…</p>}
433
- {shown.summaryStatus === "stale" && !refreshing && <p>Out of date: this changed since it was summarized.</p>}
434
- {/* the summary was written from an "Imported by" line that counts tests apart; "Used by" below counts them */}
435
- {shown.summary && data.kind === "file" && <p className="text-[12px]">Import counts in this summary leave out test files.</p>}
436
- {failure && (
437
- <p className="text-danger">
438
- {failure}{" "}
439
- <button type="button" onClick={onRetry} className="text-control underline underline-offset-2 hover:text-text">Try again</button>
440
- </p>
441
- )}
442
- </div>
443
452
  </section>
444
453
  );
445
454
  }
446
455
 
447
456
  /**
448
- * Prose with its `identifiers` as inline code (VISUAL_REFRESH.md §5): the code font and lavender on a chip. The chips
449
- * were dropped once for an outside critique ("noisy"), and the owner asked for them back: they make names easy to spot.
450
- * A chip broken across two lines (Summary breaks a long one anywhere) keeps its rounded padding on both.
457
+ * Prose with its `identifiers` as inline code: the code font in the text color, with no chip behind it (the approved
458
+ * mockup's `.summary code`, 2026-10-27). The brighter color and the code font are enough to spot a name in the gray
459
+ * prose. The chips behind them made the summary read busy beside the new rows and bars.
451
460
  */
452
461
  function Rich({ text }: { text: string }){
453
462
  return (
454
463
  <>
455
464
  {inlineCode(text).map((part, i) => part.code
456
- ? <code key={i} className="rounded-md bg-code-bg box-decoration-clone px-1 font-mono text-[13px] text-code">{part.text}</code>
465
+ ? <code key={i} className="font-mono text-[12px] text-text">{part.text}</code>
457
466
  : <span key={i}>{part.text}</span>)}
458
467
  </>
459
468
  );
460
469
  }
461
470
 
462
- function SectionTitle({ title, count, note, large = false }: { title: string; count: number; note?: string; large?: boolean }){
471
+ /** Hover titles on the tabs: the old panel's notes, said once. The three-task test's tester matched them to the arrows slowly. */
472
+ const TAB_TITLES: Record<InspectorTab, string | undefined> = {
473
+ uses: "What this uses, by number of imports. Its arrows point to these.",
474
+ usedBy: "What uses this, by number of imports, test files included. Their arrows point here.",
475
+ inside: undefined,
476
+ };
477
+
478
+ type RelationsProps = {
479
+ data: DescribeResponse;
480
+ tab: InspectorTab;
481
+ onTab: (tab: InspectorTab) => void;
482
+ onFocus: (targetPath: string) => void;
483
+ onOpen: (folder: string) => void;
484
+ /** Opens the source viewer on a declaration's lines (or the editor, or GitHub). */
485
+ onViewLines: (start: number, end: number) => void;
486
+ };
487
+
488
+ /**
489
+ * The tabs (the mockup's .tabs): Uses, Used by, and Contents for a folder or Declarations for a file, each with its
490
+ * count. A real tab list: the arrow keys, Home and End move between the tabs, and only the one on screen is in the Tab
491
+ * order. They wrap onto a second line in a very narrow panel rather than run past its edge.
492
+ */
493
+ function Relations({ data, tab, onTab, onFocus, onOpen, onViewLines }: RelationsProps){
494
+ const id = useId();
495
+ const file = data.kind === "file";
496
+ const tabs: { id: InspectorTab; label: string; count: number }[] = [
497
+ { id: "uses", label: "Uses", count: data.uses.length },
498
+ { id: "usedBy", label: "Used by", count: data.usedBy.length },
499
+ { id: "inside", label: file ? "Declarations" : "Contents", count: (file ? data.symbols?.length : data.children?.length) ?? 0 },
500
+ ];
501
+
502
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
503
+ const at = tabs.findIndex((t) => t.id === tab);
504
+ const next = event.key === "ArrowRight" ? (at + 1) % tabs.length
505
+ : event.key === "ArrowLeft" ? (at - 1 + tabs.length) % tabs.length
506
+ : event.key === "Home" ? 0
507
+ : event.key === "End" ? tabs.length - 1
508
+ : -1;
509
+ if (next === -1) return;
510
+ // the Map's own ← and → turn the page: these keys are the tabs' while a tab has the focus
511
+ event.preventDefault();
512
+ event.stopPropagation();
513
+ onTab(tabs[next].id);
514
+ event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus();
515
+ };
516
+
463
517
  return (
464
- <div className="mb-2">
465
- <h3 className={`${large ? "text-[15px]" : "text-[13px]"} font-medium text-text`}>
466
- {title} <span className="font-normal text-text-2">{formatCount(count)}</span>
467
- </h3>
468
- {note && <p className="text-[12px] text-text-2">{note}</p>}
518
+ <div>
519
+ <div role="tablist" aria-label="Relationships" onKeyDown={onKeyDown} className="flex flex-wrap gap-x-[18px] border-b border-divider @max-[260px]:gap-x-3">
520
+ {tabs.map((t) => {
521
+ const on = t.id === tab;
522
+ return (
523
+ <button
524
+ key={t.id}
525
+ type="button"
526
+ role="tab"
527
+ id={`${id}-${t.id}`}
528
+ data-tab={t.id}
529
+ aria-selected={on}
530
+ aria-controls={`${id}-panel`}
531
+ tabIndex={on ? 0 : -1}
532
+ title={TAB_TITLES[t.id]}
533
+ onClick={() => onTab(t.id)}
534
+ // Uses and Used by are underlined in the color their lines take on the Map, Contents in the accent
535
+ className={`relative whitespace-nowrap pb-2 pt-1.5 text-[13px] ${on
536
+ ? `text-text after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:rounded-[1px] ${t.id === "inside" ? "after:bg-accent" : SIDE_COLOR[t.id].underline}`
537
+ : "text-text-2 hover:text-text"}`}
538
+ >
539
+ {t.label} {formatCount(t.count)}
540
+ </button>
541
+ );
542
+ })}
543
+ </div>
544
+ <div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-${tab}`} className="pt-2">
545
+ {tab === "uses" && <Areas key="uses" items={data.uses} empty="Imports nothing outside itself." onFocus={onFocus} />}
546
+ {tab === "usedBy" && <Areas key="usedBy" items={data.usedBy} empty="Nothing outside it imports it." onFocus={onFocus} usedBy />}
547
+ {tab === "inside" && file && (
548
+ <Preview items={data.symbols ?? []} empty={noDeclarationsText(data)} render={(symbol: SymbolInfo) => (
549
+ <DeclarationRow key={`${symbol.name}:${symbol.startLine}`} symbol={symbol} onViewLines={onViewLines} />
550
+ )} />
551
+ )}
552
+ {tab === "inside" && !file && (
553
+ <Preview items={data.children ?? []} empty="Nothing inside." render={(child: MapNode) => (
554
+ <ChildRow key={child.path} child={child} onFocus={onFocus} onOpen={onOpen} />
555
+ )} />
556
+ )}
557
+ </div>
469
558
  </div>
470
559
  );
471
560
  }
472
561
 
473
- type ChipsProps = { title: string; note: string; items: readonly AreaCount[]; empty: string; onFocus: (targetPath: string) => void };
562
+ /** Uses or Used by: each area a row that focuses it on the Map, its bar measured against the tab's largest. */
563
+ function Areas({ items, empty, onFocus, usedBy = false }: { items: readonly AreaCount[]; empty: string; onFocus: (targetPath: string) => void; usedBy?: boolean }){
564
+ const largest = largestCount(items);
565
+ return (
566
+ <Preview items={items} empty={empty} render={(area: AreaCount) => (
567
+ <BarRow
568
+ key={area.path}
569
+ chip={area.path}
570
+ name={shortPath(area.path)}
571
+ count={area.count}
572
+ largest={largest}
573
+ title={importsTitle(area.path, area.count)}
574
+ onClick={() => onFocus(area.path)}
575
+ usedBy={usedBy}
576
+ />
577
+ )} />
578
+ );
579
+ }
580
+
581
+ type BarRowProps = {
582
+ name: string;
583
+ count: number;
584
+ largest: number;
585
+ /** Holds the name, so a name cut off with an ellipsis can be read in full. */
586
+ title: string;
587
+ onClick: () => void;
588
+ /** data-chip, on Uses and Used by rows. */
589
+ chip?: string;
590
+ /** data-linked, on a doorway's rows. */
591
+ linked?: string;
592
+ /** A Used by row: its bar in the gold its line takes on the Map, where every other row's is lilac. */
593
+ usedBy?: boolean;
594
+ };
474
595
 
475
596
  /**
476
- * "Depends on" / "Used by" as chips (§5): each focuses that area on the Map. The first 8, "+N more", then "Show fewer".
477
- * A chip is at most 360 px wide, or the panel's width in a narrow window, and its name truncates (the title has it).
597
+ * A row with a bar (the mockup's .irow): the name in the code font, cut off with an ellipsis, the count on the right,
598
+ * and under both a 3 px bar as long as the count is against the largest in its list.
478
599
  */
479
- function Chips({ title, note, items, empty, onFocus }: ChipsProps){
480
- const [all, setAll] = useState(false);
481
- const shown = all ? items : items.slice(0, PREVIEW);
600
+ function BarRow({ name, count, largest, title, onClick, chip, linked, usedBy = false }: BarRowProps){
482
601
  return (
483
- <div>
484
- <SectionTitle title={title} count={items.length} note={note} large />
485
- {items.length === 0
486
- ? <p className="text-[13px] text-text-2">{empty}</p>
487
- : (
488
- <ul className="flex flex-wrap gap-2">
489
- {shown.map((area) => (
490
- <li key={area.path} className="min-w-0">
491
- <button
492
- type="button"
493
- data-chip={area.path}
494
- title={`${area.path}: ${formatCount(area.count)} import${area.count === 1 ? "" : "s"}`}
495
- onClick={() => onFocus(area.path)}
496
- className="flex max-w-[min(360px,100%)] items-center gap-2 rounded-md border border-border bg-surface px-2 py-1 text-[13px] text-text hover:border-text-2"
497
- >
498
- <span className="truncate">{shortPath(area.path)}</span>
499
- <span className="shrink-0 text-text-2">{formatCount(area.count)}</span>
500
- </button>
501
- </li>
502
- ))}
503
- {items.length > PREVIEW && (
504
- <li>
505
- <button
506
- type="button"
507
- aria-expanded={all}
508
- onClick={() => setAll((open) => !open)}
509
- className="rounded-md border border-dashed border-border px-2 py-1 text-[13px] text-text-2 hover:text-text"
510
- >
511
- {all ? "Show fewer" : `+${formatCount(items.length - PREVIEW)} more`}
512
- </button>
513
- </li>
514
- )}
515
- </ul>
516
- )}
517
- </div>
602
+ <li>
603
+ <button
604
+ type="button"
605
+ data-chip={chip}
606
+ data-linked={linked}
607
+ title={title}
608
+ onClick={onClick}
609
+ className="grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2.5 rounded-[5px] px-2 py-1.5 text-left hover:bg-node"
610
+ >
611
+ <span className="truncate font-mono text-[12.5px] leading-[18px] text-text">{name}</span>
612
+ <span className="text-[11.5px] leading-4 text-text-2">{formatCount(count)}</span>
613
+ <span aria-hidden className={`col-span-2 mt-[5px] h-[3px] rounded-[2px] ${SIDE_COLOR[usedBy ? "usedBy" : "uses"].fill}`} style={{ width: `${barWidth(count, largest)}%` }} />
614
+ </button>
615
+ </li>
518
616
  );
519
617
  }
520
618
 
521
- /** A child in Contents: a folder goes into it, a file is selected on the Map (§5). */
619
+ /** A child in Contents: a folder goes into it, a file is selected on the Map. Its icon is the explorer's. */
522
620
  function ChildRow({ child, onFocus, onOpen }: { child: MapNode; onFocus: (targetPath: string) => void; onOpen: (folder: string) => void }){
523
621
  const folder = child.kind === "folder";
524
622
  const Icon = folder ? IconFolder : IconFile;
@@ -527,46 +625,59 @@ function ChildRow({ child, onFocus, onOpen }: { child: MapNode; onFocus: (target
527
625
  <button
528
626
  type="button"
529
627
  data-child={child.path}
530
- title={child.tagline ? `${child.path}: ${child.tagline}` : child.path}
628
+ title={childTitle(child)}
531
629
  onClick={() => (folder ? onOpen(child.path) : onFocus(child.path))}
532
- className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-surface"
630
+ className="grid w-full grid-cols-[14px_minmax(0,1fr)_auto] items-center gap-x-2 rounded-[5px] px-2 py-1.5 text-left hover:bg-node"
533
631
  >
534
- <Icon aria-hidden size={16} stroke={1.75} className={`shrink-0 ${folder ? "text-folder" : "text-text-2"}`} />
535
- <span className="min-w-0 flex-1 truncate">{child.name}</span>
536
- {folder && <span className="shrink-0 text-[12px] text-text-2">{formatCount(child.fileCount)} {child.fileCount === 1 ? "file" : "files"}</span>}
632
+ <Icon aria-hidden size={14} stroke={1.75} className="text-text-3" />
633
+ <span className={`truncate font-mono text-[12.5px] leading-[18px] text-text ${folder ? "" : "col-span-2"}`}>{child.name}</span>
634
+ {folder && <span className="text-[11.5px] leading-4 text-text-2">{formatCount(child.fileCount)}</span>}
537
635
  </button>
538
636
  </li>
539
637
  );
540
638
  }
541
639
 
542
- type RowsProps<T> = {
543
- title: string;
640
+ /**
641
+ * A declaration in a file: its name in the code font (brighter when it's exported), and its kind and line on the right.
642
+ * The name keeps at least 6 characters before the place moves under it, right-aligned, in a very narrow panel.
643
+ */
644
+ function DeclarationRow({ symbol, onViewLines }: { symbol: SymbolInfo; onViewLines: (start: number, end: number) => void }){
645
+ return (
646
+ <li>
647
+ <button
648
+ type="button"
649
+ title={`Show ${symbol.name} in the source`}
650
+ onClick={() => onViewLines(symbol.startLine, symbol.endLine)}
651
+ className="flex w-full flex-wrap items-baseline gap-x-3 rounded-[5px] px-2 py-1.5 text-left hover:bg-node"
652
+ >
653
+ <span className={`min-w-0 flex-[1_1_6ch] truncate font-mono text-[12.5px] leading-[18px] ${symbol.exported ? "text-text" : "text-text-2"}`}>{symbol.name}</span>
654
+ <span className="ml-auto shrink-0 whitespace-nowrap text-[11.5px] leading-4 text-text-2">{declarationPlace(symbol)}</span>
655
+ </button>
656
+ </li>
657
+ );
658
+ }
659
+
660
+ type PreviewProps<T> = {
544
661
  items: readonly T[];
662
+ /** Said in place of the list when it has no rows. */
663
+ empty: string;
545
664
  render: (item: T) => ReactNode;
546
- empty?: string;
547
665
  };
548
666
 
549
- /** A titled list: the first 8, then "View all" shows the rest and "Show fewer" folds it back. */
550
- function Rows<T>({ title, items, render, empty }: RowsProps<T>){
667
+ /** A list's first 8 rows, then "+N more" shows the rest and "Show fewer" folds it back. */
668
+ function Preview<T>({ items, empty, render }: PreviewProps<T>){
551
669
  const [all, setAll] = useState(false);
552
- if (items.length === 0 && empty === undefined) return null;
670
+ if (items.length === 0) return <p className="px-2 py-1.5 text-[13px] text-text-2">{empty}</p>;
553
671
 
554
672
  return (
555
- <section aria-label={title}>
556
- <SectionTitle title={title} count={items.length} />
557
- {items.length === 0
558
- ? <p className="text-[13px] text-text-2">{empty}</p>
559
- : <ul className="-mx-2">{(all ? items : items.slice(0, PREVIEW)).map(render)}</ul>}
673
+ <>
674
+ {/* a plain block, not a grid: an auto grid track would widen to a long declaration's whole name */}
675
+ <ul className="space-y-0.5">{(all ? items : items.slice(0, PREVIEW)).map(render)}</ul>
560
676
  {items.length > PREVIEW && (
561
- <button
562
- type="button"
563
- aria-expanded={all}
564
- onClick={() => setAll((open) => !open)}
565
- className="mt-2 text-[13px] text-control underline underline-offset-2 hover:text-text"
566
- >
567
- {all ? "Show fewer" : `View all ${formatCount(items.length)}`}
677
+ <button type="button" aria-expanded={all} onClick={() => setAll((open) => !open)} className={QUIET}>
678
+ {all ? "Show fewer" : `+${formatCount(items.length - PREVIEW)} more`}
568
679
  </button>
569
680
  )}
570
- </section>
681
+ </>
571
682
  );
572
683
  }