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,108 @@
1
+ import type { CanvasRect } from "./narrow";
2
+ import type { Point } from "./routes";
3
+
4
+ /**
5
+ * Which lines the pointer is on (the owner, 2026-10-29). Lines that share an end merge (routes.ts), so on a shared run
6
+ * the pointer catches whichever line is drawn on top, and one line's sentence there said nothing of the others. The
7
+ * lines a run is shared by are found from the routes themselves: every drawn line running the same way through the
8
+ * point the pointer is nearest. Pure, so each rule is tested alone.
9
+ */
10
+
11
+ /**
12
+ * How far apart two routes may run and still be one run. Merged lines share their lanes and tracks exactly, and lines
13
+ * that share no end keep at least a lane's gap apart (routes.ts), so this only absorbs rounding.
14
+ */
15
+ const SAME_RUN = 0.75;
16
+
17
+ /** The point of segment `a`→`b` nearest `p`. */
18
+ function nearestOnSegment(p: Point, a: Point, b: Point): Point {
19
+ const dx = b.x - a.x, dy = b.y - a.y;
20
+ const length2 = dx * dx + dy * dy;
21
+ if (length2 === 0) return a;
22
+ const t = Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2));
23
+ return { x: a.x + t * dx, y: a.y + t * dy };
24
+ }
25
+
26
+ const distance = (p: Point, q: Point): number => Math.hypot(p.x - q.x, p.y - q.y);
27
+
28
+ /** Whether two segments run the same way (or back the other way): a route's runs are level or upright, so near enough. */
29
+ function sameWay(a: Point, b: Point, c: Point, d: Point): boolean {
30
+ const u = { x: b.x - a.x, y: b.y - a.y }, v = { x: d.x - c.x, y: d.y - c.y };
31
+ const lengths = Math.hypot(u.x, u.y) * Math.hypot(v.x, v.y);
32
+ return lengths > 0 && Math.abs(u.x * v.y - u.y * v.x) / lengths < 0.01;
33
+ }
34
+
35
+ /**
36
+ * The point of `route` nearest `at`, and the segment it's on: segment i runs from route[i] to route[i + 1]. The first
37
+ * nearest wins a tie, so a corner belongs to the segment that reaches it. Null for a route of fewer than two points.
38
+ */
39
+ export function nearestOnRoute(route: readonly Point[], at: Point): { point: Point; segment: number } | null {
40
+ let best: { point: Point; segment: number; away: number } | null = null;
41
+ for (let i = 0; i + 1 < route.length; i++){
42
+ const point = nearestOnSegment(at, route[i], route[i + 1]);
43
+ const away = distance(at, point);
44
+ if (best === null || away < best.away) best = { point, segment: i, away };
45
+ }
46
+ return best && { point: best.point, segment: best.segment };
47
+ }
48
+
49
+ /**
50
+ * The drawn lines that share the run of line `id` at `at` (the Map's own coordinates): the point of its route nearest
51
+ * `at`, and every drawn line with a segment running the same way through that point, `id` among them, sorted, so the
52
+ * same run always reads the same. A line that only crosses there runs the other way, so it isn't on the run. Null when
53
+ * `id` has no route.
54
+ */
55
+ export function linesSharing(
56
+ id: string,
57
+ at: Point,
58
+ routes: ReadonlyMap<string, readonly Point[]>,
59
+ drawn: ReadonlySet<string>,
60
+ ): { lines: string[]; point: Point; segment: number } | null {
61
+ const route = routes.get(id);
62
+ if (route === undefined) return null;
63
+ const near = nearestOnRoute(route, at);
64
+ if (near === null) return null;
65
+ const a = route[near.segment], b = route[near.segment + 1];
66
+ const lines = [...routes].filter(([other, points]) => other === id || (drawn.has(other) && points.some((c, i) => {
67
+ const d = points[i + 1];
68
+ return d !== undefined && sameWay(a, b, c, d) && distance(near.point, nearestOnSegment(near.point, c, d)) <= SAME_RUN;
69
+ }))).map(([other]) => other).sort();
70
+ return { lines, point: near.point, segment: near.segment };
71
+ }
72
+
73
+ /** How near the pointer a bit of its line has to be for the hover card to keep off it, in screen px. */
74
+ const POINTED = 40;
75
+ /** How much wider than the line is drawn the card keeps off it, each side. */
76
+ const LINE_ROOM = 3;
77
+
78
+ /**
79
+ * The bit of a line the pointer is on, as rectangles on screen (canvas px), which the hover card keeps off: each
80
+ * segment of the line's route that comes within POINTED px of the pointer, as wide as the line is drawn with LINE_ROOM
81
+ * more each side, and its arrowhead (`head`, from weights.ts's arrowHead, its tip RoutedEdge's 3 px over the box) when
82
+ * that comes as near. `view` is the canvas's pan and zoom, so the route, in the Map's own coordinates, is on screen at
83
+ * point × zoom + (x, y).
84
+ */
85
+ export function pointedBits(
86
+ route: readonly Point[],
87
+ pointer: Point,
88
+ view: { x: number; y: number; zoom: number },
89
+ width: number,
90
+ head: { half: number; length: number },
91
+ ): CanvasRect[] {
92
+ const screen = (p: Point): Point => ({ x: p.x * view.zoom + view.x, y: p.y * view.zoom + view.y });
93
+ const half = (width * view.zoom) / 2 + LINE_ROOM;
94
+ const bits: CanvasRect[] = [];
95
+ for (let i = 0; i + 1 < route.length; i++){
96
+ const a = screen(route[i]), b = screen(route[i + 1]);
97
+ if (distance(pointer, nearestOnSegment(pointer, a, b)) > POINTED) continue;
98
+ bits.push({ left: Math.min(a.x, b.x) - half, top: Math.min(a.y, b.y) - half, width: Math.abs(b.x - a.x) + 2 * half, height: Math.abs(b.y - a.y) + 2 * half });
99
+ }
100
+ const end = route.at(-1);
101
+ if (end !== undefined){
102
+ const tip = screen({ x: end.x, y: end.y - 3 });
103
+ const across = head.half * view.zoom + LINE_ROOM, along = head.length * view.zoom;
104
+ const rect = { left: tip.x - across, top: tip.y - along, width: 2 * across, height: along };
105
+ if (distance(pointer, { x: Math.max(rect.left, Math.min(pointer.x, rect.left + rect.width)), y: Math.max(rect.top, Math.min(pointer.y, tip.y)) }) <= POINTED) bits.push(rect);
106
+ }
107
+ return bits;
108
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The Map's layout at any window size (the Workbench redesign, ADR-070; the owner's picks of 2026-10-27). Around the
3
+ * canvas sit the header, the explorer (the browser only), the toolbar, the legend sentence, the inspector and the
4
+ * status bar. As the window narrows or shortens each gives way in a fixed order, so nothing is cut off, nothing sits on
5
+ * anything else, and the page never scrolls sideways (the owner: "nothing should break at any size"). Only the canvas
6
+ * pans. Pure, so every rule is tested on its own; MapView reads it from the window's size.
7
+ *
8
+ * Widths (collapse option C):
9
+ * - the explorer is full from 1180 px, a 40 px rail from 600, and gone under 600 (search and the breadcrumb still reach
10
+ * every folder); VS Code's panel has none;
11
+ * - the inspector stays beside the canvas while 560 px of canvas is left. With less it moves under the canvas in a window
12
+ * at least 640 px tall, so the selected box and its details are both in view; in a shorter one it opens over the
13
+ * canvas as a drawer, and where the window is narrow too it covers the canvas, the selected box keeping its Open and
14
+ * Details beside it (narrow.ts's actionsPlace).
15
+ * Heights:
16
+ * - the legend sentence goes under 560 px tall (the ? help still has it), and under a 420 px canvas or a drawer;
17
+ * - the toolbar folds into the header under 420 px tall, one bar instead of two;
18
+ * - the status bar's two dots move to the end of that bar under 300 px tall, and the status bar goes;
19
+ * - the canvas keeps at least 160 px; below that the page scrolls down, never sideways.
20
+ */
21
+
22
+ /** Where the inspector goes: beside the canvas, under it, over it as a drawer, over all of it, or nowhere. */
23
+ export type InspectorPlace = "beside" | "below" | "drawer" | "cover" | "none";
24
+
25
+ export type Shell = {
26
+ explorer: "full" | "rail" | "none";
27
+ inspector: InspectorPlace;
28
+ /** The toolbar's controls sit in the header's row instead of a row of their own. */
29
+ toolbarInHeader: boolean;
30
+ /** The status bar, or its two dots at the end of the header's row, or (VS Code's panel, as the owner picked) its slim form. */
31
+ status: "bar" | "inHeader";
32
+ legend: boolean;
33
+ /** The canvas's width, which the toolbar's steps go by. */
34
+ canvasWidth: number;
35
+ /** The least height the page keeps: the canvas's floor plus what's above and below it. Below it the page scrolls down. */
36
+ minHeight: number;
37
+ };
38
+
39
+ export const EXPLORER_WIDTH = 236;
40
+ export const RAIL_WIDTH = 40;
41
+ export const INSPECTOR_WIDTH = 300;
42
+ /** The least canvas worth keeping beside the inspector. */
43
+ export const MIN_CANVAS_BESIDE = 560;
44
+ /** The explorer is full from this width, a rail from NARROW, and gone below. */
45
+ export const EXPLORER_FULL = 1180;
46
+ export const NARROW = 600;
47
+ /** A window at least this tall takes the inspector under the canvas rather than over it. */
48
+ export const BELOW_MIN_HEIGHT = 640;
49
+ /** The inspector under the canvas takes this share of the height. */
50
+ export const BELOW_SHARE = 0.42;
51
+ export const LEGEND_MIN_HEIGHT = 560;
52
+ export const LEGEND_MIN_CANVAS = 420;
53
+ export const TOOLBAR_FOLD_HEIGHT = 420;
54
+ export const STATUS_FOLD_HEIGHT = 300;
55
+ /** The least canvas, in px tall: below it the page scrolls down instead. */
56
+ export const MIN_CANVAS_HEIGHT = 160;
57
+
58
+ /** The bars' heights, in px. */
59
+ export const HEADER_HEIGHT = 42;
60
+ export const EMBEDDED_HEADER_HEIGHT = 38;
61
+ export const TOOLBAR_HEIGHT = 36;
62
+ export const STATUS_HEIGHT = 24;
63
+ export const LEGEND_HEIGHT = 44;
64
+
65
+ /**
66
+ * The layout for a window `width` × `height` px. `embedded` is VS Code's panel (no explorer, the slim status bar);
67
+ * `selected` says whether anything is selected: beside the canvas the inspector describes the folder on screen when
68
+ * nothing is, and in the other places it shows only for a selection.
69
+ */
70
+ export function shellLayout({ width, height, embedded, selected }: { width: number; height: number; embedded: boolean; selected: boolean }): Shell {
71
+ const explorer = embedded ? "none" : width >= EXPLORER_FULL ? "full" : width >= NARROW ? "rail" : "none";
72
+ const explorerWidth = explorer === "full" ? EXPLORER_WIDTH : explorer === "rail" ? RAIL_WIDTH : 0;
73
+ const besideRoom = width - explorerWidth - INSPECTOR_WIDTH;
74
+ const room: InspectorPlace = besideRoom >= MIN_CANVAS_BESIDE ? "beside"
75
+ : height >= BELOW_MIN_HEIGHT ? "below"
76
+ : width >= NARROW ? "drawer"
77
+ : "cover";
78
+ const inspector: InspectorPlace = room === "beside" || selected ? room : "none";
79
+ const canvasWidth = width - explorerWidth - (inspector === "beside" ? INSPECTOR_WIDTH : 0);
80
+ const toolbarInHeader = height < TOOLBAR_FOLD_HEIGHT;
81
+ const status = height < STATUS_FOLD_HEIGHT ? "inHeader" : "bar";
82
+ const legend = height >= LEGEND_MIN_HEIGHT && canvasWidth >= LEGEND_MIN_CANVAS && inspector !== "drawer" && inspector !== "cover";
83
+
84
+ // what sits above and below the canvas, and the inspector under it, which takes its share of the page's height
85
+ const chrome = (embedded ? EMBEDDED_HEADER_HEIGHT : HEADER_HEIGHT) + (toolbarInHeader ? 0 : TOOLBAR_HEIGHT)
86
+ + (status === "bar" ? STATUS_HEIGHT : 0) + (legend ? LEGEND_HEIGHT : 0);
87
+ const minHeight = inspector === "below"
88
+ ? Math.ceil((chrome + MIN_CANVAS_HEIGHT) / (1 - BELOW_SHARE))
89
+ : chrome + MIN_CANVAS_HEIGHT;
90
+ return { explorer, inspector, toolbarInHeader, status, legend, canvasWidth, minHeight };
91
+ }
@@ -26,7 +26,8 @@ export type ShortcutState = {
26
26
  * The Map's keyboard (HelpDialog's "Keyboard" list). Ctrl K or ⌘K opens search from anywhere. Every other key is the
27
27
  * Map's only while nothing is being typed and no dialog is open: with the source viewer open, Backspace or an arrow
28
28
  * would otherwise move the Map underneath and close the viewer with the panel it belongs to (second review I2).
29
- * Escape inside a dialog closes just that dialog; the dialogs handle it themselves.
29
+ * Escape inside a dialog closes just that dialog, and with a popover open just the popover. They handle it themselves and
30
+ * stop it before it gets here (useDismiss).
30
31
  */
31
32
  export function mapShortcut(event: { key: string; ctrlKey: boolean; metaKey: boolean }, state: ShortcutState): MapShortcut {
32
33
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") return { type: "search" };
@@ -1,4 +1,4 @@
1
- import type { ErrorCode, SummaryStatus } from "@/lib/contracts";
1
+ import type { DescribeResponse, ErrorCode, SummaryStatus } from "@/lib/contracts";
2
2
 
3
3
  /** A "summarize now" answer that isn't a summary: the error's code and its sentence (ApiError has both). */
4
4
  type SummaryAnswer = { code: ErrorCode | "NETWORK"; message: string };
@@ -10,15 +10,51 @@ type SummaryAnswer = { code: ErrorCode | "NETWORK"; message: string };
10
10
  */
11
11
  const WAITING: ReadonlySet<string> = new Set(["NOT_READY", "SUMMARIES_OFF"]);
12
12
 
13
- /** Whether opening a target should ask for its summary now (PIVOT_SPEC.md §5.3): it's missing or out of date. "off" never asks. */
14
- export function wantsSummary(status: SummaryStatus): boolean {
15
- return status === "pending" || status === "stale";
13
+ const missingOrOutOfDate = (status: SummaryStatus): boolean => status === "pending" || status === "stale";
14
+
15
+ /**
16
+ * Whether opening a target should ask for its summary now (PIVOT_SPEC.md §5.3). It's missing or out of date, or a model
17
+ * wrote it with an older version of the prompt, which opening rewrites in the current style (Q2 B). While summaries are
18
+ * off that one isn't asked for, since the answer could only be SUMMARIES_OFF (P11). "off" never asks.
19
+ */
20
+ export function wantsSummary(target: Pick<DescribeResponse, "summaryStatus" | "summaryOlderStyle">, summariesOff: boolean): boolean {
21
+ return missingOrOutOfDate(target.summaryStatus) || (target.summaryOlderStyle === true && !summariesOff);
22
+ }
23
+
24
+ /**
25
+ * What the panel remembers having asked for. A missing or out-of-date summary is asked for once per index version, as
26
+ * the index moving can mean it changed again. One that's only in the older style is asked for once per panel. A rewrite
27
+ * that fails leaves it current in the older style and moves the index (the stage refresh after it), so asking at each
28
+ * version would send again and again for as long as the panel stays open.
29
+ */
30
+ export function askKey(target: Pick<DescribeResponse, "path" | "indexVersion" | "summaryStatus">): string {
31
+ return missingOrOutOfDate(target.summaryStatus) ? `${target.path}@${target.indexVersion}` : `${target.path}@older`;
32
+ }
33
+
34
+ /** What's on show in the panel: the description, or the "summarize now" answer that replaced it. */
35
+ type ShownSummary = { tagline?: string; summary?: string; summaryStatus: SummaryStatus; summaryOlderStyle?: true };
36
+
37
+ /**
38
+ * The quiet lines under a summary. "Refreshing…" while it's rewritten. "Out of date, it changed since it was
39
+ * summarized." when its input changed, except for a folder or the repo an older prompt wrote, which reads stale once a
40
+ * child is restyled though no code changed (P10). Nothing for the older style itself (Q1 A). testsNote is the import-count
41
+ * note, which belongs to a file's older summary only, since the current prompt gives no counts.
42
+ */
43
+ export function summaryNotes(shown: ShownSummary, kind: DescribeResponse["kind"], refreshing: boolean): { notes: string[]; testsNote: boolean } {
44
+ const olderFolder = kind !== "file" && shown.summaryOlderStyle === true;
45
+ const notes = [
46
+ refreshing && shown.tagline ? "Refreshing…" : "",
47
+ shown.summaryStatus === "stale" && !refreshing && !olderFolder ? "Out of date, it changed since it was summarized." : "",
48
+ ].filter(Boolean);
49
+ // an older summary was written from an "Imported by" line that counts tests apart, and "Used by" counts them
50
+ const testsNote = Boolean(shown.summary) && kind === "file" && shown.summaryOlderStyle === true;
51
+ return { notes, testsNote };
16
52
  }
17
53
 
18
54
  /** What the panel says in place of a summary it doesn't have. */
19
55
  export function placeholderFor(status: SummaryStatus): string {
20
56
  switch (status){
21
- case "off": return "Summaries are off: ANTHROPIC_API_KEY isn't set. With a key, each file and folder gets a short plain-language summary here, and search can match by meaning.";
57
+ case "off": return "Summaries are off, since ANTHROPIC_API_KEY isn't set. With a key, each file and folder gets a short plain-language summary here, and search can match by meaning.";
22
58
  case "failed": return "The last attempt to summarize this failed.";
23
59
  case "pending": return "Summarizing…";
24
60
  default: return "";
package/app/map/text.ts CHANGED
@@ -36,14 +36,22 @@ function wordBefore(text: string, end: number): string {
36
36
  return text.slice(start, end).replace(/^\(/, "").toLowerCase();
37
37
  }
38
38
 
39
+ /** A blank line, the break between two paragraphs. parseSummary leaves exactly one, but any whitespace on it counts. */
40
+ const PARAGRAPH_BREAK = /\n\s*\n/;
41
+
39
42
  /**
40
- * A summary's first sentence (the panel's lead) and the rest, behind "Read full summary" (VISUAL_REFRESH.md §5).
43
+ * A summary's lead, which the panel shows first, and the rest, behind "Full summary" (VISUAL_REFRESH.md §5). A summary
44
+ * in paragraphs leads with its whole first paragraph, which the prompt keeps to a sentence or two on what the thing is
45
+ * for (D10). One in a single block, the way an older prompt wrote nearly all of them, leads with its first sentence.
41
46
  * A sentence ends at ".", "!" or "?" outside code, followed by whitespace and then something a sentence starts with,
42
47
  * unless the word before the period is an abbreviation. "3.4" and `main.ts` never split, since no space follows
43
48
  * their periods. When in doubt it doesn't split: a longer lead costs a line, a cut-off one costs the meaning.
44
49
  */
45
50
  export function splitSummary(summary: string): { lead: string; rest: string } {
46
51
  const text = summary.trim();
52
+ const paragraph = PARAGRAPH_BREAK.exec(text);
53
+ if (paragraph) return { lead: text.slice(0, paragraph.index), rest: text.slice(paragraph.index + paragraph[0].length) };
54
+
47
55
  let inCode = false;
48
56
 
49
57
  for (let i = 0; i < text.length; i++){
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The Map's colors (the Workbench redesign, 2026-10-27). Every gray comes from a theme: inside VS Code's panel the
3
+ * person's own (posted by the panel's relay, API_CONTRACTS §20's `nebulosity.theme`), in the browser and the demo a
4
+ * fixed one, VS Code's Dark 2026. Purple only means something (the selection, what's linked to it, the one primary
5
+ * button, the active tab, the logo), in the logo's own colors, a step darker on light themes so they keep their
6
+ * contrast against white. Gold, across from it, means what uses the selection.
7
+ *
8
+ * `fromTheme` is the approved mockup's rule (mockup3.html, the owner's picks of 2026-10-27): each part is mixed from the
9
+ * theme's background toward its text color by a set amount, strong enough that every box stands off any background.
10
+ * Pure, so the rule is tested on its own, and app/globals.css holds its result for Dark 2026.
11
+ */
12
+
13
+ import type { BridgeThemeColors, ThemeKind } from "@/lib/contracts";
14
+
15
+ export type { ThemeKind };
16
+
17
+ /** A theme's colors, as the panel's relay reads them from VS Code's `--vscode-*` variables. Hex, `#rrggbb`. */
18
+ export type ThemeInput = {
19
+ kind: ThemeKind;
20
+ /** editor.background: under the Map. */
21
+ editor: string;
22
+ /** sideBar.background: the Map's panels (header, explorer, inspector, status bar). */
23
+ side: string;
24
+ /** A border the theme defines (its high contrast border, a widget's, the panel's), if it has one. */
25
+ border?: string;
26
+ /** foreground: names and headings. */
27
+ fg: string;
28
+ /** descriptionForeground: the quietest text, if the theme has one. */
29
+ desc?: string;
30
+ /** focusBorder: the keyboard focus ring, if the theme has one. */
31
+ focus?: string;
32
+ };
33
+
34
+ /** Every token the Map draws with, by its CSS name (`--canvas`, …), each a `#rrggbb`, and `fade` a percentage. */
35
+ export type Palette = {
36
+ canvas: string;
37
+ panel: string;
38
+ node: string;
39
+ "node-sel": string;
40
+ "row-sel": string;
41
+ divider: string;
42
+ border: string;
43
+ "linked-border": string;
44
+ text: string;
45
+ "text-2": string;
46
+ "text-3": string;
47
+ "edge-dim": string;
48
+ /** What the selection uses, in the logo's spoke color: its lines out, and Uses' bars, underline and mark (emphasis.ts's SIDE_COLOR). */
49
+ "edge-strong": string;
50
+ "glyph-dim": string;
51
+ "linked-glyph": string;
52
+ /** What uses the selection, in gold across from the purple of what it uses: its lines in, and Used by's bars, underline and mark. */
53
+ "used-by-edge": string;
54
+ accent: string;
55
+ "on-accent": string;
56
+ core: string;
57
+ agent: string;
58
+ danger: string;
59
+ ok: string;
60
+ focus: string;
61
+ /** How much of its own color a box away from the selection keeps, mixed with the background: "50%". */
62
+ fade: string;
63
+ };
64
+
65
+ /** VS Code's Dark 2026 (theme-defaults/themes/2026-dark.json), its default dark theme: the browser's and the demo's palette. */
66
+ export const DARK_2026: ThemeInput = { kind: "dark", editor: "#121314", side: "#191A1B", border: "#2A2B2C", fg: "#BFBFBF", desc: "#8C8C8C" };
67
+
68
+ const rgbOf = (hex: string): [number, number, number] => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number];
69
+ const hexOf = (rgb: readonly number[]): string => "#" + rgb.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0")).join("").toUpperCase();
70
+
71
+ /** `a` moved `t` of the way to `b`, channel by channel in sRGB (CSS's `color-mix(in srgb, …)` does the same). */
72
+ export function mix(a: string, b: string, t: number): string {
73
+ const [x, y] = [rgbOf(a), rgbOf(b)];
74
+ return hexOf(x.map((v, i) => v + (y[i] - v) * t));
75
+ }
76
+
77
+ /**
78
+ * A CSS color as `#rrggbb`, or null when it isn't one this reads: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()` and
79
+ * `rgba()`. A see-through color is laid over `under` first, since a box drawn in it would show what's behind it (VS
80
+ * Code gives some borders as a gray with alpha).
81
+ */
82
+ export function parseColor(value: string, under: string = "#000000"): string | null {
83
+ const text = value.trim().toLowerCase();
84
+ let rgba: number[] | null = null;
85
+ const hex = /^#([0-9a-f]{3,8})$/.exec(text);
86
+ if (hex && [3, 4, 6, 8].includes(hex[1].length)){
87
+ const digits = hex[1].length <= 4 ? [...hex[1]].map((d) => d + d).join("") : hex[1];
88
+ rgba = [0, 2, 4, 6].map((i) => (i < digits.length ? parseInt(digits.slice(i, i + 2), 16) : 255));
89
+ rgba[3] /= 255;
90
+ }
91
+ const fn = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)$/.exec(text);
92
+ if (fn){
93
+ const alpha = fn[4] === undefined ? 1 : fn[4].endsWith("%") ? parseFloat(fn[4]) / 100 : parseFloat(fn[4]);
94
+ rgba = [Number(fn[1]), Number(fn[2]), Number(fn[3]), alpha];
95
+ }
96
+ if (rgba === null || rgba.some((v) => Number.isNaN(v))) return null;
97
+ const [r, g, b, a] = rgba;
98
+ const base = rgbOf(under);
99
+ const alpha = Math.min(1, Math.max(0, a));
100
+ return hexOf([r, g, b].map((v, i) => base[i] + (v - base[i]) * alpha));
101
+ }
102
+
103
+ /**
104
+ * The Map's palette for a theme. On high contrast themes the boxes are drawn in the theme's own border color and a box
105
+ * away from the selection keeps 70% of its color instead of 50%.
106
+ */
107
+ export function fromTheme(t: ThemeInput): Palette {
108
+ const light = t.kind === "light" || t.kind === "hc-light";
109
+ const hc = t.kind === "hc" || t.kind === "hc-light";
110
+ const bg = t.editor;
111
+ const fg = t.fg;
112
+ const accent = light ? "#9333EA" : "#A855F7";
113
+ // how far each part is mixed from the background toward the text: boxes strong in every theme (the owner, 2026-10-27)
114
+ const k = { node: light ? 0.05 : 0.09, line: light ? 0.28 : 0.3, linked: 0.75, edge: 0.3, glyph: light ? 0.4 : 0.45 };
115
+ const line = mix(bg, fg, k.line);
116
+ // what uses the selection, in the owner's gold (2026-10-29, after pink was too like the purple), across from the
117
+ // purple of what it uses, a mustard on light themes and a step darker again on high contrast light, the way purple
118
+ // steps darker there, so its lines keep their contrast against white
119
+ const gold = t.kind === "hc-light" ? "#A16207" : light ? "#CA8A04" : "#FCD34D";
120
+ return {
121
+ canvas: bg,
122
+ panel: t.side,
123
+ node: hc ? bg : mix(bg, fg, k.node),
124
+ "node-sel": mix(bg, accent, light ? 0.1 : 0.16),
125
+ "row-sel": mix(t.side, accent, light ? 0.12 : 0.18),
126
+ divider: t.border ?? mix(bg, fg, 0.12),
127
+ border: hc ? t.border ?? line : line,
128
+ "linked-border": hc ? (light ? "#7E22CE" : "#C084FC") : mix(bg, accent, k.linked),
129
+ text: fg,
130
+ "text-2": mix(fg, bg, 0.22),
131
+ "text-3": t.desc ?? mix(fg, bg, 0.3),
132
+ "edge-dim": mix(bg, fg, hc ? 0.5 : k.edge),
133
+ "edge-strong": light ? "#7E22CE" : "#E9D5FF",
134
+ "glyph-dim": mix(bg, fg, hc ? 0.6 : k.glyph),
135
+ "linked-glyph": light ? "#A855F7" : "#C084FC",
136
+ "used-by-edge": gold,
137
+ accent,
138
+ "on-accent": light ? "#FFFFFF" : "#120C1C",
139
+ core: light ? accent : "#F0FDFF",
140
+ // Claude Code's orange, errors, the indexed dot and the focus ring, each a step darker on light themes
141
+ agent: light ? "#C2410C" : "#F08A4B",
142
+ danger: light ? "#B91C1C" : "#F07A7A",
143
+ ok: light ? "#0E7490" : "#5FC9D9",
144
+ focus: t.focus ?? (light ? "#0E7490" : "#67E8F9"),
145
+ fade: hc ? "70%" : "50%",
146
+ };
147
+ }
148
+
149
+ /** Whether a theme kind is a light one: the syntax colors, the native scrollbars and React Flow's own parts follow it. */
150
+ export const isLightKind = (kind: ThemeKind | null): boolean => kind === "light" || kind === "hc-light";
151
+
152
+ /**
153
+ * The theme a `nebulosity.theme` message names, its colors read as hex (each over the editor's background, so a
154
+ * see-through border becomes the gray it looks like), or null when its editor, side bar or text color can't be read.
155
+ */
156
+ export function themeInputOf(kind: ThemeKind, colors: BridgeThemeColors): ThemeInput | null {
157
+ const editor = parseColor(colors.editor);
158
+ if (editor === null) return null;
159
+ const over = (value: string | undefined): string | null => (value === undefined ? null : parseColor(value, editor));
160
+ const side = over(colors.side);
161
+ const fg = over(colors.fg);
162
+ if (side === null || fg === null) return null;
163
+ const optional = (value: string | undefined): string | undefined => over(value) ?? undefined;
164
+ return { kind, editor, side, fg, border: optional(colors.border), desc: optional(colors.desc), focus: optional(colors.focus) };
165
+ }
166
+
167
+ /** The element the tokens live on, the page's `<html>`: the real one fits, and tests pass a stand-in. */
168
+ export type ThemeRoot = { style: { setProperty(name: string, value: string): void }; setAttribute(name: string, value: string): void };
169
+
170
+ /** Sets every token from a theme on the page's root, over globals.css's own values, and names its kind (`data-kind`). */
171
+ export function applyTheme(root: ThemeRoot, input: ThemeInput): void {
172
+ for (const [name, value] of Object.entries(fromTheme(input))) root.style.setProperty(`--${name}`, value);
173
+ root.setAttribute("data-kind", input.kind);
174
+ }
175
+
176
+ /**
177
+ * Stand-ins for the other kinds until the panel's message brings the person's own colors: VS Code's Light 2026 and its
178
+ * two high contrast themes (theme-defaults/themes). The panel's address names the kind (`&kind=light`), so a light
179
+ * theme's Map is light from its first paint instead of flashing dark until the bridge is up.
180
+ */
181
+ export const KIND_DEFAULTS: Record<Exclude<ThemeKind, "dark">, ThemeInput> = {
182
+ light: { kind: "light", editor: "#FFFFFF", side: "#FAFAFD", border: "#E2E2E5", fg: "#202020", desc: "#606060" },
183
+ hc: { kind: "hc", editor: "#000000", side: "#000000", border: "#6FC3DF", fg: "#FFFFFF", desc: "#FFFFFF" },
184
+ "hc-light": { kind: "hc-light", editor: "#FFFFFF", side: "#FFFFFF", border: "#0F4A85", fg: "#292929" },
185
+ };
186
+
187
+ /**
188
+ * The script the page runs before its first paint (app/layout.tsx): inside VS Code's panel, with a kind other than
189
+ * dark in the address, it sets that kind's stand-in tokens. Anywhere else it does nothing, so the browser and the
190
+ * demo keep globals.css's Dark 2026.
191
+ */
192
+ export function earlyThemeScript(): string {
193
+ const palettes = Object.fromEntries(Object.entries(KIND_DEFAULTS).map(([kind, input]) => [kind, fromTheme(input)]));
194
+ return `(function(){try{var q=new URLSearchParams(location.search);if(q.get("embed")!=="vscode")return;`
195
+ + `var k=q.get("kind"),P=${JSON.stringify(palettes)},p=Object.prototype.hasOwnProperty.call(P,k)?P[k]:null;if(!p)return;`
196
+ + `var r=document.documentElement;for(var n in p)r.style.setProperty("--"+n,p[n]);r.setAttribute("data-kind",k);}catch(e){}})();`;
197
+ }
@@ -3,9 +3,25 @@
3
3
  import { useEffect, useRef, type RefObject } from "react";
4
4
 
5
5
  /**
6
- * Closes a popover (the status menu, the repo picker) on a click anywhere outside it or on Escape. Left open, the
7
- * status menu sat over the details panel until its button was clicked again (owner review of the refresh). Returns the
8
- * ref to put on the element that holds both the button and the popover, so clicking the button still toggles it.
6
+ * The popover's Escape. It closes the popover and stops the press at the document, before it reaches the window, where
7
+ * the Map's own Escape (MapView) would let go of the selection too. The help promises one press each, the popover, then
8
+ * the selection, then Claude Code's highlights. It stays on the document rather than catching the press first, on the
9
+ * way down, as the help and the source viewer do, so either of them opened over a popover still closes alone. On its
10
+ * own so a test can press it without a page (useDismiss.test.ts).
11
+ */
12
+ export function closeOnEscape(onDismiss: () => void): (event: Pick<KeyboardEvent, "key" | "stopPropagation">) => void {
13
+ return (event) => {
14
+ if (event.key !== "Escape") return;
15
+ event.stopPropagation();
16
+ onDismiss();
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Closes a popover (the toolbar's … menu, the status menu, the repo picker, the breadcrumb's folded folders) on a click
22
+ * anywhere outside it or on Escape, which goes no further (closeOnEscape). Left open, the status menu sat over the
23
+ * details panel until its button was clicked again (owner review of the refresh). Returns the ref to put on the element
24
+ * that holds both the button and the popover, so clicking the button still toggles it.
9
25
  */
10
26
  export function useDismiss<T extends HTMLElement>(open: boolean, onDismiss: () => void): RefObject<T | null> {
11
27
  const ref = useRef<T>(null);
@@ -19,9 +35,7 @@ export function useDismiss<T extends HTMLElement>(open: boolean, onDismiss: () =
19
35
  const onPointer = (event: PointerEvent): void => {
20
36
  if (ref.current && event.target instanceof Node && !ref.current.contains(event.target)) latest.current();
21
37
  };
22
- const onKey = (event: KeyboardEvent): void => {
23
- if (event.key === "Escape") latest.current();
24
- };
38
+ const onKey = closeOnEscape(() => latest.current());
25
39
  document.addEventListener("pointerdown", onPointer);
26
40
  document.addEventListener("keydown", onKey);
27
41
  return () => {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useRef } from "react";
3
+ import { useEffect, useRef, useSyncExternalStore } from "react";
4
4
  import type { BridgeFromMap, BridgeToMap } from "@/lib/contracts";
5
5
  import { createBridge, isEmbedded, type BridgeWindow } from "./embed";
6
6
  import { DEMO } from "./mode";
@@ -12,6 +12,17 @@ import { DEMO } from "./mode";
12
12
  */
13
13
  export const EMBEDDED = !DEMO && typeof window !== "undefined" && isEmbedded(window.location.search);
14
14
 
15
+ const noSubscription = () => () => {};
16
+
17
+ /**
18
+ * EMBEDDED for what the page draws: false on the server and while the page hydrates (the server can't know it's in VS
19
+ * Code's panel), then the real value. Reading EMBEDDED itself in a render made the panel's first client render differ
20
+ * from the server's, and React threw the page away and drew it again.
21
+ */
22
+ export function useEmbedded(): boolean {
23
+ return useSyncExternalStore(noSubscription, () => EMBEDDED, () => false);
24
+ }
25
+
15
26
  type Handlers = { onMessage: (message: BridgeToMap) => void; onReady: () => BridgeFromMap | null };
16
27
 
17
28
  export type EditorLink = {
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+ import type { ThemeKind } from "@/lib/contracts";
5
+
6
+ const KINDS: ReadonlySet<string> = new Set<ThemeKind>(["dark", "light", "hc", "hc-light"]);
7
+
8
+ /** The page's theme kind (`data-kind` on `<html>`, set by theme.ts), dark when none is set. */
9
+ function readKind(): ThemeKind {
10
+ const kind = document.documentElement.getAttribute("data-kind");
11
+ return kind !== null && KINDS.has(kind) ? (kind as ThemeKind) : "dark";
12
+ }
13
+
14
+ /** Calls `onChange` whenever `<html>`'s `data-kind` changes: the panel's theme message, or VS Code's theme switched. */
15
+ function subscribe(onChange: () => void): () => void {
16
+ const observer = new MutationObserver(onChange);
17
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-kind"] });
18
+ return () => observer.disconnect();
19
+ }
20
+
21
+ /**
22
+ * The theme kind the Map is drawn in, for what CSS can't follow by itself (React Flow's own color mode). Dark on the
23
+ * server and in the browser; inside VS Code's panel, the person's kind, from its first paint (theme.ts's
24
+ * earlyThemeScript) and again whenever the theme changes.
25
+ */
26
+ export function useThemeKind(): ThemeKind {
27
+ return useSyncExternalStore(subscribe, readKind, () => "dark");
28
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+ import type { Size } from "./useSize";
5
+
6
+ /** What the server renders the layout for, and what hydration starts from: the size the Map was designed at. */
7
+ const SERVER_SIZE = "1440x900";
8
+
9
+ /**
10
+ * The window's size as one string, so an unchanged size is the same snapshot. The width is the page's own, without a
11
+ * vertical scrollbar (clientWidth): a short window scrolls down below the canvas's floor (shell.ts), and laying out for
12
+ * the width with the scrollbar would push the page sideways by its width.
13
+ */
14
+ function snapshot(): string {
15
+ return `${document.documentElement.clientWidth}x${window.innerHeight}`;
16
+ }
17
+
18
+ function subscribe(onChange: () => void): () => void {
19
+ window.addEventListener("resize", onChange);
20
+ // the scrollbar coming or going changes clientWidth without a resize
21
+ const observer = new ResizeObserver(onChange);
22
+ observer.observe(document.documentElement);
23
+ return () => {
24
+ window.removeEventListener("resize", onChange);
25
+ observer.disconnect();
26
+ };
27
+ }
28
+
29
+ /**
30
+ * The window's size in px, which the Map's layout goes by (shell.ts). Inside VS Code's panel the window is the panel.
31
+ * The server and the first client render use 1440 × 900, then the real size, so hydration never mismatches.
32
+ */
33
+ export function useWindowSize(): Size {
34
+ const text = useSyncExternalStore(subscribe, snapshot, () => SERVER_SIZE);
35
+ const [width, height] = text.split("x").map(Number);
36
+ return { width, height };
37
+ }