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
@@ -2,50 +2,73 @@
2
2
 
3
3
  import { memo, useEffect, useMemo, useRef, useState } from "react";
4
4
  import {
5
- Background, BackgroundVariant, ControlButton, Controls, MarkerType, ReactFlow, useEdgesState, useNodesState, useReactFlow, useStore, ViewportPortal,
5
+ ReactFlow, useEdgesState, useNodesState, useReactFlow, useStore, ViewportPortal,
6
6
  type EdgeMouseHandler, type EdgeTypes, type NodeMouseHandler, type NodeTypes,
7
7
  } from "@xyflow/react";
8
8
  import "@xyflow/react/dist/style.css";
9
- import { IconFocusCentered } from "@tabler/icons-react";
10
- import type { LevelResponse } from "@/lib/contracts";
9
+ import type { LevelResponse, MapEdge } from "@/lib/contracts";
11
10
  import BoxActions from "./BoxActions";
12
- import { edgeKey, mapEmphasis, type EdgeTone } from "./emphasis";
11
+ import { edgeKey, lineLook, mapEmphasis, type BoxTone, type LineDirection } from "./emphasis";
13
12
  import { reuseUnchanged, sameBoxNode, sameRoutedEdge, toBoxNode } from "./flowNodes";
14
- import { lineLabel } from "./labels";
15
- import Legend from "./Legend";
16
- import { FIT_PADDING, layoutLevel, looseDivider, perRowFor, type Divider } from "./levelLayout";
17
- import MapBox, { type BoxData, type BoxNode } from "./MapBox";
18
- import { legendMaxHeight } from "./narrow";
13
+ import { formatCount } from "./format";
14
+ import { runWords } from "./labels";
15
+ import { BOX_HEIGHT, BOX_WIDTH, layoutLevel, looseDivider, perRowFor, type Divider, type Position } from "./levelLayout";
16
+ import LineCard, { type CardAt, type CardPlace } from "./LineCard";
17
+ import MapBox, { borderOf, shade, type BoxData, type BoxNode } from "./MapBox";
18
+ import { openingOf } from "./opening";
19
19
  import { portalCaption, portalDirection, portalLinks, portalTitle } from "./portals";
20
20
  import type { Pulse } from "./pulses";
21
21
  import RoutedEdge, { type RoutedEdgeType } from "./RoutedEdge";
22
- import { routeEdges } from "./routes";
23
- import { boundsOf, initialViewport, TOP_INSET } from "./viewport";
22
+ import { headRooms, nextRoutes, placeCounts, routeEdges, type Point, type Rect } from "./routes";
23
+ import { linesSharing, pointedBits } from "./runs";
24
+ import { isLightKind } from "./theme";
25
+ import { useThemeKind } from "./useThemeKind";
26
+ import { boundsOf, drawnLinesBounds, initialViewport } from "./viewport";
27
+ import { arrowHead, boxHeadWidths, dotSize, labelledLines, lineWidth, maxFilesOf, maxImportsOf } from "./weights";
24
28
 
25
- // at module scope: a new object per render would make React Flow remount every box and line
26
- const nodeTypes: NodeTypes = { box: MapBox };
27
- const edgeTypes: EdgeTypes = { routed: RoutedEdge };
29
+ /** The line ids a key of ids joined by newlines names. */
30
+ const keySet = (key: string): Set<string> => new Set(key === "" ? [] : key.split("\n"));
31
+
32
+ /**
33
+ * What a page's counts are placed by: its lines, each one's width and label size, and the boxes a count keeps off.
34
+ */
35
+ type CountPage = { edges: readonly MapEdge[]; widths: Map<string, number>; sizes: Map<string, { width: number; height: number }>; boxes: Rect[] };
28
36
 
29
37
  /**
30
- * The fit button's fit: the whole level with room around it, never past 100% (VISUAL_REFRESH.md §4). A level opens by
31
- * viewport.ts's rule instead, which fits the same way unless that would make it unreadable.
38
+ * Where the counts of `labelled` go among the lines of `routes` that are `drawn`, from the places `kept` gives them
39
+ * (placeCounts), clear of the boxes and of the arrowheads those lines draw (headRooms).
32
40
  */
33
- const FIT = { padding: FIT_PADDING, maxZoom: 1 } as const;
41
+ function countsOn(page: CountPage, routes: ReadonlyMap<string, Point[]>, drawn: ReadonlySet<string>, labelled: ReadonlySet<string>, kept: ReadonlyMap<string, Point>): Map<string, Point> {
42
+ const on = new Map([...routes].filter(([id]) => drawn.has(id)));
43
+ const labels = new Map([...on.keys()].filter((id) => labelled.has(id)).map((id) => [id, page.sizes.get(id)!]));
44
+ return placeCounts(on, page.widths, labels, [...page.boxes, ...headRooms(on, page.edges)], kept);
45
+ }
34
46
 
35
47
  /**
36
- * Each line's look per tone (VISUAL_REFRESH.md §4): 1 px slate normally, 2 px purple for the selection's links. The
37
- * arrowhead of a quiet line is brighter than the line itself, so the direction reads without the line getting louder
38
- * (owner review: the arrows were too small to see).
48
+ * What's on screen, and what it was worked out from: the rest routes, the lines drawn and their routes, and the counts.
49
+ * `settled` is the lines drawn without the hover, `restCounts` rest's counts and `settledCounts` the counts without
50
+ * the hover (the selection's, from rest's), which a hover's `counts` start from.
39
51
  */
40
- const EDGE_STYLES: Record<Exclude<EdgeTone, "hidden">, { stroke: string; arrow: string; width: number; opacity: number; layer: number }> = {
41
- normal: { stroke: "var(--neutral-edge)", arrow: "var(--text-2)", width: 1, opacity: 1, layer: 0 },
42
- faded: { stroke: "var(--neutral-edge)", arrow: "var(--neutral-edge)", width: 1, opacity: 0.35, layer: 0 },
43
- preview: { stroke: "var(--text-2)", arrow: "var(--text)", width: 1.5, opacity: 1, layer: 1 },
44
- // 1.5 px, not 2: a selected box's lines merge into shared tracks, and at 2 px they outshone the box itself
45
- selected: { stroke: "var(--select)", arrow: "var(--select)", width: 1.5, opacity: 1, layer: 2 },
52
+ type Shown = {
53
+ rest: Map<string, Point[]>;
54
+ drawn: string;
55
+ routes: Map<string, Point[]>;
56
+ selected: string | null;
57
+ settled: string;
58
+ restCounts: Map<string, Point>;
59
+ settledCounts: Map<string, Point>;
60
+ counts: Map<string, Point>;
46
61
  };
47
- /** The arrowhead's size in px, whatever the line's width (26 since the three-task test: 18 was too small to read). */
48
- const ARROW_SIZE = 26;
62
+
63
+ /**
64
+ * The lines under the pointer (runs.ts's linesSharing), by `key`, their ids joined by newlines, and where the pointer
65
+ * is on their run, with the bit of line there and what's over the canvas then, which the hover card keeps off.
66
+ */
67
+ type LineHover = { key: string; lines: string[] } & CardAt;
68
+
69
+ // at module scope: a new object per render would make React Flow remount every box and line
70
+ const nodeTypes: NodeTypes = { box: MapBox };
71
+ const edgeTypes: EdgeTypes = { routed: RoutedEdge };
49
72
 
50
73
  type MapCanvasProps = {
51
74
  level: LevelResponse;
@@ -61,70 +84,142 @@ type MapCanvasProps = {
61
84
  onDrawn: () => void;
62
85
  /** A busy level: only the hovered or selected box's lines are drawn (emphasis.ts). */
63
86
  quietLines: boolean;
64
- /** The open panel's width over the canvas's right edge, 0 with none (narrow.ts's panelLayout). */
65
- panelWidth: number;
87
+ /** Strong links only: a weak line is drawn only when it touches the selection, the hovered box or a lit box (emphasis.ts). */
88
+ strongOnly: boolean;
66
89
  /**
67
- * The panel covers the whole canvas (a narrow window): the legend and the zoom controls would sit under it, and the
68
- * boxes can't be reached by Tab either, until its × brings the canvas back.
90
+ * Which side of the selection's lines is highlighted, the other drawn gray (emphasis.ts). It changes no line drawn,
91
+ * so no route and no count moves, only colors.
92
+ */
93
+ direction: LineDirection;
94
+ /**
95
+ * The inspector covers the whole canvas (a window both narrow and short, shell.ts): the boxes can't be reached by Tab
96
+ * then, until its × brings the canvas back.
69
97
  */
70
98
  covered: boolean;
71
- /** How far down the Map's error banner reaches on the canvas, 0 with none: the open legend stops short of it. */
72
- bannerBottom: number;
73
99
  /**
74
100
  * The selected box that shows its actions beside it (BoxActions), or null: while the panel would cover the canvas,
75
101
  * a click only selects, and Details asks for the panel.
76
102
  */
77
103
  actionsFor: string | null;
78
104
  onDetails: (path: string) => void;
105
+ /** Inside VS Code's panel only: a file box double-clicked, or its Open beside it, opens it in the editor. */
106
+ onOpenFile?: (path: string) => void;
79
107
  };
80
108
 
81
109
  /**
82
110
  * The canvas (VISUAL_REFRESH.md §4): one level's boxes, laid out by dagre, and an arrow for every import line, routed
83
111
  * around the boxes (routes.ts). A click selects (where the panel would cover the canvas, the selected box shows its
84
- * actions beside it: BoxActions); a double click opens a folder, or turns to a portal's page. The selection's lines turn purple and unrelated boxes fade;
85
- * hovering a box previews its lines without selecting it, and hovering a line shows its count. React Flow stays
86
- * controlled with the StrictMode workaround in next.config.ts.
112
+ * actions beside it: BoxActions); a double click opens a folder, turns to a portal's page, or inside VS Code's panel opens a
113
+ * file in the editor (opening.ts). The selection's lines turn purple (what it uses) and gold (what uses it), and unrelated boxes fade;
114
+ * hovering a box previews its lines without selecting it, and hovering a line names every line on that run beside the
115
+ * pointer, a click on it keeping the list (LineCard). React Flow stays controlled with the StrictMode workaround in
116
+ * next.config.ts.
87
117
  */
88
- function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPage, onDrawn, quietLines, panelWidth, covered, bannerBottom, actionsFor, onDetails }: MapCanvasProps){
118
+ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPage, onDrawn, quietLines, strongOnly, direction, covered, actionsFor, onDetails, onOpenFile }: MapCanvasProps){
89
119
  // a wide canvas wraps layers at 8 a row instead of 6, so dense levels fit larger (levelLayout.ts)
90
120
  const perRow = useStore((s) => perRowFor(s.width));
121
+ // React Flow's own parts follow a light VS Code theme (theme.ts)
122
+ const themeKind = useThemeKind();
91
123
  const positions = useMemo(() => layoutLevel(level.nodes, level.edges, undefined, perRow), [level, perRow]);
92
124
  const divider = useMemo(() => looseDivider(level.nodes, level.edges, positions), [level, positions]);
93
125
  const byPath = useMemo(() => new Map(level.nodes.map((n) => [n.path, n])), [level]);
94
- // each portal's arrow, "Lines to page 2" and "44 folders and files · 6 imports", worked out once per level, not on every hover
126
+ // each portal's arrow, "Lines to page 2" and "44 folders and files, 6 imports", worked out once per level, not on every hover
95
127
  const portals = useMemo(() => new Map<string, BoxData["portal"]>(level.nodes.filter((n) => n.kind === "portal").map((n) => [
96
128
  n.path,
97
129
  { direction: portalDirection(n, level.page), title: portalTitle(n), caption: portalCaption(n, portalLinks(level.edges, n.path)) },
98
130
  ])), [level]);
99
- const routes = useMemo(() => routeEdges(positions, level.edges), [positions, level]);
100
- const [hoveredEdge, setHoveredEdge] = useState<string | null>(null);
131
+ // dots and lines are scaled against the page's largest folder and heaviest line (weights.ts)
132
+ const maxFiles = useMemo(() => maxFilesOf(level.nodes), [level]);
133
+ const maxImports = useMemo(() => maxImportsOf(level.edges), [level]);
134
+ // each box's one head, sized from every line on the level, drawn or not, as routing sized the room above it
135
+ const heads = useMemo(() => boxHeadWidths(level.edges), [level]);
136
+ const labelled = useMemo(() => labelledLines(level.edges, selected), [level, selected]);
137
+ const [lineHover, setLineHover] = useState<LineHover | null>(null);
138
+ // the hover card a click on a run pinned, every line listed (LineCard)
139
+ const [pinned, setPinned] = useState<(LineHover & { held: CardPlace | null }) | null>(null);
101
140
  const [hoveredBox, setHoveredBox] = useState<string | null>(null);
102
141
  // A hover belongs to the level it happened on. Going into a folder unmounts the old boxes before their mouseleave
103
142
  // fires, so the hover outlived the level, and coming back (a cached level, the same object as before) lit that box
104
143
  // up with nothing under the pointer (deferred minor). So it's cleared whenever another level is drawn: React's way
105
144
  // to reset state when a prop changes, during render rather than in an effect, so no frame shows the stale hover.
145
+ // A pinned card goes with it.
106
146
  const [drawnLevel, setDrawnLevel] = useState(level);
107
- if (drawnLevel !== level){
147
+ const levelChanged = drawnLevel !== level;
148
+ if (levelChanged){
108
149
  setDrawnLevel(level);
109
150
  setHoveredBox(null);
110
- setHoveredEdge(null);
151
+ setLineHover(null);
152
+ setPinned(null);
111
153
  }
112
154
  const emphasis = useMemo(
113
- () => mapEmphasis(level.nodes.map((n) => n.path), level.edges, selected, hoveredBox, { quietLines }),
114
- [level, selected, hoveredBox, quietLines],
155
+ // the boxes Claude Code or the editor has lit keep their lines, whatever hides the rest
156
+ () => mapEmphasis(level.nodes.map((n) => n.path), level.edges, selected, hoveredBox, { quietLines, strongOnly, pinned: agent, direction }),
157
+ [level, selected, hoveredBox, quietLines, strongOnly, agent, direction],
115
158
  );
159
+ // The lines drawn at rest (nothing selected, hovered or lit) are routed once, among themselves (routes.ts). A line a
160
+ // selection, a hover or a lit box adds is routed when it appears: it takes its boxes' points (a box has one a side)
161
+ // and, where it fits, the lanes and tracks their drawn lines are on, and goes around every other line on screen. It
162
+ // keeps that route while it stays, so pointing at a box or clicking one never moves a line that's drawn (the owner,
163
+ // 2026-10-27). Keyed by content: a hover that draws the same lines keeps every route.
164
+ const restKey = useMemo(() => {
165
+ const rest = mapEmphasis(level.nodes.map((n) => n.path), level.edges, null, null, { quietLines, strongOnly });
166
+ return level.edges.map(edgeKey).filter((id) => rest.edges.get(id) !== "hidden").join("\n");
167
+ }, [level, quietLines, strongOnly]);
168
+ const restRoutes = useMemo(() => routeEdges(positions, level.edges, keySet(restKey)), [positions, level, restKey]);
169
+ const drawnKey = level.edges.map(edgeKey).filter((id) => emphasis.edges.get(id) !== "hidden").join("\n");
170
+ // the lines drawn without the hover: rest's, and the selection's and the lit boxes' on top
171
+ const settledKey = useMemo(() => {
172
+ const settled = mapEmphasis(level.nodes.map((n) => n.path), level.edges, selected, null, { quietLines, strongOnly, pinned: agent });
173
+ return level.edges.map(edgeKey).filter((id) => settled.edges.get(id) !== "hidden").join("\n");
174
+ }, [level, selected, quietLines, strongOnly, agent]);
175
+ // what a count keeps clear of: every other line by its width, every other count, its label sized from its count's
176
+ // text as RoutedEdge writes it (7 px a character and 14 of padding and border, by 18), every box with its back card,
177
+ // and every arrowhead drawn (countsOn)
178
+ const countPage = useMemo((): CountPage => ({
179
+ edges: level.edges,
180
+ widths: new Map(level.edges.map((e) => [edgeKey(e), lineWidth(e.count, maxImports)])),
181
+ sizes: new Map(level.edges.map((e) => [edgeKey(e), { width: formatCount(e.count).length * 7 + 14, height: 18 }])),
182
+ boxes: level.nodes.flatMap((box) => {
183
+ const at = positions.get(box.path);
184
+ return at === undefined ? [] : [{ x: at.x + (BOX_WIDTH + 5) / 2, y: at.y + (BOX_HEIGHT + 5) / 2, width: BOX_WIDTH + 5, height: BOX_HEIGHT + 5 }];
185
+ }),
186
+ }), [level, positions, maxImports]);
187
+ // What's on screen, and what it was worked out from: React's way to keep state that follows a prop, set during render.
188
+ // A count is placed from a base, so lines that appear move only the counts they run into (placeCounts): rest's are
189
+ // placed afresh, the screen without a hover (a selection, or a lit box) starts from rest's, and a hover from the
190
+ // screen without it. Rest's and the selection's are held beside the routes, so a count a hovered line pushed aside
191
+ // goes back when the hover ends, and nothing chains from one hover to the next. New rest routes (another level, or
192
+ // Strong links only or Show all lines turned) start the counts afresh with them.
193
+ const follow = (previous: Shown | null): Shown => {
194
+ const fresh = previous === null || previous.rest !== restRoutes;
195
+ // the same lines keep the routes they have, rest's lines included, which nextRoutes would give back as they are
196
+ const before = fresh ? restRoutes : previous.routes;
197
+ const routes = drawnKey === (fresh ? restKey : previous.drawn) ? before : nextRoutes(positions, level.edges, restRoutes, before, keySet(drawnKey));
198
+ const restCounts = fresh ? countsOn(countPage, restRoutes, keySet(restKey), labelledLines(level.edges, null), new Map()) : previous.restCounts;
199
+ // a hover draws more lines and never hides one, so the lines without it are on screen, where they'll stay
200
+ const settledCounts = !fresh && previous.selected === selected && previous.settled === settledKey
201
+ ? previous.settledCounts
202
+ : countsOn(countPage, routes, keySet(settledKey), labelled, restCounts);
203
+ const counts = drawnKey === settledKey ? settledCounts : countsOn(countPage, routes, keySet(drawnKey), labelled, settledCounts);
204
+ return { rest: restRoutes, drawn: drawnKey, routes, selected, settled: settledKey, restCounts, settledCounts, counts };
205
+ };
206
+ const [shown, setShown] = useState(() => follow(null));
207
+ if (shown.rest !== restRoutes || shown.drawn !== drawnKey || shown.selected !== selected || shown.settled !== settledKey) setShown(follow(shown));
208
+ const { routes, counts } = shown;
116
209
  const [nodes, setNodes, onNodesChange] = useNodesState<BoxNode>([]);
117
210
  const [edges, setEdges, onEdgesChange] = useEdgesState<RoutedEdgeType>([]);
118
- const { setViewport } = useReactFlow();
211
+ const { setViewport, getViewport, screenToFlowPosition } = useReactFlow();
119
212
  const canvasWidth = useStore((s) => s.width);
120
213
  const canvasHeight = useStore((s) => s.height);
121
- // a paged level keeps clear of the page pill at the canvas's top right (viewport.ts)
122
- const pillBand = level.pageCount > 1 ? TOP_INSET : 0;
123
- // the latest of each, for the opening view below: it runs when the level changes, not when they do
124
- const openRef = useRef({ setViewport, onDrawn, positions, canvas: { width: canvasWidth, height: canvasHeight }, pillBand });
214
+ const canvas = useStore((s) => s.domNode);
215
+ // the latest of each, for the opening view below: it runs when the level changes, not when they do. The lines on
216
+ // screen are framed with the boxes (viewport.ts), measured only when a level opens or refits, not on every hover
217
+ const openRef = useRef({ setViewport, onDrawn, positions, routes, edges: level.edges, canvas: { width: canvasWidth, height: canvasHeight } });
125
218
  useEffect(() => {
126
- openRef.current = { setViewport, onDrawn, positions, canvas: { width: canvasWidth, height: canvasHeight }, pillBand };
127
- }, [setViewport, onDrawn, positions, canvasWidth, canvasHeight, pillBand]);
219
+ openRef.current = { setViewport, onDrawn, positions, routes, edges: level.edges, canvas: { width: canvasWidth, height: canvasHeight } };
220
+ }, [setViewport, onDrawn, positions, routes, level.edges, canvasWidth, canvasHeight]);
221
+ // whether the person has panned or zoomed since the level opened (a programmatic move has no event)
222
+ const moved = useRef(false);
128
223
 
129
224
  useEffect(() => {
130
225
  // with their size and attach points given, the lines never wait for React Flow to measure (flowNodes.ts); a box
@@ -134,10 +229,11 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
134
229
  tone: emphasis.boxes.get(box.path) ?? "normal",
135
230
  agent: agent.has(box.path),
136
231
  pulseKey: pulses.get(box.path)?.key ?? 0,
232
+ dot: dotSize(box.fileCount, maxFiles),
137
233
  portal: portals.get(box.path),
138
234
  }));
139
235
  setNodes((previous) => reuseUnchanged(previous, next, sameBoxNode));
140
- }, [level, positions, portals, emphasis, agent, pulses, setNodes]);
236
+ }, [level, positions, portals, emphasis, agent, pulses, maxFiles, setNodes]);
141
237
 
142
238
  useEffect(() => {
143
239
  // a busy level's hidden lines aren't handed to React Flow at all: fewer lines to draw, and none to hover
@@ -145,31 +241,38 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
145
241
  const id = edgeKey(edge);
146
242
  const tone = emphasis.edges.get(id) ?? "normal";
147
243
  if (tone === "hidden") return [];
148
- const look = EDGE_STYLES[tone];
244
+ const look = lineLook(tone, edge, selected);
149
245
  const drawnEdge: RoutedEdgeType = {
150
246
  id,
151
247
  type: "routed",
152
- data: { points: routes.get(id) ?? [] },
248
+ data: {
249
+ points: routes.get(id) ?? [],
250
+ width: lineWidth(edge.count, maxImports),
251
+ headWidth: heads.get(edge.target),
252
+ color: look.color,
253
+ // the page's heaviest lines and the selection's carry their count where it has a place clear of
254
+ // everything else, the selection's in the text color
255
+ count: counts.has(id) ? formatCount(edge.count) : undefined,
256
+ countStrong: tone === "selected",
257
+ labelAt: counts.get(id),
258
+ },
153
259
  source: edge.source,
154
260
  target: edge.target,
155
261
  focusable: false,
156
- // in words ("editor uses base · 12 imports"): the three-task test's tester read an arrow backwards
157
- label: hoveredEdge === id ? lineLabel(byPath.get(edge.source)!, byPath.get(edge.target)!, edge.count) : undefined,
158
- style: { stroke: look.stroke, strokeWidth: look.width, opacity: look.opacity },
159
- // userSpaceOnUse: the arrowhead stays the same size whatever the line's width
160
- markerEnd: { type: MarkerType.ArrowClosed, width: ARROW_SIZE, height: ARROW_SIZE, color: look.arrow, markerUnits: "userSpaceOnUse" },
161
262
  };
162
263
  return [{ layer: look.layer, edge: drawnEdge }];
163
264
  });
164
- // the emphasized lines go last, so they're drawn over the quiet ones they cross. Order, not zIndex: React Flow
165
- // lifts an edge with a zIndex above every box, where it would take the box's clicks (final review I2)
265
+ // the emphasized lines go last, so they're drawn over the quiet ones they cross or share a run with. Order, not
266
+ // zIndex: React Flow lifts an edge with a zIndex above every box, where it would take the box's clicks (final
267
+ // review I2)
166
268
  const next = drawn.sort((a, b) => a.layer - b.layer).map((d) => d.edge);
167
269
  setEdges((previous) => reuseUnchanged(previous, next, sameRoutedEdge));
168
- }, [level, byPath, routes, emphasis, hoveredEdge, setEdges]);
270
+ }, [level, routes, counts, emphasis, selected, maxImports, heads, setEdges]);
169
271
 
170
- // A new level (another folder, or another repo): open it at a readable size (viewport.ts: fitted, unless that would
171
- // shrink it past 75%, then from its top), then report it drawn. The boxes reach React Flow a render after the level
172
- // does (setNodes runs in an effect), so this waits until `nodes` holds this level's boxes: placing the view sooner
272
+ // A new level (another folder, or another repo): open it at a readable size (viewport.ts: fitted, its drawn lines
273
+ // too unless that would shrink the boxes under 85% of their own fit or past 75%, and from its top when the boxes
274
+ // alone would shrink past 75%), then report it drawn. The boxes reach React Flow a render after the level does
275
+ // (setNodes runs in an effect), so this waits until `nodes` holds this level's boxes: placing the view sooner
173
276
  // frames the previous level's, which is what a level from the cache got. The same level refetched after the index
174
277
  // moved keeps its key, so the user's pan and zoom stay put.
175
278
  // the row width is in the key: a window resized across WIDE_CANVAS lays the level out again, so it's fitted again
@@ -181,30 +284,122 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
181
284
  if (!arrived) return;
182
285
  const frame = requestAnimationFrame(() => {
183
286
  fittedKey.current = levelKey;
287
+ moved.current = false;
184
288
  const open = openRef.current;
185
- void open.setViewport(initialViewport(boundsOf(open.positions), open.canvas, open.pillBand), { duration: 0 });
289
+ void open.setViewport(initialViewport(boundsOf(open.positions), open.canvas, 0, drawnLinesBounds(open.routes, open.edges)), { duration: 0 });
186
290
  open.onDrawn();
187
291
  });
188
292
  return () => cancelAnimationFrame(frame);
189
293
  }, [levelKey, nodes, level.nodes]);
190
294
 
295
+ // The canvas resized (the window or VS Code's panel dragged, the inspector moving under or beside it): a level nobody
296
+ // has moved since it opened opens again for the new size, and one the person has moved keeps its middle where it
297
+ // was, at the same zoom, so the view stays steady and the selection stays in sight (the owner, 2026-10-27).
298
+ const lastSize = useRef<{ width: number; height: number } | null>(null);
299
+ useEffect(() => {
300
+ const last = lastSize.current;
301
+ lastSize.current = { width: canvasWidth, height: canvasHeight };
302
+ if (last === null || canvasWidth === 0 || canvasHeight === 0 || fittedKey.current !== levelKey) return;
303
+ if (last.width === canvasWidth && last.height === canvasHeight) return;
304
+ // the lines move under the hover card and a pinned one, which were placed for the old view
305
+ setLineHover(null);
306
+ setPinned(null);
307
+ if (!moved.current){
308
+ const lines = drawnLinesBounds(openRef.current.routes, openRef.current.edges);
309
+ void setViewport(initialViewport(boundsOf(positions), { width: canvasWidth, height: canvasHeight }, 0, lines), { duration: 0 });
310
+ } else {
311
+ const view = getViewport();
312
+ void setViewport({ x: view.x + (canvasWidth - last.width) / 2, y: view.y + (canvasHeight - last.height) / 2, zoom: view.zoom }, { duration: 0 });
313
+ }
314
+ }, [canvasWidth, canvasHeight, levelKey, positions, setViewport, getViewport]);
315
+
191
316
  const onNodeClick: NodeMouseHandler<BoxNode> = (_, node) => onSelect(node.id);
192
317
  const onNodeDoubleClick: NodeMouseHandler<BoxNode> = (_, node) => {
193
- const { box } = node.data;
194
- if (box.kind === "folder") onOpen(node.id);
195
- // a portal turns to its page; the merged one stands for several, so it stays selected for its panel to pick one
196
- else if (box.kind === "portal" && box.page !== undefined) onTurnPage(box.page);
318
+ const opening = openingOf(node.data.box, onOpenFile !== undefined);
319
+ if (opening?.type === "folder") onOpen(opening.path);
320
+ else if (opening?.type === "page") onTurnPage(opening.page);
321
+ else if (opening?.type === "editor") onOpenFile?.(opening.path);
197
322
  };
198
323
  const onNodeMouseEnter: NodeMouseHandler<BoxNode> = (_, node) => setHoveredBox(node.id);
199
324
  const onNodeMouseLeave: NodeMouseHandler<BoxNode> = () => setHoveredBox(null);
200
- const onEdgeMouseEnter: EdgeMouseHandler = (_, edge) => setHoveredEdge(edge.id);
201
- const onEdgeMouseLeave: EdgeMouseHandler = () => setHoveredEdge(null);
202
- /** Reset focus (§4): let go of the selection and the fade it brings. */
203
- const resetFocus = (): void => {
204
- setHoveredBox(null);
205
- onSelect(null);
325
+
326
+ // The lines under the pointer (the owner, 2026-10-29): every line on the run it's on (runs.ts), named by the hover
327
+ // card at the pointer (LineCard). Another run is another card, and along the same run the card is handed the pointer
328
+ // itself (followRef), so it goes with the pointer without drawing the canvas again.
329
+ const drawnLines = useMemo(() => keySet(drawnKey), [drawnKey]);
330
+ const edgeById = useMemo(() => new Map(level.edges.map((e) => [edgeKey(e), e])), [level]);
331
+ const followCard = useRef<((at: CardAt) => void) | null>(null);
332
+ const hoverPlace = useRef<CardPlace | null>(null);
333
+ const hoverAt = (event: { clientX: number; clientY: number }, id: string): LineHover | null => {
334
+ const route = routes.get(id);
335
+ const found = linesSharing(id, screenToFlowPosition({ x: event.clientX, y: event.clientY }), routes, drawnLines);
336
+ const edge = edgeById.get(id);
337
+ if (found === null || canvas === null || edge === undefined || route === undefined) return null;
338
+ // the card points at the spot on the line nearest the pointer, across the line there (LineCard)
339
+ const box = canvas.getBoundingClientRect();
340
+ const view = getViewport();
341
+ const spot = found.point;
342
+ const point = { x: spot.x * view.zoom + view.x, y: spot.y * view.zoom + view.y };
343
+ const a = route[found.segment], b = route[found.segment + 1];
344
+ const width = lineWidth(edge.count, maxImports);
345
+ const line = pointedBits(route, point, view, width, arrowHead(heads.get(edge.target) ?? width));
346
+ // what's over the canvas says so (a drawer, a box's actions, the banner: MapView, BoxActions)
347
+ const clear = [...(canvas.parentElement ?? canvas).querySelectorAll("[data-keep-clear]")].map((el) => {
348
+ const r = el.getBoundingClientRect();
349
+ return { left: r.left - box.left, top: r.top - box.top, width: r.width, height: r.height };
350
+ });
351
+ const upright = Math.abs(b.y - a.y) > Math.abs(b.x - a.x);
352
+ return { key: found.lines.join("\n"), lines: found.lines, point, spot, upright, half: (width * view.zoom) / 2, line, clear };
353
+ };
354
+ const onEdgeMouseMove: EdgeMouseHandler = (event, edge) => {
355
+ const next = hoverAt(event, edge.id);
356
+ if (next === null) return;
357
+ if (lineHover !== null && lineHover.key === next.key) followCard.current?.(next);
358
+ else setLineHover(next);
359
+ };
360
+ const onEdgeMouseLeave: EdgeMouseHandler = () => setLineHover(null);
361
+ // a click on the run pins its hover card just as it is, to list every line (LineCard)
362
+ const onEdgeClick: EdgeMouseHandler = (event, edge) => {
363
+ const next = hoverAt(event, edge.id);
364
+ if (next !== null) setPinned({ ...next, held: lineHover !== null && lineHover.key === next.key ? hoverPlace.current : null });
206
365
  };
207
366
 
367
+ // A pinned card closes on Escape, a click anywhere outside it (a drag of the canvas among them), another page
368
+ // (above), or a resize of the canvas. A zoom with the wheel keeps it (LineCard).
369
+ // Escape is caught first, on the way down, and stopped there, so it doesn't also let go of the selection (Explorer's
370
+ // rail does the same). A click on the empty canvas that closes it does only that, as Escape does, rather than letting
371
+ // go of the selection too: the click after that pointerdown is stopped before the pane sees it.
372
+ const pinnedCard = useRef<HTMLDivElement>(null);
373
+ useEffect(() => {
374
+ if (pinned === null) return;
375
+ const onKey = (event: KeyboardEvent): void => {
376
+ if (event.key !== "Escape") return;
377
+ event.stopPropagation();
378
+ setPinned(null);
379
+ };
380
+ const onPointer = (event: PointerEvent): void => {
381
+ if (event.target instanceof Node && pinnedCard.current?.contains(event.target)) return;
382
+ setPinned(null);
383
+ if (!(event.target instanceof Element && event.target.closest(".react-flow__pane"))) return;
384
+ const stop = (click: MouseEvent): void => {
385
+ if (click.target instanceof Element && click.target.closest(".react-flow__pane")) click.stopPropagation();
386
+ };
387
+ document.addEventListener("click", stop, { capture: true, once: true });
388
+ // a drag that pans has no click: the next press takes the stopper away before its own click comes
389
+ document.addEventListener("pointerdown", () => document.removeEventListener("click", stop, { capture: true }), { capture: true, once: true });
390
+ };
391
+ window.addEventListener("keydown", onKey, true);
392
+ document.addEventListener("pointerdown", onPointer, true);
393
+ return () => {
394
+ window.removeEventListener("keydown", onKey, true);
395
+ document.removeEventListener("pointerdown", onPointer, true);
396
+ };
397
+ }, [pinned]);
398
+ // React runs the rest of the render that clears the cards (above) with them still set, so on a jump with a card
399
+ // open (the editor following a file, Claude Code's show_on_map, Back) it named the old level's lines from the new
400
+ // one, which hasn't got them, and threw. That render draws no card.
401
+ const card = levelChanged ? null : pinned ?? lineHover;
402
+
208
403
  return (
209
404
  <ReactFlow
210
405
  nodes={nodes}
@@ -218,24 +413,32 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
218
413
  onNodeMouseEnter={onNodeMouseEnter}
219
414
  onNodeMouseLeave={onNodeMouseLeave}
220
415
  onPaneClick={() => onSelect(null)}
221
- onEdgeMouseEnter={onEdgeMouseEnter}
416
+ onMoveStart={(event) => {
417
+ if (event === null) return;
418
+ moved.current = true;
419
+ // the lines move under the hover card, placed for where they were. A pinned card stays, beside the spot
420
+ // it was clicked at as the wheel zooms (LineCard), and a drag starts with a press outside it, which
421
+ // closes it (above)
422
+ setLineHover(null);
423
+ }}
424
+ onEdgeMouseEnter={onEdgeMouseMove}
425
+ onEdgeMouseMove={onEdgeMouseMove}
222
426
  onEdgeMouseLeave={onEdgeMouseLeave}
427
+ onEdgeClick={onEdgeClick}
223
428
  nodesDraggable={false}
224
429
  nodesConnectable={false}
225
430
  zoomOnDoubleClick={false}
226
431
  minZoom={0.1}
227
432
  maxZoom={2}
228
- colorMode="dark"
433
+ colorMode={isLightKind(themeKind) ? "light" : "dark"}
229
434
  proOptions={{ hideAttribution: true }}
230
435
  inert={covered}
231
436
  >
232
- {/* a faint dot grid: texture, so a busy level isn't a void with rectangles in it (VISUAL_REFRESH.md §3) */}
233
- <Background variant={BackgroundVariant.Dots} gap={22} size={1.2} color="var(--grid)" />
234
- {divider && (
235
- <ViewportPortal>
236
- <LooseDivider divider={divider} />
237
- </ViewportPortal>
238
- )}
437
+ {/* under the lines (globals.css puts this layer beneath them): the folders' back cards, and the divider */}
438
+ <ViewportPortal>
439
+ <BackCards nodes={level.nodes} positions={positions} tones={emphasis.boxes} />
440
+ {divider && <LooseDivider divider={divider} />}
441
+ </ViewportPortal>
239
442
  {actionsFor !== null && byPath.has(actionsFor) && positions.has(actionsFor) && (
240
443
  <BoxActions
241
444
  box={byPath.get(actionsFor)!}
@@ -243,40 +446,66 @@ function MapCanvas({ level, selected, agent, pulses, onSelect, onOpen, onTurnPag
243
446
  onOpen={onOpen}
244
447
  onTurnPage={onTurnPage}
245
448
  onDetails={onDetails}
449
+ onOpenFile={onOpenFile}
246
450
  />
247
451
  )}
248
- {/* a panel over the whole canvas hides what's under it; its × brings the canvas back */}
249
- {!covered && (
250
- <Legend
251
- quietLines={quietLines}
252
- doorways={level.nodes.some((n) => n.kind === "portal")}
253
- // a short canvas: the open legend stays below a paged level's pill and the error banner, and
254
- // scrolls inside if it must (narrow.ts)
255
- maxHeight={legendMaxHeight(canvasHeight, pillBand, bannerBottom)}
452
+ {card !== null && (
453
+ <LineCard
454
+ // a hover card is one a run, and a pinned one is one a click
455
+ key={pinned === null ? `hover\n${card.key}` : `pinned\n${card.key}\n${card.point.x},${card.point.y}`}
456
+ words={runWords(card.lines.map((id) => edgeById.get(id)!), byPath)}
457
+ at={card}
458
+ pinned={pinned !== null}
459
+ cardRef={pinned === null ? undefined : pinnedCard}
460
+ held={pinned?.held}
461
+ followRef={pinned === null ? followCard : undefined}
462
+ placedRef={pinned === null ? hoverPlace : undefined}
256
463
  />
257
464
  )}
258
- {/* while the panel is open it covers the canvas's right edge, so the controls move left of it (final review I1) */}
259
- {!covered && (
260
- <Controls
261
- position="bottom-right"
262
- showInteractive={false}
263
- fitViewOptions={FIT}
264
- style={panelWidth > 0 ? { right: panelWidth } : undefined}
265
- className="overflow-hidden rounded-lg"
266
- >
267
- <ControlButton onClick={resetFocus} title="Reset focus" aria-label="Reset focus">
268
- <IconFocusCentered aria-hidden size={16} stroke={1.75} />
269
- </ControlButton>
270
- </Controls>
271
- )}
272
465
  </ReactFlow>
273
466
  );
274
467
  }
275
468
 
469
+ /** A back card's color: 70% of the way from the background to its front card's, so it reads as the card behind. */
470
+ const behind = (color: string): string => `color-mix(in srgb, ${color} 70%, var(--canvas))`;
471
+
472
+ /**
473
+ * Every folder's back card, a second card 5 px behind it (the owner's pick of 2026-10-27, compare-files.png option C):
474
+ * a folder opens into more boxes, and a file has none. Opaque, its fill and border 70% of the way from the background
475
+ * to its own card's, faded with its card, and drawn under the lines, so a line leaving a box's bottom edge stays whole.
476
+ */
477
+ function BackCards({ nodes, positions, tones }: {
478
+ nodes: LevelResponse["nodes"];
479
+ positions: ReadonlyMap<string, Position>;
480
+ tones: ReadonlyMap<string, BoxTone>;
481
+ }){
482
+ return (
483
+ <div aria-hidden data-testid="back-cards" className="pointer-events-none">
484
+ {nodes.filter((n) => n.kind === "folder").map((n) => {
485
+ const at = positions.get(n.path);
486
+ if (at === undefined) return null;
487
+ const tone = tones.get(n.path) ?? "normal";
488
+ const faded = tone === "faded";
489
+ return (
490
+ <div
491
+ key={n.path}
492
+ data-back-card={n.path}
493
+ style={{
494
+ position: "absolute", left: at.x + 5, top: at.y + 5, width: BOX_WIDTH, height: BOX_HEIGHT, borderRadius: 5,
495
+ background: behind(shade("var(--node)", faded)), border: `1px solid ${behind(shade(borderOf(tone, false), faded))}`,
496
+ }}
497
+ />
498
+ );
499
+ })}
500
+ </div>
501
+ );
502
+ }
503
+
276
504
  /**
277
- * The line over a level's loose boxes with "Not linked to anything on this page" in its middle (levelLayout.ts's
278
- * looseDivider; owner's design, 2026-10-13). It lives in the Map's own coordinates, so it pans and zooms with the boxes.
279
- * A hairline in the box border's colour, dimmer than the lines and never ending in an arrow, so it can't be read as one.
505
+ * The label over a level's loose boxes, "Not linked to anything here", on a hairline (levelLayout.ts's looseDivider;
506
+ * owner's design, 2026-10-13, restyled quiet by the Workbench redesign, ADR-070). It lives in the Map's own
507
+ * coordinates, so it pans and zooms with the boxes. A hairline in the divider's color, never ending in an arrow, so it
508
+ * can't be read as a line between boxes.
280
509
  */
281
510
  function LooseDivider({ divider }: { divider: Divider }){
282
511
  return (
@@ -284,11 +513,11 @@ function LooseDivider({ divider }: { divider: Divider }){
284
513
  data-testid="loose-divider"
285
514
  aria-hidden
286
515
  style={{ position: "absolute", left: divider.x, top: divider.y - 11, width: divider.width, height: 22 }}
287
- className="pointer-events-none flex items-center gap-3.5"
516
+ className="pointer-events-none flex items-center gap-3"
288
517
  >
289
- <span className="h-px flex-1 bg-border" />
290
- <span className="whitespace-nowrap text-[15px] text-text-2">Not linked to anything on this page</span>
291
- <span className="h-px flex-1 bg-border" />
518
+ <span className="h-px flex-1 bg-divider" />
519
+ <span className="whitespace-nowrap text-[12px] text-text-3">Not linked to anything here</span>
520
+ <span className="h-px flex-1 bg-divider" />
292
521
  </div>
293
522
  );
294
523
  }