@sleep2agi/agent-network-dashboard 0.4.6-preview.17 → 0.4.6-preview.171

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 (375) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +41 -41
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/routes-manifest.json +6 -0
  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/_head.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +12 -12
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/admin.html +2 -2
  26. package/.next/server/app/admin.rsc +14 -14
  27. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  28. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  30. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  33. package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
  34. package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
  35. package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
  36. package/.next/server/app/api/hub/servers/route.js +7 -0
  37. package/.next/server/app/api/hub/servers/route.js.map +5 -0
  38. package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
  39. package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
  40. package/.next/server/app/index.html +2 -2
  41. package/.next/server/app/index.rsc +14 -14
  42. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  43. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  44. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  45. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  46. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  47. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/login.html +2 -2
  49. package/.next/server/app/login.rsc +14 -14
  50. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  51. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  52. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  53. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  54. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  55. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  56. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/logs.html +2 -2
  58. package/.next/server/app/logs.rsc +14 -14
  59. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  60. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  61. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  62. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  64. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  65. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/messages.html +2 -2
  67. package/.next/server/app/messages.rsc +14 -14
  68. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  69. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  70. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  71. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  72. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  73. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  74. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/node.html +2 -2
  76. package/.next/server/app/node.rsc +14 -14
  77. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  78. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  82. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  83. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/nodes.html +2 -2
  85. package/.next/server/app/nodes.rsc +14 -14
  86. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  87. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  88. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  89. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  90. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  91. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/server-logs.html +2 -2
  95. package/.next/server/app/server-logs.rsc +14 -14
  96. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  97. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  98. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  99. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  100. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  101. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  102. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  103. package/.next/server/app/settings/networks.html +2 -2
  104. package/.next/server/app/settings/networks.rsc +14 -14
  105. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  106. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  107. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  108. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  109. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  110. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  111. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  112. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  113. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/settings/tokens.html +2 -2
  115. package/.next/server/app/settings/tokens.rsc +14 -14
  116. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  117. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  118. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  119. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  120. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  121. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  122. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  123. package/.next/server/app/settings.html +2 -2
  124. package/.next/server/app/settings.rsc +14 -14
  125. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  126. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  127. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  128. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  130. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  131. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  132. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  133. package/.next/server/app/tasks.html +2 -2
  134. package/.next/server/app/tasks.rsc +14 -14
  135. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  136. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  137. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  138. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  139. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  140. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  141. package/.next/server/app-paths-manifest.json +1 -0
  142. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
  143. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map +1 -0
  144. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
  145. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js.map +1 -0
  146. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +1 -1
  147. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -1
  148. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +1 -1
  149. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -1
  150. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +1 -1
  151. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -1
  152. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +4 -1
  153. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  154. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  155. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  156. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +1 -1
  157. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -1
  158. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  159. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  160. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +1 -1
  161. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -1
  162. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +1 -1
  163. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -1
  164. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +1 -1
  165. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -1
  166. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +1 -1
  167. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -1
  168. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +1 -1
  169. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -1
  170. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +1 -1
  171. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -1
  172. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
  173. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -1
  174. package/.next/server/middleware-build-manifest.js +3 -3
  175. package/.next/server/pages/404.html +2 -2
  176. package/.next/server/pages/500.html +1 -1
  177. package/.next/server/server-reference-manifest.js +1 -1
  178. package/.next/server/server-reference-manifest.json +1 -1
  179. package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
  180. package/.next/static/chunks/01lmbsd37fybu.js +1 -0
  181. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  182. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  183. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  184. package/.next/static/chunks/07nyltnewyv1r.js +1 -0
  185. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  186. package/.next/static/chunks/0ggj5ummw2a84.js +7 -0
  187. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  188. package/.next/static/chunks/0ja51kalx4n.9.js +1 -0
  189. package/.next/static/chunks/0otgjhpse~amj.css +2 -0
  190. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  191. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  192. package/.next/static/chunks/0xsoiti7sg_jo.js +4 -0
  193. package/.next/static/chunks/174re5ar~kxne.js +1 -0
  194. package/.next/trace +2 -2
  195. package/.next/trace-build +1 -1
  196. package/.next/types/routes.d.ts +2 -1
  197. package/.next/types/validator.ts +9 -0
  198. package/app/api/hub/servers/route.ts +39 -0
  199. package/app/components/ChatPopover.tsx +38 -2
  200. package/app/components/CommandPalette.tsx +107 -0
  201. package/app/components/HelpOverlay.tsx +126 -0
  202. package/app/components/ServersDrawer.tsx +64 -27
  203. package/app/components/TopoGraph.tsx +3372 -241
  204. package/app/components/utils.ts +8 -7
  205. package/app/globals.css +75 -5
  206. package/app/lib/time.ts +36 -0
  207. package/package.json +1 -1
  208. package/scripts/help-overlay-topo-test.mjs +44 -0
  209. package/scripts/servers-drawer-live-test.mjs +96 -0
  210. package/scripts/topo-active-filter-pill-test.mjs +101 -0
  211. package/scripts/topo-active-links-chip-hover-test.mjs +103 -0
  212. package/scripts/topo-active-links-chip-nav-test.mjs +117 -0
  213. package/scripts/topo-active-links-chip-test.mjs +73 -0
  214. package/scripts/topo-active-links-freshness-test.mjs +113 -0
  215. package/scripts/topo-active-links-tooltip-test.mjs +79 -0
  216. package/scripts/topo-arrival-ping-test.mjs +87 -0
  217. package/scripts/topo-arrow-tier-test.mjs +61 -0
  218. package/scripts/topo-auto-fit-test.mjs +73 -0
  219. package/scripts/topo-canvas-aria-test.mjs +108 -0
  220. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  221. package/scripts/topo-chat-target-breath-test.mjs +84 -0
  222. package/scripts/topo-chip-focus-test.mjs +95 -0
  223. package/scripts/topo-chip-pin-mirror-test.mjs +113 -0
  224. package/scripts/topo-chrome-a11y-test.mjs +107 -0
  225. package/scripts/topo-click-ripple-test.mjs +66 -0
  226. package/scripts/topo-cmdk-edge-clear-test.mjs +109 -0
  227. package/scripts/topo-cmdk-layout-view-test.mjs +108 -0
  228. package/scripts/topo-cmdk-pin-test.mjs +123 -0
  229. package/scripts/topo-cmdk-vendor-clear-test.mjs +133 -0
  230. package/scripts/topo-cmdk-vendor-pin-test.mjs +103 -0
  231. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  232. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  233. package/scripts/topo-dispatch-pulse-test.mjs +83 -0
  234. package/scripts/topo-edge-badge-click-pin-test.mjs +94 -0
  235. package/scripts/topo-edge-badge-hot-test.mjs +115 -0
  236. package/scripts/topo-edge-badge-hover-test.mjs +85 -0
  237. package/scripts/topo-edge-badge-lift-test.mjs +150 -0
  238. package/scripts/topo-edge-count-badge-test.mjs +86 -0
  239. package/scripts/topo-edge-endpoint-highlight-test.mjs +111 -0
  240. package/scripts/topo-edge-focus-test.mjs +113 -0
  241. package/scripts/topo-edge-hover-test.mjs +93 -0
  242. package/scripts/topo-edge-pill-hot-test.mjs +129 -0
  243. package/scripts/topo-edge-pill-test.mjs +105 -0
  244. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  245. package/scripts/topo-endpoint-ring-test.mjs +93 -0
  246. package/scripts/topo-esc-clears-pin-test.mjs +98 -0
  247. package/scripts/topo-filter-pill-aliases-test.mjs +121 -0
  248. package/scripts/topo-filter-pill-body-click-test.mjs +112 -0
  249. package/scripts/topo-filter-pill-count-test.mjs +110 -0
  250. package/scripts/topo-filter-pill-mobile-test.mjs +90 -0
  251. package/scripts/topo-fit-key-test.mjs +106 -0
  252. package/scripts/topo-freshness-chip-test.mjs +74 -0
  253. package/scripts/topo-group-box-pinned-state-test.mjs +95 -0
  254. package/scripts/topo-group-count-test.mjs +68 -0
  255. package/scripts/topo-group-hover-test.mjs +82 -0
  256. package/scripts/topo-group-label-a11y-test.mjs +113 -0
  257. package/scripts/topo-group-label-hover-test.mjs +119 -0
  258. package/scripts/topo-group-label-tint-test.mjs +99 -0
  259. package/scripts/topo-group-label-title-test.mjs +83 -0
  260. package/scripts/topo-group-pin-test.mjs +119 -0
  261. package/scripts/topo-group-status-pips-test.mjs +103 -0
  262. package/scripts/topo-groupbox-hover-lift-test.mjs +127 -0
  263. package/scripts/topo-groupbox-live-test.mjs +100 -0
  264. package/scripts/topo-groupbox-march-busyness-test.mjs +155 -0
  265. package/scripts/topo-groupbox-test.mjs +4 -2
  266. package/scripts/topo-help-overlay-r109-test.mjs +67 -0
  267. package/scripts/topo-help-overlay-r118-test.mjs +69 -0
  268. package/scripts/topo-help-overlay-r123-test.mjs +69 -0
  269. package/scripts/topo-help-overlay-r134-test.mjs +74 -0
  270. package/scripts/topo-help-overlay-r137-test.mjs +81 -0
  271. package/scripts/topo-help-overlay-r141-test.mjs +86 -0
  272. package/scripts/topo-help-overlay-r153-test.mjs +85 -0
  273. package/scripts/topo-help-overlay-r59-test.mjs +67 -0
  274. package/scripts/topo-help-overlay-r65-test.mjs +64 -0
  275. package/scripts/topo-help-overlay-r70-test.mjs +63 -0
  276. package/scripts/topo-help-overlay-r78-test.mjs +59 -0
  277. package/scripts/topo-help-overlay-r81-test.mjs +58 -0
  278. package/scripts/topo-help-overlay-r87-test.mjs +64 -0
  279. package/scripts/topo-help-overlay-r91-test.mjs +65 -0
  280. package/scripts/topo-hub-a11y-test.mjs +112 -0
  281. package/scripts/topo-hub-busyness-test.mjs +100 -0
  282. package/scripts/topo-hub-click-fit-test.mjs +137 -0
  283. package/scripts/topo-hub-hover-ring-test.mjs +82 -0
  284. package/scripts/topo-hub-title-test.mjs +93 -0
  285. package/scripts/topo-hub-working-count-test.mjs +99 -0
  286. package/scripts/topo-ingroup-edge-test.mjs +103 -0
  287. package/scripts/topo-label-lift-test.mjs +119 -0
  288. package/scripts/topo-label-scale-test.mjs +78 -0
  289. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  290. package/scripts/topo-layout-key-test.mjs +91 -0
  291. package/scripts/topo-layout-toggle-test.mjs +131 -0
  292. package/scripts/topo-legend-count-test.mjs +88 -0
  293. package/scripts/topo-legend-hover-test.mjs +113 -0
  294. package/scripts/topo-legend-panel-header-test.mjs +83 -0
  295. package/scripts/topo-legend-pin-test.mjs +128 -0
  296. package/scripts/topo-legend-row-hover-lift-test.mjs +109 -0
  297. package/scripts/topo-legend-row-tint-test.mjs +95 -0
  298. package/scripts/topo-legend-row-title-test.mjs +102 -0
  299. package/scripts/topo-minimap-a11y-test.mjs +101 -0
  300. package/scripts/topo-minimap-test.mjs +68 -0
  301. package/scripts/topo-mobile-chips-test.mjs +103 -0
  302. package/scripts/topo-node-a11y-test.mjs +107 -0
  303. package/scripts/topo-node-hover-lift-test.mjs +85 -0
  304. package/scripts/topo-node-title-peers-test.mjs +107 -0
  305. package/scripts/topo-node-title-test.mjs +101 -0
  306. package/scripts/topo-offline-dim-test.mjs +70 -0
  307. package/scripts/topo-online-chip-nav-test.mjs +143 -0
  308. package/scripts/topo-orbit-busyness-test.mjs +96 -0
  309. package/scripts/topo-overlap-test.mjs +32 -5
  310. package/scripts/topo-pan-cursor-test.mjs +62 -0
  311. package/scripts/topo-panel-elevation-test.mjs +76 -0
  312. package/scripts/topo-panel-hover-elevation-test.mjs +118 -0
  313. package/scripts/topo-particle-stagger-test.mjs +82 -0
  314. package/scripts/topo-pin-intersection-empty-test.mjs +129 -0
  315. package/scripts/topo-pin-intersection-test.mjs +121 -0
  316. package/scripts/topo-pin-persistence-test.mjs +105 -0
  317. package/scripts/topo-popover-meta-test.mjs +78 -0
  318. package/scripts/topo-pressure-bar-test.mjs +69 -0
  319. package/scripts/topo-pressure-hover-test.mjs +123 -0
  320. package/scripts/topo-pressure-pin-test.mjs +119 -0
  321. package/scripts/topo-pressure-seg-tooltip-test.mjs +98 -0
  322. package/scripts/topo-pulse-rate-test.mjs +71 -0
  323. package/scripts/topo-recent-panel-count-freshness-test.mjs +109 -0
  324. package/scripts/topo-recent-panel-count-test.mjs +72 -0
  325. package/scripts/topo-recent-panel-hot-header-test.mjs +132 -0
  326. package/scripts/topo-recent-panel-more-nav-test.mjs +127 -0
  327. package/scripts/topo-recent-panel-more-test.mjs +106 -0
  328. package/scripts/topo-recent-row-freshness-test.mjs +102 -0
  329. package/scripts/topo-recent-row-hot-test.mjs +110 -0
  330. package/scripts/topo-recent-row-hover-lift-test.mjs +128 -0
  331. package/scripts/topo-recent-row-hover-test.mjs +122 -0
  332. package/scripts/topo-recent-row-pin-test.mjs +126 -0
  333. package/scripts/topo-recent-row-tint-test.mjs +91 -0
  334. package/scripts/topo-recent-row-title-test.mjs +112 -0
  335. package/scripts/topo-recent-row-ts-test.mjs +95 -0
  336. package/scripts/topo-recent-row-typography-test.mjs +119 -0
  337. package/scripts/topo-recent-signal-empty-hint-test.mjs +73 -0
  338. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  339. package/scripts/topo-reduced-motion-test.mjs +77 -0
  340. package/scripts/topo-spoke-busyness-test.mjs +110 -0
  341. package/scripts/topo-status-chip-hover-test.mjs +101 -0
  342. package/scripts/topo-status-chip-tooltip-test.mjs +95 -0
  343. package/scripts/topo-svg-focus-test.mjs +112 -0
  344. package/scripts/topo-sweep-busyness-test.mjs +94 -0
  345. package/scripts/topo-tier-ring-occupancy-test.mjs +87 -0
  346. package/scripts/topo-tier-ring-test.mjs +69 -0
  347. package/scripts/topo-tier-ring-tint-test.mjs +110 -0
  348. package/scripts/topo-tier-stagger-test.mjs +89 -0
  349. package/scripts/topo-tz-ghost-test.mjs +69 -0
  350. package/scripts/topo-vendor-chip-hover-test.mjs +111 -0
  351. package/scripts/topo-vendor-letter-tooltip-test.mjs +92 -0
  352. package/scripts/topo-vendor-pill-test.mjs +125 -0
  353. package/scripts/topo-vendor-pin-test.mjs +125 -0
  354. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  355. package/scripts/topo-working-chip-pin-test.mjs +148 -0
  356. package/scripts/topo-working-halo-breath-test.mjs +86 -0
  357. package/scripts/topo-zoom-keys-test.mjs +89 -0
  358. package/.next/static/chunks/0-7iowzu4.798.js +0 -1
  359. package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
  360. package/.next/static/chunks/096hkjkwj4hb3.js +0 -1
  361. package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
  362. package/.next/static/chunks/0d6xn54cd2zo-.js +0 -1
  363. package/.next/static/chunks/0elalb9n_.~di.js +0 -1
  364. package/.next/static/chunks/0fqy_0wj_1xa7.js +0 -7
  365. package/.next/static/chunks/0iv2-b3po~sbg.js +0 -1
  366. package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
  367. package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
  368. package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
  369. package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
  370. package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
  371. package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
  372. package/.next/static/chunks/13vw-ubg-9pjp.css +0 -2
  373. /package/.next/static/{TBtREVf7o4zYSqGqq3QT5 → Pp7a-aQawQY73_I-2rfeH}/_buildManifest.js +0 -0
  374. /package/.next/static/{TBtREVf7o4zYSqGqq3QT5 → Pp7a-aQawQY73_I-2rfeH}/_clientMiddlewareManifest.js +0 -0
  375. /package/.next/static/{TBtREVf7o4zYSqGqq3QT5 → Pp7a-aQawQY73_I-2rfeH}/_ssgManifest.js +0 -0
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
4
5
  import { Session } from './types';
5
6
  import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
6
7
  import { ChatPopover } from './ChatPopover';
7
8
  import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
9
+ import { parseHubTime, relativeAgo } from '../lib/time';
8
10
 
9
11
  interface MessageFlow {
10
12
  from_alias: string;
@@ -32,6 +34,9 @@ interface FlowLink {
32
34
  to: string;
33
35
  count: number;
34
36
  content: string;
37
+ /** ISO timestamp of the most recent message on this edge — drives
38
+ * the Round 10 freshness fade so dormant links recede visually. */
39
+ last_at: string;
35
40
  }
36
41
 
37
42
  const cx = 500;
@@ -85,36 +90,118 @@ function truncate(value: string, max: number) {
85
90
  return value.length > max ? `${value.slice(0, max - 1)}...` : value;
86
91
  }
87
92
 
93
+ /** Round 46 / Loop: SWR-freshness chip.
94
+ *
95
+ * TopoGraph's data refreshes every 5 s via SWR but the user has no
96
+ * visible signal that the canvas they're looking at is current. The
97
+ * flow particles and chips (R42 active-links, R43 hub) tell you when
98
+ * the FLEET last did something, not when the DATA last syncing. This
99
+ * chip ticks `live · Ns` against a 1-second interval so the operator
100
+ * can trust freshness without doing internal math.
101
+ *
102
+ * Owns its own setInterval so the parent topology doesn't re-render
103
+ * every second (only this small chip does). lastSyncRef is updated
104
+ * whenever the `sessions` prop reference changes — that's the SWR
105
+ * refresh signal. */
106
+ function FreshnessChip({ sessions }: { sessions: unknown }) {
107
+ const lastSyncRef = useRef<number>(Date.now());
108
+ const [now, setNow] = useState(() => Date.now());
109
+ useEffect(() => {
110
+ // sessions reference changed → SWR just delivered a new payload.
111
+ lastSyncRef.current = Date.now();
112
+ }, [sessions]);
113
+ useEffect(() => {
114
+ const id = setInterval(() => setNow(Date.now()), 1000);
115
+ return () => clearInterval(id);
116
+ }, []);
117
+ const sec = Math.max(0, Math.floor((now - lastSyncRef.current) / 1000));
118
+ // Tint the chip warmer when the data goes stale (>10s since last sync).
119
+ // SWR's default refreshInterval here is 5s, so anything past ~10s is
120
+ // either a poll miss or a network hiccup.
121
+ const stale = sec > 10;
122
+ return (
123
+ <span
124
+ className={stale
125
+ ? "hidden sm:inline px-2.5 py-1 rounded-md bg-amber-500/10 text-amber-300 border border-amber-500/20 font-mono"
126
+ : "hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"}
127
+ title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
128
+ data-freshness-chip
129
+ >
130
+ live · {sec}s
131
+ </span>
132
+ );
133
+ }
134
+
135
+ /** Round 36 / Loop: prefers-reduced-motion hook.
136
+ *
137
+ * Round 29's a11y sweep zeroed CSS animations via media query, but SVG
138
+ * SMIL `<animate>` / `<animateMotion>` elements aren't reachable from CSS
139
+ * — they need JS to opt out. This hook reads the media query and listens
140
+ * for changes so the topology's flow particles, pulses, click ripple
141
+ * and hub breath honour the OS-level preference. */
142
+ function useReducedMotion(): boolean {
143
+ const [reduced, setReduced] = useState(false);
144
+ useEffect(() => {
145
+ if (typeof window === 'undefined' || !window.matchMedia) return;
146
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
147
+ setReduced(mq.matches);
148
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
149
+ mq.addEventListener('change', onChange);
150
+ return () => mq.removeEventListener('change', onChange);
151
+ }, []);
152
+ return reduced;
153
+ }
154
+
155
+ // Round 38 / Loop: parseHubTime + relativeAgo factored to app/lib/time.ts
156
+ // — the Round 35 fix lives there now alongside the Round 37 mirror so
157
+ // any future TZ-safe parse update happens in one place.
158
+
159
+ /** Round 12 / Loop: status trio audit.
160
+ *
161
+ * Each (status × theme) cell returns a {primary, halo, text} trio. The trio
162
+ * invariant — keep it when adding states or tweaking shades:
163
+ *
164
+ * light: primary = <hue>-600 halo = <hue>-100 * text = <hue>-800/900
165
+ * dark: primary = <hue>-400/500 halo = <hue>-900 text = <hue>-100
166
+ *
167
+ * * offline.halo light deviates intentionally to slate-200 (#e2e8f0):
168
+ * slate-100 (#f1f5f9) is too close to the panel bg (#f8fafc) and the
169
+ * halo would vanish. The other three rows keep the 100-shade.
170
+ *
171
+ * Audit caught one cross-family drift before this round: online-other halo
172
+ * light was #dbeafe (blue-100) while its primary (#0284c7 sky-600) and text
173
+ * (#0c4a6e sky-900) were sky-family — now sky-100 (#e0f2fe). Tiny visual
174
+ * difference; large hygiene win — every trio is now mono-hue. */
88
175
  function nodeStatus(session: Session, isOnline: boolean, isLight: boolean) {
89
176
  if (!isOnline) {
90
177
  return {
91
178
  label: 'offline',
92
- primary: isLight ? '#94a3b8' : '#6b7280',
93
- halo: isLight ? '#e2e8f0' : '#111827',
94
- text: isLight ? '#475569' : '#9ca3af',
179
+ primary: isLight ? '#94a3b8' : '#6b7280', // slate-400 / gray-500
180
+ halo: isLight ? '#e2e8f0' : '#111827', // slate-200* / gray-900
181
+ text: isLight ? '#475569' : '#9ca3af', // slate-600 / gray-400
95
182
  };
96
183
  }
97
184
  if (session.status === 'working') {
98
185
  return {
99
186
  label: 'working',
100
- primary: isLight ? '#059669' : '#22c55e',
101
- halo: isLight ? '#d1fae5' : '#14532d',
102
- text: isLight ? '#065f46' : '#dcfce7',
187
+ primary: isLight ? '#059669' : '#22c55e', // emerald-600 / green-500
188
+ halo: isLight ? '#d1fae5' : '#14532d', // emerald-100 / green-900
189
+ text: isLight ? '#065f46' : '#dcfce7', // emerald-800 / green-100
103
190
  };
104
191
  }
105
192
  if (session.status === 'idle') {
106
193
  return {
107
194
  label: 'idle',
108
- primary: isLight ? '#0d9488' : '#2dd4bf',
109
- halo: isLight ? '#ccfbf1' : '#134e4a',
110
- text: isLight ? '#115e59' : '#ccfbf1',
195
+ primary: isLight ? '#0d9488' : '#2dd4bf', // teal-600 / teal-400
196
+ halo: isLight ? '#ccfbf1' : '#134e4a', // teal-100 / teal-900
197
+ text: isLight ? '#115e59' : '#ccfbf1', // teal-800 / teal-100
111
198
  };
112
199
  }
113
200
  return {
114
201
  label: session.status || 'online',
115
- primary: isLight ? '#0284c7' : '#38bdf8',
116
- halo: isLight ? '#dbeafe' : '#0c4a6e',
117
- text: isLight ? '#0c4a6e' : '#e0f2fe',
202
+ primary: isLight ? '#0284c7' : '#38bdf8', // sky-600 / sky-400
203
+ halo: isLight ? '#e0f2fe' : '#0c4a6e', // sky-100 / sky-900 (was blue-100 — drift fixed Round 12)
204
+ text: isLight ? '#0c4a6e' : '#e0f2fe', // sky-900 / sky-100
118
205
  };
119
206
  }
120
207
 
@@ -312,12 +399,20 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
312
399
  const key = `${message.from_alias}->${message.to_alias}`;
313
400
  const current = links.get(key);
314
401
 
402
+ // Keep the most-recent timestamp per pair so the render can fade
403
+ // dormant edges (Round 10 freshness fade).
404
+ const incoming = message.created_at || '';
405
+ const last_at = !current
406
+ ? incoming
407
+ : (incoming > current.last_at ? incoming : current.last_at);
408
+
315
409
  links.set(key, {
316
410
  key,
317
411
  from: message.from_alias,
318
412
  to: message.to_alias,
319
413
  count: (current?.count || 0) + 1,
320
414
  content: current?.content || message.content,
415
+ last_at,
321
416
  });
322
417
  });
323
418
 
@@ -327,9 +422,16 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
327
422
  export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
328
423
  const theme = useTheme();
329
424
  const isLight = theme === 'light';
425
+ const reducedMotion = useReducedMotion();
330
426
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
331
427
  const brand = useBrand();
332
428
  const isIntern = brand === 'intern';
429
+ // R133: Next.js client-router for the recent-signal panel "+N more"
430
+ // navigation. TopoGraph hasn't needed routing before — every other
431
+ // affordance composes back into the canvas's own state — but the
432
+ // truncated-flow footer is the one place where the user logically
433
+ // wants to leave: "show me the rest of the flows" → /messages.
434
+ const router = useRouter();
333
435
  const [messages, setMessages] = useState<MessageFlow[]>([]);
334
436
  // Issue #87: ring | grid layout toggle. Ring is the tiered-radial default;
335
437
  // grid arranges nodes in an N×M grid (better for 30+ nodes). Persisted to
@@ -388,6 +490,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
388
490
  activeAliases,
389
491
  groupKeys,
390
492
  groupBoxes,
493
+ gridContentBottom,
391
494
  } = useMemo(() => {
392
495
  const sseCount = (s: { alias: string; network_id?: string }) =>
393
496
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -397,17 +500,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
397
500
  // index, so contiguous-in-array becomes contiguous-in-ring, i.e.
398
501
  // each team visually clusters. localeCompare keeps CJK ordering sane.
399
502
  const byAlias = (a: Session, b: Session) => a.alias.localeCompare(b.alias);
400
- // #112 umbrella: ghost age-out — an offline node not seen for over a day
401
- // is almost certainly a deleted node the server `/api/status` still
503
+ // #112 umbrella: ghost age-out — an offline node not seen recently is
504
+ // almost certainly a deleted node the server `/api/status` still
402
505
  // returns (#74 root cause is server-side; this is the dashboard-side
403
506
  // fallback so stale ghosts stop cluttering the topology). A missing
404
507
  // last_seen_at is kept (conservative — could be a legitimately new node).
405
- const GHOST_MS = 24 * 60 * 60 * 1000;
508
+ // Round 27 / P0: the 24h threshold was too lenient — Vincent's preview.29
509
+ // screenshot showed B站马 nodes deleted ~4 h earlier still visible. A
510
+ // healthy agent heartbeats every few seconds; if it's been silent for
511
+ // an hour it's effectively gone. 1 h gives a fresh disconnect time to
512
+ // come back while removing dead nodes well within an operator session.
513
+ const GHOST_MS = 60 * 60 * 1000;
406
514
  const now = Date.now();
407
515
  const isGhost = (s: Session) => {
408
516
  if (s.status !== 'offline' || sseCount(s) || !s.last_seen_at) return false;
409
- const t = Date.parse(s.last_seen_at);
410
- return !Number.isNaN(t) && now - t > GHOST_MS;
517
+ // Round 35 / Loop: parseHubTime normalises SQL-style timestamps to UTC
518
+ // before parsing so non-UTC browsers don't see a phantom 8-hour skew
519
+ // and ghost freshly-disconnected nodes.
520
+ const t = parseHubTime(s.last_seen_at);
521
+ return t !== null && now - t > GHOST_MS;
411
522
  };
412
523
  const online = sessions.filter(s => s.status !== 'offline' || sseCount(s)).sort(byAlias);
413
524
  const offline = sessions.filter(s => s.status === 'offline' && !sseCount(s) && !isGhost(s)).sort(byAlias);
@@ -470,11 +581,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
470
581
  // padding always fit and stacked group boxes never touch; past the
471
582
  // floor the grid overflows and zoom/pan handles it.
472
583
  const GROUP_TOP = nodeR + 20;
473
- // Row-height floor = the tightest of: a node + its label below it
474
- // (2·nodeR + ~22), and the label band + min box padding (GROUP_TOP + 12).
475
- // Below this, rows would overlap; past it the grid overflows and
476
- // zoom/pan covers the rest.
477
- const cellH = Math.max(2 * nodeR + 22, GROUP_TOP + 12, Math.min(100, (gy1 - gy0) / totalRows));
584
+ // Round 27 / P0 (Vincent screenshot preview.29): dense plain-text
585
+ // node labels (`pos.y + radius + denseDrop`, with a 3 px containerBg
586
+ // stroke halo for readability) at the BOTTOM row of band N would
587
+ // paint OVER the start of band N+1's group label, creating the
588
+ // "blueleap → eleap", "agent-network-dashboard → t-network / board"
589
+ // visual chopping. Geometry of the collision:
590
+ // dense label visual bottom = node_N.y + radius + denseDrop + halo
591
+ // group label glyph top = boxY + 4 (text y=boxY+14, ~10px ascent)
592
+ // = node_N+1.y - GROUP_TOP + 4
593
+ // node_N+1.y - node_N.y = cellH for consecutive rows, so no-collide:
594
+ // cellH ≥ radius + denseDrop + halo + GROUP_TOP - 4 + buffer
595
+ // With halo=3 buffer=4: cellH ≥ nodeR + denseDrop + GROUP_TOP + 3.
596
+ const denseDrop = nodeScale < 0.8 ? 12 : 14;
597
+ const cellH = Math.max(
598
+ 2 * nodeR + 22, // node + dense label within band
599
+ GROUP_TOP + 12, // group-label band + box padding
600
+ nodeR + denseDrop + GROUP_TOP + 3, // round-27 dense↔group label clearance
601
+ Math.min(100, (gy1 - gy0) / totalRows),
602
+ );
478
603
 
479
604
  // Pass 2 — place each band's members.
480
605
  for (const band of bands) {
@@ -505,14 +630,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
505
630
  const xs = pts.map(p => p.x);
506
631
  const ys = pts.map(p => p.y);
507
632
  const minX = Math.min(...xs), minY = Math.min(...ys);
633
+ // Round 58 / Loop: per-group status mix for the label pip strip.
634
+ // Working = status==='working'. Idle = online but not working.
635
+ // Offline = !isOnline (either status==='offline' AND no SSE, or
636
+ // ghost-purged elsewhere — but ghosts never reach groupBoxes
637
+ // since they're filtered out upstream). Counts feed the label
638
+ // tspans directly so the strip stays inside the label's bbox,
639
+ // preserving the node↔label overlap-test guarantee from R19.
640
+ let w = 0, i = 0, o = 0;
641
+ for (const s of band.members) {
642
+ const isOn = s.status !== 'offline' || !!sseCount(s);
643
+ if (s.status === 'working') w++;
644
+ else if (isOn) i++;
645
+ else o++;
646
+ }
508
647
  return {
509
648
  key: band.members.length ? groupKeys[band.members[0].alias] : '',
649
+ count: band.members.length,
650
+ statuses: { working: w, idle: i, offline: o },
510
651
  x: minX - GROUP_PAD,
511
652
  y: minY - GROUP_TOP,
512
653
  w: Math.max(...xs) - minX + GROUP_PAD * 2,
513
654
  h: Math.max(...ys) - minY + GROUP_TOP + GROUP_PAD,
514
655
  };
515
656
  });
657
+ // Round 28 / Loop: surface the grid's natural content bottom so the
658
+ // mount effect can auto-fit zoom when the layout would overflow the
659
+ // viewBox. = bottom of the last node row + its label drop + a small
660
+ // breathing buffer. Round 27's cellH bump makes this overflow more
661
+ // common (30-node fleets reach ~774 px, viewBox is 680).
662
+ const gridContentBottom = gy0 + totalRows * cellH + 8;
516
663
  return {
517
664
  onlineNodes: online,
518
665
  offlineNodes: offline,
@@ -521,6 +668,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
521
668
  activeAliases: active,
522
669
  groupKeys,
523
670
  groupBoxes,
671
+ gridContentBottom,
524
672
  };
525
673
  }
526
674
 
@@ -602,7 +750,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
602
750
  groupKeys,
603
751
  // #111: group boxes are a grid-layout feature only — radially scattered
604
752
  // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
605
- groupBoxes: [] as { key: string; x: number; y: number; w: number; h: number }[],
753
+ groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
754
+ // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
755
+ gridContentBottom: 0,
606
756
  };
607
757
  }, [messages, sessions, sseSessions, layout, nodeScale]);
608
758
 
@@ -626,10 +776,217 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
626
776
  // label cards. 16 ≈ where the triple-tier rings start to crowd.
627
777
  const denseLayout = onlineNodes.length + offlineNodes.length > 16;
628
778
  const [hoveredAlias, setHoveredAlias] = useState<string | null>(null);
779
+ // Round 86 / Loop: pointer-over-label preview. R63 wired the group
780
+ // label to click-pin but hover gave no preview — the same hover/click
781
+ // gap R83 closed for pressure-bar segments. This state lets the
782
+ // label trigger the same dim mechanism a node hover already drives,
783
+ // independently of whether the cursor happens to be over a member
784
+ // node. ORs into hoveredGroup below so the existing derivation logic
785
+ // (which composes with hoveredAlias, activeGroup, R85 marching ants)
786
+ // keeps working unchanged.
787
+ const [hoveredGroupLabel, setHoveredGroupLabel] = useState<string | null>(null);
629
788
  // Round 8 / Loop: which group is currently focused. Nodes outside this
630
789
  // group + other group boxes fade so the eye locks onto the team you're
631
790
  // pointing at. Singletons use their own alias as the group key.
632
- const hoveredGroup = hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null;
791
+ const hoveredGroup = hoveredGroupLabel
792
+ ?? (hoveredAlias ? (groupKeys[hoveredAlias] ?? hoveredAlias) : null);
793
+ // Round 63 / Loop: sticky group focus. R8 dims non-group nodes while
794
+ // a member is hovered, but releasing the hover lets the focus fade.
795
+ // Clicking the group label pins the group key here; activeGroup =
796
+ // hoveredGroup ?? pinnedGroup so hover transiently overrides the
797
+ // pin (handy for spot-comparing teams). Same compose pattern as
798
+ // R60/R61 pinnedStatus.
799
+ const [pinnedGroup, setPinnedGroup] = useState<string | null>(() => {
800
+ // R66: same per-tab persistence treatment as pinnedStatus above.
801
+ // The group key is opaque text (could be any prefix), so the init
802
+ // reader can't validate it against a known enum — it just reads
803
+ // whatever's stored. A useEffect below clears it if the value no
804
+ // longer matches any current group (sessions changed since save).
805
+ if (typeof window === 'undefined') return null;
806
+ try { return sessionStorage.getItem('anet-topo-pinned-group'); } catch { return null; }
807
+ });
808
+ const activeGroup = hoveredGroup ?? pinnedGroup;
809
+ // R66: sync pinnedGroup into sessionStorage when it changes; clear
810
+ // it if it no longer matches any current group (the session set
811
+ // can change between loads). The pinnedStatus sync sits next to
812
+ // its declaration further down.
813
+ useEffect(() => {
814
+ try {
815
+ if (pinnedGroup) sessionStorage.setItem('anet-topo-pinned-group', pinnedGroup);
816
+ else sessionStorage.removeItem('anet-topo-pinned-group');
817
+ } catch {}
818
+ }, [pinnedGroup]);
819
+ useEffect(() => {
820
+ if (!pinnedGroup) return;
821
+ const known = new Set(Object.values(groupKeys));
822
+ if (!known.has(pinnedGroup)) setPinnedGroup(null);
823
+ }, [pinnedGroup, groupKeys]);
824
+ // Round 49 / Loop: reverse-direction of R40's edge-on-node-hover linkage.
825
+ // R48 widened the flow hitbox to 16 px, so edges are precise enough to
826
+ // serve as a state trigger. When the user hovers a flow edge, light up its
827
+ // two endpoint nodes and dim the rest — "who is this edge between" becomes
828
+ // visible without reading the tooltip. The set is the link's two aliases
829
+ // (null when no edge hovered); node opacity composes this after inFocus.
830
+ const [hoveredEdgeKey, setHoveredEdgeKey] = useState<string | null>(null);
831
+ // Round 116 / Loop: sticky variant of hoveredEdgeKey. R56 made the
832
+ // recent-signal rows brighten the matching edge on hover, but the
833
+ // filter released on mouseleave — no way to lock a flow for a
834
+ // closer look. Click-to-pin closes that gap, matching the
835
+ // established hover→pin idiom (R60 status, R61 legend, R63 group,
836
+ // R88 vendor). activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey
837
+ // below so hover still wins for spot-comparison while a pin is set.
838
+ const [pinnedEdgeKey, setPinnedEdgeKey] = useState<string | null>(null);
839
+ const activeEdgeKey = hoveredEdgeKey ?? pinnedEdgeKey;
840
+ // Round 77 / Loop: hovering the "N active links" header chip globally
841
+ // brightens every flow edge (1.5× opacity). Transient affordance — the
842
+ // chip already says HOW MANY active flows exist; this answers WHERE
843
+ // they are in one glance, without the user scanning the canvas for
844
+ // moving particles. Reset on mouse leave.
845
+ const [hoveredActiveLinks, setHoveredActiveLinks] = useState(false);
846
+ // Round 115 / Loop: hub-center hover state. R52 made the hub
847
+ // clickable (fit view) + cursor:pointer, but there was no
848
+ // hover-time visual feedback — the click target felt guessed at
849
+ // rather than confirmed. This state drives a subtle hint ring on
850
+ // hover so users see the affordance before committing the click.
851
+ const [hoveredHub, setHoveredHub] = useState(false);
852
+ // R133: hover state for the recent-signal panel's "+N more flows"
853
+ // navigation footer. Drives the on-hover opacity boost + underline
854
+ // that signals interactivity, mirroring the hoveredHub idiom above.
855
+ const [hoveredRecentMore, setHoveredRecentMore] = useState(false);
856
+ // R135: panel-wide hover-elevation. The recent-signal + legend
857
+ // panels both already host clickable rows (R56/R116 recent rows,
858
+ // R55/R61 legend rows) and a clickable footer (R133), so the
859
+ // chrome itself is interactive territory. Drop-shadow boost on
860
+ // mouseenter says "this whole panel is alive" — matches the R18
861
+ // KPI-card-hover idiom from the Overview page. Single state for
862
+ // both panels since they don't overlap; null when neither hovered.
863
+ const [hoveredPanel, setHoveredPanel] = useState<'recent' | 'legend' | null>(null);
864
+ // Round 80 / Loop: vendor-letter hover in the distribution chip. The
865
+ // chip already names vendor mix (`C:5 G:3 ?:1`); hover a letter and
866
+ // every node from OTHER vendors dims. Surfaces the breakdown spatially
867
+ // without inventing a new pin slot. Stores the vendor `initial`
868
+ // (single char or "?") that matches the tally key.
869
+ const [hoveredVendor, setHoveredVendor] = useState<string | null>(null);
870
+ // Round 88 / Loop: vendor filter pin. R80 added hover-to-dim on the
871
+ // vendor letters but the filter released as soon as the cursor left
872
+ // — vendor was the only filter dimension without a sticky variant
873
+ // (R60 status, R63 group, R69 Cmd+K all support pin). This state
874
+ // closes the gap. Same pattern as pinnedStatus / pinnedGroup:
875
+ // per-tab sessionStorage persistence, Esc clears, activeVendor =
876
+ // hoveredVendor ?? pinnedVendor so hover still wins for spot-
877
+ // comparison while a pin is active.
878
+ const [pinnedVendor, setPinnedVendor] = useState<string | null>(() => {
879
+ if (typeof window === 'undefined') return null;
880
+ try { return sessionStorage.getItem('anet-topo-pinned-vendor'); } catch { return null; }
881
+ });
882
+ const activeVendor = hoveredVendor ?? pinnedVendor;
883
+ useEffect(() => {
884
+ try {
885
+ if (pinnedVendor) sessionStorage.setItem('anet-topo-pinned-vendor', pinnedVendor);
886
+ else sessionStorage.removeItem('anet-topo-pinned-vendor');
887
+ } catch {}
888
+ }, [pinnedVendor]);
889
+ // R89: stale-purge. If the fleet's vendor distribution changes such
890
+ // that a previously-pinned vendor is gone (last node of that
891
+ // vendor disconnected), clear the pin so the chip row doesn't
892
+ // show "filter: A · 0" forever. Matches the same defensive purge
893
+ // pattern pinnedGroup uses higher up — sessionStorage survives
894
+ // reloads, but a stored value that no longer matches reality
895
+ // would paint with an impossible filter.
896
+ useEffect(() => {
897
+ if (pinnedVendor && !vendorDist.some(v => v.initial === pinnedVendor)) {
898
+ setPinnedVendor(null);
899
+ }
900
+ }, [pinnedVendor, vendorDist]);
901
+ // Round 55 / Loop: hovering a legend status row dims nodes whose status
902
+ // doesn't match. The legend was passive — "what does this colour mean".
903
+ // Now it answers "show me all of these" the same way R8 group-focus
904
+ // answers "show me this team". Three values match the legend rows.
905
+ const [hoveredStatus, setHoveredStatus] = useState<'working' | 'idle' | 'offline' | null>(null);
906
+ // Round 60 / Loop: sticky variant of `hoveredStatus`. R55 only filters
907
+ // while the user is actively hovering the legend; for sweeping a fleet
908
+ // you want to LOCK the filter. Each segment of the R31 pressure bar
909
+ // toggles a pin — click again on the same segment to release. The node
910
+ // opacity formula reads `activeStatus = hoveredStatus ?? pinnedStatus`
911
+ // so hover transiently overrides a pin (handy for spot-comparison)
912
+ // without nuking it.
913
+ // Round 66 / Loop: pin survives a page reload via sessionStorage —
914
+ // per-tab not per-browser (a new tab starts clean, intentionally).
915
+ // The init reader validates against the known status set so a stale
916
+ // / corrupt value can't paint the canvas with an impossible filter.
917
+ const [pinnedStatus, setPinnedStatus] = useState<'working' | 'idle' | 'offline' | null>(() => {
918
+ if (typeof window === 'undefined') return null;
919
+ try {
920
+ const v = sessionStorage.getItem('anet-topo-pinned-status');
921
+ return (v === 'working' || v === 'idle' || v === 'offline') ? v : null;
922
+ } catch { return null; }
923
+ });
924
+ const activeStatus = hoveredStatus ?? pinnedStatus;
925
+ // R66: sync pinnedStatus into sessionStorage when it changes. Paired
926
+ // with the matching effect for pinnedGroup higher up.
927
+ useEffect(() => {
928
+ try {
929
+ if (pinnedStatus) sessionStorage.setItem('anet-topo-pinned-status', pinnedStatus);
930
+ else sessionStorage.removeItem('anet-topo-pinned-status');
931
+ } catch {}
932
+ }, [pinnedStatus]);
933
+ // R69: listen for Cmd+K palette pin actions. The palette can't reach
934
+ // this component's state directly so it dispatches a CustomEvent;
935
+ // we react by setting pinnedStatus / pinnedGroup. The palette also
936
+ // writes to sessionStorage in lockstep, so the R66 init readers
937
+ // would pick it up on reload — the event handler is what keeps the
938
+ // currently-mounted canvas in sync without one.
939
+ useEffect(() => {
940
+ const onPin = (e: Event) => {
941
+ const detail = (e as CustomEvent).detail || {};
942
+ if (detail.kind === 'clear') {
943
+ // R90: extend the universal clear to vendor. R69 only cleared
944
+ // status + group because pinnedVendor didn't exist yet; R88
945
+ // shipped the third pin and R90 closes the palette gap so
946
+ // "Clear topology filters" really means all of them.
947
+ // R117: extend again to pinnedEdgeKey — R116 added the 4th
948
+ // pin dim and this command must clear it too or the edge stays
949
+ // bright in the canvas while every other pill clears.
950
+ setPinnedStatus(null);
951
+ setPinnedGroup(null);
952
+ setPinnedVendor(null);
953
+ setPinnedEdgeKey(null);
954
+ } else if (detail.kind === 'clear-vendor') {
955
+ // R90: granular vendor-only clear so power users can release
956
+ // just the vendor without disturbing status/group pins.
957
+ setPinnedVendor(null);
958
+ } else if (detail.kind === 'clear-edge') {
959
+ // R117: granular edge-only clear, mirror of R90 clear-vendor.
960
+ setPinnedEdgeKey(null);
961
+ } else if (detail.kind === 'status') {
962
+ const v = detail.value;
963
+ if (v === 'working' || v === 'idle' || v === 'offline') setPinnedStatus(v);
964
+ } else if (detail.kind === 'group' && typeof detail.value === 'string') {
965
+ setPinnedGroup(detail.value);
966
+ } else if (detail.kind === 'vendor' && typeof detail.value === 'string') {
967
+ // R108: palette pin-vendor support. R69 added pin-status, R88
968
+ // added clickable vendor letters in the chip row, R90 added
969
+ // granular clear-vendor — but the palette never gained a
970
+ // PIN-vendor command. This branch listens for it; the matching
971
+ // commands live in CommandPalette R108. The stale-purge
972
+ // useEffect higher up already drops a pin whose vendor isn't
973
+ // in the current distribution, so commands for an absent
974
+ // vendor are harmless.
975
+ setPinnedVendor(detail.value);
976
+ }
977
+ };
978
+ window.addEventListener('anet:topo-pin', onPin);
979
+ return () => window.removeEventListener('anet:topo-pin', onPin);
980
+ }, []);
981
+ // R74 listener for layout + view palette commands lives below
982
+ // fitView's declaration — see further down in the file.
983
+ const hoveredEdgeEndpoints = useMemo<Set<string> | null>(() => {
984
+ // R116: compose hover ?? pin so pinning a row via click keeps the
985
+ // endpoint ring + edge ladder lit after mouseleave.
986
+ if (!activeEdgeKey) return null;
987
+ const link = flowLinks.find(l => l.key === activeEdgeKey);
988
+ return link ? new Set([link.from, link.to]) : null;
989
+ }, [activeEdgeKey, flowLinks]);
633
990
 
634
991
  // --- Round 103 (issue #81): fullscreen + zoom + pan interaction layer ---
635
992
  // DIY native (no d3 / svg-pan-zoom): wrap the topology content in a single
@@ -649,6 +1006,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
649
1006
  // Issue #100: singleton chat popover. One alias at a time — clicking
650
1007
  // another node swaps the target and the conversation switches in place.
651
1008
  const [chatAlias, setChatAlias] = useState<string | null>(null);
1009
+ // Round 14 / Loop: one-shot click ripple — fades outward from the clicked
1010
+ // node so the click registers physically before the popover snaps in.
1011
+ // Pairs with the Round 11 chat-focus ring: ripple expands, ring locks on.
1012
+ // Keyed by ts so re-clicking the same node remounts the <circle> and the
1013
+ // SMIL <animate> replays. Cleared 600ms after click (longer than the
1014
+ // 500ms animation so a final frame at opacity 0 is still in the tree).
1015
+ const [clickRipple, setClickRipple] = useState<{
1016
+ ts: number; x: number; y: number; r0: number; color: string;
1017
+ } | null>(null);
652
1018
  // #84: when a node is renamed while its chat popover is open, follow the
653
1019
  // rename so the conversation keeps targeting a live alias.
654
1020
  useEffect(() => {
@@ -679,6 +1045,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
679
1045
  } catch {}
680
1046
  }, []);
681
1047
 
1048
+ // Round 28 / Loop: first-paint auto-fit. Round 27's cellH bump means
1049
+ // dense grids (≥6-7 rows) overflow the 680-px viewBox at the natural
1050
+ // 100% zoom — a new user sees the topology with its bottom rows
1051
+ // clipped and no hint that there's more below. Auto-fit on first
1052
+ // paint (and only if the user has no persisted view) sets the
1053
+ // initial zoom so all content fits. Subsequent zoom changes persist
1054
+ // and override the auto-fit on reload; explicit reset (0 key) still
1055
+ // goes back to 100% so the gesture's "reset to natural size" semantic
1056
+ // is preserved.
1057
+ //
1058
+ // Capture pre-mount persistence in a useState initializer — the
1059
+ // existing `persist` effect (declared below) runs on first render
1060
+ // with the default {1,0,0} view and writes it to localStorage before
1061
+ // the auto-fit effect (deps on async-arriving sessions) gets a turn.
1062
+ // Reading localStorage from the effect would see that write and
1063
+ // skip the fit. The useState snapshot fires once, before any effects.
1064
+ const [hadPersistedViewOnMount] = useState<boolean>(
1065
+ () => typeof window !== 'undefined' && !!localStorage.getItem('anet-topo-view'),
1066
+ );
1067
+ const autoFitDoneRef = useRef(false);
1068
+ useEffect(() => {
1069
+ if (autoFitDoneRef.current) return;
1070
+ if (hadPersistedViewOnMount) {
1071
+ autoFitDoneRef.current = true;
1072
+ return;
1073
+ }
1074
+ if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
1075
+ if (gridContentBottom <= VIEWBOX_H) {
1076
+ autoFitDoneRef.current = true; // no overflow → no fit needed
1077
+ return;
1078
+ }
1079
+ const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1080
+ setView({ zoom: fitZoom, x: 0, y: 0 });
1081
+ autoFitDoneRef.current = true;
1082
+ }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
1083
+
682
1084
  // persist view
683
1085
  useEffect(() => {
684
1086
  try { localStorage.setItem('anet-topo-view', JSON.stringify(view)); } catch {}
@@ -698,6 +1100,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
698
1100
  const svg = svgRef.current;
699
1101
  if (!svg) return;
700
1102
  const onWheel = (e: WheelEvent) => {
1103
+ // Round 23 / Loop: only zoom on Ctrl/Meta+wheel when inline; plain
1104
+ // wheel over the canvas keeps scrolling the page (the topo sits at
1105
+ // the top of /; trapping page scroll mid-page is the classic
1106
+ // "scroll-jail" anti-pattern). In fullscreen the canvas owns the
1107
+ // viewport so any wheel zooms — no page scroll to preserve. Pinch-
1108
+ // zoom on a trackpad surfaces as ctrlKey=true natively, which is
1109
+ // also what we want (zoom the canvas, not the browser).
1110
+ if (!isFullscreen && !e.ctrlKey && !e.metaKey) return;
701
1111
  e.preventDefault();
702
1112
  const rect = svg.getBoundingClientRect();
703
1113
  const mx = ((e.clientX - rect.left) / rect.width) * VIEWBOX_W;
@@ -718,8 +1128,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
718
1128
  };
719
1129
  svg.addEventListener('wheel', onWheel, { passive: false });
720
1130
  return () => svg.removeEventListener('wheel', onWheel);
721
- }, []);
1131
+ // Re-attach when the fullscreen flag toggles so the handler's
1132
+ // closure picks up the new value (capture-by-closure means we'd
1133
+ // otherwise read the stale boolean forever).
1134
+ }, [isFullscreen]);
722
1135
 
1136
+ // Round 21 / Loop: pan-aware cursor. Static `grab` gave no tactile cue
1137
+ // that the canvas was actually being dragged — "grabbing" while
1138
+ // dragRef.current.active = true tells the hand it's moving the
1139
+ // viewport. Mirroring dragRef.active into state is the only way to
1140
+ // re-render the SVG style; the ref itself doesn't trigger renders.
1141
+ const [isPanning, setIsPanning] = useState(false);
723
1142
  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
724
1143
  if (e.button !== 0) return;
725
1144
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -730,6 +1149,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
730
1149
  baseX: viewRef.current.x,
731
1150
  baseY: viewRef.current.y,
732
1151
  };
1152
+ setIsPanning(true);
733
1153
  };
734
1154
  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
735
1155
  const d = dragRef.current;
@@ -744,6 +1164,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
744
1164
  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
745
1165
  if (!dragRef.current.active) return;
746
1166
  dragRef.current.active = false;
1167
+ setIsPanning(false);
747
1168
  try {
748
1169
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
749
1170
  } catch {}
@@ -761,6 +1182,91 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
761
1182
  };
762
1183
  const resetView = () => setView({ zoom: 1, x: 0, y: 0 });
763
1184
 
1185
+ // Round 29 / Loop: `f` = fit-to-content. Shared by the Round 28
1186
+ // first-paint auto-fit effect and the keyboard handler so the math is
1187
+ // in one place. When content already fits at natural zoom, this is
1188
+ // effectively a "recenter" — `f` always lands on a known good view.
1189
+ const fitView = useCallback(() => {
1190
+ const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1191
+ ? 1
1192
+ : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1193
+ setView({ zoom, x: 0, y: 0 });
1194
+ }, [gridContentBottom]);
1195
+
1196
+ // R74: listen for layout + view palette commands. Sister to R69's
1197
+ // pin listener — palette dispatches a CustomEvent, the reducer here
1198
+ // calls toggleLayout / fitView. Sits below fitView's declaration so
1199
+ // the deps list resolves cleanly.
1200
+ useEffect(() => {
1201
+ const onLayout = (e: Event) => {
1202
+ const detail = (e as CustomEvent).detail || {};
1203
+ if (detail.kind === 'toggle') toggleLayout();
1204
+ };
1205
+ const onView = (e: Event) => {
1206
+ const detail = (e as CustomEvent).detail || {};
1207
+ if (detail.kind === 'fit') fitView();
1208
+ };
1209
+ window.addEventListener('anet:topo-layout', onLayout);
1210
+ window.addEventListener('anet:topo-view', onView);
1211
+ return () => {
1212
+ window.removeEventListener('anet:topo-layout', onLayout);
1213
+ window.removeEventListener('anet:topo-view', onView);
1214
+ };
1215
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1216
+ }, [fitView]);
1217
+
1218
+ // Round 22 / Loop: keyboard zoom — +/= zoom in, - zoom out, 0 reset.
1219
+ // Round 29 / Loop: +f to fit content.
1220
+ // Listen on window so the user doesn't need to focus the SVG first,
1221
+ // but only act when no text input has focus (otherwise typing "-" in
1222
+ // a chat would zoom the topology — surprising and bad). Modifier keys
1223
+ // pass through so Cmd/Ctrl combos still hit their owners. The button
1224
+ // titles below document these shortcuts so users discover them.
1225
+ useEffect(() => {
1226
+ const onKey = (e: KeyboardEvent) => {
1227
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1228
+ const ae = document.activeElement as HTMLElement | null;
1229
+ if (ae) {
1230
+ const tag = ae.tagName;
1231
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || ae.isContentEditable) return;
1232
+ }
1233
+ if (e.key === '+' || e.key === '=') { zoomBy(1.2); e.preventDefault(); }
1234
+ else if (e.key === '-' || e.key === '_') { zoomBy(1 / 1.2); e.preventDefault(); }
1235
+ else if (e.key === '0') { resetView(); e.preventDefault(); }
1236
+ else if (e.key === 'f' || e.key === 'F') { fitView(); e.preventDefault(); }
1237
+ // Round 32 / Loop: `l` toggles ring|grid. The vim-style `g l` route
1238
+ // (Audit Log) requires a preceding `g` within 1500ms; a bare `l`
1239
+ // outside that window is free for topology use.
1240
+ else if (e.key === 'l' || e.key === 'L') { toggleLayout(); e.preventDefault(); }
1241
+ // Round 62 / Loop: Esc clears the R60/R61 pinned status filter so
1242
+ // users have a universal-cancel keyboard out. Esc on an open chat
1243
+ // is owned by ChatPopover (which only mounts when chatAlias is
1244
+ // set), so this handler is effectively scoped to "no chat open".
1245
+ // We additionally guard on chatAlias to be explicit — if the chat
1246
+ // closed mid-cycle, the pin can still be cleared on the next Esc.
1247
+ // R63: extends to pinnedGroup too. Clears WHATEVER pin is active
1248
+ // (one Esc collapses all topology pins) so the keyboard escape
1249
+ // route stays a single key, not "Esc maybe Esc again".
1250
+ else if (e.key === 'Escape' && !chatAlias && (pinnedStatus || pinnedGroup || pinnedVendor || pinnedEdgeKey)) {
1251
+ // R88/R116: extend the universal-cancel to vendor + edge too.
1252
+ // One Esc collapses every topology pin (matches R62/R63's
1253
+ // "single key, not Esc-maybe-Esc-again" promise).
1254
+ if (pinnedStatus) setPinnedStatus(null);
1255
+ if (pinnedGroup) setPinnedGroup(null);
1256
+ if (pinnedVendor) setPinnedVendor(null);
1257
+ if (pinnedEdgeKey) setPinnedEdgeKey(null);
1258
+ e.preventDefault();
1259
+ }
1260
+ };
1261
+ window.addEventListener('keydown', onKey);
1262
+ return () => window.removeEventListener('keydown', onKey);
1263
+ // zoomBy / resetView are stable wrt setView callback; fitView changes
1264
+ // with gridContentBottom so deps list catches it. R62 adds chatAlias
1265
+ // and pinnedStatus so the Escape branch reads fresh state (re-binding
1266
+ // the listener on these state changes is sub-ms — cheaper than refs).
1267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1268
+ }, [fitView, chatAlias, pinnedStatus, pinnedGroup, pinnedVendor, pinnedEdgeKey]);
1269
+
764
1270
  const toggleFullscreen = () => {
765
1271
  const el = containerRef.current;
766
1272
  if (!el) return;
@@ -783,45 +1289,769 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
783
1289
  </div>
784
1290
  <div className="flex flex-wrap items-center gap-2 text-xs">
785
1291
  {/* Issue #87: ring | grid layout toggle — segmented control,
786
- persisted to localStorage anet-topo-layout. */}
1292
+ persisted to localStorage anet-topo-layout.
1293
+ Round 163 / Loop: bring the layout toggle into the R154
1294
+ chrome-button focus convention. Pre-R163 the buttons had
1295
+ aria-pressed + transition-colors but no focus-visible ring
1296
+ (browser default — invisible against dark canvas) and the
1297
+ inactive variant only nudged text from gray-500 → gray-400
1298
+ on hover with no bg tint, so the hover-to-click affordance
1299
+ was barely perceptible.
1300
+
1301
+ R163 closes both gaps to match R154:
1302
+ focus-visible:ring-2 focus-visible:ring-cyan-400/60
1303
+ → keyboard users see exactly which segment is focused
1304
+ hover:bg-cyan-500/5 (inactive)
1305
+ → mouse hover shows a faint cyan ghost of the active
1306
+ state, signalling 'click to switch'
1307
+ hover:bg-cyan-500/20 (active)
1308
+ → active button responds too, says 'still clickable'
1309
+ data-topo-chrome-layout for testability symmetric with the
1310
+ R154 chrome buttons (data-topo-chrome-zoom-in / -reset /
1311
+ -fullscreen etc). */}
787
1312
  <div className="inline-flex rounded-md border border-gray-500/25 overflow-hidden" role="group" aria-label="Topology layout">
788
1313
  <button
789
1314
  onClick={() => { if (layout !== 'ring') toggleLayout(); }}
790
1315
  aria-pressed={layout === 'ring'}
791
- className={`px-2.5 py-1 transition-colors ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
1316
+ title="Ring layout (l to toggle)"
1317
+ data-topo-chrome-layout="ring"
1318
+ data-topo-chrome-layout-active={layout === 'ring' ? 'true' : 'false'}
1319
+ className={`px-2.5 py-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300 hover:bg-cyan-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-cyan-500/5'}`}
792
1320
  >
793
1321
  Ring
794
1322
  </button>
795
1323
  <button
796
1324
  onClick={() => { if (layout !== 'grid') toggleLayout(); }}
797
1325
  aria-pressed={layout === 'grid'}
798
- className={`px-2.5 py-1 border-l border-gray-500/25 transition-colors ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300' : 'text-gray-500 hover:text-gray-400'}`}
1326
+ title="Grid layout (l to toggle)"
1327
+ data-topo-chrome-layout="grid"
1328
+ data-topo-chrome-layout-active={layout === 'grid' ? 'true' : 'false'}
1329
+ className={`px-2.5 py-1 border-l border-gray-500/25 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300 hover:bg-cyan-500/20' : 'text-gray-500 hover:text-gray-300 hover:bg-cyan-500/5'}`}
799
1330
  >
800
1331
  Grid
801
1332
  </button>
802
1333
  </div>
803
- <span className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20">
804
- {workingCount} working
805
- </span>
806
- <span className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
807
- {onlineNodes.length} online
808
- </span>
809
- {vendorDist.length > 1 && (
1334
+ {/* R79: working + online count chips become hover affordances —
1335
+ extends R77's chip-hover pattern to status counts. Hover the
1336
+ working chip → setHoveredStatus('working') so the R55 dim
1337
+ mechanic kicks in (same as hovering the SVG legend "working"
1338
+ row, just a different surface for the same gesture).
1339
+ The online chip uses the same setHoveredStatus('idle') path
1340
+ when there's idle but no working — falling back to working
1341
+ if any working node exists; "online" without a single bucket
1342
+ isn't part of the R55 type set, so this chip routes to the
1343
+ dominant online sub-tier instead of inventing a new state.
1344
+ Cursor only flips when there's anything to highlight. */}
1345
+ {/* R82: pin-mirror. R60 pressure-bar segments visualise the
1346
+ pinned status via an inset boxShadow; R61 legend rows via a
1347
+ concentric r=8 ring; the chip-row chips next to them did
1348
+ not — so a user who pinned via Cmd+K (R69) or the legend
1349
+ had no chip-row signal that "working" was the current
1350
+ filter. Mirror the pressure-bar treatment here so all
1351
+ status surfaces sing in unison. The online chip mirrors
1352
+ for idle pins (working ⊆ online; the routing in
1353
+ onMouseEnter already treats online as the idle fallback). */}
1354
+ {(() => {
1355
+ // R113 / Loop: extend the R97-R102/R101 alias-list tooltip
1356
+ // sweep to the remaining chip-row affordances. R79 made
1357
+ // these chips hoverable; their generic "Hover to highlight"
1358
+ // titles never said WHICH nodes match. Compute the alias
1359
+ // lists once for both chips to share.
1360
+ const workingAliases = onlineNodes.filter(s => s.status === 'working').map(s => s.alias);
1361
+ const onlineAliases = onlineNodes.map(s => s.alias);
1362
+ const truncate = (list: string[]) => {
1363
+ const head = list.slice(0, 8).join(', ');
1364
+ const tail = list.length > 8 ? ` + ${list.length - 8} more` : '';
1365
+ return head + tail;
1366
+ };
1367
+ const workingTitle = workingCount === 0
1368
+ ? undefined
1369
+ : pinnedStatus === 'working'
1370
+ ? `${truncate(workingAliases)} — pinned, Esc to clear`
1371
+ : `${truncate(workingAliases)} — hover highlights, click to pin`;
1372
+ // R140: online chip title gains a "click to open /nodes" tail
1373
+ // when interactive. R79 made the cursor pointer-shaped but
1374
+ // wired nothing; R136 + R139 closed the same lie on two
1375
+ // sibling chips by wiring real actions. The online chip
1376
+ // can't pin a single status (online = working + idle,
1377
+ // not a single pinnedStatus value), so a pin idiom would
1378
+ // be semantically wrong. /nodes is the natural full-list
1379
+ // destination — same "click chip for the full list" idiom
1380
+ // the active-links chip uses (R136 → /messages).
1381
+ const onlineTitle = onlineNodes.length === 0
1382
+ ? undefined
1383
+ : pinnedStatus === 'idle'
1384
+ ? `${truncate(onlineAliases)} — pinned, Esc to clear`
1385
+ : `${truncate(onlineAliases)} — hover highlights · click to open /nodes`;
1386
+ return (
1387
+ <>
1388
+ <span
1389
+ className="px-2.5 py-1 rounded-md bg-green-500/10 text-green-300 border border-green-500/20 anet-topo-chip-focus"
1390
+ data-working-chip
1391
+ data-working-chip-aliases={workingAliases.join(',')}
1392
+ data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
1393
+ data-working-chip-clickable={workingCount > 0 ? 'true' : 'false'}
1394
+ title={workingTitle}
1395
+ role={workingCount > 0 ? 'button' : undefined}
1396
+ tabIndex={workingCount > 0 ? 0 : undefined}
1397
+ aria-pressed={workingCount > 0 ? (pinnedStatus === 'working') : undefined}
1398
+ style={{
1399
+ cursor: workingCount > 0 ? 'pointer' : undefined,
1400
+ boxShadow: pinnedStatus === 'working' ? 'inset 0 0 0 1px #4ade80, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1401
+ }}
1402
+ onMouseEnter={() => { if (workingCount > 0) setHoveredStatus('working'); }}
1403
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' ? null : prev)}
1404
+ // R139: the title hover-text has been promising "click to
1405
+ // pin" since R79 but no onClick was ever wired. The cursor:
1406
+ // pointer at line 1363 set up the same lie R136 fixed on
1407
+ // the active-links chip. Wire it now: click toggles the
1408
+ // status pin to 'working', composing with R60 (pressure-
1409
+ // bar segments) and R61 (legend rows) — three different
1410
+ // surfaces that all toggle the same pinnedStatus. boxShadow
1411
+ // pin-mirror at line 1364 already reflects the state; aria-
1412
+ // pressed now exposes it for screen readers too.
1413
+ onClick={() => {
1414
+ if (workingCount > 0) setPinnedStatus(prev => prev === 'working' ? null : 'working');
1415
+ }}
1416
+ onKeyDown={(e) => {
1417
+ if (workingCount === 0) return;
1418
+ if (e.key === 'Enter' || e.key === ' ') {
1419
+ e.preventDefault();
1420
+ setPinnedStatus(prev => prev === 'working' ? null : 'working');
1421
+ }
1422
+ }}
1423
+ >
1424
+ {workingCount} working
1425
+ </span>
1426
+ <span
1427
+ className="px-2.5 py-1 rounded-md bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 anet-topo-chip-focus"
1428
+ data-online-chip
1429
+ data-online-chip-aliases={onlineAliases.join(',')}
1430
+ data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
1431
+ data-online-chip-clickable={onlineNodes.length > 0 ? 'true' : 'false'}
1432
+ title={onlineTitle}
1433
+ role={onlineNodes.length > 0 ? 'link' : undefined}
1434
+ tabIndex={onlineNodes.length > 0 ? 0 : undefined}
1435
+ style={{
1436
+ cursor: onlineNodes.length > 0 ? 'pointer' : undefined,
1437
+ boxShadow: pinnedStatus === 'idle' ? 'inset 0 0 0 1px #67e8f9, inset 0 0 0 2px rgba(255,255,255,0.45)' : undefined,
1438
+ }}
1439
+ onMouseEnter={() => {
1440
+ // If a working filter would isolate nothing, route to idle.
1441
+ const idleCount = onlineNodes.length - workingCount;
1442
+ if (workingCount > 0) setHoveredStatus('working');
1443
+ else if (idleCount > 0) setHoveredStatus('idle');
1444
+ }}
1445
+ onMouseLeave={() => setHoveredStatus(prev => prev === 'working' || prev === 'idle' ? null : prev)}
1446
+ // R140: click → /nodes. Mirrors R136 active-links→/messages
1447
+ // idiom. The chip's hover semantics (R79 highlight all
1448
+ // online) keep their meaning — hover for canvas preview,
1449
+ // click for the full list. Pinning idle here would
1450
+ // semantically misrepresent the chip (which means
1451
+ // working+idle), so we navigate instead of pin.
1452
+ onClick={() => {
1453
+ if (onlineNodes.length > 0) router.push('/nodes');
1454
+ }}
1455
+ onKeyDown={(e) => {
1456
+ if (onlineNodes.length === 0) return;
1457
+ if (e.key === 'Enter' || e.key === ' ') {
1458
+ e.preventDefault();
1459
+ router.push('/nodes');
1460
+ }
1461
+ }}
1462
+ >
1463
+ {onlineNodes.length} online
1464
+ </span>
1465
+ </>
1466
+ );
1467
+ })()}
1468
+ {/* Round 31 / Loop: fleet-health pressure bar. The "X working /
1469
+ Y online" chips already carry the raw counts; the bar lets
1470
+ the eye get the working/idle/offline RATIO in one glance
1471
+ without mental math. Stacked 3-segment chip, ~64 px wide. */}
1472
+ {(() => {
1473
+ const w = workingCount;
1474
+ const i = onlineNodes.length - workingCount; // idle = online - working
1475
+ const o = offlineNodes.length;
1476
+ const total = w + i + o;
1477
+ if (total === 0) return null;
1478
+ // Round 60 / Loop: each segment toggles a sticky filter via
1479
+ // `pinnedStatus`. Click the working segment → all non-working
1480
+ // nodes dim; click again → release. Segments share width with
1481
+ // their proportion of `total`, so a 1-node working share is
1482
+ // ~3 px wide on a 64-px bar. We pad the click target with a
1483
+ // negative-margin overlay wrapper to give thin slices a
1484
+ // 14-px minimum hit area without disturbing the rendered
1485
+ // chip width. cursor:pointer + a one-line title hint at the
1486
+ // affordance.
1487
+ const seg = (n: number, color: string, key: 'working' | 'idle' | 'offline', label: string) => {
1488
+ if (n === 0) return null;
1489
+ const isPinned = pinnedStatus === key;
1490
+ // R102: list the aliases that match this segment's bucket
1491
+ // so the title answers WHICH n, not just HOW MANY. Closes
1492
+ // the last "info-density gap" in the chip-row surfaces
1493
+ // (R97 pills / R99 group labels / R101 vendor letters all
1494
+ // already enumerate). Truncates at 8 with "+N more".
1495
+ const matchAliases = key === 'working'
1496
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
1497
+ : key === 'idle'
1498
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
1499
+ : offlineNodes.map(s => s.alias);
1500
+ const previewList = matchAliases.slice(0, 8).join(', ');
1501
+ const suffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1502
+ const titleAction = isPinned ? 'click to release filter' : 'click to highlight';
1503
+ return (
1504
+ <span
1505
+ key={key}
1506
+ data-pressure-seg={key}
1507
+ data-pressure-seg-aliases={matchAliases.join(',')}
1508
+ role="button"
1509
+ tabIndex={0}
1510
+ aria-pressed={isPinned}
1511
+ className="anet-topo-chip-focus"
1512
+ title={`${n} ${label}\n${previewList}${suffix}\n${titleAction}`}
1513
+ style={{
1514
+ width: `${(n / total) * 100}%`,
1515
+ background: color,
1516
+ height: '100%',
1517
+ cursor: 'pointer',
1518
+ boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
1519
+ }}
1520
+ onClick={(e) => {
1521
+ e.stopPropagation();
1522
+ setPinnedStatus(prev => prev === key ? null : key);
1523
+ }}
1524
+ onKeyDown={(e) => {
1525
+ if (e.key === 'Enter' || e.key === ' ') {
1526
+ e.preventDefault();
1527
+ setPinnedStatus(prev => prev === key ? null : key);
1528
+ }
1529
+ }}
1530
+ // R83: hover preview — segments now match the R55 legend,
1531
+ // R79 working/online chips, and R80 vendor letters in
1532
+ // offering a hover-transient highlight before the click
1533
+ // commits to a pin. Users get to feel what the filter
1534
+ // does before they lock it in. Same activeStatus =
1535
+ // hoveredStatus ?? pinnedStatus formula; releasing the
1536
+ // pointer falls back to the pin if one is set, or to
1537
+ // baseline. Thin (3-px) segments still hit-test fine
1538
+ // because the chip itself is a flex row — span doesn't
1539
+ // need any extra hit padding for hover.
1540
+ onMouseEnter={() => setHoveredStatus(key)}
1541
+ onMouseLeave={() => setHoveredStatus(prev => prev === key ? null : prev)}
1542
+ />
1543
+ );
1544
+ };
1545
+ // Round 47 / Loop: hidden on mobile — at <640px the chip row
1546
+ // wraps to multiple lines and crowds the topology header;
1547
+ // pressure ratio is best read with the working+online raw
1548
+ // counts (kept visible) anyway.
1549
+ return (
1550
+ <span
1551
+ className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
1552
+ title={`${w} working · ${i} idle · ${o} offline`}
1553
+ data-fleet-pressure
1554
+ >
1555
+ <span className="text-[10px] tracking-wide">pressure</span>
1556
+ <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
1557
+ {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
1558
+ {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
1559
+ {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
1560
+ </span>
1561
+ </span>
1562
+ );
1563
+ })()}
1564
+ {/* Round 64 / Loop: active-filter pills. When pinnedStatus or
1565
+ pinnedGroup is set, show a small "filter: <key> ×" pill so
1566
+ the user can see the pin from the chip row even if they
1567
+ scrolled the canvas off-screen. × button clears the
1568
+ specific pin (Esc still clears all — both paths are
1569
+ valid). pinnedStatus and pinnedGroup pin independently so
1570
+ both pills may render simultaneously. */}
1571
+ {/* R67: pills enter via anet-fade-in so they appear softly
1572
+ instead of popping. The "filter: " prefix collapses below
1573
+ sm — at narrow viewports the chip row is precious real
1574
+ estate (R47 already hides pressure / vendor / freshness),
1575
+ so dropping the redundant label keeps the working/idle/
1576
+ alpha keys readable without overflow. */}
1577
+ {/* R71: each pill picks up a "· N" match count tail. Tells the
1578
+ user at a glance how many sessions the active filter
1579
+ matches without scanning the canvas. Counts come from the
1580
+ already-computed workingCount + onlineNodes + offlineNodes
1581
+ for status, and groupKeys for group. */}
1582
+ {/* R73: entire pill body is a click-to-clear target — matches
1583
+ the Notion / Linear tag UX (the whole chip releases). The
1584
+ × keeps its dedicated <button> + aria-label for screen
1585
+ readers; the outer span just adds an extra mouse-friendly
1586
+ hit area with a title hint. ×'s onClick stopPropagation
1587
+ so the redundant outer onClick doesn't double-fire (no
1588
+ functional difference since both clear, but cleaner
1589
+ event flow). */}
1590
+ {pinnedStatus && (() => {
1591
+ const matchCount = pinnedStatus === 'working' ? workingCount
1592
+ : pinnedStatus === 'idle' ? (onlineNodes.length - workingCount)
1593
+ : offlineNodes.length;
1594
+ // Round 97 / Loop: the pill says HOW MANY but not WHICH.
1595
+ // Hovering it now shows the matched alias list in the
1596
+ // tooltip — answers "exactly who is this filtering to"
1597
+ // without forcing the user to scan the dim mask on the
1598
+ // canvas. Truncates at 8 names so a 50-node working
1599
+ // bucket doesn't produce a 12-line tooltip; the count
1600
+ // chip already answers "how many overall".
1601
+ const matchAliases = pinnedStatus === 'working'
1602
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
1603
+ : pinnedStatus === 'idle'
1604
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
1605
+ : offlineNodes.map(s => s.alias);
1606
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1607
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1608
+ return (
1609
+ <span
1610
+ data-active-filter="status"
1611
+ data-filter-match-count={matchCount}
1612
+ data-filter-match-aliases={matchAliases.join(',')}
1613
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
1614
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
1615
+ onClick={() => setPinnedStatus(null)}
1616
+ style={{
1617
+ background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
1618
+ : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
1619
+ : (isLight ? '#94a3b814' : '#6b72801f'),
1620
+ color: pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
1621
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
1622
+ : (isLight ? '#475569' : '#9ca3af'),
1623
+ borderColor: 'currentColor',
1624
+ cursor: 'pointer',
1625
+ }}
1626
+ >
1627
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70"> · {matchCount}</span></span>
1628
+ <button
1629
+ type="button"
1630
+ aria-label={`Clear ${pinnedStatus} filter`}
1631
+ onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
1632
+ className="ml-0.5 leading-none hover:opacity-70"
1633
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1634
+ >×</button>
1635
+ </span>
1636
+ );
1637
+ })()}
1638
+ {pinnedGroup && (() => {
1639
+ const matchCount = Object.values(groupKeys).filter(k => k === pinnedGroup).length;
1640
+ // R97: list group members in the tooltip.
1641
+ const matchAliases = Object.entries(groupKeys)
1642
+ .filter(([, key]) => key === pinnedGroup)
1643
+ .map(([alias]) => alias);
1644
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1645
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1646
+ return (
1647
+ <span
1648
+ data-active-filter="group"
1649
+ data-filter-match-count={matchCount}
1650
+ data-filter-match-aliases={matchAliases.join(',')}
1651
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
1652
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
1653
+ onClick={() => setPinnedGroup(null)}
1654
+ style={{
1655
+ background: isLight ? '#67e8f914' : '#67e8f91f',
1656
+ color: pal.legendAccent,
1657
+ borderColor: 'currentColor',
1658
+ cursor: 'pointer',
1659
+ }}
1660
+ >
1661
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70"> · {matchCount}</span></span>
1662
+ <button
1663
+ type="button"
1664
+ aria-label={`Clear group filter ${pinnedGroup}`}
1665
+ onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
1666
+ className="ml-0.5 leading-none hover:opacity-70"
1667
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1668
+ >×</button>
1669
+ </span>
1670
+ );
1671
+ })()}
1672
+ {/* R89: vendor pin gets its own filter pill, matching the R64
1673
+ status + group pattern. R88 added the pin but only the
1674
+ letter itself carried the state; the chip-row had no
1675
+ "filter: A · 2 ×" affordance the other two pins have. The
1676
+ pill colour borrows the vendor's own swatch so each pin
1677
+ still reads in its native hue (A green, O cyan, 书 blue,
1678
+ ? slate). Same body-click-clears + × button pattern as
1679
+ R64. */}
1680
+ {pinnedVendor && (() => {
1681
+ const matchEntry = vendorDist.find(v => v.initial === pinnedVendor);
1682
+ const matchCount = matchEntry?.count ?? 0;
1683
+ const vendorColor = matchEntry?.color ?? pal.legendText;
1684
+ // R97: list vendor users in the tooltip. The vendorIdentity
1685
+ // resolver maps model strings to a vendor initial — match
1686
+ // any session whose initial equals the pinned letter (with
1687
+ // unknowns folded to '?').
1688
+ const matchAliases = [...onlineNodes, ...offlineNodes]
1689
+ .filter(s => {
1690
+ const v = vendorForModel(s.model);
1691
+ return (v.id === 'unknown' ? '?' : v.initial) === pinnedVendor;
1692
+ })
1693
+ .map(s => s.alias);
1694
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1695
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1696
+ return (
810
1697
  <span
811
- className="inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
812
- title="Fleet by model vendor"
1698
+ data-active-filter="vendor"
1699
+ data-filter-match-count={matchCount}
1700
+ data-filter-match-aliases={matchAliases.join(',')}
1701
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
1702
+ title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
1703
+ onClick={() => setPinnedVendor(null)}
1704
+ style={{
1705
+ background: `${vendorColor}1f`,
1706
+ color: vendorColor,
1707
+ borderColor: 'currentColor',
1708
+ cursor: 'pointer',
1709
+ }}
813
1710
  >
814
- {vendorDist.map(v => (
815
- <span key={v.initial} className="inline-flex items-baseline gap-0.5">
816
- <span style={{ color: v.color }}>{v.initial}</span>
817
- <span className="text-gray-500">:{v.count}</span>
1711
+ <span><span className="hidden sm:inline" data-filter-prefix>filter: </span>{pinnedVendor}<span className="opacity-70"> · {matchCount}</span></span>
1712
+ <button
1713
+ type="button"
1714
+ aria-label={`Clear vendor filter ${pinnedVendor}`}
1715
+ onClick={(e) => { e.stopPropagation(); setPinnedVendor(null); }}
1716
+ className="ml-0.5 leading-none hover:opacity-70"
1717
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1718
+ >×</button>
1719
+ </span>
1720
+ );
1721
+ })()}
1722
+ {/* R119: edge pin filter pill — completes the R64 / R89 pill
1723
+ pattern across all four pin dimensions. R116 added
1724
+ pinnedEdgeKey but the chip row never grew the matching
1725
+ pill, leaving the locked flow visible only on the canvas +
1726
+ recent-signal row tint. This pill shows "filter:
1727
+ alpha→beta · 3" so the locked flow appears in the same
1728
+ row as the other three pin types. Body-click + × button
1729
+ clear, same pattern as R64. */}
1730
+ {pinnedEdgeKey && (() => {
1731
+ const link = flowLinks.find(l => l.key === pinnedEdgeKey);
1732
+ if (!link) return null;
1733
+ // R150 / Loop: extend the hot-lane amber convention from the
1734
+ // canvas badge (R126) / recent-row count (R127) / panel
1735
+ // header (R129) to the R119 edge filter pill — when the
1736
+ // pinned edge has count ≥ 10 the count tspan flips to
1737
+ // amber + 700 weight, and the tooltip grows a "(hot lane
1738
+ // · ≥ 10)" marker. Closes the 4th hot-lane surface,
1739
+ // completing R150's milestone: every place that surfaces
1740
+ // an edge count now uses the same amber-when-hot vocab.
1741
+ const isHot = link.count >= 10;
1742
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
1743
+ return (
1744
+ <span
1745
+ data-active-filter="edge"
1746
+ data-filter-match-count={link.count}
1747
+ data-filter-match-aliases={`${link.from},${link.to}`}
1748
+ data-active-filter-edge-hot={isHot ? 'true' : 'false'}
1749
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
1750
+ title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
1751
+ onClick={() => setPinnedEdgeKey(null)}
1752
+ style={{
1753
+ background: isLight ? `${pal.flowEdge}14` : `${pal.flowEdge}1f`,
1754
+ color: pal.flowEdge,
1755
+ borderColor: 'currentColor',
1756
+ cursor: 'pointer',
1757
+ }}
1758
+ >
1759
+ <span>
1760
+ <span className="hidden sm:inline" data-filter-prefix>filter: </span>
1761
+ {link.from}→{link.to}
1762
+ {isHot ? (
1763
+ <span
1764
+ className="opacity-90"
1765
+ style={{ color: hotStroke, fontWeight: 700 }}
1766
+ data-active-filter-edge-count-hot
1767
+ >
1768
+ {' · '}{link.count}
1769
+ </span>
1770
+ ) : (
1771
+ <span className="opacity-70" data-active-filter-edge-count>
1772
+ {' · '}{link.count}
1773
+ </span>
1774
+ )}
1775
+ </span>
1776
+ <button
1777
+ type="button"
1778
+ aria-label={`Clear edge filter ${link.from} → ${link.to}`}
1779
+ onClick={(e) => { e.stopPropagation(); setPinnedEdgeKey(null); }}
1780
+ className="ml-0.5 leading-none hover:opacity-70"
1781
+ style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
1782
+ >×</button>
1783
+ </span>
1784
+ );
1785
+ })()}
1786
+ {/* Round 124 / Loop: pin-intersection summary chip. The four
1787
+ filter pills (R64 status, R63 group, R89 vendor, R119
1788
+ edge) each report their own dim's match count in
1789
+ isolation — that answers "what does THIS filter catch"
1790
+ but not "what survives ALL pins". Since the node-opacity
1791
+ chain AND-composes the four pin dimensions, two active
1792
+ pins routinely produce an intersection smaller than
1793
+ either individual count. With three or four pins active
1794
+ the gap widens further. This chip appears ONLY when
1795
+ ≥ 2 pin dims are active (a single pin's pill already
1796
+ tells the whole story) and shows the count of nodes
1797
+ that satisfy every active pin simultaneously. Color is
1798
+ deliberately neutral (gray) since the chip represents
1799
+ the AND of mixed-color filters — borrowing any one of
1800
+ the pill hues would mis-signal which dim dominates.
1801
+ Tooltip lists the surviving aliases with the same
1802
+ 8-truncate + "+N more" pattern as the individual pill
1803
+ tooltips (R97). No click handler — it's a pure readout;
1804
+ Esc / per-pill × are still the cancel paths. */}
1805
+ {(() => {
1806
+ const pinDimCount =
1807
+ (pinnedStatus ? 1 : 0) +
1808
+ (pinnedGroup ? 1 : 0) +
1809
+ (pinnedVendor ? 1 : 0) +
1810
+ (pinnedEdgeKey ? 1 : 0);
1811
+ if (pinDimCount < 2) return null;
1812
+ const edgeLink = pinnedEdgeKey
1813
+ ? flowLinks.find(l => l.key === pinnedEdgeKey)
1814
+ : null;
1815
+ const edgeEndpoints: Set<string> | null = edgeLink
1816
+ ? new Set([edgeLink.from, edgeLink.to])
1817
+ : null;
1818
+ const allSessions = [...onlineNodes, ...offlineNodes];
1819
+ const survivors = allSessions.filter(s => {
1820
+ const isOnline = s.status !== 'offline';
1821
+ if (pinnedStatus === 'working' && s.status !== 'working') return false;
1822
+ if (pinnedStatus === 'idle' && !(isOnline && s.status !== 'working')) return false;
1823
+ if (pinnedStatus === 'offline' && isOnline) return false;
1824
+ if (pinnedGroup) {
1825
+ const gk = groupKeys[s.alias] ?? s.alias;
1826
+ if (gk !== pinnedGroup) return false;
1827
+ }
1828
+ if (pinnedVendor) {
1829
+ const v = vendorForModel(s.model);
1830
+ const initial = v.id === 'unknown' ? '?' : v.initial;
1831
+ if (initial !== pinnedVendor) return false;
1832
+ }
1833
+ if (edgeEndpoints && !edgeEndpoints.has(s.alias)) return false;
1834
+ return true;
1835
+ });
1836
+ const matchAliases = survivors.map(s => s.alias);
1837
+ const matchPreview = matchAliases.slice(0, 8).join(', ');
1838
+ const matchSuffix = matchAliases.length > 8 ? ` + ${matchAliases.length - 8} more` : '';
1839
+ const isEmpty = matchAliases.length === 0;
1840
+ const tooltip = !isEmpty
1841
+ ? `${matchPreview}${matchSuffix} — nodes passing all ${pinDimCount} pinned filters`
1842
+ : `No nodes pass all ${pinDimCount} pinned filters — release one to widen (Esc clears all)`;
1843
+ // R125: when the intersection drops to zero, the chip flips
1844
+ // from neutral gray to a warning amber. Zero-overlap is the
1845
+ // exact case users get confused — canvas dims to 0.28
1846
+ // everywhere, no positive signal explains why. The "· 0"
1847
+ // tail in neutral gray reads as just another number,
1848
+ // indistinguishable from "· 12". Amber + a ⚠ glyph lifts
1849
+ // it to "your filters cancel out" at a glance. Color
1850
+ // choice: amber (#d97706 light, #fbbf24 dark) — same hue
1851
+ // family as warning chips elsewhere in the dashboard,
1852
+ // distinct from any pill color (status green/teal/slate,
1853
+ // group cyan, vendor varies, edge cyan, neutral gray for
1854
+ // non-empty intersection) so the empty state stands out.
1855
+ const emptyColor = isLight ? '#d97706' : '#fbbf24';
1856
+ return (
1857
+ <span
1858
+ data-pin-intersection
1859
+ data-pin-dim-count={pinDimCount}
1860
+ data-pin-intersection-count={matchAliases.length}
1861
+ data-pin-intersection-empty={isEmpty ? 'true' : 'false'}
1862
+ data-pin-intersection-aliases={matchAliases.join(',')}
1863
+ className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono text-xs border anet-fade-in anet-topo-chip-focus"
1864
+ title={tooltip}
1865
+ style={{
1866
+ background: isEmpty
1867
+ ? (isLight ? '#d97706' + '14' : '#fbbf24' + '1f')
1868
+ : (isLight ? '#94a3b814' : '#94a3b81f'),
1869
+ color: isEmpty
1870
+ ? emptyColor
1871
+ : (isLight ? '#475569' : '#9ca3af'),
1872
+ borderColor: 'currentColor',
1873
+ }}
1874
+ >
1875
+ <span>
1876
+ <span className="hidden sm:inline" data-pin-intersection-prefix>match: </span>
1877
+ {pinDimCount} pins<span className="opacity-70"> · {matchAliases.length}</span>
1878
+ {isEmpty && <span className="ml-1" aria-hidden>⚠</span>}
818
1879
  </span>
819
- ))}
1880
+ </span>
1881
+ );
1882
+ })()}
1883
+ {vendorDist.length > 1 && (
1884
+ <span
1885
+ className="hidden sm:inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 font-mono"
1886
+ title="Hover to highlight; click to pin"
1887
+ >
1888
+ {vendorDist.map(v => {
1889
+ const isPinned = pinnedVendor === v.initial;
1890
+ // R88: click toggles a sticky filter the same way R60
1891
+ // pressure-bar segments toggle pinnedStatus. Visual
1892
+ // mirror = inset boxShadow using the vendor's own
1893
+ // colour, so each pinned letter sings in its own hue
1894
+ // (Anthropic green / OpenAI cyan / 书 blue / ?).
1895
+ // R101: tooltip lists the aliases that use this vendor —
1896
+ // completes the info-density triple started by R97 pills,
1897
+ // R98 node titles, R99 group-label titles. Anywhere the
1898
+ // UI shows "A:3" should hover-explain which 3.
1899
+ const aliases = [...onlineNodes, ...offlineNodes]
1900
+ .filter(s => {
1901
+ const vid = vendorForModel(s.model);
1902
+ return (vid.id === 'unknown' ? '?' : vid.initial) === v.initial;
1903
+ })
1904
+ .map(s => s.alias);
1905
+ const preview = aliases.slice(0, 8).join(', ');
1906
+ const suffix = aliases.length > 8 ? ` + ${aliases.length - 8} more` : '';
1907
+ const tooltip = isPinned
1908
+ ? `${preview}${suffix} — click again or Esc to clear`
1909
+ : `${preview}${suffix} — click to pin`;
1910
+ return (
1911
+ <span
1912
+ key={v.initial}
1913
+ role="button"
1914
+ tabIndex={0}
1915
+ aria-pressed={isPinned}
1916
+ className="inline-flex items-baseline gap-0.5 px-1 rounded anet-topo-chip-focus"
1917
+ data-vendor-letter={v.initial}
1918
+ data-vendor-pinned={isPinned ? 'true' : 'false'}
1919
+ data-vendor-aliases={aliases.join(',')}
1920
+ title={tooltip}
1921
+ style={{
1922
+ cursor: 'pointer',
1923
+ boxShadow: isPinned
1924
+ ? `inset 0 0 0 1px ${v.color}, inset 0 0 0 2px rgba(255,255,255,0.45)`
1925
+ : undefined,
1926
+ }}
1927
+ onMouseEnter={() => setHoveredVendor(v.initial)}
1928
+ onMouseLeave={() => setHoveredVendor(prev => prev === v.initial ? null : prev)}
1929
+ onClick={() => setPinnedVendor(prev => prev === v.initial ? null : v.initial)}
1930
+ onKeyDown={(e) => {
1931
+ if (e.key === 'Enter' || e.key === ' ') {
1932
+ e.preventDefault();
1933
+ setPinnedVendor(prev => prev === v.initial ? null : v.initial);
1934
+ }
1935
+ }}
1936
+ >
1937
+ <span style={{ color: v.color }}>{v.initial}</span>
1938
+ <span className="text-gray-500">:{v.count}</span>
1939
+ </span>
1940
+ );
1941
+ })}
820
1942
  </span>
821
1943
  )}
822
- <span className="px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20">
823
- {flowLinks.length} active links
824
- </span>
1944
+ {/* Round 42 / Loop: extend active-links chip with the timestamp
1945
+ of the most-recent flow event. Tells the operator at a glance
1946
+ whether the topology is currently humming (last 30s) or has
1947
+ been quiet for a while — the visual flow particles and
1948
+ edge brightness only show that there IS traffic, not when
1949
+ the last one was. Reuses Round 38's relativeAgo. */}
1950
+ {(() => {
1951
+ const recent = flowLinks.reduce<number | null>((acc, l) => {
1952
+ if (!l.last_at) return acc;
1953
+ const t = parseHubTime(l.last_at);
1954
+ if (t === null) return acc;
1955
+ return acc === null || t > acc ? t : acc;
1956
+ }, null);
1957
+ const rel = recent !== null ? relativeAgo(new Date(recent).toISOString()) : null;
1958
+ // R114: tooltip lists the actual flows. Closes the
1959
+ // info-density sweep on the last chip-row hover surface
1960
+ // (R97-R113 covered everything else). Format:
1961
+ // "alpha→beta (3), gamma→delta (1) — hover brightens all"
1962
+ // Truncates at 6 flows with "+N more" so a busy fleet
1963
+ // doesn't paint a tall tooltip; the recent-signal panel
1964
+ // already shows the top 3 in detail.
1965
+ const flowList = flowLinks
1966
+ .slice(0, 6)
1967
+ .map(l => `${l.from}→${l.to} (${l.count})`)
1968
+ .join(', ');
1969
+ const flowSuffix = flowLinks.length > 6 ? ` + ${flowLinks.length - 6} more` : '';
1970
+ // R136: the chip already had cursor:pointer when flowLinks
1971
+ // > 0 (line 1877) — but no onClick was wired. The cursor
1972
+ // lied; users got the affordance signal with no follow-
1973
+ // through. Wire it to /messages, mirroring R133's footer-
1974
+ // nav idiom. Hover (R77) keeps its semantic "preview all
1975
+ // flows on canvas"; click is the action "open the full
1976
+ // list". Two distinct gestures, both meaningful. The
1977
+ // tooltip grows a "click to open" tail when interactive.
1978
+ // Drop the chip out of click territory entirely when
1979
+ // flowLinks is empty — no flows = no list to open.
1980
+ const isInteractive = flowLinks.length > 0;
1981
+ const tooltip = !isInteractive
1982
+ ? undefined
1983
+ : `${flowList}${flowSuffix} — hover brightens all · click to open /messages`;
1984
+ return (
1985
+ <span
1986
+ className="hidden sm:inline px-2.5 py-1 rounded-md bg-gray-500/10 text-gray-400 border border-gray-500/20 anet-topo-chip-focus"
1987
+ data-active-links-chip
1988
+ data-active-links-flow-count={flowLinks.length}
1989
+ data-active-links-clickable={isInteractive ? 'true' : 'false'}
1990
+ title={tooltip}
1991
+ role={isInteractive ? 'link' : undefined}
1992
+ tabIndex={isInteractive ? 0 : undefined}
1993
+ style={{ cursor: isInteractive ? 'pointer' : undefined }}
1994
+ onMouseEnter={() => { if (isInteractive) setHoveredActiveLinks(true); }}
1995
+ onMouseLeave={() => setHoveredActiveLinks(false)}
1996
+ onClick={() => { if (isInteractive) router.push('/messages'); }}
1997
+ onKeyDown={(e) => {
1998
+ if (!isInteractive) return;
1999
+ if (e.key === 'Enter' || e.key === ' ') {
2000
+ e.preventDefault();
2001
+ router.push('/messages');
2002
+ }
2003
+ }}
2004
+ >
2005
+ {flowLinks.length} active link{flowLinks.length === 1 ? '' : 's'}
2006
+ {rel ? (() => {
2007
+ // Round 161 / Loop: extend R160's recency-pip
2008
+ // vocabulary up one scope — from per-flow row to
2009
+ // fleet-aggregate chip. The chip already shows
2010
+ // recency in text ("last 5s ago"); the " · "
2011
+ // separator bullet was dead gray. Color the
2012
+ // bullet by freshness using the same alpha
2013
+ // ramp R160 uses on recent-signal rows:
2014
+ // ageSec ≤ 30 → 1.0 (fully fresh)
2015
+ // 30-300s → smooth decay 1.0 → 0.25
2016
+ // > 300s → 0.25 (stale floor)
2017
+ // Cyan bullet pulse when fresh + gray text tail
2018
+ // = "is the network firing right now" readable
2019
+ // at a glance, without parsing the timestamp
2020
+ // numerals. Same vocabulary the canvas uses
2021
+ // (R10 edge fade) and the recent-signal panel
2022
+ // uses (R160 row pip) — three nested scopes
2023
+ // now share one freshness ladder.
2024
+ const ageSec = recent !== null
2025
+ ? Math.max(0, (Date.now() - recent) / 1000)
2026
+ : 999;
2027
+ const alpha = ageSec <= 30
2028
+ ? 1
2029
+ : ageSec <= 300
2030
+ ? 1 - ((ageSec - 30) / 270) * 0.75
2031
+ : 0.25;
2032
+ // Cyan dark / teal light to match palette legendAccent.
2033
+ const dotColor = isLight
2034
+ ? `rgba(13, 148, 136, ${alpha.toFixed(2)})`
2035
+ : `rgba(34, 211, 238, ${alpha.toFixed(2)})`;
2036
+ return (
2037
+ <span className="text-gray-500">
2038
+ <span
2039
+ data-active-links-freshness-dot
2040
+ data-active-links-freshness-alpha={alpha.toFixed(2)}
2041
+ style={{
2042
+ color: dotColor,
2043
+ fontWeight: alpha > 0.7 ? 700 : 400,
2044
+ transition: 'color 200ms ease-out',
2045
+ }}
2046
+ >{' · '}</span>
2047
+ last {rel}
2048
+ </span>
2049
+ );
2050
+ })() : null}
2051
+ </span>
2052
+ );
2053
+ })()}
2054
+ <FreshnessChip sessions={sessions} />
825
2055
  </div>
826
2056
  </div>
827
2057
 
@@ -832,16 +2062,55 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
832
2062
  >
833
2063
  <div className={`absolute inset-x-0 top-0 h-px bg-gradient-to-r ${pal.topRailGradient}`} />
834
2064
 
2065
+ {/* Round 158 / Loop: give the canvas SVG itself an accessible
2066
+ name + role description. R151-R157 added a11y to every
2067
+ interactive surface inside the canvas (nodes, group labels,
2068
+ badges, rows, minimap, chrome buttons) — but the canvas
2069
+ container itself was a nameless 1000×680 box. A screen
2070
+ reader that hit the SVG before tab-diving into its
2071
+ children heard nothing identifying it as "the topology".
2072
+ aria-roledescription gives it a meaningful announcement;
2073
+ aria-label provides a live snapshot of the network
2074
+ (online / working / active links / offline) plus the two
2075
+ canvas-scope gestures (Tab + double-click). Default SVG
2076
+ role is graphics-document so children remain navigable —
2077
+ we deliberately don't override role with "img" which
2078
+ would flatten the SVG to a single opaque image. */}
835
2079
  <svg
836
2080
  ref={svgRef}
837
2081
  viewBox="0 0 1000 680"
838
2082
  className="w-full h-auto block"
839
2083
  preserveAspectRatio="xMidYMid meet"
2084
+ aria-roledescription="agent network topology"
2085
+ aria-label={(() => {
2086
+ const online = onlineNodes.length;
2087
+ const working = workingCount;
2088
+ const offline = offlineNodes.length;
2089
+ const flows = flowLinks.length;
2090
+ const parts: string[] = [];
2091
+ parts.push(`${online} agent${online === 1 ? '' : 's'} online`);
2092
+ if (working > 0) parts.push(`${working} working`);
2093
+ if (offline > 0) parts.push(`${offline} offline`);
2094
+ parts.push(`${flows} active link${flows === 1 ? '' : 's'}`);
2095
+ return `Agent network topology — ${parts.join(' · ')}. Tab to navigate nodes, double-click canvas to reset view.`;
2096
+ })()}
2097
+ data-topo-canvas-aria
840
2098
  onPointerDown={onPointerDown}
841
2099
  onPointerMove={onPointerMove}
842
2100
  onPointerUp={onPointerUp}
843
2101
  onPointerLeave={onPointerUp}
844
- style={{ cursor: 'grab', touchAction: 'none' }}
2102
+ // Round 41 / Loop: the reset-button title (R22) and the Help
2103
+ // overlay (R25) both advertise "double-click the canvas to
2104
+ // reset", but the handler was never actually wired. The text
2105
+ // was lying. Wire it here, guarded so dbl-clicking a node
2106
+ // (which would also trigger the SVG-level handler via event
2107
+ // bubbling) doesn't unexpectedly reset the view on the user.
2108
+ onDoubleClick={(e) => {
2109
+ const t = e.target as Element | null;
2110
+ if (t?.closest('g[data-node]')) return;
2111
+ resetView();
2112
+ }}
2113
+ style={{ cursor: isPanning ? 'grabbing' : 'grab', touchAction: 'none' }}
845
2114
  >
846
2115
  <defs>
847
2116
  <linearGradient id="topo-panel" x1="0" x2="1" y1="0" y2="1">
@@ -863,9 +2132,56 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
863
2132
  </feMerge>
864
2133
  </filter>
865
2134
  )}
866
- <marker id="topo-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
867
- <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
868
- </marker>
2135
+ {/* R142 / Loop: drop-shadow filter for group-box hover-lift.
2136
+ Mirrors R135's panel hover-elevation idiom but applied
2137
+ at the per-group canvas level. R68 already gives a
2138
+ hovered/pinned group box a solid accent stroke; R142
2139
+ adds a soft outward shadow on top so the box visually
2140
+ "rises off the canvas" when selected — same visual
2141
+ vocabulary as the panels + the Overview KPI cards
2142
+ (R18). Theme-aware flood: darker shadow on cyber so
2143
+ it reads above the dark canvas, lighter for light
2144
+ theme. Bounding box of the group box is unchanged —
2145
+ filters only affect paint area, not bbox geometry, so
2146
+ the overlap-test invariant is preserved. */}
2147
+ <filter id="topo-groupbox-lift" x="-10%" y="-10%" width="120%" height="120%">
2148
+ <feDropShadow
2149
+ dx="0" dy="3" stdDeviation="4"
2150
+ floodColor={isLight ? '#0f172a' : '#000000'}
2151
+ floodOpacity={isLight ? 0.18 : 0.55}
2152
+ />
2153
+ </filter>
2154
+ {/* Round 16 / Loop: 3-tier flow-link arrow markers.
2155
+ The single marker had `markerUnits` defaulting to
2156
+ `strokeWidth`, so heavy edges (stroke=7) rendered 35-user-
2157
+ unit arrowheads — visually dominant, the head outweighed
2158
+ the line. Switching to `userSpaceOnUse` decouples arrow
2159
+ size from stroke; binning by count gives a clearer
2160
+ hierarchy than continuous linear scaling:
2161
+ s (count 1-2) → 12 user units
2162
+ m (count 3-4) → 16 user units (alias for `topo-arrow`)
2163
+ l (count 5+) → 22 user units
2164
+ `topo-arrow` stays bound to the medium tier so the legend
2165
+ swatch (line ~1500) renders without change. */}
2166
+ {[
2167
+ { id: 'topo-arrow-s', size: 12 },
2168
+ { id: 'topo-arrow', size: 16 },
2169
+ { id: 'topo-arrow-l', size: 22 },
2170
+ ].map(m => (
2171
+ <marker
2172
+ key={m.id}
2173
+ id={m.id}
2174
+ viewBox="0 0 10 10"
2175
+ refX="8"
2176
+ refY="5"
2177
+ markerWidth={m.size}
2178
+ markerHeight={m.size}
2179
+ markerUnits="userSpaceOnUse"
2180
+ orient="auto-start-reverse"
2181
+ >
2182
+ <path d="M 0 0 L 10 5 L 0 10 z" fill={pal.arrowFill} />
2183
+ </marker>
2184
+ ))}
869
2185
  {/* Round 45: radial radar sweep gradient — bright at center
870
2186
  (where it meets the hub) fading to leading edge. */}
871
2187
  <radialGradient id="topo-sweep" cx="0%" cy="50%" r="100%">
@@ -885,13 +2201,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
885
2201
  grid mode drops it so the concentric rings don't sit behind a
886
2202
  rectangular grid. */}
887
2203
  {layout === 'ring' && (<>
888
- <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" />
2204
+ {/* R52: radar bg is pure decoration — drop its pointer events so
2205
+ the hub <g> under it (and any future inner-disk affordances)
2206
+ can receive clicks. Previously the r=330 disc intercepted
2207
+ the hub click outright. */}
2208
+ <circle cx={cx} cy={cy} r="330" fill="url(#topo-radar)" style={{ pointerEvents: 'none' }} />
889
2209
 
890
2210
  {/* Round 45: subtle star field — 24 deterministic dots scattered
891
2211
  across the canvas give the radar bg some depth. Skipped on
892
2212
  light theme so the white surface stays clean. */}
893
2213
  {!isLight && (
894
- <g opacity="0.5">
2214
+ <g opacity="0.5" style={{ pointerEvents: 'none' }}>
895
2215
  {Array.from({ length: 28 }).map((_, i) => {
896
2216
  // Deterministic pseudo-random scatter so positions are
897
2217
  // stable between renders (no JS hydration mismatch).
@@ -909,50 +2229,169 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
909
2229
  scan without being noisy. Inline transform-origin on the
910
2230
  <g> wrapper ensures Chrome / Firefox rotate around (cx,cy)
911
2231
  instead of the SVG viewBox corner. */}
912
- <g
913
- style={{
914
- transformOrigin: `${cx}px ${cy}px`,
915
- transformBox: 'view-box',
916
- }}
917
- className="anet-topo-sweep"
918
- opacity={isLight ? 0.7 : 1}
919
- >
920
- <path
921
- d={`M ${cx} ${cy} L ${cx + 330} ${cy} A 330 330 0 0 0 ${cx + 330 * Math.cos(-Math.PI / 4.5)} ${cy + 330 * Math.sin(-Math.PI / 4.5)} Z`}
922
- fill="url(#topo-sweep)"
923
- />
924
- </g>
2232
+ {(() => {
2233
+ // R146: radar sweep rotation buckets on workingCount, joining
2234
+ // R84 hub breath / R131 outer orbit / R132 group march /
2235
+ // R145 idle spokes as the 5th and final layer in the busyness-
2236
+ // driven motion family. 8 / 6 / 4 / 3 seconds — same 0 / 1-2 /
2237
+ // 3-5 / 6+ thresholds R84 uses. "Busier fleet = more frequent
2238
+ // scans" feels semantically right for a radar idiom. R45
2239
+ // baseline 6s sits at bucket 1 so the calm/busy spread bracks
2240
+ // around the historical default.
2241
+ const busy = workingCount === 0 ? 0
2242
+ : workingCount <= 2 ? 1
2243
+ : workingCount <= 5 ? 2
2244
+ : 3;
2245
+ const sweepDur = [8, 6, 4, 3][busy];
2246
+ return (
2247
+ <g
2248
+ style={{
2249
+ transformOrigin: `${cx}px ${cy}px`,
2250
+ transformBox: 'view-box',
2251
+ pointerEvents: 'none',
2252
+ // CSS var consumed by `.anet-topo-sweep` (line 848 of
2253
+ // globals.css). React's CSSProperties type doesn't model
2254
+ // custom properties → cast through Record<string, string>.
2255
+ ...({ ['--sweep-dur']: `${sweepDur}s` } as Record<string, string>),
2256
+ } as React.CSSProperties}
2257
+ className="anet-topo-sweep"
2258
+ opacity={isLight ? 0.7 : 1}
2259
+ data-topo-sweep-bucket={busy}
2260
+ data-topo-sweep-dur={sweepDur}
2261
+ >
2262
+ <path
2263
+ d={`M ${cx} ${cy} L ${cx + 330} ${cy} A 330 330 0 0 0 ${cx + 330 * Math.cos(-Math.PI / 4.5)} ${cy + 330 * Math.sin(-Math.PI / 4.5)} Z`}
2264
+ fill="url(#topo-sweep)"
2265
+ />
2266
+ </g>
2267
+ );
2268
+ })()}
925
2269
 
926
- {/* radar rings */}
2270
+ {/* radar rings — pure decoration at fixed radii, independent of
2271
+ node positions so the radar aesthetic is preserved across tier
2272
+ changes. */}
927
2273
  {[90, 170, 250, 330].map(radius => (
928
2274
  <circle key={radius} cx={cx} cy={cy} r={radius} fill="none" stroke={pal.ringStroke} strokeWidth="1" opacity={isLight ? 0.6 : 0.35} />
929
2275
  ))}
930
2276
 
2277
+ {/* Round 54 / Loop: tier-radius guide rings. The radar rings above
2278
+ are decorative and don't match the actual tier radii nodes sit
2279
+ on (single 220 / dual 175,260 / triple 145,215,285). Drawing
2280
+ a faint dashed ring at each ACTIVE tier radius lets the eye
2281
+ anchor "this is the inner / outer ring" without inferring from
2282
+ node spacing. Picked based on online node count so only the
2283
+ tiers currently in use draw — empty tiers stay quiet. pointer-
2284
+ events:none so they never intercept hub or node clicks. The
2285
+ 0.7 stroke + dashed pattern reads as guide, not feature. */}
2286
+ {(() => {
2287
+ const tierRadii = onlineNodes.length > onlineTripleThreshold
2288
+ ? [onlineTripleInnerR, onlineTripleMidR, onlineTripleOuterR]
2289
+ : onlineNodes.length > onlineTierThreshold
2290
+ ? [onlineInnerRadius, onlineOuterRadius]
2291
+ : onlineNodes.length > 0
2292
+ ? [onlineRadius]
2293
+ : [];
2294
+ // Round 92 / Loop: tier-ring occupancy. R54 drew the guide
2295
+ // rings at fixed opacity regardless of how many nodes lived
2296
+ // on each tier. With pinned filters dimming most nodes to
2297
+ // 0.28, the ring at a deserted tier looked identical to a
2298
+ // crowded one — wasting a free piece of canvas. Count
2299
+ // online nodes whose hub-distance falls within ±15 px of
2300
+ // each ring (15 px = half the inter-tier gap, so each
2301
+ // node is assigned to exactly one ring). Empty tier → skip
2302
+ // entirely. Crowded tier → stronger opacity, says "look
2303
+ // here". Buckets so the ladder feels intentional, not
2304
+ // jittery as one node migrates between tiers.
2305
+ const occupancyOf = (r: number) => onlineNodes.reduce((acc, s) => {
2306
+ const p = nodePositions[s.alias];
2307
+ if (!p) return acc;
2308
+ const d = Math.hypot(p.x - cx, p.y - cy);
2309
+ return Math.abs(d - r) < 15 ? acc + 1 : acc;
2310
+ }, 0);
2311
+ // Round 93 / Loop: when any pin is active, tint the tier
2312
+ // rings to the legend accent so the spatial guide visually
2313
+ // shares the canvas's "filtered mode" colour. The chip
2314
+ // row already says WHICH filter is on (pills + letter
2315
+ // mirror); rings answering "we're filtered" reinforces
2316
+ // the state when the eye is on the canvas, not the chip
2317
+ // row. Composes cleanly with R92 occupancy — same opacity
2318
+ // bucket logic; just the stroke colour swaps.
2319
+ const anyPin = !!(pinnedStatus || pinnedGroup || pinnedVendor);
2320
+ const tierStroke = anyPin ? pal.legendAccent : pal.ringStroke;
2321
+ return tierRadii.map(r => {
2322
+ const n = occupancyOf(r);
2323
+ if (n === 0) return null;
2324
+ const bucket = n <= 2 ? 0 : n <= 6 ? 1 : 2;
2325
+ const opLight = [0.24, 0.36, 0.50][bucket];
2326
+ const opDark = [0.32, 0.46, 0.62][bucket];
2327
+ return (
2328
+ <circle
2329
+ key={`tier-${r}`}
2330
+ cx={cx} cy={cy} r={r}
2331
+ fill="none"
2332
+ stroke={tierStroke}
2333
+ strokeWidth="0.7"
2334
+ strokeDasharray="2 8"
2335
+ opacity={isLight ? opLight : opDark}
2336
+ style={{ pointerEvents: 'none', transition: 'stroke 200ms ease-out' }}
2337
+ data-tier-ring={r}
2338
+ data-tier-occupancy={n}
2339
+ data-tier-bucket={bucket}
2340
+ data-tier-tinted={anyPin ? 'true' : 'false'}
2341
+ />
2342
+ );
2343
+ });
2344
+ })()}
2345
+
931
2346
  {/* Round 50: 4 small particles slowly orbiting the outer ring
932
2347
  (r=330). Each starts at a different angle (offset 0/0.25/0.5/0.75
933
2348
  of the cycle) so they're evenly spaced. 16s per revolution is
934
2349
  slow enough to feel ambient, not noisy. Skipped on light theme
935
- so the white surface stays clean. */}
936
- {!isLight && [0, 0.25, 0.5, 0.75].map((phase, i) => (
937
- <g key={`orbit-${i}`}>
938
- <circle
939
- cx={cx + 330} cy={cy}
940
- r={i === 0 ? 2.8 : 2.2}
941
- fill="#22d3ee"
942
- opacity={0.9}
943
- filter="url(#topo-glow)"
2350
+ so the white surface stays clean.
2351
+
2352
+ R131 / Loop: orbit period now buckets on workingCount,
2353
+ mirroring R84's hub-busyness breath cadence. An idle
2354
+ fleet keeps the original 16s "calm sweep"; as work
2355
+ accumulates the orbit subtly accelerates (capped at
2356
+ 10s so it never feels frantic). Two-layer motion
2357
+ coordination now: R84 breathes the hub, R131 spins
2358
+ the outer ring — both reading the same underlying
2359
+ "is the network busy" signal, both visible
2360
+ simultaneously without competing. Same bucket
2361
+ thresholds (0 / 1-2 / 3-5 / 6+) the R84 block uses
2362
+ at line ~2702 so the two cadences stay in sync if
2363
+ a future refactor shifts buckets. */}
2364
+ {!isLight && (() => {
2365
+ const busy = workingCount === 0 ? 0
2366
+ : workingCount <= 2 ? 1
2367
+ : workingCount <= 5 ? 2
2368
+ : 3;
2369
+ const dur = [16, 14, 12, 10][busy];
2370
+ return [0, 0.25, 0.5, 0.75].map((phase, i) => (
2371
+ <g
2372
+ key={`orbit-${i}`}
2373
+ data-topo-orbit-bucket={busy}
2374
+ data-topo-orbit-dur={dur}
944
2375
  >
945
- <animateTransform
946
- attributeName="transform"
947
- type="rotate"
948
- from={`${phase * 360} ${cx} ${cy}`}
949
- to={`${phase * 360 + 360} ${cx} ${cy}`}
950
- dur="16s"
951
- repeatCount="indefinite"
952
- />
953
- </circle>
954
- </g>
955
- ))}
2376
+ <circle
2377
+ cx={cx + 330} cy={cy}
2378
+ r={i === 0 ? 2.8 : 2.2}
2379
+ fill="#22d3ee"
2380
+ opacity={0.9}
2381
+ filter="url(#topo-glow)"
2382
+ >
2383
+ <animateTransform
2384
+ attributeName="transform"
2385
+ type="rotate"
2386
+ from={`${phase * 360} ${cx} ${cy}`}
2387
+ to={`${phase * 360 + 360} ${cx} ${cy}`}
2388
+ dur={`${dur}s`}
2389
+ repeatCount="indefinite"
2390
+ />
2391
+ </circle>
2392
+ </g>
2393
+ ));
2394
+ })()}
956
2395
 
957
2396
  {[0, 30, 60, 90, 120, 150].map(angle => (
958
2397
  <line
@@ -971,65 +2410,282 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
971
2410
  {/* hub links — round 46: idle spokes now have animated
972
2411
  stroke-dashoffset so dashes flow outward from the hub
973
2412
  ("command relay" feel). Active spokes carrying live message
974
- flow stay as solid bright strokes. */}
975
- {layout === 'ring' && onlineNodes.map((session, idx) => {
976
- const pos = nodePositions[session.alias];
977
- if (!pos) return null;
978
- const path = curvePath({ x: cx, y: cy }, pos, 0);
979
- const isActiveSpoke = activeAliases.has(session.alias);
2413
+ flow stay as solid bright strokes.
2414
+ R145 / Loop: idle-spoke animation cadence buckets on
2415
+ workingCount, mirroring R84 hub-breath / R131 outer-
2416
+ ring orbit / R132 groupbox-march coordination. Same
2417
+ 0 / 1-2 / 3-5 / 6+ thresholds. Idle ↔ idle network
2418
+ has a slow "command relay" feel; as work accumulates
2419
+ the dashes accelerate outward, completing the 4th
2420
+ motion layer in the busyness-driven family:
2421
+ R84 hub breath (centre)
2422
+ R131 outer ring orbit (periphery)
2423
+ R132 groupbox march (per-team, grid layout)
2424
+ R145 idle-spoke flow (ring layout, hub→nodes)
2425
+ 4 surfaces, 1 signal. Same cadence ladder 2.8/2.4/2.0/
2426
+ 1.6s — 1.75× range from calm to busy, capped so the
2427
+ network never feels frantic. */}
2428
+ {layout === 'ring' && (() => {
2429
+ const busy = workingCount === 0 ? 0
2430
+ : workingCount <= 2 ? 1
2431
+ : workingCount <= 5 ? 2
2432
+ : 3;
2433
+ const spokeDur = [2.8, 2.4, 2.0, 1.6][busy];
2434
+ return onlineNodes.map((session, idx) => {
2435
+ const pos = nodePositions[session.alias];
2436
+ if (!pos) return null;
2437
+ const path = curvePath({ x: cx, y: cy }, pos, 0);
2438
+ const isActiveSpoke = activeAliases.has(session.alias);
980
2439
 
981
- return (
982
- <path
983
- key={`hub-${session.alias}`}
984
- d={path}
985
- fill="none"
986
- stroke={isActiveSpoke ? pal.spokeStroke.active : pal.spokeStroke.idle}
987
- strokeWidth={isActiveSpoke ? 2 : 1}
988
- strokeDasharray={isActiveSpoke ? 'none' : '6 14'}
989
- opacity={isActiveSpoke ? 0.7 : 0.45}
990
- className={isActiveSpoke ? undefined : 'anet-topo-spoke-flow'}
991
- style={isActiveSpoke ? undefined : { animationDelay: `${-(idx * 0.25)}s` }}
992
- />
993
- );
994
- })}
2440
+ return (
2441
+ <path
2442
+ key={`hub-${session.alias}`}
2443
+ d={path}
2444
+ fill="none"
2445
+ stroke={isActiveSpoke ? pal.spokeStroke.active : pal.spokeStroke.idle}
2446
+ strokeWidth={isActiveSpoke ? 2 : 1}
2447
+ strokeDasharray={isActiveSpoke ? 'none' : '6 14'}
2448
+ opacity={isActiveSpoke ? 0.7 : 0.45}
2449
+ className={isActiveSpoke ? undefined : 'anet-topo-spoke-flow'}
2450
+ data-topo-spoke-bucket={isActiveSpoke ? undefined : busy}
2451
+ data-topo-spoke-dur={isActiveSpoke ? undefined : spokeDur}
2452
+ style={isActiveSpoke ? undefined : ({
2453
+ animationDelay: `${-(idx * 0.25)}s`,
2454
+ // CSS var consumed by `.anet-topo-spoke-flow`
2455
+ // (line 859 of globals.css). React's CSSProperties
2456
+ // type doesn't model custom properties → cast
2457
+ // through Record<string, string>.
2458
+ ...({ ['--spoke-dur']: `${spokeDur}s` } as Record<string, string>),
2459
+ } as React.CSSProperties)}
2460
+ />
2461
+ );
2462
+ });
2463
+ })()}
995
2464
 
996
2465
  {/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
997
2466
  only — groupBoxes is empty in ring mode. Rendered behind the
998
2467
  flow links + nodes; pointer-events off so they never intercept
999
2468
  a node click. Restrained dashed container + group-name label. */}
1000
- {groupBoxes.map(box => (
1001
- <g
1002
- key={`grp-${box.key}`}
1003
- data-group={box.key}
1004
- className="transition-opacity"
1005
- // Round 8: dim group boxes outside the hover focus so the eye
1006
- // locks on the team being inspected.
1007
- style={{ pointerEvents: 'none', opacity: !hoveredGroup || box.key === hoveredGroup ? 1 : 0.28 }}
1008
- >
1009
- <rect
1010
- x={box.x}
1011
- y={box.y}
1012
- width={box.w}
1013
- height={box.h}
1014
- rx="14"
1015
- fill={isLight ? '#0f172a' : '#a5b4fc'}
1016
- fillOpacity={isLight ? 0.025 : 0.045}
1017
- stroke={pal.ringStroke}
1018
- strokeWidth="1.5"
1019
- strokeDasharray="6 6"
1020
- />
1021
- <text
1022
- x={box.x + 12}
1023
- y={box.y + 14}
1024
- fill={pal.legendText}
1025
- fontSize="13"
1026
- fontFamily="monospace"
1027
- fontWeight="700"
2469
+ {groupBoxes.map(box => {
2470
+ const isHovered = activeGroup === box.key;
2471
+ // R68: distinguish "locked by click" from "currently hovered".
2472
+ // R63 made pinned and hovered identical (both hit isHovered
2473
+ // via activeGroup). A user with one team pinned should see at
2474
+ // a glance which is the locked one even while their cursor
2475
+ // sweeps elsewhere. isPinned reads pinnedGroup directly
2476
+ // (NOT activeGroup) so the visual is specific to the sticky
2477
+ // state — transient hover keeps the R63 isHovered styling.
2478
+ const isPinned = pinnedGroup === box.key;
2479
+ // Round 18 / Loop: group-box hover linkage. The Round 8 fade
2480
+ // already dropped OUT-of-focus groups to 0.28, but the IN-focus
2481
+ // group sat at its baseline appearance — no positive emphasis.
2482
+ // Hovering now upgrades the box to an "accent" treatment:
2483
+ // solid stroke (not dashed), thicker, accent-coloured; brighter
2484
+ // text and slightly stronger fill. Label and box read as one
2485
+ // selected unit. Geometry unchanged → overlap test untouched.
2486
+ // R132: per-group marching-ants duration computed once,
2487
+ // reused on the data attribute + the inline custom property.
2488
+ const w = box.statuses.working;
2489
+ const marchDur = w >= 6 ? 8 : w >= 4 ? 10 : w >= 2 ? 12 : 14;
2490
+ return (
2491
+ <g
2492
+ key={`grp-${box.key}`}
2493
+ data-group={box.key}
2494
+ className="transition-opacity"
2495
+ // R63: drop the blanket pointerEvents:'none' that
2496
+ // previously sat here. Chrome's SVG impl doesn't let a
2497
+ // child override a parent's `none` even though the spec
2498
+ // says it should — moving the property onto just the
2499
+ // rect (where it's needed so nodes underneath stay
2500
+ // clickable) lets the label text receive its own click.
2501
+ style={{ opacity: !activeGroup || isHovered ? 1 : 0.28 }}
1028
2502
  >
1029
- {box.key}
1030
- </text>
1031
- </g>
1032
- ))}
2503
+ <rect
2504
+ x={box.x}
2505
+ y={box.y}
2506
+ width={box.w}
2507
+ height={box.h}
2508
+ rx="14"
2509
+ fill={isLight ? '#0f172a' : '#a5b4fc'}
2510
+ // R68: 3-tier opacity + stroke ladder.
2511
+ // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
2512
+ // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
2513
+ // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
2514
+ fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
2515
+ : isHovered ? (isLight ? 0.05 : 0.09)
2516
+ : (isLight ? 0.025 : 0.045)}
2517
+ stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
2518
+ strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
2519
+ strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
2520
+ data-group-box-pinned={isPinned ? 'true' : 'false'}
2521
+ // R85: ambient "marching ants" drift on the perimeter
2522
+ // when this group has at least one working member, and
2523
+ // neither pin nor hover is active (those treatments
2524
+ // already shout for attention via solid stroke). 12s
2525
+ // cycle reads as ambient — the eye parses "live work
2526
+ // here" without registering the box as animating.
2527
+ // R132: per-group ant rate buckets on box.statuses.working
2528
+ // — the same coupling-to-busyness idiom R84 uses for the
2529
+ // hub and R131 uses for the outer-ring orbit, applied at
2530
+ // the GROUP scale. A team with one working member ambles;
2531
+ // a team with five working members visibly accelerates.
2532
+ // Bucket boundaries (1 / 2-3 / 4-5 / 6+) chosen to land
2533
+ // on the same 14/12/10/8 cadence ladder so the three
2534
+ // motion layers (hub / ring / group) keep a coherent
2535
+ // tempo grammar. Default 12s when working=0 doesn't
2536
+ // matter — the className is only applied when working>0.
2537
+ data-group-box-live={!isPinned && !isHovered && box.statuses.working > 0 ? 'true' : 'false'}
2538
+ data-group-box-march-dur={marchDur}
2539
+ data-group-box-lifted={(isPinned || isHovered) ? 'true' : 'false'}
2540
+ className={!isPinned && !isHovered && box.statuses.working > 0 ? 'anet-topo-groupbox-live' : undefined}
2541
+ // R142: drop-shadow filter when pinned or hovered. Box
2542
+ // visually "rises off the canvas" — same vocabulary
2543
+ // R18 KPI cards + R135 overlay panels use. Idle group
2544
+ // boxes carry no filter (purely flat dashed outline)
2545
+ // so the unstyled canvas stays uncluttered. Filter
2546
+ // affects paint area only, not the geometric bbox
2547
+ // the overlap-test reads, so zero-overlap invariant
2548
+ // is preserved.
2549
+ filter={(isPinned || isHovered) ? 'url(#topo-groupbox-lift)' : undefined}
2550
+ style={{
2551
+ transition: 'stroke 200ms ease-out, stroke-width 200ms ease-out, fill-opacity 200ms ease-out, filter 200ms ease-out',
2552
+ pointerEvents: 'none',
2553
+ // CSS var consumed by `.anet-topo-groupbox-live`
2554
+ // (line 877 of globals.css). React's CSSProperties
2555
+ // type doesn't model custom properties, so cast
2556
+ // through Record<string, string>.
2557
+ ...({['--march-dur']: `${marchDur}s`} as Record<string, string>),
2558
+ }}
2559
+ />
2560
+ {/* R63: wrap label in a clickable <g> with an invisible
2561
+ rect hitbox. The text alone wasn't getting hit-tested
2562
+ reliably — the SVG-wide topo-panel <rect> intercepts
2563
+ at the label's screen position when the label sits at
2564
+ a high viewBox-y (it lands below where the compositor
2565
+ expects the text to paint on top, same gotcha as the
2566
+ recent-signal panel rows in R56). Hitbox rect + the
2567
+ parent <g> taking the click + onPointerDown stop-
2568
+ propagation match the R55/R56/R61 pattern. */}
2569
+ <g
2570
+ role="button"
2571
+ tabIndex={0}
2572
+ aria-pressed={pinnedGroup === box.key}
2573
+ data-group-label-hit={box.key}
2574
+ className="anet-topo-svg-focus"
2575
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
2576
+ onPointerDown={(e) => e.stopPropagation()}
2577
+ onClick={() => setPinnedGroup(prev => prev === box.key ? null : box.key)}
2578
+ // R86: hover the label → transient group focus. Releases
2579
+ // on leave; activeGroup = hoveredGroup ?? pinnedGroup
2580
+ // formula already handles transient-over-pin so a
2581
+ // user can spot-compare teams without losing their
2582
+ // pinned one. Closes the same R83-style hover/click
2583
+ // gap (segments) — now group labels carry it too.
2584
+ onPointerEnter={() => setHoveredGroupLabel(box.key)}
2585
+ onPointerLeave={() => setHoveredGroupLabel(prev => prev === box.key ? null : prev)}
2586
+ // R152: a11y completeness — R63 added role + tabIndex +
2587
+ // aria-pressed but never wired onKeyDown, so the focused
2588
+ // group label was tab-reachable but Enter/Space was a
2589
+ // no-op. Closes the last keyboard gap among the
2590
+ // role="button" surfaces. Other group-pin trigger paths
2591
+ // (R69 palette, R74 cmdk, R86 hover, dispatchEvent) are
2592
+ // unchanged. Matches the onKeyDown idiom from R116 /
2593
+ // R139 / R140 / R151 (Enter & Space → same setter as
2594
+ // onClick, preventDefault on Space to stop SVG scroll).
2595
+ onKeyDown={(e) => {
2596
+ if (e.key === 'Enter' || e.key === ' ') {
2597
+ e.preventDefault();
2598
+ setPinnedGroup(prev => prev === box.key ? null : box.key);
2599
+ }
2600
+ }}
2601
+ >
2602
+ {/* R99: SVG <title> tooltip listing group members +
2603
+ status breakdown. Same info-density spirit as
2604
+ R97 pill tooltips + R98 node tooltips — anywhere
2605
+ a UI element says "alpha · 3" should hover-
2606
+ explain WHICH 3. Truncates at 8 aliases with a
2607
+ "+N more" suffix so a 20-member band doesn't
2608
+ paint a 22-line tooltip. */}
2609
+ {(() => {
2610
+ const members = Object.entries(groupKeys)
2611
+ .filter(([, key]) => key === box.key)
2612
+ .map(([alias]) => alias);
2613
+ const memberPreview = members.slice(0, 8).join(', ');
2614
+ const suffix = members.length > 8 ? ` + ${members.length - 8} more` : '';
2615
+ const statusSummary = [
2616
+ box.statuses.working > 0 ? `${box.statuses.working} working` : null,
2617
+ box.statuses.idle > 0 ? `${box.statuses.idle} idle` : null,
2618
+ box.statuses.offline > 0 ? `${box.statuses.offline} offline` : null,
2619
+ ].filter(Boolean).join(' · ');
2620
+ return (
2621
+ <title>{[
2622
+ `${box.key} (${members.length} member${members.length === 1 ? '' : 's'})`,
2623
+ statusSummary || null,
2624
+ `${memberPreview}${suffix}`,
2625
+ pinnedGroup === box.key ? 'click to release pin' : 'click to pin this group',
2626
+ ].filter(Boolean).join('\n')}</title>
2627
+ );
2628
+ })()}
2629
+ <rect
2630
+ x={box.x + 6}
2631
+ y={box.y + 2}
2632
+ width={Math.min(box.w - 12, 240)}
2633
+ height={20}
2634
+ rx="4"
2635
+ /* R107 / Loop: list-item tint extends to the SVG
2636
+ group labels — same idiom R104 added to recent-
2637
+ signal rows and R105 to the legend rows. The
2638
+ tint colour is pal.legendAccent (cyan) since
2639
+ groups don't carry an inherent swatch the way
2640
+ legend rows do; this matches R68's group-box
2641
+ isPinned/isHovered accent stroke for consistency.
2642
+ hover < pinned opacity so locked vs preview is
2643
+ discriminable at a glance. */
2644
+ fill={pinnedGroup === box.key || hoveredGroupLabel === box.key ? pal.legendAccent : 'transparent'}
2645
+ opacity={pinnedGroup === box.key ? (isLight ? 0.16 : 0.20)
2646
+ : hoveredGroupLabel === box.key ? (isLight ? 0.09 : 0.13)
2647
+ : 1}
2648
+ data-group-label-tinted={pinnedGroup === box.key ? 'pinned' : hoveredGroupLabel === box.key ? 'hover' : 'none'}
2649
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
2650
+ />
2651
+ <text
2652
+ x={box.x + 12}
2653
+ y={box.y + 14}
2654
+ fill={isHovered ? pal.legendHeadline : pal.legendText}
2655
+ fontSize="13"
2656
+ fontFamily="monospace"
2657
+ fontWeight="700"
2658
+ style={{ transition: 'fill 200ms ease-out' }}
2659
+ data-group-label={box.key}
2660
+ >
2661
+ {box.key}
2662
+ {/* Round 19 / Loop: member-count chip. Inline tspan stays
2663
+ in the single <text> bbox the overlap test reads, so
2664
+ the node↔label guard still catches if the chip ever
2665
+ pushes the label far enough right to clip a node.
2666
+ Smaller + lighter weight reads as metadata, not name. */}
2667
+ <tspan dx="6" fill={pal.legendText} fontSize="11" fontWeight="400">· {box.count}</tspan>
2668
+ {/* Round 58 / Loop: status mix pip strip. Compact text-
2669
+ based chips (e.g. "2w 1i") so the strip stays inside
2670
+ the same <text> bbox the overlap-test reads — keeps
2671
+ the R27 label↔label and R19 node↔label guards intact.
2672
+ Each tier is colour-coded against the legend swatches
2673
+ and only renders when count > 0, so a healthy all-
2674
+ working group reads simply " · 2w". */}
2675
+ {box.statuses.working > 0 && (
2676
+ <tspan dx="8" fill={isLight ? '#059669' : '#22c55e'} fontSize="11" fontWeight="600">{box.statuses.working}w</tspan>
2677
+ )}
2678
+ {box.statuses.idle > 0 && (
2679
+ <tspan dx="4" fill={isLight ? '#0d9488' : '#2dd4bf'} fontSize="11" fontWeight="600">{box.statuses.idle}i</tspan>
2680
+ )}
2681
+ {box.statuses.offline > 0 && (
2682
+ <tspan dx="4" fill={isLight ? '#94a3b8' : '#6b7280'} fontSize="11" fontWeight="600">{box.statuses.offline}o</tspan>
2683
+ )}
2684
+ </text>
2685
+ </g>
2686
+ </g>
2687
+ );
2688
+ })}
1033
2689
 
1034
2690
  {/* directed message flows */}
1035
2691
  {flowLinks.map((link, index) => {
@@ -1047,17 +2703,111 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1047
2703
  const path = curvePath(from, to, lift);
1048
2704
  const width = Math.min(2 + link.count, 7);
1049
2705
  const duration = Math.max(0.9, 2.6 / Math.sqrt(link.count));
2706
+ // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
2707
+ // stays at full intensity; over 5 minutes it decays to ~35%.
2708
+ // Surfaces "what's happening now" vs background chatter without
2709
+ // hiding old flow entirely (some context still useful). `now`
2710
+ // captured at useMemo-recompute time (every 5s message refresh)
2711
+ // — accuracy is within the poll interval, plenty.
2712
+ const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
2713
+ const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
2714
+ // Round 16 arrow-tier binning — keep `topo-arrow` as the
2715
+ // medium tier id so the legend swatch picks it up unchanged.
2716
+ const arrowId = link.count <= 2 ? 'topo-arrow-s'
2717
+ : link.count <= 4 ? 'topo-arrow'
2718
+ : 'topo-arrow-l';
1050
2719
 
2720
+ // Round 39 / Loop: edge hover tooltip — surface the same
2721
+ // last_at + count info the freshness fade and arrow tier
2722
+ // already encode visually, in plain text. The stroke is the
2723
+ // hover target; SVG `<title>` honours newlines on every
2724
+ // browser the dashboard targets.
2725
+ const lastAt = relativeAgo(link.last_at);
2726
+ const tooltip = `${link.from} → ${link.to}\n${link.count} message${link.count === 1 ? '' : 's'}${lastAt ? ` · last ${lastAt}` : ''}`;
2727
+ // Round 40 / Loop: edges follow node hover — when an alias is
2728
+ // hovered, every edge touching it brightens, the rest fade.
2729
+ // Pairs with the Round 8 group-focus fade on nodes: hover a
2730
+ // node to find "who is this agent talking to" at a glance.
2731
+ // No hover → multiplier is 1.0 (current behaviour preserved).
2732
+ // Round 50 / Loop: edge-on-self priority. When the user hovers
2733
+ // a flow edge directly (R48 widened the hitbox so this is now
2734
+ // a precise gesture), THAT edge gets the strongest boost (2.0)
2735
+ // and every other edge dims to 0.35. Node-hover (R40) keeps
2736
+ // its own ladder. Edge-hover and node-hover are mutually
2737
+ // exclusive in practice — the cursor is over one or the
2738
+ // other — but the order below makes edge-hover win if both
2739
+ // ever read truthy at the same React tick.
2740
+ // Round 53 / Loop: in-group edges follow team focus. R40
2741
+ // brightened only edges touching the exact hovered alias —
2742
+ // but with R8/R18 prefix-clustering, a user hovering one
2743
+ // member is asking about the team. So when BOTH endpoints of
2744
+ // an edge share the hoveredGroup (and neither is the exact
2745
+ // hovered alias — that gets the stronger 1.7×), the edge
2746
+ // boosts to 1.3×. Edges leaving the team (one endpoint in,
2747
+ // one out) still dim to 0.35× per R40 since they read as
2748
+ // background to the focus. Singletons fall through unchanged
2749
+ // (their group key is the alias itself, so bothInHoveredGroup
2750
+ // is impossible for a non-self-edge).
2751
+ // R116: composes hover ?? pin — a pinned edge stays "hot" after the cursor leaves.
2752
+ const isHoveredEdge = activeEdgeKey === link.key;
2753
+ const fromGroup = groupKeys[link.from] ?? link.from;
2754
+ const toGroup = groupKeys[link.to] ?? link.to;
2755
+ const bothInHoveredGroup = !!activeGroup && fromGroup === activeGroup && toGroup === activeGroup;
2756
+ // R63: also gate "no filter" on activeGroup so pinnedGroup
2757
+ // alone activates the in-group 1.3× boost + non-group dim.
2758
+ // When neither a node nor a group is in focus, mul is 1.
2759
+ // R77: when the user hovers the "N active links" chip, the
2760
+ // baseline 1× becomes 1.5× — every flow brightens at once.
2761
+ // Sits inside the no-other-hover branch so it doesn't fight
2762
+ // the edge-hover (2.0×) or node-hover (1.7×) priorities.
2763
+ const edgeOpacityMul = isHoveredEdge
2764
+ ? 2.0
2765
+ : activeEdgeKey
2766
+ ? 0.35
2767
+ : !hoveredAlias && !activeGroup
2768
+ ? (hoveredActiveLinks ? 1.5 : 1)
2769
+ : (link.from === hoveredAlias || link.to === hoveredAlias)
2770
+ ? 1.7
2771
+ : bothInHoveredGroup
2772
+ ? 1.3
2773
+ : 0.35;
2774
+ // Round 50: the hovered edge also visibly thickens so the eye
2775
+ // tracks it even at low message counts (width was 3 → 4.5 on
2776
+ // hover). 1.4× is enough to read as "lifted" without
2777
+ // breaching the 16-px hitbox bound.
2778
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
1051
2779
  return (
1052
2780
  <g key={link.key}>
2781
+ {/* Round 48 / Loop: invisible hover hitbox — visible flow
2782
+ path is 3-7 px wide and damn hard to hover precisely.
2783
+ Stack a transparent 16-px-wide stroke behind it so the
2784
+ cursor only needs to be ~8 px from the line for the
2785
+ tooltip to fire. Native <title> moves here; the
2786
+ visible path no longer needs it. pointer-events on
2787
+ the visible path drop to "none" since the hitbox
2788
+ owns the hover surface. */}
2789
+ <path
2790
+ d={path}
2791
+ fill="none"
2792
+ stroke="transparent"
2793
+ strokeWidth={Math.max(width + 10, 16)}
2794
+ style={{ pointerEvents: 'stroke' }}
2795
+ data-edge-hitbox
2796
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2797
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2798
+ >
2799
+ <title>{tooltip}</title>
2800
+ </path>
1053
2801
  <path
1054
2802
  d={path}
1055
2803
  fill="none"
1056
2804
  stroke={pal.flowEdge}
1057
- strokeWidth={width}
1058
- opacity={isLight ? 0.22 : 0.28}
2805
+ strokeWidth={renderWidth}
2806
+ opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
1059
2807
  filter={isLight ? undefined : 'url(#topo-glow)'}
1060
- markerEnd="url(#topo-arrow)"
2808
+ markerEnd={`url(#${arrowId})`}
2809
+ className="transition-opacity duration-300"
2810
+ style={{ pointerEvents: 'none' }}
1061
2811
  />
1062
2812
  <path
1063
2813
  id={`flow-path-${index}`}
@@ -1066,41 +2816,403 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1066
2816
  stroke={pal.flowPath}
1067
2817
  strokeWidth="1"
1068
2818
  strokeDasharray="2 12"
1069
- opacity={isLight ? 0.4 : 0.75}
2819
+ opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
2820
+ className="transition-opacity duration-300"
1070
2821
  />
1071
- <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'}>
1072
- <animateMotion dur={`${duration}s`} repeatCount="indefinite" path={path} />
1073
- </circle>
2822
+ {!reducedMotion && (
2823
+ /* Round 103 / Loop: phase-stagger the particles so
2824
+ concurrent edges don't pulse in lockstep. SMIL
2825
+ `begin` accepts negative offsets to shift the cycle
2826
+ backwards in time, which means the particle starts
2827
+ mid-flight on first paint — no visible "all
2828
+ particles spawn from source simultaneously" tell.
2829
+ `(index * 0.37) % duration` gives a deterministic,
2830
+ well-distributed offset (the golden-ratio-ish 0.37
2831
+ fraction prevents lining up when N is a small
2832
+ multiple). Edge order is stable (sorted by recent
2833
+ activity), so the offsets feel calm rather than
2834
+ reshuffling each refresh. */
2835
+ <circle r="4" fill={pal.flowParticle} filter={isLight ? undefined : 'url(#topo-glow)'} opacity={Math.min(1, fresh * edgeOpacityMul)}>
2836
+ <animateMotion
2837
+ dur={`${duration}s`}
2838
+ begin={`-${((index * 0.37) % duration).toFixed(3)}s`}
2839
+ repeatCount="indefinite"
2840
+ path={path}
2841
+ />
2842
+ </circle>
2843
+ )}
2844
+ {/* Round 75 / Loop: arrival ping at the destination. The
2845
+ particle currently fades into the arrow marker
2846
+ silently — adding a small radiating ring synchronised
2847
+ to the particle's period turns message delivery into
2848
+ a visible event. begin = -dur*0.92 offsets the
2849
+ animation so the ring expands NEAR the end of each
2850
+ cycle (≈when the particle arrives). Gated by
2851
+ reducedMotion and on fresh > 0.5 — stale edges that
2852
+ haven't fired in minutes don't need the eye-grab.
2853
+ data-arrival-ping for testability. */}
2854
+ {!reducedMotion && fresh > 0.5 && (
2855
+ <circle
2856
+ cx={to.x}
2857
+ cy={to.y}
2858
+ r="0"
2859
+ fill="none"
2860
+ stroke={pal.flowEdge}
2861
+ strokeWidth="1.5"
2862
+ opacity="0"
2863
+ style={{ pointerEvents: 'none' }}
2864
+ data-arrival-ping={link.key}
2865
+ >
2866
+ <animate attributeName="r" values="0;14;22" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2867
+ <animate attributeName="opacity" values="0;0.55;0" dur={`${duration}s`} begin={`-${(duration * 0.92).toFixed(2)}s`} repeatCount="indefinite" />
2868
+ </circle>
2869
+ )}
2870
+ {/* Round 76 / Loop: source dispatch pulse — mirror to the
2871
+ R75 arrival ping. begin = 0s (start of cycle) so the
2872
+ ring expands as the particle LEAVES the source. Only
2873
+ fires for high-traffic edges (link.count >= 3) — on
2874
+ quiet conversations the canvas should stay calm; on
2875
+ busy senders the pulse plus arrival ping bookend
2876
+ every message in flight, making the topology feel
2877
+ alive. Same fresh/reducedMotion gates as R75. Slightly
2878
+ smaller radius (0→12→18 vs R75's 0→14→22) so the
2879
+ source reads as "smaller event than arrival" — the
2880
+ destination is the meaningful endpoint. */}
2881
+ {!reducedMotion && fresh > 0.5 && link.count >= 3 && (
2882
+ <circle
2883
+ cx={from.x}
2884
+ cy={from.y}
2885
+ r="0"
2886
+ fill="none"
2887
+ stroke={pal.flowEdge}
2888
+ strokeWidth="1.5"
2889
+ opacity="0"
2890
+ style={{ pointerEvents: 'none' }}
2891
+ data-dispatch-pulse={link.key}
2892
+ >
2893
+ <animate attributeName="r" values="0;12;18" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2894
+ <animate attributeName="opacity" values="0;0.45;0" dur={`${duration}s`} begin="0s" repeatCount="indefinite" />
2895
+ </circle>
2896
+ )}
2897
+ {/* Round 100 / Loop: midpoint count badge for high-
2898
+ traffic edges. Width already gauges intensity but
2899
+ "5 vs 12" is hard to read off stroke alone. Render
2900
+ a compact pill at the bezier midpoint (t=0.5 →
2901
+ midpoint + perpendicular × lift/2) showing the
2902
+ integer count. Threshold link.count >= 3 keeps
2903
+ the canvas quiet for sparse traffic. Bezier
2904
+ midpoint math: for a quadratic curve M A Q C B,
2905
+ the t=0.5 point is (A + 2C + B) / 4, which
2906
+ simplifies to (midAB) + 0.5 × (control - midAB)
2907
+ = midAB + perpendicular × lift/2. */}
2908
+ {link.count >= 3 && (() => {
2909
+ const midX = (from.x + to.x) / 2;
2910
+ const midY = (from.y + to.y) / 2;
2911
+ const dx = to.x - from.x;
2912
+ const dy = to.y - from.y;
2913
+ const len = Math.hypot(dx, dy) || 1;
2914
+ const badgeX = midX + (-dy / len) * lift * 0.5;
2915
+ const badgeY = midY + ( dx / len) * lift * 0.5;
2916
+ const badgeOpacity = Math.min(1, fresh * edgeOpacityMul);
2917
+ /* R121: the badge becomes a canvas-side click-to-pin
2918
+ affordance. R100 introduced it as a passive count
2919
+ display; R116 added pinnedEdgeKey. Joining them
2920
+ lets users pin a flow directly from the canvas
2921
+ without crossing to the recent-signal panel.
2922
+ pointerEvents move from 'none' to 'all' on the
2923
+ wrapper <g>; the underlying R48 edge hitbox is
2924
+ wider (16 px) than the 18-px badge diameter so
2925
+ clicks landing on either still route correctly
2926
+ — the badge consumes its small footprint, the
2927
+ hitbox owns the rest. stopPropagation on
2928
+ pointerdown so the SVG pan capture doesn't
2929
+ redirect the click. */
2930
+ const isPinned = pinnedEdgeKey === link.key;
2931
+ // R126: hot-edge accent. The edge stroke width (line
2932
+ // 2344) already scales with count but clamps at 7 —
2933
+ // so count=5 and count=50 look identical at the line,
2934
+ // and the only signal differentiating them is the
2935
+ // integer in the badge. Bucket count into a "hot"
2936
+ // band (≥ 10) and flip the badge stroke to a warmer
2937
+ // tone + thicker ring so busy lanes telegraph at a
2938
+ // glance without reading the digit. Reuses the amber
2939
+ // family from R125 (chip empty-state) — semantic is
2940
+ // "draw the eye here"; R125 amber is in the chip row
2941
+ // above the SVG, R126 amber is on the canvas, so the
2942
+ // surfaces never compete in the same eye-sweep. Pin
2943
+ // still wins (uses legendHeadline) so a pinned hot
2944
+ // edge reads as "locked + busy" via the badge text
2945
+ // and edge brightness, not via a third stroke colour.
2946
+ const isHot = link.count >= 10;
2947
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
2948
+ return (
2949
+ <g
2950
+ data-edge-count-badge={link.key}
2951
+ data-edge-count-badge-pinned={isPinned ? 'true' : 'false'}
2952
+ data-edge-count-badge-hot={isHot ? 'true' : 'false'}
2953
+ role="button"
2954
+ tabIndex={0}
2955
+ aria-pressed={isPinned}
2956
+ className="anet-topo-svg-focus"
2957
+ style={{ pointerEvents: 'all', cursor: 'pointer' }}
2958
+ opacity={badgeOpacity}
2959
+ onPointerDown={(e) => e.stopPropagation()}
2960
+ // R122: badge hover propagates to hoveredEdgeKey so
2961
+ // moving the cursor onto the badge lights the
2962
+ // same endpoint rings + edge brighten as hovering
2963
+ // the line. R121 only wired click; the badge sat
2964
+ // visually separate from the line on hover,
2965
+ // which felt like two surfaces rather than one.
2966
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
2967
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
2968
+ onClick={(e) => {
2969
+ e.stopPropagation();
2970
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
2971
+ }}
2972
+ onKeyDown={(e) => {
2973
+ if (e.key === 'Enter' || e.key === ' ') {
2974
+ e.preventDefault();
2975
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
2976
+ }
2977
+ }}
2978
+ >
2979
+ <title>{isPinned
2980
+ ? `${link.from} → ${link.to} (${link.count}) — click to release pin`
2981
+ : `${link.from} → ${link.to} (${link.count}) — click to pin`}</title>
2982
+ {/* Round 164 / Loop: edge badge gains hover-lift
2983
+ to match the 5-surface hover-elevation
2984
+ family (R51 node / R135 panel / R142 group
2985
+ box / R143 recent row / R144 legend row).
2986
+ Pre-R164 the badge had R122 hover→edge-brighten
2987
+ propagation but the badge ITSELF stayed
2988
+ static, so the cursor-on-target feedback
2989
+ felt mismatched with every other interactive
2990
+ surface. Bumping r 9 → 10.5 on hover OR pin
2991
+ gives the same "lift" gesture in canvas
2992
+ space (the badge sits on a curved path, so
2993
+ translate-Y wouldn't track the line; radius
2994
+ growth is the SVG-native equivalent). Pin
2995
+ and hover share the lift so a pinned badge
2996
+ stays visually raised even after mouseleave —
2997
+ mirrors R143/R144 where row pin gets the
2998
+ same lift as row hover. Pinned still keeps
2999
+ its R121 stroke change (legendHeadline +
3000
+ width 2) so pin and hover stay
3001
+ discriminable on the same lifted state.
3002
+ strokeWidth stays at 1 / 2 — won't trip the
3003
+ R51 overlap-test sentinels (1.5 / 3 are
3004
+ reserved). transition keeps the lift smooth
3005
+ (180ms ease-out) and respects prefers-
3006
+ reduced-motion via the globals.css blanket
3007
+ override that neutralises transitions.
3008
+
3009
+ Six surfaces now share the hover-elevation
3010
+ idiom: nodes (R51), panels (R135), group
3011
+ boxes (R142), recent rows (R143), legend
3012
+ rows (R144), edge badges (R164). */}
3013
+ <circle
3014
+ cx={badgeX} cy={badgeY}
3015
+ r={isHoveredEdge || isPinned ? 10.5 : 9}
3016
+ fill={pal.legendBox.fill}
3017
+ stroke={isPinned ? pal.legendHeadline : isHot ? hotStroke : pal.flowEdge}
3018
+ strokeWidth={isPinned ? 2 : isHot ? 2 : 1}
3019
+ opacity={isLight ? 0.95 : 0.82}
3020
+ data-edge-badge-lifted={(isHoveredEdge || isPinned) ? 'true' : 'false'}
3021
+ style={{ transition: 'r 180ms ease-out' }}
3022
+ />
3023
+ <text
3024
+ x={badgeX} y={badgeY + 3}
3025
+ textAnchor="middle"
3026
+ fill={pal.legendHeadline}
3027
+ fontSize="10"
3028
+ fontFamily="monospace"
3029
+ fontWeight="700"
3030
+ style={{ pointerEvents: 'none' }}
3031
+ >{link.count}</text>
3032
+ </g>
3033
+ );
3034
+ })()}
1074
3035
  </g>
1075
3036
  );
1076
3037
  })}
1077
3038
 
1078
- {/* center hub — round 39: enlarged from r=6 (12px) to r=10 (20px)
1079
- core so the "control plane" reads as the network's anchor,
1080
- not a stray particle. Static halo ring at r=18 grounds it
1081
- visually; two outward pulses keep the "signal source" idea
1082
- from r17. Light theme gets a paler core to avoid hot-spotting
1083
- the bg. */}
1084
- {layout === 'ring' && (<g>
1085
- {/* static grounding halo — sits underneath, low opacity */}
1086
- <circle
1087
- cx={cx} cy={cy} r="18"
1088
- fill={isLight ? '#d1fae5' : '#10b981'}
1089
- opacity={isLight ? 0.35 : 0.10}
1090
- />
1091
- {/* outer pulse 1 */}
1092
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5">
1093
- <animate attributeName="r" values="10;38;10" dur="2.4s" repeatCount="indefinite" />
1094
- <animate attributeName="opacity" values="0.55;0;0.55" dur="2.4s" repeatCount="indefinite" />
1095
- </circle>
1096
- {/* outer pulse 2 (delayed 1.2s) */}
1097
- <circle cx={cx} cy={cy} r="10" fill="none" stroke="#10b981" strokeWidth="1.5" opacity="0">
1098
- <animate attributeName="r" values="10;38;10" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1099
- <animate attributeName="opacity" values="0.45;0;0.45" dur="2.4s" begin="1.2s" repeatCount="indefinite" />
1100
- </circle>
3039
+ {/* center hub — round 39 sized + round 13 restraint.
3040
+ The hub is the control-plane anchor. r39 gave it two outward
3041
+ pulses (r 10→38, 2.4s, double-phase) which read as "loudest
3042
+ node in the room" — wrong semantics: anchors don't emit,
3043
+ they hold. r13 swaps the kinetic pulse for a slow opacity
3044
+ breath on the static halo (4s, ±15% from base), so the hub
3045
+ stays alive without throwing kinetic energy outward. The
3046
+ dual-circle "lit lamp" core is unchanged. */}
3047
+ {layout === 'ring' && (<g
3048
+ data-topo-hub
3049
+ data-topo-hub-hovered={hoveredHub ? 'true' : 'false'}
3050
+ // Round 159 / Loop: the hub is the most visually
3051
+ // prominent interactive element on the canvas (R39
3052
+ // enlarged it, R52 made it click to fitView, R115
3053
+ // added a hover ring, R43 gave it a tooltip) — but
3054
+ // R151-R157's a11y sweep skipped it. role/tabIndex/
3055
+ // aria-label/onKeyDown bring it to parity with node <g>
3056
+ // (R151), group label hit (R152), and minimap (R157).
3057
+ // anet-topo-svg-focus picks up R156's explicit cyan
3058
+ // focus ring (default browser SVG focus rect is hard
3059
+ // to see against the dark canvas).
3060
+ role="button"
3061
+ tabIndex={0}
3062
+ aria-label={(() => {
3063
+ const parts = ['Network hub'];
3064
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
3065
+ if (workingCount > 0) parts.push(`${workingCount} working`);
3066
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
3067
+ return parts.join(' · ') + ' — Enter to fit view';
3068
+ })()}
3069
+ className="anet-topo-svg-focus"
3070
+ style={{ cursor: 'pointer' }}
3071
+ // Stop pointerdown from reaching the SVG pan handler — same
3072
+ // reason as the node <g>: a captured pointer makes Chromium
3073
+ // fire the follow-up click on the SVG, not this group.
3074
+ onPointerDown={(e) => e.stopPropagation()}
3075
+ onMouseEnter={() => setHoveredHub(true)}
3076
+ onMouseLeave={() => setHoveredHub(false)}
3077
+ // Round 52 / Loop: hub is the visual anchor but had no click
3078
+ // action — users discovered fit-to-content only via the `f`
3079
+ // key or the chrome button. Wire the hub to fitView() so the
3080
+ // most prominent element in the canvas is also the "re-
3081
+ // center" affordance. A click-ripple keyed by ts confirms
3082
+ // the gesture; the hub <title> now ends with a hint.
3083
+ onClick={() => {
3084
+ fitView();
3085
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
3086
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
3087
+ }}
3088
+ onKeyDown={(e) => {
3089
+ if (e.key === 'Enter' || e.key === ' ') {
3090
+ e.preventDefault();
3091
+ fitView();
3092
+ setClickRipple({ ts: Date.now(), x: cx, y: cy, r0: 18, color: isLight ? '#059669' : '#10b981' });
3093
+ setTimeout(() => setClickRipple(prev => prev && prev.x === cx && prev.y === cy ? null : prev), 600);
3094
+ }
3095
+ }}
3096
+ >
3097
+ {/* Round 43 / Loop: hub `<title>` summary — hovering the
3098
+ central glow now answers "what is this?" with a one-line
3099
+ fleet snapshot. Duplicates the header chips by design;
3100
+ hovering the most visually prominent element is the
3101
+ most natural impulse, so satisfy it where the cursor
3102
+ already is. Falls clean: omits sub-clauses when their
3103
+ counts are zero. R52 appends a "click to fit view" hint.
3104
+ */}
3105
+ <title>{(() => {
3106
+ const total = sessions.length;
3107
+ const parts = [`Network hub`, `${total} session${total === 1 ? '' : 's'}`];
3108
+ if (onlineNodes.length > 0) parts.push(`${onlineNodes.length} online`);
3109
+ if (workingCount > 0) parts.push(`${workingCount} working`);
3110
+ if (flowLinks.length > 0) parts.push(`${flowLinks.length} active link${flowLinks.length === 1 ? '' : 's'}`);
3111
+ return parts.join(' · ') + '\nclick to fit view';
3112
+ })()}</title>
3113
+ {/* grounding halo — breathes in opacity, no expansion.
3114
+ R84: breath amplitude + tempo reflect workingCount. An
3115
+ idle fleet keeps the original gentle 0.32→0.52 (light) /
3116
+ 0.08→0.16 (dark) cycle over 4s — "heart at rest". As
3117
+ working sessions accumulate, the peak climbs and the
3118
+ period shortens, capped at 2.4s so it never feels
3119
+ manic. Quiet fleets see zero change; busy fleets feel
3120
+ the canvas working. */}
3121
+ {(() => {
3122
+ // Bucket workingCount to keep the visual feedback discrete
3123
+ // rather than continuous: 0 / 1-2 / 3-5 / 6+. Three buckets
3124
+ // are enough — finer gradations are imperceptible.
3125
+ const busy = workingCount === 0 ? 0
3126
+ : workingCount <= 2 ? 1
3127
+ : workingCount <= 5 ? 2
3128
+ : 3;
3129
+ const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
3130
+ const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
3131
+ const troughLight = 0.32;
3132
+ const troughDark = 0.08;
3133
+ const dur = [4.0, 3.2, 2.7, 2.4][busy];
3134
+ const valuesLight = `${troughLight};${peakLight};${troughLight}`;
3135
+ const valuesDark = `${troughDark};${peakDark};${troughDark}`;
3136
+ return (
3137
+ <circle
3138
+ cx={cx} cy={cy} r="18"
3139
+ fill={isLight ? '#d1fae5' : '#10b981'}
3140
+ opacity={isLight ? 0.42 : 0.12}
3141
+ data-hub-busyness={busy}
3142
+ >
3143
+ {!reducedMotion && (
3144
+ <animate
3145
+ attributeName="opacity"
3146
+ values={isLight ? valuesLight : valuesDark}
3147
+ dur={`${dur}s`}
3148
+ repeatCount="indefinite"
3149
+ />
3150
+ )}
3151
+ </circle>
3152
+ );
3153
+ })()}
1101
3154
  {/* core — 20px diameter, larger inner highlight reads as a "lit lamp" */}
1102
3155
  <circle cx={cx} cy={cy} r="10" fill={isLight ? '#059669' : '#10b981'} />
1103
- <circle cx={cx} cy={cy} r="5" fill="#d1fae5" opacity="0.9" />
3156
+ {/* R130 / Loop: when workingCount > 0, the decorative inner
3157
+ highlight gets replaced with the workingCount digit. The
3158
+ R84 busyness breath already encodes the same metric
3159
+ through motion — adding the digit gives it a second
3160
+ visual channel right at the canvas's focal point. A
3161
+ user glancing at the hub now sees both "the network is
3162
+ pulsing" (motion) AND "3 agents are working" (digit)
3163
+ without having to scan the chip row or panels.
3164
+
3165
+ Geometry: text at (cx, cy) with fontSize 11 monospace
3166
+ + fontWeight 700 sits inside the r=10 core (a 2-digit
3167
+ 12 reads ~12 px wide × 11 px tall, well inside the
3168
+ 20-px diameter core). Centered vertically via dy=
3169
+ "0.36em" — the standard SVG trick for text-vertical-
3170
+ center without measuring fontMetrics.
3171
+
3172
+ pointerEvents:none so the digit can't intercept the
3173
+ hub click (R52 fit-to-view still fires).
3174
+
3175
+ workingCount=0 falls through to the existing
3176
+ decorative highlight so the hub never looks empty. */}
3177
+ {workingCount > 0 ? (
3178
+ <text
3179
+ x={cx} y={cy}
3180
+ textAnchor="middle"
3181
+ dy="0.36em"
3182
+ fill={isLight ? '#d1fae5' : '#ecfdf5'}
3183
+ fontSize="11"
3184
+ fontFamily="monospace"
3185
+ fontWeight="700"
3186
+ style={{ pointerEvents: 'none' }}
3187
+ data-topo-hub-working-count={workingCount}
3188
+ >
3189
+ {workingCount}
3190
+ </text>
3191
+ ) : (
3192
+ <circle
3193
+ cx={cx} cy={cy} r="5"
3194
+ fill="#d1fae5"
3195
+ opacity="0.9"
3196
+ data-topo-hub-highlight
3197
+ />
3198
+ )}
3199
+ {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
3200
+ that fades in when the hub is hovered — the same idea
3201
+ R44 used for node avatars (group-hover stroke). r=14
3202
+ sits comfortably outside the r=10 core and INSIDE the
3203
+ r=18 grounding halo, so the hover indicator is fully
3204
+ contained within the existing hub footprint (no bbox
3205
+ growth, overlap test unchanged). pointerEvents:none so
3206
+ the hint can't intercept the click that produced it. */}
3207
+ <circle
3208
+ cx={cx} cy={cy} r="14"
3209
+ fill="none"
3210
+ stroke={isLight ? '#059669' : '#10b981'}
3211
+ strokeWidth="1.5"
3212
+ opacity={hoveredHub ? (isLight ? 0.85 : 0.7) : 0}
3213
+ data-topo-hub-hover-ring
3214
+ style={{ pointerEvents: 'none', transition: 'opacity 180ms ease-out' }}
3215
+ />
1104
3216
  </g>)}
1105
3217
 
1106
3218
  {/* agent nodes */}
@@ -1127,22 +3239,113 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1127
3239
  // Round 8: when a node in another group is hovered, fade this
1128
3240
  // one. Same-group nodes (incl. singletons matching the hovered
1129
3241
  // alias) stay full. Pure visual focus, geometry unchanged.
1130
- const inFocus = !hoveredGroup || (groupKeys[session.alias] ?? session.alias) === hoveredGroup;
3242
+ const inFocus = !activeGroup || (groupKeys[session.alias] ?? session.alias) === activeGroup;
3243
+ // R72: in ring layout, classify the node into a tier by its
3244
+ // distance from hub centre so the first-paint stagger can
3245
+ // emanate inner → outer instead of running clockwise. Grid
3246
+ // layout doesn't have a radial structure; it keeps R9's pure
3247
+ // nodeIdx stagger. Thresholds bracket the actual tier radii
3248
+ // (single 220, dual 175/260, triple 145/215/285, offline 325+).
3249
+ let tierIdx = 0;
3250
+ if (layout === 'ring') {
3251
+ const p = nodePositions[session.alias];
3252
+ if (p) {
3253
+ const d = Math.hypot(p.x - cx, p.y - cy);
3254
+ tierIdx = d < 195 ? 0 : d < 270 ? 1 : d < 310 ? 2 : 3;
3255
+ }
3256
+ }
1131
3257
 
1132
3258
  return (
1133
3259
  <g
1134
3260
  key={session.alias}
1135
3261
  data-node={session.alias}
3262
+ data-tier-idx={layout === 'ring' ? tierIdx : -1}
3263
+ // R151 / Loop: node a11y compliance — match the pattern
3264
+ // R116 / R139 / R140 / R148 / R149 applied to other
3265
+ // interactive surfaces. Node <g> has been clickable for
3266
+ // chat since R45 but tab-unreachable + screen-reader-
3267
+ // unannounced. role="button" + tabIndex=0 + aria-label
3268
+ // expose it; aria-pressed reflects chat-target state so
3269
+ // SR users know which node currently has the popover
3270
+ // open. onKeyDown for Enter / Space fires the same
3271
+ // setChatAlias path as onClick. preventDefault on
3272
+ // Space stops the SVG canvas from scrolling.
3273
+ role="button"
3274
+ tabIndex={0}
3275
+ aria-pressed={chatAlias === session.alias}
3276
+ aria-label={`Chat with ${session.alias} (${session.status})`}
3277
+ onKeyDown={(e) => {
3278
+ if (e.key === 'Enter' || e.key === ' ') {
3279
+ e.preventDefault();
3280
+ setChatAlias(session.alias);
3281
+ setClickRipple({
3282
+ ts: Date.now(),
3283
+ x: pos.x, y: pos.y, r0: radius,
3284
+ color: status.primary,
3285
+ });
3286
+ setTimeout(() => setClickRipple(prev =>
3287
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
3288
+ }
3289
+ }}
1136
3290
  // Round 3 / Loop: `anet-fade-in` runs once when the <g>
1137
3291
  // mounts — a new session entering the fleet (or the topology
1138
3292
  // first rendering) eases in instead of popping. Re-renders of
1139
3293
  // an existing node don't re-trigger (React preserves the <g>
1140
3294
  // via the alias key), so status changes don't flicker. The
1141
3295
  // global prefers-reduced-motion sweep already neutralises it.
1142
- className="group transition-opacity anet-fade-in"
3296
+ className="group transition-opacity anet-fade-in anet-topo-svg-focus"
1143
3297
  style={{
1144
3298
  cursor: 'pointer',
1145
- opacity: inFocus ? 1 : 0.32,
3299
+ // Round 17 / Loop: offline nodes drop to 0.6 at rest so
3300
+ // online nodes pop without losing the offline-as-ghost
3301
+ // information. The dashed stroke + smaller radius already
3302
+ // say "offline"; dimming the whole group strengthens the
3303
+ // online-vs-offline hierarchy at first glance. Exempt the
3304
+ // chat-focused node — if the user explicitly opened a
3305
+ // popover targeting an offline alias, that node stays
3306
+ // full-brightness so the focus ring + popover read as one
3307
+ // selected thing rather than a dimmed selection. Group-
3308
+ // hover fade (Round 8) still wins when inFocus is false.
3309
+ // Round 49 / Loop: edge-hover endpoint highlight composes
3310
+ // OVER the inFocus/online formula — a non-endpoint node
3311
+ // dims to 0.28 (just below the inFocus 0.32 to read as a
3312
+ // stronger "not relevant" signal), endpoints keep their
3313
+ // base opacity. chatAlias still wins to keep the focus
3314
+ // ring legible if the user clicked through.
3315
+ // Round 55 / Loop: legend-status hover composes on the
3316
+ // SAME level as edge-hover-endpoint. Non-matching nodes
3317
+ // dim to 0.28; matching nodes stay at their base.
3318
+ // activeStatus matches: working = status==='working',
3319
+ // idle = online but not working, offline = !isOnline.
3320
+ // Round 60 / Loop: activeStatus = hoveredStatus ?? pinnedStatus
3321
+ // so the pressure-bar segment pins (R60) and the legend
3322
+ // row hover (R55) feed the same branch.
3323
+ // Round 80 / Loop: vendor-letter hover composes ABOVE the
3324
+ // activeStatus branch so hovering the vendor chip's `C`
3325
+ // dims everything except C-vendor nodes. Same dim value
3326
+ // (0.28) as edge-endpoint and status filters — visually
3327
+ // consistent. Vendor lookup uses the same vendorForModel
3328
+ // helper the avatar render uses, keyed by initial so the
3329
+ // chip and the avatar always agree on grouping.
3330
+ opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
3331
+ ? 0.28
3332
+ : activeVendor && chatAlias !== session.alias && (() => {
3333
+ const v = vendorForModel(session.model);
3334
+ const initial = v.id === 'unknown' ? '?' : v.initial;
3335
+ return initial !== activeVendor;
3336
+ })()
3337
+ ? 0.28
3338
+ : activeStatus && chatAlias !== session.alias && !(
3339
+ activeStatus === 'working' ? session.status === 'working'
3340
+ : activeStatus === 'idle' ? (isOnline && session.status !== 'working')
3341
+ : /* offline */ !isOnline
3342
+ )
3343
+ ? 0.28
3344
+ : !inFocus
3345
+ ? 0.32
3346
+ : chatAlias === session.alias
3347
+ ? 1
3348
+ : isOnline ? 1 : 0.6,
1146
3349
  // Round 9 / Loop: stagger the anet-fade-in so the topology
1147
3350
  // reveals as a wave on first paint instead of one big pop.
1148
3351
  // Cap at 24 indices (≈600ms tail) so 50-node fleets still
@@ -1150,7 +3353,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1150
3353
  // applies during the keyframe — re-renders without a new
1151
3354
  // mount (same alias key) don't replay, so status changes
1152
3355
  // never trigger the stagger again.
1153
- animationDelay: `${Math.min(nodeIdx, 24) * 25}ms`,
3356
+ // Round 72 / Loop: in ring layout, stagger by tier radius
3357
+ // so the topology emanates from the hub outward — inner
3358
+ // tier at 0ms, middle at ~180ms, outer at ~360ms, offline
3359
+ // at ~540ms. A small within-tier offset (nodeIdx % 6 * 25)
3360
+ // adds variety so each ring rotates in instead of all-at-
3361
+ // once popping. Grid keeps R9's pure nodeIdx stagger.
3362
+ animationDelay: layout === 'ring'
3363
+ ? `${tierIdx * 180 + (nodeIdx % 6) * 25}ms`
3364
+ : `${Math.min(nodeIdx, 24) * 25}ms`,
3365
+ // Round 51 / Loop: hover micro-lift. The label already
3366
+ // lifts on group-hover (R26). The node body — circle,
3367
+ // avatar, status ring — stays planted, so the gesture
3368
+ // reads "label moves, body doesn't". Now the whole <g>
3369
+ // translates -2px when hovered: avatar + ring + label
3370
+ // move as one unit. 2 px is well inside the inter-row
3371
+ // gap (cellH headroom ≥22), and CSS transform on SVG
3372
+ // <g> never affects the overlap-test geometry (the
3373
+ // test never simulates hover). useReducedMotion drops
3374
+ // the lift to 0 for prefers-reduced-motion users.
3375
+ transform: !reducedMotion && hoveredAlias === session.alias ? 'translateY(-2px)' : undefined,
3376
+ transition: 'transform 180ms cubic-bezier(0.4,0,0.2,1)',
1154
3377
  }}
1155
3378
  // Stop the pointerdown from reaching the SVG pan handler: the
1156
3379
  // SVG calls setPointerCapture, and a captured pointer makes
@@ -1163,12 +3386,97 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1163
3386
  // Round 8 group-focus fade works at any fleet size.
1164
3387
  onPointerEnter={() => setHoveredAlias(session.alias)}
1165
3388
  onPointerLeave={() => setHoveredAlias(prev => (prev === session.alias ? null : prev))}
1166
- onClick={() => setChatAlias(session.alias)}
3389
+ onClick={() => {
3390
+ setChatAlias(session.alias);
3391
+ // Round 14 ripple — capture position, radius and status
3392
+ // colour at click time so the one-shot circle is self-
3393
+ // contained (no re-render-time recomputation needed).
3394
+ setClickRipple({
3395
+ ts: Date.now(),
3396
+ x: pos.x, y: pos.y, r0: radius,
3397
+ color: status.primary,
3398
+ });
3399
+ setTimeout(() => setClickRipple(prev =>
3400
+ prev && Date.now() - prev.ts >= 590 ? null : prev), 600);
3401
+ }}
1167
3402
  >
1168
3403
  {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
1169
3404
  Falls back to just the alias when the node reports no
1170
- model/runtime. */}
1171
- <title>{identityLine(session.model, session.runtime) || session.alias}</title>
3405
+ model/runtime.
3406
+ Round 33 / Loop: cwd line answers "what is this agent on?".
3407
+ Round 34 / Loop: for offline nodes, append "last seen: 6m ago"
3408
+ so the operator knows whether to wait or chase. Online nodes
3409
+ skip the line (Round 27's 1 h ghost age-out means anything
3410
+ still online has heartbeated recently — the line would just
3411
+ be noise). Accepts both ISO ("…T06:00:28Z") and SQL-style
3412
+ ("… 06:00:28" assumed UTC) formats. */}
3413
+ {(() => {
3414
+ // Round 35 / Loop: TZ-safe parsing via the shared lib helper
3415
+ // — same parseHubTime is used by isGhost above (Round 38
3416
+ // factored it to app/lib/time.ts so both paths interpret SQL
3417
+ // bare timestamps as UTC on every browser).
3418
+ const lastSeen = !isOnline && session.last_seen_at ? relativeAgo(session.last_seen_at) : null;
3419
+ // Round 98 / Loop: enrich the node tooltip with status,
3420
+ // group membership, and inbound/outbound flow counts —
3421
+ // same info-density spirit as R97 active-filter pill
3422
+ // tooltips. Hovering any node now answers "what is this
3423
+ // and how does it sit in the topology" without forcing
3424
+ // a click into the chat popover. Group line only shows
3425
+ // when the alias is part of a multi-member band (R106
3426
+ // prefix grouping); singletons skip it. Flow line only
3427
+ // when at least one direction has count > 0.
3428
+ const groupKey = groupKeys[session.alias];
3429
+ const groupMembers = groupKey
3430
+ ? Object.values(groupKeys).filter(k => k === groupKey).length
3431
+ : 1;
3432
+ const groupLine = groupMembers > 1 ? `group: ${groupKey} · ${groupMembers}` : null;
3433
+ // R147 / Loop: node tooltip extends R98's flow summary
3434
+ // with the actual sender / receiver aliases. R98 told you
3435
+ // "12 in / 5 out" but not WHO. The R97 idiom — anywhere
3436
+ // the UI shows "N" should hover-explain WHICH N — applied
3437
+ // to filter pills, group labels, vendor letters, pressure
3438
+ // segments, recent-row text, active-links chip; the node
3439
+ // title was the last surface still showing only the
3440
+ // aggregate. Direction-tagged: senders are who's
3441
+ // messaging THIS node (inbound), receivers are who this
3442
+ // node is messaging (outbound). 6-truncate + "+N more"
3443
+ // matches the pattern other tooltips use so a 30-flow
3444
+ // node doesn't paint a 30-line tooltip.
3445
+ let flowIn = 0, flowOut = 0;
3446
+ const sendersMap = new Map<string, number>(); // alias → count, inbound
3447
+ const receiversMap = new Map<string, number>(); // alias → count, outbound
3448
+ for (const fl of flowLinks) {
3449
+ if (fl.from === session.alias) {
3450
+ flowOut += fl.count;
3451
+ receiversMap.set(fl.to, (receiversMap.get(fl.to) ?? 0) + fl.count);
3452
+ }
3453
+ if (fl.to === session.alias) {
3454
+ flowIn += fl.count;
3455
+ sendersMap.set(fl.from, (sendersMap.get(fl.from) ?? 0) + fl.count);
3456
+ }
3457
+ }
3458
+ const fmtPeers = (m: Map<string, number>) => {
3459
+ const pairs = [...m.entries()].sort((a, b) => b[1] - a[1]);
3460
+ const preview = pairs.slice(0, 6).map(([a, n]) => `${a} (${n})`).join(', ');
3461
+ const suffix = pairs.length > 6 ? ` + ${pairs.length - 6} more` : '';
3462
+ return preview + suffix;
3463
+ };
3464
+ const flowLine = (flowIn + flowOut) > 0 ? `flows: ${flowIn} in / ${flowOut} out` : null;
3465
+ const sendersLine = sendersMap.size > 0 ? `← from: ${fmtPeers(sendersMap)}` : null;
3466
+ const receiversLine = receiversMap.size > 0 ? `→ to: ${fmtPeers(receiversMap)}` : null;
3467
+ return (
3468
+ <title>{[
3469
+ `${session.alias} · ${session.status}`,
3470
+ identityLine(session.model, session.runtime),
3471
+ groupLine,
3472
+ session.project_dir ? `cwd: ${session.project_dir}` : null,
3473
+ lastSeen ? `last seen: ${lastSeen}` : null,
3474
+ flowLine,
3475
+ sendersLine,
3476
+ receiversLine,
3477
+ ].filter(Boolean).join('\n')}</title>
3478
+ );
3479
+ })()}
1172
3480
  {/* Round 2 / Loop: hover ring — a thin outer stroke that fades
1173
3481
  in when the cursor enters the node, signalling clickability
1174
3482
  (real-user feedback for the chat-popover open). Pure CSS via
@@ -1188,7 +3496,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1188
3496
  className="opacity-0 group-hover:opacity-70 transition-opacity duration-150"
1189
3497
  style={{ pointerEvents: 'none' }}
1190
3498
  />
1191
- {isActive && (
3499
+ {/* Round 11 / Loop: chat-focus ring — when the ChatPopover is
3500
+ open targeting this node, anchor a persistent ring around
3501
+ it so the floating popover visibly links back to its source
3502
+ node. Static (not pulsing) so it reads as "selected state"
3503
+ rather than "this node is active". strokeWidth=2.5 stays
3504
+ clear of the overlap-test selectors (1.5 / 3). Sits just
3505
+ outside the halo radius+8 so it never overlaps a neighbour
3506
+ (halos already pack flush in dense grids). */}
3507
+ {chatAlias === session.alias && (
3508
+ /* R51 chat-target ring. R120 / Loop: gentle SMIL
3509
+ breath on the ring's opacity (±0.1 over 3s) when
3510
+ chat is open + !reducedMotion. Says "active session
3511
+ here" continuously without animation noise — the
3512
+ ring only appears for one node at a time (the
3513
+ chatAlias), so it never competes with R84 hub
3514
+ busyness or R112 working halo for attention. */
3515
+ <circle
3516
+ cx={pos.x}
3517
+ cy={pos.y}
3518
+ r={radius + 14}
3519
+ fill="none"
3520
+ stroke={status.primary}
3521
+ strokeWidth="2.5"
3522
+ opacity={isLight ? 0.85 : 0.95}
3523
+ filter={!isLight ? 'url(#topo-glow)' : undefined}
3524
+ className="transition-opacity duration-200"
3525
+ style={{ pointerEvents: 'none' }}
3526
+ data-chat-target-ring
3527
+ data-chat-target-breath={!reducedMotion ? 'on' : 'off'}
3528
+ >
3529
+ {!reducedMotion && (
3530
+ <animate
3531
+ attributeName="opacity"
3532
+ values={isLight ? '0.72;0.95;0.72' : '0.82;1;0.82'}
3533
+ dur="3s"
3534
+ repeatCount="indefinite"
3535
+ />
3536
+ )}
3537
+ </circle>
3538
+ )}
3539
+ {isActive && !reducedMotion && (
1192
3540
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
1193
3541
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
1194
3542
  <animate attributeName="opacity" values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'} dur="2.4s" repeatCount="indefinite" />
@@ -1196,7 +3544,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1196
3544
  )}
1197
3545
  {/* Round 4 / Loop: transition-[fill,stroke,opacity] smooths
1198
3546
  status colour changes so idle↔working↔offline doesn't snap
1199
- — task replies / node-rename / SSE updates ease in. */}
3547
+ — task replies / node-rename / SSE updates ease in.
3548
+ Round 112 / Loop: working nodes get a subtle halo breath
3549
+ (±0.12 opacity at 3s cycle) so the eye can find "what's
3550
+ busy" at a glance without scanning chips. Idle + offline
3551
+ halos stay flat — they don't need to demand attention.
3552
+ R84 hub-center breath stays the loudest "fleet busyness"
3553
+ signal; this one is quieter, per-node. SMIL `<animate>`
3554
+ inside the circle, gated by reducedMotion. */}
1200
3555
  <circle
1201
3556
  cx={pos.x}
1202
3557
  cy={pos.y}
@@ -1204,7 +3559,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1204
3559
  fill={status.halo}
1205
3560
  opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
1206
3561
  className="transition-[fill,opacity] duration-300 ease-out"
1207
- />
3562
+ data-node-halo-breath={!reducedMotion && session.status === 'working' ? 'on' : 'off'}
3563
+ >
3564
+ {!reducedMotion && session.status === 'working' && (
3565
+ <animate
3566
+ attributeName="opacity"
3567
+ values={isLight ? '0.73;0.92;0.73' : '0.53;0.78;0.53'}
3568
+ dur="3s"
3569
+ repeatCount="indefinite"
3570
+ />
3571
+ )}
3572
+ </circle>
3573
+ {/* Round 111 / Loop: edge-endpoint emphasis ring. R49
3574
+ already keeps endpoint nodes at opacity 1 while
3575
+ others dim when an edge is hovered, but the
3576
+ endpoints had no POSITIVE indicator — they just
3577
+ "stayed bright". An accent stroke at r=radius+7
3578
+ (just inside the halo's r=radius+8 bbox so we
3579
+ don't grow the overlap footprint) clearly says
3580
+ "these are the two participants in this flow".
3581
+ pointerEvents:none so the node hitbox stays alive. */}
3582
+ {hoveredEdgeEndpoints && hoveredEdgeEndpoints.has(session.alias) && (
3583
+ <circle
3584
+ cx={pos.x}
3585
+ cy={pos.y}
3586
+ r={radius + 7}
3587
+ fill="none"
3588
+ stroke={pal.flowEdge}
3589
+ strokeWidth={1.5}
3590
+ opacity={isLight ? 0.9 : 0.85}
3591
+ data-edge-endpoint-ring
3592
+ style={{ pointerEvents: 'none', transition: 'opacity 180ms ease-out' }}
3593
+ />
3594
+ )}
1208
3595
  <circle
1209
3596
  cx={pos.x}
1210
3597
  cy={pos.y}
@@ -1301,52 +3688,115 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1301
3688
  </g>
1302
3689
  );
1303
3690
  })()}
1304
- {session.status === 'working' && (
1305
- // working pulse — small dot just inside the status ring,
1306
- // above the avatar text. Was a 18px horizontal bar at
1307
- // y+11 which now collides with the avatar; the dot reads
1308
- // as the same "active" cue without occluding the initial.
1309
- <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle}>
1310
- <animate attributeName="opacity" values="1;0.25;1" dur="1.1s" repeatCount="indefinite" />
1311
- </circle>
1312
- )}
3691
+ {session.status === 'working' && (() => {
3692
+ // Round 24 / Loop: pulse rate ↔ traffic. The dot's dur
3693
+ // was a flat 1.1s — every working node looked equally
3694
+ // busy. Mapping it to sse:N lets the eye land on what's
3695
+ // actually hot. 3 discrete tiers read cleaner than
3696
+ // continuous easing at a glance; thresholds picked so
3697
+ // typical 1-2-SSE Claude/Codex sessions stay in the
3698
+ // "active" tier and only multi-pane orchestrators pop
3699
+ // into the "busy" tier. Geometry unchanged (r=2.5).
3700
+ const sse = sseCountFor ?? 0;
3701
+ const dur = sse >= 4 ? '0.7s' : sse >= 2 ? '0.9s' : '1.2s';
3702
+ return (
3703
+ <circle cx={pos.x} cy={pos.y - (radius - 6)} r="2.5" fill={pal.flowParticle} data-pulse-dur={dur} opacity={reducedMotion ? 0.6 : undefined}>
3704
+ {!reducedMotion && (
3705
+ <animate attributeName="opacity" values="1;0.25;1" dur={dur} repeatCount="indefinite" />
3706
+ )}
3707
+ </circle>
3708
+ );
3709
+ })()}
1313
3710
 
1314
3711
  {/* Round 98 (issue #61): label rect 124px → 100px.
1315
3712
  Round 109/110 (Vincent P0): full opaque card below the
1316
3713
  density threshold / on hover / when zoomed; otherwise a
1317
3714
  lightweight plain-text alias that keeps every node
1318
- labelled without an opaque box covering its neighbours. */}
1319
- {showFullLabel ? (
1320
- <g transform={`translate(${pos.x}, ${pos.y + radius + 22})`} style={{ pointerEvents: 'none' }}>
1321
- <rect x="-50" y="-14" width="100" height="42" rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
1322
- <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize="12" fontFamily="monospace" fontWeight="700">
1323
- {truncate(session.alias, 12)}
1324
- </text>
1325
- <text x="0" y="17" textAnchor="middle" fill={status.primary} fontSize="9" fontFamily="monospace">
1326
- {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
3715
+ labelled without an opaque box covering its neighbours.
3716
+ Round 15 / Loop: when nodeScale=S the node shrinks 30%
3717
+ but the label card was staying full-size, so the label
3718
+ visually outweighed the small node. Tighten the card
3719
+ frame, alias / sub fontSize, drop-offset and truncate
3720
+ length specifically for S; M and L keep their existing
3721
+ sizes (M ≈ L for labels by design — the S user is the
3722
+ one who explicitly asked for a denser view). */}
3723
+ {(() => {
3724
+ const isSmall = nodeScale < 0.8;
3725
+ const cardW = isSmall ? 88 : 100;
3726
+ const cardH = isSmall ? 36 : 42;
3727
+ const cardTopY = isSmall ? -12 : -14;
3728
+ const aliasFs = isSmall ? 11 : 12;
3729
+ const subFs = isSmall ? 8 : 9;
3730
+ const subY = isSmall ? 15 : 17;
3731
+ const dropY = isSmall ? 18 : 22;
3732
+ const fullMax = isSmall ? 11 : 12;
3733
+ const denseFs = isSmall ? 9 : 10;
3734
+ const denseDrop = isSmall ? 12 : 14;
3735
+ // Round 26 / Loop: micro-lift the label group on hover —
3736
+ // 1.5 px upward, 200 ms ease. Pairs with the Round 18
3737
+ // group-box hover-accent treatment to give the same
3738
+ // "this is the focused element" feedback at the per-
3739
+ // node level. CSS transform stacks onto the SVG
3740
+ // positioning transform attribute (SVG 2 cascade);
3741
+ // bbox is unchanged at rest, so the overlap-test gate
3742
+ // continues to see the geometric layout it expects.
3743
+ return showFullLabel ? (
3744
+ <g transform={`translate(${pos.x}, ${pos.y + radius + dropY})`} style={{ pointerEvents: 'none' }}
3745
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]">
3746
+ <rect x={-cardW / 2} y={cardTopY} width={cardW} height={cardH} rx="6" fill={pal.labelBox.fill} stroke={pal.labelBox.stroke} opacity={isLight ? 1 : 0.94} />
3747
+ <text x="0" y="1" textAnchor="middle" fill={status.text} fontSize={aliasFs} fontFamily="monospace" fontWeight="700">
3748
+ {truncate(session.alias, fullMax)}
3749
+ </text>
3750
+ <text x="0" y={subY} textAnchor="middle" fill={status.primary} fontSize={subFs} fontFamily="monospace">
3751
+ {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
3752
+ </text>
3753
+ </g>
3754
+ ) : (
3755
+ <text
3756
+ x={pos.x}
3757
+ y={pos.y + radius + denseDrop}
3758
+ textAnchor="middle"
3759
+ fill={status.text}
3760
+ fontSize={denseFs}
3761
+ fontFamily="monospace"
3762
+ fontWeight="700"
3763
+ opacity={0.9}
3764
+ className="transition-transform duration-200 group-hover:-translate-y-[1.5px]"
3765
+ style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
3766
+ stroke={pal.containerBg}
3767
+ strokeWidth="3"
3768
+ >
3769
+ {truncate(session.alias, isSmall ? 9 : 10)}
1327
3770
  </text>
1328
- </g>
1329
- ) : (
1330
- <text
1331
- x={pos.x}
1332
- y={pos.y + radius + 14}
1333
- textAnchor="middle"
1334
- fill={status.text}
1335
- fontSize="10"
1336
- fontFamily="monospace"
1337
- fontWeight="700"
1338
- opacity={0.9}
1339
- style={{ pointerEvents: 'none', paintOrder: 'stroke' }}
1340
- stroke={pal.containerBg}
1341
- strokeWidth="3"
1342
- >
1343
- {truncate(session.alias, 10)}
1344
- </text>
1345
- )}
3771
+ );
3772
+ })()}
1346
3773
  </g>
1347
3774
  );
1348
3775
  })}
1349
3776
 
3777
+ {/* Round 14 / Loop: click ripple — one-shot expanding ring from
3778
+ the clicked node, ~500ms, status-coloured. Sits inside the
3779
+ zoom/pan <g> so it scales / pans with the topology. Keyed by
3780
+ ts so a re-click on any node (same or different) remounts the
3781
+ <circle> and the SMIL <animate> elements replay from t=0.
3782
+ strokeWidth=2 doesn't match the overlap-test selectors. */}
3783
+ {clickRipple && !reducedMotion && (
3784
+ <circle
3785
+ key={clickRipple.ts}
3786
+ cx={clickRipple.x}
3787
+ cy={clickRipple.y}
3788
+ r={clickRipple.r0 + 4}
3789
+ fill="none"
3790
+ stroke={clickRipple.color}
3791
+ strokeWidth="2"
3792
+ opacity="0"
3793
+ style={{ pointerEvents: 'none' }}
3794
+ >
3795
+ <animate attributeName="r" values={`${clickRipple.r0 + 4};${clickRipple.r0 + 30}`} dur="0.5s" fill="freeze" />
3796
+ <animate attributeName="opacity" values="0.7;0" dur="0.5s" fill="freeze" />
3797
+ </circle>
3798
+ )}
3799
+
1350
3800
  </g>
1351
3801
 
1352
3802
  {/* #112: overlay panels (recent-signal + legend) render OUTSIDE the
@@ -1357,38 +3807,709 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1357
3807
  corner triangles, so the panels never overlap a node, in ring
1358
3808
  OR grid layout (Vincent 4727 zero-overlap criterion). */}
1359
3809
  {/* latest flow labels */}
1360
- <g transform="translate(16, 16)">
1361
- <rect x="0" y="0" width="230" height="84" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
3810
+ {/* Round 57 / Loop: drop-shadow on the panel rects gives them
3811
+ card-like elevation, especially on light theme where the
3812
+ near-white fill on a near-white canvas read as pasted-on.
3813
+ data-topo-panel-elevation tag so the test can verify both
3814
+ panels carry the filter. The filter is on the rect, not
3815
+ the parent <g>, so it doesn't shadow the rows + text inside
3816
+ — only the panel chrome lifts. */}
3817
+ <g
3818
+ transform="translate(16, 16)"
3819
+ data-topo-panel="recent"
3820
+ data-topo-panel-hovered={hoveredPanel === 'recent' ? 'true' : 'false'}
3821
+ onMouseEnter={() => setHoveredPanel('recent')}
3822
+ onMouseLeave={() => setHoveredPanel(prev => prev === 'recent' ? null : prev)}
3823
+ >
3824
+ <rect
3825
+ x="0" y="0" width="230" height="84" rx="10"
3826
+ fill={pal.legendBox.fill}
3827
+ stroke={pal.legendBox.stroke}
3828
+ opacity={isLight ? 0.97 : 0.92}
3829
+ style={{
3830
+ /* R135: drop-shadow intensifies on panel hover. Base
3831
+ shadow (2px / 6px blur) signals card elevation
3832
+ (R57); hovered (4px / 12px blur) tells the user
3833
+ the whole chrome is interactive territory — rows
3834
+ pin (R116), footer navigates (R133), legend rows
3835
+ pin status (R61). Reuses R18's KPI-card hover-
3836
+ elevation idiom for visual consistency. Theme-
3837
+ aware shadow colour stays the same; just the
3838
+ spread + blur grow. */
3839
+ filter: hoveredPanel === 'recent'
3840
+ ? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
3841
+ : 'drop-shadow(0 4px 12px rgba(0,0,0,0.65))')
3842
+ : (isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))'
3843
+ : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))'),
3844
+ transition: 'filter 200ms ease-out',
3845
+ }}
3846
+ data-topo-panel-elevation="recent"
3847
+ />
1362
3848
  <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">recent signal</text>
1363
- <text x="150" y="21" fill={pal.legendAccent} fontSize="10" fontFamily="monospace">{messages.length} msgs</text>
1364
- {flowLinks.slice(0, 3).map((link, index) => (
1365
- <text key={link.key} x="13" y={38 + index * 16} fill={pal.legendText} fontSize="9" fontFamily="monospace">
1366
- {truncate(link.from, 6)} {'->'} {truncate(link.to, 6)} / {link.count} / {truncate(link.content, 12)}
1367
- </text>
1368
- ))}
3849
+ {/* R96: header count now matches what the rows show. Pre-R96
3850
+ this read "X msgs" off the raw messages array, but the
3851
+ rows below render DEDUPED flowLinks — so a fleet with 10
3852
+ messages aggregating to 3 pairs read "10 msgs" above
3853
+ only 3 rows. Misreads as "where are the other 7?".
3854
+ "X flows" mirrors flowLinks.length one-for-one. When
3855
+ flows < msgs the chip-row's "N active links · last 2s"
3856
+ already tells the operator about traffic volume — no
3857
+ duplicate metric needed here.
3858
+ R129 / Loop: header gains an amber " · N hot" tail
3859
+ when ≥ 1 flowLink has count ≥ 10. The third surface
3860
+ of the hot-lane convention (R126 canvas badge / R127
3861
+ row count) lives at the panel header so a user
3862
+ scanning vertically — header → rows — gets a top-
3863
+ level summary before reading each row's amber digit.
3864
+ Restructured into a single <text> with <tspan>
3865
+ fragments so the amber portion can carry its own
3866
+ fill + weight without a sibling <text>. Switched
3867
+ anchor x=150 left-justified → x=217 right-justified
3868
+ so the count column unifies visually with the legend
3869
+ panel's right-justified header (line 3511 — also
3870
+ fontSize 10 monospace, also x≈215 textAnchor end).
3871
+ data-recent-panel-count stays on the flow tspan so
3872
+ the R96 / R128 tests still resolve. data-recent-
3873
+ panel-hot-count exposes the hot bucket count. */}
3874
+ {(() => {
3875
+ const hotFlowCount = flowLinks.filter(l => l.count >= 10).length;
3876
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
3877
+ // R162 / Loop: freshness tint on the panel-header
3878
+ // count tspan. R161 just colored the chip-row's "N
3879
+ // active links · last 5s" bullet by recency; the
3880
+ // recent-signal panel header is the panel-side
3881
+ // mirror of the same metric (flowLinks.length). Both
3882
+ // scopes now speak the same freshness vocabulary,
3883
+ // so a glance at either tells the operator whether
3884
+ // the network is firing right now. Four nested
3885
+ // scopes share one ladder:
3886
+ // canvas edge fade (R10)
3887
+ // row pip (R160)
3888
+ // chip bullet (R161)
3889
+ // panel header (R162, this round)
3890
+ // Same alpha ramp:
3891
+ // ageSec ≤ 30 → 1.0 (fully fresh)
3892
+ // 30-300s → smooth decay 1.0 → 0.25
3893
+ // > 300s → 0.25 stale floor
3894
+ // Hot tail (amber " · N hot" R129) is independent
3895
+ // of recency and keeps its own color — recency
3896
+ // tints the head; volume colors the tail.
3897
+ const recentMs = flowLinks.reduce<number | null>((acc, l) => {
3898
+ if (!l.last_at) return acc;
3899
+ const t = Date.parse(l.last_at);
3900
+ if (Number.isNaN(t)) return acc;
3901
+ return acc === null || t > acc ? t : acc;
3902
+ }, null);
3903
+ const ageSec = recentMs !== null
3904
+ ? Math.max(0, (Date.now() - recentMs) / 1000)
3905
+ : 999;
3906
+ const alpha = ageSec <= 30
3907
+ ? 1
3908
+ : ageSec <= 300
3909
+ ? 1 - ((ageSec - 30) / 270) * 0.75
3910
+ : 0.25;
3911
+ // Dark cyan-400 / light teal-600 with alpha — same
3912
+ // palette as R161's chip bullet so the two scopes
3913
+ // visually align even side-by-side.
3914
+ const freshFill = isLight
3915
+ ? `rgba(13, 148, 136, ${alpha.toFixed(2)})`
3916
+ : `rgba(34, 211, 238, ${alpha.toFixed(2)})`;
3917
+ return (
3918
+ <text
3919
+ x="217" y="21"
3920
+ textAnchor="end"
3921
+ fontSize="10"
3922
+ fontFamily="monospace"
3923
+ >
3924
+ <tspan
3925
+ fill={freshFill}
3926
+ data-recent-panel-count
3927
+ data-recent-panel-count-freshness-alpha={alpha.toFixed(2)}
3928
+ style={{ transition: 'fill 200ms ease-out' }}
3929
+ >{flowLinks.length} flows</tspan>
3930
+ {hotFlowCount > 0 && (
3931
+ <tspan
3932
+ fill={hotStroke}
3933
+ fontWeight="700"
3934
+ data-recent-panel-hot-count={hotFlowCount}
3935
+ >
3936
+ {' · '}{hotFlowCount} hot
3937
+ </tspan>
3938
+ )}
3939
+ </text>
3940
+ );
3941
+ })()}
3942
+ {/* Round 45 / Loop: empty state. The panel used to render
3943
+ "recent signal" + "0 msgs" with three blank slots below
3944
+ when no flow yet — read as "broken" rather than "quiet".
3945
+ A muted centred placeholder makes the empty state
3946
+ deliberate. Messages count CAN diverge from flowLinks
3947
+ count (raw count vs. deduped pairs), so the placeholder
3948
+ fires on flowLinks.length=0 specifically. */}
3949
+ {flowLinks.length === 0 ? (
3950
+ /* R45 placeholder. R110: add a sub-text hint so the
3951
+ empty state explains what it's empty OF — operators
3952
+ looking at "no flow yet" sometimes mistook it for
3953
+ a connection error. The sub-line reads as a quiet
3954
+ invitation, not an error. Two-line layout uses
3955
+ standard SVG <text>+<text> rather than tspan so the
3956
+ y-coordinates are explicit and the data-attr selector
3957
+ still finds the primary line. */
3958
+ <>
3959
+ <text x="115" y="54" textAnchor="middle" fill={pal.legendText} fontSize="10" fontFamily="monospace" fontStyle="italic" opacity={0.65} data-recent-signal-empty>
3960
+ no flow yet
3961
+ </text>
3962
+ <text x="115" y="68" textAnchor="middle" fill={pal.legendText} fontSize="8" fontFamily="monospace" opacity={0.45} data-recent-signal-empty-hint>
3963
+ send a message between agents
3964
+ </text>
3965
+ </>
3966
+ ) : (
3967
+ // Round 56 / Loop: each row is a navigator into the canvas.
3968
+ // Hover a row → set hoveredEdgeKey, which the existing R50
3969
+ // edge-focus + R49 endpoint-highlight ladders consume. The
3970
+ // matching flow edge brightens to 2× + thickens, its two
3971
+ // endpoint nodes stay full opacity, and every other edge +
3972
+ // non-endpoint node dims. Released → all restore. Wrapping
3973
+ // <g> + a transparent 218×14 hitbox so the cursor doesn't
3974
+ // have to land precisely on the truncated text.
3975
+ flowLinks.slice(0, 3).map((link, index) => {
3976
+ // Round 94 / Loop: per-row relative timestamp. The chip
3977
+ // row shows "last 2s" for the most recent flow overall,
3978
+ // but a user scanning the recent-signal panel had no way
3979
+ // to tell whether row 2 was 5s old or 5m old without
3980
+ // hovering nodes. Compact `2s` / `1m` glyph at the
3981
+ // right edge — same relativeAgo helper R42 uses for
3982
+ // the chip — pulls double duty as a recency anchor and
3983
+ // a sortedness hint (top row is freshest by construction).
3984
+ // Strip the " ago" suffix — at fontSize=8 in a 32-px
3985
+ // right-edge slot, every char counts. "30s ago" → "30s".
3986
+ const rawAt = link.last_at ? relativeAgo(link.last_at) : null;
3987
+ const lastAt = rawAt ? rawAt.replace(/\s+ago$/, '') : null;
3988
+ const isRowHovered = hoveredEdgeKey === link.key;
3989
+ const isRowPinned = pinnedEdgeKey === link.key;
3990
+ const isRowActive = isRowHovered || isRowPinned;
3991
+ // R127: panel-side mirror of R126's canvas hot-badge.
3992
+ // The recent-signal row text packs `alias→alias / N /
3993
+ // preview` into one line, with N rendered identically
3994
+ // regardless of magnitude. Now that the canvas badge
3995
+ // tells the user "≥ 10 msgs = hot lane" via amber
3996
+ // stroke, the panel row needs the same affordance so
3997
+ // the user reading the list at a glance can spot hot
3998
+ // lanes without crossing to the canvas. Renders the
3999
+ // count digit in amber + 700-weight when isHot; the
4000
+ // surrounding alias text + separators stay in the
4001
+ // existing legendText/legendHeadline palette. Reuses
4002
+ // R126's hotStroke colour for visual consistency.
4003
+ const isHot = link.count >= 10;
4004
+ const hotStroke = isLight ? '#d97706' : '#fbbf24';
4005
+ return (
4006
+ <g
4007
+ key={link.key}
4008
+ data-recent-row={link.key}
4009
+ data-recent-row-hovered={isRowHovered ? 'true' : 'false'}
4010
+ data-recent-row-pinned={isRowPinned ? 'true' : 'false'}
4011
+ data-recent-row-hot={isHot ? 'true' : 'false'}
4012
+ data-recent-row-lifted={(isRowHovered || isRowPinned) ? 'true' : 'false'}
4013
+ className="anet-topo-svg-focus"
4014
+ role="button"
4015
+ tabIndex={0}
4016
+ aria-pressed={isRowPinned}
4017
+ // R143 / Loop: extend the R135/R142 "interactive surface
4018
+ // elevates" idiom down one layer to the recent-signal
4019
+ // panel rows. R104 already tints the row background on
4020
+ // hover; R143 adds a 1-px translate so the row text
4021
+ // visually lifts off the panel — same vocabulary R51
4022
+ // uses for nodes, R135 uses for panels, R142 uses for
4023
+ // group boxes. Pinned rows lift too (sticky state
4024
+ // should look like locked-in selection). Reduced-motion
4025
+ // safe via prefers-reduced-motion blanket override
4026
+ // applied to transition-duration in globals.css.
4027
+ style={{
4028
+ cursor: 'pointer',
4029
+ transform: (isRowHovered || isRowPinned) ? 'translateY(-1px)' : undefined,
4030
+ transition: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)',
4031
+ }}
4032
+ onPointerDown={(e) => e.stopPropagation()}
4033
+ onMouseEnter={() => setHoveredEdgeKey(link.key)}
4034
+ onMouseLeave={() => setHoveredEdgeKey(prev => prev === link.key ? null : prev)}
4035
+ // R116: click toggles pin. activeEdgeKey =
4036
+ // hoveredEdgeKey ?? pinnedEdgeKey so the matching
4037
+ // edge stays "hot" after mouseleave; click again
4038
+ // (or Esc) releases.
4039
+ onClick={() => setPinnedEdgeKey(prev => prev === link.key ? null : link.key)}
4040
+ onKeyDown={(e) => {
4041
+ if (e.key === 'Enter' || e.key === ' ') {
4042
+ e.preventDefault();
4043
+ setPinnedEdgeKey(prev => prev === link.key ? null : link.key);
4044
+ }
4045
+ }}
4046
+ >
4047
+ {/* R148 / Loop: row tooltip with full message context.
4048
+ The row text truncates aliases to 6 chars (R127)
4049
+ and content to 8 chars — useful for scan-density
4050
+ but obscures the underlying message. A native SVG
4051
+ <title> reveals the full alias / content /
4052
+ timestamp on hover. Pinned vs unpinned switches
4053
+ the click hint so the user knows the next
4054
+ gesture's effect. R98 enriched the node tooltip
4055
+ the same way for the source/destination scope;
4056
+ R148 brings the per-row equivalent to the panel
4057
+ side. */}
4058
+ <title>{[
4059
+ `${link.from} → ${link.to} · ${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ' (hot lane · ≥ 10)' : ''}`,
4060
+ link.last_at ? `last: ${new Date(link.last_at).toLocaleString()}` : null,
4061
+ link.content ? `"${link.content}"` : null,
4062
+ isRowPinned ? 'click to release pin (Esc to clear)' : 'click to pin · hover to preview',
4063
+ ].filter(Boolean).join('\n')}</title>
4064
+ {/* R104: subtle row-background tint on hover. R56
4065
+ already brightens the matching edge on the canvas,
4066
+ but the panel row itself stayed flat — felt more
4067
+ like text-with-handlers than a navigable list.
4068
+ Filling the rect at hover with `pal.legendAccent`
4069
+ at low alpha gives the row visual feedback at the
4070
+ source surface, mirroring the list-item idiom from
4071
+ the chip-row pills. R116: pinned rows tint
4072
+ stronger than hovered ones so locked vs preview
4073
+ is discriminable. */}
4074
+ <rect
4075
+ x="6" y={38 + index * 16 - 10}
4076
+ width="218" height="14" rx="3"
4077
+ fill={isRowActive ? pal.legendAccent : 'transparent'}
4078
+ opacity={isRowPinned ? (isLight ? 0.18 : 0.22)
4079
+ : isRowHovered ? (isLight ? 0.10 : 0.14)
4080
+ : 1}
4081
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
4082
+ />
4083
+ {/* Round 160 / Loop: recency pip. Canvas flow edges
4084
+ fade by freshness (R10: full intensity ≤30s →
4085
+ ~35% over 5min). The recent-signal panel rows
4086
+ duplicate that data (alias→alias · N · 5s)
4087
+ but encode freshness purely in text — no
4088
+ at-a-glance visual cue for "which row is
4089
+ actively firing right now". A 1.6-px cyan dot
4090
+ at x=10 (in the 7-px margin between rect-
4091
+ start x=6 and text-start x=13) brightens
4092
+ fresh rows and dims stale ones — same
4093
+ vocabulary the canvas uses, brought to the
4094
+ panel side.
4095
+
4096
+ Three encodings now coexist on each row,
4097
+ none competing:
4098
+ rect fill = hover/pin state (R104/R116)
4099
+ count tspan = magnitude (R127 amber when ≥10)
4100
+ pip = recency (this round)
4101
+
4102
+ Geometry: cy = row_y - 3 (mid-row vertical
4103
+ centre, where text baseline at y=row_y sits
4104
+ slightly below). r=1.6 fits cleanly in the
4105
+ 7-px left margin. pointerEvents:none so the
4106
+ row's button-role hit area is unchanged.
4107
+ No overlap-test impact (entirely within the
4108
+ existing rect bbox). */}
4109
+ {(() => {
4110
+ if (!link.last_at) return null;
4111
+ const ageSec = Math.max(0, (Date.now() - Date.parse(link.last_at)) / 1000);
4112
+ // 0-30s: fully fresh (1.0). 30-300s: smooth
4113
+ // decay 1→0.25. >300s: stale floor (0.25).
4114
+ const alpha = ageSec <= 30
4115
+ ? 1
4116
+ : ageSec <= 300
4117
+ ? 1 - ((ageSec - 30) / 270) * 0.75
4118
+ : 0.25;
4119
+ return (
4120
+ <circle
4121
+ cx={10}
4122
+ cy={38 + index * 16 - 3}
4123
+ r={1.6}
4124
+ fill={pal.legendAccent}
4125
+ opacity={alpha}
4126
+ data-recent-row-freshness={link.key}
4127
+ data-recent-row-freshness-alpha={alpha.toFixed(2)}
4128
+ style={{ pointerEvents: 'none', transition: 'opacity 200ms ease-out' }}
4129
+ />
4130
+ );
4131
+ })()}
4132
+ <text
4133
+ x="13" y={38 + index * 16}
4134
+ fill={isRowActive ? pal.legendHeadline : pal.legendText}
4135
+ fontSize="9"
4136
+ fontFamily="monospace"
4137
+ style={{ transition: 'fill 150ms ease-out' }}
4138
+ >
4139
+ {/* R138 / Loop: typography unification with the rest
4140
+ of the topology UI. Filter pills (R119) render
4141
+ "{from}→{to}", node tooltips (R98) use →, the
4142
+ active-links chip tooltip (R114) and edge-badge
4143
+ titles all use unicode →. The recent-signal row
4144
+ was the lone holdout still rendering "from -> to"
4145
+ in ASCII. The data delimiter likewise: filter
4146
+ pills use " · " ("status · 3"); the row was using
4147
+ " / ". Both swaps make the row read like every
4148
+ other surface — one less micro-style to remember. */}
4149
+ {truncate(link.from, 6)} {'→'} {truncate(link.to, 6)} {' · '}
4150
+ {isHot ? (
4151
+ <tspan fill={hotStroke} fontWeight="700" data-recent-row-count-hot>
4152
+ {link.count}
4153
+ </tspan>
4154
+ ) : (
4155
+ <tspan data-recent-row-count>{link.count}</tspan>
4156
+ )}
4157
+ {' · '}{truncate(link.content, 8)}
4158
+ </text>
4159
+ {lastAt ? (
4160
+ <text
4161
+ x="217" y={38 + index * 16}
4162
+ textAnchor="end"
4163
+ fill={pal.legendText}
4164
+ fontSize="8"
4165
+ fontFamily="monospace"
4166
+ opacity={0.55}
4167
+ data-recent-row-ts={link.key}
4168
+ style={{ pointerEvents: 'none' }}
4169
+ >
4170
+ {lastAt}
4171
+ </text>
4172
+ ) : null}
4173
+ </g>
4174
+ );
4175
+ })
4176
+ )}
4177
+ {/* Round 128 / Loop: overflow hint. The recent-signal panel
4178
+ renders the top 3 flowLinks via .slice(0, 3) — but a
4179
+ fleet with 5 or 10 active flows silently truncates the
4180
+ rest. The R96 "X flows" header tells the total but
4181
+ doesn't say "you're seeing top-3". This hint fires
4182
+ only when flowLinks.length > 3 so quiet fleets stay
4183
+ clean. Footer y=82 sits in the 10-px gap between
4184
+ row 3 (baseline 70) and the panel bottom (84), so
4185
+ overlap-test geometry is unchanged. fontStyle=italic
4186
+ + opacity 0.55 reads as muted metadata, not an
4187
+ actionable row — matches the R110 empty-state hint
4188
+ idiom. */}
4189
+ {flowLinks.length > 3 && (() => {
4190
+ const moreCount = flowLinks.length - 3;
4191
+ const label = `+ ${moreCount} more flow${moreCount === 1 ? '' : 's'}`;
4192
+ // R133: the truncation hint becomes a clickable nav to
4193
+ // /messages. R128 introduced the footer as pure metadata
4194
+ // ("you're seeing top-3"); R133 closes the gap by giving
4195
+ // users a way to ACT on that info — see the full list.
4196
+ // Wrap in <g> with onClick so SVG hit-testing fires the
4197
+ // route push. cursor:pointer + the underline-on-hover
4198
+ // visual cue tells users this is interactive. The hover
4199
+ // state is React-controlled (no CSS :hover on SVG <g>
4200
+ // descendant text would feel reliable across Chrome's
4201
+ // SVG quirks). pointerEvents:all so the text receives
4202
+ // the click; onPointerDown stop-propagation so the
4203
+ // R103 zoom/pan capture doesn't intercept first.
4204
+ return (
4205
+ <g
4206
+ data-recent-panel-more-nav
4207
+ role="link"
4208
+ tabIndex={0}
4209
+ className="anet-topo-svg-focus"
4210
+ style={{ cursor: 'pointer', pointerEvents: 'all' }}
4211
+ onPointerDown={(e) => e.stopPropagation()}
4212
+ onMouseEnter={() => setHoveredRecentMore(true)}
4213
+ onMouseLeave={() => setHoveredRecentMore(false)}
4214
+ onClick={() => router.push('/messages')}
4215
+ onKeyDown={(e) => {
4216
+ if (e.key === 'Enter' || e.key === ' ') {
4217
+ e.preventDefault();
4218
+ router.push('/messages');
4219
+ }
4220
+ }}
4221
+ >
4222
+ <title>{`${label} — open /messages for the full list`}</title>
4223
+ <text
4224
+ x="115" y="82"
4225
+ textAnchor="middle"
4226
+ fill={pal.legendText}
4227
+ fontSize="8"
4228
+ fontFamily="monospace"
4229
+ fontStyle="italic"
4230
+ opacity={hoveredRecentMore ? 0.85 : 0.55}
4231
+ textDecoration={hoveredRecentMore ? 'underline' : 'none'}
4232
+ data-recent-panel-more={moreCount}
4233
+ style={{ transition: 'opacity 150ms ease-out' }}
4234
+ >
4235
+ {label}
4236
+ </text>
4237
+ </g>
4238
+ );
4239
+ })()}
1369
4240
  </g>
1370
4241
 
1371
- {/* legend */}
1372
- <g transform="translate(760, 16)">
1373
- <rect x="0" y="0" width="224" height="96" rx="10" fill={pal.legendBox.fill} stroke={pal.legendBox.stroke} opacity={isLight ? 0.97 : 0.92} />
1374
- <circle cx="16" cy="24" r="5.5" fill={isLight ? '#059669' : '#22c55e'} />
1375
- <text x="30" y="28" fill={pal.legendText} fontSize="11" fontFamily="monospace">working node</text>
1376
- <circle cx="16" cy="48" r="5.5" fill={isLight ? '#0d9488' : '#2dd4bf'} />
1377
- <text x="30" y="52" fill={pal.legendText} fontSize="11" fontFamily="monospace">online idle</text>
1378
- <circle cx="16" cy="72" r="5.5" fill={isLight ? '#94a3b8' : '#6b7280'} />
1379
- <text x="30" y="76" fill={pal.legendText} fontSize="11" fontFamily="monospace">offline / no SSE</text>
1380
- <path d="M140,72 Q164,48 196,72" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" />
4242
+ {/* legend — Round 55 / Loop: each status row is now a hover
4243
+ target. Pointer enter sets `hoveredStatus`; pointer leave
4244
+ clears it. Node opacity formula composes the match below.
4245
+ The row text brightens to legendHeadline while hovered as
4246
+ a small affordance hint. Geometry unchanged — the new
4247
+ <g> wrappers only carry pointer handlers. */}
4248
+ <g
4249
+ transform="translate(760, 16)"
4250
+ data-topo-panel="legend"
4251
+ data-topo-panel-hovered={hoveredPanel === 'legend' ? 'true' : 'false'}
4252
+ onMouseEnter={() => setHoveredPanel('legend')}
4253
+ onMouseLeave={() => setHoveredPanel(prev => prev === 'legend' ? null : prev)}
4254
+ >
4255
+ {/* R57: matching drop-shadow elevation to the legend panel.
4256
+ R106: panel height grew 96 → 104 to seat the new header
4257
+ line + 4 px row-shift below it (so the new header text
4258
+ doesn't overlap the row-1 hitbox region).
4259
+ R135: hover-elevation mirrors the recent-signal panel
4260
+ rect at line ~3299. Both panels grow their shadow on
4261
+ hover to telegraph "the chrome is interactive" since
4262
+ their rows pin / nav. */}
4263
+ <rect
4264
+ x="0" y="0" width="224" height="104" rx="10"
4265
+ fill={pal.legendBox.fill}
4266
+ stroke={pal.legendBox.stroke}
4267
+ opacity={isLight ? 0.97 : 0.92}
4268
+ style={{
4269
+ filter: hoveredPanel === 'legend'
4270
+ ? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
4271
+ : 'drop-shadow(0 4px 12px rgba(0,0,0,0.65))')
4272
+ : (isLight ? 'drop-shadow(0 2px 6px rgba(15,23,42,0.08))'
4273
+ : 'drop-shadow(0 2px 6px rgba(0,0,0,0.45))'),
4274
+ transition: 'filter 200ms ease-out',
4275
+ }}
4276
+ data-topo-panel-elevation="legend"
4277
+ />
4278
+ {/* R106 / Loop: panel header — symmetric with the recent-
4279
+ signal panel's "recent signal · N flows" (R96). Same
4280
+ font + position vocabulary so the two side panels feel
4281
+ paired. Title text at x=13 y=21; total fleet count
4282
+ right-aligned at x=215 y=21 in the accent colour. */}
4283
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700">legend</text>
4284
+ <text x="215" y="21" textAnchor="end" fill={pal.legendAccent} fontSize="10" fontFamily="monospace" data-legend-panel-count>{sessions.length} node{sessions.length === 1 ? '' : 's'}</text>
4285
+ {(() => {
4286
+ const idleCount = onlineNodes.length - workingCount;
4287
+ // R106: rows shift +8 px (was y0=24, 48, 72 → 32, 56, 80)
4288
+ // to clear the new header row. R57 panel rect grew 96 →
4289
+ // 104 to seat them.
4290
+ const rows = [
4291
+ { key: 'working' as const, y0: 32, y1: 36, fill: isLight ? '#059669' : '#22c55e', label: 'working node', count: workingCount },
4292
+ { key: 'idle' as const, y0: 56, y1: 60, fill: isLight ? '#0d9488' : '#2dd4bf', label: 'online idle', count: idleCount },
4293
+ { key: 'offline' as const, y0: 80, y1: 84, fill: isLight ? '#94a3b8' : '#6b7280', label: 'offline / no SSE', count: offlineNodes.length },
4294
+ ];
4295
+ return rows;
4296
+ })().map(row => {
4297
+ // Round 61 / Loop: legend rows pin too — symmetric with the
4298
+ // R60 pressure-bar segments. R55 hover stays transient; the
4299
+ // new onClick toggles pinnedStatus so users can lock a
4300
+ // filter without holding the cursor still. Pinned row gets
4301
+ // an inset ring on the swatch (same vocab as R60).
4302
+ const isPinned = pinnedStatus === row.key;
4303
+ const isRowHovered = hoveredStatus === row.key;
4304
+ const isLifted = isRowHovered || isPinned;
4305
+ return (
4306
+ <g
4307
+ key={row.key}
4308
+ data-legend-status={row.key}
4309
+ data-legend-row-lifted={isLifted ? 'true' : 'false'}
4310
+ className="anet-topo-svg-focus"
4311
+ role="button"
4312
+ tabIndex={0}
4313
+ aria-pressed={isPinned}
4314
+ // R144: mirror of R143 — extend the row-lift idiom to the
4315
+ // legend panel rows for symmetry with the recent-signal
4316
+ // panel rows. Same translate-1px transform, same 150ms
4317
+ // cubic-bezier timing. R55 hovers the row (transient),
4318
+ // R61 pins it (sticky); both states earn the lift.
4319
+ // Composes with R105 row-bg-tint and R135 panel hover-
4320
+ // shadow → three layers of feedback at three nested
4321
+ // scopes (panel chrome → row text lift → bg tint).
4322
+ style={{
4323
+ cursor: 'pointer',
4324
+ transform: isLifted ? 'translateY(-1px)' : undefined,
4325
+ transition: 'transform 150ms cubic-bezier(0.4, 0, 0.2, 1)',
4326
+ }}
4327
+ // R61: stopPropagation on pointerdown so the SVG-level
4328
+ // pan handler (R103) doesn't setPointerCapture and
4329
+ // redirect the follow-up click away from this <g>.
4330
+ // Same trick the node <g> uses (and R52 hub).
4331
+ onPointerDown={(e) => e.stopPropagation()}
4332
+ onMouseEnter={() => setHoveredStatus(row.key)}
4333
+ onMouseLeave={() => setHoveredStatus(prev => prev === row.key ? null : prev)}
4334
+ onClick={() => setPinnedStatus(prev => prev === row.key ? null : row.key)}
4335
+ >
4336
+ {/* R149 / Loop: legend row gains a <title> tooltip
4337
+ symmetric with R148's recent-signal row tooltip.
4338
+ Same R97 idiom — anywhere the UI shows "N" should
4339
+ hover-explain WHICH N — applied to the legend
4340
+ panel which had been showing only the bucket
4341
+ count without alias context. Header line names
4342
+ the status bucket; body lists the matched aliases
4343
+ with 8-truncate + "+N more"; footer hint flips
4344
+ with pin state. Hot-lane convention doesn't apply
4345
+ here (status buckets aren't traffic counts), so
4346
+ no isHot suffix. */}
4347
+ <title>{(() => {
4348
+ const aliases = row.key === 'working'
4349
+ ? onlineNodes.filter(s => s.status === 'working').map(s => s.alias)
4350
+ : row.key === 'idle'
4351
+ ? onlineNodes.filter(s => s.status !== 'working').map(s => s.alias)
4352
+ : offlineNodes.map(s => s.alias);
4353
+ const preview = aliases.slice(0, 8).join(', ');
4354
+ const suffix = aliases.length > 8 ? ` + ${aliases.length - 8} more` : '';
4355
+ return [
4356
+ `${row.label} · ${row.count}`,
4357
+ aliases.length > 0 ? `${preview}${suffix}` : null,
4358
+ isPinned ? 'click to release pin (Esc to clear)' : 'click to pin · hover to preview',
4359
+ ].filter(Boolean).join('\n');
4360
+ })()}</title>
4361
+ {/* R55 hitbox covers the row so cursor doesn't need to
4362
+ be exactly on the 5-px swatch. R105 / Loop: the
4363
+ hitbox now also carries a subtle hover/pin tint —
4364
+ mirroring R104's recent-signal row treatment so
4365
+ both side panels share the list-item idiom. The
4366
+ tint borrows the ROW'S OWN swatch colour rather
4367
+ than legendAccent, so the user's eye associates
4368
+ the tinted row with its colour swatch instead of
4369
+ a generic accent. Pin is a stronger tint than
4370
+ hover; idle stays fully transparent. */}
4371
+ <rect
4372
+ x="6" y={row.y0 - 12}
4373
+ width="170" height="22" rx="3"
4374
+ fill={hoveredStatus === row.key || isPinned ? row.fill : 'transparent'}
4375
+ opacity={isPinned ? (isLight ? 0.14 : 0.18)
4376
+ : hoveredStatus === row.key ? (isLight ? 0.08 : 0.12)
4377
+ : 1}
4378
+ data-legend-row-tinted={isPinned ? 'pinned' : hoveredStatus === row.key ? 'hover' : 'none'}
4379
+ style={{ transition: 'fill 150ms ease-out, opacity 150ms ease-out' }}
4380
+ />
4381
+ <circle cx="16" cy={row.y0} r="5.5" fill={row.fill} />
4382
+ {/* R61 pinned-state ring — concentric stroke at r=8 in
4383
+ the row colour, draws OUTSIDE the swatch so it
4384
+ doesn't fight the fill colour the user is matching. */}
4385
+ {isPinned && (
4386
+ <circle cx="16" cy={row.y0} r="8" fill="none" stroke={row.fill} strokeWidth="1.5" />
4387
+ )}
4388
+ <text
4389
+ x="30" y={row.y1}
4390
+ fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
4391
+ fontSize="11"
4392
+ fontFamily="monospace"
4393
+ style={{ transition: 'fill 150ms ease-out' }}
4394
+ >{row.label}</text>
4395
+ {/* R95: live count anchored to the right edge of the
4396
+ panel (x=215, after the flow-arrow swatch). Same
4397
+ counts the chip-row shows ("3 working" etc.) but
4398
+ here next to the swatch the user is matching —
4399
+ saves crossing the canvas to the chip row for
4400
+ the number. text-anchor=end aligns the column
4401
+ visually like a table. pointerEvents:none so the
4402
+ count doesn't intercept the row hover hitbox. */}
4403
+ <text
4404
+ x="215" y={row.y1}
4405
+ textAnchor="end"
4406
+ fill={pal.legendText}
4407
+ fontSize="11"
4408
+ fontFamily="monospace"
4409
+ opacity={hoveredStatus === row.key || isPinned ? 0.95 : 0.65}
4410
+ data-legend-count={row.key}
4411
+ style={{ pointerEvents: 'none', transition: 'opacity 150ms ease-out' }}
4412
+ >{row.count}</text>
4413
+ </g>
4414
+ );
4415
+ })}
4416
+ {/* Flow-arrow swatch tracks the offline row — R106 shifted
4417
+ rows down by 8 px to make space for the panel header so
4418
+ this moves from y=72 to y=80. Drop its pointerEvents so
4419
+ the offline legend row stays hoverable (R55). It's
4420
+ decoration, no need to receive events. */}
4421
+ <path d="M140,80 Q164,56 196,80" fill="none" stroke={pal.flowEdge} strokeWidth="3" markerEnd="url(#topo-arrow)" style={{ pointerEvents: 'none' }} />
1381
4422
  </g>
1382
4423
  </svg>
1383
4424
 
4425
+ {/* Round 30 / Loop: minimap. Big fleets in fullscreen mode at high
4426
+ zoom let users lose their position — the minimap shows the
4427
+ whole topology miniaturised plus a viewport rectangle so the
4428
+ user always knows where they are. Click anywhere to recenter
4429
+ the canvas there. Only mounted when the view is non-default
4430
+ (zoomed or panned) since at 1× centered the minimap and the
4431
+ canvas show the same thing. HTML overlay so it stays fixed
4432
+ while the SVG transforms. */}
4433
+ {(() => {
4434
+ const isDefaultView = Math.abs(view.zoom - 1) < 0.01 && Math.abs(view.x) < 1 && Math.abs(view.y) < 1;
4435
+ if (isDefaultView || (onlineNodes.length + offlineNodes.length) === 0) return null;
4436
+ const MW = 120, MH = 82;
4437
+ const sx = MW / VIEWBOX_W, sy = MH / VIEWBOX_H;
4438
+ const rectX = (-view.x / view.zoom) * sx;
4439
+ const rectY = (-view.y / view.zoom) * sy;
4440
+ const rectW = (VIEWBOX_W / view.zoom) * sx;
4441
+ const rectH = (VIEWBOX_H / view.zoom) * sy;
4442
+ return (
4443
+ <div
4444
+ className="absolute right-3 rounded-md border shadow-lg shadow-black/30 overflow-hidden anet-fade-in anet-topo-chip-focus"
4445
+ style={{ bottom: 56, background: pal.legendBox.fill, borderColor: pal.containerBorder, cursor: 'crosshair', color: pal.legendAccent }}
4446
+ // R157: minimap a11y completion. Pre-R157 the element had
4447
+ // role="img" + aria-label but no tabIndex / onKeyDown — it
4448
+ // was clickable for mouse users (recenter to where you
4449
+ // clicked) but tab-unreachable. role="img" was also wrong
4450
+ // for an interactive surface; role="button" matches the
4451
+ // canonical pattern R116 / R139 / R140 / R151 / R152 use.
4452
+ // Keyboard activation can't compute a click position, so
4453
+ // Enter / Space falls back to resetView() — same gesture
4454
+ // as the dedicated reset button (R104). Click semantics
4455
+ // unchanged; only added a clarifying tail to the aria-
4456
+ // label + title. anet-topo-chip-focus picks up R155's
4457
+ // cyan outline via color: pal.legendAccent inline so the
4458
+ // currentColor inherits cleanly on the rounded card.
4459
+ role="button"
4460
+ tabIndex={0}
4461
+ aria-label="Topology minimap — click to recenter, Enter to reset view"
4462
+ title="Minimap · click to recenter · Enter to reset view"
4463
+ onClick={(e) => {
4464
+ const r = e.currentTarget.getBoundingClientRect();
4465
+ const fx = (e.clientX - r.left) / r.width;
4466
+ const fy = (e.clientY - r.top) / r.height;
4467
+ setView(prev => ({
4468
+ ...prev,
4469
+ x: VIEWBOX_W / 2 - fx * VIEWBOX_W * prev.zoom,
4470
+ y: VIEWBOX_H / 2 - fy * VIEWBOX_H * prev.zoom,
4471
+ }));
4472
+ }}
4473
+ onKeyDown={(e) => {
4474
+ if (e.key === 'Enter' || e.key === ' ') {
4475
+ e.preventDefault();
4476
+ resetView();
4477
+ }
4478
+ }}
4479
+ data-topo-minimap
4480
+ >
4481
+ <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
4482
+ {[...onlineNodes, ...offlineNodes].map(s => {
4483
+ const p = nodePositions[s.alias];
4484
+ if (!p) return null;
4485
+ const sseN = (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
4486
+ const isOn = s.status !== 'offline' || !!sseN;
4487
+ const st = nodeStatus(s, isOn, isLight);
4488
+ return <circle key={s.alias} cx={p.x * sx} cy={p.y * sy} r={isOn ? 1.7 : 1.2} fill={st.primary} opacity={isOn ? 0.9 : 0.5} />;
4489
+ })}
4490
+ {/* viewport rectangle */}
4491
+ <rect
4492
+ x={Math.max(0, rectX)} y={Math.max(0, rectY)}
4493
+ width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
4494
+ height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
4495
+ fill="none" stroke={pal.legendAccent} strokeWidth="1" opacity="0.9"
4496
+ />
4497
+ </svg>
4498
+ </div>
4499
+ );
4500
+ })()}
1384
4501
  {/* Round 103 (issue #81): zoom / pan / fullscreen controls — HTML
1385
4502
  overlay so they stay fixed while the SVG content transforms.
1386
4503
  Round 104: Vincent 实测 — the reset action used to be hidden
1387
4504
  behind the "%" label (looked like an indicator, not a button).
1388
4505
  Split into a plain % readout + an explicit reset button with
1389
4506
  its own icon + tooltip. */}
1390
- <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none">
1391
- {/* #113: node size — S / M / L segmented control (Vincent 4727). */}
4507
+ <div className="absolute bottom-3 right-3 flex items-center gap-1.5 text-xs select-none" data-topo-chrome>
4508
+ {/* #113: node size — S / M / L segmented control (Vincent 4727).
4509
+ R154: stable data-* hooks for tests + focus-visible ring so
4510
+ keyboard navigation lands somewhere visible against the
4511
+ dark canvas (browser default outline often vanishes on
4512
+ cyber theme). */}
1392
4513
  <div
1393
4514
  className="flex items-center rounded-md border overflow-hidden"
1394
4515
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder }}
@@ -1400,8 +4521,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1400
4521
  key={lbl}
1401
4522
  onClick={() => pickNodeScale(v)}
1402
4523
  aria-pressed={nodeScale === v}
4524
+ data-topo-chrome-nodesize={lbl}
4525
+ data-topo-chrome-nodesize-active={nodeScale === v ? 'true' : 'false'}
1403
4526
  title={`Node size: ${lbl === 'S' ? 'small' : lbl === 'M' ? 'medium' : 'large'}`}
1404
- className={`px-2 py-1 transition-colors ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
4527
+ className={`px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300' : 'hover:bg-white/5'}`}
1405
4528
  style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
1406
4529
  >
1407
4530
  {lbl}
@@ -1414,14 +4537,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1414
4537
  >
1415
4538
  <button
1416
4539
  onClick={() => zoomBy(1 / 1.2)}
1417
- className="px-2 py-1 hover:bg-white/5 transition-colors"
4540
+ data-topo-chrome-zoom-out
4541
+ className="px-2 py-1 hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
1418
4542
  style={{ color: pal.legendText }}
1419
4543
  aria-label="Zoom out"
4544
+ title="Zoom out (−)"
1420
4545
  >
1421
4546
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M5 12h14" /></svg>
1422
4547
  </button>
1423
4548
  <span
1424
4549
  className="px-2 py-1 tabular-nums border-x text-center"
4550
+ data-topo-chrome-zoom-level
1425
4551
  style={{ color: pal.legendText, borderColor: pal.containerBorder, minWidth: 46 }}
1426
4552
  title="Current zoom level"
1427
4553
  >
@@ -1429,19 +4555,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1429
4555
  </span>
1430
4556
  <button
1431
4557
  onClick={() => zoomBy(1.2)}
1432
- className="px-2 py-1 hover:bg-white/5 transition-colors"
4558
+ data-topo-chrome-zoom-in
4559
+ className="px-2 py-1 hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
1433
4560
  style={{ color: pal.legendText }}
1434
4561
  aria-label="Zoom in"
4562
+ title="Zoom in (+)"
1435
4563
  >
1436
4564
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden><path d="M12 5v14M5 12h14" /></svg>
1437
4565
  </button>
1438
4566
  </div>
1439
4567
  <button
1440
4568
  onClick={resetView}
1441
- className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
4569
+ data-topo-chrome-reset
4570
+ className="p-1.5 rounded-md border hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
1442
4571
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1443
4572
  aria-label="Reset view"
1444
- title="Reset zoom + pan (or double-click the canvas)"
4573
+ title="Reset zoom + pan (0, or double-click the canvas)"
1445
4574
  >
1446
4575
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
1447
4576
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />
@@ -1450,7 +4579,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1450
4579
  </button>
1451
4580
  <button
1452
4581
  onClick={toggleFullscreen}
1453
- className="p-1.5 rounded-md border hover:bg-white/5 transition-colors"
4582
+ data-topo-chrome-fullscreen
4583
+ data-topo-chrome-fullscreen-active={isFullscreen ? 'true' : 'false'}
4584
+ className="p-1.5 rounded-md border hover:bg-white/5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
1454
4585
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
1455
4586
  aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
1456
4587
  title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}