@sleep2agi/agent-network-dashboard 0.4.6-preview.18 → 0.4.6-preview.180

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