nebulosity 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +11 -11
  3. package/.next/diagnostics/route-bundle-stats.json +10 -10
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +5 -5
  6. package/.next/server/app/_global-error/page/build-manifest.json +3 -3
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page/build-manifest.json +3 -3
  14. package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
  15. package/.next/server/app/_not-found/page.js +3 -2
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +27 -20
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
  21. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/server/app/index.html +1 -1
  23. package/.next/server/app/index.rsc +29 -23
  24. package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
  25. package/.next/server/app/index.segments/_full.segment.rsc +29 -23
  26. package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
  27. package/.next/server/app/page/build-manifest.json +3 -3
  28. package/.next/server/app/page/next-font-manifest.json +2 -3
  29. package/.next/server/app/page.js +4 -4
  30. package/.next/server/app/page_client-reference-manifest.js +1 -1
  31. package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
  32. package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
  33. package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
  34. package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
  35. package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
  36. package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
  37. package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
  38. package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
  39. package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
  40. package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
  41. package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
  42. package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
  43. package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
  44. package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
  45. package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
  46. package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
  47. package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
  48. package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
  49. package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
  50. package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
  51. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
  52. package/.next/server/middleware-build-manifest.js +11 -11
  53. package/.next/server/next-font-manifest.js +1 -1
  54. package/.next/server/next-font-manifest.json +4 -6
  55. package/.next/server/pages/404.html +1 -1
  56. package/.next/server/pages/500.html +1 -1
  57. package/.next/server/preview-props.json +3 -3
  58. package/.next/server/server-reference-manifest.js +1 -1
  59. package/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
  61. package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
  62. package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
  63. package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
  64. package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
  65. package/.next/static/chunks/3w00e1w2125pp.js +11 -0
  66. package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
  67. package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
  68. package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
  69. package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
  70. package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
  71. package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
  72. package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
  73. package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
  74. package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
  75. package/.next/trace +1 -1
  76. package/.next/trace-build +1 -1
  77. package/README.md +13 -4
  78. package/app/globals.css +91 -54
  79. package/app/layout.tsx +20 -11
  80. package/app/map/BackButton.tsx +5 -5
  81. package/app/map/BoxActions.tsx +20 -12
  82. package/app/map/Breadcrumb.tsx +132 -46
  83. package/app/map/DetailPanel.tsx +408 -297
  84. package/app/map/Explorer.tsx +268 -0
  85. package/app/map/Header.tsx +105 -0
  86. package/app/map/HelpDialog.tsx +37 -34
  87. package/app/map/Legend.tsx +19 -75
  88. package/app/map/LineCard.tsx +187 -0
  89. package/app/map/MapBox.tsx +73 -38
  90. package/app/map/MapCanvas.tsx +344 -115
  91. package/app/map/MapToolbar.tsx +377 -0
  92. package/app/map/MapView.tsx +217 -213
  93. package/app/map/RepoMenu.tsx +13 -12
  94. package/app/map/RoutedEdge.tsx +48 -16
  95. package/app/map/SearchPalette.tsx +6 -6
  96. package/app/map/SourceViewer.tsx +19 -17
  97. package/app/map/StatusBar.tsx +166 -0
  98. package/app/map/embed.ts +22 -2
  99. package/app/map/emphasis.ts +94 -8
  100. package/app/map/explorerRows.ts +85 -0
  101. package/app/map/flowNodes.ts +12 -7
  102. package/app/map/format.ts +2 -2
  103. package/app/map/history.ts +4 -3
  104. package/app/map/inspector.ts +113 -0
  105. package/app/map/labels.ts +66 -7
  106. package/app/map/narrow.ts +165 -85
  107. package/app/map/opening.ts +16 -0
  108. package/app/map/portals.ts +2 -2
  109. package/app/map/routes.ts +1060 -55
  110. package/app/map/runs.ts +108 -0
  111. package/app/map/shell.ts +91 -0
  112. package/app/map/shortcuts.ts +2 -1
  113. package/app/map/summaries.ts +41 -5
  114. package/app/map/text.ts +9 -1
  115. package/app/map/theme.ts +197 -0
  116. package/app/map/useDismiss.ts +20 -6
  117. package/app/map/useEditorBridge.ts +12 -1
  118. package/app/map/useThemeKind.ts +28 -0
  119. package/app/map/useWindowSize.ts +37 -0
  120. package/app/map/viewport.ts +134 -16
  121. package/app/map/weights.ts +80 -0
  122. package/app/page.tsx +3 -5
  123. package/lib/background/child.ts +2 -0
  124. package/lib/background/service.ts +26 -10
  125. package/lib/contracts.ts +25 -2
  126. package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
  127. package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
  128. package/lib/db/migrations/meta/_journal.json +7 -0
  129. package/lib/db/schema.ts +2 -1
  130. package/lib/db/store/map.ts +6 -0
  131. package/lib/db/store/summaries.ts +14 -8
  132. package/lib/engine/describe.ts +2 -0
  133. package/lib/index/summarize/budget.ts +5 -2
  134. package/lib/index/summarize/claude.ts +19 -4
  135. package/lib/index/summarize/compare.ts +2 -2
  136. package/lib/index/summarize/plan.ts +73 -13
  137. package/lib/index/summarize/preview.ts +12 -9
  138. package/lib/index/summarize/prompts.ts +122 -25
  139. package/lib/index/summarize/run.ts +48 -18
  140. package/lib/index/summarize/snapshot.ts +7 -2
  141. package/lib/index/summarize/status.ts +3 -2
  142. package/lib/tools/text.ts +3 -2
  143. package/mcp/server.ts +1 -1
  144. package/package.json +4 -2
  145. package/scripts/commands/summarize-command.ts +27 -9
  146. package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
  147. package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
  148. package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
  149. package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
  150. package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
  151. package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
  152. package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
  153. package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
  154. package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
  155. package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
  156. package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
  157. package/.next/static/chunks/10u7p2gj02qay.js +0 -1
  158. package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
  159. package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
  160. package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
  161. package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
  162. package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
  163. package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
  164. package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
  165. package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
  166. package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
  167. package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
  168. package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
  169. package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
  170. package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
  171. package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
  172. package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
  173. package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
  174. package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
  175. package/app/map/ActivityBar.tsx +0 -81
  176. package/app/map/StatusPill.tsx +0 -93
  177. package/app/map/TopBar.tsx +0 -84
  178. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
  179. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
  180. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_ssgManifest.js +0 -0
@@ -0,0 +1,268 @@
1
+ "use client";
2
+
3
+ import { memo, useCallback, useEffect, useId, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
4
+ import { IconFile, IconFolder, IconX } from "@tabler/icons-react";
5
+ import type { LevelResponse } from "@/lib/contracts";
6
+ import { explorerView, rowTone, type ExplorerRow, type ExplorerView, type RowTone } from "./explorerRows";
7
+ import { formatCount } from "./format";
8
+ import { useDismiss } from "./useDismiss";
9
+
10
+ export type ExplorerProps = {
11
+ /** The level on screen: its folders, files and doorways, its lines, and which page of it this is. */
12
+ level: LevelResponse;
13
+ /** The selected box's path, or null. */
14
+ selected: string | null;
15
+ /** The paths joined to the selection by a drawn line. */
16
+ linked: ReadonlySet<string>;
17
+ /** full is the 236 px list. rail is a 40 px column whose one button opens the list over the canvas. */
18
+ form: "full" | "rail";
19
+ /** A click on a row selects that box on the Map. */
20
+ onSelect: (path: string) => void;
21
+ /** A double click on a folder's row goes into it. */
22
+ onOpen: (folder: string) => void;
23
+ };
24
+
25
+ /**
26
+ * How long the rail's list stays open after a click on a row. The second click of a double click has to find the row
27
+ * still there: closed at once, it would land on the canvas underneath and let go of the selection (the pane's click), or
28
+ * open whatever box sits under the pointer.
29
+ */
30
+ const DOUBLE_CLICK_WAIT = 350; // ms
31
+
32
+ const ROW_TONE: Record<RowTone, string> = {
33
+ selected: "bg-row-sel text-text",
34
+ linked: "text-text hover:bg-node",
35
+ plain: "text-text-2 hover:bg-node",
36
+ };
37
+ const BAR_TONE: Record<RowTone, string> = { selected: "bg-accent", linked: "bg-linked-glyph", plain: "bg-glyph-dim" };
38
+
39
+ const rowOf = (list: HTMLElement, path: string): HTMLElement | null => list.querySelector<HTMLElement>(`[data-explorer-row="${CSS.escape(path)}"]`);
40
+
41
+ /**
42
+ * Scrolls the list just far enough to show the selected row, the way VS Code's explorer follows the editor: a box
43
+ * selected on the Map may sit far down a long list. Only the list moves. scrollIntoView would scroll every ancestor
44
+ * that can, the page among them, and the page must never scroll.
45
+ */
46
+ function reveal(list: HTMLElement | null, path: string | null): void {
47
+ const row = list !== null && path !== null ? rowOf(list, path) : null;
48
+ if (list === null || row === null) return;
49
+ const top = row.offsetTop;
50
+ const bottom = top + row.offsetHeight;
51
+ if (top < list.scrollTop) list.scrollTop = top;
52
+ else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
53
+ }
54
+
55
+ /** "Explorer" and how many items, over the list. The rail's list adds its close button at the end. */
56
+ function Header({ count, tall, children }: { count: string; tall?: boolean; children?: ReactNode }){
57
+ return (
58
+ <div className={`flex shrink-0 items-center justify-between gap-2 pl-[14px] text-[11.5px] text-text-3 ${tall ? "h-10 pr-2" : "h-[34px] pr-[14px]"}`}>
59
+ <span className="truncate">Explorer</span>
60
+ <span className="flex shrink-0 items-center gap-1.5 whitespace-nowrap">{count}{children}</span>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * One row, a real button: the icon, the name in mono, and for a folder its bar and how many files it holds. The whole
67
+ * path is its title, since a long name is cut short with an ellipsis. The focus ring is drawn inside, where the list's
68
+ * edges can't clip it.
69
+ */
70
+ function Row({ row, tone, onPick, onOpen }: {
71
+ row: ExplorerRow;
72
+ tone: RowTone;
73
+ onPick: (path: string, event: MouseEvent<HTMLButtonElement>) => void;
74
+ onOpen: (folder: string) => void;
75
+ }){
76
+ const folder = row.kind === "folder";
77
+ const Icon = folder ? IconFolder : IconFile;
78
+ return (
79
+ <li>
80
+ <button
81
+ type="button"
82
+ data-explorer-row={row.path}
83
+ aria-current={tone === "selected" ? "true" : undefined}
84
+ aria-label={folder ? row.label : undefined}
85
+ title={row.path}
86
+ onClick={(event) => onPick(row.path, event)}
87
+ onDoubleClick={folder ? () => onOpen(row.path) : undefined}
88
+ className={`grid h-[26px] w-full grid-cols-[16px_minmax(0,1fr)_36px_minmax(44px,max-content)] items-center gap-2 pl-[14px] pr-3 text-left focus-visible:-outline-offset-2 ${ROW_TONE[tone]}`}
89
+ >
90
+ <Icon aria-hidden size={14} stroke={1.75} className="text-text-3" />
91
+ <span className={`truncate font-mono text-[12.5px] ${folder ? "" : "col-span-3"}`}>{row.name}</span>
92
+ {folder && (
93
+ <>
94
+ <span aria-hidden className={`h-[3px] rounded-[2px] ${BAR_TONE[tone]}`} style={{ width: row.bar }} />
95
+ <span className="whitespace-nowrap text-right text-[11.5px] tabular-nums text-text-3">{formatCount(row.files)}</span>
96
+ </>
97
+ )}
98
+ </button>
99
+ </li>
100
+ );
101
+ }
102
+
103
+ type RowsProps = {
104
+ view: ExplorerView;
105
+ selected: string | null;
106
+ linked: ReadonlySet<string>;
107
+ onPick: (path: string, event: MouseEvent<HTMLButtonElement>) => void;
108
+ onOpen: (folder: string) => void;
109
+ };
110
+
111
+ /**
112
+ * The rows, scrolling inside when there are more than fit, and under them, for a folder drawn a page at a time, which
113
+ * page this is. It keeps the selected row in view as the selection changes.
114
+ */
115
+ function Rows({ view, selected, linked, onPick, onOpen }: RowsProps){
116
+ const list = useRef<HTMLDivElement>(null);
117
+ useEffect(() => reveal(list.current, selected), [selected, view]);
118
+
119
+ return (
120
+ <div ref={list} className="relative min-h-0 flex-1 overflow-y-auto pb-[10px]">
121
+ <ul aria-label="Folders and files">
122
+ {view.rows.map((row) => <Row key={row.path} row={row} tone={rowTone(row.path, selected, linked)} onPick={onPick} onOpen={onOpen} />)}
123
+ </ul>
124
+ {view.pageLine !== null && <p className="px-[14px] pt-2 text-[11.5px] leading-4 text-text-3">{view.pageLine}</p>}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ /**
130
+ * The list over the canvas, from the rail's right edge. It's a dialog, so the Map's own keys (Escape, Backspace, the
131
+ * arrows) wait while it's open (MapView asks the page whether one is). The focus goes to the selected row, or the first,
132
+ * so the keyboard can carry on from the button that opened it.
133
+ */
134
+ function Overlay({ id, view, selected, linked, onPick, onOpen, onClose }: RowsProps & { id: string; onClose: () => void }){
135
+ const self = useRef<HTMLDivElement>(null);
136
+ useEffect(() => {
137
+ const dialog = self.current;
138
+ const row = dialog?.querySelector<HTMLElement>('[aria-current="true"]') ?? dialog?.querySelector<HTMLElement>("[data-explorer-row]");
139
+ // the list has already scrolled to the selection (Rows), so the focus mustn't scroll anything again
140
+ row?.focus({ preventScroll: true });
141
+ }, []);
142
+
143
+ return (
144
+ <div
145
+ ref={self}
146
+ id={id}
147
+ role="dialog"
148
+ aria-label="Explorer"
149
+ className="absolute bottom-0 left-full top-0 z-20 flex w-[236px] max-w-[calc(100vw_-_48px)] flex-col border-r border-divider bg-panel"
150
+ >
151
+ <Header count={view.count} tall>
152
+ <button type="button" onClick={onClose} aria-label="Close the explorer" className="grid h-6 w-6 place-items-center rounded-md text-text-2 hover:bg-node hover:text-text">
153
+ <IconX aria-hidden size={16} stroke={1.75} />
154
+ </button>
155
+ </Header>
156
+ <Rows view={view} selected={selected} linked={linked} onPick={onPick} onOpen={onOpen} />
157
+ </div>
158
+ );
159
+ }
160
+
161
+ /**
162
+ * The explorer as a 40 px rail, for a window too narrow to keep the whole list beside the canvas: one button that opens
163
+ * the list over the canvas. Escape, the ×, a click outside or the focus moving away closes it. A click on a row selects
164
+ * that box and closes it a moment later, so a double click still opens a folder (DOUBLE_CLICK_WAIT). The keyboard's
165
+ * Enter has no second click to wait for, so it closes at once.
166
+ */
167
+ function Rail({ view, selected, linked, onSelect, onOpen }: Omit<RowsProps, "onPick" | "onOpen"> & Pick<ExplorerProps, "onSelect" | "onOpen">){
168
+ const [open, setOpen] = useState(false);
169
+ // bumped by each click on a row: the list closes DOUBLE_CLICK_WAIT after the last
170
+ const [picks, setPicks] = useState(0);
171
+ const id = useId();
172
+ const button = useRef<HTMLButtonElement>(null);
173
+
174
+ /** Closes the list without moving the focus: a click outside, or the focus already gone elsewhere. */
175
+ const dismiss = useCallback((): void => {
176
+ setOpen(false);
177
+ setPicks(0);
178
+ }, []);
179
+ const root = useDismiss<HTMLElement>(open, dismiss);
180
+ /** Closes the list, the focus going back to the rail's button when it was in the list, rather than to the page. */
181
+ const close = useCallback((): void => {
182
+ if (root.current?.contains(document.activeElement)) button.current?.focus();
183
+ dismiss();
184
+ }, [dismiss, root]);
185
+
186
+ useEffect(() => {
187
+ if (picks === 0) return;
188
+ const timer = setTimeout(close, DOUBLE_CLICK_WAIT);
189
+ return () => clearTimeout(timer);
190
+ }, [picks, close]);
191
+
192
+ // Escape closes just the list. Caught first, on the way down, and stopped there, so it doesn't also reach the Map's
193
+ // Escape, which would let go of the selection the moment the list is gone
194
+ useEffect(() => {
195
+ if (!open) return;
196
+ const onKey = (event: KeyboardEvent): void => {
197
+ if (event.key !== "Escape") return;
198
+ event.stopPropagation();
199
+ close();
200
+ };
201
+ window.addEventListener("keydown", onKey, true);
202
+ return () => window.removeEventListener("keydown", onKey, true);
203
+ }, [open, close]);
204
+
205
+ const pick = (path: string, event: MouseEvent<HTMLButtonElement>): void => {
206
+ onSelect(path);
207
+ // a click's detail counts its clicks, and Enter or Space on the row gives 0
208
+ if (event.detail === 0) close();
209
+ else setPicks((n) => n + 1);
210
+ };
211
+ const openFolder = (folder: string): void => {
212
+ onOpen(folder);
213
+ close();
214
+ };
215
+
216
+ return (
217
+ <aside
218
+ ref={root}
219
+ aria-label="Explorer"
220
+ onBlur={(event) => {
221
+ // the focus left for something outside (Tab past the last row, the search box opening): close behind it
222
+ if (open && event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)) dismiss();
223
+ }}
224
+ className="relative flex h-full w-full flex-col items-center border-r border-divider bg-panel pt-1.5"
225
+ >
226
+ <button
227
+ ref={button}
228
+ type="button"
229
+ aria-label="Open the explorer"
230
+ title="Explorer"
231
+ aria-expanded={open}
232
+ aria-controls={open ? id : undefined}
233
+ onClick={() => (open ? close() : setOpen(true))}
234
+ className={`grid h-7 w-7 shrink-0 place-items-center rounded-[5px] ${open ? "bg-node text-text" : "text-text-2 hover:bg-node hover:text-text"}`}
235
+ >
236
+ <IconFolder aria-hidden size={18} stroke={1.75} />
237
+ </button>
238
+ {open && (
239
+ <Overlay id={id} view={view} selected={selected} linked={linked} onPick={pick} onOpen={openFolder} onClose={close} />
240
+ )}
241
+ </aside>
242
+ );
243
+ }
244
+
245
+ /**
246
+ * The explorer (the Workbench redesign, ADR-070): the boxes of the level on screen as a list beside the canvas, the
247
+ * browser's answer to VS Code's own side bar, which the panel inside VS Code has instead. Folders come first, the
248
+ * largest at the top, each with a bar on the same log scale as its dot and how many files it holds, then the files by
249
+ * name. Doorways to other pages aren't rows, and a folder drawn a page at a time says which page under the list. The rows
250
+ * follow the Map: the selection's row is filled, and what's joined to it by a line is brighter with a lilac bar. A click
251
+ * selects that box on the Map, and a double click on a folder goes into it. In a narrower window it's a 40 px rail
252
+ * whose button opens the same list over the canvas. It fills its parent's height, scrolling inside, and the parent sets
253
+ * its width.
254
+ */
255
+ function Explorer({ level, selected, linked, form, onSelect, onOpen }: ExplorerProps){
256
+ const view = useMemo(() => explorerView(level), [level]);
257
+
258
+ if (form === "rail") return <Rail view={view} selected={selected} linked={linked} onSelect={onSelect} onOpen={onOpen} />;
259
+ return (
260
+ <aside aria-label="Explorer" className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden border-r border-divider bg-panel">
261
+ <Header count={view.count} />
262
+ <Rows view={view} selected={selected} linked={linked} onPick={(path) => onSelect(path)} onOpen={onOpen} />
263
+ </aside>
264
+ );
265
+ }
266
+
267
+ // memo: the list draws again only when its own props change, not with everything else MapView redraws
268
+ export default memo(Explorer);
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore, type ReactNode } from "react";
4
+ import Image from "next/image";
5
+ import { IconSearch } from "@tabler/icons-react";
6
+ import BackButton from "./BackButton";
7
+ import { DEMO } from "./mode";
8
+
9
+ type HeaderProps = {
10
+ /** The breadcrumb, with the repo's name and picker first (Breadcrumb, RepoMenu). */
11
+ crumb: ReactNode;
12
+ onSearch: () => void;
13
+ onHelp: () => void;
14
+ /** Inside VS Code's panel: no logo and no help button (the ? key still opens it). */
15
+ embedded: boolean;
16
+ /** Whether Back has one of the Map's own steps to go back to (history.ts's historyDepth). */
17
+ canGoBack: boolean;
18
+ /** A short window's toolbar, folded into this bar (shell.ts). */
19
+ toolbar?: ReactNode;
20
+ /** A very short window's status dots, at the bar's end (shell.ts). */
21
+ status?: ReactNode;
22
+ };
23
+
24
+ const noSubscription = () => () => {};
25
+
26
+ /**
27
+ * The search field's icon form: under 640 px, or under 1000 where the toolbar's controls share the bar (and gone under
28
+ * 360). As a field it gives way before the breadcrumb does (flex-shrink 1000 to its 1), down to 160 px.
29
+ */
30
+ const SEARCH_ICON = {
31
+ alone: { field: "max-[640px]:min-w-0 max-[640px]:w-7 max-[640px]:flex-none max-[640px]:cursor-pointer max-[640px]:justify-center max-[640px]:p-0", words: "max-[640px]:hidden" },
32
+ shared: { field: "max-[1000px]:min-w-0 max-[1000px]:w-7 max-[1000px]:flex-none max-[1000px]:cursor-pointer max-[1000px]:justify-center max-[1000px]:p-0 max-[360px]:hidden", words: "max-[1000px]:hidden" },
33
+ };
34
+
35
+ /**
36
+ * The Map's one top bar (the Workbench redesign, ADR-070), 42 px, 38 inside VS Code's panel. Left to right: the logo
37
+ * and name, Back, the breadcrumb in mono with the repo first, the search field, and help. In the demo the logo goes to
38
+ * nebulosity.ca (the owner, 2026-10-27). Inside VS Code's panel it's Back, the breadcrumb and search, since VS Code has
39
+ * its own chrome. Back is the same button in the browser, the demo and the panel, right before the breadcrumb it
40
+ * changes, and greyed out where none of the Map's own steps lie behind it (the owner's pick, 2026-10-29).
41
+ *
42
+ * A narrower window drops secondary parts one step at a time, so nothing sits on anything else and nothing scrolls
43
+ * sideways: under 1000 px the search's shortcut, under 900 the Nebulosity name (the mark stays), under 640 the search's
44
+ * words (the field becomes its icon), and under 480 the help button (the toolbar's … menu has it). Under 360 the mark
45
+ * and its divider go too, and the bar's gaps and Back's box narrow (24 px, 4 px from the breadcrumb), so the folder on
46
+ * screen keeps a readable name at 200 × 200 with the toolbar and the status dots in this bar. Back never goes, so under
47
+ * 360 the browser's bar is the panel's. The breadcrumb takes what's left and folds its middle (under 360 the repo's
48
+ * name with it). In a short window the toolbar's controls and then the status dots join this bar, which then has less
49
+ * to give: the search is its icon under 1000 px, and under 360 it moves into the toolbar's … menu (Ctrl K still opens
50
+ * it).
51
+ */
52
+ export default function Header({ crumb, onSearch, onHelp, embedded, canGoBack, toolbar, status }: HeaderProps){
53
+ // the server renders "Ctrl K"; a Mac shows "⌘K" once hydrated, without a mismatch
54
+ const isMac = useSyncExternalStore(noSubscription, () => /Mac|iPhone|iPad/.test(navigator.platform), () => false);
55
+ const icon = toolbar ? SEARCH_ICON.shared : SEARCH_ICON.alone;
56
+ const brand = (
57
+ <>
58
+ <Image src="/icon.svg" alt="" width={18} height={18} unoptimized className="shrink-0" />
59
+ <span className="text-[13px] font-semibold text-text max-[900px]:hidden">Nebulosity</span>
60
+ </>
61
+ );
62
+ return (
63
+ <header className={`relative z-20 flex shrink-0 items-center gap-3 border-b border-divider bg-panel pl-3 pr-2.5 max-[360px]:gap-1.5 max-[360px]:pl-2 max-[360px]:pr-1.5 ${embedded ? "h-[38px]" : "h-[42px]"}`}>
64
+ {/* at its natural width, so a long breadcrumb takes room from the search field before it truncates */}
65
+ <div className="flex min-w-0 flex-[1_1_auto] items-center gap-2.5 max-[360px]:gap-1">
66
+ {!embedded && (DEMO
67
+ ? <a href="https://nebulosity.ca" title="nebulosity.ca" className="flex shrink-0 items-center gap-2 rounded-[4px] max-[360px]:hidden">{brand}</a>
68
+ : <span className="flex shrink-0 items-center gap-2 max-[360px]:hidden">{brand}</span>)}
69
+ {!embedded && <span aria-hidden className="h-4 w-px shrink-0 bg-divider max-[360px]:hidden" />}
70
+ <BackButton enabled={canGoBack} />
71
+ {crumb}
72
+ </div>
73
+ <button
74
+ type="button"
75
+ onClick={onSearch}
76
+ data-testid="search-button"
77
+ aria-label="Search files and concepts"
78
+ title={`Search files and concepts (${isMac ? "⌘K" : "Ctrl K"})`}
79
+ className={`flex h-7 min-w-[160px] flex-[0_1000_440px] cursor-text items-center gap-2 rounded-[5px] border border-border bg-canvas pl-2.5 pr-1.5 text-[13px] text-text-3 hover:border-text-3 ${icon.field}`}
80
+ >
81
+ <IconSearch aria-hidden size={14} stroke={2} className="shrink-0" />
82
+ <span className={`w-0 flex-1 truncate text-left ${icon.words}`}>Search files and concepts</span>
83
+ <kbd className="shrink-0 rounded-[4px] border border-border px-1.5 font-sans text-[11.5px] leading-[17px] text-text-3 max-[1000px]:hidden">{isMac ? "⌘K" : "Ctrl K"}</kbd>
84
+ </button>
85
+ {/* as wide as the left side while the search field is centred, and only its own width once it's an icon; never
86
+ narrower than what it holds, which would spill left over the search field (its end is aligned) */}
87
+ <div className={`flex items-center justify-end gap-1.5 ${toolbar ? "shrink-0" : "flex-1 max-[640px]:flex-none"}`}>
88
+ {toolbar}
89
+ {!embedded && (
90
+ <button
91
+ type="button"
92
+ onClick={onHelp}
93
+ data-testid="help-button"
94
+ aria-label="How to use the Map"
95
+ title="How to use the Map (?)"
96
+ className="grid h-7 w-7 shrink-0 place-items-center rounded-[5px] border border-border text-[13px] font-semibold text-text-2 hover:border-text-3 hover:text-text max-[480px]:hidden"
97
+ >
98
+ ?
99
+ </button>
100
+ )}
101
+ {status}
102
+ </div>
103
+ </header>
104
+ );
105
+ }
@@ -7,11 +7,13 @@ import { DEMO } from "./mode";
7
7
  import { useScrollLock } from "./useScrollLock";
8
8
 
9
9
  /**
10
- * How to use the Map (owner review: "it's kinda confusing at first as to what all of this does"). Opens from the top
11
- * bar's "?" button or the ? key, and by itself on a first visit. Rendered into the page's body, above everything;
12
- * Escape or a click outside closes it. In the static demo it opens with what the demo can't do (ADR-049).
10
+ * How to use the Map (owner review: "it's kinda confusing at first as to what all of this does"). Opens from the
11
+ * header's "?" button, the toolbar's … menu where there's no room for it, or the ? key. It no longer opens by itself on
12
+ * a first visit, since the legend sentence under the canvas says what the marks mean (ADR-070). Rendered into the
13
+ * page's body, above everything; Escape or a click outside closes it. In the static demo it opens with what the demo
14
+ * can't do (ADR-049). Inside VS Code's panel (`embedded`) it says how a file opens in the editor.
13
15
  */
14
- export default function HelpDialog({ onClose }: { onClose: () => void }){
16
+ export default function HelpDialog({ onClose, embedded = false }: { onClose: () => void; embedded?: boolean }){
15
17
  useScrollLock();
16
18
  useEffect(() => {
17
19
  const onKey = (event: KeyboardEvent): void => {
@@ -50,59 +52,60 @@ export default function HelpDialog({ onClose }: { onClose: () => void }){
50
52
  at the top right, or the ? key.
51
53
  </p>
52
54
  </div>
53
- <button type="button" onClick={onClose} aria-label="Close help" className="shrink-0 rounded-md p-1 text-text-2 hover:bg-surface hover:text-text">
55
+ <button type="button" onClick={onClose} aria-label="Close help" className="shrink-0 rounded-md p-1 text-text-2 hover:bg-node hover:text-text">
54
56
  <IconX aria-hidden size={16} stroke={1.75} />
55
57
  </button>
56
58
  </div>
57
59
 
58
60
  <div className="mt-6 grid gap-x-8 gap-y-6 text-[14px] leading-normal text-text sm:grid-cols-2">
59
61
  <Section title="Getting around">
60
- <li><b>Click</b> a box to see what it is, in the panel on the right.</li>
61
- <li><b>Double-click</b> a folder to go inside it.</li>
62
- <li>Go back up with the path at the top left, <Key>Backspace</Key>, or your browser&apos;s Back button. The repo&apos;s name at the very top goes back to the start.</li>
63
- <li>Big folders are split into pages of related boxes: turn the page with the arrows at the top right, or <Key>←</Key> <Key>→</Key>.</li>
64
- <li>A busy folder opens at a readable size, from its top: drag to see the rest, or press the fit button at the bottom right to see it all.</li>
62
+ <li><b>Click</b> a box to see what it is, in the inspector beside or under the Map.</li>
63
+ <li><b>Double-click</b> a folder to go inside it, or click a folder in the explorer on the left.</li>
64
+ {/* a click only selects a file in the panel too (the owner, 2026-10-29) */}
65
+ {embedded && <li>In VS Code, <b>double-click</b> a file to open it in the editor, or press <b>Open in VS Code</b> in the inspector.</li>}
66
+ <li>Go back up with the path at the top left, <Key>Backspace</Key>, or your browser&apos;s Back button. The repo&apos;s name at its start goes back to the top.</li>
67
+ <li>Big folders are split into pages of related boxes. Turn the page with the arrows in the toolbar, or <Key>←</Key> <Key>→</Key>.</li>
68
+ <li>A busy folder opens at a readable size, from its top. Drag to see the rest, or press <b>Fit</b> in the toolbar to see it all.</li>
65
69
  <li>Search files and concepts with <Key>Ctrl K</Key> (<Key>⌘K</Key> on a Mac).</li>
66
70
  </Section>
67
71
  <Section title="Reading the Map">
68
- <li>A box is a folder (with how many files it holds) or a file.</li>
69
- <li>A line from A to B means A uses code from B: the arrow points at what&apos;s being used. A box&apos;s panel lists both sides: <b>Depends on</b> (what it uses) and <b>Used by</b> (what uses it).</li>
70
- <li>Hover a box to see its summary and preview its lines. Hover a line to read it as a sentence, like &quot;editor uses base · 12 imports&quot;.</li>
71
- <li>Busy folders (more than 60 lines) draw only the lines of the box you hover or select. <b>Show all lines</b>, at the top right, brings back the rest.</li>
72
- <li>Boxes with no lines on the page sit under a line that says &quot;Not linked to anything on this page&quot;.</li>
73
- <li>Lines to other pages end at a doorway, a dashed box like &quot;Lines to page 2&quot;; double-click it to go there.</li>
72
+ <li>A box is a folder or a file. A folder&apos;s dot grows with how many files it holds, and it has a second card behind it. A file has a page.</li>
73
+ <li>A line from A to B means A uses code from B, and the arrow points at what&apos;s being used. Its weight, and the number on it, show how many imports it carries.</li>
74
+ <li>Hover a box to bring its lines forward. Hover a line to read it as a sentence, like &quot;editor uses base, 12 imports&quot;. Where lines join, it lists every line on that stretch, and a click keeps the list open until you press Escape or click elsewhere.</li>
75
+ <li><b>Strong links only</b>, in the toolbar, hides the lines under 2% of the heaviest line&apos;s imports. Every line of the box you select or hover still shows.</li>
76
+ <li>With a box selected, <b>Uses</b> or <b>Used by</b> in the toolbar keeps only its lines to what it uses, or from what uses it, in color, and turns its other lines gray. <b>All</b> colors both, and it&apos;s back to All when you select something else.</li>
77
+ <li>Busy folders (more than 60 lines) draw only the lines of the box you hover or select, until you ask for <b>Show all lines</b>.</li>
78
+ <li>Boxes with no lines on the page sit under a line that says &quot;Not linked to anything here&quot;. Lines to other pages end at a doorway, a dashed box like &quot;Lines to page 2&quot;, and double-clicking it goes there.</li>
74
79
  </Section>
75
80
  <Section title="Colors">
76
- <li><Swatch className="border-2 border-select bg-select-fill" /> Purple: the box you selected and its lines. Everything else fades.</li>
77
- <li><Swatch className="outline-2 outline-offset-1 outline-agent" /> Orange: what Claude Code is touching right now.</li>
78
- <li><Swatch className="rounded-full bg-control" /> Cyan: something you can click, like a link or the Map&apos;s buttons.</li>
79
- <li><Swatch className="rounded-full bg-ok" /> Green: the index is up to date.</li>
80
- <li><Swatch className="rounded-full bg-danger" /> Red: something failed.</li>
81
+ <li><Swatch className="border-2 border-accent bg-node-sel" /> Purple is the box you selected, the lines to what it uses, and what they join. <span className="whitespace-nowrap"><Swatch className="bg-used-by-edge" /> Gold</span> is the lines from what uses it. Everything else fades.</li>
82
+ <li><Swatch className="outline-2 outline-offset-1 outline-agent" /> Orange is what Claude Code is touching right now.</li>
83
+ <li><Swatch className="rounded-full bg-ok" /> The cyan dot at the bottom left says the index is up to date.</li>
84
+ <li><Swatch className="rounded-full bg-danger" /> Red means something failed.</li>
81
85
  </Section>
82
- <Section title="The details panel">
83
- <li>A short summary; <b>Read full summary</b> shows the rest.</li>
84
- <li><b>Depends on</b> and <b>Used by</b>: click one to jump to it on the Map.</li>
85
- <li><b>Contents</b>: click a folder to go in, or a file to select it.</li>
86
+ <Section title="The inspector">
87
+ <li>A short summary, and <b>Full summary</b> for the rest.</li>
88
+ <li><b>Uses</b>, <b>Used by</b> and <b>Contents</b> are tabs. Click a row to jump to it on the Map.</li>
86
89
  {DEMO
87
- ? <li>For a file, <b>View on GitHub</b> opens its code at the indexed commit; click a declaration to open it there.</li>
88
- : <li>For a file, <b>View source</b> shows its code; click a declaration to jump to it there.</li>}
90
+ ? <li>For a file, <b>View on GitHub</b> opens its code at the indexed commit, and a declaration opens there too.</li>
91
+ : <li>For a file, <b>View source</b> shows its code, and a declaration jumps to its line there.</li>}
89
92
  </Section>
90
93
  <Section title="Claude Code">
91
94
  {DEMO
92
- ? <li>In the app, the bar along the bottom shows Claude Code&apos;s last 3 tool calls, and the boxes they touched get an orange ring.</li>
95
+ ? <li>In the app, the status bar along the bottom shows Claude Code&apos;s newest tool call as it works, and the boxes it touched get an orange ring.</li>
93
96
  : (
94
97
  <>
95
- <li>The bar along the bottom shows Claude Code&apos;s last 3 tool calls, newest first.</li>
98
+ <li>The status bar along the bottom shows Claude Code&apos;s newest tool call as it works.</li>
96
99
  <li>Boxes holding what it touched get an orange ring for 30 seconds.</li>
97
100
  </>
98
101
  )}
99
102
  </Section>
100
103
  <Section title="The index">
101
- <li>The status at the top right says whether the index is up to date. Click it for each stage{DEMO ? "." : ", and to re-index."}</li>
104
+ <li>The status bar&apos;s first cell says whether the index is up to date. Click it for each stage{DEMO ? "." : ", and to re-index."}</li>
102
105
  </Section>
103
106
  </div>
104
107
 
105
- <h3 className="mt-6 text-[13px] font-medium text-title">Keyboard</h3>
108
+ <h3 className="mt-6 text-[13px] font-medium text-text">Keyboard</h3>
106
109
  <dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px] text-text-2 max-[360px]:grid-cols-1 max-[360px]:[&_dd]:mb-1.5">
107
110
  <dt><Key>Ctrl K</Key></dt><dd>Search files and concepts</dd>
108
111
  <dt><Key>Esc</Key></dt><dd>Close what&apos;s open, let go of the selection, then clear Claude Code&apos;s highlights</dd>
@@ -119,14 +122,14 @@ export default function HelpDialog({ onClose }: { onClose: () => void }){
119
122
  function Section({ title, children }: { title: string; children: ReactNode }){
120
123
  return (
121
124
  <section>
122
- <h3 className="text-[13px] font-medium text-title">{title}</h3>
123
- <ul className="mt-2 space-y-1.5 text-text-body [&_b]:font-medium [&_b]:text-text">{children}</ul>
125
+ <h3 className="text-[13px] font-medium text-text">{title}</h3>
126
+ <ul className="mt-2 space-y-1.5 text-text-2 [&_b]:font-medium [&_b]:text-text">{children}</ul>
124
127
  </section>
125
128
  );
126
129
  }
127
130
 
128
131
  function Key({ children }: { children: ReactNode }){
129
- return <kbd className="rounded-md border border-border bg-surface px-1.5 py-px font-sans text-[12px] text-text">{children}</kbd>;
132
+ return <kbd className="rounded-md border border-border bg-node px-1.5 py-px font-sans text-[12px] text-text">{children}</kbd>;
130
133
  }
131
134
 
132
135
  function Swatch({ className }: { className: string }){
@@ -1,82 +1,26 @@
1
1
  "use client";
2
2
 
3
- import { useState, type ReactNode } from "react";
4
- import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
5
-
6
- /** Remembers in this browser whether the legend was left open. */
7
- const LEGEND_OPEN = "nebulosity.legendOpen";
8
-
9
- function readOpen(): boolean {
10
- try{
11
- return window.localStorage.getItem(LEGEND_OPEN) === "1";
12
- } catch{
13
- return false; // no storage (a private window): start closed
14
- }
15
- }
16
-
17
3
  /**
18
- * What the lines and colors mean (VISUAL_REFRESH.md §4: "label the relationship"), said once in a corner instead of on
19
- * every line. Closed, it's a small "Legend" button; open, a narrow card of short rows (owner review, 2026-10-13: the wide
20
- * block ran into the boxes). Like the other overlays it's a solid card in the logo's violet with no outline: lines are
21
- * never filled, so it can't be mistaken for one (a light outline was), and no box is that colour. Its button is cyan, the
22
- * color of everything you click.
23
- * The canvas only renders in the browser, so reading storage on the first render is safe. On a short canvas (a narrow
24
- * window's floor), it's at most `maxHeight` tall, its rows scrolling inside, so it never reaches the page pill.
4
+ * What the Map's marks mean, in a sentence under the canvas (the Workbench redesign, ADR-070). It replaces both the
5
+ * legend card in the canvas's corner and the help that opened by itself on a first visit: the full help stays behind
6
+ * the ? button and key. The doorways, Claude Code's rings and a busy folder's hidden lines get a sentence of their own
7
+ * only where they appear. In a short window or over a narrow canvas it goes (shell.ts), the help still having it.
25
8
  */
26
- export default function Legend({ quietLines, doorways, maxHeight }: { quietLines: boolean; doorways: boolean; maxHeight?: number }){
27
- const [open, setOpen] = useState(readOpen);
28
- const toggle = (): void => {
29
- const next = !open;
30
- setOpen(next);
31
- try{
32
- window.localStorage.setItem(LEGEND_OPEN, next ? "1" : "0");
33
- } catch{
34
- // nothing to remember it in
35
- }
36
- };
37
- const Chevron = open ? IconChevronDown : IconChevronUp;
38
-
39
- return (
40
- <div aria-label="Legend" style={{ maxHeight }} className={`nopan nodrag nowheel absolute bottom-4 left-4 z-[5] flex flex-col ${open ? "w-[252px]" : ""} rounded-lg bg-overlay text-[12px] text-overlay-text-2`}>
41
- {open && (
42
- <div className="min-h-0 space-y-1.5 overflow-y-auto border-b border-overlay-line px-3 py-2">
43
- {/* one sentence for direction: "arrows out / arrows in" rows read as abstract, even to the owner */}
44
- <Row glyph={<Arrow color="var(--text-2)" />}>A → B: A uses B. The arrow points at what&apos;s being used.</Row>
45
- <Row glyph={<Arrow color="var(--select)" />}>Selected box and its lines</Row>
46
- <Row glyph={<span className="block h-3 w-3 rounded-sm outline-2 outline-agent" />}>What Claude Code is touching</Row>
47
- {/* only where there are doorways: a page of a big folder (deferred minor: the legend had no row for them) */}
48
- {doorways && <Row glyph={<span className="block h-3 w-5 rounded-sm border border-dashed border-text-2" />}>Dashed box: a doorway to another page</Row>}
49
- {quietLines && <Row glyph={<span aria-hidden className="text-[14px] leading-none">⋯</span>}>Busy folder: hover a box to see its lines</Row>}
50
- </div>
51
- )}
52
- <button
53
- type="button"
54
- data-testid="legend-toggle"
55
- onClick={toggle}
56
- aria-expanded={open}
57
- className="flex w-full shrink-0 items-center justify-between gap-3 rounded-lg px-3 py-1.5 text-[12px] font-medium text-control hover:bg-overlay-line"
58
- >
59
- Legend
60
- <Chevron aria-hidden size={16} stroke={1.75} />
61
- </button>
62
- </div>
63
- );
64
- }
65
-
66
- function Row({ glyph, children }: { glyph: ReactNode; children: ReactNode }){
67
- return (
68
- <div className="flex items-start gap-2">
69
- <span aria-hidden className="flex h-4 w-6 shrink-0 items-center justify-center">{glyph}</span>
70
- <span className="leading-4">{children}</span>
71
- </div>
72
- );
73
- }
74
-
75
- function Arrow({ color }: { color: string }){
9
+ export default function Legend({ doorways, agent, quiet }: {
10
+ /** The level has doorways to its other pages. */
11
+ doorways: boolean;
12
+ /** Claude Code is touching boxes on screen. */
13
+ agent: boolean;
14
+ /** A busy level, whose lines show on hover. */
15
+ quiet: boolean;
16
+ }){
76
17
  return (
77
- <svg width="24" height="8" viewBox="0 0 24 8" style={{ color }}>
78
- <line x1="0" y1="4" x2="18" y2="4" stroke="currentColor" strokeWidth="1.5" />
79
- <path d="M17 0.5 L24 4 L17 7.5 Z" fill="currentColor" />
80
- </svg>
18
+ <p data-testid="legend" className="shrink-0 border-t border-divider px-3.5 py-2.5 text-[12px] leading-[1.45] text-text-3">
19
+ Dot size shows how many files a folder has. Line weight, and the number on a line, show how many imports it carries. Arrows point at the code being used.
20
+ Folders are stacked cards, and a page marks a single file.
21
+ {doorways && " A dashed box is a doorway to another page of this folder."}
22
+ {agent && " An orange ring is what Claude Code is touching right now."}
23
+ {quiet && " In a busy folder, hover a box to see its lines."}
24
+ </p>
81
25
  );
82
26
  }