@sleep2agi/agent-network-dashboard 0.4.6-preview.20 → 0.4.6-preview.201

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