@sleep2agi/agent-network-dashboard 0.5.1-preview.1 → 0.5.1-preview.100

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 (253) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/diagnostics/route-bundle-stats.json +6 -6
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/server/app/_global-error.html +1 -1
  6. package/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/_not-found.html +2 -2
  14. package/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  22. package/.next/server/app/admin.html +2 -2
  23. package/.next/server/app/admin.rsc +2 -2
  24. package/.next/server/app/admin.segments/_full.segment.rsc +2 -2
  25. package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
  26. package/.next/server/app/admin.segments/_index.segment.rsc +2 -2
  27. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  28. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
  29. package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
  30. package/.next/server/app/index.html +2 -2
  31. package/.next/server/app/index.rsc +3 -3
  32. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  33. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  34. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  35. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  36. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  37. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  38. package/.next/server/app/login.html +2 -2
  39. package/.next/server/app/login.rsc +3 -3
  40. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  41. package/.next/server/app/login.segments/_head.segment.rsc +1 -1
  42. package/.next/server/app/login.segments/_index.segment.rsc +2 -2
  43. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  44. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  45. package/.next/server/app/login.segments/login.segment.rsc +1 -1
  46. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  47. package/.next/server/app/logs.html +2 -2
  48. package/.next/server/app/logs.rsc +2 -2
  49. package/.next/server/app/logs.segments/_full.segment.rsc +2 -2
  50. package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
  51. package/.next/server/app/logs.segments/_index.segment.rsc +2 -2
  52. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  53. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
  54. package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
  55. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  56. package/.next/server/app/messages.html +2 -2
  57. package/.next/server/app/messages.rsc +2 -2
  58. package/.next/server/app/messages.segments/_full.segment.rsc +2 -2
  59. package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
  60. package/.next/server/app/messages.segments/_index.segment.rsc +2 -2
  61. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  62. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
  63. package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
  64. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  65. package/.next/server/app/node.html +2 -2
  66. package/.next/server/app/node.rsc +2 -2
  67. package/.next/server/app/node.segments/_full.segment.rsc +2 -2
  68. package/.next/server/app/node.segments/_head.segment.rsc +1 -1
  69. package/.next/server/app/node.segments/_index.segment.rsc +2 -2
  70. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +1 -1
  72. package/.next/server/app/node.segments/node.segment.rsc +1 -1
  73. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  74. package/.next/server/app/nodes.html +2 -2
  75. package/.next/server/app/nodes.rsc +2 -2
  76. package/.next/server/app/nodes.segments/_full.segment.rsc +2 -2
  77. package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
  78. package/.next/server/app/nodes.segments/_index.segment.rsc +2 -2
  79. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  80. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +1 -1
  81. package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
  82. package/.next/server/app/page_client-reference-manifest.js +1 -1
  83. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/server-logs.html +2 -2
  85. package/.next/server/app/server-logs.rsc +2 -2
  86. package/.next/server/app/server-logs.segments/_full.segment.rsc +2 -2
  87. package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
  88. package/.next/server/app/server-logs.segments/_index.segment.rsc +2 -2
  89. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  90. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
  91. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
  92. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app/settings/networks.html +2 -2
  94. package/.next/server/app/settings/networks.rsc +2 -2
  95. package/.next/server/app/settings/networks.segments/_full.segment.rsc +2 -2
  96. package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
  97. package/.next/server/app/settings/networks.segments/_index.segment.rsc +2 -2
  98. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  99. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
  100. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
  101. package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
  102. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  103. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  104. package/.next/server/app/settings/tokens.html +2 -2
  105. package/.next/server/app/settings/tokens.rsc +2 -2
  106. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +2 -2
  107. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
  108. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +2 -2
  109. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  110. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
  111. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
  112. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
  113. package/.next/server/app/settings.html +2 -2
  114. package/.next/server/app/settings.rsc +3 -3
  115. package/.next/server/app/settings.segments/_full.segment.rsc +3 -3
  116. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  117. package/.next/server/app/settings.segments/_index.segment.rsc +2 -2
  118. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  119. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  120. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  121. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  122. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  123. package/.next/server/app/tasks.html +2 -2
  124. package/.next/server/app/tasks.rsc +2 -2
  125. package/.next/server/app/tasks.segments/_full.segment.rsc +2 -2
  126. package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
  127. package/.next/server/app/tasks.segments/_index.segment.rsc +2 -2
  128. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
  130. package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
  131. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +3 -3
  132. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  133. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  134. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  135. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  136. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  137. package/.next/server/middleware-build-manifest.js +3 -3
  138. package/.next/server/pages/404.html +2 -2
  139. package/.next/server/pages/500.html +1 -1
  140. package/.next/static/chunks/0.pk4~tmpnb0_.js +1 -0
  141. package/.next/static/chunks/004_pmytjt5g..js +4 -0
  142. package/.next/static/chunks/017hq2-5l~_98.css +2 -0
  143. package/.next/static/chunks/0poutjg8.1a9n.js +1 -0
  144. package/.next/trace +2 -2
  145. package/.next/trace-build +1 -1
  146. package/app/components/TopoGraph.tsx +2126 -145
  147. package/package.json +1 -1
  148. package/scripts/topo-active-links-chip-hover-lift-test.mjs +93 -0
  149. package/scripts/topo-active-links-freshness-color-test.mjs +86 -0
  150. package/scripts/topo-chip-digit-fontweight-test.mjs +105 -0
  151. package/scripts/topo-chip-digit-hover-bold-test.mjs +94 -0
  152. package/scripts/topo-chip-row-group-hover-brighten-test.mjs +107 -0
  153. package/scripts/topo-chip-row-hover-lift-test.mjs +95 -0
  154. package/scripts/topo-chrome-button-hover-lift-test.mjs +94 -0
  155. package/scripts/topo-chrome-segmented-radius-test.mjs +100 -0
  156. package/scripts/topo-click-ripple-opacity-test.mjs +99 -0
  157. package/scripts/topo-edge-badge-digit-fw-test.mjs +103 -0
  158. package/scripts/topo-edge-badge-fontsize-test.mjs +90 -0
  159. package/scripts/topo-edge-badge-hover-opacity-test.mjs +94 -0
  160. package/scripts/topo-edge-badge-hover-stroke-test.mjs +92 -0
  161. package/scripts/topo-edge-badge-letter-spacing-test.mjs +86 -0
  162. package/scripts/topo-edge-badge-opacity-test.mjs +80 -0
  163. package/scripts/topo-edge-badge-pin-opacity-test.mjs +86 -0
  164. package/scripts/topo-edge-badge-stroke-test.mjs +92 -0
  165. package/scripts/topo-edge-endpoint-hover-sw-test.mjs +127 -0
  166. package/scripts/topo-edge-freshness-floor-test.mjs +99 -0
  167. package/scripts/topo-edge-particle-hover-r-test.mjs +113 -0
  168. package/scripts/topo-edge-particle-radius-test.mjs +76 -0
  169. package/scripts/topo-edge-visible-linecap-test.mjs +89 -0
  170. package/scripts/topo-filter-pill-hover-lift-test.mjs +101 -0
  171. package/scripts/topo-filter-pill-hover-opacity-test.mjs +110 -0
  172. package/scripts/topo-filter-pill-value-fw-test.mjs +88 -0
  173. package/scripts/topo-filter-pill-weight-test.mjs +75 -0
  174. package/scripts/topo-filter-pill-x-hover-scale-test.mjs +99 -0
  175. package/scripts/topo-flow-rail-hover-sw-test.mjs +113 -0
  176. package/scripts/topo-flow-rail-linecap-test.mjs +79 -0
  177. package/scripts/topo-freshness-chip-hierarchy-test.mjs +93 -0
  178. package/scripts/topo-freshness-chip-tabular-test.mjs +41 -0
  179. package/scripts/topo-freshness-floor-lift-test.mjs +92 -0
  180. package/scripts/topo-freshness-suffix-tabular-test.mjs +88 -0
  181. package/scripts/topo-fullscreen-icon-hover-scale-test.mjs +91 -0
  182. package/scripts/topo-group-box-stroke-test.mjs +105 -0
  183. package/scripts/topo-group-label-count-fontweight-test.mjs +108 -0
  184. package/scripts/topo-group-label-letter-spacing-test.mjs +113 -0
  185. package/scripts/topo-hover-detail-body-fw-test.mjs +101 -0
  186. package/scripts/topo-hover-detail-model-fw-test.mjs +98 -0
  187. package/scripts/topo-hover-detail-opacity-test.mjs +98 -0
  188. package/scripts/topo-hover-detail-rx-test.mjs +81 -0
  189. package/scripts/topo-hub-digit-fontsize-test.mjs +86 -0
  190. package/scripts/topo-hub-digit-fw-hover-test.mjs +102 -0
  191. package/scripts/topo-hub-halo-light-trough-test.mjs +88 -0
  192. package/scripts/topo-hub-halo-radius-test.mjs +86 -0
  193. package/scripts/topo-hub-halo-trough-test.mjs +83 -0
  194. package/scripts/topo-hub-highlight-opacity-test.mjs +88 -0
  195. package/scripts/topo-hub-highlight-radius-test.mjs +90 -0
  196. package/scripts/topo-hub-hover-ring-opacity-test.mjs +96 -0
  197. package/scripts/topo-hub-hover-ring-stroke-test.mjs +86 -0
  198. package/scripts/topo-hub-spoke-hover-opacity-test.mjs +119 -0
  199. package/scripts/topo-hub-spoke-hover-sw-test.mjs +121 -0
  200. package/scripts/topo-hub-spoke-linecap-test.mjs +80 -0
  201. package/scripts/topo-label-card-opacity-hover-test.mjs +99 -0
  202. package/scripts/topo-layout-toggle-hover-tracking-test.mjs +109 -0
  203. package/scripts/topo-layout-toggle-radius-test.mjs +87 -0
  204. package/scripts/topo-legend-label-fontweight-test.mjs +94 -0
  205. package/scripts/topo-legend-pin-ring-stroke-test.mjs +101 -0
  206. package/scripts/topo-legend-row-letter-spacing-test.mjs +101 -0
  207. package/scripts/topo-minimap-offline-opacity-test.mjs +90 -0
  208. package/scripts/topo-minimap-online-hover-opacity-test.mjs +92 -0
  209. package/scripts/topo-minimap-online-opacity-test.mjs +93 -0
  210. package/scripts/topo-minimap-online-radius-test.mjs +85 -0
  211. package/scripts/topo-minimap-viewport-hover-test.mjs +109 -0
  212. package/scripts/topo-minimap-viewport-linejoin-test.mjs +75 -0
  213. package/scripts/topo-minimap-viewport-rx-test.mjs +85 -0
  214. package/scripts/topo-more-flows-fontweight-test.mjs +103 -0
  215. package/scripts/topo-more-flows-hover-letterspacing-test.mjs +98 -0
  216. package/scripts/topo-node-alias-letter-spacing-test.mjs +112 -0
  217. package/scripts/topo-node-halo-offline-opacity-test.mjs +87 -0
  218. package/scripts/topo-node-label-card-rx-test.mjs +85 -0
  219. package/scripts/topo-node-pulse-peak-test.mjs +89 -0
  220. package/scripts/topo-node-pulse-trough-test.mjs +91 -0
  221. package/scripts/topo-node-sub-text-letter-spacing-test.mjs +115 -0
  222. package/scripts/topo-panel-count-fw-hover-test.mjs +105 -0
  223. package/scripts/topo-panel-count-letterspacing-test.mjs +89 -0
  224. package/scripts/topo-panel-rect-opacity-hover-test.mjs +109 -0
  225. package/scripts/topo-panel-stroke-hover-test.mjs +110 -0
  226. package/scripts/topo-panel-title-hover-letterspacing-test.mjs +97 -0
  227. package/scripts/topo-pin-intersection-unit-test.mjs +100 -0
  228. package/scripts/topo-pressure-bar-height-test.mjs +92 -0
  229. package/scripts/topo-pressure-kicker-fontweight-test.mjs +76 -0
  230. package/scripts/topo-recent-pip-radius-2-test.mjs +72 -0
  231. package/scripts/topo-recent-pip-radius-test.mjs +76 -0
  232. package/scripts/topo-recent-row-content-opacity-test.mjs +81 -0
  233. package/scripts/topo-recent-row-letter-spacing-test.mjs +109 -0
  234. package/scripts/topo-recent-row-text-fontweight-test.mjs +90 -0
  235. package/scripts/topo-reset-hover-rotate-test.mjs +102 -0
  236. package/scripts/topo-spoke-active-opacity-test.mjs +104 -0
  237. package/scripts/topo-spoke-active-stroke-test.mjs +95 -0
  238. package/scripts/topo-spoke-idle-opacity-test.mjs +91 -0
  239. package/scripts/topo-status-ring-hover-sw-test.mjs +105 -0
  240. package/scripts/topo-vendor-chip-hover-lift-test.mjs +87 -0
  241. package/scripts/topo-vendor-glyph-fontweight-test.mjs +102 -0
  242. package/scripts/topo-vendor-letter-hover-scale-test.mjs +129 -0
  243. package/scripts/topo-vendor-suffix-hover-brighten-test.mjs +87 -0
  244. package/scripts/topo-zoom-icon-hover-scale-test.mjs +114 -0
  245. package/scripts/topo-zoom-level-hover-fw-test.mjs +95 -0
  246. package/scripts/topo-zoom-level-hover-letterspacing-test.mjs +91 -0
  247. package/.next/static/chunks/0aauz~36q5n2a.css +0 -2
  248. package/.next/static/chunks/0wratil0li9xw.js +0 -4
  249. package/.next/static/chunks/10paf4uz-_0g1.js +0 -1
  250. package/.next/static/chunks/12wcm9omjb_x6.js +0 -1
  251. /package/.next/static/{4t8OH-lNFieRsHEC2eZfD → gcwnb1fGF5WXL0umdpAPw}/_buildManifest.js +0 -0
  252. /package/.next/static/{4t8OH-lNFieRsHEC2eZfD → gcwnb1fGF5WXL0umdpAPw}/_clientMiddlewareManifest.js +0 -0
  253. /package/.next/static/{4t8OH-lNFieRsHEC2eZfD → gcwnb1fGF5WXL0umdpAPw}/_ssgManifest.js +0 -0
@@ -194,7 +194,27 @@ function FreshnessChip({ sessions }: { sessions: unknown }) {
194
194
  // tier (R312-R314 family); the amber bg/text/border still does
195
195
  // the warning-state work, the weight just keeps the chip in the
196
196
  // same data-typography ladder as its siblings.
197
- const baseClass = "hidden sm:inline px-2.5 py-1 rounded-md font-mono font-medium border transition-colors duration-300";
197
+ // Round 377 / Loop: FreshnessChip baseClass picks up `tabular-nums`.
198
+ // The chip-row's last untouched chip joins the R224-R357 broader
199
+ // tabular-nums sweep:
200
+ // R224 edge badge digit
201
+ // R225 hub digit / panel header counts / recent row count
202
+ // R232 chip row counts (working / online / active-links)
203
+ // R321 recent row timestamp
204
+ // R322 recent panel hot count
205
+ // R323 filter pin pill counts
206
+ // R333 vendor count suffix
207
+ // R357 active-links freshness suffix wrapper
208
+ // R377 FreshnessChip body (this round)
209
+ // `font-mono` already gives equal-width glyphs but `tabular-nums`
210
+ // is the explicit-invariant the rest of the chip row carries.
211
+ // FreshnessChip body reads `lag · {sec}s` — the {sec} digit grows
212
+ // every second; tabular-nums explicitly locks digit width so the
213
+ // chip stays planted as the seconds counter ticks past 9 → 10 →
214
+ // 99 → 100. R187 transition-colors duration-300 + R275 stale-only
215
+ // render gate + R315 font-medium R313 family alignment all
216
+ // preserved.
217
+ const baseClass = "hidden sm:inline px-2.5 py-1 rounded-md font-mono font-medium tabular-nums border transition-colors duration-300";
198
218
  const colorClass = stale
199
219
  ? "bg-amber-500/10 text-amber-300 border-amber-500/20"
200
220
  : "bg-gray-500/10 text-gray-400 border-gray-500/20";
@@ -237,7 +257,21 @@ function FreshnessChip({ sessions }: { sessions: unknown }) {
237
257
  duration-300 still eases the bg/color flip. Title (hover
238
258
  tooltip) still spells out the full meaning in either
239
259
  state. */}
240
- {stale ? 'lag' : 'live'} · {sec}s
260
+ {/* Round 410 / Loop: FreshnessChip body picks up the chip-
261
+ internal-hierarchy arc. Pre-R410 the body rendered as a
262
+ single text node `lag · {sec}s` with the parent's font-
263
+ medium (fw=500) applied uniformly. R410 splits the digit
264
+ and unit into separate spans so the chip's internal
265
+ typography mirrors the family pattern R333-R341/R362/R369/
266
+ R389 established for the chip row:
267
+ digit (fw=600) data tier
268
+ unit (fw=500 + opacity=0.7) label tier
269
+ The `lag` prefix stays at the chip's baseline (fw=500
270
+ from parent font-medium) — it labels the state, not a
271
+ data value. data-freshness-chip-digit / -unit attrs
272
+ surface the spans for tests. tabular-nums + transition-
273
+ colors + R275 stale-only gate all preserved. */}
274
+ {stale ? 'lag' : 'live'} · <span className="font-semibold" data-freshness-chip-digit>{sec}</span><span className="opacity-70" data-freshness-chip-unit>s</span>
241
275
  </span>
242
276
  );
243
277
  }
@@ -1026,6 +1060,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1026
1060
  // navigation footer. Drives the on-hover opacity boost + underline
1027
1061
  // that signals interactivity, mirroring the hoveredHub idiom above.
1028
1062
  const [hoveredRecentMore, setHoveredRecentMore] = useState(false);
1063
+ // Round 346 / Loop: minimap-container hover affordance. The minimap
1064
+ // is a click-target (role=button at line ~7810, recenter-on-click +
1065
+ // Enter→resetView) but pre-R346 nothing visually changed on hover —
1066
+ // the only hint was the `cursor: crosshair` style. R346 lifts the
1067
+ // viewport rect (strokeWidth 1.5 → 1.75 + opacity 0.9 → 1.0) when
1068
+ // the user enters the minimap, marking "this is the recenter target
1069
+ // and it's alive". Sibling polish to the R332 minimap rounded-md →
1070
+ // rounded-lg corner family — that round refined geometry, this one
1071
+ // gives the viewport indicator inside the geometry a hover state.
1072
+ // 280ms ease-out transition list matches R199 smoothView vocabulary
1073
+ // so the visual joins the existing rhythm on the same rect.
1074
+ const [hoveredMinimap, setHoveredMinimap] = useState(false);
1075
+ // Round 347 / Loop: zoom-level readout hover-state letter-spacing
1076
+ // tween (0 → 0.5 px). The readout sandwiched between zoom-out /
1077
+ // zoom-in is a passive percent display — pre-R347 it had no hover
1078
+ // feedback at all (only a `title` tooltip). R347 extends the R344
1079
+ // (`+N more flows` footer) + R345 (panel titles) hover-letter-
1080
+ // spacing family from panel/footer surfaces into the HTML chrome
1081
+ // strip. Hovering the readout spreads its digits 0.5 px, signalling
1082
+ // "this is alive". tabular-nums + minWidth: 46 from R225 still lock
1083
+ // the column so the tween doesn't shove neighbouring controls.
1084
+ // 200ms ease-out joins the existing R264 color/border transition
1085
+ // list on the same span.
1086
+ const [hoveredZoomLevel, setHoveredZoomLevel] = useState(false);
1087
+ // Round 350 / Loop: reset-button icon hover-rotate preview of the
1088
+ // R184 click-spin. Pre-R350 hovering the reset button only changed
1089
+ // the button bg (white/5); the icon inside stayed perfectly still.
1090
+ // R350 nudges the icon -8° on hover — a tactile hint that this
1091
+ // button rotates the icon on click. When the click fires, the
1092
+ // R184 anet-reset-spin keyframe animation overrides the hover
1093
+ // transform for its 450 ms run (CSS animations win over transitions
1094
+ // on the same property); when the animation ends + React removes
1095
+ // the className, the inline transform eases back to whatever the
1096
+ // hover state says — either -8° (still hovering) or 0 (mouse left).
1097
+ // 350th-round milestone polish.
1098
+ const [hoveredReset, setHoveredReset] = useState(false);
1029
1099
  // R135: panel-wide hover-elevation. The recent-signal + legend
1030
1100
  // panels both already host clickable rows (R56/R116 recent rows,
1031
1101
  // R55/R61 legend rows) and a clickable footer (R133), so the
@@ -1787,12 +1857,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1787
1857
  while honoring R328's wider baseline rhythm. data-topo-
1788
1858
  chrome-layout-trailer attr unchanged — it still marks
1789
1859
  the boundary surface for the gap probe. */}
1860
+ {/* Round 375 / Loop: Layout-toggle wrapper rounded-md → rounded-
1861
+ lg (6 → 8 px). Extends the corner-radius cascade family
1862
+ to the chrome-strip layout-toggle wrapper:
1863
+ R330 canvas wrapper rounded-xl 12 px
1864
+ R331 SVG panels rx=10 10 px
1865
+ R332 minimap container rounded-lg 8 px
1866
+ R375 Layout-toggle wrapper rounded-lg 8 px (this round)
1867
+ Pre-R375 the wrapper at rounded-md (6 px) was the only
1868
+ chrome-strip container still using the smaller corner
1869
+ radius — both R330 outer wrapper and R332 minimap sit at
1870
+ ≥ 8 px, so the Layout toggle's 6 px stood out as a
1871
+ tighter corner against the family. R375 brings it into
1872
+ the rounded-lg tier where the minimap already lives.
1873
+ Pure paint change — overflow-hidden still clips the
1874
+ inner buttons' bg-cyan-500/15 tints; no layout shift.
1875
+ R268 border-color + 200ms transition + R329 mr-0.5 +
1876
+ data-topo-chrome-layout-trailer all preserved. */}
1790
1877
  <div
1791
- className="mr-0.5 inline-flex rounded-md border overflow-hidden"
1878
+ className="mr-0.5 inline-flex rounded-lg border overflow-hidden"
1792
1879
  style={{ borderColor: pal.containerBorder, transition: 'border-color 200ms ease-out' }}
1793
1880
  role="group"
1794
1881
  aria-label="Topology layout"
1795
1882
  data-topo-chrome-layout-trailer
1883
+ data-topo-chrome-layout-radius="rounded-lg"
1796
1884
  >
1797
1885
  <button
1798
1886
  onClick={() => { popChrome('layout-ring'); if (layout !== 'ring') toggleLayout(); }}
@@ -1826,7 +1914,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1826
1914
  weight; cyan-400/60 + ring-inset retained. The
1827
1915
  R163/R196 hover/active deeps + R249 chrome-pop
1828
1916
  click feedback continue unchanged. */
1829
- className={`px-2.5 py-1 transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'text-gray-400 hover:text-cyan-300 hover:bg-cyan-500/5 active:bg-cyan-500/15'} ${chromePopping === 'layout-ring' ? ' anet-chrome-pop' : ''}`}
1917
+ // R351: hover:tracking-wide extends the R344/R345/R347
1918
+ // hover-letter-spacing family to a 4th surface (chrome-
1919
+ // strip Ring/Grid pair). transition-colors className
1920
+ // dropped in favour of an inline transition spec that
1921
+ // bundles bg/color (150ms ease) + letter-spacing
1922
+ // (200ms ease-out) — Tailwind's transition-colors
1923
+ // doesn't list letter-spacing, so without this the
1924
+ // hover:tracking-wide would snap. Sibling change on
1925
+ // the Grid button below.
1926
+ className={`px-2.5 py-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset hover:tracking-wide ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'text-gray-400 hover:text-cyan-300 hover:bg-cyan-500/5 active:bg-cyan-500/15'} ${chromePopping === 'layout-ring' ? ' anet-chrome-pop' : ''}`}
1927
+ style={{ transition: 'background-color 150ms ease, color 150ms ease, letter-spacing 200ms ease-out' }}
1830
1928
  >
1831
1929
  Ring
1832
1930
  </button>
@@ -1843,14 +1941,20 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1843
1941
  // Round 306 / Loop: focus-visible:ring-2 → ring-1 sibling
1844
1942
  // change to Ring above — unifies focus-ring width across
1845
1943
  // all chrome buttons.
1846
- className={`px-2.5 py-1 border-l transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'text-gray-400 hover:text-cyan-300 hover:bg-cyan-500/5 active:bg-cyan-500/15'} ${chromePopping === 'layout-grid' ? ' anet-chrome-pop' : ''}`}
1944
+ // R351 sibling — Grid button picks up hover:tracking-wide
1945
+ // + inline transition spec. Same vocabulary as Ring.
1946
+ className={`px-2.5 py-1 border-l focus:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset hover:tracking-wide ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'text-gray-400 hover:text-cyan-300 hover:bg-cyan-500/5 active:bg-cyan-500/15'} ${chromePopping === 'layout-grid' ? ' anet-chrome-pop' : ''}`}
1847
1947
  /* Round 268 / Loop: Grid button's left border (the
1848
1948
  internal divider between Ring and Grid) picks up
1849
1949
  pal.containerBorder, matching the wrapper change at
1850
1950
  line ~1460 and the chrome strip's segmented borders
1851
- (nodeSize, zoom). transition-colors className covers
1852
- the border-color eased on theme toggle. */
1853
- style={{ borderColor: pal.containerBorder }}
1951
+ (nodeSize, zoom). The R268 transition-colors className
1952
+ used to carry the border-color ease; R351 unfolds the
1953
+ transition list into the inline spec below so the
1954
+ letter-spacing tween rides alongside without snapping
1955
+ the border-color flip — border-color 200ms ease-out
1956
+ keeps R268's theme-toggle smoothness intact. */
1957
+ style={{ borderColor: pal.containerBorder, transition: 'background-color 150ms ease, color 150ms ease, border-color 200ms ease-out, letter-spacing 200ms ease-out' }}
1854
1958
  >
1855
1959
  Grid
1856
1960
  </button>
@@ -1937,11 +2041,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1937
2041
  7th surface in the info-density tabular-nums
1938
2042
  sweep — and the first on the HTML side
1939
2043
  (previous 6 were SVG <text>/<tspan>). */
1940
- className={`tabular-nums font-medium px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors duration-200 ${
2044
+ /* Round 398 / Loop: chip-row chips gain hover translateY
2045
+ (-1px) lift on the CLICKABLE variant only (workingCount
2046
+ > 0 here / onlineNodes.length > 0 below / activeLinks
2047
+ > 0 deeper). Pre-R398 the chips brightened bg + border
2048
+ on hover (R201) but didn't lift — only their clickable
2049
+ siblings (filter pin pills R397, recent rows R143,
2050
+ legend rows R144) acknowledged cursor entry with a
2051
+ translate-y. R398 closes the chip-row by extending
2052
+ the same gesture to the static header chips, gated
2053
+ on the clickable role so empty chips (which have
2054
+ no role="button") stay planted at their R205
2055
+ opacity-50 receded paint. transition-transform
2056
+ + duration-200 + ease-out + transform-gpu added
2057
+ alongside existing transition-colors so the lift
2058
+ and the color tween share rhythm.
2059
+ Gesture-vocabulary table (post-R398):
2060
+ recent-signal row -1 px (R143)
2061
+ legend row -1 px (R144)
2062
+ group cluster box fill+sw lift (R142)
2063
+ filter pin pills -1 px (R397)
2064
+ chip-row chips -1 px (R398, this round)
2065
+ Empty chips: no lift. Pin-mirror chips: no
2066
+ conflict (R180 inset double-ring is a box-shadow
2067
+ not a transform). new data-chip-hover-lift attr
2068
+ surfaces the lift surface for tests. */
2069
+ // R414: chip-row chips gain `group` so inner unit
2070
+ // span brightens via group-hover:opacity-100 — sibling
2071
+ // to R355 filter pin pill inner-span hover-brighten.
2072
+ // Hover-brighten family extends from filter pills to
2073
+ // chip-row chips at the inner-span scope.
2074
+ className={`group tabular-nums font-medium px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors transition-transform duration-200 ease-out transform-gpu ${
1941
2075
  workingCount > 0
1942
- ? 'bg-green-500/10 text-green-300 border-green-500/20 hover:bg-green-500/15 hover:border-green-500/30'
2076
+ ? 'bg-green-500/10 text-green-300 border-green-500/20 hover:bg-green-500/15 hover:border-green-500/30 hover:-translate-y-px'
1943
2077
  : 'bg-green-500/10 text-green-300 border-green-500/20'
1944
2078
  }`}
2079
+ data-chip-hover-lift={workingCount > 0 ? 'true' : 'false'}
2080
+ data-chip-group-hover-brighten="true"
1945
2081
  data-working-chip
1946
2082
  data-working-chip-aliases={workingAliases.join(',')}
1947
2083
  data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
@@ -2011,7 +2147,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2011
2147
  pattern: small label spans demote, value stays
2012
2148
  prominent. data-working-chip-unit exposes the
2013
2149
  span for tests. */}
2014
- {workingCount}<span className="opacity-70" data-working-chip-unit> working</span>
2150
+ {/* Round 362 / Loop: digit picks up font-semibold
2151
+ (fw 500 → 600) for within-chip weight tier. The
2152
+ chip's outer className stays at font-medium (R313
2153
+ data-weight baseline); the digit overrides to
2154
+ semibold so it reads heavier than its " working"
2155
+ unit (which keeps fw 500 + R338 opacity-70).
2156
+ Joins the R333-R341 chip-internal-hierarchy arc
2157
+ at the chip-count scope. Sibling edits on the
2158
+ online + active-links chip digits below. data-
2159
+ working-chip-digit attr exposes the digit span. */}
2160
+ <span className="font-semibold transition-[font-weight] duration-200 group-hover:font-bold" data-working-chip-digit>{workingCount}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-working-chip-unit> working</span>
2015
2161
  </span>
2016
2162
  <span
2017
2163
  // Round 201 / Loop: online chip — mirror of the working
@@ -2025,11 +2171,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2025
2171
  /* Round 232 / Loop: tabular-nums on online chip
2026
2172
  (sibling treatment to working chip — same row,
2027
2173
  same digit-jitter physics on count crossings). */
2028
- className={`tabular-nums font-medium px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors duration-200 ${
2174
+ // R398: hover translate-y lift on clickable variant — see working chip above.
2175
+ // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
2176
+ className={`group tabular-nums font-medium px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors transition-transform duration-200 ease-out transform-gpu ${
2029
2177
  onlineNodes.length > 0
2030
- ? 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20 hover:bg-cyan-500/15 hover:border-cyan-500/30'
2178
+ ? 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20 hover:bg-cyan-500/15 hover:border-cyan-500/30 hover:-translate-y-px'
2031
2179
  : 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20'
2032
2180
  }`}
2181
+ data-chip-hover-lift={onlineNodes.length > 0 ? 'true' : 'false'}
2182
+ data-chip-group-hover-brighten="true"
2033
2183
  data-online-chip
2034
2184
  data-online-chip-aliases={onlineAliases.join(',')}
2035
2185
  data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
@@ -2074,7 +2224,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2074
2224
  }}
2075
2225
  >
2076
2226
  {/* R337 sibling — online chip unit demotion. */}
2077
- {onlineNodes.length}<span className="opacity-70" data-online-chip-unit> online</span>
2227
+ {/* R362 sibling — online-chip digit gains font-semibold. */}
2228
+ <span className="font-semibold transition-[font-weight] duration-200 group-hover:font-bold" data-online-chip-digit>{onlineNodes.length}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-online-chip-unit> online</span>
2078
2229
  </span>
2079
2230
  </>
2080
2231
  );
@@ -2201,8 +2352,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2201
2352
  title={`${w} working · ${i} idle · ${o} offline`}
2202
2353
  data-fleet-pressure
2203
2354
  >
2204
- <span className="text-[10px] tracking-wide">pressure</span>
2205
- <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
2355
+ {/* Round 373 / Loop: pressure-bar kicker label gains
2356
+ font-medium (fw 400 → 500). Sibling small-text fw
2357
+ lift family with R363 recent-row alias + R364
2358
+ legend-row label + R366 group-label count + R368
2359
+ +N more flows footer — extends to a 5th surface
2360
+ (the chip-row's 'pressure' label). At fontSize
2361
+ 10 px tracking-wide against the chip's gray bg,
2362
+ the default fw 400 sat below the deliberate-data
2363
+ band; fw 500 brings it into parity with the
2364
+ chip-row 'working / online / active links' unit
2365
+ spans (chip-level font-medium R313). data-fleet-
2366
+ pressure-kicker attr exposes the kicker for tests. */}
2367
+ <span className="text-[10px] tracking-wide font-medium" data-fleet-pressure-kicker>pressure</span>
2368
+ {/* Round 374 / Loop: pressure-bar height h-1.5 → h-2
2369
+ (6 → 8 px) — sibling visual-weight bump (8th anchor
2370
+ in the family):
2371
+ R287 minimap viewport stroke 1 → 1.5
2372
+ R295 legend swatch base radius 5.5 → 6
2373
+ R359 recent-row pip base radius 1.6 → 1.8
2374
+ R360 hub digit fontSize 11 → 12
2375
+ R361 edge-badge digit fontSize 10 → 11
2376
+ R365 hub-highlight base radius 5 → 5.5
2377
+ R367 edge-badge rest stroke 1 → 1.25
2378
+ R374 pressure-bar height 1.5 → 2 (this round)
2379
+ +33 % bar height gives the working/idle/offline
2380
+ segments more visibility — at h-1.5 the 3-segment
2381
+ proportion bar was readable but slim; at h-2 the
2382
+ segments parse cleanly even when one tier is
2383
+ < 10 % share. Geometry-safe: items-center flex
2384
+ centers the bar inside the chip's py-1 (4 px top +
2385
+ 4 px bottom) — bar at 8 px stays comfortably
2386
+ inside the 10-px text-row height. R165 segment
2387
+ width transitions + R210 brightness hover + R83
2388
+ pin-mirror box-shadow on segments all preserved
2389
+ (segments inherit width from parent so the height
2390
+ bump propagates without segment-side edits).
2391
+ data-fleet-pressure-bar-height attr exposes the
2392
+ height token for tests. */}
2393
+ <span className="inline-flex h-2 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }} data-fleet-pressure-bar-height="h-2">
2206
2394
  {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
2207
2395
  {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
2208
2396
  {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
@@ -2259,7 +2447,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2259
2447
  data-active-filter="status"
2260
2448
  data-filter-match-count={matchCount}
2261
2449
  data-filter-match-aliases={matchAliases.join(',')}
2262
- 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"
2450
+ // R355: `group` lets the inner opacity-70 spans (prefix
2451
+ // `filter:` + count `· N`) brighten to 100 % on pill hover.
2452
+ // Sibling treatment on group + vendor pills below.
2453
+ className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono font-medium text-xs border anet-fade-in anet-topo-chip-focus transition-transform duration-200 ease-out hover:-translate-y-px transform-gpu" data-topo-filter-pill-hover-lift="true"
2263
2454
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2264
2455
  onClick={() => setPinnedStatus(null)}
2265
2456
  style={{
@@ -2273,12 +2464,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2273
2464
  cursor: 'pointer',
2274
2465
  }}
2275
2466
  >
2276
- <span><span className="hidden sm:inline opacity-70" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70 tabular-nums" data-filter-pill-count> · {matchCount}</span></span>
2467
+ {/* Round 412 / Loop: filter pin pill VALUE picks up the
2468
+ chip-internal-hierarchy arc. Pre-R412 the value span
2469
+ (pinnedStatus / pinnedGroup / pinnedVendor) inherited
2470
+ the parent's font-medium (fw=500); prefix and suffix
2471
+ were opacity-70 label-tier but the VALUE itself sat
2472
+ at the same baseline weight. R412 wraps the value in
2473
+ a font-semibold span (fw=600) so the pill now reads
2474
+ with proper data-tier emphasis — sibling treatment
2475
+ to R333/R335-R341/R362/R369/R389/R410. data-filter-
2476
+ value attr surfaces the value span for tests.
2477
+ 4-pill replace family — status / group / vendor / edge. */}
2478
+ <span><span className="hidden sm:inline opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-filter-prefix>filter: </span><span className="font-semibold" data-filter-value>{pinnedStatus}</span><span className="opacity-70 tabular-nums transition-opacity duration-200 group-hover:opacity-100" data-filter-pill-count> · {matchCount}</span></span>
2277
2479
  <button
2278
2480
  type="button"
2279
2481
  aria-label={`Clear ${pinnedStatus} filter`}
2280
2482
  onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
2281
- className="ml-0.5 leading-none hover:opacity-70"
2483
+ /* Round 356 / Loop: filter pin pill × buttons gain
2484
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2485
+ not legacy transform). Sibling polish to R354 vendor
2486
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2487
+ Pre-R356 the × had only hover:opacity-70 — the target
2488
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2489
+ scale on hover so the click-target reads as "press me"
2490
+ alongside the dim. transform-gpu hint promotes the
2491
+ button to its own compositor layer for crisper edges
2492
+ during the scale tween. transition-transform duration-
2493
+ 200 matches the chrome icon hover-scale timing family.
2494
+ inline-block is default for <button> so no display
2495
+ tweak needed. replace_all covers all 4 filter pin
2496
+ pills (status / group / vendor / edge) at once. */
2497
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2282
2498
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2283
2499
  >×</button>
2284
2500
  </span>
@@ -2297,7 +2513,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2297
2513
  data-active-filter="group"
2298
2514
  data-filter-match-count={matchCount}
2299
2515
  data-filter-match-aliases={matchAliases.join(',')}
2300
- 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"
2516
+ // R355 sibling — `group` parent + group-hover on inner spans.
2517
+ className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono font-medium text-xs border anet-fade-in anet-topo-chip-focus transition-transform duration-200 ease-out hover:-translate-y-px transform-gpu" data-topo-filter-pill-hover-lift="true"
2301
2518
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2302
2519
  onClick={() => setPinnedGroup(null)}
2303
2520
  style={{
@@ -2307,12 +2524,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2307
2524
  cursor: 'pointer',
2308
2525
  }}
2309
2526
  >
2310
- <span><span className="hidden sm:inline opacity-70" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70 tabular-nums" data-filter-pill-count> · {matchCount}</span></span>
2527
+ {/* R412: see status pill above — filter value fw=600 data tier. */}
2528
+ <span><span className="hidden sm:inline opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-filter-prefix>filter: </span><span className="font-semibold" data-filter-value>{pinnedGroup}</span><span className="opacity-70 tabular-nums transition-opacity duration-200 group-hover:opacity-100" data-filter-pill-count> · {matchCount}</span></span>
2311
2529
  <button
2312
2530
  type="button"
2313
2531
  aria-label={`Clear group filter ${pinnedGroup}`}
2314
2532
  onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
2315
- className="ml-0.5 leading-none hover:opacity-70"
2533
+ /* Round 356 / Loop: filter pin pill × buttons gain
2534
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2535
+ not legacy transform). Sibling polish to R354 vendor
2536
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2537
+ Pre-R356 the × had only hover:opacity-70 — the target
2538
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2539
+ scale on hover so the click-target reads as "press me"
2540
+ alongside the dim. transform-gpu hint promotes the
2541
+ button to its own compositor layer for crisper edges
2542
+ during the scale tween. transition-transform duration-
2543
+ 200 matches the chrome icon hover-scale timing family.
2544
+ inline-block is default for <button> so no display
2545
+ tweak needed. replace_all covers all 4 filter pin
2546
+ pills (status / group / vendor / edge) at once. */
2547
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2316
2548
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2317
2549
  >×</button>
2318
2550
  </span>
@@ -2347,7 +2579,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2347
2579
  data-active-filter="vendor"
2348
2580
  data-filter-match-count={matchCount}
2349
2581
  data-filter-match-aliases={matchAliases.join(',')}
2350
- 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"
2582
+ // R355 sibling — `group` parent + group-hover on inner spans.
2583
+ className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono font-medium text-xs border anet-fade-in anet-topo-chip-focus transition-transform duration-200 ease-out hover:-translate-y-px transform-gpu" data-topo-filter-pill-hover-lift="true"
2351
2584
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
2352
2585
  onClick={() => setPinnedVendor(null)}
2353
2586
  style={{
@@ -2357,12 +2590,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2357
2590
  cursor: 'pointer',
2358
2591
  }}
2359
2592
  >
2360
- <span><span className="hidden sm:inline opacity-70" data-filter-prefix>filter: </span>{pinnedVendor}<span className="opacity-70 tabular-nums" data-filter-pill-count> · {matchCount}</span></span>
2593
+ {/* R412: see status pill above — filter value fw=600 data tier. */}
2594
+ <span><span className="hidden sm:inline opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-filter-prefix>filter: </span><span className="font-semibold" data-filter-value>{pinnedVendor}</span><span className="opacity-70 tabular-nums transition-opacity duration-200 group-hover:opacity-100" data-filter-pill-count> · {matchCount}</span></span>
2361
2595
  <button
2362
2596
  type="button"
2363
2597
  aria-label={`Clear vendor filter ${pinnedVendor}`}
2364
2598
  onClick={(e) => { e.stopPropagation(); setPinnedVendor(null); }}
2365
- className="ml-0.5 leading-none hover:opacity-70"
2599
+ /* Round 356 / Loop: filter pin pill × buttons gain
2600
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2601
+ not legacy transform). Sibling polish to R354 vendor
2602
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2603
+ Pre-R356 the × had only hover:opacity-70 — the target
2604
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2605
+ scale on hover so the click-target reads as "press me"
2606
+ alongside the dim. transform-gpu hint promotes the
2607
+ button to its own compositor layer for crisper edges
2608
+ during the scale tween. transition-transform duration-
2609
+ 200 matches the chrome icon hover-scale timing family.
2610
+ inline-block is default for <button> so no display
2611
+ tweak needed. replace_all covers all 4 filter pin
2612
+ pills (status / group / vendor / edge) at once. */
2613
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2366
2614
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2367
2615
  >×</button>
2368
2616
  </span>
@@ -2395,7 +2643,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2395
2643
  data-filter-match-count={link.count}
2396
2644
  data-filter-match-aliases={`${link.from},${link.to}`}
2397
2645
  data-active-filter-edge-hot={isHot ? 'true' : 'false'}
2398
- 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"
2646
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md font-mono font-medium text-xs border anet-fade-in anet-topo-chip-focus transition-transform duration-200 ease-out hover:-translate-y-px transform-gpu" data-topo-filter-pill-hover-lift="true"
2399
2647
  title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
2400
2648
  onClick={() => setPinnedEdgeKey(null)}
2401
2649
  style={{
@@ -2405,9 +2653,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2405
2653
  cursor: 'pointer',
2406
2654
  }}
2407
2655
  >
2656
+ {/* R412: filter pin pill value (edge variant) picks up fw=600.
2657
+ Sibling treatment to the status/group/vendor pills above. */}
2408
2658
  <span>
2409
2659
  <span className="hidden sm:inline opacity-70" data-filter-prefix>filter: </span>
2410
- {link.from}→{link.to}
2660
+ <span className="font-semibold" data-filter-value>{link.from}→{link.to}</span>
2411
2661
  {/* Round 323 / Loop: edge filter pill count digit picks
2412
2662
  up tabular-nums (Tailwind class on both cold +
2413
2663
  hot branches). Sibling treatment to the status /
@@ -2439,7 +2689,21 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2439
2689
  type="button"
2440
2690
  aria-label={`Clear edge filter ${link.from} → ${link.to}`}
2441
2691
  onClick={(e) => { e.stopPropagation(); setPinnedEdgeKey(null); }}
2442
- className="ml-0.5 leading-none hover:opacity-70"
2692
+ /* Round 356 / Loop: filter pin pill × buttons gain
2693
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2694
+ not legacy transform). Sibling polish to R354 vendor
2695
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2696
+ Pre-R356 the × had only hover:opacity-70 — the target
2697
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2698
+ scale on hover so the click-target reads as "press me"
2699
+ alongside the dim. transform-gpu hint promotes the
2700
+ button to its own compositor layer for crisper edges
2701
+ during the scale tween. transition-transform duration-
2702
+ 200 matches the chrome icon hover-scale timing family.
2703
+ inline-block is default for <button> so no display
2704
+ tweak needed. replace_all covers all 4 filter pin
2705
+ pills (status / group / vendor / edge) at once. */
2706
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2443
2707
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2444
2708
  >×</button>
2445
2709
  </span>
@@ -2586,7 +2850,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2586
2850
  R150 pin-pill family parity now complete with
2587
2851
  this composed-pin sibling). data-pin-intersection-
2588
2852
  count-* attrs expose both spans for tests. */}
2589
- <span className="tabular-nums" data-pin-intersection-count-dims>{pinDimCount}</span> pins<span className="opacity-70 tabular-nums" data-pin-intersection-count-matches> · {matchAliases.length}</span>
2853
+ {/* Round 341 / Loop: middle " pins" unit word
2854
+ previously sat as a bare text node between the
2855
+ two count spans, while the matches-count span
2856
+ already carried opacity-0.7 (R335 + R324 era).
2857
+ The pinDimCount span is prominent and the
2858
+ matches count is recessive — but the literal
2859
+ " pins" was at FULL opacity, breaking the
2860
+ chip-internal hierarchy unified across R333/
2861
+ R335/R336/R337/R338/R340. R341 wraps " pins"
2862
+ in an opacity-0.7 span so the chip reads:
2863
+ pinDimCount (prominent value)
2864
+ " pins" (recessive unit)
2865
+ " · {N}" (recessive count)
2866
+ Three-tier hierarchy on a single chip; 7th
2867
+ surface in the chip-internal-hierarchy arc. */}
2868
+ <span className="tabular-nums" data-pin-intersection-count-dims>{pinDimCount}</span><span className="opacity-70" data-pin-intersection-unit> pins</span><span className="opacity-70 tabular-nums" data-pin-intersection-count-matches> · {matchAliases.length}</span>
2590
2869
  {/* Round 237 / Loop: ⚠ warning glyph picks up the
2591
2870
  always-mount-opacity-gate idiom. Pre-R237 the
2592
2871
  glyph was conditionally rendered on isEmpty,
@@ -2689,9 +2968,35 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2689
2968
  vendor letter chips ('A:N', 'O:N', '书:N',
2690
2969
  '?:N'). They display vendor-distribution
2691
2970
  data; same tier as the sibling data chips. */
2692
- className="tabular-nums font-medium inline-flex items-baseline gap-0.5 px-1 rounded anet-topo-chip-focus"
2971
+ /* Round 401 / Loop: vendor letter chip closes the
2972
+ hover-lift gesture family at its last unaddressed
2973
+ interactive HTML surface. R397/R398/R399 lifted
2974
+ filter pin pills + chip-row chips (working /
2975
+ online / active-links); R400 lifted standalone
2976
+ chrome buttons (reset / fullscreen). The vendor
2977
+ letter chips (A:N / O:N / 书:N / ?:N) are
2978
+ sibling interactive chips in the same chip-row
2979
+ — clickable to toggle the vendor filter pin —
2980
+ but were not yet on the hover-lift family.
2981
+ R401 closes the gap with hover:-translate-y-px
2982
+ + transition-transform + transform-gpu added
2983
+ to the className. The inline transition list
2984
+ (box-shadow + background-color) keeps eaching
2985
+ independently — different property axes compose
2986
+ cleanly. Existing R354 glyph scale-1.1 (inner
2987
+ span) + R202 chip bg color-mix + R180 pin-mirror
2988
+ box-shadow + R354 glyph hover transform all
2989
+ preserved. data-vendor-letter-hover-lift attr
2990
+ surfaces the lift for tests. */
2991
+ // R417: `group` parent enables the count suffix to
2992
+ // brighten on chip hover via group-hover:opacity-100
2993
+ // — sibling to R355 filter-pill prefix/suffix + R414
2994
+ // chip-row unit brighten. Closes the inner-span
2995
+ // hover-brighten family at the vendor chip surface.
2996
+ className="group tabular-nums font-medium inline-flex items-baseline gap-0.5 px-1 rounded anet-topo-chip-focus transition-transform duration-200 ease-out transform-gpu hover:-translate-y-px"
2693
2997
  data-vendor-letter={v.initial}
2694
2998
  data-vendor-letter-count={v.count}
2999
+ data-vendor-letter-hover-lift="true"
2695
3000
  data-vendor-pinned={isPinned ? 'true' : 'false'}
2696
3001
  data-vendor-hovered={hoveredVendor === v.initial ? 'true' : 'false'}
2697
3002
  data-vendor-aliases={aliases.join(',')}
@@ -2735,7 +3040,64 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2735
3040
  }
2736
3041
  }}
2737
3042
  >
2738
- <span style={{ color: v.color }}>{v.initial}</span>
3043
+ {/* Round 354 / Loop: vendor letter glyph scales
3044
+ 1.0 → 1.1 on hover. R88 already dims OTHER
3045
+ vendors on hover via canvas-wide opacity
3046
+ masking; R202 added a chip-level bg tint
3047
+ (color-mix 12 % alpha) so the chip itself
3048
+ responds. R354 closes the trio with a glyph-
3049
+ level lift: the focused vendor LETTER actively
3050
+ rises (transform scale) rather than the chip
3051
+ merely changing colour. Three layers of positive
3052
+ feedback on the hovered vendor + canvas-wide
3053
+ negative feedback on the others — a clean
3054
+ figure/ground separation.
3055
+
3056
+ display: inline-block is required for transform
3057
+ to apply (inline elements ignore transform).
3058
+ transformOrigin: 'center' so the glyph pivots
3059
+ around its centre instead of arcing from the
3060
+ baseline anchor. transition rides the existing
3061
+ Tailwind 4 transform/scale list (no new
3062
+ property — Tailwind already lists transform in
3063
+ the default transition-property set). 200ms
3064
+ matches the R202 chip bg-tint timing so the
3065
+ glyph lift and chip background ease in concert. */}
3066
+ {/* Round 369 / Loop: vendor letter glyph picks up
3067
+ fontWeight 600 (font-semibold). The glyph is the
3068
+ vendor identifier — the DATA the operator scans
3069
+ in this chip (A / O / 书 / C / G / ?). R333 set
3070
+ the count suffix `:N` to text-gray-400 + tabular-
3071
+ nums and (via parent inheritance) fw 500. Pre-
3072
+ R369 the LETTER also inherited fw 500 from the
3073
+ chip's font-medium — letter and count read at
3074
+ the same weight, contradicting the data-vs-label
3075
+ hierarchy the rest of the chip-row already speaks.
3076
+ R369 lifts the letter to fw 600 so the chip now
3077
+ reads as the same two-tier pattern R362 closed
3078
+ on the working / online / active-links chips:
3079
+ chip digit/letter fw 600 (data)
3080
+ chip unit/count fw 500 (label)
3081
+ Sibling treatment to R362 — extends the R333-R341
3082
+ chip-internal-hierarchy arc to the vendor-letter
3083
+ chip surface (9th surface family). R354 transform-
3084
+ scale-on-hover + R88 canvas-dim-others + R202
3085
+ chip bg color-mix all preserved on the same span.
3086
+ data-vendor-letter-glyph-font-weight attr exposes
3087
+ the value for tests. */}
3088
+ <span
3089
+ data-vendor-letter-glyph={v.initial}
3090
+ data-vendor-letter-glyph-hover={hoveredVendor === v.initial ? 'true' : 'false'}
3091
+ data-vendor-letter-glyph-font-weight="600"
3092
+ style={{
3093
+ color: v.color,
3094
+ display: 'inline-block',
3095
+ fontWeight: 600,
3096
+ transform: hoveredVendor === v.initial ? 'scale(1.1)' : 'scale(1)',
3097
+ transformOrigin: 'center',
3098
+ transition: 'transform 200ms ease-out',
3099
+ }}
3100
+ >{v.initial}</span>
2739
3101
  {/* Round 333 / Loop: vendor count suffix `:{N}` joins
2740
3102
  the R317 subordinate-text-lift family (gray-500 →
2741
3103
  gray-400) plus picks up tabular-nums for digit
@@ -2748,8 +3110,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2748
3110
  reads it as "deliberate subordinate metadata"
2749
3111
  rather than near-invisible chrome. data-vendor-
2750
3112
  letter-count exposes the span for tests. */}
3113
+ {/* R417: count suffix opacity-70 + group-hover:
3114
+ opacity-100 brightens on chip hover. Inner-span
3115
+ hover-brighten family (3rd anchor) — sibling to
3116
+ R355 filter pill prefix/suffix and R414 chip-row
3117
+ unit. Effective shade at rest: text-gray-400 ×
3118
+ 70 % alpha; on hover: full gray-400. The label-
3119
+ tier-vs-glyph differentiation persists on hover
3120
+ since the glyph (R369 fw=600) stays at full
3121
+ opacity. R333 :{count} format preserved. */}
2751
3122
  <span
2752
- className="text-gray-400 tabular-nums"
3123
+ className="text-gray-400 tabular-nums opacity-70 transition-opacity duration-200 group-hover:opacity-100"
2753
3124
  data-vendor-letter-count-suffix
2754
3125
  >:{v.count}</span>
2755
3126
  </span>
@@ -2837,11 +3208,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2837
3208
  (third chip in the row — matches working + online
2838
3209
  chip treatment so all three digits in the chip row
2839
3210
  stay width-stable across counter crossings). */
2840
- className={`tabular-nums font-medium hidden sm:inline px-2.5 py-1 rounded-md border anet-topo-chip-focus ${
3211
+ /* Round 399 / Loop: active-links chip closes the 3-chip
3212
+ chip-row by extending R398's hover translateY(-1px)
3213
+ lift onto the third (rightmost) chip. The R398 family
3214
+ already covered working + online chips on the
3215
+ clickable variant; R399 adds the same gate (isInter-
3216
+ active = flowLinks.length > 0) so empty active-links
3217
+ stays planted at R206's opacity-50 receded paint.
3218
+ transition-transform + ease-out + transform-gpu join
3219
+ the inline transition list (different property axes
3220
+ compose cleanly: inline handles color/bg/border/
3221
+ opacity, className handles transform).
3222
+ Gesture-vocabulary table (post-R399 — now complete
3223
+ across the chip-row):
3224
+ working chip -1 px (R398)
3225
+ online chip -1 px (R398)
3226
+ active-links chip -1 px (R399, this round)
3227
+ filter pin pills -1 px (R397)
3228
+ recent-signal row -1 px (R143)
3229
+ legend row -1 px (R144)
3230
+ Every interactive chip in TopoGraph lifts on hover.
3231
+ data-chip-hover-lift attr exposes the lift surface
3232
+ state ('true' clickable, 'false' empty) for tests. */
3233
+ // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
3234
+ className={`group tabular-nums font-medium hidden sm:inline px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-transform duration-200 ease-out transform-gpu ${
2841
3235
  isInteractive
2842
- ? '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'
3236
+ ? '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 hover:-translate-y-px'
2843
3237
  : 'bg-gray-500/10 text-gray-400 border-gray-500/20'
2844
3238
  }`}
3239
+ data-chip-hover-lift={isInteractive ? 'true' : 'false'}
3240
+ data-chip-group-hover-brighten="true"
2845
3241
  data-active-links-chip
2846
3242
  data-active-links-flow-count={flowLinks.length}
2847
3243
  data-active-links-clickable={isInteractive ? 'true' : 'false'}
@@ -2869,7 +3265,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2869
3265
  the 5th chip surface in the R333/R335/R336/R337
2870
3266
  chip-internal-hierarchy arc. data-active-links-
2871
3267
  chip-unit exposes the unit span for tests. */}
2872
- {flowLinks.length}<span className="opacity-70" data-active-links-chip-unit> active link{flowLinks.length === 1 ? '' : 's'}</span>
3268
+ {/* R362 sibling — active-links chip digit gains font-semibold. */}
3269
+ <span className="font-semibold transition-[font-weight] duration-200 group-hover:font-bold" data-active-links-chip-digit>{flowLinks.length}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-active-links-chip-unit> active link{flowLinks.length === 1 ? '' : 's'}</span>
2873
3270
  {rel ? (() => {
2874
3271
  // Round 161 / Loop: extend R160's recency-pip
2875
3272
  // vocabulary up one scope — from per-flow row to
@@ -2894,14 +3291,46 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2894
3291
  const alpha = ageSec <= 30
2895
3292
  ? 1
2896
3293
  : ageSec <= 300
2897
- ? 1 - ((ageSec - 30) / 270) * 0.75
2898
- : 0.25;
3294
+ ? 1 - ((ageSec - 30) / 270) * 0.70 /* R358: floor 0.25 → 0.30 lift across 3 freshness scopes */
3295
+ : 0.30; /* R358: stale floor lifted 0.25 → 0.30 — 20% legibility bump while preserving fresh/stale ratio */
2899
3296
  // Cyan dark / teal light to match palette legendAccent.
2900
3297
  const dotColor = isLight
2901
3298
  ? `rgba(13, 148, 136, ${alpha.toFixed(2)})`
2902
3299
  : `rgba(34, 211, 238, ${alpha.toFixed(2)})`;
3300
+ // Round 342 / Loop: active-links chip freshness suffix
3301
+ // wrapper text-gray-500 → text-gray-400 (R317
3302
+ // subordinate-text-lift family applied to chrome
3303
+ // inactive Layout toggle + R333 vendor count suffix).
3304
+ // The "last 5s ago" suffix is chip-subordinate
3305
+ // metadata; gray-500 sat near-invisible against the
3306
+ // chip's outer color, gray-400 lifts it into the band
3307
+ // where the eye reads it as deliberate freshness
3308
+ // annotation. The freshness DOT keeps its own inline
3309
+ // color: dotColor — the lift only affects the trailing
3310
+ // literal "last {rel}" text.
2903
3311
  return (
2904
- <span className="text-gray-500">
3312
+ // Round 357 / Loop: active-links chip freshness
3313
+ // suffix wrapper picks up `tabular-nums` for digit
3314
+ // width-lock. Pre-R357 the literal "last {rel}"
3315
+ // text (e.g. "last 5s ago", "last 10s ago",
3316
+ // "last 1m ago") had natural-figure digits — the
3317
+ // freshness ticker updates every second, so the
3318
+ // 9→10 boundary on the seconds counter and the
3319
+ // 59→60s → 1m flip both jittered ~1-2 px of glyph
3320
+ // width which propagated through the chip-row's
3321
+ // inline-flex layout, nudging the freshness DOT
3322
+ // and the chip's left edge. Tabular-nums on the
3323
+ // wrapper applies to all descendant digits only
3324
+ // (letters render at natural widths) so the
3325
+ // ticker stays planted across every count cross.
3326
+ // Joins the R224-R232 info-density tabular-nums
3327
+ // sweep at the chip-row freshness scope. Pure
3328
+ // paint-level change, no geometry shift on rest.
3329
+ // The R342 text-gray-400 lift + R161 dot freshness
3330
+ // alpha ramp + R317 subordinate-text-lift family
3331
+ // all preserved. data-active-links-freshness-
3332
+ // wrapper attr exposes the wrapper for tests.
3333
+ <span className="text-gray-400 tabular-nums" data-active-links-freshness-wrapper>
2905
3334
  <span
2906
3335
  data-active-links-freshness-dot
2907
3336
  data-active-links-freshness-alpha={alpha.toFixed(2)}
@@ -3565,19 +3994,174 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3565
3994
  active surfaces the activity state for test probes
3566
3995
  (active spokes don't carry the bucket/dur attrs so
3567
3996
  they need their own data anchor). */
3997
+ // Round 382 / Loop: hub-spoke path picks up
3998
+ // strokeLinecap='round'. Sibling polish to R378 flow-
3999
+ // rail dashes + R380 group box dashes — three dashed-
4000
+ // stroke surfaces now share 'round' linecap:
4001
+ // R378 flow-rail '2 12' -> soft 3-px pills
4002
+ // R380 group box '6 6' -> soft 7.5-px pills
4003
+ // R382 hub spoke '6 14' -> soft 7-px pills (this round)
4004
+ // For idle spokes (dashed at sw=1), each 6-px dash gains
4005
+ // 0.5-px round caps and reads as a soft pill instead of
4006
+ // a sharp 6 x 1 rectangle. Active spokes (solid, no
4007
+ // dasharray) have caps mostly hidden by the hub center +
4008
+ // node radius. Geometry-safe; paint-only. R51 sentinel
4009
+ // strokeWidth 1.5/3 untouched (idle=1, active=2). data-
4010
+ // topo-hub-spoke-linecap attr exposes the value for tests.
4011
+ // Round 419 / Loop: hub-spoke idle opacity 0.45 → 0.50.
4012
+ // Stale-state legibility lift family 9th anchor — pairs
4013
+ // with R391 (active 0.7 → 0.8) and R415 (active sw 2 →
4014
+ // 2.25) so the same spoke path is now polished on BOTH
4015
+ // active AND idle tiers. Pre-R419 idle spokes painted
4016
+ // at α=0.45 with R46 anet-topo-spoke-flow dashed
4017
+ // animation; the dashed pulses sat at the "background
4018
+ // chatter" floor — visible but understated. R419
4019
+ // lifts to 0.50 so idle spokes read more confidently
4020
+ // while the active/idle contrast ratio stays clear
4021
+ // (0.8/0.50 = 1.6× vs prior 0.8/0.45 = 1.78×; still
4022
+ // a sharp two-tier distinction).
4023
+ // Stale-state legibility lift family (9 anchors now):
4024
+ // R317 subordinate-text gray-500 → gray-400
4025
+ // R358 freshness floor 0.25 → 0.30
4026
+ // R372 minimap offline-dot 0.5 → 0.6
4027
+ // R404 hub-halo cyber trough 0.08 → 0.10
4028
+ // R405 hub-halo light trough 0.32 → 0.34
4029
+ // R406 edge freshness floor 0.35 → 0.40
4030
+ // R407 node halo offline opacity (cyber + light)
4031
+ // R413 active-node pulse trough (cyber + light)
4032
+ // R419 hub-spoke idle opacity 0.45 → 0.50 (this round)
4033
+ // data-topo-hub-spoke-opacity attr (R391) updates to
4034
+ // surface the resolved per-state value.
4035
+ //
4036
+ // Round 415 / Loop: hub-spoke active strokeWidth 2 → 2.25.
4037
+ // Pairs with R391 (active opacity 0.7 → 0.8) so the same
4038
+ // active-state path lifts BOTH stroke weight AND opacity
4039
+ // in concert. Pre-R415 active strokes sat at sw=2 — clear
4040
+ // step over idle sw=1, but a touch lighter than the
4041
+ // weight family's other "active" indicators (R385 hub
4042
+ // hover-ring sw=1.75 / R402 legend pin-ring sw=1.75 /
4043
+ // R367 edge-badge rest sw=1.25). R415 bumps to 2.25 so
4044
+ // the active spoke reads with proportional weight to its
4045
+ // role — the line connecting the focal point to the
4046
+ // active node deserves the heaviest active stroke in the
4047
+ // family (after pin/hot edge-badge sw=2). Stays clear of
4048
+ // R51 sentinels (1.5 / 3) at 2.25.
4049
+ // Visual-weight bump family (14 anchors now):
4050
+ // R287 minimap viewport stroke 1 → 1.5
4051
+ // R295 legend swatch radius 5.5 → 6
4052
+ // R359 recent-row pip radius 1.6 → 1.8
4053
+ // R360 hub digit fontSize 11 → 12
4054
+ // R361 edge-badge digit fontSize 10 → 11
4055
+ // R365 hub-highlight radius 5 → 5.5
4056
+ // R367 edge-badge rest stroke 1 → 1.25
4057
+ // R374 pressure-bar height 1.5 → 2
4058
+ // R383 recent-row pip radius 1.8 → 2.0
4059
+ // R384 minimap online dot 1.7 → 1.9
4060
+ // R385 hub hover-ring stroke 1.5 → 1.75
4061
+ // R402 legend pin-ring stroke 1.5 → 1.75
4062
+ // R408 hub-halo radius 18 → 20
4063
+ // R415 hub-spoke active stroke 2 → 2.25 (this round)
4064
+ // R382 strokeLinecap='round' + R391 opacity 0.45/0.8 +
4065
+ // R51-safe idle sw=1 all preserved. 250ms transition
4066
+ // list already covers stroke-width — the new tier eases
4067
+ // naturally. data-topo-hub-spoke-stroke-width-active
4068
+ // attr surfaces the active value for tests.
4069
+ //
4070
+ // Round 391 / Loop: hub-spoke active opacity 0.7 → 0.8.
4071
+ // Pre-R391 active spokes (the spoke connecting the hub
4072
+ // to the currently-active alias — hovered or pinned)
4073
+ // lifted opacity from rest 0.45 to active 0.7 — a clear
4074
+ // step but slightly understated against the canvas
4075
+ // chrome. R391 lifts active to 0.8 so the "this spoke
4076
+ // connects to your active node" signal reads with
4077
+ // matching weight to the R370 hub hover-ring opacity
4078
+ // (0.7 → 0.8 cyber) — paired canvas signals now share
4079
+ // the same active-state alpha (0.8) so when a user
4080
+ // hovers a node, both the spoke and the hub-ring lift
4081
+ // to identical opacity. Rest 0.45 invariant preserved.
4082
+ // Theme-consistency / canvas-presence polish family
4083
+ // (6th anchor):
4084
+ // R370 hub hover-ring opacity 0.7 → 0.8 cyber
4085
+ // R371 edge-badge rest opacity 0.82 → 0.85 cyber
4086
+ // R372 minimap offline-dot opacity 0.5 → 0.6
4087
+ // R386 hub-highlight idle opacity 0.9 → 0.95
4088
+ // R387 hover-detail panel opacity 0.94 → 0.97 cyber
4089
+ // R391 hub-spoke active opacity 0.7 → 0.8 (this round)
4090
+ // Idle path (45% alpha + dashed flow animation) entirely
4091
+ // untouched — R391 is an active-state-only lift.
4092
+ // data-topo-hub-spoke-opacity attr exposes the resolved
4093
+ // value for tests. R382 strokeLinecap='round' + R51
4094
+ // sentinel-safe sw (1 idle / 2 active) preserved.
4095
+ /* Round 430 / Loop: hub-spoke opacity hover lift on
4096
+ hoveredAlias === session.alias. Adds a "this node's
4097
+ spoke" affordance to the node-hover gesture — in a
4098
+ dense ring layout the spokes are visually quiet
4099
+ (idle α=0.50 dashed, active α=0.80 solid) so hovering
4100
+ a node didn't telegraph which line connects to it.
4101
+ R430 lifts the matched spoke's opacity:
4102
+ idle 0.50 → 0.70 (hover-α=0.70, +0.20)
4103
+ active 0.80 → 0.95 (hover-α=0.95, +0.15)
4104
+ The +0.15-to-0.20 lift keeps the active/idle two-tier
4105
+ distinction (0.95 vs 0.70 still a clear gap) while
4106
+ making the hovered-node's spoke visibly brighter than
4107
+ every other spoke at its own activity tier. R241
4108
+ transition list already covers opacity 250ms so the
4109
+ lift eases for free. Sibling to R429 label-card body
4110
+ solidity lift — both surface a single-node-focused
4111
+ attention cue with the same easing cadence.
4112
+ Stacks with the 6-layer node hover cue stack at the
4113
+ inter-node-link scope:
4114
+ R26 group translateY -2px (per-node)
4115
+ R217 stroke tint legendAccent (per-node card)
4116
+ R142 drop-shadow boost (per-node card)
4117
+ R427 alias letter-spacing (per-node text)
4118
+ R428 sub-text letter-spacing (per-node text)
4119
+ R429 body opacity 0.94 → 1.0 (per-node card)
4120
+ R430 spoke opacity α+ (this round) (link to hub)
4121
+ data-topo-hub-spoke-hovered exposes the gate. */
4122
+ const isHoveredSpoke = !reducedMotion && hoveredAlias === session.alias;
4123
+ const spokeOpacity = isActiveSpoke
4124
+ ? (isHoveredSpoke ? 0.95 : 0.80)
4125
+ : (isHoveredSpoke ? 0.70 : 0.50);
4126
+ /* Round 435 / Loop: hub-spoke stroke-width hover lift —
4127
+ sibling to R430 opacity hover at the same surface. When
4128
+ hoveredAlias matches, BOTH opacity AND stroke-width
4129
+ lift on the matched spoke so the eye registers a
4130
+ 2-axis "this node's spoke" gesture (paint + geometry).
4131
+ idle 1.00 → 1.25 (Δ +0.25, +25%)
4132
+ active 2.25 → 2.50 (Δ +0.25, +11%)
4133
+ Same +0.25 absolute delta keeps the idle/active visual
4134
+ progression consistent — at rest sw ratio 2.25:1 = 2.25,
4135
+ on hover 2.50:1.25 = 2.0; both still clearly two-tier.
4136
+ R241 transition list already covers stroke-width 250ms
4137
+ so the lift eases for free.
4138
+ R51 sentinel-safe: spoke is canvas <path>, not
4139
+ data-node <circle> (the sentinel selector is gated to
4140
+ g[data-node] descendants). 1.25 and 2.5 are not in the
4141
+ reserved {1.5, 3} set so the overlap-test sentinel
4142
+ attribute selector wouldn't match either way. */
4143
+ const spokeStrokeWidth = isActiveSpoke
4144
+ ? (isHoveredSpoke ? 2.5 : 2.25)
4145
+ : (isHoveredSpoke ? 1.25 : 1);
3568
4146
  return (
3569
4147
  <path
3570
4148
  key={`hub-${session.alias}`}
3571
4149
  d={path}
3572
4150
  fill="none"
3573
4151
  stroke={isActiveSpoke ? pal.spokeStroke.active : pal.spokeStroke.idle}
3574
- strokeWidth={isActiveSpoke ? 2 : 1}
4152
+ strokeWidth={spokeStrokeWidth}
3575
4153
  strokeDasharray={isActiveSpoke ? 'none' : '6 14'}
3576
- opacity={isActiveSpoke ? 0.7 : 0.45}
4154
+ strokeLinecap="round"
4155
+ opacity={spokeOpacity}
3577
4156
  className={isActiveSpoke ? undefined : 'anet-topo-spoke-flow'}
3578
4157
  data-topo-spoke-bucket={isActiveSpoke ? undefined : busy}
3579
4158
  data-topo-spoke-dur={isActiveSpoke ? undefined : spokeDur}
3580
4159
  data-topo-hub-spoke-active={isActiveSpoke ? 'true' : 'false'}
4160
+ data-topo-hub-spoke-hovered={isHoveredSpoke ? 'true' : 'false'}
4161
+ data-topo-hub-spoke-opacity={spokeOpacity}
4162
+ data-topo-hub-spoke-stroke-width={spokeStrokeWidth}
4163
+ data-topo-hub-spoke-stroke-width-active="2.25"
4164
+ data-topo-hub-spoke-linecap="round"
3581
4165
  style={{
3582
4166
  transition: 'stroke 250ms ease-out, stroke-width 250ms ease-out, opacity 250ms ease-out',
3583
4167
  ...(isActiveSpoke ? {} : {
@@ -3669,7 +4253,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3669
4253
  stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
3670
4254
  strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
3671
4255
  strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
4256
+ /* Round 380 / Loop: cluster box stroke gets round
4257
+ linecap + round linejoin. Sibling SVG stroke-
4258
+ softening polish to R378 flow-rail linecap + R379
4259
+ minimap viewport linejoin — extends the family to
4260
+ the group cluster boundary box (grid layout only):
4261
+ R288 chrome icons strokeLinecap='round'
4262
+ R378 flow-rail dashes strokeLinecap='round'
4263
+ R380 group box dashes strokeLinecap='round' (this round)
4264
+ R379 viewport rect strokeLinejoin='round'
4265
+ R380 group box corners strokeLinejoin='round' (this round)
4266
+ Linecap rounds the R85 '6 6' marching-ants dash
4267
+ pills at rest — each 6 px dash gains a ~0.75 px
4268
+ round cap (sw=1.5 idle), reading as soft pills
4269
+ instead of sharp 6 × 1.5 px rectangles. Linejoin
4270
+ rounds the 4 sharp 90° corners (any state — solid
4271
+ or dashed); at sw=1.5 the join arc is ~0.75 px,
4272
+ matching R379 viewport vocabulary. Geometry-safe:
4273
+ stroke-* properties only affect paint, not bbox.
4274
+ The R51 sentinel 1.5/3 strokeWidth values stay
4275
+ intact (the overlap probe is gated to g[data-
4276
+ node], so this cluster-internal rect is invisible
4277
+ to it anyway). data-group-box-linecap + -linejoin
4278
+ attrs expose the values for tests. */
4279
+ strokeLinecap="round"
4280
+ strokeLinejoin="round"
3672
4281
  data-group-box-pinned={isPinned ? 'true' : 'false'}
4282
+ data-group-box-linecap="round"
4283
+ data-group-box-linejoin="round"
3673
4284
  // R85: ambient "marching ants" drift on the perimeter
3674
4285
  // when this group has at least one working member, and
3675
4286
  // neither pin nor hover is active (those treatments
@@ -3826,6 +4437,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3826
4437
  matches R142 fill timing so all the group-label
3827
4438
  state-flip channels (fill colour, rect stroke,
3828
4439
  rect drop-shadow, label tracking) ease as one. */}
4440
+ {/* Round 432 / Loop: extend the group-label letter-
4441
+ spacing tween from 2-tier (rest/pin) to 3-tier
4442
+ (rest/hover/pin → 0/0.25/0.5). Pre-R432 R218
4443
+ spread the text only on pin; hover got an
4444
+ R63 fill brighten (legendText → legendHeadline)
4445
+ but no typographic axis of its own. R432 adds
4446
+ the missing mid tier so hover telegraphs through
4447
+ BOTH the fill brighten AND a subtle kerning
4448
+ spread — sibling pattern to R427 node-alias
4449
+ (0/0.3/0.5) and R431 edge-badge (0/0.2/0.4) at
4450
+ group-label scope. Pin tier (0.5) still wins.
4451
+ Subtler mid tier (0.25 vs alias 0.3) because the
4452
+ group label is a structural anchor — too much
4453
+ spread would steal weight from the per-node
4454
+ alias identity it groups. Hover-letter-spacing
4455
+ family extension (8 anchors now):
4456
+ R344 chip count digit
4457
+ R345 panel title
4458
+ R347 active-links chip
4459
+ R351 vendor chip
4460
+ R420 zoom-level chip
4461
+ R427 node alias text
4462
+ R431 edge-badge digit
4463
+ R432 group label text (this round)
4464
+ R218 transition list ('fill 200ms, letter-spacing
4465
+ 200ms') untouched — additive conditional case. */}
3829
4466
  <text
3830
4467
  x={box.x + 12}
3831
4468
  y={box.y + 14}
@@ -3833,9 +4470,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3833
4470
  fontSize="13"
3834
4471
  fontFamily="monospace"
3835
4472
  fontWeight="700"
4473
+ data-group-label-hovered={isHovered && !isPinned ? 'true' : 'false'}
3836
4474
  style={{
3837
4475
  transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out',
3838
- letterSpacing: isPinned ? '0.5px' : '0px',
4476
+ letterSpacing: isPinned ? '0.5px' :
4477
+ isHovered ? '0.25px' : '0px',
3839
4478
  }}
3840
4479
  data-group-label={box.key}
3841
4480
  data-group-label-pinned={isPinned ? 'true' : 'false'}
@@ -3869,12 +4508,33 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3869
4508
  lives at a fixed dx=6 offset from the name, so a
3870
4509
  digit-width jitter at 9→10 used to shift the
3871
4510
  whole count visibly. Tabular locks it. */}
4511
+ {/* Round 366 / Loop: group label member-count tspan
4512
+ fontWeight 400 → 500. Sibling polish to R363
4513
+ recent-row alias text fw 400 → 500 + R364 legend-
4514
+ row label fw 400 → 500 — closes the per-row 'count
4515
+ is fw 500 against label-tier fw 700' pattern at
4516
+ the group-label scope (grid layout cluster mark).
4517
+ Hierarchy snapshot post-R366 across all 3 row
4518
+ surfaces:
4519
+ recent count(hot/cold) fw 700/600 (R320)
4520
+ recent alias fw 500 (R363)
4521
+ legend count fw 600 (R309)
4522
+ legend label fw 500 (R364)
4523
+ group name fw 700 (legacy)
4524
+ group count fw 500 (R366, this round)
4525
+ Monospace family + R225 tabular-nums lock digit
4526
+ width, so the fw bump is paint-only — bbox
4527
+ unchanged + overlap-test invariants hold. R229
4528
+ fill-inherit from parent label (hover-deepen-own-
4529
+ hue family) preserved. data-group-label-count-
4530
+ font-weight attr exposes the value for tests. */}
3872
4531
  <tspan
3873
4532
  dx="6"
3874
4533
  fontSize="11"
3875
- fontWeight="400"
4534
+ fontWeight="500"
3876
4535
  data-group-label-count={box.key}
3877
4536
  data-group-label-count-value={box.count}
4537
+ data-group-label-count-font-weight="500"
3878
4538
  style={{ fontVariantNumeric: 'tabular-nums' }}
3879
4539
  >· {box.count}</tspan>
3880
4540
  {/* Round 58 / Loop: status mix pip strip. Compact text-
@@ -4005,13 +4665,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4005
4665
  // synchronised per-edge animation set.
4006
4666
  const stagger = (index * 0.37) % duration;
4007
4667
  // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
4008
- // stays at full intensity; over 5 minutes it decays to ~35%.
4009
- // Surfaces "what's happening now" vs background chatter without
4010
- // hiding old flow entirely (some context still useful). `now`
4011
- // captured at useMemo-recompute time (every 5s message refresh)
4012
- // — accuracy is within the poll interval, plenty.
4668
+ // stays at full intensity; over 5 minutes it decays to a
4669
+ // floor. Surfaces "what's happening now" vs background
4670
+ // chatter without hiding old flow entirely (some context
4671
+ // still useful). `now` captured at useMemo-recompute time
4672
+ // (every 5s message refresh) — accuracy is within the poll
4673
+ // interval, plenty.
4674
+ //
4675
+ // Round 406 / Loop: edge freshness fade floor 0.35 → 0.40.
4676
+ // Stale-state legibility lift family (6th anchor) — pre-
4677
+ // R406 edges older than 5 minutes faded to α=0.35 (a 65 %
4678
+ // dim against full intensity). The decay rate is the same
4679
+ // 1 - ageMs/300s curve; only the FLOOR shifts. Sibling
4680
+ // treatment to:
4681
+ // R317 subordinate-text gray-500 → gray-400
4682
+ // R358 freshness ramp floor 0.25 → 0.30
4683
+ // R372 minimap offline-dot opacity 0.5 → 0.6
4684
+ // R404 hub-halo cyber trough 0.08 → 0.10
4685
+ // R405 hub-halo light trough 0.32 → 0.34
4686
+ // R406 edge freshness floor 0.35 → 0.40 (this round)
4687
+ // Edges past 5min now sit at 40% intensity instead of 35%
4688
+ // — they still recede against fresh edges but read
4689
+ // legibly enough to convey "this conversation existed".
4690
+ // ageMs threshold for the 5-minute decay unchanged; the
4691
+ // decay curve shape (linear) unchanged. The visual delta
4692
+ // is most pronounced on edges between 5-60 minutes old —
4693
+ // where the floor was binding pre-R406.
4013
4694
  const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
4014
- const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
4695
+ const fresh = Math.max(0.40, 1 - ageMs / (5 * 60 * 1000));
4015
4696
  // Round 16 arrow-tier binning — keep `topo-arrow` as the
4016
4697
  // medium tier id so the legend swatch picks it up unchanged.
4017
4698
  const arrowId = link.count <= 2 ? 'topo-arrow-s'
@@ -4076,7 +4757,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4076
4757
  // tracks it even at low message counts (width was 3 → 4.5 on
4077
4758
  // hover). 1.4× is enough to read as "lifted" without
4078
4759
  // breaching the 16-px hitbox bound.
4079
- const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10) : width;
4760
+ // Round 436 / Loop: extend the thickening to the
4761
+ // "hovered-endpoint" case — when hoveredAlias matches one
4762
+ // of this edge's endpoints, lift width by 1.15× (capped at
4763
+ // 8 px to stay clear of the 16-px hitbox). Pre-R436 the
4764
+ // edgeOpacityMul=1.7 (line 4703) lifted the matched edge's
4765
+ // OPACITY when an endpoint was hovered but the stroke-WIDTH
4766
+ // stayed at base — so the edge faded brighter without
4767
+ // thickening, leaving the paint+geometry axes mismatched.
4768
+ // Mirror of R430/R435 hub-spoke pattern (opacity + stroke-
4769
+ // width co-lift on hoveredAlias); R436 brings the same
4770
+ // dual-axis "this node's link" gesture to the edge scope.
4771
+ // 1.15× is subtler than isHoveredEdge=1.4× because
4772
+ // endpoint-hover lifts MANY edges at once (every edge
4773
+ // incident on the hovered node) while edge-hover lifts ONE
4774
+ // — the gesture should read as "highlighted" not "loud".
4775
+ // R166 stroke-width 300ms transition already in the
4776
+ // visible-path style list so the lift eases for free.
4777
+ const isEndpointHoveredEdge = !!hoveredAlias && (link.from === hoveredAlias || link.to === hoveredAlias);
4778
+ const renderWidth = isHoveredEdge ? Math.min(width * 1.4, 10)
4779
+ : isEndpointHoveredEdge ? Math.min(width * 1.15, 8)
4780
+ : width;
4080
4781
  return (
4081
4782
  <g
4082
4783
  key={link.key}
@@ -4163,30 +4864,89 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4163
4864
  online chips to splice in additional properties
4164
4865
  beside Tailwind's). data-edge-flow-rail attr
4165
4866
  surfaces the path for test introspection. */}
4867
+ {/* Round 381 / Loop: edge visible flow path picks up
4868
+ strokeLinecap='round'. Sibling polish to R378
4869
+ flow-rail dashed linecap — both flow-element paths
4870
+ (visible primary + dashed secondary rail) now share
4871
+ 'round' linecap vocabulary. The visible path runs
4872
+ source-node → dest-node as one continuous line, so
4873
+ the dest-end is covered by the markerEnd arrow and
4874
+ the source-end usually sits inside the source-node
4875
+ circle. At certain alignments (post-zoom, post-
4876
+ layout-switch transitions), the source-end may peek
4877
+ out by a fraction of a px past the node edge —
4878
+ round caps render that overshoot as a smooth half-
4879
+ disc instead of a sharp rectangle. Pure paint
4880
+ refinement, geometry-safe (bbox of the stroke
4881
+ unchanged at the join with the arrow marker).
4882
+ data-edge-visible-linecap attr exposes the value
4883
+ for tests. */}
4166
4884
  <path
4167
4885
  d={path}
4168
4886
  fill="none"
4169
4887
  stroke={pal.flowEdge}
4170
4888
  strokeWidth={renderWidth}
4889
+ strokeLinecap="round"
4171
4890
  opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
4172
4891
  filter={isLight ? undefined : 'url(#topo-glow)'}
4173
4892
  markerEnd={`url(#${arrowId})`}
4174
4893
  data-edge-visible={link.key}
4894
+ data-edge-visible-linecap="round"
4895
+ data-edge-visible-endpoint-hovered={isEndpointHoveredEdge ? 'true' : 'false'}
4896
+ data-edge-visible-stroke-width={renderWidth}
4175
4897
  style={{
4176
4898
  pointerEvents: 'none',
4177
4899
  transition: 'opacity 300ms ease-out, stroke-width 300ms ease-out, stroke 300ms ease-out',
4178
4900
  }}
4179
4901
  />
4902
+ {/* Round 378 / Loop: edge flow-path dashed-rail picks
4903
+ up strokeLinecap='round'. Pre-R378 the rail
4904
+ rendered '2 12' dashes as sharp 1×2 rectangles
4905
+ against the canvas backdrop; default 'butt' caps
4906
+ leave dash ends square. R378 rounds each cap so
4907
+ the dashes read as soft 3-px pills (1 px stroke +
4908
+ 0.5 px round cap each end). The flow-rail is the
4909
+ secondary 'invisible-spine' line that gives the
4910
+ R57 spoke flow a directional rail to slide along
4911
+ — rounding the dashes softens its presence
4912
+ against the primary visible flow path (R245 has
4913
+ no strokeLinecap so it inherits 'butt' on a
4914
+ continuous line, irrelevant). Geometry-safe:
4915
+ round caps only widen the visible dash; the
4916
+ bbox of the path is unchanged so overlap-test
4917
+ invariants hold. data-edge-flow-rail-linecap
4918
+ attr exposes the value for tests. */}
4180
4919
  <path
4181
4920
  id={`flow-path-${index}`}
4182
4921
  d={path}
4183
4922
  fill="none"
4184
4923
  stroke={pal.flowPath}
4185
- strokeWidth="1"
4924
+ /* Round 437 / Loop: flow-rail strokeWidth hover lift —
4925
+ 1 → 1.5 on (isHoveredEdge || isEndpointHoveredEdge).
4926
+ Pre-R437 the dashed rail sat at sw=1 always while the
4927
+ visible flow path above it lifted (R50 ×1.4 on
4928
+ isHoveredEdge / R436 ×1.15 on isEndpointHoveredEdge).
4929
+ The two edge paint layers were mismatched on hover:
4930
+ top layer thickened, underline stayed thin — so the
4931
+ hover gesture lifted only half the edge surface.
4932
+ R437 lifts the underline too so the whole edge
4933
+ reads as "raised" on hover, not just its bright
4934
+ top stripe. Same +0.5 absolute delta R435 used at
4935
+ hub-spoke scope (1→1.25 there, slightly bigger
4936
+ here because the rail's base 1 is at the kerning
4937
+ floor and needs more lift to register).
4938
+ Transition list extends to include stroke-width
4939
+ 300ms so the new lift eases under the same R166
4940
+ cadence as the visible path's stroke-width. */
4941
+ strokeWidth={(isHoveredEdge || isEndpointHoveredEdge) ? 1.5 : 1}
4186
4942
  strokeDasharray="2 12"
4943
+ strokeLinecap="round"
4187
4944
  opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
4188
4945
  data-edge-flow-rail={link.key}
4189
- style={{ transition: 'opacity 300ms ease-out, stroke 300ms ease-out' }}
4946
+ data-edge-flow-rail-linecap="round"
4947
+ data-edge-flow-rail-stroke-width={(isHoveredEdge || isEndpointHoveredEdge) ? 1.5 : 1}
4948
+ data-edge-flow-rail-lifted={(isHoveredEdge || isEndpointHoveredEdge) ? 'true' : 'false'}
4949
+ style={{ transition: 'opacity 300ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out' }}
4190
4950
  />
4191
4951
  {!reducedMotion && (
4192
4952
  /* Round 103 / Loop: phase-stagger the particles so
@@ -4201,25 +4961,40 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4201
4961
  multiple). Edge order is stable (sorted by recent
4202
4962
  activity), so the offsets feel calm rather than
4203
4963
  reshuffling each refresh. */
4964
+ /* Round 422 / Loop: edge flow particle radius 4 → 4.5.
4965
+ Visual-weight bump family (15th anchor) — particles
4966
+ riding along the edge animateMotion path get +0.5px
4967
+ radius lift, increasing visual area by ~27%
4968
+ (π·4.5² / π·4² = 1.27). Sibling magnitude to R383
4969
+ recent-row pip 1.8 → 2.0 (+25% area), R384 minimap
4970
+ online dot 1.7 → 1.9 (+25% area). R251 fill +
4971
+ R252 transitions + R103 phase-stagger animateMotion
4972
+ all preserved. data-edge-particle-radius attr
4973
+ exposes the value for tests. */
4204
4974
  <circle
4205
- r="4"
4975
+ /* Round 439 / Loop: edge flow particle radius hover
4976
+ lift — r 4.5 → 5.5 on (isHoveredEdge ||
4977
+ isEndpointHoveredEdge). Continues edge paint-
4978
+ layer parity arc (R436 visible path sw / R437
4979
+ flow-rail sw / R439 particle r) so the whole
4980
+ edge surface — including the moving particle —
4981
+ lifts on hover, not just the static stripes.
4982
+ +1px radius gives ~50% area boost. Subtler than
4983
+ 1.4× sw bump on visible path because the
4984
+ particle is already small + motion-bright;
4985
+ +1px reads as "the dot caught attention"
4986
+ without overshadowing the path lift. R252
4987
+ transition list extends to include r 200ms so
4988
+ the size change eases under the same fill/
4989
+ opacity cadence. */
4990
+ r={(isHoveredEdge || isEndpointHoveredEdge) ? 5.5 : 4.5}
4206
4991
  fill={pal.flowParticle}
4207
4992
  filter={isLight ? undefined : 'url(#topo-glow)'}
4208
4993
  opacity={Math.min(1, fresh * edgeOpacityMul)}
4209
4994
  data-edge-particle={link.key}
4210
- /* Round 252 / Loop: particle picks up fill +
4211
- opacity transition for theme-toggle smoothing.
4212
- Pre-R252 pal.flowParticle (cyber #fef08a yellow
4213
- ↔ light #f59e0b amber) snapped on theme toggle
4214
- while every other edge element eased (R245
4215
- paths, R251 badge, R242 chat-target, R233
4216
- endpoint ring). opacity is freshness-driven so
4217
- it transitions per-frame as fresh decays anyway
4218
- — but adding opacity to the explicit transition
4219
- list also covers theme toggle (R3 className
4220
- transition-opacity duration-300 was previously
4221
- absent on this circle). */
4222
- style={{ transition: 'fill 200ms ease-out, opacity 200ms ease-out' }}
4995
+ data-edge-particle-radius={(isHoveredEdge || isEndpointHoveredEdge) ? 5.5 : 4.5}
4996
+ data-edge-particle-lifted={(isHoveredEdge || isEndpointHoveredEdge) ? 'true' : 'false'}
4997
+ style={{ transition: 'fill 200ms ease-out, opacity 200ms ease-out, r 200ms ease-out' }}
4223
4998
  >
4224
4999
  <animateMotion
4225
5000
  dur={`${duration}s`}
@@ -4550,14 +5325,137 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4550
5325
  R251 closes the per-edge surface theme-toggle
4551
5326
  smoothness — every theme-driven property on
4552
5327
  every edge element now eases under cyber↔light. */}
5328
+ {/* Round 367 / Loop: edge midpoint badge rest
5329
+ stroke-width 1 → 1.25. Sibling visual-weight
5330
+ bump family (7th canvas anchor now):
5331
+ R287 minimap viewport stroke 1 → 1.5
5332
+ R295 legend swatch base radius 5.5 → 6
5333
+ R359 recent-row pip base radius 1.6 → 1.8
5334
+ R360 hub digit fontSize 11 → 12
5335
+ R361 edge-badge digit fontSize 10 → 11
5336
+ R365 hub-highlight base radius 5 → 5.5
5337
+ R367 edge-badge rest stroke 1 → 1.25 (this round)
5338
+ Cold edge badges gain ~25 % stroke presence
5339
+ (1.25/1.0 = 1.25). Stays clear of the R51
5340
+ overlap-test sentinel values (1.5 / 3 reserved
5341
+ for node strokes — the test selector is gated
5342
+ to g[data-node] ancestors so this edge-internal
5343
+ circle is invisible to that probe anyway, but
5344
+ 1.25 is a safe non-sentinel value regardless).
5345
+ R188 transition list 'stroke-width 300ms ease-
5346
+ out' still smoothes the hot/pin flip — now
5347
+ 1.25 → 2 instead of 1 → 2, same ease pace.
5348
+ data-edge-badge-stroke-width-rest exposes the
5349
+ new baseline for tests. */}
5350
+ {/* Round 371 / Loop: edge-badge cyber opacity 0.82
5351
+ → 0.85. Sibling theme-consistency polish to R370
5352
+ hub hover-ring 0.7 → 0.8. R251 designed this
5353
+ badge with opacity 0.82 (cyber) / 0.95 (light)
5354
+ — 13 % delta. Cyber-theme dark bg needs more
5355
+ alpha to read as 'present'; R371 narrows the
5356
+ gap to 10 %, bringing the badge closer to light
5357
+ theme's 0.95 floor. Light stays at 0.95
5358
+ (already in the legibility band). data-edge-
5359
+ badge-opacity attr exposes the resolved value.
5360
+ Theme-consistency polish family:
5361
+ R246/R247 panel transition family
5362
+ R251 edge badge fill/opacity baseline
5363
+ R370 hub hover-ring cyber 0.7 → 0.8
5364
+ R371 edge badge cyber 0.82 → 0.85 (this round)
5365
+ R164 r=9/10.5 hover-lift + R188/R251 transition
5366
+ list + R367 strokeWidth=1.25 cold rest preserved. */}
5367
+ {/* Round 394 / Loop: edge-badge gains a hover
5368
+ strokeWidth tier (1.5) between cold rest
5369
+ (R367 1.25) and pin/hot (2). Pre-R394 the
5370
+ badge lifted only its radius on hover (R164
5371
+ 9 → 10.5); the stroke stayed at cold rest
5372
+ 1.25 unless pin/hot kicked in, so a plain
5373
+ hover felt half-lifted — geometry expanded
5374
+ while the contour stayed thin. R394 adds
5375
+ strokeWidth=1.5 on isHoveredEdge so hover
5376
+ now lifts both r AND stroke in concert —
5377
+ same pattern R385 used for the hub hover-
5378
+ ring (1.5 → 1.75) where the ring's three
5379
+ hover axes (r grow / opacity fade-in /
5380
+ stroke thicken) all rise together.
5381
+ Three-tier stroke hierarchy now:
5382
+ cold rest 1.25 (R367)
5383
+ hovered 1.5 (R394 — this round)
5384
+ pinned / hot 2.0 (R188)
5385
+ R51 sentinel concern: strokeWidth=1.5 is
5386
+ one of the two sentinels reserved for node
5387
+ detection, but the R51 selector is gated
5388
+ to `g[data-node]` ancestors so this edge-
5389
+ internal circle is invisible to the probe
5390
+ (same lesson R177 hub hover-ring + R367
5391
+ cold rest documented). 300ms strokeWidth
5392
+ transition already in the style list eases
5393
+ the new tier naturally. data-edge-badge-
5394
+ stroke-width-hover attr exposes the hover
5395
+ value for tests. */}
5396
+ {/* Round 395 / Loop: edge-badge gains a third
5397
+ hover axis — opacity 0.85 (cyber) / 0.95
5398
+ (light) → 1.0 on isHoveredEdge. Pre-R395
5399
+ hovering thickened the stroke (R394 1.25 →
5400
+ 1.5) and grew the radius (R164 9 → 10.5)
5401
+ but the badge's translucency stayed put at
5402
+ R371's rest alpha (cyber 0.85 / light 0.95).
5403
+ R395 lifts hover to a clean 1.0 — fully
5404
+ opaque — so the hovered badge reads as
5405
+ "in focus" against the dim siblings.
5406
+ Three-axis hover-lift parity now complete:
5407
+ hub hover-ring (R177/R370/R385):
5408
+ r 14 → 17, opacity 0 → 0.8 cyber, sw 1.5 → 1.75
5409
+ edge badge (R164/R394/R395):
5410
+ r 9 → 10.5, sw 1.25 → 1.5, opacity → 1.0
5411
+ 200ms opacity transition (already in the
5412
+ style list) eases the new axis naturally.
5413
+ R371 rest opacity (0.85 cyber / 0.95 light)
5414
+ preserved as the resting alpha — R395
5415
+ adds an isHoveredEdge override on top.
5416
+ data-edge-badge-opacity-hover attr exposes
5417
+ the hover value for tests. */}
5418
+ {/* Round 396 / Loop: extend the R395 opacity → 1.0
5419
+ lift to the pinned state. Pre-R396 the badge
5420
+ shared `r=10.5` on both hover AND pin (R164
5421
+ unified-lift) but R395's opacity lift fired
5422
+ ONLY on isHoveredEdge — pinned badges stayed
5423
+ at R371 rest alpha (cyber 0.85 / light 0.95).
5424
+ That left pin (sticky selection) reading
5425
+ softer than hover (transient preview), even
5426
+ though pin is the stronger commitment.
5427
+ R396 unifies hover + pin at opacity=1.0
5428
+ so the same data-edge-badge-lifted='true'
5429
+ surface uniformly carries full alpha. Pin
5430
+ stroke (R188 sw=2 + pal.legendHeadline color)
5431
+ continues to differentiate pin from hover —
5432
+ the opacity track now closes the lift parity.
5433
+ The new gate (isHoveredEdge || isPinned)
5434
+ mirrors the existing R164 r-lift gate, so
5435
+ the badge has a single "active state"
5436
+ signature across r + opacity.
5437
+ 200ms opacity transition (already in style
5438
+ list) eases pin/unpin naturally. R371 rest
5439
+ opacity preserved as the resting alpha.
5440
+ data-edge-badge-opacity-hover renamed
5441
+ semantically to -active (covers hover+pin)
5442
+ via the new -opacity-active attr; the
5443
+ legacy -opacity-hover attr kept for R395
5444
+ test compatibility. */}
4553
5445
  <circle
4554
5446
  cx={badgeX} cy={badgeY}
4555
5447
  r={isHoveredEdge || isPinned ? 10.5 : 9}
4556
5448
  fill={pal.legendBox.fill}
4557
5449
  stroke={isPinned ? pal.legendHeadline : isHot ? hotStroke : pal.flowEdge}
4558
- strokeWidth={isPinned ? 2 : isHot ? 2 : 1}
4559
- opacity={isLight ? 0.95 : 0.82}
5450
+ strokeWidth={isPinned ? 2 : isHot ? 2 : isHoveredEdge ? 1.5 : 1.25}
5451
+ opacity={(isHoveredEdge || isPinned) ? 1 : (isLight ? 0.95 : 0.85)}
4560
5452
  data-edge-badge-lifted={(isHoveredEdge || isPinned) ? 'true' : 'false'}
5453
+ data-edge-badge-stroke-width-rest="1.25"
5454
+ data-edge-badge-stroke-width-hover="1.5"
5455
+ data-edge-badge-opacity={(isHoveredEdge || isPinned) ? 1 : (isLight ? 0.95 : 0.85)}
5456
+ data-edge-badge-opacity-rest={isLight ? 0.95 : 0.85}
5457
+ data-edge-badge-opacity-hover="1"
5458
+ data-edge-badge-opacity-active="1"
4561
5459
  style={{ transition: 'r 180ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out, fill 200ms ease-out, opacity 200ms ease-out' }}
4562
5460
  />
4563
5461
  {/* Round 224 / Loop: edge badge text gains the 4th
@@ -4596,16 +5494,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4596
5494
  x={badgeX} y={badgeY + 3}
4597
5495
  textAnchor="middle"
4598
5496
  fill={pal.legendHeadline}
4599
- fontSize="10"
5497
+ /* Round 361 / Loop: edge midpoint badge text
5498
+ fontSize 10 → 11. Sibling visual-weight bump
5499
+ to R360 hub digit 11 → 12. The badge digit
5500
+ is the per-edge equivalent of the hub digit
5501
+ — a high-information scalar (link.count) at
5502
+ a stable canvas position. Pre-R361 fontSize=
5503
+ 10 + R220 letter-spacing 0.4 + R224 tabular-
5504
+ nums made the digit READABLE but small
5505
+ against the r=9 / 18-px badge envelope;
5506
+ fontSize=11 nudges the glyph ~10 % bigger
5507
+ (bbox ~7×10 px from ~6×9 px) so the count
5508
+ reads more cleanly at glance — still well
5509
+ inside the r=9 idle circle and the r=10.5
5510
+ hover/pin lift (R164). y=badgeY+3 empirical
5511
+ vertical centring kept (1px drift at the
5512
+ bumped size is below the noise floor in
5513
+ the on-curve flow path).
5514
+ Visual-weight bump family:
5515
+ R287 minimap viewport stroke 1 → 1.5
5516
+ R295 legend swatch base radius 5.5 → 6
5517
+ R359 recent-row pip base radius 1.6 → 1.8
5518
+ R360 hub digit fontSize 11 → 12
5519
+ R361 edge-badge digit fontSize 10 → 11 (this round)
5520
+ data-edge-badge-text-font-size attr exposes
5521
+ the value for tests. R220 pin/hot letter-
5522
+ spacing tween + R224 tabular-nums + R188
5523
+ stroke-width pin/hot transitions all preserved. */
5524
+ fontSize="11"
4600
5525
  fontFamily="monospace"
4601
- fontWeight="700"
5526
+ /* R426 — edge-badge digit fontWeight 700 → 800 on
5527
+ (isPinned || isHot). 4th anchor on the "data
5528
+ tightens under attention" typographic-weight
5529
+ pattern:
5530
+ R416 chip-digit (chip-row hover trigger)
5531
+ R424 panel-digit (panel hover trigger)
5532
+ R425 hub-digit (hub hover trigger)
5533
+ R426 edge-badge-digit (pin/hot trigger) ← this
5534
+ The badge digit is the per-edge equivalent of
5535
+ the hub digit (R361 sibling fontSize bump
5536
+ reasoning). Stacks with R188 stroke-width pin/
5537
+ hot lift (1.25 → 1.5) + R220 letter-spacing pin/
5538
+ hot tween (0 → 0.4) for a 3-axis pin/hot signa-
5539
+ ture (edge structure + text spacing + text
5540
+ weight). The R408 transition is letter-spacing
5541
+ 300ms; R426 appends font-weight 300ms so the
5542
+ weight bump co-eases under the same cadence. */
5543
+ fontWeight={(isPinned || isHot) ? '800' : '700'}
4602
5544
  data-edge-badge-text={link.key}
4603
5545
  data-edge-badge-text-pin={(isPinned || isHot) ? 'true' : 'false'}
5546
+ data-edge-badge-text-font-size="11"
4604
5547
  style={{
4605
5548
  pointerEvents: 'none',
4606
5549
  fontVariantNumeric: 'tabular-nums',
4607
- letterSpacing: (isPinned || isHot) ? '0.4px' : '0px',
4608
- transition: 'letter-spacing 300ms ease-out',
5550
+ /* R431 — edge-badge digit 3-tier letter-spacing:
5551
+ rest 0 / isHoveredEdge 0.2 / (isPinned || isHot) 0.4.
5552
+ Mirrors R427 node-alias 3-tier (rest/hover/chat-
5553
+ target → 0/0.3/0.5) at edge-badge scope. Pre-R431
5554
+ letter-spacing only fired on pin/hot (R220) while
5555
+ pure edge hover lifted stroke (R394) + opacity
5556
+ (R395) + radius (R164) but left the text dead-
5557
+ typographic. R431 adds the missing typographic
5558
+ spacing axis to the edge-hover gesture so the
5559
+ text rises with the badge geometry. Pin/hot
5560
+ tier (0.4) still wins; hover is the mid step.
5561
+ Hover-letter-spacing family extension (7 anchors
5562
+ now): R344/R345/R347/R351/R420/R427/R431. */
5563
+ letterSpacing: (isPinned || isHot) ? '0.4px' :
5564
+ isHoveredEdge ? '0.2px' : '0px',
5565
+ transition: 'letter-spacing 300ms ease-out, font-weight 300ms ease-out',
4609
5566
  }}
4610
5567
  >{link.count}</text>
4611
5568
  </g>
@@ -4716,19 +5673,68 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4716
5673
  : workingCount <= 2 ? 1
4717
5674
  : workingCount <= 5 ? 2
4718
5675
  : 3;
5676
+ // Round 404 / Loop: hub-halo cyber trough opacity 0.08 →
5677
+ // 0.10. Pre-R404 the breath's low-point sat at α=0.08
5678
+ // cyber (per R84 family tuning) — the halo nearly faded
5679
+ // out at trough on the dark canvas. R404 lifts cyber
5680
+ // trough to 0.10. Per-bucket peak amplitudes [0.16/0.20/
5681
+ // 0.26/0.32] stay exactly tuned.
5682
+ //
5683
+ // Round 405 / Loop: hub-halo LIGHT trough 0.32 → 0.34 —
5684
+ // symmetric +0.02 lift to mirror R404's cyber treatment
5685
+ // across both themes. Pre-R405 only cyber got the lift
5686
+ // (R404 docstring noted "light already at the strong
5687
+ // end" as deliberate); but the cyber/light delta in
5688
+ // R404 was an inconsistency in the family pattern.
5689
+ // R405 closes the symmetry — both themes get +0.02
5690
+ // baseline lift, so the breath low-point reads with
5691
+ // matching confidence regardless of theme. Light peak
5692
+ // array [0.52/0.58/0.65/0.72] stays tuned.
5693
+ //
5694
+ // Stale-state legibility lift family (5 anchors now):
5695
+ // R317 subordinate-text gray-500 → gray-400
5696
+ // R358 freshness floor 0.25 → 0.30
5697
+ // R372 minimap offline-dot opacity 0.5 → 0.6
5698
+ // R404 hub-halo cyber trough 0.08 → 0.10
5699
+ // R405 hub-halo light trough 0.32 → 0.34 (this round)
5700
+ //
5701
+ // R84 per-bucket peak/dur + R245 ease-in-out spline
5702
+ // keySplines all preserved. Test fixture probes the
5703
+ // SMIL <animate> values via data-topo-hub-halo-trough
5704
+ // attr (now exposes both light + cyber resolved values).
4719
5705
  const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
4720
5706
  const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
4721
- const troughLight = 0.32;
4722
- const troughDark = 0.08;
5707
+ const troughLight = 0.34;
5708
+ const troughDark = 0.10;
4723
5709
  const dur = [4.0, 3.2, 2.7, 2.4][busy];
4724
5710
  const valuesLight = `${troughLight};${peakLight};${troughLight}`;
4725
5711
  const valuesDark = `${troughDark};${peakDark};${troughDark}`;
5712
+ // Round 408 / Loop: hub-halo radius 18 → 20. The
5713
+ // grounding halo (the breathing outer circle around
5714
+ // the hub center) is the canvas's signature breath
5715
+ // element — R84 family. R408 bumps r=18 → 20 so the
5716
+ // breath extends slightly further while keeping 4px
5717
+ // clearance before the spoke origin (still room for
5718
+ // spoke start anchors). Visual presence on the
5719
+ // canvas focal point lifts ~23% area (π·20²/π·18²
5720
+ // = 1.23) without changing the per-bucket opacity
5721
+ // envelope or breath rhythm. Visual-weight bump
5722
+ // family 13th anchor — pairs with R404/R405 trough
5723
+ // lifts so the halo now breathes both with more
5724
+ // visible amplitude AND more visual footprint.
5725
+ // R84 per-bucket peak/dur invariants + R244 calc-
5726
+ // Mode='spline' + R245 ease-in-out keySplines all
5727
+ // preserved. data-topo-hub-halo-radius attr exposes
5728
+ // value for tests.
4726
5729
  return (
4727
5730
  <circle
4728
- cx={cx} cy={cy} r="18"
5731
+ cx={cx} cy={cy} r="20"
4729
5732
  fill={isLight ? '#d1fae5' : '#10b981'}
4730
5733
  opacity={isLight ? 0.42 : 0.12}
4731
5734
  data-hub-busyness={busy}
5735
+ data-topo-hub-halo-radius="20"
5736
+ data-topo-hub-halo-trough={isLight ? troughLight : troughDark}
5737
+ data-topo-hub-halo-peak={isLight ? peakLight : peakDark}
4732
5738
  /* Round 253 / Loop: hub grounding halo fill transition
4733
5739
  for theme toggle. Pre-R253 the base fill (cyber
4734
5740
  #10b981 ↔ light #d1fae5) snapped while R244's SMIL
@@ -4827,11 +5833,40 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4827
5833
  textAnchor="middle"
4828
5834
  dy="0.36em"
4829
5835
  fill={isLight ? '#d1fae5' : '#ecfdf5'}
4830
- fontSize="11"
5836
+ /* Round 360 / Loop: hub working-count digit fontSize 11
5837
+ → 12. The hub is the canvas's focal point — its digit
5838
+ is the most-read scalar on the whole topology. R130
5839
+ sized it at 11 (well inside the r=10 / 20-px core);
5840
+ R360 nudges it to 12 (~13 px wide × 12 px tall, still
5841
+ well inside the 20-px diameter) for ~9 % more presence.
5842
+ Sibling visual-weight bump family:
5843
+ R287 minimap viewport stroke 1 → 1.5
5844
+ R295 legend swatch base radius 5.5 → 6
5845
+ R359 recent-row pip radius 1.6 → 1.8
5846
+ R360 hub digit fontSize 11 → 12 (this round)
5847
+ The R209 scale-1.08-on-hub-hover, R225 tabular-nums,
5848
+ R253 fill transition, R213 always-mount opacity gate
5849
+ all preserved. data-topo-hub-working-count-font-size
5850
+ attr exposes the value for tests. */
5851
+ fontSize="12"
4831
5852
  fontFamily="monospace"
4832
- fontWeight="700"
5853
+ /* R425 — hub digit fontWeight 700 → 800 on hoveredHub.
5854
+ Closes the "data tightens under attention" pattern
5855
+ across three focal scopes: chip-digit (R416, chip
5856
+ scope) → panel-digit (R424, panel-header scope) →
5857
+ hub-digit (R425, hub focal scope). The hub digit is
5858
+ the most-read scalar on the topology; adding a weight
5859
+ axis on hover stacks with the R209 scale-1.08 + R177
5860
+ ring grow + R370 halo opacity + R386 highlight
5861
+ opacity hub-hover gestures, giving the focal point
5862
+ a typographic axis alongside its scale/structure cues.
5863
+ R360 fontSize=12 + R225 tabular-nums + R209 scale +
5864
+ R253 fill transition all preserved. Transition list
5865
+ extends to include font-weight 200ms ease-out. */
5866
+ fontWeight={hoveredHub ? '800' : '700'}
4833
5867
  opacity={workingCount > 0 ? 1 : 0}
4834
5868
  data-topo-hub-working-count={workingCount}
5869
+ data-topo-hub-working-count-font-size="12"
4835
5870
  data-topo-hub-working-count-hovered={hoveredHub ? 'true' : 'false'}
4836
5871
  data-topo-hub-working-count-visible={workingCount > 0 ? 'true' : 'false'}
4837
5872
  // Round 209 / Loop: hub workingCount digit scales 1.0 →
@@ -4865,19 +5900,63 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4865
5900
  transform: !reducedMotion && hoveredHub ? 'scale(1.08)' : 'scale(1)',
4866
5901
  transformBox: 'fill-box',
4867
5902
  transformOrigin: 'center',
4868
- transition: 'transform 200ms ease-out, opacity 300ms ease-out, fill 200ms ease-out',
5903
+ /* R425: font-weight 200ms appended so the hover fw
5904
+ bump 700 → 800 eases under the same cadence as
5905
+ R209 scale + R253 fill + R213 opacity. */
5906
+ transition: 'transform 200ms ease-out, opacity 300ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out',
4869
5907
  fontVariantNumeric: 'tabular-nums',
4870
5908
  }}
4871
5909
  >
4872
5910
  {workingCount}
4873
5911
  </text>
4874
5912
  {/* decorative highlight (visible when workingCount === 0) */}
5913
+ {/* Round 365 / Loop: hub-center 'lit-lamp' decorative highlight
5914
+ circle r 5 → 5.5. Sibling visual-weight bump family —
5915
+ each round lifts one canvas anchor's geometric presence
5916
+ without disturbing its bbox envelope:
5917
+ R287 minimap viewport stroke 1 → 1.5
5918
+ R295 legend swatch base radius 5.5 → 6
5919
+ R359 recent-row pip base radius 1.6 → 1.8
5920
+ R360 hub digit fontSize 11 → 12
5921
+ R361 edge-badge digit fontSize 10 → 11
5922
+ R365 hub-highlight base radius 5 → 5.5 (this round)
5923
+ The highlight only renders when workingCount === 0
5924
+ (decorative 'lamp lit but idle' state per R130 + R213
5925
+ always-mount opacity-gate). At idle, the 0.5-px radius
5926
+ bump (21 % area, π*5.5² / π*5² = 1.21) lifts the lamp's
5927
+ presence — still well inside the r=10 hub-core (R130).
5928
+ opacity=0 when working preserved so the hub-digit's R130
5929
+ takeover stays seamless. R213 always-mount opacity-gate
5930
+ + 300ms opacity transition + pointerEvents:none all
5931
+ preserved. data-topo-hub-highlight-radius attr exposes
5932
+ the value for tests. */}
5933
+ {/* Round 386 / Loop: hub-highlight idle opacity 0.9 → 0.95.
5934
+ When workingCount===0 the highlight paints as the visible
5935
+ idle "lamp lit but no work" core (R130 takeover gate).
5936
+ Pre-R386 idle opacity was 0.9 — a ~6 % fade against full
5937
+ paint that read as slightly-dimmed-ghost on the focal
5938
+ point. R386 lifts to 0.95 (idle alpha gap halved 0.10
5939
+ → 0.05) so the canvas anchor reads more confidently
5940
+ as a present-but-idle state rather than a faded ghost.
5941
+ Theme-consistency / canvas-presence polish family (4th
5942
+ anchor):
5943
+ R370 hub hover-ring opacity 0.7 → 0.8 cyber
5944
+ R371 edge-badge rest opacity 0.82 → 0.85 cyber
5945
+ R372 minimap offline-dot opacity 0.5 → 0.6
5946
+ R386 hub-highlight idle opacity 0.9 → 0.95 (this round)
5947
+ opacity=0 when working preserved so the hub-digit's
5948
+ R130 takeover stays seamless. 300ms opacity transition
5949
+ + R213 always-mount opacity-gate + pointerEvents:none
5950
+ + R365 r=5.5 all preserved. data-topo-hub-highlight-
5951
+ opacity attr exposes the resolved value for tests. */}
4875
5952
  <circle
4876
- cx={cx} cy={cy} r="5"
5953
+ cx={cx} cy={cy} r="5.5"
4877
5954
  fill="#d1fae5"
4878
- opacity={workingCount > 0 ? 0 : 0.9}
5955
+ opacity={workingCount > 0 ? 0 : 0.95}
4879
5956
  data-topo-hub-highlight
4880
5957
  data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
5958
+ data-topo-hub-highlight-radius="5.5"
5959
+ data-topo-hub-highlight-opacity={workingCount > 0 ? 0 : 0.95}
4881
5960
  style={{
4882
5961
  pointerEvents: 'none',
4883
5962
  transition: 'opacity 300ms ease-out',
@@ -4908,15 +5987,46 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4908
5987
  R142 group boxes, R143/R144 rows, R164 edge badges,
4909
5988
  R177 hub ring). prefers-reduced-motion respected via
4910
5989
  R29 globals.css blanket. */}
5990
+ {/* Round 385 / Loop: hub hover-ring strokeWidth 1.5 → 1.75.
5991
+ Sibling visual-weight bump (11th anchor) to R367 edge-
5992
+ badge rest stroke 1 → 1.25. The ring is only visible
5993
+ during hub hover (opacity=0 rest, R177 + R370 control
5994
+ the hover-state alpha) so the change manifests purely
5995
+ as a thicker hover-state ring on the canvas focal
5996
+ point. R177 r 14 → 17 grow + R370 opacity 0 → 0.8
5997
+ already lift the hover cue; R385 adds stroke weight
5998
+ as the third lift axis. Stays clear of R51 overlap-
5999
+ test sentinel value 3 (1.75 is non-sentinel); the
6000
+ R51 selector is gated to g[data-node] ancestors so
6001
+ this hub-internal circle is invisible to the probe
6002
+ regardless. R253 stroke transition + pointerEvents:
6003
+ none preserved. data-topo-hub-hover-ring-stroke-width
6004
+ attr exposes the value for tests. */}
4911
6005
  <circle
4912
6006
  cx={cx} cy={cy}
4913
6007
  r={hoveredHub ? 17 : 14}
4914
6008
  fill="none"
4915
6009
  stroke={isLight ? '#059669' : '#10b981'}
4916
- strokeWidth="1.5"
4917
- opacity={hoveredHub ? (isLight ? 0.85 : 0.7) : 0}
6010
+ strokeWidth="1.75"
6011
+ /* Round 370 / Loop: hub hover-ring cyber opacity 0.7 →
6012
+ 0.8. R177 designed the hub hover-ring at opacity-0 →
6013
+ 0.85 (light) / 0 → 0.7 (cyber). The 15 % gap between
6014
+ the two themes meant cyber-theme operators got a
6015
+ noticeably softer hover cue than light-theme users
6016
+ against backgrounds that should equalise (dark bg
6017
+ needs more luminance to read as 'on'). R370 bumps
6018
+ cyber 0.7 → 0.8, narrowing the theme gap to 5 % —
6019
+ sibling theme-consistency polish to R251 edge badge
6020
+ fill/opacity (cyber 0.82 / light 0.95) and R246/R247
6021
+ panel transition families. Light theme 0.85 stays
6022
+ as is (already in the legibility band). data-topo-
6023
+ hub-hover-ring-opacity attr exposes the value for
6024
+ tests. */
6025
+ opacity={hoveredHub ? (isLight ? 0.85 : 0.8) : 0}
4918
6026
  data-topo-hub-hover-ring
4919
6027
  data-topo-hub-hover-ring-radius={hoveredHub ? 17 : 14}
6028
+ data-topo-hub-hover-ring-stroke-width="1.75"
6029
+ data-topo-hub-hover-ring-opacity={hoveredHub ? (isLight ? 0.85 : 0.8) : 0}
4920
6030
  /* Round 253 / Loop: hub hover ring also gets stroke
4921
6031
  transition for theme toggle (cyber #10b981 ↔ light
4922
6032
  #059669). The opacity + r transitions stay for hover
@@ -5345,14 +6455,51 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5345
6455
  keyTimes="0;0.5;1"
5346
6456
  keySplines="0.42 0 0.58 1;0.42 0 0.58 1"
5347
6457
  />
6458
+ {/* Round 409 / Loop: active-node pulse peak
6459
+ opacity lift — cyber 0.18 → 0.20 / light
6460
+ 0.12 → 0.14. Theme-consistency / canvas-
6461
+ presence family 9th anchor. R243 family
6462
+ rhythm preserved.
6463
+ Round 413 / Loop: trough lift mirrors R409
6464
+ peak — cyber 0.04 → 0.05 / light 0.02 →
6465
+ 0.03. Stale-state legibility lift family
6466
+ 8th anchor — pairs with R404 (hub-halo
6467
+ cyber trough 0.08 → 0.10) and R405 (light
6468
+ trough 0.32 → 0.34). The per-node breath's
6469
+ low-point now reads slightly above the
6470
+ "nearly gone" zone while preserving the
6471
+ breath amplitude (cyber Δ 0.16 vs Δ pre-
6472
+ R409+R413 of 0.14; light Δ 0.11 vs 0.10).
6473
+ Both peak (R409) AND trough (R413) lift
6474
+ together so the active-pulse signal stays
6475
+ confidently present at both ends of its
6476
+ 2.4s cycle.
6477
+ Stale-state legibility lift family (8):
6478
+ R317 subordinate-text gray-500→400
6479
+ R358 freshness floor 0.25→0.30
6480
+ R372 minimap offline-dot 0.5→0.6
6481
+ R404 hub-halo cyber trough 0.08→0.10
6482
+ R405 hub-halo light trough 0.32→0.34
6483
+ R406 edge freshness floor 0.35→0.40
6484
+ R407 node halo offline opacity (cyber+light)
6485
+ R413 active-node pulse trough (this round)
6486
+ cyber 0.04 → 0.05
6487
+ light 0.02 → 0.03
6488
+ R243 always-mount opacity-gate + R243
6489
+ ease-in-out keySplines + r animation
6490
+ (radius+8 ↔ radius+22) preserved.
6491
+ data-node-pulse-peak + new -pulse-trough
6492
+ attrs expose resolved per-theme values. */}
5348
6493
  <animate
5349
6494
  attributeName="opacity"
5350
- values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'}
6495
+ values={isLight ? '0.14;0.03;0.14' : '0.20;0.05;0.20'}
5351
6496
  dur="2.4s"
5352
6497
  repeatCount="indefinite"
5353
6498
  calcMode="spline"
5354
6499
  keyTimes="0;0.5;1"
5355
6500
  keySplines="0.42 0 0.58 1;0.42 0 0.58 1"
6501
+ data-node-pulse-peak={isLight ? '0.14' : '0.20'}
6502
+ data-node-pulse-trough={isLight ? '0.03' : '0.05'}
5356
6503
  />
5357
6504
  </circle>
5358
6505
  </g>
@@ -5397,12 +6544,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5397
6544
  parent stays for status flips. data-node-halo-
5398
6545
  breath-offset surfaces the chosen offset for
5399
6546
  test introspection. */}
6547
+ {/* Round 407 / Loop: offline node halo opacity lift —
6548
+ cyber 0.25 → 0.30 and light 0.4 → 0.45. Pre-R407
6549
+ offline node halos faded to α=0.25 cyber (75 %
6550
+ dim) / α=0.4 light. On the dark canvas the 0.25
6551
+ halo read as "nearly gone" — exactly the
6552
+ legibility floor R404/R405 just lifted on the
6553
+ hub-halo and R372 lifted on minimap offline dots.
6554
+ R407 closes the same family at the per-node halo
6555
+ surface: +0.05 lift on both themes so offline
6556
+ anchors stay legibly present without crossing into
6557
+ "could be online" territory (online cyber 0.65 /
6558
+ light 0.85 unchanged — the 0.30/0.65 cyber ratio
6559
+ still gives 2.17× contrast for online/offline).
6560
+ Stale-state legibility lift family (7 anchors now):
6561
+ R317 subordinate-text gray-500 → gray-400
6562
+ R358 freshness floor 0.25 → 0.30
6563
+ R372 minimap offline-dot 0.5 → 0.6
6564
+ R404 hub-halo cyber trough 0.08 → 0.10
6565
+ R405 hub-halo light trough 0.32 → 0.34
6566
+ R406 edge freshness floor 0.35 → 0.40
6567
+ R407 node halo offline opacity (this round)
6568
+ cyber 0.25 → 0.30
6569
+ light 0.4 → 0.45
6570
+ R278 retired-breath gate + R12 status.halo color
6571
+ + R226 phase stagger code-path preserved (the
6572
+ breath stays disabled per Vincent's R278 ask;
6573
+ only the BASE opacity floor shifts here). transi-
6574
+ tion list ('fill,opacity' 300ms ease-out) unchanged.
6575
+ data-node-halo-offline-opacity attr exposes the
6576
+ resolved value for tests. */}
5400
6577
  <circle
5401
6578
  cx={pos.x}
5402
6579
  cy={pos.y}
5403
6580
  r={radius + 8}
5404
6581
  fill={status.halo}
5405
- opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
6582
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.45 : 0.30)}
6583
+ data-node-halo-offline-opacity={isOnline ? undefined : (isLight ? 0.45 : 0.30)}
5406
6584
  className="transition-[fill,opacity] duration-300 ease-out"
5407
6585
  data-node-halo-breath={!reducedMotion && session.status === 'working' ? 'on' : 'off'}
5408
6586
  data-node-halo-breath-offset={
@@ -5554,20 +6732,62 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5554
6732
  stroke-width="3"/"1.5" still works against the
5555
6733
  DOM attribute value (React-rendered, not
5556
6734
  interpolated). */}
5557
- <circle
5558
- cx={pos.x}
5559
- cy={pos.y}
5560
- r={radius}
5561
- fill={isOnline ? pal.nodeFill.online : pal.nodeFill.offline}
5562
- stroke={status.primary}
5563
- strokeWidth={isOnline ? 3 : 1.5}
5564
- strokeDasharray={isOnline ? 'none' : '5 5'}
5565
- filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
5566
- data-node-status-ring={status.label}
5567
- style={{
5568
- transition: 'fill 300ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out',
5569
- }}
5570
- />
6735
+ {(() => {
6736
+ /* Round 438 / Loop: status ring strokeWidth hover lift —
6737
+ when hoveredAlias matches, the node's status ring
6738
+ thickens by +0.5: online 3 → 3.5, offline 1.5 → 2.
6739
+ Same absolute delta as R435 hub-spoke (idle 1→1.25
6740
+ used Δ +0.25 because rest base was thinner; status
6741
+ ring's heavier rest values 1.5/3 need a bigger
6742
+ +0.5 to register as visible thickening).
6743
+ Status-ring axis joins the node-hover cue stack
6744
+ (now 9 layers including link surfaces):
6745
+ R26 group translateY -2px per-node geometry
6746
+ R217 stroke tint legendAccent per-node card
6747
+ R142 drop-shadow boost per-node card
6748
+ R427 alias letter-spacing per-node text
6749
+ R428 sub-text letter-spacing per-node text
6750
+ R429 body opacity 0.94 → 1.0 per-node card
6751
+ R430 hub-spoke α+ link to hub (paint)
6752
+ R435 hub-spoke sw+ link to hub (geo)
6753
+ R94 edge α 1.7× inter-node link (paint)
6754
+ R436 edge sw 1.15× inter-node link (geo)
6755
+ R437 flow-rail sw 1 → 1.5 edge paint-layer
6756
+ R438 status-ring sw +0.5 ring geometry ← this round
6757
+ R51 sentinel safety: rest values 3 / 1.5 unchanged
6758
+ so the overlap-test selector `circle[stroke-width=
6759
+ "3"]` / `circle[stroke-width="1.5"]` inside
6760
+ g[data-node] still matches at rest. Hover values
6761
+ 3.5 / 2 are not in the reserved {1.5, 3} set so
6762
+ the selector wouldn't match them anyway; but the
6763
+ test runs WITHOUT hover so this never matters
6764
+ in practice. R167 stroke-width 300ms transition
6765
+ already in the style list eases the lift for
6766
+ free. data-node-status-ring-hovered exposes the
6767
+ gate for tests. */
6768
+ const isRingHovered = !reducedMotion && hoveredAlias === session.alias;
6769
+ const ringStrokeWidth = isOnline
6770
+ ? (isRingHovered ? 3.5 : 3)
6771
+ : (isRingHovered ? 2 : 1.5);
6772
+ return (
6773
+ <circle
6774
+ cx={pos.x}
6775
+ cy={pos.y}
6776
+ r={radius}
6777
+ fill={isOnline ? pal.nodeFill.online : pal.nodeFill.offline}
6778
+ stroke={status.primary}
6779
+ strokeWidth={ringStrokeWidth}
6780
+ strokeDasharray={isOnline ? 'none' : '5 5'}
6781
+ filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
6782
+ data-node-status-ring={status.label}
6783
+ data-node-status-ring-hovered={isRingHovered ? 'true' : 'false'}
6784
+ data-node-status-ring-stroke-width={ringStrokeWidth}
6785
+ style={{
6786
+ transition: 'fill 300ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out',
6787
+ }}
6788
+ />
6789
+ );
6790
+ })()}
5571
6791
  {/* v0.10.0 Hero 1+2 / §3.F server-health node-ring tint.
5572
6792
  When the host server this agent runs on is in the
5573
6793
  `red` tier (CPU/Mem/Disk worst-of ≥ 85% per
@@ -5891,14 +7111,69 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5891
7111
  220ms cadence the existing filter/stroke
5892
7112
  pair uses — coordinated 4-property easing
5893
7113
  across the card. */}
7114
+ {/* Round 411 / Loop: node label card rx 6 → 8.
7115
+ Pre-R411 the per-node label card painted at
7116
+ rx=6, sitting one tier BELOW the R332/R375/
7117
+ R376 compact-chrome tier (rx=8). Inside the
7118
+ corner-radius cascade family the cards used
7119
+ to be the only "smaller" tier — but the
7120
+ label card is a content-bearing surface
7121
+ (alias + sub text + ring), not a sub-
7122
+ element decoration. R411 lifts rx=6 → 8
7123
+ to align with the compact-chrome / segmented-
7124
+ control tier so all "compact card" surfaces
7125
+ read with the same corner radius.
7126
+ Corner-radius cascade (8 anchors now):
7127
+ R330 canvas rx 12 (root)
7128
+ R331 panels rx 10 (recent-signal, legend)
7129
+ R332 minimap container rx 8 (compact chrome)
7130
+ R375 Layout-toggle rx 8 (segmented control)
7131
+ R376 nodeSize/zoom rx 8 (segmented control)
7132
+ R390 hover-detail rx 10 (panel)
7133
+ R393 minimap viewport rx 2 (sub-element)
7134
+ R411 node label card rx 6 → 8 (compact card, this round)
7135
+ Pure paint — rx grows the corner curve
7136
+ inward without changing the card's outer
7137
+ cardW × cardH bbox (cardW=92/cardH=22 for
7138
+ standard nodes per R23 / R187 sizing). R217
7139
+ hover-stroke cyan tint + R142 drop-shadow
7140
+ + R246 fill+opacity 220ms transition list
7141
+ + R211 alias/sub text-fill ease all
7142
+ preserved. data-node-label-card-rx attr
7143
+ exposes the value for tests. */}
7144
+ {/* Round 429 / Loop: node label-card body opacity
7145
+ 0.94 → 1.0 on hover (cyber theme). Sibling
7146
+ treatment to R348 panel rect opacity lift —
7147
+ 0.92 → 0.97 cyber / 0.97 → 1.0 light at the
7148
+ panel scope. Pre-R429 the cyber theme card
7149
+ sat at 0.94 always; on hover R217 tinted the
7150
+ stroke + R142 grew the drop-shadow + R26
7151
+ lifted the group + R427/R428 spaced the text
7152
+ but the rect itself never solidified —
7153
+ the card glowed brighter through the
7154
+ shadow but the body alpha gap (6 pct) stayed
7155
+ fixed. R429 lifts the body to full alpha on
7156
+ hover so the card reads as a confidently
7157
+ present surface under the cursor (matching
7158
+ the panel-pair pattern). Light theme stays
7159
+ at 1.0 in both states (already maxed). R246
7160
+ transition list already covers opacity 220ms
7161
+ so the lift eases for free. R217 stroke tint
7162
+ + R142 drop-shadow + R211 fill ease all
7163
+ preserved (additive opacity branch only). */}
5894
7164
  <rect
5895
- x={-cardW / 2} y={cardTopY} width={cardW} height={cardH} rx="6"
7165
+ x={-cardW / 2} y={cardTopY} width={cardW} height={cardH} rx="8"
5896
7166
  fill={pal.labelBox.fill}
5897
7167
  stroke={!reducedMotion && hoveredAlias === session.alias
5898
7168
  ? pal.legendAccent
5899
7169
  : pal.labelBox.stroke}
5900
- opacity={isLight ? 1 : 0.94}
7170
+ opacity={
7171
+ !reducedMotion && hoveredAlias === session.alias
7172
+ ? 1
7173
+ : (isLight ? 1 : 0.94)
7174
+ }
5901
7175
  data-node-label-card={session.alias}
7176
+ data-node-label-card-rx="8"
5902
7177
  data-node-label-card-elevation={
5903
7178
  !reducedMotion && hoveredAlias === session.alias ? 'hover' : 'idle'
5904
7179
  }
@@ -5942,25 +7217,72 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5942
7217
  transition list extends 'letter-spacing
5943
7218
  200ms ease-out' so it eases alongside the
5944
7219
  existing 300ms fill transition. */}
7220
+ {/* Round 427 / Loop: extend the node alias label
7221
+ letter-spacing family to a 3-tier scale —
7222
+ rest 0px → hover 0.3px → chat-target 0.5px.
7223
+ Pre-R427 the alias text shifted only when
7224
+ chat was actively pinned (R305); pure node-
7225
+ hover left the text dead-typographic while
7226
+ the surrounding card lifted (R26 translateY
7227
+ + R242 stroke + filter cues). R427 adds the
7228
+ missing typographic axis to the hover gesture
7229
+ so the alias text rises with the card.
7230
+ The chat-target tier still wins (0.5 > 0.3)
7231
+ so the pin signature stays at the top of the
7232
+ scale — hover is the mid tier between rest
7233
+ and chat-target.
7234
+ Hover-letter-spacing family extension:
7235
+ R344 chip count digit
7236
+ R345 panel title (R423 sibling)
7237
+ R347 active-links chip
7238
+ R351 vendor chip
7239
+ R420 zoom-level chip
7240
+ R427 node alias text (this round)
7241
+ R211 fill 300ms + R305 letter-spacing 200ms
7242
+ transition list preserved; only the
7243
+ conditional gets a middle case. */}
5945
7244
  <text
5946
7245
  x="0" y="1" textAnchor="middle"
5947
7246
  fill={status.text}
5948
7247
  fontSize={aliasFs} fontFamily="monospace" fontWeight="700"
5949
7248
  data-node-alias-text={session.alias}
5950
7249
  data-node-alias-chat-target={chatAlias === session.alias ? 'true' : 'false'}
7250
+ data-node-alias-hovered={hoveredAlias === session.alias ? 'true' : 'false'}
5951
7251
  style={{
5952
7252
  transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out',
5953
- letterSpacing: chatAlias === session.alias ? '0.5px' : '0px',
7253
+ letterSpacing:
7254
+ chatAlias === session.alias ? '0.5px' :
7255
+ hoveredAlias === session.alias ? '0.3px' : '0px',
5954
7256
  }}
5955
7257
  >
5956
7258
  {truncate(session.alias, fullMax)}
5957
7259
  </text>
7260
+ {/* Round 428 / Loop: node sub-text (status label
7261
+ line beneath the alias) adopts hover letter-
7262
+ spacing tween 0 → 0.2px on hoveredAlias.
7263
+ Sibling treatment to R427 alias-text hover
7264
+ tween (0 → 0.3) — the alias is the primary
7265
+ identity (top-tier kerning 0.3), the sub-text
7266
+ is the secondary status line (one tier lower
7267
+ at 0.2). Now both lines of the label card
7268
+ telegraph hover typographically as one unit,
7269
+ matching the R26 card lift + R242 stroke
7270
+ tint + R975 filter cues. Subtler delta on
7271
+ the sub-text (0.2 vs alias 0.3) preserves
7272
+ the alias > status visual hierarchy at the
7273
+ hover scope. R211 fill 300ms transition
7274
+ preserved (additive letter-spacing branch
7275
+ + appended 'letter-spacing 200ms ease-out'). */}
5958
7276
  <text
5959
7277
  x="0" y={subY} textAnchor="middle"
5960
7278
  fill={status.primary}
5961
7279
  fontSize={subFs} fontFamily="monospace"
5962
7280
  data-node-sub-text={session.alias}
5963
- style={{ transition: 'fill 300ms ease-out' }}
7281
+ data-node-sub-text-hovered={hoveredAlias === session.alias ? 'true' : 'false'}
7282
+ style={{
7283
+ transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out',
7284
+ letterSpacing: hoveredAlias === session.alias ? '0.2px' : '0px',
7285
+ }}
5964
7286
  >
5965
7287
  {status.label}{isOnline && sseCountFor != null ? ` sse:${sseCountFor}` : ''}
5966
7288
  </text>
@@ -6033,26 +7355,122 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6033
7355
  const detailY = pos.y - detailH / 2;
6034
7356
  return (
6035
7357
  <g transform={`translate(${detailX}, ${detailY})`} data-topo-hover-detail={session.alias} style={{ pointerEvents: 'none' }}>
7358
+ {/* Round 387 / Loop: hover-detail panel cyber backdrop
7359
+ opacity 0.94 → 0.97. The hover-detail card is
7360
+ ALWAYS rendered in active-hover context (it IS
7361
+ the hover product), so it should carry the
7362
+ same backdrop weight as the R348 recent-signal /
7363
+ legend panel HOVER state (which lifts 0.92 →
7364
+ 0.97 cyber). Pre-R387 the card sat at 0.94
7365
+ cyber, leaving a 0.03 alpha gap against the
7366
+ R348 panel-hover state — small but visible
7367
+ when the hover-detail floats next to a hovered
7368
+ recent-signal panel. R387 unifies them at 0.97
7369
+ so all active-hover panels paint with the same
7370
+ confident backdrop opacity in cyber. Light
7371
+ stays at 0.98 (already at the strong end —
7372
+ R348 light also stays at 0.97/0.98 max).
7373
+ Theme-consistency / canvas-presence polish
7374
+ family (5th anchor):
7375
+ R370 hub hover-ring opacity 0.7 → 0.8 cyber
7376
+ R371 edge-badge rest opacity 0.82 → 0.85 cyber
7377
+ R372 minimap offline-dot opacity 0.5 → 0.6
7378
+ R386 hub-highlight idle opacity 0.9 → 0.95
7379
+ R387 hover-detail panel opacity 0.94 → 0.97 cyber (this round)
7380
+ data-topo-hover-detail-opacity attr exposes
7381
+ the resolved value for tests. R348 drop-shadow
7382
+ + rx=8 + stroke=pal.legendAccent + fill=pal.
7383
+ labelBox.fill all preserved. */}
7384
+ {/* Round 390 / Loop: hover-detail card rx 8 → 10.
7385
+ Corner-radius cascade family — the hover-detail
7386
+ card is a panel-tier surface (192×88 floating
7387
+ info card with drop-shadow + stroke), so its
7388
+ corner radius should match the R331 panel tier
7389
+ (rx=10) used by the recent-signal and legend
7390
+ panels. Pre-R390 it shared rx=8 with the R332
7391
+ minimap and R375/R376 segmented-control tier
7392
+ (Layout-toggle, nodeSize, zoom wrappers),
7393
+ which is the "compact chrome control" tier —
7394
+ a tier mismatch for a content-bearing panel.
7395
+ Corner-radius cascade (6 anchors now):
7396
+ R330 canvas rx 12 (root)
7397
+ R331 panels rx 10 (recent-signal, legend)
7398
+ R332 minimap rx 8 (compact chrome)
7399
+ R375 Layout-toggle rx 8 (segmented control)
7400
+ R376 nodeSize/zoom rx 8 (segmented control)
7401
+ R390 hover-detail rx 10 (panel — this round)
7402
+ Pure paint change; no layout shift (rx grows
7403
+ the corner curve INWARD without changing the
7404
+ card's outer bbox). data-topo-hover-detail-
7405
+ rx attr exposes the resolved value for tests.
7406
+ R348 drop-shadow + stroke + R387 opacity all
7407
+ preserved. */}
6036
7408
  <rect
6037
- x="0" y="0" width={detailW} height={detailH} rx="8"
7409
+ x="0" y="0" width={detailW} height={detailH} rx="10"
6038
7410
  fill={pal.labelBox.fill}
6039
7411
  stroke={pal.legendAccent}
6040
- opacity={isLight ? 0.98 : 0.94}
7412
+ opacity={isLight ? 0.98 : 0.97}
7413
+ data-topo-hover-detail-opacity={isLight ? 0.98 : 0.97}
7414
+ data-topo-hover-detail-rx="10"
6041
7415
  style={{ filter: isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.16))' : 'drop-shadow(0 4px 12px rgba(0,0,0,0.6))' }}
6042
7416
  />
6043
7417
  <text x="10" y="16" fontSize="9" fontFamily="monospace" fill={pal.legendAccent} fontWeight="700">
6044
7418
  {v.id !== 'unknown' ? v.label : '—'}
6045
7419
  </text>
6046
- <text x="10" y="32" fontSize="10" fontFamily="monospace" fill={pal.legendHeadline}>
7420
+ {/* Round 389 / Loop: hover-detail model line (y=32)
7421
+ fontWeight 400 → 600. R388 lifted body lines
7422
+ (runtime/host/task at fontSize=9) to fw=500;
7423
+ R389 closes the typography hierarchy by giving
7424
+ the model name (the dominant subhead text in
7425
+ the card) its own weight tier. Three-tier
7426
+ ladder now reads cleanly:
7427
+ vendor fontSize=9 fw=700 (label badge)
7428
+ model fontSize=10 fw=600 (subhead — this round)
7429
+ body 3× fontSize=9 fw=500 (R388)
7430
+ One tier step per dimension (size + weight)
7431
+ between adjacent levels — classic editorial
7432
+ hierarchy idiom adapted to a 192×88 SVG card.
7433
+ Sibling to the chip-internal-hierarchy arc
7434
+ (R333-R341/R362/R369) which uses fw=600/500
7435
+ for digit/unit pairs; R389 applies the same
7436
+ fw=600 to a content-bearing identity line.
7437
+ data-topo-hover-detail-model-fw attr exposes
7438
+ the resolved value for tests. pal.legendHeadline
7439
+ fill preserved (R389 doesn't touch color). */}
7440
+ <text x="10" y="32" fontSize="10" fontFamily="monospace" fontWeight="600" fill={pal.legendHeadline} data-topo-hover-detail-model-fw="600">
6047
7441
  {session.model || 'model · pending'}
6048
7442
  </text>
6049
- <text x="10" y="48" fontSize="9" fontFamily="monospace" fill={pal.legendText}>
7443
+ {/* Round 388 / Loop: hover-detail body lines (the
7444
+ three fontSize=9 lines: runtime, host, task)
7445
+ gain fontWeight=500. Small-text fw lift family
7446
+ (6th anchor) — fontSize 9-10 px text reads
7447
+ consistently bolder at fw=500 than at the
7448
+ default 400 weight at small sizes, especially
7449
+ on the cyber-theme backdrop where stroke-
7450
+ rendering is the limiting factor.
7451
+ Sibling lifts in this family:
7452
+ R363 recent-row alias text 400 → 500
7453
+ R364 legend-row label 400 → 500
7454
+ R366 group-label count tspan 400 → 500
7455
+ R368 +N more flows footer 400 → 500
7456
+ R373 pressure-bar kicker (font-medium)
7457
+ R388 hover-detail body lines 400 → 500 (this round)
7458
+ Tier structure preserved:
7459
+ y=16 vendor (fw=700, headline)
7460
+ y=32 model (fontSize=10, subhead by size)
7461
+ y=48 runtime / y=64 host / y=80 task (body, now fw=500)
7462
+ The y=80 task line keeps opacity=0.7 so its
7463
+ caption-tier identity stays distinct from the
7464
+ y=48 / y=64 body lines despite shared fw.
7465
+ data-topo-hover-detail-body-fw attr exposes
7466
+ the resolved value for tests. */}
7467
+ <text x="10" y="48" fontSize="9" fontFamily="monospace" fontWeight="500" fill={pal.legendText} data-topo-hover-detail-body-fw="500">
6050
7468
  {rt ? rt.label : 'runtime · pending'}
6051
7469
  </text>
6052
- <text x="10" y="64" fontSize="9" fontFamily="monospace" fill={pal.legendText}>
7470
+ <text x="10" y="64" fontSize="9" fontFamily="monospace" fontWeight="500" fill={pal.legendText} data-topo-hover-detail-body-fw="500">
6053
7471
  host · {session.server || 'unknown'}
6054
7472
  </text>
6055
- <text x="10" y="80" fontSize="9" fontFamily="monospace" fill={pal.legendText} opacity="0.7">
7473
+ <text x="10" y="80" fontSize="9" fontFamily="monospace" fontWeight="500" fill={pal.legendText} opacity="0.7" data-topo-hover-detail-body-fw="500">
6056
7474
  {session.task ? truncate(session.task, 28) : 'no recent task'}
6057
7475
  </text>
6058
7476
  </g>
@@ -6116,14 +7534,41 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6116
7534
  keySplines="0.25 0.1 0.25 1"
6117
7535
  fill="freeze"
6118
7536
  />
7537
+ {/* Round 403 / Loop: click-ripple SMIL initial opacity
7538
+ 0.7 → 0.8. Pre-R403 the ripple's opacity animation
7539
+ faded from 0.7 to 0 over 500ms, providing a clean
7540
+ click-feedback pulse. Theme-consistency / canvas-
7541
+ presence polish family (R370 hub hover-ring +
7542
+ R391 hub-spoke active) already lifted paired
7543
+ hover-state alphas from 0.7 → 0.8. R403 brings
7544
+ click-feedback into that same alpha — three canvas
7545
+ state-feedback indicators (hover-ring, active spoke,
7546
+ click ripple) now share a uniform 0.8 start alpha
7547
+ so the visual "I responded" signal carries the
7548
+ same weight regardless of which state fired it.
7549
+ Pre-R403 invariants preserved: 500ms duration,
7550
+ R227 calcMode='spline' + ease-out keySplines
7551
+ (0.25 0.1 0.25 1), fill='freeze', concurrent r
7552
+ animation. Theme-consistency family (8 anchors):
7553
+ R370 hub hover-ring 0.7 → 0.8
7554
+ R371 edge-badge rest 0.82 → 0.85 cyber
7555
+ R372 minimap offline-dot 0.5 → 0.6
7556
+ R386 hub-highlight idle 0.9 → 0.95
7557
+ R387 hover-detail panel 0.94 → 0.97 cyber
7558
+ R391 hub-spoke active 0.7 → 0.8
7559
+ R392 minimap online-dot 0.9 → 0.95
7560
+ R403 click-ripple start 0.7 → 0.8 (this round)
7561
+ data-click-ripple-start-opacity attr exposes the
7562
+ resolved value for tests. */}
6119
7563
  <animate
6120
7564
  attributeName="opacity"
6121
- values="0.7;0"
7565
+ values="0.8;0"
6122
7566
  dur="0.5s"
6123
7567
  calcMode="spline"
6124
7568
  keyTimes="0;1"
6125
7569
  keySplines="0.25 0.1 0.25 1"
6126
7570
  fill="freeze"
7571
+ data-click-ripple-start-opacity="0.8"
6127
7572
  />
6128
7573
  </circle>
6129
7574
  )}
@@ -6197,8 +7642,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6197
7642
  <rect
6198
7643
  x="0" y="0" width="230" height="88" rx="10"
6199
7644
  fill={pal.legendBox.fill}
6200
- stroke={pal.legendBox.stroke}
6201
- opacity={isLight ? 0.97 : 0.92}
7645
+ // Round 423 / Loop: panel rect stroke tints to legendAccent
7646
+ // (cyan) on hover — sibling to R217 label-card stroke
7647
+ // hover-tint at the panel scope. Pre-R423 the panel rect
7648
+ // stroke painted pal.legendBox.stroke (neutral) regardless
7649
+ // of hover state, while every other panel hover cue stacked:
7650
+ // R135 drop-shadow boost
7651
+ // R348 rect opacity 0.92 → 0.97 cyber
7652
+ // R345 title letter-spacing 0.3 → 0.4
7653
+ // R423 rect stroke → legendAccent (this round)
7654
+ // Four hover layers now telegraph "you're entering this
7655
+ // panel" through structural, paint, and typographic axes
7656
+ // simultaneously. R247 transition list already covers
7657
+ // stroke 200ms ease-out so the tint eases naturally.
7658
+ // Sibling change at the legend panel rect below.
7659
+ stroke={hoveredPanel === 'recent' ? pal.legendAccent : pal.legendBox.stroke}
7660
+ opacity={hoveredPanel === 'recent' ? (isLight ? 1 : 0.97) : (isLight ? 0.97 : 0.92)}
6202
7661
  style={{
6203
7662
  /* R135: drop-shadow intensifies on panel hover. Base
6204
7663
  shadow (2px / 6px blur) signals card elevation
@@ -6250,7 +7709,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6250
7709
  title (line ~6556) — both panels share the same
6251
7710
  editorial-text-spacing convention. data-recent-panel-
6252
7711
  title handle unchanged so R266 test still resolves. */}
6253
- <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700" letterSpacing="0.3" style={{ transition: 'fill 200ms ease-out' }} data-recent-panel-title>recent signal</text>
7712
+ {/* Round 345 / Loop: recent-signal panel title gains
7713
+ letter-spacing tween 0.3 → 0.4 on panel hover.
7714
+ hoveredPanel === 'recent' is set by the panel <g>
7715
+ wrapper's onMouseEnter (line ~6263 area). Sibling to
7716
+ R344 hover-letter-spacing applied to the +N more
7717
+ flows footer — same gesture vocabulary at a panel-
7718
+ title scope: hovering the panel chrome spreads the
7719
+ title 0.1 px, signalling "this is a coherent unit
7720
+ you're entering". transition list extends letter-
7721
+ spacing 200ms ease-out alongside existing fill 200ms. */}
7722
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700" letterSpacing={hoveredPanel === 'recent' ? '0.4' : '0.3'} style={{ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out' }} data-recent-panel-title>recent signal</text>
6254
7723
  {/* R96: header count now matches what the rows show. Pre-R96
6255
7724
  this read "X msgs" off the raw messages array, but the
6256
7725
  rows below render DEDUPED flowLinks — so a fleet with 10
@@ -6311,8 +7780,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6311
7780
  const alpha = ageSec <= 30
6312
7781
  ? 1
6313
7782
  : ageSec <= 300
6314
- ? 1 - ((ageSec - 30) / 270) * 0.75
6315
- : 0.25;
7783
+ ? 1 - ((ageSec - 30) / 270) * 0.70 /* R358: floor 0.25 → 0.30 lift across 3 freshness scopes */
7784
+ : 0.30; /* R358: stale floor lifted 0.25 → 0.30 — 20% legibility bump while preserving fresh/stale ratio */
6316
7785
  // Dark cyan-400 / light teal-600 with alpha — same
6317
7786
  // palette as R161's chip bullet so the two scopes
6318
7787
  // visually align even side-by-side.
@@ -6325,6 +7794,21 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6325
7794
  textAnchor="end"
6326
7795
  fontSize="10"
6327
7796
  fontFamily="monospace"
7797
+ // Round 349 / Loop: editorial letter-spacing 0.2 on the
7798
+ // recent-signal panel header count. Sits one tier below
7799
+ // the R301 panel title letterSpacing="0.3" so the panel
7800
+ // header reads as a 2-step hierarchy (title 0.3 / count
7801
+ // 0.2). Sibling change on the legend panel count below
7802
+ // closes the panel-pair editorial symmetry. Joins the
7803
+ // R285 / R289 / R301 / R302 / R304 / R325 editorial-
7804
+ // letterspacing tier at the panel-summary scope. The
7805
+ // R162 freshness fill, R225 tabular-nums, R311 fw=600,
7806
+ // R336 unit-tspan opacity-0.7 split all preserved —
7807
+ // the tier propagates to all descendant tspans via
7808
+ // SVG inheritance. data-recent-panel-count-letter-
7809
+ // spacing exposes the value for tests.
7810
+ letterSpacing="0.2"
7811
+ data-recent-panel-count-letter-spacing="0.2"
6328
7812
  >
6329
7813
  {/* Round 225 / Loop: tabular-nums on the panel-header
6330
7814
  flow-count tspan. The "{N} flows" string lives in
@@ -6365,13 +7849,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6365
7849
  tests + count value reads still resolve via
6366
7850
  .textContent. data-recent-panel-count-unit on
6367
7851
  the inner unit tspan for R336 introspection. */}
7852
+ {/* R424 — recent-signal panel count digit fontWeight
7853
+ 600 → 700 on panel hover. Closes the 5-layer panel
7854
+ hover cue stack with a typographic-weight axis at
7855
+ the panel-header data scope: depth (R135 drop-
7856
+ shadow) + solidity (R348 fill opacity) + spacing
7857
+ (R345 title letter-spacing) + edge color (R423
7858
+ stroke tint) + weight (THIS, digit fw). Sibling
7859
+ pattern to R416 chip-digit-hover-bold at chip
7860
+ scope — same "data tightens under attention"
7861
+ idiom now at the panel-header data scope. R311
7862
+ base fw=600 + R225 tabular-nums + R162 fill
7863
+ transition + R336 unit-tspan opacity-0.7 all
7864
+ preserved; only the weight axis tweens via R247's
7865
+ transition shape (added font-weight to the list). */}
6368
7866
  <tspan
6369
7867
  fill={freshFill}
6370
- fontWeight="600"
7868
+ fontWeight={hoveredPanel === 'recent' ? '700' : '600'}
6371
7869
  data-recent-panel-count
6372
7870
  data-recent-panel-count-freshness-alpha={alpha.toFixed(2)}
6373
7871
  style={{
6374
- transition: 'fill 200ms ease-out',
7872
+ transition: 'fill 200ms ease-out, font-weight 200ms ease-out',
6375
7873
  fontVariantNumeric: 'tabular-nums',
6376
7874
  }}
6377
7875
  >{flowLinks.length}<tspan opacity="0.7" data-recent-panel-count-unit> flows</tspan></tspan>
@@ -6793,17 +8291,58 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6793
8291
  const alpha = ageSec <= 30
6794
8292
  ? 1
6795
8293
  : ageSec <= 300
6796
- ? 1 - ((ageSec - 30) / 270) * 0.75
6797
- : 0.25;
8294
+ ? 1 - ((ageSec - 30) / 270) * 0.70 /* R358: floor 0.25 → 0.30 lift across 3 freshness scopes */
8295
+ : 0.30; /* R358: stale floor lifted 0.25 → 0.30 — 20% legibility bump while preserving fresh/stale ratio */
6798
8296
  return (
6799
8297
  <circle
6800
8298
  cx={10}
6801
8299
  cy={38 + index * 16 - 3}
6802
- r={1.6}
8300
+ /* Round 359 / Loop: recency pip base radius
8301
+ 1.6 → 1.8. Sibling lift to R358's freshness-
8302
+ floor bump (alpha 0.25 → 0.30) — pre-R358/
8303
+ R359 the stale pip painted at r=1.6 + α=0.25
8304
+ which read as near-invisible chrome. R358
8305
+ gave it more alpha; R359 gives it more area
8306
+ (1.8² / 1.6² ≈ 1.27, so ~27 % more glyph)
8307
+ so the pip stays distinguishable across the
8308
+ freshness ramp. Geometry: 1.8-radius dot
8309
+ centred at (10, row_y - 3) is bbox 3.6×3.6,
8310
+ still well inside the 7-px left margin
8311
+ (x=6 rect-start → x=13 text-start) the R160
8312
+ pip was placed in. Overlap-test reads the
8313
+ parent row rect's bbox, not this pip's, so
8314
+ grid+ring invariants hold. Matches the same
8315
+ 1.6 → 1.8 visual-weight bump R295 applied
8316
+ to the legend swatch (5.5 → 6 base radius)
8317
+ and R287 to the minimap viewport stroke
8318
+ (1 → 1.5). data-recent-row-freshness-radius
8319
+ attr exposes the value for tests. */
8320
+ /* Round 383 / Loop: recency pip base radius
8321
+ 1.8 → 2.0. Continues the R359 lift
8322
+ trajectory — pip area grows ~23 % (π·2²/
8323
+ π·1.8² ≈ 1.23) for a clearer at-a-glance
8324
+ freshness anchor in each row. Bbox 4.0×4.0
8325
+ still inside the 7-px R160 left margin
8326
+ (3-px remaining clearance vs 3.4 at r=1.8
8327
+ — geometry-safe margin holds). Sibling
8328
+ visual-weight bump family (9th anchor now):
8329
+ R287 minimap viewport stroke 1 → 1.5
8330
+ R295 legend swatch base radius 5.5 → 6
8331
+ R359 recent-row pip base radius 1.6 → 1.8
8332
+ R360 hub digit fontSize 11 → 12
8333
+ R361 edge-badge digit fontSize 10 → 11
8334
+ R365 hub-highlight base radius 5 → 5.5
8335
+ R367 edge-badge rest stroke 1 → 1.25
8336
+ R374 pressure-bar height 1.5 → 2
8337
+ R383 recent-row pip radius 1.8 → 2.0 (this round)
8338
+ data-recent-row-freshness-radius attr
8339
+ bumps to '2.0' for tests. */
8340
+ r={2.0}
6803
8341
  fill={pal.legendAccent}
6804
8342
  opacity={alpha}
6805
8343
  data-recent-row-freshness={link.key}
6806
8344
  data-recent-row-freshness-alpha={alpha.toFixed(2)}
8345
+ data-recent-row-freshness-radius="2.0"
6807
8346
  style={{ pointerEvents: 'none', transition: 'opacity 200ms ease-out' }}
6808
8347
  />
6809
8348
  );
@@ -6833,11 +8372,65 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6833
8372
  fill={isRowActive ? pal.legendHeadline : pal.legendText}
6834
8373
  fontSize="9"
6835
8374
  fontFamily="monospace"
8375
+ /* Round 363 / Loop: recent-row text fontWeight 400
8376
+ → 500 (font-medium tier). At fontSize=9 the
8377
+ default-weight 400 glyphs read thin against the
8378
+ panel chrome (pal.legendBox.fill with 0.92/0.97
8379
+ opacity); the 100-weight bump lifts the alias→
8380
+ alias text into the legibility band without
8381
+ changing geometry. The R320 count tspan fw=600
8382
+ (cold) / fw=700 (hot) override still wins
8383
+ locally via inline fontWeight on the inner
8384
+ tspan, so the count-vs-alias hierarchy stays
8385
+ intact:
8386
+ alias fw 500 (R363, this round)
8387
+ count fw 600/700 (R320)
8388
+ Sibling typography lift to R362 chip-row digit
8389
+ 500 → 600 — both nudge a within-element data
8390
+ tier without disturbing the surrounding family
8391
+ baseline. data-recent-row-text-font-weight attr
8392
+ exposes the value for tests. */
8393
+ fontWeight="500"
6836
8394
  data-recent-row-text={link.key}
6837
8395
  data-recent-row-text-pinned={isRowPinned ? 'true' : 'false'}
8396
+ data-recent-row-text-hovered={!isRowPinned && isRowHovered ? 'true' : 'false'}
8397
+ data-recent-row-text-font-weight="500"
8398
+ /* Round 434 / Loop: recent-signal row text extends
8399
+ from R220's pin-only letter-spacing (0 → 0.5 on
8400
+ isRowPinned) to a 3-tier scale matching R433
8401
+ legend-row at the sibling panel-row scope:
8402
+ rest → 0px
8403
+ isRowHovered → 0.25px ← this round
8404
+ isRowPinned → 0.5px (R220 preserved)
8405
+ Pre-R434 R220 noted: "Hover stays at default
8406
+ tracking — the spread is pin-exclusive so
8407
+ users can read pinned vs hovered at the text
8408
+ alone." R427-R433 established a 3-tier pattern
8409
+ across 4 surfaces (node-alias, edge-badge,
8410
+ group-label, legend-row) where hover gets a
8411
+ subtler intermediate kerning step distinct
8412
+ from the pin tier's stronger spread — the
8413
+ locked vs preview discrimination R220 wanted
8414
+ is preserved (0.5 > 0.25) AND hover gets a
8415
+ typographic axis of its own. R434 completes
8416
+ the 5-surface arc.
8417
+ 5-surface 3-tier letter-spacing pattern now
8418
+ spans every interactive label on TopoGraph
8419
+ that distinguishes hover from pin:
8420
+ node-alias (R427) 0 / 0.3 / 0.5
8421
+ edge-badge (R431) 0 / 0.2 / 0.4
8422
+ group-label (R432) 0 / 0.25 / 0.5
8423
+ legend-row (R433) 0 / 0.25 / 0.5
8424
+ recent-row (R434) 0 / 0.25 / 0.5 ← this round
8425
+ Hover-letter-spacing family extension
8426
+ (10 anchors now): R344/R345/R347/R351/R420/
8427
+ R427/R431/R432/R433/R434. R55 fill 150ms +
8428
+ R220 letter-spacing 150ms transition kept
8429
+ (additive conditional case, no new property). */
6838
8430
  style={{
6839
8431
  transition: 'fill 150ms ease-out, letter-spacing 150ms ease-out',
6840
- letterSpacing: isRowPinned ? '0.5px' : '0px',
8432
+ letterSpacing: isRowPinned ? '0.5px' :
8433
+ isRowHovered ? '0.25px' : '0px',
6841
8434
  }}
6842
8435
  >
6843
8436
  {/* R138 / Loop: typography unification with the rest
@@ -6909,7 +8502,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6909
8502
  >
6910
8503
  {link.count}
6911
8504
  </tspan>
6912
- {' · '}{truncate(link.content, 8)}
8505
+ {/* Round 418 / Loop: recent-row content preview
8506
+ gains opacity=0.7 wrapper — subordinate-text
8507
+ tier at the SVG-text scope. Pre-R418 the
8508
+ truncated content preview (e.g. " · hi there")
8509
+ inherited the row's full opacity, reading at
8510
+ the same emphasis as the alias text and
8511
+ count digit. R418 wraps it in a <tspan> at
8512
+ opacity=0.7 so the preview reads as
8513
+ subordinate metadata — sibling to R333-R341/
8514
+ R362/R369/R389/R410/R412 chip-internal-
8515
+ hierarchy "label tier" (opacity-70) at the
8516
+ HTML scope, and R317 subordinate-text-lift
8517
+ gray-500 → gray-400 family. The leading
8518
+ " · " separator stays at full opacity so
8519
+ the row punctuation rhythm holds. data-
8520
+ recent-row-content-tspan attr surfaces the
8521
+ subordinate wrapper for tests. */}
8522
+ {' · '}
8523
+ <tspan opacity="0.7" data-recent-row-content-tspan>{truncate(link.content, 8)}</tspan>
6913
8524
  </text>
6914
8525
  {lastAt ? (
6915
8526
  /* Round 321 / Loop: lastAt freshness timestamp picks
@@ -7085,6 +8696,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7085
8696
  preserved for the <title> tooltip — only the SVG
7086
8697
  render splits. data-recent-panel-more-unit exposes
7087
8698
  the unit tspan for tests. */}
8699
+ {/* Round 344 / Loop: footer hover gains letter-spacing
8700
+ tween 0.2 → 0.3. R325 set rest letter-spacing
8701
+ 0.2 to join the editorial-spacing family; R344
8702
+ adds a 0.1px hover spread that layers on top of
8703
+ R195 cyan fill + R325 spacing + R133 underline
8704
+ so the footer reads "lit up and spaced" on
8705
+ hover — sibling to R218/R219/R220 pin-signature
8706
+ letter-spacing family applied to a hover-only
8707
+ surface. transition list extends letter-spacing
8708
+ 200ms ease-out alongside the existing opacity/
8709
+ fill easings. */}
8710
+ {/* Round 368 / Loop: `+N more flows` footer text gains
8711
+ fontWeight=500 (font-medium tier). Sibling small-
8712
+ text fw lift family with R363 recent-row alias
8713
+ + R364 legend-row label + R366 group-label count
8714
+ — all four lifts share the same theory: at small
8715
+ fontSize (9-11 px) against panel chrome, SVG-
8716
+ default fw 400 sits at the legibility floor;
8717
+ fw 500 brings the glyph into the deliberate-data
8718
+ band. fontStyle=italic + opacity 0.55 rest + R325
8719
+ letterSpacing 0.2 baseline + R344 hover-spread
8720
+ 0.2 → 0.3 + R195 cyan fill on hover all preserved
8721
+ — the fw bump just thickens the italic stroke.
8722
+ Hover-state punch (R195 fill + R325 opacity 0.55
8723
+ → 0.85 + R344 letter-spacing + R133 underline)
8724
+ stays as is, so the rest-vs-hover delta still
8725
+ reads clearly. data-recent-panel-more-font-weight
8726
+ attr exposes the value for tests. */}
7088
8727
  <text
7089
8728
  x="115" y="82"
7090
8729
  textAnchor="middle"
@@ -7092,12 +8731,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7092
8731
  fontSize="9"
7093
8732
  fontFamily="monospace"
7094
8733
  fontStyle="italic"
7095
- letterSpacing="0.2"
8734
+ fontWeight="500"
8735
+ letterSpacing={hoveredRecentMore ? '0.3' : '0.2'}
7096
8736
  opacity={hoveredRecentMore ? 0.85 : 0.55}
7097
8737
  textDecoration={hoveredRecentMore ? 'underline' : 'none'}
7098
8738
  data-recent-panel-more={moreCount}
7099
8739
  data-recent-panel-more-hovered={hoveredRecentMore ? 'true' : 'false'}
7100
- style={{ transition: 'opacity 150ms ease-out, fill 200ms ease-out' }}
8740
+ data-recent-panel-more-font-weight="500"
8741
+ style={{ transition: 'opacity 150ms ease-out, fill 200ms ease-out, letter-spacing 200ms ease-out' }}
7101
8742
  >
7102
8743
  {`+ ${moreCount}`}
7103
8744
  <tspan opacity="0.7" data-recent-panel-more-unit>{` more flow${moreCount === 1 ? '' : 's'}`}</tspan>
@@ -7148,8 +8789,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7148
8789
  <rect
7149
8790
  x="0" y="0" width="224" height="88" rx="10"
7150
8791
  fill={pal.legendBox.fill}
7151
- stroke={pal.legendBox.stroke}
7152
- opacity={isLight ? 0.97 : 0.92}
8792
+ // R423 sibling — legend panel rect stroke tints to
8793
+ // legendAccent on hover (mirrors recent-signal panel
8794
+ // above). 4-layer hover cue stack now symmetric across
8795
+ // both side panels.
8796
+ stroke={hoveredPanel === 'legend' ? pal.legendAccent : pal.legendBox.stroke}
8797
+ // R348 sibling — legend panel rect opacity hover-state
8798
+ // bump 0.92 → 0.97 (cyber) / 0.97 → 1 (light) on
8799
+ // hoveredPanel === 'legend'. Pairs with the recent-signal
8800
+ // panel rect above so the two corner panels' hover cues
8801
+ // stay symmetric. Geometry-safe (paint-only).
8802
+ opacity={hoveredPanel === 'legend' ? (isLight ? 1 : 0.97) : (isLight ? 0.97 : 0.92)}
7153
8803
  style={{
7154
8804
  filter: hoveredPanel === 'legend'
7155
8805
  ? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
@@ -7172,7 +8822,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7172
8822
  surrounding chrome eased; R266 closes both at once. */}
7173
8823
  {/* R301: sibling to recent-signal panel title above —
7174
8824
  same letterSpacing 0.3 for editorial parity. */}
7175
- <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700" letterSpacing="0.3" style={{ transition: 'fill 200ms ease-out' }} data-legend-panel-title>legend</text>
8825
+ {/* R345 sibling — legend panel title same hover letter-
8826
+ spacing tween 0.3 → 0.4 on panel hover. */}
8827
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700" letterSpacing={hoveredPanel === 'legend' ? '0.4' : '0.3'} style={{ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out' }} data-legend-panel-title>legend</text>
7176
8828
  {/* Round 257 / Loop: legend panel header count picks up the
7177
8829
  symmetric 13L/13R inner-padding pattern from the recent-
7178
8830
  signal panel. Pre-R257 the legend header was 13px from
@@ -7233,12 +8885,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7233
8885
  R336 introspection; the parent .textContent still
7234
8886
  reads "{N} node(s)" so existing R310 count tests via
7235
8887
  textContent unchanged. */}
8888
+ {/* R424 sibling — legend panel count digit fontWeight 600
8889
+ → 700 on panel hover. Closes 5-layer panel hover cue
8890
+ stack symmetric across both side panels (recent-signal
8891
+ + legend): depth (R135) + solidity (R348) + spacing
8892
+ (R345) + edge color (R423) + weight (R424). R310 base
8893
+ fw=600 + R292 tabular-nums + R266 fill transition + R336
8894
+ unit-tspan opacity-0.7 all preserved. Same "data tightens
8895
+ under attention" idiom R416 established at chip scope. */}
7236
8896
  <text
7237
8897
  x="211" y="21" textAnchor="end"
7238
- fill={pal.legendAccent} fontSize="10" fontFamily="monospace" fontWeight="600"
8898
+ fill={pal.legendAccent} fontSize="10" fontFamily="monospace" fontWeight={hoveredPanel === 'legend' ? '700' : '600'}
8899
+ // R349 sibling — legend panel header count picks up
8900
+ // letterSpacing="0.2", one tier below the R301 panel
8901
+ // title 0.3. Pairs with the recent-signal panel count
8902
+ // letter-spacing above so the two corner panels' header
8903
+ // typography stays editorially symmetric.
8904
+ letterSpacing="0.2"
7239
8905
  data-legend-panel-count
8906
+ data-legend-panel-count-letter-spacing="0.2"
7240
8907
  style={{
7241
- transition: 'fill 200ms ease-out',
8908
+ transition: 'fill 200ms ease-out, font-weight 200ms ease-out',
7242
8909
  fontVariantNumeric: 'tabular-nums',
7243
8910
  }}
7244
8911
  >{sessions.length}<tspan opacity="0.7" data-legend-panel-count-unit> node{sessions.length === 1 ? '' : 's'}</tspan></text>
@@ -7457,14 +9124,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7457
9124
  so this legend-internal circle is invisible to
7458
9125
  that probe. pointerEvents:none so the ring can't
7459
9126
  intercept the row click that produced it. */}
9127
+ {/* Round 402 / Loop: legend pin-ring strokeWidth 1.5
9128
+ → 1.75. Sibling visual-weight bump (12th anchor)
9129
+ to R385 hub hover-ring strokeWidth 1.5 → 1.75 —
9130
+ both are pin/hover state indicators painted as
9131
+ stroke-only circles outside their target swatch
9132
+ with the R51 sentinel value 1.5. R402 lifts to
9133
+ 1.75 (matching R385's choice) so the pin signal
9134
+ reads slightly heavier without crossing the
9135
+ R51 sentinel band (3 reserved for offline node).
9136
+ The R51 selector is gated to g[data-node]
9137
+ ancestors so this legend-internal circle (lives
9138
+ under a <g data-legend-status>) is invisible
9139
+ to the probe — same lesson R177/R385 documented.
9140
+ Visual-weight bump family (12 anchors now):
9141
+ R287 minimap viewport stroke 1 → 1.5
9142
+ R295 legend swatch radius 5.5 → 6
9143
+ R359 recent-row pip radius 1.6 → 1.8
9144
+ R360 hub digit fontSize 11 → 12
9145
+ R361 edge-badge digit fontSize 10 → 11
9146
+ R365 hub-highlight radius 5 → 5.5
9147
+ R367 edge-badge rest stroke 1 → 1.25
9148
+ R374 pressure-bar height 1.5 → 2
9149
+ R383 recent-row pip radius 1.8 → 2.0
9150
+ R384 minimap online dot 1.7 → 1.9
9151
+ R385 hub hover-ring stroke 1.5 → 1.75
9152
+ R402 legend pin-ring stroke 1.5 → 1.75 (this round)
9153
+ R181 always-mount opacity gate + 150ms transition
9154
+ + pointerEvents:none all preserved. data-legend-
9155
+ pin-ring-stroke-width attr exposes the value for
9156
+ tests. */}
7460
9157
  <circle
7461
9158
  cx="16" cy={row.y0} r="8"
7462
9159
  fill="none"
7463
9160
  stroke={row.fill}
7464
- strokeWidth="1.5"
9161
+ strokeWidth="1.75"
7465
9162
  opacity={isPinned ? 1 : 0}
7466
9163
  data-legend-pin-ring={row.key}
7467
9164
  data-legend-pin-ring-pinned={isPinned ? 'true' : 'false'}
9165
+ data-legend-pin-ring-stroke-width="1.75"
7468
9166
  style={{
7469
9167
  pointerEvents: 'none',
7470
9168
  transition: 'opacity 150ms ease-out',
@@ -7491,11 +9189,61 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7491
9189
  fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
7492
9190
  fontSize="11"
7493
9191
  fontFamily="monospace"
9192
+ /* Round 364 / Loop: legend-row label fontWeight 400
9193
+ → 500. Sibling typography lift to R363 recent-row
9194
+ text fw 400 → 500. Both surfaces render small
9195
+ monospace text against panel chrome at fontSize
9196
+ 9-11 where SVG-default fw 400 sits at the
9197
+ legibility floor. font-medium tier (500) gives
9198
+ the label a more deliberate-data register.
9199
+ The R309 per-row count text (separate element
9200
+ below at x=215 textAnchor=end) keeps its own
9201
+ fontWeight 600 inline override, so the count >
9202
+ label hierarchy stays intact at the legend
9203
+ scope same as R363 holds it at the recent-row
9204
+ scope:
9205
+ legend label fw 500 (R364, this round)
9206
+ legend count fw 600 (R309)
9207
+ recent alias fw 500 (R363)
9208
+ recent count fw 600/700 (R320)
9209
+ data-legend-row-label-font-weight attr exposes
9210
+ the value for tests. R219 letter-spacing pin
9211
+ tween + R55 fill transition + R181 always-mount
9212
+ pin ring all preserved. */
9213
+ fontWeight="500"
7494
9214
  data-legend-row-label={row.key}
7495
9215
  data-legend-row-label-pinned={isPinned ? 'true' : 'false'}
9216
+ data-legend-row-label-hovered={!isPinned && hoveredStatus === row.key ? 'true' : 'false'}
9217
+ data-legend-row-label-font-weight="500"
9218
+ /* Round 433 / Loop: legend-row text extends from
9219
+ R219's pin-only letter-spacing (0px → 0.5px on
9220
+ isPinned) to a 3-tier scale matching the R432
9221
+ group-label pattern:
9222
+ rest → 0px
9223
+ hoveredStatus → 0.25px ← this round
9224
+ isPinned → 0.5px (R219 preserved)
9225
+ Pre-R433 hover already brightened the fill
9226
+ (hoveredStatus===row.key || isPinned matches the
9227
+ legendHeadline branch) but the letter-form
9228
+ stayed dead-typographic on transient hover —
9229
+ only the pin tier carried a kerning signature.
9230
+ R433 adds the missing mid tier so hover
9231
+ telegraphs through BOTH fill brighten AND a
9232
+ subtle 0.25-px kerning spread, mirroring
9233
+ R427/R431/R432 at legend-row scope. Pin tier
9234
+ still wins so the locked vs preview distinction
9235
+ at the type level stays intact.
9236
+ Hover-letter-spacing family extension (9 anchors
9237
+ now): R344/R345/R347/R351/R420/R427/R431/R432/
9238
+ R433. 3-tier letter-spacing pattern now spans 4
9239
+ surfaces (node-alias R427, edge-badge R431,
9240
+ group-label R432, legend-row R433). R55 fill
9241
+ 150ms + R219 letter-spacing 150ms transition
9242
+ untouched — additive conditional case. */
7496
9243
  style={{
7497
9244
  transition: 'fill 150ms ease-out, letter-spacing 150ms ease-out',
7498
- letterSpacing: isPinned ? '0.5px' : '0px',
9245
+ letterSpacing: isPinned ? '0.5px' :
9246
+ hoveredStatus === row.key ? '0.25px' : '0px',
7499
9247
  }}
7500
9248
  >{row.label}</text>
7501
9249
  {/* R95: live count anchored to the right edge of the
@@ -7778,7 +9526,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7778
9526
  resetView();
7779
9527
  }
7780
9528
  }}
9529
+ // R346: viewport rect hover affordance driven by parent.
9530
+ onMouseEnter={() => setHoveredMinimap(true)}
9531
+ onMouseLeave={() => setHoveredMinimap(false)}
9532
+ onFocus={() => setHoveredMinimap(true)}
9533
+ onBlur={() => setHoveredMinimap(false)}
7781
9534
  data-topo-minimap
9535
+ data-topo-minimap-hovered={hoveredMinimap ? 'true' : 'false'}
7782
9536
  >
7783
9537
  <svg width={MW} height={MH} viewBox={`0 0 ${MW} ${MH}`} style={{ display: 'block' }}>
7784
9538
  {/* Round 198 / Loop: minimap dots gain smooth status
@@ -7805,14 +9559,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7805
9559
  const isOn = s.status !== 'offline' || !!sseN;
7806
9560
  const st = nodeStatus(s, isOn, isLight);
7807
9561
  return (
9562
+ /* Round 372 / Loop: minimap offline-dot opacity
9563
+ 0.5 → 0.6. Sibling stale-state legibility lift
9564
+ to R358 freshness ramp floor 0.25 → 0.30 + R317
9565
+ subordinate-text-lift family. Pre-R372 R198
9566
+ drew offline dots at α=0.5 (44 % below online
9567
+ 0.9). The minimap is a small overlay against
9568
+ the canvas backdrop — at α=0.5 offline dots
9569
+ sat at the legibility floor when the minimap
9570
+ mounted (only on non-default view). R372 lifts
9571
+ offline 0.5 → 0.6 for +20 % relative presence;
9572
+ online stays at 0.9 so the offline/online
9573
+ contrast ratio is now 0.6/0.9 ≈ 0.67 (vs prior
9574
+ 0.5/0.9 ≈ 0.56) — still a clear two-tier
9575
+ distinction. R198 opacity + fill + r transition
9576
+ list preserved so status flips still ease
9577
+ smoothly. data-topo-minimap-dot-opacity attr
9578
+ exposes the resolved value for tests. */
7808
9579
  <circle
7809
9580
  key={s.alias}
7810
9581
  cx={p.x * sx} cy={p.y * sy}
7811
- r={isOn ? 1.7 : 1.2}
9582
+ /* Round 384 / Loop: minimap online dot radius 1.7
9583
+ → 1.9. Sibling visual-weight bump (10th anchor)
9584
+ to R383 recent-row pip 1.8 → 2.0. R198 designed
9585
+ the dots at 1.7 (online) / 1.2 (offline) — at
9586
+ the minimap's 120 × 82 scale these read clearly
9587
+ but the online ↔ offline contrast was modest
9588
+ (1.7/1.2 = 1.42×). R384 bumps online to 1.9 so
9589
+ the tier delta widens to 1.58× (1.9/1.2). Pair
9590
+ completes minimap-dot legibility polish:
9591
+ R358 (era R372) offline opacity 0.5 → 0.6
9592
+ R384 online radius 1.7 → 1.9 (this round)
9593
+ R198 transition list (opacity + fill + r 200ms)
9594
+ preserved so status flips still ease smoothly.
9595
+ data-topo-minimap-dot-radius attr exposes the
9596
+ resolved value for tests. */
9597
+ /* Round 392 / Loop: minimap online dot opacity
9598
+ 0.9 → 0.95. Theme-consistency / canvas-presence
9599
+ polish family (7th anchor) — mirrors R386's
9600
+ hub-highlight idle 0.9 → 0.95 lift on the
9601
+ minimap surface: the online-dot's idle alpha
9602
+ gap (0.10 against full presence) halves to
9603
+ 0.05, so the live-fleet anchors on the minimap
9604
+ read more confidently. Offline dot stays at
9605
+ R372 0.6 — the binary online/offline contrast
9606
+ ratio shifts from 0.6/0.9 ≈ 0.67 to 0.6/0.95
9607
+ ≈ 0.63, preserved as a clear two-tier
9608
+ distinction. R198 opacity + fill + r transition
9609
+ list + R384 r=1.9 + R372 offline 0.6 all
9610
+ preserved. data-topo-minimap-dot-opacity attr
9611
+ bumps to '0.95' for tests. */
9612
+ /* Round 421 / Loop: online dot opacity 0.95 → 1.0
9613
+ on minimap container hover. Sibling to R346
9614
+ viewport rect strokeWidth/opacity hover tween.
9615
+ When the user hovers the minimap container,
9616
+ the live-fleet anchors brighten from R392
9617
+ baseline (0.95) to full opacity in concert
9618
+ with the R346 viewport rect lift. Offline
9619
+ stays at R372 0.6 — hover state focuses
9620
+ attention on the ACTIVE anchors, not the
9621
+ stale ones. data-topo-minimap-dot-opacity
9622
+ attr (R392) reflects the resolved hover-
9623
+ state value for tests. */
9624
+ r={isOn ? 1.9 : 1.2}
7812
9625
  fill={st.primary}
7813
- opacity={isOn ? 0.9 : 0.5}
9626
+ opacity={isOn ? (hoveredMinimap ? 1 : 0.95) : 0.6}
7814
9627
  data-topo-minimap-dot={s.alias}
7815
9628
  data-topo-minimap-dot-online={isOn ? 'true' : 'false'}
9629
+ data-topo-minimap-dot-opacity={isOn ? (hoveredMinimap ? 1 : 0.95) : 0.6}
9630
+ data-topo-minimap-dot-radius={isOn ? 1.9 : 1.2}
7816
9631
  style={{
7817
9632
  transition: 'opacity 200ms ease-out, fill 200ms ease-out, r 200ms ease-out',
7818
9633
  } as React.CSSProperties}
@@ -7853,17 +9668,70 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7853
9668
  information element to lift it above ambient
7854
9669
  chrome. opacity 0.9 stays — strokeWidth alone
7855
9670
  does the lifting. */}
9671
+ {/* Round 379 / Loop: minimap viewport rect picks up
9672
+ strokeLinejoin='round'. Pre-R379 the rect's 4
9673
+ corners painted with default 'miter' joins —
9674
+ sharp 90° corners with a small miter overshoot
9675
+ (≈ strokeWidth × 1.4 = 2.1 px at sw=1.5). R379
9676
+ rounds the joins so corners arc smoothly through
9677
+ a quarter-circle of radius ≈ strokeWidth/2. At
9678
+ sw=1.5 that's a 0.75-px radius — subtle but
9679
+ matches the same stroke-softening vocabulary R288
9680
+ chrome icons (zoom/reset/fullscreen) and R378
9681
+ flow-rail already speak. Geometry-safe: stroke-
9682
+ linejoin only affects the corner overshoot, the
9683
+ rect's bbox is unchanged. R287 strokeWidth=1.5 +
9684
+ R346 hover-state strokeWidth/opacity bump + R199
9685
+ smoothView x/y/w/h transition all preserved.
9686
+ data-topo-minimap-viewport-linejoin attr exposes
9687
+ the value for tests. */}
9688
+ {/* Round 393 / Loop: minimap viewport rect rx 0 → 2.
9689
+ Pre-R393 the cyan-stroked viewport rect (the frame
9690
+ showing what's currently visible on the canvas)
9691
+ drew with sharp corners inside the R332 rounded
9692
+ minimap container (rx=8). A small frame with sharp
9693
+ corners sitting inside a rounded container reads
9694
+ as visually loud — the 90° corners catch the eye
9695
+ against the soft container edge. R393 adds rx=2
9696
+ so the viewport corners get a subtle radius that
9697
+ matches the family's softening idiom on a sub-
9698
+ element scale. The R379 strokeLinejoin='round'
9699
+ already softens stroke joins; R393 adds a complete
9700
+ geometric soften via rx.
9701
+ Corner-radius cascade (7 anchors now):
9702
+ R330 canvas rx 12
9703
+ R331 panels rx 10
9704
+ R332 minimap container rx 8
9705
+ R375 Layout-toggle rx 8
9706
+ R376 nodeSize/zoom rx 8
9707
+ R390 hover-detail rx 10
9708
+ R393 minimap viewport rx 2 (this round, sub-element)
9709
+ The 2-px radius is intentionally small — the
9710
+ viewport rect is typically only 30-50px wide,
9711
+ where rx=2 reads as "rounded enough to not snap"
9712
+ without feeling pillowy. data-topo-minimap-
9713
+ viewport-rx attr exposes the resolved value
9714
+ for tests. R346 hover-state tweens (strokeWidth
9715
+ + opacity) preserved verbatim. */}
7856
9716
  <rect
7857
9717
  x={Math.max(0, rectX)} y={Math.max(0, rectY)}
7858
9718
  width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
7859
9719
  height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
7860
- fill="none" stroke={pal.legendAccent} strokeWidth="1.5" opacity="0.9"
9720
+ rx="2"
9721
+ fill="none" stroke={pal.legendAccent}
9722
+ // R346: strokeWidth + opacity tween on container hover.
9723
+ strokeWidth={hoveredMinimap ? '1.75' : '1.5'}
9724
+ strokeLinejoin="round"
9725
+ opacity={hoveredMinimap ? '1' : '0.9'}
7861
9726
  data-topo-minimap-viewport
9727
+ data-topo-minimap-viewport-rx="2"
7862
9728
  data-topo-minimap-viewport-smooth={smoothView ? 'true' : 'false'}
9729
+ data-topo-minimap-viewport-hover={hoveredMinimap ? 'true' : 'false'}
9730
+ data-topo-minimap-viewport-linejoin="round"
7863
9731
  style={{
7864
9732
  transition: smoothView
7865
- ? 'x 280ms ease-out, y 280ms ease-out, width 280ms ease-out, height 280ms ease-out'
7866
- : 'none',
9733
+ ? 'x 280ms ease-out, y 280ms ease-out, width 280ms ease-out, height 280ms ease-out, stroke-width 200ms ease-out, opacity 200ms ease-out'
9734
+ : 'stroke-width 200ms ease-out, opacity 200ms ease-out',
7867
9735
  } as React.CSSProperties}
7868
9736
  />
7869
9737
  </svg>
@@ -7918,8 +9786,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7918
9786
  own transition-colors. Same R254 holdover pattern that
7919
9787
  R263 just closed at the canvas wrapper scope, now at the
7920
9788
  chrome strip's nodeSize sub-wrapper scope. */}
9789
+ {/* Round 376 / Loop: nodeSize wrapper rounded-md → rounded-lg.
9790
+ Sibling polish to R375 Layout-toggle wrapper. Three
9791
+ chrome-strip segmented controls now all share rounded-lg
9792
+ at the wrapper tier:
9793
+ R375 Layout-toggle wrapper rounded-lg 8 px
9794
+ R376 nodeSize wrapper rounded-lg 8 px (this round)
9795
+ R376 zoom wrapper rounded-lg 8 px (this round)
9796
+ Individual atomic chrome buttons (reset, fullscreen) keep
9797
+ rounded-md (6 px) as their own atomic-button tier — the
9798
+ chrome strip's typography now expresses a clear two-tier
9799
+ hierarchy: 'segmented control container' (rounded-lg)
9800
+ vs 'standalone button' (rounded-md). Pure paint change,
9801
+ no layout shift. */}
7921
9802
  <div
7922
- className="flex items-center rounded-md border overflow-hidden"
9803
+ className="flex items-center rounded-lg border overflow-hidden"
9804
+ data-topo-chrome-nodesize-radius="rounded-lg"
7923
9805
  style={{
7924
9806
  background: pal.legendBox.fill,
7925
9807
  borderColor: pal.containerBorder,
@@ -7999,8 +9881,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7999
9881
  data-topo-chrome-view-group-leader marks the boundary surface
8000
9882
  for the test probe; data-topo-chrome-fleet-group-trailer marks
8001
9883
  the nodeSize wrapper's right edge for the gap measurement. */}
9884
+ {/* R376 sibling — zoom wrapper rounded-md → rounded-lg.
9885
+ Closes the chrome-strip segmented-control corner radius
9886
+ cascade (Layout R375 + nodeSize R376 + zoom R376). */}
8002
9887
  <div
8003
- className="ml-1.5 flex items-center rounded-md border overflow-hidden"
9888
+ className="ml-1.5 flex items-center rounded-lg border overflow-hidden"
9889
+ data-topo-chrome-zoom-wrapper-radius="rounded-lg"
8004
9890
  style={{
8005
9891
  background: pal.legendBox.fill,
8006
9892
  borderColor: pal.containerBorder,
@@ -8016,7 +9902,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8016
9902
  // R196: press-state deepens bg one tier above hover (white/5
8017
9903
  // → white/10) so mouse-down has a tactile dim before the
8018
9904
  // R186 icon pop fires on release.
8019
- className="px-2 py-1 hover:bg-white/5 active:bg-white/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
9905
+ // R352: `group` lets the inner svg respond via group-hover.
9906
+ className="group px-2 py-1 hover:bg-white/5 active:bg-white/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
8020
9907
  style={{ color: pal.legendText }}
8021
9908
  aria-label="Zoom out"
8022
9909
  title="Zoom out (−)"
@@ -8024,11 +9911,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8024
9911
  {/* R186: icon pop on click. CSS animation runs once;
8025
9912
  React removes the class after 240ms so a quick
8026
9913
  re-click can replay. */}
9914
+ {/* Round 352 / Loop: zoom-out icon picks up group-hover:
9915
+ scale-110 — sibling to R350 reset hover-rotate. Pre-
9916
+ R352 hovering the zoom button only changed the bg
9917
+ (white/5); the icon inside stayed perfectly still.
9918
+ R352 lifts the icon 10% on hover for a tactile "this
9919
+ button does something" cue. The R186 anet-chrome-pop
9920
+ keyframe (220ms scale 1→1.06→1) still owns transform
9921
+ during click via CSS-animation precedence over
9922
+ transition-transform; after the pop ends + className
9923
+ is removed, the group-hover scale-110 picks up
9924
+ smoothly. `transform-gpu` hint promotes the svg to
9925
+ its own compositor layer for crisper edges during
9926
+ the scale tween. Sibling change on zoom-in icon
9927
+ below. */}
8027
9928
  <svg
8028
9929
  width="12" height="12" viewBox="0 0 24 24"
8029
9930
  fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
8030
9931
  aria-hidden
8031
- className={chromePopping === 'zoom-out' ? 'anet-chrome-pop' : undefined}
9932
+ className={`transition-transform duration-200 ease-out group-hover:scale-110 transform-gpu${chromePopping === 'zoom-out' ? ' anet-chrome-pop' : ''}`}
8032
9933
  data-topo-chrome-zoom-out-icon
8033
9934
  ><path d="M5 12h14" /></svg>
8034
9935
  </button>
@@ -8074,11 +9975,35 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8074
9975
  chromePopping === 'zoom-in' || chromePopping === 'zoom-out'
8075
9976
  ? 'true' : 'false'
8076
9977
  }
9978
+ data-topo-chrome-zoom-level-hover={hoveredZoomLevel ? 'true' : 'false'}
9979
+ onMouseEnter={() => setHoveredZoomLevel(true)}
9980
+ onMouseLeave={() => setHoveredZoomLevel(false)}
8077
9981
  style={{
8078
9982
  color: pal.legendText,
8079
9983
  borderColor: pal.containerBorder,
8080
9984
  minWidth: 46,
8081
9985
  display: 'inline-block',
9986
+ // R347: letter-spacing hover tween — extends R344/R345
9987
+ // hover-letter-spacing family into the chrome strip.
9988
+ letterSpacing: hoveredZoomLevel ? '0.5px' : '0',
9989
+ // Round 420 / Loop: zoom-level readout gains a SECOND
9990
+ // hover axis — fontWeight 500 → 600 on hover. Sibling
9991
+ // to R347 (same element, hover letter-spacing tween).
9992
+ // The chrome strip's only data display now has a two-
9993
+ // axis hover signature (letter-spacing + fontWeight),
9994
+ // matching the R416 chip-row chip digit hover-bold
9995
+ // pattern at the chrome scope. Pre-R420 hovering only
9996
+ // spread the digits 0 → 0.5px; the weight stayed at
9997
+ // R332's 'font-medium' (500) baseline. Post-R420
9998
+ // hover lifts BOTH letter-spacing AND weight so the
9999
+ // percent reads with the same data-tier emphasis
10000
+ // intensification the chip-row chips do on hover.
10001
+ // Inline fontWeight overrides the className's
10002
+ // 'font-medium' since they target the same property.
10003
+ // 200ms transition list extends to font-weight for
10004
+ // smooth easing. data-topo-chrome-zoom-level-hover
10005
+ // attr surfaces the hover state for tests.
10006
+ fontWeight: hoveredZoomLevel ? 600 : 500,
8082
10007
  /* Round 264 / Loop: zoom level readout gains theme-toggle
8083
10008
  transition. The span has theme-driven color (pal.
8084
10009
  legendText) + border-x (pal.containerBorder via the
@@ -8087,7 +10012,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8087
10012
  on theme flip while siblings eased. Sibling treatment
8088
10013
  to the nodeSize + zoom wrapper transitions added this
8089
10014
  round. */
8090
- transition: 'color 200ms ease-out, border-color 200ms ease-out',
10015
+ transition: 'color 200ms ease-out, border-color 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out',
8091
10016
  }}
8092
10017
  title="Current zoom level"
8093
10018
  >
@@ -8098,18 +10023,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8098
10023
  data-topo-chrome-zoom-in
8099
10024
  data-topo-chrome-zoom-in-popping={chromePopping === 'zoom-in' ? 'true' : 'false'}
8100
10025
  // R196: press-state (mirror of zoom-out above).
8101
- className="px-2 py-1 hover:bg-white/5 active:bg-white/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
10026
+ // R352: `group` lets the inner svg respond via group-hover.
10027
+ className="group px-2 py-1 hover:bg-white/5 active:bg-white/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
8102
10028
  style={{ color: pal.legendText }}
8103
10029
  aria-label="Zoom in"
8104
10030
  title="Zoom in (+)"
8105
10031
  >
8106
10032
  {/* R186: icon pop on click. Same one-shot CSS animation
8107
10033
  as zoom-out; React removes the class after 240ms. */}
10034
+ {/* R352 sibling — zoom-in icon picks up the same
10035
+ group-hover:scale-110 family. Mirror change at
10036
+ the zoom-out icon above. */}
8108
10037
  <svg
8109
10038
  width="12" height="12" viewBox="0 0 24 24"
8110
10039
  fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
8111
10040
  aria-hidden
8112
- className={chromePopping === 'zoom-in' ? 'anet-chrome-pop' : undefined}
10041
+ className={`transition-transform duration-200 ease-out group-hover:scale-110 transform-gpu${chromePopping === 'zoom-in' ? ' anet-chrome-pop' : ''}`}
8113
10042
  data-topo-chrome-zoom-in-icon
8114
10043
  ><path d="M12 5v14M5 12h14" /></svg>
8115
10044
  </button>
@@ -8118,9 +10047,35 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8118
10047
  onClick={() => { armResetSpin(); resetView(); }}
8119
10048
  data-topo-chrome-reset
8120
10049
  data-topo-chrome-reset-spinning={resetSpinning ? 'true' : 'false'}
10050
+ data-topo-chrome-reset-hover={hoveredReset ? 'true' : 'false'}
10051
+ // R350: hover state drives the icon transform below.
10052
+ onMouseEnter={() => setHoveredReset(true)}
10053
+ onMouseLeave={() => setHoveredReset(false)}
10054
+ onFocus={() => setHoveredReset(true)}
10055
+ onBlur={() => setHoveredReset(false)}
8121
10056
  // R196: press-state deepens before R184 reset-spin fires on
8122
10057
  // release — mouse-down dim then 450ms spin = full handshake.
8123
- className="p-1.5 rounded-md border hover:bg-white/5 active:bg-white/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
10058
+ /* Round 400 / Loop · milestone: chrome reset + fullscreen
10059
+ buttons gain hover:-translate-y-px lift — closes the
10060
+ hover-lift gesture vocabulary across every standalone
10061
+ interactive HTML element in TopoGraph. Segmented
10062
+ controls (zoom -/+, nodeSize S/M/L, Layout Ring/Grid)
10063
+ intentionally stay planted: lifting one segment of a
10064
+ unified strip would tear the visual unity of the
10065
+ segmented control. Only the standalone chrome buttons
10066
+ (reset, fullscreen) get the lift.
10067
+ Gesture vocabulary post-R400 (now complete across HTML):
10068
+ chip-row chips (3×) -1 px R398, R399
10069
+ filter pin pills (4×) -1 px R397
10070
+ recent-signal row -1 px R143
10071
+ legend row -1 px R144
10072
+ reset button -1 px R400 (this round)
10073
+ fullscreen button -1 px R400 (this round)
10074
+ Every standalone interactive HTML surface in TopoGraph
10075
+ now lifts on hover. data-topo-chrome-reset-hover-lift
10076
+ attr surfaces the lift for tests. */
10077
+ className="p-1.5 rounded-md border hover:bg-white/5 active:bg-white/10 hover:-translate-y-px transition-colors transition-transform duration-200 ease-out transform-gpu focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
10078
+ data-topo-chrome-reset-hover-lift="true"
8124
10079
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
8125
10080
  aria-label="Reset view"
8126
10081
  title="Reset zoom + pan (0, or double-click the canvas)"
@@ -8147,6 +10102,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8147
10102
  aria-hidden
8148
10103
  className={resetSpinning ? 'anet-reset-spin' : undefined}
8149
10104
  data-topo-chrome-reset-icon
10105
+ // R350: hover-rotate preview of the R184 click-spin.
10106
+ // Gated on !resetSpinning so the anet-reset-spin keyframe
10107
+ // owns transform during its 450ms run. transformOrigin
10108
+ // 'center' so rotation pivots around the icon's centre
10109
+ // (default would be top-left and the icon would arc).
10110
+ style={{
10111
+ transform: hoveredReset && !resetSpinning ? 'rotate(-8deg)' : 'rotate(0deg)',
10112
+ transformOrigin: 'center',
10113
+ transition: 'transform 200ms ease-out',
10114
+ }}
10115
+ data-topo-chrome-reset-icon-hover={hoveredReset && !resetSpinning ? 'true' : 'false'}
8150
10116
  >
8151
10117
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />
8152
10118
  <path d="M3 3v5h5" />
@@ -8180,11 +10146,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8180
10146
  its inactive state benefits from the same "hover previews
8181
10147
  active state" idiom R163 designed. Sibling treatment to
8182
10148
  the nodeSize buttons at line ~6711. */
8183
- className={`p-1.5 rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 ${
10149
+ // R353: `group` lets the inner svg respond via group-hover —
10150
+ // sibling to R352 zoom buttons. Closes the chrome-strip per-
10151
+ // icon hover-affordance arc (zoom-out / zoom-in / reset /
10152
+ // fullscreen now all carry an icon-level hover gesture in
10153
+ // addition to the bg hover).
10154
+ // R400: hover translateY(-1px) lift — see reset button above for family doc.
10155
+ className={`group p-1.5 rounded-md border hover:-translate-y-px transition-colors transition-transform duration-200 ease-out transform-gpu focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 ${
8184
10156
  isFullscreen
8185
10157
  ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25'
8186
10158
  : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'
8187
10159
  }${chromePopping === 'fullscreen' ? ' anet-chrome-pop' : ''}`}
10160
+ data-topo-chrome-fullscreen-hover-lift="true"
8188
10161
  style={{
8189
10162
  borderColor: pal.containerBorder,
8190
10163
  ...(isFullscreen
@@ -8199,12 +10172,20 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8199
10172
  at the reset icon above. data-topo-chrome-fullscreen-
8200
10173
  icon attribute exposes BOTH variants (entered / exited)
8201
10174
  for the round's stroke-width regression probe. */}
10175
+ {/* Round 353 / Loop: fullscreen icon (both enter + exit
10176
+ variants) picks up the R352 family group-hover:scale-110.
10177
+ Pre-R353 hovering the button only changed the bg; the
10178
+ icon stayed still. R353 lifts the icon 10 % on hover —
10179
+ same gesture vocabulary as the zoom buttons. transform-
10180
+ gpu hint promotes the svg to its own compositor layer
10181
+ for crisper edges during the scale tween. Closes the
10182
+ chrome-strip per-icon hover-affordance arc. */}
8202
10183
  {isFullscreen ? (
8203
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden data-topo-chrome-fullscreen-icon="exit">
10184
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="transition-transform duration-200 ease-out group-hover:scale-110 transform-gpu" data-topo-chrome-fullscreen-icon="exit">
8204
10185
  <path d="M8 3v4a1 1 0 0 1-1 1H3M21 8h-4a1 1 0 0 1-1-1V3M3 16h4a1 1 0 0 1 1 1v4M16 21v-4a1 1 0 0 1 1-1h4" />
8205
10186
  </svg>
8206
10187
  ) : (
8207
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden data-topo-chrome-fullscreen-icon="enter">
10188
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="transition-transform duration-200 ease-out group-hover:scale-110 transform-gpu" data-topo-chrome-fullscreen-icon="enter">
8208
10189
  <path d="M3 8V5a2 2 0 0 1 2-2h3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M21 16v3a2 2 0 0 1-2 2h-3" />
8209
10190
  </svg>
8210
10191
  )}