@sleep2agi/agent-network-dashboard 0.4.6-preview.19 → 0.4.6-preview.191

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