@sleep2agi/agent-network-dashboard 0.5.1-preview.7 → 0.5.1-preview.71

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 (218) 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/04.j~y8e~sbs4.js +1 -0
  141. package/.next/static/chunks/06llamqb4jsu..js +4 -0
  142. package/.next/static/chunks/0_p8jkzdw5x2_.css +2 -0
  143. package/.next/static/chunks/12heglqfrp1bm.js +1 -0
  144. package/.next/trace +2 -2
  145. package/.next/trace-build +1 -1
  146. package/app/components/TopoGraph.tsx +1426 -84
  147. package/package.json +1 -1
  148. package/scripts/topo-active-links-chip-hover-lift-test.mjs +93 -0
  149. package/scripts/topo-chip-digit-fontweight-test.mjs +105 -0
  150. package/scripts/topo-chip-row-hover-lift-test.mjs +95 -0
  151. package/scripts/topo-chrome-button-hover-lift-test.mjs +94 -0
  152. package/scripts/topo-chrome-segmented-radius-test.mjs +100 -0
  153. package/scripts/topo-click-ripple-opacity-test.mjs +99 -0
  154. package/scripts/topo-edge-badge-fontsize-test.mjs +90 -0
  155. package/scripts/topo-edge-badge-hover-opacity-test.mjs +94 -0
  156. package/scripts/topo-edge-badge-hover-stroke-test.mjs +92 -0
  157. package/scripts/topo-edge-badge-opacity-test.mjs +80 -0
  158. package/scripts/topo-edge-badge-pin-opacity-test.mjs +86 -0
  159. package/scripts/topo-edge-badge-stroke-test.mjs +92 -0
  160. package/scripts/topo-edge-freshness-floor-test.mjs +99 -0
  161. package/scripts/topo-edge-visible-linecap-test.mjs +89 -0
  162. package/scripts/topo-filter-pill-hover-lift-test.mjs +101 -0
  163. package/scripts/topo-filter-pill-hover-opacity-test.mjs +110 -0
  164. package/scripts/topo-filter-pill-x-hover-scale-test.mjs +99 -0
  165. package/scripts/topo-flow-rail-linecap-test.mjs +79 -0
  166. package/scripts/topo-freshness-chip-hierarchy-test.mjs +93 -0
  167. package/scripts/topo-freshness-chip-tabular-test.mjs +41 -0
  168. package/scripts/topo-freshness-floor-lift-test.mjs +92 -0
  169. package/scripts/topo-freshness-suffix-tabular-test.mjs +88 -0
  170. package/scripts/topo-fullscreen-icon-hover-scale-test.mjs +91 -0
  171. package/scripts/topo-group-box-stroke-test.mjs +105 -0
  172. package/scripts/topo-group-label-count-fontweight-test.mjs +108 -0
  173. package/scripts/topo-hover-detail-body-fw-test.mjs +101 -0
  174. package/scripts/topo-hover-detail-model-fw-test.mjs +98 -0
  175. package/scripts/topo-hover-detail-opacity-test.mjs +98 -0
  176. package/scripts/topo-hover-detail-rx-test.mjs +81 -0
  177. package/scripts/topo-hub-digit-fontsize-test.mjs +86 -0
  178. package/scripts/topo-hub-halo-light-trough-test.mjs +88 -0
  179. package/scripts/topo-hub-halo-radius-test.mjs +86 -0
  180. package/scripts/topo-hub-halo-trough-test.mjs +83 -0
  181. package/scripts/topo-hub-highlight-opacity-test.mjs +88 -0
  182. package/scripts/topo-hub-highlight-radius-test.mjs +90 -0
  183. package/scripts/topo-hub-hover-ring-opacity-test.mjs +96 -0
  184. package/scripts/topo-hub-hover-ring-stroke-test.mjs +86 -0
  185. package/scripts/topo-hub-spoke-linecap-test.mjs +80 -0
  186. package/scripts/topo-layout-toggle-hover-tracking-test.mjs +109 -0
  187. package/scripts/topo-layout-toggle-radius-test.mjs +87 -0
  188. package/scripts/topo-legend-label-fontweight-test.mjs +94 -0
  189. package/scripts/topo-legend-pin-ring-stroke-test.mjs +101 -0
  190. package/scripts/topo-minimap-offline-opacity-test.mjs +90 -0
  191. package/scripts/topo-minimap-online-opacity-test.mjs +93 -0
  192. package/scripts/topo-minimap-online-radius-test.mjs +85 -0
  193. package/scripts/topo-minimap-viewport-linejoin-test.mjs +75 -0
  194. package/scripts/topo-minimap-viewport-rx-test.mjs +85 -0
  195. package/scripts/topo-more-flows-fontweight-test.mjs +103 -0
  196. package/scripts/topo-node-halo-offline-opacity-test.mjs +87 -0
  197. package/scripts/topo-node-pulse-peak-test.mjs +89 -0
  198. package/scripts/topo-panel-count-letterspacing-test.mjs +89 -0
  199. package/scripts/topo-panel-rect-opacity-hover-test.mjs +109 -0
  200. package/scripts/topo-pressure-bar-height-test.mjs +92 -0
  201. package/scripts/topo-pressure-kicker-fontweight-test.mjs +76 -0
  202. package/scripts/topo-recent-pip-radius-2-test.mjs +72 -0
  203. package/scripts/topo-recent-pip-radius-test.mjs +76 -0
  204. package/scripts/topo-recent-row-text-fontweight-test.mjs +90 -0
  205. package/scripts/topo-reset-hover-rotate-test.mjs +102 -0
  206. package/scripts/topo-spoke-active-opacity-test.mjs +104 -0
  207. package/scripts/topo-vendor-chip-hover-lift-test.mjs +87 -0
  208. package/scripts/topo-vendor-glyph-fontweight-test.mjs +102 -0
  209. package/scripts/topo-vendor-letter-hover-scale-test.mjs +129 -0
  210. package/scripts/topo-zoom-icon-hover-scale-test.mjs +114 -0
  211. package/scripts/topo-zoom-level-hover-letterspacing-test.mjs +91 -0
  212. package/.next/static/chunks/0aauz~36q5n2a.css +0 -2
  213. package/.next/static/chunks/0bja1amnrg3li.js +0 -1
  214. package/.next/static/chunks/0k~uc0~~19hyy.js +0 -4
  215. package/.next/static/chunks/0wtq_6dnzems6.js +0 -1
  216. /package/.next/static/{x9zCCrMkHsIYlXNY791KF → gaK6yNvVjshUCmKR9qrPn}/_buildManifest.js +0 -0
  217. /package/.next/static/{x9zCCrMkHsIYlXNY791KF → gaK6yNvVjshUCmKR9qrPn}/_clientMiddlewareManifest.js +0 -0
  218. /package/.next/static/{x9zCCrMkHsIYlXNY791KF → gaK6yNvVjshUCmKR9qrPn}/_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
  }
@@ -1038,6 +1072,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1038
1072
  // 280ms ease-out transition list matches R199 smoothView vocabulary
1039
1073
  // so the visual joins the existing rhythm on the same rect.
1040
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);
1041
1099
  // R135: panel-wide hover-elevation. The recent-signal + legend
1042
1100
  // panels both already host clickable rows (R56/R116 recent rows,
1043
1101
  // R55/R61 legend rows) and a clickable footer (R133), so the
@@ -1799,12 +1857,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1799
1857
  while honoring R328's wider baseline rhythm. data-topo-
1800
1858
  chrome-layout-trailer attr unchanged — it still marks
1801
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. */}
1802
1877
  <div
1803
- className="mr-0.5 inline-flex rounded-md border overflow-hidden"
1878
+ className="mr-0.5 inline-flex rounded-lg border overflow-hidden"
1804
1879
  style={{ borderColor: pal.containerBorder, transition: 'border-color 200ms ease-out' }}
1805
1880
  role="group"
1806
1881
  aria-label="Topology layout"
1807
1882
  data-topo-chrome-layout-trailer
1883
+ data-topo-chrome-layout-radius="rounded-lg"
1808
1884
  >
1809
1885
  <button
1810
1886
  onClick={() => { popChrome('layout-ring'); if (layout !== 'ring') toggleLayout(); }}
@@ -1838,7 +1914,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1838
1914
  weight; cyan-400/60 + ring-inset retained. The
1839
1915
  R163/R196 hover/active deeps + R249 chrome-pop
1840
1916
  click feedback continue unchanged. */
1841
- 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' }}
1842
1928
  >
1843
1929
  Ring
1844
1930
  </button>
@@ -1855,14 +1941,20 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1855
1941
  // Round 306 / Loop: focus-visible:ring-2 → ring-1 sibling
1856
1942
  // change to Ring above — unifies focus-ring width across
1857
1943
  // all chrome buttons.
1858
- 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' : ''}`}
1859
1947
  /* Round 268 / Loop: Grid button's left border (the
1860
1948
  internal divider between Ring and Grid) picks up
1861
1949
  pal.containerBorder, matching the wrapper change at
1862
1950
  line ~1460 and the chrome strip's segmented borders
1863
- (nodeSize, zoom). transition-colors className covers
1864
- the border-color eased on theme toggle. */
1865
- 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' }}
1866
1958
  >
1867
1959
  Grid
1868
1960
  </button>
@@ -1949,11 +2041,37 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1949
2041
  7th surface in the info-density tabular-nums
1950
2042
  sweep — and the first on the HTML side
1951
2043
  (previous 6 were SVG <text>/<tspan>). */
1952
- 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
+ className={`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 ${
1953
2070
  workingCount > 0
1954
- ? 'bg-green-500/10 text-green-300 border-green-500/20 hover:bg-green-500/15 hover:border-green-500/30'
2071
+ ? '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'
1955
2072
  : 'bg-green-500/10 text-green-300 border-green-500/20'
1956
2073
  }`}
2074
+ data-chip-hover-lift={workingCount > 0 ? 'true' : 'false'}
1957
2075
  data-working-chip
1958
2076
  data-working-chip-aliases={workingAliases.join(',')}
1959
2077
  data-pin-mirror={pinnedStatus === 'working' ? 'true' : 'false'}
@@ -2023,7 +2141,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2023
2141
  pattern: small label spans demote, value stays
2024
2142
  prominent. data-working-chip-unit exposes the
2025
2143
  span for tests. */}
2026
- {workingCount}<span className="opacity-70" data-working-chip-unit> working</span>
2144
+ {/* Round 362 / Loop: digit picks up font-semibold
2145
+ (fw 500 → 600) for within-chip weight tier. The
2146
+ chip's outer className stays at font-medium (R313
2147
+ data-weight baseline); the digit overrides to
2148
+ semibold so it reads heavier than its " working"
2149
+ unit (which keeps fw 500 + R338 opacity-70).
2150
+ Joins the R333-R341 chip-internal-hierarchy arc
2151
+ at the chip-count scope. Sibling edits on the
2152
+ online + active-links chip digits below. data-
2153
+ working-chip-digit attr exposes the digit span. */}
2154
+ <span className="font-semibold" data-working-chip-digit>{workingCount}</span><span className="opacity-70" data-working-chip-unit> working</span>
2027
2155
  </span>
2028
2156
  <span
2029
2157
  // Round 201 / Loop: online chip — mirror of the working
@@ -2037,11 +2165,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2037
2165
  /* Round 232 / Loop: tabular-nums on online chip
2038
2166
  (sibling treatment to working chip — same row,
2039
2167
  same digit-jitter physics on count crossings). */
2040
- className={`tabular-nums font-medium px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors duration-200 ${
2168
+ // R398: hover translate-y lift on clickable variant see working chip above.
2169
+ className={`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 ${
2041
2170
  onlineNodes.length > 0
2042
- ? 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20 hover:bg-cyan-500/15 hover:border-cyan-500/30'
2171
+ ? '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'
2043
2172
  : 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20'
2044
2173
  }`}
2174
+ data-chip-hover-lift={onlineNodes.length > 0 ? 'true' : 'false'}
2045
2175
  data-online-chip
2046
2176
  data-online-chip-aliases={onlineAliases.join(',')}
2047
2177
  data-pin-mirror={pinnedStatus === 'idle' ? 'true' : 'false'}
@@ -2086,7 +2216,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2086
2216
  }}
2087
2217
  >
2088
2218
  {/* R337 sibling — online chip unit demotion. */}
2089
- {onlineNodes.length}<span className="opacity-70" data-online-chip-unit> online</span>
2219
+ {/* R362 sibling — online-chip digit gains font-semibold. */}
2220
+ <span className="font-semibold" data-online-chip-digit>{onlineNodes.length}</span><span className="opacity-70" data-online-chip-unit> online</span>
2090
2221
  </span>
2091
2222
  </>
2092
2223
  );
@@ -2213,8 +2344,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2213
2344
  title={`${w} working · ${i} idle · ${o} offline`}
2214
2345
  data-fleet-pressure
2215
2346
  >
2216
- <span className="text-[10px] tracking-wide">pressure</span>
2217
- <span className="inline-flex h-1.5 w-16 rounded-full overflow-hidden" style={{ background: 'rgb(75 85 99 / 0.25)' }}>
2347
+ {/* Round 373 / Loop: pressure-bar kicker label gains
2348
+ font-medium (fw 400 500). Sibling small-text fw
2349
+ lift family with R363 recent-row alias + R364
2350
+ legend-row label + R366 group-label count + R368
2351
+ +N more flows footer — extends to a 5th surface
2352
+ (the chip-row's 'pressure' label). At fontSize
2353
+ 10 px tracking-wide against the chip's gray bg,
2354
+ the default fw 400 sat below the deliberate-data
2355
+ band; fw 500 brings it into parity with the
2356
+ chip-row 'working / online / active links' unit
2357
+ spans (chip-level font-medium R313). data-fleet-
2358
+ pressure-kicker attr exposes the kicker for tests. */}
2359
+ <span className="text-[10px] tracking-wide font-medium" data-fleet-pressure-kicker>pressure</span>
2360
+ {/* Round 374 / Loop: pressure-bar height h-1.5 → h-2
2361
+ (6 → 8 px) — sibling visual-weight bump (8th anchor
2362
+ in the family):
2363
+ R287 minimap viewport stroke 1 → 1.5
2364
+ R295 legend swatch base radius 5.5 → 6
2365
+ R359 recent-row pip base radius 1.6 → 1.8
2366
+ R360 hub digit fontSize 11 → 12
2367
+ R361 edge-badge digit fontSize 10 → 11
2368
+ R365 hub-highlight base radius 5 → 5.5
2369
+ R367 edge-badge rest stroke 1 → 1.25
2370
+ R374 pressure-bar height 1.5 → 2 (this round)
2371
+ +33 % bar height gives the working/idle/offline
2372
+ segments more visibility — at h-1.5 the 3-segment
2373
+ proportion bar was readable but slim; at h-2 the
2374
+ segments parse cleanly even when one tier is
2375
+ < 10 % share. Geometry-safe: items-center flex
2376
+ centers the bar inside the chip's py-1 (4 px top +
2377
+ 4 px bottom) — bar at 8 px stays comfortably
2378
+ inside the 10-px text-row height. R165 segment
2379
+ width transitions + R210 brightness hover + R83
2380
+ pin-mirror box-shadow on segments all preserved
2381
+ (segments inherit width from parent so the height
2382
+ bump propagates without segment-side edits).
2383
+ data-fleet-pressure-bar-height attr exposes the
2384
+ height token for tests. */}
2385
+ <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">
2218
2386
  {seg(w, isLight ? '#059669' : '#22c55e', 'working', 'working')}
2219
2387
  {seg(i, isLight ? '#0d9488' : '#2dd4bf', 'idle', 'idle')}
2220
2388
  {seg(o, isLight ? '#94a3b8' : '#6b7280', 'offline', 'offline')}
@@ -2271,7 +2439,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2271
2439
  data-active-filter="status"
2272
2440
  data-filter-match-count={matchCount}
2273
2441
  data-filter-match-aliases={matchAliases.join(',')}
2274
- 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"
2442
+ // R355: `group` lets the inner opacity-70 spans (prefix
2443
+ // `filter:` + count `· N`) brighten to 100 % on pill hover.
2444
+ // Sibling treatment on group + vendor pills below.
2445
+ 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"
2275
2446
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2276
2447
  onClick={() => setPinnedStatus(null)}
2277
2448
  style={{
@@ -2285,12 +2456,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2285
2456
  cursor: 'pointer',
2286
2457
  }}
2287
2458
  >
2288
- <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>
2459
+ <span><span className="hidden sm:inline opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-filter-prefix>filter: </span>{pinnedStatus}<span className="opacity-70 tabular-nums transition-opacity duration-200 group-hover:opacity-100" data-filter-pill-count> · {matchCount}</span></span>
2289
2460
  <button
2290
2461
  type="button"
2291
2462
  aria-label={`Clear ${pinnedStatus} filter`}
2292
2463
  onClick={(e) => { e.stopPropagation(); setPinnedStatus(null); }}
2293
- className="ml-0.5 leading-none hover:opacity-70"
2464
+ /* Round 356 / Loop: filter pin pill × buttons gain
2465
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2466
+ not legacy transform). Sibling polish to R354 vendor
2467
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2468
+ Pre-R356 the × had only hover:opacity-70 — the target
2469
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2470
+ scale on hover so the click-target reads as "press me"
2471
+ alongside the dim. transform-gpu hint promotes the
2472
+ button to its own compositor layer for crisper edges
2473
+ during the scale tween. transition-transform duration-
2474
+ 200 matches the chrome icon hover-scale timing family.
2475
+ inline-block is default for <button> so no display
2476
+ tweak needed. replace_all covers all 4 filter pin
2477
+ pills (status / group / vendor / edge) at once. */
2478
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2294
2479
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2295
2480
  >×</button>
2296
2481
  </span>
@@ -2309,7 +2494,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2309
2494
  data-active-filter="group"
2310
2495
  data-filter-match-count={matchCount}
2311
2496
  data-filter-match-aliases={matchAliases.join(',')}
2312
- 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"
2497
+ // R355 sibling `group` parent + group-hover on inner spans.
2498
+ 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"
2313
2499
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2314
2500
  onClick={() => setPinnedGroup(null)}
2315
2501
  style={{
@@ -2319,12 +2505,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2319
2505
  cursor: 'pointer',
2320
2506
  }}
2321
2507
  >
2322
- <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>
2508
+ <span><span className="hidden sm:inline opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-filter-prefix>filter: </span>{pinnedGroup}<span className="opacity-70 tabular-nums transition-opacity duration-200 group-hover:opacity-100" data-filter-pill-count> · {matchCount}</span></span>
2323
2509
  <button
2324
2510
  type="button"
2325
2511
  aria-label={`Clear group filter ${pinnedGroup}`}
2326
2512
  onClick={(e) => { e.stopPropagation(); setPinnedGroup(null); }}
2327
- className="ml-0.5 leading-none hover:opacity-70"
2513
+ /* Round 356 / Loop: filter pin pill × buttons gain
2514
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2515
+ not legacy transform). Sibling polish to R354 vendor
2516
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2517
+ Pre-R356 the × had only hover:opacity-70 — the target
2518
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2519
+ scale on hover so the click-target reads as "press me"
2520
+ alongside the dim. transform-gpu hint promotes the
2521
+ button to its own compositor layer for crisper edges
2522
+ during the scale tween. transition-transform duration-
2523
+ 200 matches the chrome icon hover-scale timing family.
2524
+ inline-block is default for <button> so no display
2525
+ tweak needed. replace_all covers all 4 filter pin
2526
+ pills (status / group / vendor / edge) at once. */
2527
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2328
2528
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2329
2529
  >×</button>
2330
2530
  </span>
@@ -2359,7 +2559,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2359
2559
  data-active-filter="vendor"
2360
2560
  data-filter-match-count={matchCount}
2361
2561
  data-filter-match-aliases={matchAliases.join(',')}
2362
- 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"
2562
+ // R355 sibling `group` parent + group-hover on inner spans.
2563
+ 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"
2363
2564
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
2364
2565
  onClick={() => setPinnedVendor(null)}
2365
2566
  style={{
@@ -2369,12 +2570,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2369
2570
  cursor: 'pointer',
2370
2571
  }}
2371
2572
  >
2372
- <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>
2573
+ <span><span className="hidden sm:inline opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-filter-prefix>filter: </span>{pinnedVendor}<span className="opacity-70 tabular-nums transition-opacity duration-200 group-hover:opacity-100" data-filter-pill-count> · {matchCount}</span></span>
2373
2574
  <button
2374
2575
  type="button"
2375
2576
  aria-label={`Clear vendor filter ${pinnedVendor}`}
2376
2577
  onClick={(e) => { e.stopPropagation(); setPinnedVendor(null); }}
2377
- className="ml-0.5 leading-none hover:opacity-70"
2578
+ /* Round 356 / Loop: filter pin pill × buttons gain
2579
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2580
+ not legacy transform). Sibling polish to R354 vendor
2581
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2582
+ Pre-R356 the × had only hover:opacity-70 — the target
2583
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2584
+ scale on hover so the click-target reads as "press me"
2585
+ alongside the dim. transform-gpu hint promotes the
2586
+ button to its own compositor layer for crisper edges
2587
+ during the scale tween. transition-transform duration-
2588
+ 200 matches the chrome icon hover-scale timing family.
2589
+ inline-block is default for <button> so no display
2590
+ tweak needed. replace_all covers all 4 filter pin
2591
+ pills (status / group / vendor / edge) at once. */
2592
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2378
2593
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2379
2594
  >×</button>
2380
2595
  </span>
@@ -2407,7 +2622,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2407
2622
  data-filter-match-count={link.count}
2408
2623
  data-filter-match-aliases={`${link.from},${link.to}`}
2409
2624
  data-active-filter-edge-hot={isHot ? 'true' : 'false'}
2410
- 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"
2625
+ 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"
2411
2626
  title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
2412
2627
  onClick={() => setPinnedEdgeKey(null)}
2413
2628
  style={{
@@ -2451,7 +2666,21 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2451
2666
  type="button"
2452
2667
  aria-label={`Clear edge filter ${link.from} → ${link.to}`}
2453
2668
  onClick={(e) => { e.stopPropagation(); setPinnedEdgeKey(null); }}
2454
- className="ml-0.5 leading-none hover:opacity-70"
2669
+ /* Round 356 / Loop: filter pin pill × buttons gain
2670
+ hover:scale-110 (Tailwind 4 modern CSS `scale` property,
2671
+ not legacy transform). Sibling polish to R354 vendor
2672
+ letter glyph + R350/R352/R353 chrome icon hover-scales.
2673
+ Pre-R356 the × had only hover:opacity-70 — the target
2674
+ dimmed under cursor but didn't lift. R356 adds a 10 %
2675
+ scale on hover so the click-target reads as "press me"
2676
+ alongside the dim. transform-gpu hint promotes the
2677
+ button to its own compositor layer for crisper edges
2678
+ during the scale tween. transition-transform duration-
2679
+ 200 matches the chrome icon hover-scale timing family.
2680
+ inline-block is default for <button> so no display
2681
+ tweak needed. replace_all covers all 4 filter pin
2682
+ pills (status / group / vendor / edge) at once. */
2683
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2455
2684
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2456
2685
  >×</button>
2457
2686
  </span>
@@ -2716,9 +2945,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2716
2945
  vendor letter chips ('A:N', 'O:N', '书:N',
2717
2946
  '?:N'). They display vendor-distribution
2718
2947
  data; same tier as the sibling data chips. */
2719
- className="tabular-nums font-medium inline-flex items-baseline gap-0.5 px-1 rounded anet-topo-chip-focus"
2948
+ /* Round 401 / Loop: vendor letter chip closes the
2949
+ hover-lift gesture family at its last unaddressed
2950
+ interactive HTML surface. R397/R398/R399 lifted
2951
+ filter pin pills + chip-row chips (working /
2952
+ online / active-links); R400 lifted standalone
2953
+ chrome buttons (reset / fullscreen). The vendor
2954
+ letter chips (A:N / O:N / 书:N / ?:N) are
2955
+ sibling interactive chips in the same chip-row
2956
+ — clickable to toggle the vendor filter pin —
2957
+ but were not yet on the hover-lift family.
2958
+ R401 closes the gap with hover:-translate-y-px
2959
+ + transition-transform + transform-gpu added
2960
+ to the className. The inline transition list
2961
+ (box-shadow + background-color) keeps eaching
2962
+ independently — different property axes compose
2963
+ cleanly. Existing R354 glyph scale-1.1 (inner
2964
+ span) + R202 chip bg color-mix + R180 pin-mirror
2965
+ box-shadow + R354 glyph hover transform all
2966
+ preserved. data-vendor-letter-hover-lift attr
2967
+ surfaces the lift for tests. */
2968
+ className="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"
2720
2969
  data-vendor-letter={v.initial}
2721
2970
  data-vendor-letter-count={v.count}
2971
+ data-vendor-letter-hover-lift="true"
2722
2972
  data-vendor-pinned={isPinned ? 'true' : 'false'}
2723
2973
  data-vendor-hovered={hoveredVendor === v.initial ? 'true' : 'false'}
2724
2974
  data-vendor-aliases={aliases.join(',')}
@@ -2762,7 +3012,64 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2762
3012
  }
2763
3013
  }}
2764
3014
  >
2765
- <span style={{ color: v.color }}>{v.initial}</span>
3015
+ {/* Round 354 / Loop: vendor letter glyph scales
3016
+ 1.0 → 1.1 on hover. R88 already dims OTHER
3017
+ vendors on hover via canvas-wide opacity
3018
+ masking; R202 added a chip-level bg tint
3019
+ (color-mix 12 % alpha) so the chip itself
3020
+ responds. R354 closes the trio with a glyph-
3021
+ level lift: the focused vendor LETTER actively
3022
+ rises (transform scale) rather than the chip
3023
+ merely changing colour. Three layers of positive
3024
+ feedback on the hovered vendor + canvas-wide
3025
+ negative feedback on the others — a clean
3026
+ figure/ground separation.
3027
+
3028
+ display: inline-block is required for transform
3029
+ to apply (inline elements ignore transform).
3030
+ transformOrigin: 'center' so the glyph pivots
3031
+ around its centre instead of arcing from the
3032
+ baseline anchor. transition rides the existing
3033
+ Tailwind 4 transform/scale list (no new
3034
+ property — Tailwind already lists transform in
3035
+ the default transition-property set). 200ms
3036
+ matches the R202 chip bg-tint timing so the
3037
+ glyph lift and chip background ease in concert. */}
3038
+ {/* Round 369 / Loop: vendor letter glyph picks up
3039
+ fontWeight 600 (font-semibold). The glyph is the
3040
+ vendor identifier — the DATA the operator scans
3041
+ in this chip (A / O / 书 / C / G / ?). R333 set
3042
+ the count suffix `:N` to text-gray-400 + tabular-
3043
+ nums and (via parent inheritance) fw 500. Pre-
3044
+ R369 the LETTER also inherited fw 500 from the
3045
+ chip's font-medium — letter and count read at
3046
+ the same weight, contradicting the data-vs-label
3047
+ hierarchy the rest of the chip-row already speaks.
3048
+ R369 lifts the letter to fw 600 so the chip now
3049
+ reads as the same two-tier pattern R362 closed
3050
+ on the working / online / active-links chips:
3051
+ chip digit/letter fw 600 (data)
3052
+ chip unit/count fw 500 (label)
3053
+ Sibling treatment to R362 — extends the R333-R341
3054
+ chip-internal-hierarchy arc to the vendor-letter
3055
+ chip surface (9th surface family). R354 transform-
3056
+ scale-on-hover + R88 canvas-dim-others + R202
3057
+ chip bg color-mix all preserved on the same span.
3058
+ data-vendor-letter-glyph-font-weight attr exposes
3059
+ the value for tests. */}
3060
+ <span
3061
+ data-vendor-letter-glyph={v.initial}
3062
+ data-vendor-letter-glyph-hover={hoveredVendor === v.initial ? 'true' : 'false'}
3063
+ data-vendor-letter-glyph-font-weight="600"
3064
+ style={{
3065
+ color: v.color,
3066
+ display: 'inline-block',
3067
+ fontWeight: 600,
3068
+ transform: hoveredVendor === v.initial ? 'scale(1.1)' : 'scale(1)',
3069
+ transformOrigin: 'center',
3070
+ transition: 'transform 200ms ease-out',
3071
+ }}
3072
+ >{v.initial}</span>
2766
3073
  {/* Round 333 / Loop: vendor count suffix `:{N}` joins
2767
3074
  the R317 subordinate-text-lift family (gray-500 →
2768
3075
  gray-400) plus picks up tabular-nums for digit
@@ -2864,11 +3171,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2864
3171
  (third chip in the row — matches working + online
2865
3172
  chip treatment so all three digits in the chip row
2866
3173
  stay width-stable across counter crossings). */
2867
- className={`tabular-nums font-medium hidden sm:inline px-2.5 py-1 rounded-md border anet-topo-chip-focus ${
3174
+ /* Round 399 / Loop: active-links chip closes the 3-chip
3175
+ chip-row by extending R398's hover translateY(-1px)
3176
+ lift onto the third (rightmost) chip. The R398 family
3177
+ already covered working + online chips on the
3178
+ clickable variant; R399 adds the same gate (isInter-
3179
+ active = flowLinks.length > 0) so empty active-links
3180
+ stays planted at R206's opacity-50 receded paint.
3181
+ transition-transform + ease-out + transform-gpu join
3182
+ the inline transition list (different property axes
3183
+ compose cleanly: inline handles color/bg/border/
3184
+ opacity, className handles transform).
3185
+ Gesture-vocabulary table (post-R399 — now complete
3186
+ across the chip-row):
3187
+ working chip -1 px (R398)
3188
+ online chip -1 px (R398)
3189
+ active-links chip -1 px (R399, this round)
3190
+ filter pin pills -1 px (R397)
3191
+ recent-signal row -1 px (R143)
3192
+ legend row -1 px (R144)
3193
+ Every interactive chip in TopoGraph lifts on hover.
3194
+ data-chip-hover-lift attr exposes the lift surface
3195
+ state ('true' clickable, 'false' empty) for tests. */
3196
+ className={`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 ${
2868
3197
  isInteractive
2869
- ? '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'
3198
+ ? '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'
2870
3199
  : 'bg-gray-500/10 text-gray-400 border-gray-500/20'
2871
3200
  }`}
3201
+ data-chip-hover-lift={isInteractive ? 'true' : 'false'}
2872
3202
  data-active-links-chip
2873
3203
  data-active-links-flow-count={flowLinks.length}
2874
3204
  data-active-links-clickable={isInteractive ? 'true' : 'false'}
@@ -2896,7 +3226,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2896
3226
  the 5th chip surface in the R333/R335/R336/R337
2897
3227
  chip-internal-hierarchy arc. data-active-links-
2898
3228
  chip-unit exposes the unit span for tests. */}
2899
- {flowLinks.length}<span className="opacity-70" data-active-links-chip-unit> active link{flowLinks.length === 1 ? '' : 's'}</span>
3229
+ {/* R362 sibling — active-links chip digit gains font-semibold. */}
3230
+ <span className="font-semibold" data-active-links-chip-digit>{flowLinks.length}</span><span className="opacity-70" data-active-links-chip-unit> active link{flowLinks.length === 1 ? '' : 's'}</span>
2900
3231
  {rel ? (() => {
2901
3232
  // Round 161 / Loop: extend R160's recency-pip
2902
3233
  // vocabulary up one scope — from per-flow row to
@@ -2921,8 +3252,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2921
3252
  const alpha = ageSec <= 30
2922
3253
  ? 1
2923
3254
  : ageSec <= 300
2924
- ? 1 - ((ageSec - 30) / 270) * 0.75
2925
- : 0.25;
3255
+ ? 1 - ((ageSec - 30) / 270) * 0.70 /* R358: floor 0.25 → 0.30 lift across 3 freshness scopes */
3256
+ : 0.30; /* R358: stale floor lifted 0.25 → 0.30 — 20% legibility bump while preserving fresh/stale ratio */
2926
3257
  // Cyan dark / teal light to match palette legendAccent.
2927
3258
  const dotColor = isLight
2928
3259
  ? `rgba(13, 148, 136, ${alpha.toFixed(2)})`
@@ -2939,7 +3270,28 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2939
3270
  // color: dotColor — the lift only affects the trailing
2940
3271
  // literal "last {rel}" text.
2941
3272
  return (
2942
- <span className="text-gray-400">
3273
+ // Round 357 / Loop: active-links chip freshness
3274
+ // suffix wrapper picks up `tabular-nums` for digit
3275
+ // width-lock. Pre-R357 the literal "last {rel}"
3276
+ // text (e.g. "last 5s ago", "last 10s ago",
3277
+ // "last 1m ago") had natural-figure digits — the
3278
+ // freshness ticker updates every second, so the
3279
+ // 9→10 boundary on the seconds counter and the
3280
+ // 59→60s → 1m flip both jittered ~1-2 px of glyph
3281
+ // width which propagated through the chip-row's
3282
+ // inline-flex layout, nudging the freshness DOT
3283
+ // and the chip's left edge. Tabular-nums on the
3284
+ // wrapper applies to all descendant digits only
3285
+ // (letters render at natural widths) so the
3286
+ // ticker stays planted across every count cross.
3287
+ // Joins the R224-R232 info-density tabular-nums
3288
+ // sweep at the chip-row freshness scope. Pure
3289
+ // paint-level change, no geometry shift on rest.
3290
+ // The R342 text-gray-400 lift + R161 dot freshness
3291
+ // alpha ramp + R317 subordinate-text-lift family
3292
+ // all preserved. data-active-links-freshness-
3293
+ // wrapper attr exposes the wrapper for tests.
3294
+ <span className="text-gray-400 tabular-nums" data-active-links-freshness-wrapper>
2943
3295
  <span
2944
3296
  data-active-links-freshness-dot
2945
3297
  data-active-links-freshness-alpha={alpha.toFixed(2)}
@@ -3603,6 +3955,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3603
3955
  active surfaces the activity state for test probes
3604
3956
  (active spokes don't carry the bucket/dur attrs so
3605
3957
  they need their own data anchor). */
3958
+ // Round 382 / Loop: hub-spoke path picks up
3959
+ // strokeLinecap='round'. Sibling polish to R378 flow-
3960
+ // rail dashes + R380 group box dashes — three dashed-
3961
+ // stroke surfaces now share 'round' linecap:
3962
+ // R378 flow-rail '2 12' -> soft 3-px pills
3963
+ // R380 group box '6 6' -> soft 7.5-px pills
3964
+ // R382 hub spoke '6 14' -> soft 7-px pills (this round)
3965
+ // For idle spokes (dashed at sw=1), each 6-px dash gains
3966
+ // 0.5-px round caps and reads as a soft pill instead of
3967
+ // a sharp 6 x 1 rectangle. Active spokes (solid, no
3968
+ // dasharray) have caps mostly hidden by the hub center +
3969
+ // node radius. Geometry-safe; paint-only. R51 sentinel
3970
+ // strokeWidth 1.5/3 untouched (idle=1, active=2). data-
3971
+ // topo-hub-spoke-linecap attr exposes the value for tests.
3972
+ // Round 391 / Loop: hub-spoke active opacity 0.7 → 0.8.
3973
+ // Pre-R391 active spokes (the spoke connecting the hub
3974
+ // to the currently-active alias — hovered or pinned)
3975
+ // lifted opacity from rest 0.45 to active 0.7 — a clear
3976
+ // step but slightly understated against the canvas
3977
+ // chrome. R391 lifts active to 0.8 so the "this spoke
3978
+ // connects to your active node" signal reads with
3979
+ // matching weight to the R370 hub hover-ring opacity
3980
+ // (0.7 → 0.8 cyber) — paired canvas signals now share
3981
+ // the same active-state alpha (0.8) so when a user
3982
+ // hovers a node, both the spoke and the hub-ring lift
3983
+ // to identical opacity. Rest 0.45 invariant preserved.
3984
+ // Theme-consistency / canvas-presence polish family
3985
+ // (6th anchor):
3986
+ // R370 hub hover-ring opacity 0.7 → 0.8 cyber
3987
+ // R371 edge-badge rest opacity 0.82 → 0.85 cyber
3988
+ // R372 minimap offline-dot opacity 0.5 → 0.6
3989
+ // R386 hub-highlight idle opacity 0.9 → 0.95
3990
+ // R387 hover-detail panel opacity 0.94 → 0.97 cyber
3991
+ // R391 hub-spoke active opacity 0.7 → 0.8 (this round)
3992
+ // Idle path (45% alpha + dashed flow animation) entirely
3993
+ // untouched — R391 is an active-state-only lift.
3994
+ // data-topo-hub-spoke-opacity attr exposes the resolved
3995
+ // value for tests. R382 strokeLinecap='round' + R51
3996
+ // sentinel-safe sw (1 idle / 2 active) preserved.
3606
3997
  return (
3607
3998
  <path
3608
3999
  key={`hub-${session.alias}`}
@@ -3611,11 +4002,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3611
4002
  stroke={isActiveSpoke ? pal.spokeStroke.active : pal.spokeStroke.idle}
3612
4003
  strokeWidth={isActiveSpoke ? 2 : 1}
3613
4004
  strokeDasharray={isActiveSpoke ? 'none' : '6 14'}
3614
- opacity={isActiveSpoke ? 0.7 : 0.45}
4005
+ strokeLinecap="round"
4006
+ opacity={isActiveSpoke ? 0.8 : 0.45}
3615
4007
  className={isActiveSpoke ? undefined : 'anet-topo-spoke-flow'}
3616
4008
  data-topo-spoke-bucket={isActiveSpoke ? undefined : busy}
3617
4009
  data-topo-spoke-dur={isActiveSpoke ? undefined : spokeDur}
3618
4010
  data-topo-hub-spoke-active={isActiveSpoke ? 'true' : 'false'}
4011
+ data-topo-hub-spoke-opacity={isActiveSpoke ? 0.8 : 0.45}
4012
+ data-topo-hub-spoke-linecap="round"
3619
4013
  style={{
3620
4014
  transition: 'stroke 250ms ease-out, stroke-width 250ms ease-out, opacity 250ms ease-out',
3621
4015
  ...(isActiveSpoke ? {} : {
@@ -3707,7 +4101,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3707
4101
  stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
3708
4102
  strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
3709
4103
  strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
4104
+ /* Round 380 / Loop: cluster box stroke gets round
4105
+ linecap + round linejoin. Sibling SVG stroke-
4106
+ softening polish to R378 flow-rail linecap + R379
4107
+ minimap viewport linejoin — extends the family to
4108
+ the group cluster boundary box (grid layout only):
4109
+ R288 chrome icons strokeLinecap='round'
4110
+ R378 flow-rail dashes strokeLinecap='round'
4111
+ R380 group box dashes strokeLinecap='round' (this round)
4112
+ R379 viewport rect strokeLinejoin='round'
4113
+ R380 group box corners strokeLinejoin='round' (this round)
4114
+ Linecap rounds the R85 '6 6' marching-ants dash
4115
+ pills at rest — each 6 px dash gains a ~0.75 px
4116
+ round cap (sw=1.5 idle), reading as soft pills
4117
+ instead of sharp 6 × 1.5 px rectangles. Linejoin
4118
+ rounds the 4 sharp 90° corners (any state — solid
4119
+ or dashed); at sw=1.5 the join arc is ~0.75 px,
4120
+ matching R379 viewport vocabulary. Geometry-safe:
4121
+ stroke-* properties only affect paint, not bbox.
4122
+ The R51 sentinel 1.5/3 strokeWidth values stay
4123
+ intact (the overlap probe is gated to g[data-
4124
+ node], so this cluster-internal rect is invisible
4125
+ to it anyway). data-group-box-linecap + -linejoin
4126
+ attrs expose the values for tests. */
4127
+ strokeLinecap="round"
4128
+ strokeLinejoin="round"
3710
4129
  data-group-box-pinned={isPinned ? 'true' : 'false'}
4130
+ data-group-box-linecap="round"
4131
+ data-group-box-linejoin="round"
3711
4132
  // R85: ambient "marching ants" drift on the perimeter
3712
4133
  // when this group has at least one working member, and
3713
4134
  // neither pin nor hover is active (those treatments
@@ -3907,12 +4328,33 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3907
4328
  lives at a fixed dx=6 offset from the name, so a
3908
4329
  digit-width jitter at 9→10 used to shift the
3909
4330
  whole count visibly. Tabular locks it. */}
4331
+ {/* Round 366 / Loop: group label member-count tspan
4332
+ fontWeight 400 → 500. Sibling polish to R363
4333
+ recent-row alias text fw 400 → 500 + R364 legend-
4334
+ row label fw 400 → 500 — closes the per-row 'count
4335
+ is fw 500 against label-tier fw 700' pattern at
4336
+ the group-label scope (grid layout cluster mark).
4337
+ Hierarchy snapshot post-R366 across all 3 row
4338
+ surfaces:
4339
+ recent count(hot/cold) fw 700/600 (R320)
4340
+ recent alias fw 500 (R363)
4341
+ legend count fw 600 (R309)
4342
+ legend label fw 500 (R364)
4343
+ group name fw 700 (legacy)
4344
+ group count fw 500 (R366, this round)
4345
+ Monospace family + R225 tabular-nums lock digit
4346
+ width, so the fw bump is paint-only — bbox
4347
+ unchanged + overlap-test invariants hold. R229
4348
+ fill-inherit from parent label (hover-deepen-own-
4349
+ hue family) preserved. data-group-label-count-
4350
+ font-weight attr exposes the value for tests. */}
3910
4351
  <tspan
3911
4352
  dx="6"
3912
4353
  fontSize="11"
3913
- fontWeight="400"
4354
+ fontWeight="500"
3914
4355
  data-group-label-count={box.key}
3915
4356
  data-group-label-count-value={box.count}
4357
+ data-group-label-count-font-weight="500"
3916
4358
  style={{ fontVariantNumeric: 'tabular-nums' }}
3917
4359
  >· {box.count}</tspan>
3918
4360
  {/* Round 58 / Loop: status mix pip strip. Compact text-
@@ -4043,13 +4485,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4043
4485
  // synchronised per-edge animation set.
4044
4486
  const stagger = (index * 0.37) % duration;
4045
4487
  // Round 10 / Loop: freshness fade. An edge that fired ≤30s ago
4046
- // stays at full intensity; over 5 minutes it decays to ~35%.
4047
- // Surfaces "what's happening now" vs background chatter without
4048
- // hiding old flow entirely (some context still useful). `now`
4049
- // captured at useMemo-recompute time (every 5s message refresh)
4050
- // — accuracy is within the poll interval, plenty.
4488
+ // stays at full intensity; over 5 minutes it decays to a
4489
+ // floor. Surfaces "what's happening now" vs background
4490
+ // chatter without hiding old flow entirely (some context
4491
+ // still useful). `now` captured at useMemo-recompute time
4492
+ // (every 5s message refresh) — accuracy is within the poll
4493
+ // interval, plenty.
4494
+ //
4495
+ // Round 406 / Loop: edge freshness fade floor 0.35 → 0.40.
4496
+ // Stale-state legibility lift family (6th anchor) — pre-
4497
+ // R406 edges older than 5 minutes faded to α=0.35 (a 65 %
4498
+ // dim against full intensity). The decay rate is the same
4499
+ // 1 - ageMs/300s curve; only the FLOOR shifts. Sibling
4500
+ // treatment to:
4501
+ // R317 subordinate-text gray-500 → gray-400
4502
+ // R358 freshness ramp floor 0.25 → 0.30
4503
+ // R372 minimap offline-dot opacity 0.5 → 0.6
4504
+ // R404 hub-halo cyber trough 0.08 → 0.10
4505
+ // R405 hub-halo light trough 0.32 → 0.34
4506
+ // R406 edge freshness floor 0.35 → 0.40 (this round)
4507
+ // Edges past 5min now sit at 40% intensity instead of 35%
4508
+ // — they still recede against fresh edges but read
4509
+ // legibly enough to convey "this conversation existed".
4510
+ // ageMs threshold for the 5-minute decay unchanged; the
4511
+ // decay curve shape (linear) unchanged. The visual delta
4512
+ // is most pronounced on edges between 5-60 minutes old —
4513
+ // where the floor was binding pre-R406.
4051
4514
  const ageMs = link.last_at ? Math.max(0, Date.now() - Date.parse(link.last_at)) : 0;
4052
- const fresh = Math.max(0.35, 1 - ageMs / (5 * 60 * 1000));
4515
+ const fresh = Math.max(0.40, 1 - ageMs / (5 * 60 * 1000));
4053
4516
  // Round 16 arrow-tier binning — keep `topo-arrow` as the
4054
4517
  // medium tier id so the legend swatch picks it up unchanged.
4055
4518
  const arrowId = link.count <= 2 ? 'topo-arrow-s'
@@ -4201,20 +4664,56 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4201
4664
  online chips to splice in additional properties
4202
4665
  beside Tailwind's). data-edge-flow-rail attr
4203
4666
  surfaces the path for test introspection. */}
4667
+ {/* Round 381 / Loop: edge visible flow path picks up
4668
+ strokeLinecap='round'. Sibling polish to R378
4669
+ flow-rail dashed linecap — both flow-element paths
4670
+ (visible primary + dashed secondary rail) now share
4671
+ 'round' linecap vocabulary. The visible path runs
4672
+ source-node → dest-node as one continuous line, so
4673
+ the dest-end is covered by the markerEnd arrow and
4674
+ the source-end usually sits inside the source-node
4675
+ circle. At certain alignments (post-zoom, post-
4676
+ layout-switch transitions), the source-end may peek
4677
+ out by a fraction of a px past the node edge —
4678
+ round caps render that overshoot as a smooth half-
4679
+ disc instead of a sharp rectangle. Pure paint
4680
+ refinement, geometry-safe (bbox of the stroke
4681
+ unchanged at the join with the arrow marker).
4682
+ data-edge-visible-linecap attr exposes the value
4683
+ for tests. */}
4204
4684
  <path
4205
4685
  d={path}
4206
4686
  fill="none"
4207
4687
  stroke={pal.flowEdge}
4208
4688
  strokeWidth={renderWidth}
4689
+ strokeLinecap="round"
4209
4690
  opacity={Math.min(1, (isLight ? 0.22 : 0.28) * fresh * edgeOpacityMul)}
4210
4691
  filter={isLight ? undefined : 'url(#topo-glow)'}
4211
4692
  markerEnd={`url(#${arrowId})`}
4212
4693
  data-edge-visible={link.key}
4694
+ data-edge-visible-linecap="round"
4213
4695
  style={{
4214
4696
  pointerEvents: 'none',
4215
4697
  transition: 'opacity 300ms ease-out, stroke-width 300ms ease-out, stroke 300ms ease-out',
4216
4698
  }}
4217
4699
  />
4700
+ {/* Round 378 / Loop: edge flow-path dashed-rail picks
4701
+ up strokeLinecap='round'. Pre-R378 the rail
4702
+ rendered '2 12' dashes as sharp 1×2 rectangles
4703
+ against the canvas backdrop; default 'butt' caps
4704
+ leave dash ends square. R378 rounds each cap so
4705
+ the dashes read as soft 3-px pills (1 px stroke +
4706
+ 0.5 px round cap each end). The flow-rail is the
4707
+ secondary 'invisible-spine' line that gives the
4708
+ R57 spoke flow a directional rail to slide along
4709
+ — rounding the dashes softens its presence
4710
+ against the primary visible flow path (R245 has
4711
+ no strokeLinecap so it inherits 'butt' on a
4712
+ continuous line, irrelevant). Geometry-safe:
4713
+ round caps only widen the visible dash; the
4714
+ bbox of the path is unchanged so overlap-test
4715
+ invariants hold. data-edge-flow-rail-linecap
4716
+ attr exposes the value for tests. */}
4218
4717
  <path
4219
4718
  id={`flow-path-${index}`}
4220
4719
  d={path}
@@ -4222,8 +4721,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4222
4721
  stroke={pal.flowPath}
4223
4722
  strokeWidth="1"
4224
4723
  strokeDasharray="2 12"
4724
+ strokeLinecap="round"
4225
4725
  opacity={Math.min(1, (isLight ? 0.4 : 0.75) * fresh * edgeOpacityMul)}
4226
4726
  data-edge-flow-rail={link.key}
4727
+ data-edge-flow-rail-linecap="round"
4227
4728
  style={{ transition: 'opacity 300ms ease-out, stroke 300ms ease-out' }}
4228
4729
  />
4229
4730
  {!reducedMotion && (
@@ -4588,14 +5089,137 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4588
5089
  R251 closes the per-edge surface theme-toggle
4589
5090
  smoothness — every theme-driven property on
4590
5091
  every edge element now eases under cyber↔light. */}
5092
+ {/* Round 367 / Loop: edge midpoint badge rest
5093
+ stroke-width 1 → 1.25. Sibling visual-weight
5094
+ bump family (7th canvas anchor now):
5095
+ R287 minimap viewport stroke 1 → 1.5
5096
+ R295 legend swatch base radius 5.5 → 6
5097
+ R359 recent-row pip base radius 1.6 → 1.8
5098
+ R360 hub digit fontSize 11 → 12
5099
+ R361 edge-badge digit fontSize 10 → 11
5100
+ R365 hub-highlight base radius 5 → 5.5
5101
+ R367 edge-badge rest stroke 1 → 1.25 (this round)
5102
+ Cold edge badges gain ~25 % stroke presence
5103
+ (1.25/1.0 = 1.25). Stays clear of the R51
5104
+ overlap-test sentinel values (1.5 / 3 reserved
5105
+ for node strokes — the test selector is gated
5106
+ to g[data-node] ancestors so this edge-internal
5107
+ circle is invisible to that probe anyway, but
5108
+ 1.25 is a safe non-sentinel value regardless).
5109
+ R188 transition list 'stroke-width 300ms ease-
5110
+ out' still smoothes the hot/pin flip — now
5111
+ 1.25 → 2 instead of 1 → 2, same ease pace.
5112
+ data-edge-badge-stroke-width-rest exposes the
5113
+ new baseline for tests. */}
5114
+ {/* Round 371 / Loop: edge-badge cyber opacity 0.82
5115
+ → 0.85. Sibling theme-consistency polish to R370
5116
+ hub hover-ring 0.7 → 0.8. R251 designed this
5117
+ badge with opacity 0.82 (cyber) / 0.95 (light)
5118
+ — 13 % delta. Cyber-theme dark bg needs more
5119
+ alpha to read as 'present'; R371 narrows the
5120
+ gap to 10 %, bringing the badge closer to light
5121
+ theme's 0.95 floor. Light stays at 0.95
5122
+ (already in the legibility band). data-edge-
5123
+ badge-opacity attr exposes the resolved value.
5124
+ Theme-consistency polish family:
5125
+ R246/R247 panel transition family
5126
+ R251 edge badge fill/opacity baseline
5127
+ R370 hub hover-ring cyber 0.7 → 0.8
5128
+ R371 edge badge cyber 0.82 → 0.85 (this round)
5129
+ R164 r=9/10.5 hover-lift + R188/R251 transition
5130
+ list + R367 strokeWidth=1.25 cold rest preserved. */}
5131
+ {/* Round 394 / Loop: edge-badge gains a hover
5132
+ strokeWidth tier (1.5) between cold rest
5133
+ (R367 1.25) and pin/hot (2). Pre-R394 the
5134
+ badge lifted only its radius on hover (R164
5135
+ 9 → 10.5); the stroke stayed at cold rest
5136
+ 1.25 unless pin/hot kicked in, so a plain
5137
+ hover felt half-lifted — geometry expanded
5138
+ while the contour stayed thin. R394 adds
5139
+ strokeWidth=1.5 on isHoveredEdge so hover
5140
+ now lifts both r AND stroke in concert —
5141
+ same pattern R385 used for the hub hover-
5142
+ ring (1.5 → 1.75) where the ring's three
5143
+ hover axes (r grow / opacity fade-in /
5144
+ stroke thicken) all rise together.
5145
+ Three-tier stroke hierarchy now:
5146
+ cold rest 1.25 (R367)
5147
+ hovered 1.5 (R394 — this round)
5148
+ pinned / hot 2.0 (R188)
5149
+ R51 sentinel concern: strokeWidth=1.5 is
5150
+ one of the two sentinels reserved for node
5151
+ detection, but the R51 selector is gated
5152
+ to `g[data-node]` ancestors so this edge-
5153
+ internal circle is invisible to the probe
5154
+ (same lesson R177 hub hover-ring + R367
5155
+ cold rest documented). 300ms strokeWidth
5156
+ transition already in the style list eases
5157
+ the new tier naturally. data-edge-badge-
5158
+ stroke-width-hover attr exposes the hover
5159
+ value for tests. */}
5160
+ {/* Round 395 / Loop: edge-badge gains a third
5161
+ hover axis — opacity 0.85 (cyber) / 0.95
5162
+ (light) → 1.0 on isHoveredEdge. Pre-R395
5163
+ hovering thickened the stroke (R394 1.25 →
5164
+ 1.5) and grew the radius (R164 9 → 10.5)
5165
+ but the badge's translucency stayed put at
5166
+ R371's rest alpha (cyber 0.85 / light 0.95).
5167
+ R395 lifts hover to a clean 1.0 — fully
5168
+ opaque — so the hovered badge reads as
5169
+ "in focus" against the dim siblings.
5170
+ Three-axis hover-lift parity now complete:
5171
+ hub hover-ring (R177/R370/R385):
5172
+ r 14 → 17, opacity 0 → 0.8 cyber, sw 1.5 → 1.75
5173
+ edge badge (R164/R394/R395):
5174
+ r 9 → 10.5, sw 1.25 → 1.5, opacity → 1.0
5175
+ 200ms opacity transition (already in the
5176
+ style list) eases the new axis naturally.
5177
+ R371 rest opacity (0.85 cyber / 0.95 light)
5178
+ preserved as the resting alpha — R395
5179
+ adds an isHoveredEdge override on top.
5180
+ data-edge-badge-opacity-hover attr exposes
5181
+ the hover value for tests. */}
5182
+ {/* Round 396 / Loop: extend the R395 opacity → 1.0
5183
+ lift to the pinned state. Pre-R396 the badge
5184
+ shared `r=10.5` on both hover AND pin (R164
5185
+ unified-lift) but R395's opacity lift fired
5186
+ ONLY on isHoveredEdge — pinned badges stayed
5187
+ at R371 rest alpha (cyber 0.85 / light 0.95).
5188
+ That left pin (sticky selection) reading
5189
+ softer than hover (transient preview), even
5190
+ though pin is the stronger commitment.
5191
+ R396 unifies hover + pin at opacity=1.0
5192
+ so the same data-edge-badge-lifted='true'
5193
+ surface uniformly carries full alpha. Pin
5194
+ stroke (R188 sw=2 + pal.legendHeadline color)
5195
+ continues to differentiate pin from hover —
5196
+ the opacity track now closes the lift parity.
5197
+ The new gate (isHoveredEdge || isPinned)
5198
+ mirrors the existing R164 r-lift gate, so
5199
+ the badge has a single "active state"
5200
+ signature across r + opacity.
5201
+ 200ms opacity transition (already in style
5202
+ list) eases pin/unpin naturally. R371 rest
5203
+ opacity preserved as the resting alpha.
5204
+ data-edge-badge-opacity-hover renamed
5205
+ semantically to -active (covers hover+pin)
5206
+ via the new -opacity-active attr; the
5207
+ legacy -opacity-hover attr kept for R395
5208
+ test compatibility. */}
4591
5209
  <circle
4592
5210
  cx={badgeX} cy={badgeY}
4593
5211
  r={isHoveredEdge || isPinned ? 10.5 : 9}
4594
5212
  fill={pal.legendBox.fill}
4595
5213
  stroke={isPinned ? pal.legendHeadline : isHot ? hotStroke : pal.flowEdge}
4596
- strokeWidth={isPinned ? 2 : isHot ? 2 : 1}
4597
- opacity={isLight ? 0.95 : 0.82}
5214
+ strokeWidth={isPinned ? 2 : isHot ? 2 : isHoveredEdge ? 1.5 : 1.25}
5215
+ opacity={(isHoveredEdge || isPinned) ? 1 : (isLight ? 0.95 : 0.85)}
4598
5216
  data-edge-badge-lifted={(isHoveredEdge || isPinned) ? 'true' : 'false'}
5217
+ data-edge-badge-stroke-width-rest="1.25"
5218
+ data-edge-badge-stroke-width-hover="1.5"
5219
+ data-edge-badge-opacity={(isHoveredEdge || isPinned) ? 1 : (isLight ? 0.95 : 0.85)}
5220
+ data-edge-badge-opacity-rest={isLight ? 0.95 : 0.85}
5221
+ data-edge-badge-opacity-hover="1"
5222
+ data-edge-badge-opacity-active="1"
4599
5223
  style={{ transition: 'r 180ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out, fill 200ms ease-out, opacity 200ms ease-out' }}
4600
5224
  />
4601
5225
  {/* Round 224 / Loop: edge badge text gains the 4th
@@ -4634,11 +5258,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4634
5258
  x={badgeX} y={badgeY + 3}
4635
5259
  textAnchor="middle"
4636
5260
  fill={pal.legendHeadline}
4637
- fontSize="10"
5261
+ /* Round 361 / Loop: edge midpoint badge text
5262
+ fontSize 10 → 11. Sibling visual-weight bump
5263
+ to R360 hub digit 11 → 12. The badge digit
5264
+ is the per-edge equivalent of the hub digit
5265
+ — a high-information scalar (link.count) at
5266
+ a stable canvas position. Pre-R361 fontSize=
5267
+ 10 + R220 letter-spacing 0.4 + R224 tabular-
5268
+ nums made the digit READABLE but small
5269
+ against the r=9 / 18-px badge envelope;
5270
+ fontSize=11 nudges the glyph ~10 % bigger
5271
+ (bbox ~7×10 px from ~6×9 px) so the count
5272
+ reads more cleanly at glance — still well
5273
+ inside the r=9 idle circle and the r=10.5
5274
+ hover/pin lift (R164). y=badgeY+3 empirical
5275
+ vertical centring kept (1px drift at the
5276
+ bumped size is below the noise floor in
5277
+ the on-curve flow path).
5278
+ Visual-weight bump family:
5279
+ R287 minimap viewport stroke 1 → 1.5
5280
+ R295 legend swatch base radius 5.5 → 6
5281
+ R359 recent-row pip base radius 1.6 → 1.8
5282
+ R360 hub digit fontSize 11 → 12
5283
+ R361 edge-badge digit fontSize 10 → 11 (this round)
5284
+ data-edge-badge-text-font-size attr exposes
5285
+ the value for tests. R220 pin/hot letter-
5286
+ spacing tween + R224 tabular-nums + R188
5287
+ stroke-width pin/hot transitions all preserved. */
5288
+ fontSize="11"
4638
5289
  fontFamily="monospace"
4639
5290
  fontWeight="700"
4640
5291
  data-edge-badge-text={link.key}
4641
5292
  data-edge-badge-text-pin={(isPinned || isHot) ? 'true' : 'false'}
5293
+ data-edge-badge-text-font-size="11"
4642
5294
  style={{
4643
5295
  pointerEvents: 'none',
4644
5296
  fontVariantNumeric: 'tabular-nums',
@@ -4754,19 +5406,68 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4754
5406
  : workingCount <= 2 ? 1
4755
5407
  : workingCount <= 5 ? 2
4756
5408
  : 3;
5409
+ // Round 404 / Loop: hub-halo cyber trough opacity 0.08 →
5410
+ // 0.10. Pre-R404 the breath's low-point sat at α=0.08
5411
+ // cyber (per R84 family tuning) — the halo nearly faded
5412
+ // out at trough on the dark canvas. R404 lifts cyber
5413
+ // trough to 0.10. Per-bucket peak amplitudes [0.16/0.20/
5414
+ // 0.26/0.32] stay exactly tuned.
5415
+ //
5416
+ // Round 405 / Loop: hub-halo LIGHT trough 0.32 → 0.34 —
5417
+ // symmetric +0.02 lift to mirror R404's cyber treatment
5418
+ // across both themes. Pre-R405 only cyber got the lift
5419
+ // (R404 docstring noted "light already at the strong
5420
+ // end" as deliberate); but the cyber/light delta in
5421
+ // R404 was an inconsistency in the family pattern.
5422
+ // R405 closes the symmetry — both themes get +0.02
5423
+ // baseline lift, so the breath low-point reads with
5424
+ // matching confidence regardless of theme. Light peak
5425
+ // array [0.52/0.58/0.65/0.72] stays tuned.
5426
+ //
5427
+ // Stale-state legibility lift family (5 anchors now):
5428
+ // R317 subordinate-text gray-500 → gray-400
5429
+ // R358 freshness floor 0.25 → 0.30
5430
+ // R372 minimap offline-dot opacity 0.5 → 0.6
5431
+ // R404 hub-halo cyber trough 0.08 → 0.10
5432
+ // R405 hub-halo light trough 0.32 → 0.34 (this round)
5433
+ //
5434
+ // R84 per-bucket peak/dur + R245 ease-in-out spline
5435
+ // keySplines all preserved. Test fixture probes the
5436
+ // SMIL <animate> values via data-topo-hub-halo-trough
5437
+ // attr (now exposes both light + cyber resolved values).
4757
5438
  const peakLight = [0.52, 0.58, 0.65, 0.72][busy];
4758
5439
  const peakDark = [0.16, 0.20, 0.26, 0.32][busy];
4759
- const troughLight = 0.32;
4760
- const troughDark = 0.08;
5440
+ const troughLight = 0.34;
5441
+ const troughDark = 0.10;
4761
5442
  const dur = [4.0, 3.2, 2.7, 2.4][busy];
4762
5443
  const valuesLight = `${troughLight};${peakLight};${troughLight}`;
4763
5444
  const valuesDark = `${troughDark};${peakDark};${troughDark}`;
5445
+ // Round 408 / Loop: hub-halo radius 18 → 20. The
5446
+ // grounding halo (the breathing outer circle around
5447
+ // the hub center) is the canvas's signature breath
5448
+ // element — R84 family. R408 bumps r=18 → 20 so the
5449
+ // breath extends slightly further while keeping 4px
5450
+ // clearance before the spoke origin (still room for
5451
+ // spoke start anchors). Visual presence on the
5452
+ // canvas focal point lifts ~23% area (π·20²/π·18²
5453
+ // = 1.23) without changing the per-bucket opacity
5454
+ // envelope or breath rhythm. Visual-weight bump
5455
+ // family 13th anchor — pairs with R404/R405 trough
5456
+ // lifts so the halo now breathes both with more
5457
+ // visible amplitude AND more visual footprint.
5458
+ // R84 per-bucket peak/dur invariants + R244 calc-
5459
+ // Mode='spline' + R245 ease-in-out keySplines all
5460
+ // preserved. data-topo-hub-halo-radius attr exposes
5461
+ // value for tests.
4764
5462
  return (
4765
5463
  <circle
4766
- cx={cx} cy={cy} r="18"
5464
+ cx={cx} cy={cy} r="20"
4767
5465
  fill={isLight ? '#d1fae5' : '#10b981'}
4768
5466
  opacity={isLight ? 0.42 : 0.12}
4769
5467
  data-hub-busyness={busy}
5468
+ data-topo-hub-halo-radius="20"
5469
+ data-topo-hub-halo-trough={isLight ? troughLight : troughDark}
5470
+ data-topo-hub-halo-peak={isLight ? peakLight : peakDark}
4770
5471
  /* Round 253 / Loop: hub grounding halo fill transition
4771
5472
  for theme toggle. Pre-R253 the base fill (cyber
4772
5473
  #10b981 ↔ light #d1fae5) snapped while R244's SMIL
@@ -4865,11 +5566,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4865
5566
  textAnchor="middle"
4866
5567
  dy="0.36em"
4867
5568
  fill={isLight ? '#d1fae5' : '#ecfdf5'}
4868
- fontSize="11"
5569
+ /* Round 360 / Loop: hub working-count digit fontSize 11
5570
+ → 12. The hub is the canvas's focal point — its digit
5571
+ is the most-read scalar on the whole topology. R130
5572
+ sized it at 11 (well inside the r=10 / 20-px core);
5573
+ R360 nudges it to 12 (~13 px wide × 12 px tall, still
5574
+ well inside the 20-px diameter) for ~9 % more presence.
5575
+ Sibling visual-weight bump family:
5576
+ R287 minimap viewport stroke 1 → 1.5
5577
+ R295 legend swatch base radius 5.5 → 6
5578
+ R359 recent-row pip radius 1.6 → 1.8
5579
+ R360 hub digit fontSize 11 → 12 (this round)
5580
+ The R209 scale-1.08-on-hub-hover, R225 tabular-nums,
5581
+ R253 fill transition, R213 always-mount opacity gate
5582
+ all preserved. data-topo-hub-working-count-font-size
5583
+ attr exposes the value for tests. */
5584
+ fontSize="12"
4869
5585
  fontFamily="monospace"
4870
5586
  fontWeight="700"
4871
5587
  opacity={workingCount > 0 ? 1 : 0}
4872
5588
  data-topo-hub-working-count={workingCount}
5589
+ data-topo-hub-working-count-font-size="12"
4873
5590
  data-topo-hub-working-count-hovered={hoveredHub ? 'true' : 'false'}
4874
5591
  data-topo-hub-working-count-visible={workingCount > 0 ? 'true' : 'false'}
4875
5592
  // Round 209 / Loop: hub workingCount digit scales 1.0 →
@@ -4910,12 +5627,53 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4910
5627
  {workingCount}
4911
5628
  </text>
4912
5629
  {/* decorative highlight (visible when workingCount === 0) */}
5630
+ {/* Round 365 / Loop: hub-center 'lit-lamp' decorative highlight
5631
+ circle r 5 → 5.5. Sibling visual-weight bump family —
5632
+ each round lifts one canvas anchor's geometric presence
5633
+ without disturbing its bbox envelope:
5634
+ R287 minimap viewport stroke 1 → 1.5
5635
+ R295 legend swatch base radius 5.5 → 6
5636
+ R359 recent-row pip base radius 1.6 → 1.8
5637
+ R360 hub digit fontSize 11 → 12
5638
+ R361 edge-badge digit fontSize 10 → 11
5639
+ R365 hub-highlight base radius 5 → 5.5 (this round)
5640
+ The highlight only renders when workingCount === 0
5641
+ (decorative 'lamp lit but idle' state per R130 + R213
5642
+ always-mount opacity-gate). At idle, the 0.5-px radius
5643
+ bump (21 % area, π*5.5² / π*5² = 1.21) lifts the lamp's
5644
+ presence — still well inside the r=10 hub-core (R130).
5645
+ opacity=0 when working preserved so the hub-digit's R130
5646
+ takeover stays seamless. R213 always-mount opacity-gate
5647
+ + 300ms opacity transition + pointerEvents:none all
5648
+ preserved. data-topo-hub-highlight-radius attr exposes
5649
+ the value for tests. */}
5650
+ {/* Round 386 / Loop: hub-highlight idle opacity 0.9 → 0.95.
5651
+ When workingCount===0 the highlight paints as the visible
5652
+ idle "lamp lit but no work" core (R130 takeover gate).
5653
+ Pre-R386 idle opacity was 0.9 — a ~6 % fade against full
5654
+ paint that read as slightly-dimmed-ghost on the focal
5655
+ point. R386 lifts to 0.95 (idle alpha gap halved 0.10
5656
+ → 0.05) so the canvas anchor reads more confidently
5657
+ as a present-but-idle state rather than a faded ghost.
5658
+ Theme-consistency / canvas-presence polish family (4th
5659
+ anchor):
5660
+ R370 hub hover-ring opacity 0.7 → 0.8 cyber
5661
+ R371 edge-badge rest opacity 0.82 → 0.85 cyber
5662
+ R372 minimap offline-dot opacity 0.5 → 0.6
5663
+ R386 hub-highlight idle opacity 0.9 → 0.95 (this round)
5664
+ opacity=0 when working preserved so the hub-digit's
5665
+ R130 takeover stays seamless. 300ms opacity transition
5666
+ + R213 always-mount opacity-gate + pointerEvents:none
5667
+ + R365 r=5.5 all preserved. data-topo-hub-highlight-
5668
+ opacity attr exposes the resolved value for tests. */}
4913
5669
  <circle
4914
- cx={cx} cy={cy} r="5"
5670
+ cx={cx} cy={cy} r="5.5"
4915
5671
  fill="#d1fae5"
4916
- opacity={workingCount > 0 ? 0 : 0.9}
5672
+ opacity={workingCount > 0 ? 0 : 0.95}
4917
5673
  data-topo-hub-highlight
4918
5674
  data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
5675
+ data-topo-hub-highlight-radius="5.5"
5676
+ data-topo-hub-highlight-opacity={workingCount > 0 ? 0 : 0.95}
4919
5677
  style={{
4920
5678
  pointerEvents: 'none',
4921
5679
  transition: 'opacity 300ms ease-out',
@@ -4946,15 +5704,46 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4946
5704
  R142 group boxes, R143/R144 rows, R164 edge badges,
4947
5705
  R177 hub ring). prefers-reduced-motion respected via
4948
5706
  R29 globals.css blanket. */}
5707
+ {/* Round 385 / Loop: hub hover-ring strokeWidth 1.5 → 1.75.
5708
+ Sibling visual-weight bump (11th anchor) to R367 edge-
5709
+ badge rest stroke 1 → 1.25. The ring is only visible
5710
+ during hub hover (opacity=0 rest, R177 + R370 control
5711
+ the hover-state alpha) so the change manifests purely
5712
+ as a thicker hover-state ring on the canvas focal
5713
+ point. R177 r 14 → 17 grow + R370 opacity 0 → 0.8
5714
+ already lift the hover cue; R385 adds stroke weight
5715
+ as the third lift axis. Stays clear of R51 overlap-
5716
+ test sentinel value 3 (1.75 is non-sentinel); the
5717
+ R51 selector is gated to g[data-node] ancestors so
5718
+ this hub-internal circle is invisible to the probe
5719
+ regardless. R253 stroke transition + pointerEvents:
5720
+ none preserved. data-topo-hub-hover-ring-stroke-width
5721
+ attr exposes the value for tests. */}
4949
5722
  <circle
4950
5723
  cx={cx} cy={cy}
4951
5724
  r={hoveredHub ? 17 : 14}
4952
5725
  fill="none"
4953
5726
  stroke={isLight ? '#059669' : '#10b981'}
4954
- strokeWidth="1.5"
4955
- opacity={hoveredHub ? (isLight ? 0.85 : 0.7) : 0}
5727
+ strokeWidth="1.75"
5728
+ /* Round 370 / Loop: hub hover-ring cyber opacity 0.7
5729
+ 0.8. R177 designed the hub hover-ring at opacity-0 →
5730
+ 0.85 (light) / 0 → 0.7 (cyber). The 15 % gap between
5731
+ the two themes meant cyber-theme operators got a
5732
+ noticeably softer hover cue than light-theme users
5733
+ against backgrounds that should equalise (dark bg
5734
+ needs more luminance to read as 'on'). R370 bumps
5735
+ cyber 0.7 → 0.8, narrowing the theme gap to 5 % —
5736
+ sibling theme-consistency polish to R251 edge badge
5737
+ fill/opacity (cyber 0.82 / light 0.95) and R246/R247
5738
+ panel transition families. Light theme 0.85 stays
5739
+ as is (already in the legibility band). data-topo-
5740
+ hub-hover-ring-opacity attr exposes the value for
5741
+ tests. */
5742
+ opacity={hoveredHub ? (isLight ? 0.85 : 0.8) : 0}
4956
5743
  data-topo-hub-hover-ring
4957
5744
  data-topo-hub-hover-ring-radius={hoveredHub ? 17 : 14}
5745
+ data-topo-hub-hover-ring-stroke-width="1.75"
5746
+ data-topo-hub-hover-ring-opacity={hoveredHub ? (isLight ? 0.85 : 0.8) : 0}
4958
5747
  /* Round 253 / Loop: hub hover ring also gets stroke
4959
5748
  transition for theme toggle (cyber #10b981 ↔ light
4960
5749
  #059669). The opacity + r transitions stay for hover
@@ -5383,14 +6172,49 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5383
6172
  keyTimes="0;0.5;1"
5384
6173
  keySplines="0.42 0 0.58 1;0.42 0 0.58 1"
5385
6174
  />
6175
+ {/* Round 409 / Loop: active-node pulse peak
6176
+ opacity lift — cyber 0.18 → 0.20 / light
6177
+ 0.12 → 0.14. The R243 pulse is the per-
6178
+ node "this alias has recent flow" breath;
6179
+ its peak amplitude was tuned in R243 but
6180
+ sat at 0.18 cyber / 0.12 light — visible
6181
+ but understated against the surrounding
6182
+ status halos (R407 lifted offline halos
6183
+ 0.25→0.30 cyber; online halos sit at 0.65
6184
+ cyber). R409 lifts peak +0.02 on both
6185
+ themes so the active-state breath reads
6186
+ more confidently at its high-point while
6187
+ the trough (0.04 cyber / 0.02 light)
6188
+ stays put — breath amplitude widens
6189
+ slightly (cyber 0.14 → 0.16; light 0.10
6190
+ → 0.12) without changing rhythm or dur.
6191
+ Theme-consistency / canvas-presence family
6192
+ (9 anchors now):
6193
+ R370 hub hover-ring 0.7 → 0.8 cyber
6194
+ R371 edge-badge rest 0.82 → 0.85 cyber
6195
+ R372 minimap offline-dot 0.5 → 0.6
6196
+ R386 hub-highlight idle 0.9 → 0.95
6197
+ R387 hover-detail panel 0.94 → 0.97 cyber
6198
+ R391 hub-spoke active 0.7 → 0.8
6199
+ R392 minimap online-dot 0.9 → 0.95
6200
+ R403 click-ripple start 0.7 → 0.8
6201
+ R409 active-node pulse peak (this round)
6202
+ cyber 0.18 → 0.20
6203
+ light 0.12 → 0.14
6204
+ R243 always-mount opacity-gate + R243
6205
+ ease-in-out keySplines + r animation
6206
+ (radius+8 ↔ radius+22) preserved.
6207
+ data-node-pulse-peak attr exposes the
6208
+ resolved per-theme peak for tests. */}
5386
6209
  <animate
5387
6210
  attributeName="opacity"
5388
- values={isLight ? '0.12;0.02;0.12' : '0.18;0.04;0.18'}
6211
+ values={isLight ? '0.14;0.02;0.14' : '0.20;0.04;0.20'}
5389
6212
  dur="2.4s"
5390
6213
  repeatCount="indefinite"
5391
6214
  calcMode="spline"
5392
6215
  keyTimes="0;0.5;1"
5393
6216
  keySplines="0.42 0 0.58 1;0.42 0 0.58 1"
6217
+ data-node-pulse-peak={isLight ? '0.14' : '0.20'}
5394
6218
  />
5395
6219
  </circle>
5396
6220
  </g>
@@ -5435,12 +6259,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5435
6259
  parent stays for status flips. data-node-halo-
5436
6260
  breath-offset surfaces the chosen offset for
5437
6261
  test introspection. */}
6262
+ {/* Round 407 / Loop: offline node halo opacity lift —
6263
+ cyber 0.25 → 0.30 and light 0.4 → 0.45. Pre-R407
6264
+ offline node halos faded to α=0.25 cyber (75 %
6265
+ dim) / α=0.4 light. On the dark canvas the 0.25
6266
+ halo read as "nearly gone" — exactly the
6267
+ legibility floor R404/R405 just lifted on the
6268
+ hub-halo and R372 lifted on minimap offline dots.
6269
+ R407 closes the same family at the per-node halo
6270
+ surface: +0.05 lift on both themes so offline
6271
+ anchors stay legibly present without crossing into
6272
+ "could be online" territory (online cyber 0.65 /
6273
+ light 0.85 unchanged — the 0.30/0.65 cyber ratio
6274
+ still gives 2.17× contrast for online/offline).
6275
+ Stale-state legibility lift family (7 anchors now):
6276
+ R317 subordinate-text gray-500 → gray-400
6277
+ R358 freshness floor 0.25 → 0.30
6278
+ R372 minimap offline-dot 0.5 → 0.6
6279
+ R404 hub-halo cyber trough 0.08 → 0.10
6280
+ R405 hub-halo light trough 0.32 → 0.34
6281
+ R406 edge freshness floor 0.35 → 0.40
6282
+ R407 node halo offline opacity (this round)
6283
+ cyber 0.25 → 0.30
6284
+ light 0.4 → 0.45
6285
+ R278 retired-breath gate + R12 status.halo color
6286
+ + R226 phase stagger code-path preserved (the
6287
+ breath stays disabled per Vincent's R278 ask;
6288
+ only the BASE opacity floor shifts here). transi-
6289
+ tion list ('fill,opacity' 300ms ease-out) unchanged.
6290
+ data-node-halo-offline-opacity attr exposes the
6291
+ resolved value for tests. */}
5438
6292
  <circle
5439
6293
  cx={pos.x}
5440
6294
  cy={pos.y}
5441
6295
  r={radius + 8}
5442
6296
  fill={status.halo}
5443
- opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.4 : 0.25)}
6297
+ opacity={isOnline ? (isLight ? 0.85 : 0.65) : (isLight ? 0.45 : 0.30)}
6298
+ data-node-halo-offline-opacity={isOnline ? undefined : (isLight ? 0.45 : 0.30)}
5444
6299
  className="transition-[fill,opacity] duration-300 ease-out"
5445
6300
  data-node-halo-breath={!reducedMotion && session.status === 'working' ? 'on' : 'off'}
5446
6301
  data-node-halo-breath-offset={
@@ -6071,26 +6926,122 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6071
6926
  const detailY = pos.y - detailH / 2;
6072
6927
  return (
6073
6928
  <g transform={`translate(${detailX}, ${detailY})`} data-topo-hover-detail={session.alias} style={{ pointerEvents: 'none' }}>
6929
+ {/* Round 387 / Loop: hover-detail panel cyber backdrop
6930
+ opacity 0.94 → 0.97. The hover-detail card is
6931
+ ALWAYS rendered in active-hover context (it IS
6932
+ the hover product), so it should carry the
6933
+ same backdrop weight as the R348 recent-signal /
6934
+ legend panel HOVER state (which lifts 0.92 →
6935
+ 0.97 cyber). Pre-R387 the card sat at 0.94
6936
+ cyber, leaving a 0.03 alpha gap against the
6937
+ R348 panel-hover state — small but visible
6938
+ when the hover-detail floats next to a hovered
6939
+ recent-signal panel. R387 unifies them at 0.97
6940
+ so all active-hover panels paint with the same
6941
+ confident backdrop opacity in cyber. Light
6942
+ stays at 0.98 (already at the strong end —
6943
+ R348 light also stays at 0.97/0.98 max).
6944
+ Theme-consistency / canvas-presence polish
6945
+ family (5th anchor):
6946
+ R370 hub hover-ring opacity 0.7 → 0.8 cyber
6947
+ R371 edge-badge rest opacity 0.82 → 0.85 cyber
6948
+ R372 minimap offline-dot opacity 0.5 → 0.6
6949
+ R386 hub-highlight idle opacity 0.9 → 0.95
6950
+ R387 hover-detail panel opacity 0.94 → 0.97 cyber (this round)
6951
+ data-topo-hover-detail-opacity attr exposes
6952
+ the resolved value for tests. R348 drop-shadow
6953
+ + rx=8 + stroke=pal.legendAccent + fill=pal.
6954
+ labelBox.fill all preserved. */}
6955
+ {/* Round 390 / Loop: hover-detail card rx 8 → 10.
6956
+ Corner-radius cascade family — the hover-detail
6957
+ card is a panel-tier surface (192×88 floating
6958
+ info card with drop-shadow + stroke), so its
6959
+ corner radius should match the R331 panel tier
6960
+ (rx=10) used by the recent-signal and legend
6961
+ panels. Pre-R390 it shared rx=8 with the R332
6962
+ minimap and R375/R376 segmented-control tier
6963
+ (Layout-toggle, nodeSize, zoom wrappers),
6964
+ which is the "compact chrome control" tier —
6965
+ a tier mismatch for a content-bearing panel.
6966
+ Corner-radius cascade (6 anchors now):
6967
+ R330 canvas rx 12 (root)
6968
+ R331 panels rx 10 (recent-signal, legend)
6969
+ R332 minimap rx 8 (compact chrome)
6970
+ R375 Layout-toggle rx 8 (segmented control)
6971
+ R376 nodeSize/zoom rx 8 (segmented control)
6972
+ R390 hover-detail rx 10 (panel — this round)
6973
+ Pure paint change; no layout shift (rx grows
6974
+ the corner curve INWARD without changing the
6975
+ card's outer bbox). data-topo-hover-detail-
6976
+ rx attr exposes the resolved value for tests.
6977
+ R348 drop-shadow + stroke + R387 opacity all
6978
+ preserved. */}
6074
6979
  <rect
6075
- x="0" y="0" width={detailW} height={detailH} rx="8"
6980
+ x="0" y="0" width={detailW} height={detailH} rx="10"
6076
6981
  fill={pal.labelBox.fill}
6077
6982
  stroke={pal.legendAccent}
6078
- opacity={isLight ? 0.98 : 0.94}
6983
+ opacity={isLight ? 0.98 : 0.97}
6984
+ data-topo-hover-detail-opacity={isLight ? 0.98 : 0.97}
6985
+ data-topo-hover-detail-rx="10"
6079
6986
  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))' }}
6080
6987
  />
6081
6988
  <text x="10" y="16" fontSize="9" fontFamily="monospace" fill={pal.legendAccent} fontWeight="700">
6082
6989
  {v.id !== 'unknown' ? v.label : '—'}
6083
6990
  </text>
6084
- <text x="10" y="32" fontSize="10" fontFamily="monospace" fill={pal.legendHeadline}>
6991
+ {/* Round 389 / Loop: hover-detail model line (y=32)
6992
+ fontWeight 400 → 600. R388 lifted body lines
6993
+ (runtime/host/task at fontSize=9) to fw=500;
6994
+ R389 closes the typography hierarchy by giving
6995
+ the model name (the dominant subhead text in
6996
+ the card) its own weight tier. Three-tier
6997
+ ladder now reads cleanly:
6998
+ vendor fontSize=9 fw=700 (label badge)
6999
+ model fontSize=10 fw=600 (subhead — this round)
7000
+ body 3× fontSize=9 fw=500 (R388)
7001
+ One tier step per dimension (size + weight)
7002
+ between adjacent levels — classic editorial
7003
+ hierarchy idiom adapted to a 192×88 SVG card.
7004
+ Sibling to the chip-internal-hierarchy arc
7005
+ (R333-R341/R362/R369) which uses fw=600/500
7006
+ for digit/unit pairs; R389 applies the same
7007
+ fw=600 to a content-bearing identity line.
7008
+ data-topo-hover-detail-model-fw attr exposes
7009
+ the resolved value for tests. pal.legendHeadline
7010
+ fill preserved (R389 doesn't touch color). */}
7011
+ <text x="10" y="32" fontSize="10" fontFamily="monospace" fontWeight="600" fill={pal.legendHeadline} data-topo-hover-detail-model-fw="600">
6085
7012
  {session.model || 'model · pending'}
6086
7013
  </text>
6087
- <text x="10" y="48" fontSize="9" fontFamily="monospace" fill={pal.legendText}>
7014
+ {/* Round 388 / Loop: hover-detail body lines (the
7015
+ three fontSize=9 lines: runtime, host, task)
7016
+ gain fontWeight=500. Small-text fw lift family
7017
+ (6th anchor) — fontSize 9-10 px text reads
7018
+ consistently bolder at fw=500 than at the
7019
+ default 400 weight at small sizes, especially
7020
+ on the cyber-theme backdrop where stroke-
7021
+ rendering is the limiting factor.
7022
+ Sibling lifts in this family:
7023
+ R363 recent-row alias text 400 → 500
7024
+ R364 legend-row label 400 → 500
7025
+ R366 group-label count tspan 400 → 500
7026
+ R368 +N more flows footer 400 → 500
7027
+ R373 pressure-bar kicker (font-medium)
7028
+ R388 hover-detail body lines 400 → 500 (this round)
7029
+ Tier structure preserved:
7030
+ y=16 vendor (fw=700, headline)
7031
+ y=32 model (fontSize=10, subhead by size)
7032
+ y=48 runtime / y=64 host / y=80 task (body, now fw=500)
7033
+ The y=80 task line keeps opacity=0.7 so its
7034
+ caption-tier identity stays distinct from the
7035
+ y=48 / y=64 body lines despite shared fw.
7036
+ data-topo-hover-detail-body-fw attr exposes
7037
+ the resolved value for tests. */}
7038
+ <text x="10" y="48" fontSize="9" fontFamily="monospace" fontWeight="500" fill={pal.legendText} data-topo-hover-detail-body-fw="500">
6088
7039
  {rt ? rt.label : 'runtime · pending'}
6089
7040
  </text>
6090
- <text x="10" y="64" fontSize="9" fontFamily="monospace" fill={pal.legendText}>
7041
+ <text x="10" y="64" fontSize="9" fontFamily="monospace" fontWeight="500" fill={pal.legendText} data-topo-hover-detail-body-fw="500">
6091
7042
  host · {session.server || 'unknown'}
6092
7043
  </text>
6093
- <text x="10" y="80" fontSize="9" fontFamily="monospace" fill={pal.legendText} opacity="0.7">
7044
+ <text x="10" y="80" fontSize="9" fontFamily="monospace" fontWeight="500" fill={pal.legendText} opacity="0.7" data-topo-hover-detail-body-fw="500">
6094
7045
  {session.task ? truncate(session.task, 28) : 'no recent task'}
6095
7046
  </text>
6096
7047
  </g>
@@ -6154,14 +7105,41 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6154
7105
  keySplines="0.25 0.1 0.25 1"
6155
7106
  fill="freeze"
6156
7107
  />
7108
+ {/* Round 403 / Loop: click-ripple SMIL initial opacity
7109
+ 0.7 → 0.8. Pre-R403 the ripple's opacity animation
7110
+ faded from 0.7 to 0 over 500ms, providing a clean
7111
+ click-feedback pulse. Theme-consistency / canvas-
7112
+ presence polish family (R370 hub hover-ring +
7113
+ R391 hub-spoke active) already lifted paired
7114
+ hover-state alphas from 0.7 → 0.8. R403 brings
7115
+ click-feedback into that same alpha — three canvas
7116
+ state-feedback indicators (hover-ring, active spoke,
7117
+ click ripple) now share a uniform 0.8 start alpha
7118
+ so the visual "I responded" signal carries the
7119
+ same weight regardless of which state fired it.
7120
+ Pre-R403 invariants preserved: 500ms duration,
7121
+ R227 calcMode='spline' + ease-out keySplines
7122
+ (0.25 0.1 0.25 1), fill='freeze', concurrent r
7123
+ animation. Theme-consistency family (8 anchors):
7124
+ R370 hub hover-ring 0.7 → 0.8
7125
+ R371 edge-badge rest 0.82 → 0.85 cyber
7126
+ R372 minimap offline-dot 0.5 → 0.6
7127
+ R386 hub-highlight idle 0.9 → 0.95
7128
+ R387 hover-detail panel 0.94 → 0.97 cyber
7129
+ R391 hub-spoke active 0.7 → 0.8
7130
+ R392 minimap online-dot 0.9 → 0.95
7131
+ R403 click-ripple start 0.7 → 0.8 (this round)
7132
+ data-click-ripple-start-opacity attr exposes the
7133
+ resolved value for tests. */}
6157
7134
  <animate
6158
7135
  attributeName="opacity"
6159
- values="0.7;0"
7136
+ values="0.8;0"
6160
7137
  dur="0.5s"
6161
7138
  calcMode="spline"
6162
7139
  keyTimes="0;1"
6163
7140
  keySplines="0.25 0.1 0.25 1"
6164
7141
  fill="freeze"
7142
+ data-click-ripple-start-opacity="0.8"
6165
7143
  />
6166
7144
  </circle>
6167
7145
  )}
@@ -6236,7 +7214,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6236
7214
  x="0" y="0" width="230" height="88" rx="10"
6237
7215
  fill={pal.legendBox.fill}
6238
7216
  stroke={pal.legendBox.stroke}
6239
- opacity={isLight ? 0.97 : 0.92}
7217
+ // Round 348 / Loop: recent-signal panel rect opacity hover-
7218
+ // state bump — joins the panel-hover cue stack (R135 drop-
7219
+ // shadow boost + R345 title letter-spacing tween 0.3 → 0.4
7220
+ // + R266 fill theme-flip). Cyber 0.92 → 0.97, light 0.97 →
7221
+ // 1.0 on hoveredPanel === 'recent'. The panel "solidifies"
7222
+ // on hover — pure paint-level change, geometry-safe (bbox
7223
+ // unchanged so topo-overlap-test invariants hold). The
7224
+ // R247 transition list already includes `opacity 200ms
7225
+ // ease-out` so the value tween is automatic. Sibling
7226
+ // change at legend panel rect below (~line 7222).
7227
+ opacity={hoveredPanel === 'recent' ? (isLight ? 1 : 0.97) : (isLight ? 0.97 : 0.92)}
6240
7228
  style={{
6241
7229
  /* R135: drop-shadow intensifies on panel hover. Base
6242
7230
  shadow (2px / 6px blur) signals card elevation
@@ -6359,8 +7347,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6359
7347
  const alpha = ageSec <= 30
6360
7348
  ? 1
6361
7349
  : ageSec <= 300
6362
- ? 1 - ((ageSec - 30) / 270) * 0.75
6363
- : 0.25;
7350
+ ? 1 - ((ageSec - 30) / 270) * 0.70 /* R358: floor 0.25 → 0.30 lift across 3 freshness scopes */
7351
+ : 0.30; /* R358: stale floor lifted 0.25 → 0.30 — 20% legibility bump while preserving fresh/stale ratio */
6364
7352
  // Dark cyan-400 / light teal-600 with alpha — same
6365
7353
  // palette as R161's chip bullet so the two scopes
6366
7354
  // visually align even side-by-side.
@@ -6373,6 +7361,21 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6373
7361
  textAnchor="end"
6374
7362
  fontSize="10"
6375
7363
  fontFamily="monospace"
7364
+ // Round 349 / Loop: editorial letter-spacing 0.2 on the
7365
+ // recent-signal panel header count. Sits one tier below
7366
+ // the R301 panel title letterSpacing="0.3" so the panel
7367
+ // header reads as a 2-step hierarchy (title 0.3 / count
7368
+ // 0.2). Sibling change on the legend panel count below
7369
+ // closes the panel-pair editorial symmetry. Joins the
7370
+ // R285 / R289 / R301 / R302 / R304 / R325 editorial-
7371
+ // letterspacing tier at the panel-summary scope. The
7372
+ // R162 freshness fill, R225 tabular-nums, R311 fw=600,
7373
+ // R336 unit-tspan opacity-0.7 split all preserved —
7374
+ // the tier propagates to all descendant tspans via
7375
+ // SVG inheritance. data-recent-panel-count-letter-
7376
+ // spacing exposes the value for tests.
7377
+ letterSpacing="0.2"
7378
+ data-recent-panel-count-letter-spacing="0.2"
6376
7379
  >
6377
7380
  {/* Round 225 / Loop: tabular-nums on the panel-header
6378
7381
  flow-count tspan. The "{N} flows" string lives in
@@ -6841,17 +7844,58 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6841
7844
  const alpha = ageSec <= 30
6842
7845
  ? 1
6843
7846
  : ageSec <= 300
6844
- ? 1 - ((ageSec - 30) / 270) * 0.75
6845
- : 0.25;
7847
+ ? 1 - ((ageSec - 30) / 270) * 0.70 /* R358: floor 0.25 → 0.30 lift across 3 freshness scopes */
7848
+ : 0.30; /* R358: stale floor lifted 0.25 → 0.30 — 20% legibility bump while preserving fresh/stale ratio */
6846
7849
  return (
6847
7850
  <circle
6848
7851
  cx={10}
6849
7852
  cy={38 + index * 16 - 3}
6850
- r={1.6}
7853
+ /* Round 359 / Loop: recency pip base radius
7854
+ 1.6 → 1.8. Sibling lift to R358's freshness-
7855
+ floor bump (alpha 0.25 → 0.30) — pre-R358/
7856
+ R359 the stale pip painted at r=1.6 + α=0.25
7857
+ which read as near-invisible chrome. R358
7858
+ gave it more alpha; R359 gives it more area
7859
+ (1.8² / 1.6² ≈ 1.27, so ~27 % more glyph)
7860
+ so the pip stays distinguishable across the
7861
+ freshness ramp. Geometry: 1.8-radius dot
7862
+ centred at (10, row_y - 3) is bbox 3.6×3.6,
7863
+ still well inside the 7-px left margin
7864
+ (x=6 rect-start → x=13 text-start) the R160
7865
+ pip was placed in. Overlap-test reads the
7866
+ parent row rect's bbox, not this pip's, so
7867
+ grid+ring invariants hold. Matches the same
7868
+ 1.6 → 1.8 visual-weight bump R295 applied
7869
+ to the legend swatch (5.5 → 6 base radius)
7870
+ and R287 to the minimap viewport stroke
7871
+ (1 → 1.5). data-recent-row-freshness-radius
7872
+ attr exposes the value for tests. */
7873
+ /* Round 383 / Loop: recency pip base radius
7874
+ 1.8 → 2.0. Continues the R359 lift
7875
+ trajectory — pip area grows ~23 % (π·2²/
7876
+ π·1.8² ≈ 1.23) for a clearer at-a-glance
7877
+ freshness anchor in each row. Bbox 4.0×4.0
7878
+ still inside the 7-px R160 left margin
7879
+ (3-px remaining clearance vs 3.4 at r=1.8
7880
+ — geometry-safe margin holds). Sibling
7881
+ visual-weight bump family (9th anchor now):
7882
+ R287 minimap viewport stroke 1 → 1.5
7883
+ R295 legend swatch base radius 5.5 → 6
7884
+ R359 recent-row pip base radius 1.6 → 1.8
7885
+ R360 hub digit fontSize 11 → 12
7886
+ R361 edge-badge digit fontSize 10 → 11
7887
+ R365 hub-highlight base radius 5 → 5.5
7888
+ R367 edge-badge rest stroke 1 → 1.25
7889
+ R374 pressure-bar height 1.5 → 2
7890
+ R383 recent-row pip radius 1.8 → 2.0 (this round)
7891
+ data-recent-row-freshness-radius attr
7892
+ bumps to '2.0' for tests. */
7893
+ r={2.0}
6851
7894
  fill={pal.legendAccent}
6852
7895
  opacity={alpha}
6853
7896
  data-recent-row-freshness={link.key}
6854
7897
  data-recent-row-freshness-alpha={alpha.toFixed(2)}
7898
+ data-recent-row-freshness-radius="2.0"
6855
7899
  style={{ pointerEvents: 'none', transition: 'opacity 200ms ease-out' }}
6856
7900
  />
6857
7901
  );
@@ -6881,8 +7925,28 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6881
7925
  fill={isRowActive ? pal.legendHeadline : pal.legendText}
6882
7926
  fontSize="9"
6883
7927
  fontFamily="monospace"
7928
+ /* Round 363 / Loop: recent-row text fontWeight 400
7929
+ → 500 (font-medium tier). At fontSize=9 the
7930
+ default-weight 400 glyphs read thin against the
7931
+ panel chrome (pal.legendBox.fill with 0.92/0.97
7932
+ opacity); the 100-weight bump lifts the alias→
7933
+ alias text into the legibility band without
7934
+ changing geometry. The R320 count tspan fw=600
7935
+ (cold) / fw=700 (hot) override still wins
7936
+ locally via inline fontWeight on the inner
7937
+ tspan, so the count-vs-alias hierarchy stays
7938
+ intact:
7939
+ alias fw 500 (R363, this round)
7940
+ count fw 600/700 (R320)
7941
+ Sibling typography lift to R362 chip-row digit
7942
+ 500 → 600 — both nudge a within-element data
7943
+ tier without disturbing the surrounding family
7944
+ baseline. data-recent-row-text-font-weight attr
7945
+ exposes the value for tests. */
7946
+ fontWeight="500"
6884
7947
  data-recent-row-text={link.key}
6885
7948
  data-recent-row-text-pinned={isRowPinned ? 'true' : 'false'}
7949
+ data-recent-row-text-font-weight="500"
6886
7950
  style={{
6887
7951
  transition: 'fill 150ms ease-out, letter-spacing 150ms ease-out',
6888
7952
  letterSpacing: isRowPinned ? '0.5px' : '0px',
@@ -7144,6 +8208,23 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7144
8208
  surface. transition list extends letter-spacing
7145
8209
  200ms ease-out alongside the existing opacity/
7146
8210
  fill easings. */}
8211
+ {/* Round 368 / Loop: `+N more flows` footer text gains
8212
+ fontWeight=500 (font-medium tier). Sibling small-
8213
+ text fw lift family with R363 recent-row alias
8214
+ + R364 legend-row label + R366 group-label count
8215
+ — all four lifts share the same theory: at small
8216
+ fontSize (9-11 px) against panel chrome, SVG-
8217
+ default fw 400 sits at the legibility floor;
8218
+ fw 500 brings the glyph into the deliberate-data
8219
+ band. fontStyle=italic + opacity 0.55 rest + R325
8220
+ letterSpacing 0.2 baseline + R344 hover-spread
8221
+ 0.2 → 0.3 + R195 cyan fill on hover all preserved
8222
+ — the fw bump just thickens the italic stroke.
8223
+ Hover-state punch (R195 fill + R325 opacity 0.55
8224
+ → 0.85 + R344 letter-spacing + R133 underline)
8225
+ stays as is, so the rest-vs-hover delta still
8226
+ reads clearly. data-recent-panel-more-font-weight
8227
+ attr exposes the value for tests. */}
7147
8228
  <text
7148
8229
  x="115" y="82"
7149
8230
  textAnchor="middle"
@@ -7151,11 +8232,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7151
8232
  fontSize="9"
7152
8233
  fontFamily="monospace"
7153
8234
  fontStyle="italic"
8235
+ fontWeight="500"
7154
8236
  letterSpacing={hoveredRecentMore ? '0.3' : '0.2'}
7155
8237
  opacity={hoveredRecentMore ? 0.85 : 0.55}
7156
8238
  textDecoration={hoveredRecentMore ? 'underline' : 'none'}
7157
8239
  data-recent-panel-more={moreCount}
7158
8240
  data-recent-panel-more-hovered={hoveredRecentMore ? 'true' : 'false'}
8241
+ data-recent-panel-more-font-weight="500"
7159
8242
  style={{ transition: 'opacity 150ms ease-out, fill 200ms ease-out, letter-spacing 200ms ease-out' }}
7160
8243
  >
7161
8244
  {`+ ${moreCount}`}
@@ -7208,7 +8291,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7208
8291
  x="0" y="0" width="224" height="88" rx="10"
7209
8292
  fill={pal.legendBox.fill}
7210
8293
  stroke={pal.legendBox.stroke}
7211
- opacity={isLight ? 0.97 : 0.92}
8294
+ // R348 sibling legend panel rect opacity hover-state
8295
+ // bump 0.92 → 0.97 (cyber) / 0.97 → 1 (light) on
8296
+ // hoveredPanel === 'legend'. Pairs with the recent-signal
8297
+ // panel rect above so the two corner panels' hover cues
8298
+ // stay symmetric. Geometry-safe (paint-only).
8299
+ opacity={hoveredPanel === 'legend' ? (isLight ? 1 : 0.97) : (isLight ? 0.97 : 0.92)}
7212
8300
  style={{
7213
8301
  filter: hoveredPanel === 'legend'
7214
8302
  ? (isLight ? 'drop-shadow(0 4px 12px rgba(15,23,42,0.14))'
@@ -7297,7 +8385,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7297
8385
  <text
7298
8386
  x="211" y="21" textAnchor="end"
7299
8387
  fill={pal.legendAccent} fontSize="10" fontFamily="monospace" fontWeight="600"
8388
+ // R349 sibling — legend panel header count picks up
8389
+ // letterSpacing="0.2", one tier below the R301 panel
8390
+ // title 0.3. Pairs with the recent-signal panel count
8391
+ // letter-spacing above so the two corner panels' header
8392
+ // typography stays editorially symmetric.
8393
+ letterSpacing="0.2"
7300
8394
  data-legend-panel-count
8395
+ data-legend-panel-count-letter-spacing="0.2"
7301
8396
  style={{
7302
8397
  transition: 'fill 200ms ease-out',
7303
8398
  fontVariantNumeric: 'tabular-nums',
@@ -7518,14 +8613,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7518
8613
  so this legend-internal circle is invisible to
7519
8614
  that probe. pointerEvents:none so the ring can't
7520
8615
  intercept the row click that produced it. */}
8616
+ {/* Round 402 / Loop: legend pin-ring strokeWidth 1.5
8617
+ → 1.75. Sibling visual-weight bump (12th anchor)
8618
+ to R385 hub hover-ring strokeWidth 1.5 → 1.75 —
8619
+ both are pin/hover state indicators painted as
8620
+ stroke-only circles outside their target swatch
8621
+ with the R51 sentinel value 1.5. R402 lifts to
8622
+ 1.75 (matching R385's choice) so the pin signal
8623
+ reads slightly heavier without crossing the
8624
+ R51 sentinel band (3 reserved for offline node).
8625
+ The R51 selector is gated to g[data-node]
8626
+ ancestors so this legend-internal circle (lives
8627
+ under a <g data-legend-status>) is invisible
8628
+ to the probe — same lesson R177/R385 documented.
8629
+ Visual-weight bump family (12 anchors now):
8630
+ R287 minimap viewport stroke 1 → 1.5
8631
+ R295 legend swatch radius 5.5 → 6
8632
+ R359 recent-row pip radius 1.6 → 1.8
8633
+ R360 hub digit fontSize 11 → 12
8634
+ R361 edge-badge digit fontSize 10 → 11
8635
+ R365 hub-highlight radius 5 → 5.5
8636
+ R367 edge-badge rest stroke 1 → 1.25
8637
+ R374 pressure-bar height 1.5 → 2
8638
+ R383 recent-row pip radius 1.8 → 2.0
8639
+ R384 minimap online dot 1.7 → 1.9
8640
+ R385 hub hover-ring stroke 1.5 → 1.75
8641
+ R402 legend pin-ring stroke 1.5 → 1.75 (this round)
8642
+ R181 always-mount opacity gate + 150ms transition
8643
+ + pointerEvents:none all preserved. data-legend-
8644
+ pin-ring-stroke-width attr exposes the value for
8645
+ tests. */}
7521
8646
  <circle
7522
8647
  cx="16" cy={row.y0} r="8"
7523
8648
  fill="none"
7524
8649
  stroke={row.fill}
7525
- strokeWidth="1.5"
8650
+ strokeWidth="1.75"
7526
8651
  opacity={isPinned ? 1 : 0}
7527
8652
  data-legend-pin-ring={row.key}
7528
8653
  data-legend-pin-ring-pinned={isPinned ? 'true' : 'false'}
8654
+ data-legend-pin-ring-stroke-width="1.75"
7529
8655
  style={{
7530
8656
  pointerEvents: 'none',
7531
8657
  transition: 'opacity 150ms ease-out',
@@ -7552,8 +8678,31 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7552
8678
  fill={hoveredStatus === row.key || isPinned ? pal.legendHeadline : pal.legendText}
7553
8679
  fontSize="11"
7554
8680
  fontFamily="monospace"
8681
+ /* Round 364 / Loop: legend-row label fontWeight 400
8682
+ → 500. Sibling typography lift to R363 recent-row
8683
+ text fw 400 → 500. Both surfaces render small
8684
+ monospace text against panel chrome at fontSize
8685
+ 9-11 where SVG-default fw 400 sits at the
8686
+ legibility floor. font-medium tier (500) gives
8687
+ the label a more deliberate-data register.
8688
+ The R309 per-row count text (separate element
8689
+ below at x=215 textAnchor=end) keeps its own
8690
+ fontWeight 600 inline override, so the count >
8691
+ label hierarchy stays intact at the legend
8692
+ scope same as R363 holds it at the recent-row
8693
+ scope:
8694
+ legend label fw 500 (R364, this round)
8695
+ legend count fw 600 (R309)
8696
+ recent alias fw 500 (R363)
8697
+ recent count fw 600/700 (R320)
8698
+ data-legend-row-label-font-weight attr exposes
8699
+ the value for tests. R219 letter-spacing pin
8700
+ tween + R55 fill transition + R181 always-mount
8701
+ pin ring all preserved. */
8702
+ fontWeight="500"
7555
8703
  data-legend-row-label={row.key}
7556
8704
  data-legend-row-label-pinned={isPinned ? 'true' : 'false'}
8705
+ data-legend-row-label-font-weight="500"
7557
8706
  style={{
7558
8707
  transition: 'fill 150ms ease-out, letter-spacing 150ms ease-out',
7559
8708
  letterSpacing: isPinned ? '0.5px' : '0px',
@@ -7872,14 +9021,63 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7872
9021
  const isOn = s.status !== 'offline' || !!sseN;
7873
9022
  const st = nodeStatus(s, isOn, isLight);
7874
9023
  return (
9024
+ /* Round 372 / Loop: minimap offline-dot opacity
9025
+ 0.5 → 0.6. Sibling stale-state legibility lift
9026
+ to R358 freshness ramp floor 0.25 → 0.30 + R317
9027
+ subordinate-text-lift family. Pre-R372 R198
9028
+ drew offline dots at α=0.5 (44 % below online
9029
+ 0.9). The minimap is a small overlay against
9030
+ the canvas backdrop — at α=0.5 offline dots
9031
+ sat at the legibility floor when the minimap
9032
+ mounted (only on non-default view). R372 lifts
9033
+ offline 0.5 → 0.6 for +20 % relative presence;
9034
+ online stays at 0.9 so the offline/online
9035
+ contrast ratio is now 0.6/0.9 ≈ 0.67 (vs prior
9036
+ 0.5/0.9 ≈ 0.56) — still a clear two-tier
9037
+ distinction. R198 opacity + fill + r transition
9038
+ list preserved so status flips still ease
9039
+ smoothly. data-topo-minimap-dot-opacity attr
9040
+ exposes the resolved value for tests. */
7875
9041
  <circle
7876
9042
  key={s.alias}
7877
9043
  cx={p.x * sx} cy={p.y * sy}
7878
- r={isOn ? 1.7 : 1.2}
9044
+ /* Round 384 / Loop: minimap online dot radius 1.7
9045
+ → 1.9. Sibling visual-weight bump (10th anchor)
9046
+ to R383 recent-row pip 1.8 → 2.0. R198 designed
9047
+ the dots at 1.7 (online) / 1.2 (offline) — at
9048
+ the minimap's 120 × 82 scale these read clearly
9049
+ but the online ↔ offline contrast was modest
9050
+ (1.7/1.2 = 1.42×). R384 bumps online to 1.9 so
9051
+ the tier delta widens to 1.58× (1.9/1.2). Pair
9052
+ completes minimap-dot legibility polish:
9053
+ R358 (era R372) offline opacity 0.5 → 0.6
9054
+ R384 online radius 1.7 → 1.9 (this round)
9055
+ R198 transition list (opacity + fill + r 200ms)
9056
+ preserved so status flips still ease smoothly.
9057
+ data-topo-minimap-dot-radius attr exposes the
9058
+ resolved value for tests. */
9059
+ /* Round 392 / Loop: minimap online dot opacity
9060
+ 0.9 → 0.95. Theme-consistency / canvas-presence
9061
+ polish family (7th anchor) — mirrors R386's
9062
+ hub-highlight idle 0.9 → 0.95 lift on the
9063
+ minimap surface: the online-dot's idle alpha
9064
+ gap (0.10 against full presence) halves to
9065
+ 0.05, so the live-fleet anchors on the minimap
9066
+ read more confidently. Offline dot stays at
9067
+ R372 0.6 — the binary online/offline contrast
9068
+ ratio shifts from 0.6/0.9 ≈ 0.67 to 0.6/0.95
9069
+ ≈ 0.63, preserved as a clear two-tier
9070
+ distinction. R198 opacity + fill + r transition
9071
+ list + R384 r=1.9 + R372 offline 0.6 all
9072
+ preserved. data-topo-minimap-dot-opacity attr
9073
+ bumps to '0.95' for tests. */
9074
+ r={isOn ? 1.9 : 1.2}
7879
9075
  fill={st.primary}
7880
- opacity={isOn ? 0.9 : 0.5}
9076
+ opacity={isOn ? 0.95 : 0.6}
7881
9077
  data-topo-minimap-dot={s.alias}
7882
9078
  data-topo-minimap-dot-online={isOn ? 'true' : 'false'}
9079
+ data-topo-minimap-dot-opacity={isOn ? 0.95 : 0.6}
9080
+ data-topo-minimap-dot-radius={isOn ? 1.9 : 1.2}
7883
9081
  style={{
7884
9082
  transition: 'opacity 200ms ease-out, fill 200ms ease-out, r 200ms ease-out',
7885
9083
  } as React.CSSProperties}
@@ -7920,17 +9118,66 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7920
9118
  information element to lift it above ambient
7921
9119
  chrome. opacity 0.9 stays — strokeWidth alone
7922
9120
  does the lifting. */}
9121
+ {/* Round 379 / Loop: minimap viewport rect picks up
9122
+ strokeLinejoin='round'. Pre-R379 the rect's 4
9123
+ corners painted with default 'miter' joins —
9124
+ sharp 90° corners with a small miter overshoot
9125
+ (≈ strokeWidth × 1.4 = 2.1 px at sw=1.5). R379
9126
+ rounds the joins so corners arc smoothly through
9127
+ a quarter-circle of radius ≈ strokeWidth/2. At
9128
+ sw=1.5 that's a 0.75-px radius — subtle but
9129
+ matches the same stroke-softening vocabulary R288
9130
+ chrome icons (zoom/reset/fullscreen) and R378
9131
+ flow-rail already speak. Geometry-safe: stroke-
9132
+ linejoin only affects the corner overshoot, the
9133
+ rect's bbox is unchanged. R287 strokeWidth=1.5 +
9134
+ R346 hover-state strokeWidth/opacity bump + R199
9135
+ smoothView x/y/w/h transition all preserved.
9136
+ data-topo-minimap-viewport-linejoin attr exposes
9137
+ the value for tests. */}
9138
+ {/* Round 393 / Loop: minimap viewport rect rx 0 → 2.
9139
+ Pre-R393 the cyan-stroked viewport rect (the frame
9140
+ showing what's currently visible on the canvas)
9141
+ drew with sharp corners inside the R332 rounded
9142
+ minimap container (rx=8). A small frame with sharp
9143
+ corners sitting inside a rounded container reads
9144
+ as visually loud — the 90° corners catch the eye
9145
+ against the soft container edge. R393 adds rx=2
9146
+ so the viewport corners get a subtle radius that
9147
+ matches the family's softening idiom on a sub-
9148
+ element scale. The R379 strokeLinejoin='round'
9149
+ already softens stroke joins; R393 adds a complete
9150
+ geometric soften via rx.
9151
+ Corner-radius cascade (7 anchors now):
9152
+ R330 canvas rx 12
9153
+ R331 panels rx 10
9154
+ R332 minimap container rx 8
9155
+ R375 Layout-toggle rx 8
9156
+ R376 nodeSize/zoom rx 8
9157
+ R390 hover-detail rx 10
9158
+ R393 minimap viewport rx 2 (this round, sub-element)
9159
+ The 2-px radius is intentionally small — the
9160
+ viewport rect is typically only 30-50px wide,
9161
+ where rx=2 reads as "rounded enough to not snap"
9162
+ without feeling pillowy. data-topo-minimap-
9163
+ viewport-rx attr exposes the resolved value
9164
+ for tests. R346 hover-state tweens (strokeWidth
9165
+ + opacity) preserved verbatim. */}
7923
9166
  <rect
7924
9167
  x={Math.max(0, rectX)} y={Math.max(0, rectY)}
7925
9168
  width={Math.max(0, Math.min(MW - Math.max(0, rectX), rectW))}
7926
9169
  height={Math.max(0, Math.min(MH - Math.max(0, rectY), rectH))}
9170
+ rx="2"
7927
9171
  fill="none" stroke={pal.legendAccent}
7928
9172
  // R346: strokeWidth + opacity tween on container hover.
7929
9173
  strokeWidth={hoveredMinimap ? '1.75' : '1.5'}
9174
+ strokeLinejoin="round"
7930
9175
  opacity={hoveredMinimap ? '1' : '0.9'}
7931
9176
  data-topo-minimap-viewport
9177
+ data-topo-minimap-viewport-rx="2"
7932
9178
  data-topo-minimap-viewport-smooth={smoothView ? 'true' : 'false'}
7933
9179
  data-topo-minimap-viewport-hover={hoveredMinimap ? 'true' : 'false'}
9180
+ data-topo-minimap-viewport-linejoin="round"
7934
9181
  style={{
7935
9182
  transition: smoothView
7936
9183
  ? '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'
@@ -7989,8 +9236,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7989
9236
  own transition-colors. Same R254 holdover pattern that
7990
9237
  R263 just closed at the canvas wrapper scope, now at the
7991
9238
  chrome strip's nodeSize sub-wrapper scope. */}
9239
+ {/* Round 376 / Loop: nodeSize wrapper rounded-md → rounded-lg.
9240
+ Sibling polish to R375 Layout-toggle wrapper. Three
9241
+ chrome-strip segmented controls now all share rounded-lg
9242
+ at the wrapper tier:
9243
+ R375 Layout-toggle wrapper rounded-lg 8 px
9244
+ R376 nodeSize wrapper rounded-lg 8 px (this round)
9245
+ R376 zoom wrapper rounded-lg 8 px (this round)
9246
+ Individual atomic chrome buttons (reset, fullscreen) keep
9247
+ rounded-md (6 px) as their own atomic-button tier — the
9248
+ chrome strip's typography now expresses a clear two-tier
9249
+ hierarchy: 'segmented control container' (rounded-lg)
9250
+ vs 'standalone button' (rounded-md). Pure paint change,
9251
+ no layout shift. */}
7992
9252
  <div
7993
- className="flex items-center rounded-md border overflow-hidden"
9253
+ className="flex items-center rounded-lg border overflow-hidden"
9254
+ data-topo-chrome-nodesize-radius="rounded-lg"
7994
9255
  style={{
7995
9256
  background: pal.legendBox.fill,
7996
9257
  borderColor: pal.containerBorder,
@@ -8070,8 +9331,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8070
9331
  data-topo-chrome-view-group-leader marks the boundary surface
8071
9332
  for the test probe; data-topo-chrome-fleet-group-trailer marks
8072
9333
  the nodeSize wrapper's right edge for the gap measurement. */}
9334
+ {/* R376 sibling — zoom wrapper rounded-md → rounded-lg.
9335
+ Closes the chrome-strip segmented-control corner radius
9336
+ cascade (Layout R375 + nodeSize R376 + zoom R376). */}
8073
9337
  <div
8074
- className="ml-1.5 flex items-center rounded-md border overflow-hidden"
9338
+ className="ml-1.5 flex items-center rounded-lg border overflow-hidden"
9339
+ data-topo-chrome-zoom-wrapper-radius="rounded-lg"
8075
9340
  style={{
8076
9341
  background: pal.legendBox.fill,
8077
9342
  borderColor: pal.containerBorder,
@@ -8087,7 +9352,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8087
9352
  // R196: press-state deepens bg one tier above hover (white/5
8088
9353
  // → white/10) so mouse-down has a tactile dim before the
8089
9354
  // R186 icon pop fires on release.
8090
- 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"
9355
+ // R352: `group` lets the inner svg respond via group-hover.
9356
+ 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"
8091
9357
  style={{ color: pal.legendText }}
8092
9358
  aria-label="Zoom out"
8093
9359
  title="Zoom out (−)"
@@ -8095,11 +9361,25 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8095
9361
  {/* R186: icon pop on click. CSS animation runs once;
8096
9362
  React removes the class after 240ms so a quick
8097
9363
  re-click can replay. */}
9364
+ {/* Round 352 / Loop: zoom-out icon picks up group-hover:
9365
+ scale-110 — sibling to R350 reset hover-rotate. Pre-
9366
+ R352 hovering the zoom button only changed the bg
9367
+ (white/5); the icon inside stayed perfectly still.
9368
+ R352 lifts the icon 10% on hover for a tactile "this
9369
+ button does something" cue. The R186 anet-chrome-pop
9370
+ keyframe (220ms scale 1→1.06→1) still owns transform
9371
+ during click via CSS-animation precedence over
9372
+ transition-transform; after the pop ends + className
9373
+ is removed, the group-hover scale-110 picks up
9374
+ smoothly. `transform-gpu` hint promotes the svg to
9375
+ its own compositor layer for crisper edges during
9376
+ the scale tween. Sibling change on zoom-in icon
9377
+ below. */}
8098
9378
  <svg
8099
9379
  width="12" height="12" viewBox="0 0 24 24"
8100
9380
  fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
8101
9381
  aria-hidden
8102
- className={chromePopping === 'zoom-out' ? 'anet-chrome-pop' : undefined}
9382
+ className={`transition-transform duration-200 ease-out group-hover:scale-110 transform-gpu${chromePopping === 'zoom-out' ? ' anet-chrome-pop' : ''}`}
8103
9383
  data-topo-chrome-zoom-out-icon
8104
9384
  ><path d="M5 12h14" /></svg>
8105
9385
  </button>
@@ -8145,11 +9425,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8145
9425
  chromePopping === 'zoom-in' || chromePopping === 'zoom-out'
8146
9426
  ? 'true' : 'false'
8147
9427
  }
9428
+ data-topo-chrome-zoom-level-hover={hoveredZoomLevel ? 'true' : 'false'}
9429
+ onMouseEnter={() => setHoveredZoomLevel(true)}
9430
+ onMouseLeave={() => setHoveredZoomLevel(false)}
8148
9431
  style={{
8149
9432
  color: pal.legendText,
8150
9433
  borderColor: pal.containerBorder,
8151
9434
  minWidth: 46,
8152
9435
  display: 'inline-block',
9436
+ // R347: letter-spacing hover tween — extends R344/R345
9437
+ // hover-letter-spacing family into the chrome strip.
9438
+ letterSpacing: hoveredZoomLevel ? '0.5px' : '0',
8153
9439
  /* Round 264 / Loop: zoom level readout gains theme-toggle
8154
9440
  transition. The span has theme-driven color (pal.
8155
9441
  legendText) + border-x (pal.containerBorder via the
@@ -8158,7 +9444,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8158
9444
  on theme flip while siblings eased. Sibling treatment
8159
9445
  to the nodeSize + zoom wrapper transitions added this
8160
9446
  round. */
8161
- transition: 'color 200ms ease-out, border-color 200ms ease-out',
9447
+ transition: 'color 200ms ease-out, border-color 200ms ease-out, letter-spacing 200ms ease-out',
8162
9448
  }}
8163
9449
  title="Current zoom level"
8164
9450
  >
@@ -8169,18 +9455,22 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8169
9455
  data-topo-chrome-zoom-in
8170
9456
  data-topo-chrome-zoom-in-popping={chromePopping === 'zoom-in' ? 'true' : 'false'}
8171
9457
  // R196: press-state (mirror of zoom-out above).
8172
- 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"
9458
+ // R352: `group` lets the inner svg respond via group-hover.
9459
+ 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"
8173
9460
  style={{ color: pal.legendText }}
8174
9461
  aria-label="Zoom in"
8175
9462
  title="Zoom in (+)"
8176
9463
  >
8177
9464
  {/* R186: icon pop on click. Same one-shot CSS animation
8178
9465
  as zoom-out; React removes the class after 240ms. */}
9466
+ {/* R352 sibling — zoom-in icon picks up the same
9467
+ group-hover:scale-110 family. Mirror change at
9468
+ the zoom-out icon above. */}
8179
9469
  <svg
8180
9470
  width="12" height="12" viewBox="0 0 24 24"
8181
9471
  fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
8182
9472
  aria-hidden
8183
- className={chromePopping === 'zoom-in' ? 'anet-chrome-pop' : undefined}
9473
+ className={`transition-transform duration-200 ease-out group-hover:scale-110 transform-gpu${chromePopping === 'zoom-in' ? ' anet-chrome-pop' : ''}`}
8184
9474
  data-topo-chrome-zoom-in-icon
8185
9475
  ><path d="M12 5v14M5 12h14" /></svg>
8186
9476
  </button>
@@ -8189,9 +9479,35 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8189
9479
  onClick={() => { armResetSpin(); resetView(); }}
8190
9480
  data-topo-chrome-reset
8191
9481
  data-topo-chrome-reset-spinning={resetSpinning ? 'true' : 'false'}
9482
+ data-topo-chrome-reset-hover={hoveredReset ? 'true' : 'false'}
9483
+ // R350: hover state drives the icon transform below.
9484
+ onMouseEnter={() => setHoveredReset(true)}
9485
+ onMouseLeave={() => setHoveredReset(false)}
9486
+ onFocus={() => setHoveredReset(true)}
9487
+ onBlur={() => setHoveredReset(false)}
8192
9488
  // R196: press-state deepens before R184 reset-spin fires on
8193
9489
  // release — mouse-down dim then 450ms spin = full handshake.
8194
- 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"
9490
+ /* Round 400 / Loop · milestone: chrome reset + fullscreen
9491
+ buttons gain hover:-translate-y-px lift — closes the
9492
+ hover-lift gesture vocabulary across every standalone
9493
+ interactive HTML element in TopoGraph. Segmented
9494
+ controls (zoom -/+, nodeSize S/M/L, Layout Ring/Grid)
9495
+ intentionally stay planted: lifting one segment of a
9496
+ unified strip would tear the visual unity of the
9497
+ segmented control. Only the standalone chrome buttons
9498
+ (reset, fullscreen) get the lift.
9499
+ Gesture vocabulary post-R400 (now complete across HTML):
9500
+ chip-row chips (3×) -1 px R398, R399
9501
+ filter pin pills (4×) -1 px R397
9502
+ recent-signal row -1 px R143
9503
+ legend row -1 px R144
9504
+ reset button -1 px R400 (this round)
9505
+ fullscreen button -1 px R400 (this round)
9506
+ Every standalone interactive HTML surface in TopoGraph
9507
+ now lifts on hover. data-topo-chrome-reset-hover-lift
9508
+ attr surfaces the lift for tests. */
9509
+ 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"
9510
+ data-topo-chrome-reset-hover-lift="true"
8195
9511
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
8196
9512
  aria-label="Reset view"
8197
9513
  title="Reset zoom + pan (0, or double-click the canvas)"
@@ -8218,6 +9534,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8218
9534
  aria-hidden
8219
9535
  className={resetSpinning ? 'anet-reset-spin' : undefined}
8220
9536
  data-topo-chrome-reset-icon
9537
+ // R350: hover-rotate preview of the R184 click-spin.
9538
+ // Gated on !resetSpinning so the anet-reset-spin keyframe
9539
+ // owns transform during its 450ms run. transformOrigin
9540
+ // 'center' so rotation pivots around the icon's centre
9541
+ // (default would be top-left and the icon would arc).
9542
+ style={{
9543
+ transform: hoveredReset && !resetSpinning ? 'rotate(-8deg)' : 'rotate(0deg)',
9544
+ transformOrigin: 'center',
9545
+ transition: 'transform 200ms ease-out',
9546
+ }}
9547
+ data-topo-chrome-reset-icon-hover={hoveredReset && !resetSpinning ? 'true' : 'false'}
8221
9548
  >
8222
9549
  <path d="M3 12a9 9 0 1 0 9-9 9 9 0 0 0-6.4 2.6L3 8" />
8223
9550
  <path d="M3 3v5h5" />
@@ -8251,11 +9578,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8251
9578
  its inactive state benefits from the same "hover previews
8252
9579
  active state" idiom R163 designed. Sibling treatment to
8253
9580
  the nodeSize buttons at line ~6711. */
8254
- className={`p-1.5 rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 ${
9581
+ // R353: `group` lets the inner svg respond via group-hover
9582
+ // sibling to R352 zoom buttons. Closes the chrome-strip per-
9583
+ // icon hover-affordance arc (zoom-out / zoom-in / reset /
9584
+ // fullscreen now all carry an icon-level hover gesture in
9585
+ // addition to the bg hover).
9586
+ // R400: hover translateY(-1px) lift — see reset button above for family doc.
9587
+ 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 ${
8255
9588
  isFullscreen
8256
9589
  ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25'
8257
9590
  : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'
8258
9591
  }${chromePopping === 'fullscreen' ? ' anet-chrome-pop' : ''}`}
9592
+ data-topo-chrome-fullscreen-hover-lift="true"
8259
9593
  style={{
8260
9594
  borderColor: pal.containerBorder,
8261
9595
  ...(isFullscreen
@@ -8270,12 +9604,20 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8270
9604
  at the reset icon above. data-topo-chrome-fullscreen-
8271
9605
  icon attribute exposes BOTH variants (entered / exited)
8272
9606
  for the round's stroke-width regression probe. */}
9607
+ {/* Round 353 / Loop: fullscreen icon (both enter + exit
9608
+ variants) picks up the R352 family group-hover:scale-110.
9609
+ Pre-R353 hovering the button only changed the bg; the
9610
+ icon stayed still. R353 lifts the icon 10 % on hover —
9611
+ same gesture vocabulary as the zoom buttons. transform-
9612
+ gpu hint promotes the svg to its own compositor layer
9613
+ for crisper edges during the scale tween. Closes the
9614
+ chrome-strip per-icon hover-affordance arc. */}
8273
9615
  {isFullscreen ? (
8274
- <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">
9616
+ <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">
8275
9617
  <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" />
8276
9618
  </svg>
8277
9619
  ) : (
8278
- <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">
9620
+ <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">
8279
9621
  <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" />
8280
9622
  </svg>
8281
9623
  )}