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,187 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useState, type CSSProperties, type RefObject } from "react";
4
+ import { useStore } from "@xyflow/react";
5
+ import { formatCount } from "./format";
6
+ import type { RunWords } from "./labels";
7
+ import {
8
+ bubblePlace, bubbleRoom, CARD_EDGE, CARD_FRAME, CARD_ROW, hoverCardRows, insideCanvas, TAIL_BASE, TAIL_LENGTH,
9
+ type BubblePlace, type CanvasRect, type TailSide,
10
+ } from "./narrow";
11
+ import type { Point } from "./routes";
12
+ import { useSize } from "./useSize";
13
+
14
+ /** The card's widest, so a long name is cut short rather than stretching it across the canvas. */
15
+ const CARD_WIDTH = 280;
16
+ /** The rows a pinned card shows before its list scrolls, so it stays a small card beside the run. */
17
+ const PINNED_ROWS = 10;
18
+
19
+ /**
20
+ * The spot on the run nearest the pointer, on the canvas (canvas px) and on the Map (`spot`, the Map's own coordinates),
21
+ * whether the line runs up and down there and half its width on screen, and the bit of line there and what's over the
22
+ * canvas, which the card keeps off.
23
+ */
24
+ export type CardAt = { point: Point; spot: Point; upright: boolean; half: number; line: readonly CanvasRect[]; clear: readonly CanvasRect[] };
25
+ /** Where a hover card is on the canvas, with its tail, so a click can pin it just as it is. */
26
+ export type CardPlace = BubblePlace & { width: number; height: number };
27
+
28
+ /**
29
+ * What the lines under the pointer are, in words, pointing at the line (the owner, 2026-10-29). Lines that share an end
30
+ * merge, so one line's sentence on a shared run didn't say which line it meant, and drawn over the run it hid the lines
31
+ * it was about. So the card names every line on the run, heaviest first, with its imports ("Into src", then "build
32
+ * 16"), or one line's sentence where a line has the stretch to itself. A tail like a speech bubble's points from it
33
+ * across the line at the spot nearest the pointer, its tip just short of the line, one size always (the owner's idea),
34
+ * so the card sits beside the line, above and left of the spot where it can (narrow.ts's bubblePlace), inside the
35
+ * canvas, and goes with the pointer along the run (`followRef`: left where the pointer first landed, it sat far down a
36
+ * long line from it, owner review). It takes no pointer: reaching for something in it would mean leaving the line, which
37
+ * ends the hover. So it lists five lines and says how many more.
38
+ *
39
+ * A click on the run pins it (`pinned`, `held`): the same card, where it was and as wide, that only grows, away from
40
+ * its tail, to list every line, scrolling past PINNED_ROWS (a pinned card placed afresh changed shape on the click,
41
+ * owner review), its tail still on the spot once the pointer has gone. The wheel zooming the canvas keeps it beside that
42
+ * spot, kept inside the canvas, without its tail where that moved it off (the owner, 2026-10-29), and Escape, a click
43
+ * elsewhere, or another page closes it (MapCanvas). At screen size whatever the zoom, as wide as its words up to
44
+ * CARD_WIDTH, and never wider than the canvas.
45
+ */
46
+ export default function LineCard({ words, at: landed, pinned, held, cardRef, followRef, placedRef }: {
47
+ words: RunWords;
48
+ /** Where the pointer was on the run when the card came up. */
49
+ at: CardAt;
50
+ pinned: boolean;
51
+ /** The hover card a pinned one stays in the place of, if it was up (a tap pins one with none before it). */
52
+ held?: CardPlace | null;
53
+ /** The pinned card's element, for MapCanvas's click elsewhere. */
54
+ cardRef?: RefObject<HTMLDivElement | null>;
55
+ /** Where MapCanvas hands the hover card the pointer as it moves along the run, without drawing the canvas again. */
56
+ followRef?: RefObject<((at: CardAt) => void) | null>;
57
+ /** Where the hover card is, for a click to pin it there. */
58
+ placedRef?: RefObject<CardPlace | null>;
59
+ }){
60
+ const [at, setAt] = useState(landed);
61
+ useLayoutEffect(() => {
62
+ if (followRef === undefined) return;
63
+ followRef.current = setAt;
64
+ return () => { followRef.current = null; };
65
+ }, [followRef]);
66
+ // measured before the first paint (useSize), and laid out unseen until then, so it never shows a frame out of place
67
+ const [sizeRef, size] = useSize<HTMLDivElement>();
68
+ const canvasWidth = useStore((s) => s.width);
69
+ const canvasHeight = useStore((s) => s.height);
70
+ const canvas = { width: canvasWidth, height: canvasHeight };
71
+ const hold = pinned ? held ?? null : null;
72
+ // a pinned card goes where the spot it was clicked at goes on screen, as the wheel zooms the canvas round the pointer
73
+ const view = useStore((s) => (pinned ? s.transform : null));
74
+ const shift = view === null ? { x: 0, y: 0 } : { x: at.spot.x * view[2] + view[0] - at.point.x, y: at.spot.y * view[2] + view[1] - at.point.y };
75
+ const base = size === null ? null
76
+ : hold !== null ? grown(hold, size)
77
+ : bubblePlace(at.point, at.upright, at.half, size, canvas, at.line, at.clear);
78
+ let place = base;
79
+ if (pinned && base !== null && size !== null){
80
+ const moved = { left: base.left + shift.x, top: base.top + shift.y };
81
+ const kept = insideCanvas(moved, size, canvas);
82
+ // moved inside the canvas, it's no longer the tail's length from the spot, so it has no tail
83
+ place = { ...base, ...kept, tail: kept.left === moved.left && kept.top === moved.top ? base.tail : null };
84
+ }
85
+ useLayoutEffect(() => {
86
+ if (placedRef === undefined) return;
87
+ placedRef.current = place === null || size === null ? null : { ...place, ...size };
88
+ });
89
+
90
+ // as many rows as fit over or under the spot, past the tail, so the card keeps its tail and stays off the line; in a
91
+ // canvas with no room even for the title and "and N more", the title alone says how many lines there are
92
+ // ("Into utils.ts", "43 lines")
93
+ const room = hold !== null
94
+ ? Math.max(hold.height, hold.rises ? hold.top + hold.height - CARD_EDGE : canvasHeight - CARD_EDGE - hold.top)
95
+ : Math.max(bubbleRoom(at.point, at.upright, at.half, size?.width ?? Math.min(CARD_WIDTH, canvasWidth - 2 * CARD_EDGE), canvas), pinned ? CARD_FRAME + 2 * CARD_ROW : 0);
96
+ const titleOnly = !pinned && words.kind === "run" && room < CARD_FRAME + 2 * CARD_ROW;
97
+ const rows = words.kind === "run" && !titleOnly ? (pinned ? words.rows.length : hoverCardRows(words.rows.length, room)) : 0;
98
+ const more = words.kind === "run" && !titleOnly ? words.rows.length - rows : 0;
99
+ const label = words.kind === "one" ? words.sentence : words.title;
100
+ return (
101
+ <div
102
+ ref={(element) => {
103
+ sizeRef(element);
104
+ if (cardRef) cardRef.current = element;
105
+ }}
106
+ data-line-card={pinned ? "pinned" : "hover"}
107
+ data-lines={words.kind === "one" ? 1 : words.rows.length}
108
+ role={pinned ? "group" : undefined}
109
+ aria-label={label}
110
+ style={{
111
+ left: place?.left ?? 0,
112
+ top: place?.top ?? 0,
113
+ width: hold?.width,
114
+ maxWidth: Math.min(CARD_WIDTH, canvasWidth - 2 * CARD_EDGE),
115
+ // pinned: the title, PINNED_ROWS rows and the frame, or the room it has, and never less than it was
116
+ maxHeight: pinned ? Math.min(room, Math.max(hold?.height ?? 0, CARD_FRAME + CARD_ROW * (1 + PINNED_ROWS))) : undefined,
117
+ visibility: place === null ? "hidden" : undefined,
118
+ }}
119
+ className={`absolute z-[8] flex w-max flex-col rounded-md border border-border bg-panel py-1 text-[12px] leading-[18px] text-text ${pinned ? "nowheel" : "pointer-events-none"}`}
120
+ >
121
+ {words.kind === "one" ? (
122
+ <p className="flex whitespace-nowrap px-2">
123
+ <span className="truncate" title={words.parts.who}>{words.parts.who}</span>
124
+ <span className="shrink-0 whitespace-pre"> {words.parts.verb} </span>
125
+ <span className="truncate" title={words.parts.whom}>{words.parts.whom}</span>
126
+ <span className="shrink-0 whitespace-pre">, {words.parts.imports}</span>
127
+ </p>
128
+ ) : (
129
+ <>
130
+ <p className="flex shrink-0 gap-3 whitespace-nowrap px-2">
131
+ <span className="truncate font-medium" title={words.title}>{words.title}</span>
132
+ <span className="ml-auto shrink-0 text-text-3">{titleOnly ? `${formatCount(words.rows.length)} lines` : "imports"}</span>
133
+ </p>
134
+ <ul className={pinned ? "min-h-0 overflow-y-auto overscroll-contain" : undefined}>
135
+ {words.rows.slice(0, rows).map((row) => (
136
+ <li key={row.id} className="flex gap-3 whitespace-nowrap px-2">
137
+ <span className="truncate text-text-2" title={row.name}>{row.name}</span>
138
+ <span className="ml-auto shrink-0 tabular-nums">{formatCount(row.count)}</span>
139
+ </li>
140
+ ))}
141
+ </ul>
142
+ {more > 0 && (
143
+ <p title={`and ${formatCount(more)} more, click to see all`} className="shrink-0 truncate px-2 text-text-3">and {formatCount(more)} more, click to see all</p>
144
+ )}
145
+ </>
146
+ )}
147
+ {place?.tail && <Tail side={place.tail.side} at={place.tail.at} />}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ /** A held card grown to `size`: its left and its edge at the tail kept, so one rising from its tail grows up. */
153
+ function grown(hold: CardPlace, size: { width: number; height: number }): BubblePlace {
154
+ const rise = hold.rises ? size.height - hold.height : 0;
155
+ const side = hold.tail?.side;
156
+ return {
157
+ left: hold.left,
158
+ top: hold.top - rise,
159
+ // a tail on the card's side stays the same way from its bottom as the card rises
160
+ tail: hold.tail && (side === "left" || side === "right") ? { side: hold.tail.side, at: hold.tail.at + rise } : hold.tail,
161
+ rises: hold.rises,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * The card's tail, a triangle TAIL_BASE wide and TAIL_LENGTH long out of side `side`, its middle `at` px along it from
167
+ * the card's left or top. Its base lies on the card's border, which its fill hides, and its two other sides are drawn in
168
+ * the border's color, so card and tail read as one shape.
169
+ */
170
+ function Tail({ side, at }: { side: TailSide; at: number }){
171
+ const across = side === "top" || side === "bottom";
172
+ const length = TAIL_LENGTH + 1;
173
+ // placed from the card's padding box, 1 px in from its border box, so its base covers the border
174
+ const style: CSSProperties = side === "bottom" ? { left: at - TAIL_BASE / 2 - 1, top: "100%" }
175
+ : side === "top" ? { left: at - TAIL_BASE / 2 - 1, bottom: "100%" }
176
+ : side === "right" ? { top: at - TAIL_BASE / 2 - 1, left: "100%" }
177
+ : { top: at - TAIL_BASE / 2 - 1, right: "100%" };
178
+ const d = side === "bottom" ? `M 0 0 L ${TAIL_BASE / 2} ${length} L ${TAIL_BASE} 0`
179
+ : side === "top" ? `M 0 ${length} L ${TAIL_BASE / 2} 0 L ${TAIL_BASE} ${length}`
180
+ : side === "right" ? `M 0 0 L ${length} ${TAIL_BASE / 2} L 0 ${TAIL_BASE}`
181
+ : `M ${length} 0 L 0 ${TAIL_BASE / 2} L ${length} ${TAIL_BASE}`;
182
+ return (
183
+ <svg aria-hidden data-tail={side} width={across ? TAIL_BASE : length} height={across ? length : TAIL_BASE} style={style} className="absolute overflow-visible fill-panel stroke-border">
184
+ <path d={d} strokeWidth={1} strokeLinejoin="round" />
185
+ </svg>
186
+ );
187
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { memo } from "react";
4
4
  import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
5
- import { IconArrowLeft, IconArrowRight, IconFile, IconFolder, IconStack2 } from "@tabler/icons-react";
5
+ import { IconArrowLeft, IconArrowRight, IconStack2 } from "@tabler/icons-react";
6
6
  import type { MapNode } from "@/lib/contracts";
7
7
  import type { BoxTone } from "./emphasis";
8
8
  import { formatCount } from "./format";
@@ -16,67 +16,102 @@ export type BoxData = {
16
16
  agent: boolean;
17
17
  /** Changes when a tool call touches this box: the pulse ring remounts and plays again. */
18
18
  pulseKey: number;
19
- /** Portals only: which way the doorway leads, "Lines to page 2", and "44 folders and files · 6 imports" under it (portals.ts). */
19
+ /** The dot's diameter in px, by how many files the box holds against the page's largest folder (weights.ts). */
20
+ dot: number;
21
+ /** Portals only: which way the doorway leads, "Lines to page 2", and "44 folders and files, 6 imports" under it (portals.ts). */
20
22
  portal?: { direction: "previous" | "next" | "many"; title: string; caption: string };
21
23
  };
22
24
 
23
25
  export type BoxNode = Node<BoxData, "box">;
24
26
 
25
27
  /**
26
- * Fill, border and opacity per tone. The selected box's border is 2 px, so its padding is 1 px less and its content
27
- * doesn't shift when it's selected.
28
+ * A color as a box away from the selection draws it: mixed toward the background, keeping `--fade` of itself (50%, 70%
29
+ * on high contrast), never see-through, so a folder's back card can't show through the card in front of it (the
30
+ * owner, 2026-10-27).
28
31
  */
29
- const TONES: Record<BoxTone, string> = {
30
- normal: "border border-border bg-surface px-3",
31
- linked: "border border-border bg-surface px-3",
32
- preview: "border border-text-2 bg-surface px-3",
33
- selected: "border-2 border-select bg-select-fill px-[11px]",
34
- faded: "border border-border bg-surface px-3 opacity-60",
35
- };
32
+ export function shade(color: string, faded: boolean): string {
33
+ return faded ? `color-mix(in srgb, ${color} var(--fade), var(--canvas))` : color;
34
+ }
36
35
 
37
- /**
38
- * A portal, a doorway to another page, is dashed and unfilled, the dot grid showing through, its border a step brighter
39
- * than a box's so it reads as a way out rather than a folder (owner pick, 2026-10-13). Selected, it fills like any box.
40
- */
41
- const PORTAL_TONES: Record<BoxTone, string> = {
42
- normal: "border border-dashed border-text-2 bg-transparent px-3",
43
- linked: "border border-dashed border-text-2 bg-transparent px-3",
44
- preview: "border border-dashed border-text bg-transparent px-3",
45
- selected: "border-2 border-dashed border-select bg-select-fill px-[11px]",
46
- faded: "border border-dashed border-text-2 bg-transparent px-3 opacity-60",
47
- };
36
+ /** A box's border by its tone: the accent for the selection, a step toward it for what's linked to it. */
37
+ export function borderOf(tone: BoxTone, portal: boolean): string {
38
+ if (tone === "selected") return "var(--accent)";
39
+ if (tone === "linked") return "var(--linked-border)";
40
+ return tone === "preview" || portal ? "var(--text-3)" : "var(--border)";
41
+ }
48
42
 
49
43
  const PORTAL_ICONS = { previous: IconArrowLeft, next: IconArrowRight, many: IconStack2 } as const;
50
44
 
51
45
  /**
52
- * One box on the Map (VISUAL_REFRESH.md §4): an outline icon, the name, and for folders how many files they hold.
53
- * Summaries live in the panel, not here. A portal, the doorway to another page of a big folder, is dashed and unfilled,
54
- * titled by where its lines go ("Lines to page 2"), with an arrow toward its page (a stack for several) and how many
55
- * boxes and imports are behind it; double-clicking it turns to that page.
46
+ * The glyph slot at a box's left (the Workbench redesign, ADR-070): a folder's dot, sized by its files; a file's page,
47
+ * its corner folded; and for the selection the logo's core, its white center in a purple ring. What's linked to the
48
+ * selection takes the logo's lilac.
49
+ */
50
+ function Glyph({ box, tone, dot }: Pick<BoxData, "box" | "tone" | "dot">){
51
+ const faded = tone === "faded";
52
+ const color = shade(tone === "linked" ? "var(--linked-glyph)" : tone === "preview" ? "var(--text-2)" : "var(--glyph-dim)", faded);
53
+ return (
54
+ <svg aria-hidden width={24} height={24} viewBox="0 0 24 24" className="absolute left-[12px] top-[16px]">
55
+ {tone === "selected"
56
+ ? <circle cx={12} cy={12} r={dot / 2} fill="var(--core)" stroke="var(--accent)" strokeWidth={3} />
57
+ : box.kind === "folder"
58
+ ? <circle cx={12} cy={12} r={dot / 2} fill={color} />
59
+ : <path d="M7 5 h6 l4 4 v10 h-10 z M13 5 v4 h4" fill="none" stroke={color} strokeWidth={1.4} strokeLinejoin="round" />}
60
+ </svg>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * One box on the Map (the Workbench redesign, ADR-070): the glyph slot, the name in mono, and for a folder how many
66
+ * files it holds, right-aligned. Every box is 232 × 56 (levelLayout.ts); a folder's back card, the hint that it opens
67
+ * into more boxes, is drawn by the canvas under the lines (MapCanvas's BackCards). Summaries live in the inspector, not
68
+ * here. A portal, the doorway to another page of a big folder, is dashed and unfilled, titled by where its lines go
69
+ * ("Lines to page 2"), with an arrow toward its page (a stack for several) and how many boxes and imports are behind
70
+ * it; double-clicking it turns to that page.
56
71
  */
57
72
  function MapBox({ data }: NodeProps<BoxNode>){
58
- const { box, tone, agent, pulseKey, portal } = data;
59
- const Icon = portal ? PORTAL_ICONS[portal.direction] : box.kind === "folder" ? IconFolder : IconFile;
73
+ const { box, tone, agent, pulseKey, portal, dot } = data;
74
+ const faded = tone === "faded";
75
+ const selected = tone === "selected";
60
76
  const title = portal
61
- ? `${portal.title}: ${portal.caption}. ${portal.direction === "many" ? "Select it to pick a page." : "Double-click to go there."}`
77
+ ? `${portal.title}, ${portal.caption}. ${portal.direction === "many" ? "Select it to pick a page." : "Double-click to go there."}`
62
78
  : boxTitle(box);
79
+ const fill = selected ? "var(--node-sel)" : portal ? "transparent" : shade("var(--node)", faded);
80
+ const PortalIcon = portal ? PORTAL_ICONS[portal.direction] : null;
63
81
 
64
82
  return (
65
83
  <div
66
84
  data-box={box.path}
67
85
  data-tone={tone}
68
86
  title={title}
69
- className={`relative flex h-[56px] w-[232px] items-center gap-2 rounded-lg ${portal ? PORTAL_TONES[tone] : TONES[tone]} ${agent ? "outline-2 outline-offset-2 outline-agent" : ""}`}
87
+ style={{ background: fill }}
88
+ className={`relative h-[56px] w-[232px] rounded-[5px] ${agent ? "outline-2 outline-offset-2 outline-agent" : ""}`}
70
89
  >
71
- {pulseKey > 0 && <span key={pulseKey} aria-hidden className="neb-pulse pointer-events-none absolute inset-0 rounded-lg" />}
72
- <Icon aria-hidden size={16} stroke={1.75} className={`shrink-0 ${box.kind === "folder" ? "text-folder" : "text-text-2"}`} />
73
- <div className="min-w-0">
74
- <div className="truncate text-[15px] font-medium leading-5 text-text">{portal ? portal.title : box.name}</div>
75
- {portal && <div className="truncate text-[12px] leading-4 text-text-2">{portal.caption}</div>}
76
- {box.kind === "folder" && (
77
- <div className="text-[12px] leading-4 text-text-2">{formatCount(box.fileCount)} {box.fileCount === 1 ? "file" : "files"}</div>
90
+ {pulseKey > 0 && <span key={pulseKey} aria-hidden className="neb-pulse pointer-events-none absolute inset-0 rounded-[5px]" />}
91
+ {/* the border is its own layer, so a selected box's thicker one never moves what's inside */}
92
+ <span
93
+ aria-hidden
94
+ style={{ borderColor: shade(borderOf(tone, portal !== undefined), faded), borderWidth: selected ? 1.5 : 1 }}
95
+ className={`pointer-events-none absolute inset-0 rounded-[5px] ${portal ? "border-dashed" : "border-solid"}`}
96
+ />
97
+ {PortalIcon
98
+ ? <PortalIcon aria-hidden size={16} stroke={1.75} className="absolute left-[16px] top-[20px]" style={{ color: shade("var(--text-3)", faded) }} />
99
+ : <Glyph box={box} tone={tone} dot={dot} />}
100
+ {portal
101
+ ? (
102
+ <div className="absolute inset-y-0 left-[44px] right-[14px] flex flex-col justify-center">
103
+ <div className="truncate text-[13px] font-medium leading-[18px]" style={{ color: shade("var(--text)", faded) }}>{portal.title}</div>
104
+ <div className="truncate text-[11.5px] leading-4" style={{ color: shade("var(--text-3)", faded) }}>{portal.caption}</div>
105
+ </div>
106
+ )
107
+ : (
108
+ <div className="absolute inset-y-0 left-[44px] right-[14px] flex items-center gap-2">
109
+ <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" style={{ color: shade("var(--text)", faded) }}>{box.name}</span>
110
+ {box.kind === "folder" && (
111
+ <span className="shrink-0 text-[11.5px] tabular-nums" style={{ color: shade("var(--text-2)", faded) }}>{formatCount(box.fileCount)}</span>
112
+ )}
113
+ </div>
78
114
  )}
79
- </div>
80
115
  {/* the lines attach here; they carry the direction, so the handles stay out of sight */}
81
116
  <Handle type="target" position={Position.Top} className="!opacity-0" />
82
117
  <Handle type="source" position={Position.Bottom} className="!opacity-0" />