@sleep2agi/agent-network-dashboard 0.5.3-preview.3 → 0.5.3-preview.30

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 (189) 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 +32 -32
  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 +12 -12
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  16. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  17. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  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 +14 -14
  24. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  25. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  26. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  27. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  28. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  29. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  30. package/.next/server/app/index.html +2 -2
  31. package/.next/server/app/index.rsc +14 -14
  32. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  33. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  34. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  35. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  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 +14 -14
  40. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  41. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  42. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  43. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  44. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  45. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  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 +14 -14
  49. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  50. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  51. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  52. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  53. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  54. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  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 +14 -14
  58. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  59. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  60. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  61. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  62. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  63. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  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 +14 -14
  67. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  68. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  69. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  70. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  72. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  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 +14 -14
  76. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  77. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  78. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  79. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  80. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  81. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  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 +14 -14
  86. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  87. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  88. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  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 +4 -4
  91. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  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 +14 -14
  95. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  96. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  97. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  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 +4 -4
  100. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  101. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  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 +14 -14
  106. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  107. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  108. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  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 +4 -4
  111. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  112. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  113. package/.next/server/app/settings.html +2 -2
  114. package/.next/server/app/settings.rsc +14 -14
  115. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  116. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  117. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  118. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  119. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  120. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  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 +14 -14
  125. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  126. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  127. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  128. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  130. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  131. package/.next/server/chunks/ssr/[root-of-the-server]__0sv~g.o._.js +1 -1
  132. package/.next/server/chunks/ssr/[root-of-the-server]__0sv~g.o._.js.map +1 -1
  133. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +3 -3
  134. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  135. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  136. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  137. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  138. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  139. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
  140. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -1
  141. package/.next/server/middleware-build-manifest.js +3 -3
  142. package/.next/server/pages/404.html +2 -2
  143. package/.next/server/pages/500.html +1 -1
  144. package/.next/static/chunks/02.trkmkuwcuu.js +4 -0
  145. package/.next/static/chunks/0d.g4hk1qlsa3.js +1 -0
  146. package/.next/static/chunks/{0kurrvjgmhct8.js → 0g3amqqhiubl2.js} +1 -1
  147. package/.next/static/chunks/0m.1mvl~t.avc.css +2 -0
  148. package/.next/static/chunks/0n5a06bgte2qp.js +1 -0
  149. package/.next/static/chunks/{03a4--7ncekmk.js → 0v4-5tng.uh.7.js} +2 -2
  150. package/.next/trace +2 -2
  151. package/.next/trace-build +1 -1
  152. package/app/components/ServersDrawer.tsx +16 -3
  153. package/app/components/TopoGraph.tsx +652 -40
  154. package/app/globals.css +51 -3
  155. package/package.json +4 -4
  156. package/scripts/p157-servers-copy-test.mjs +95 -0
  157. package/scripts/topo-alias-glow-test.mjs +121 -0
  158. package/scripts/topo-avatar-brightness-test.mjs +116 -0
  159. package/scripts/topo-chip-row-press-test.mjs +93 -0
  160. package/scripts/topo-chrome-press-fullstrip-test.mjs +105 -0
  161. package/scripts/topo-chrome-press-scale-test.mjs +100 -0
  162. package/scripts/topo-cluster-count-attr-test.mjs +80 -0
  163. package/scripts/topo-filter-pills-press-test.mjs +96 -0
  164. package/scripts/topo-fleet-density-tier-test.mjs +84 -0
  165. package/scripts/topo-freshness-chip-fade-test.mjs +105 -0
  166. package/scripts/topo-fullscreen-attr-test.mjs +73 -0
  167. package/scripts/topo-grid-content-bottom-attr-test.mjs +72 -0
  168. package/scripts/topo-hub-highlight-amplify-test.mjs +139 -0
  169. package/scripts/topo-hub-highlight-fill-transition-test.mjs +84 -0
  170. package/scripts/topo-hub-highlight-recede-test.mjs +144 -0
  171. package/scripts/topo-hub-highlight-theme-fill-test.mjs +83 -0
  172. package/scripts/topo-hub-idle-breath-test.mjs +109 -0
  173. package/scripts/topo-hub-recede-test.mjs +124 -0
  174. package/scripts/topo-orphan-box-dash-test.mjs +89 -0
  175. package/scripts/topo-orphan-fill-opacity-test.mjs +91 -0
  176. package/scripts/topo-orphan-label-italic-test.mjs +90 -0
  177. package/scripts/topo-pinned-aspect-test.mjs +89 -0
  178. package/scripts/topo-recent-hot-pulse-test.mjs +102 -0
  179. package/scripts/topo-reduced-motion-attr-test.mjs +69 -0
  180. package/scripts/topo-reset-icon-hover-scale-test.mjs +102 -0
  181. package/scripts/topo-svg-focus-transition-test.mjs +105 -0
  182. package/scripts/topo-vendor-activelinks-press-test.mjs +100 -0
  183. package/.next/static/chunks/0a~3lmgl2.3sm.js +0 -4
  184. package/.next/static/chunks/0c5zf9f-h7gni.js +0 -1
  185. package/.next/static/chunks/0i2qt3ct3dh73.js +0 -1
  186. package/.next/static/chunks/0x.63iu4he92k.css +0 -2
  187. /package/.next/static/{9MPaBwgdo_VS82WDvA3Qb → 3GzOANlRfMMbjx-RdP7Jh}/_buildManifest.js +0 -0
  188. /package/.next/static/{9MPaBwgdo_VS82WDvA3Qb → 3GzOANlRfMMbjx-RdP7Jh}/_clientMiddlewareManifest.js +0 -0
  189. /package/.next/static/{9MPaBwgdo_VS82WDvA3Qb → 3GzOANlRfMMbjx-RdP7Jh}/_ssgManifest.js +0 -0
@@ -240,11 +240,30 @@ function FreshnessChip({ sessions }: { sessions: unknown }) {
240
240
  stale-onset to direct attention. */
241
241
  if (!stale) return null;
242
242
  return (
243
+ /* Round 505 / Loop — FreshnessChip mount animation. Pre-R505 the
244
+ chip popped into the chip-row instantly when SWR data crossed
245
+ the 10s stale threshold; users saw an abrupt amber pill appear
246
+ mid-row. R505 adds the existing `anet-fade-in` class so the
247
+ chip eases through opacity 0→1 over 150ms (R51 globals.css
248
+ keyframe) on first appearance. The chip itself only renders
249
+ when stale (R275 conditional), so the fade plays exactly when
250
+ the stale signal first arrives — perfectly aligned with the
251
+ semantic. Mount-once via React reconciliation (key not used
252
+ since FreshnessChip is a singleton in the parent).
253
+ a11y respected via R29 blanket — `@media (prefers-reduced-
254
+ motion: reduce)` neutralizes anet-fade-in to `animation:none`
255
+ (globals.css line 1083-1089 includes anet-fade-in in the
256
+ blanket list). Reduced-motion users see the chip pop instantly,
257
+ same as pre-R505 behavior — no regression.
258
+ Pure paint-axis addition (opacity animation, no geometry),
259
+ bbox unchanged. data-freshness-chip-mount-fade attr exposes
260
+ the gate for tests. */
243
261
  <span
244
- className={`${baseClass} ${colorClass}`}
262
+ className={`${baseClass} ${colorClass} anet-fade-in`}
245
263
  title={stale ? `Last sync ${sec}s ago — SWR refresh may be lagging` : `Live data · refreshes every 5s · last sync ${sec}s ago`}
246
264
  data-freshness-chip
247
265
  data-freshness-chip-stale={stale ? 'true' : 'false'}
266
+ data-freshness-chip-mount-fade="true"
248
267
  >
249
268
  {/* Round 272 / Loop: swap prefix word to match color state so
250
269
  text and color point the same way. Pre-R272 the chip read
@@ -867,12 +886,19 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
867
886
  // R63 label render + R86 hover-pin keying + #99 tooltip
868
887
  // member listing, so all the existing group-box machinery
869
888
  // applies uniformly to the orphan bucket too.
889
+ // Round 499 / Loop — surface `isOrphan` flag on the box
890
+ // shape so downstream renderers (label text, future polish)
891
+ // can apply orphan-specific typography (italic) without
892
+ // re-deriving the flag from key === '其他' (key matching
893
+ // would also catch a legitimate "其他" prefix-group, this
894
+ // flag is canonical from the band assignment pass).
870
895
  return {
871
896
  key: band.isOrphan
872
897
  ? '其他'
873
898
  : band.members.length
874
899
  ? groupKeys[band.members[0].alias]
875
900
  : '',
901
+ isOrphan: !!band.isOrphan,
876
902
  count: band.members.length,
877
903
  statuses: { working: w, idle: i, offline: o },
878
904
  x: minX - GROUP_PAD,
@@ -977,7 +1003,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
977
1003
  groupKeys,
978
1004
  // #111: group boxes are a grid-layout feature only — radially scattered
979
1005
  // ring nodes can't be cleanly boxed. Ring keeps the #83 prefix hue.
980
- groupBoxes: [] as { key: string; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
1006
+ groupBoxes: [] as { key: string; isOrphan?: boolean; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
981
1007
  // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
982
1008
  gridContentBottom: 0,
983
1009
  };
@@ -1943,8 +1969,24 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1943
1969
  // doesn't list letter-spacing, so without this the
1944
1970
  // hover:tracking-wide would snap. Sibling change on
1945
1971
  // the Grid button below.
1946
- 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' : ''}`}
1947
- style={{ transition: 'background-color 150ms ease, color 150ms ease, letter-spacing 200ms ease-out' }}
1972
+ // Round 492 / Loop add `active:scale-95` press feedback
1973
+ // alongside R196's `active:bg-cyan-500/25` color-deepen.
1974
+ // Pre-R492 the chrome-strip Ring/Grid buttons had color
1975
+ // tactile (deeper cyan on mouse-down) + R249 chrome-pop
1976
+ // on release, but no transform during the press itself —
1977
+ // the button stayed planted between mouse-down and pop.
1978
+ // Adding `active:scale-95` (5% compression) on the
1979
+ // pressed pseudo-state, with `transform 150ms ease-out`
1980
+ // bundled into the inline transition list, gives haptic-
1981
+ // like push-back feedback. The press-down (down to 95%
1982
+ // scale) eases in over 150ms in sync with the bg/color
1983
+ // deepen; the release auto-springs back to scale-100 via
1984
+ // the same transition, then R249's anet-chrome-pop class
1985
+ // overlays the release-pop. Matching `transform-gpu`
1986
+ // promotes the layer so the scale doesn't trigger
1987
+ // layout/paint thrash. Sibling change on Grid below.
1988
+ 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 active:scale-95 transform-gpu ${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' : ''}`}
1989
+ style={{ transition: 'background-color 150ms ease, color 150ms ease, letter-spacing 200ms ease-out, transform 150ms ease-out' }}
1948
1990
  >
1949
1991
  Ring
1950
1992
  </button>
@@ -1963,7 +2005,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1963
2005
  // all chrome buttons.
1964
2006
  // R351 sibling — Grid button picks up hover:tracking-wide
1965
2007
  // + inline transition spec. Same vocabulary as Ring.
1966
- 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' : ''}`}
2008
+ // R492 sibling Grid button picks up active:scale-95
2009
+ // press feedback + transform in transition list. Same
2010
+ // vocabulary as Ring above.
2011
+ 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 active:scale-95 transform-gpu ${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' : ''}`}
1967
2012
  /* Round 268 / Loop: Grid button's left border (the
1968
2013
  internal divider between Ring and Grid) picks up
1969
2014
  pal.containerBorder, matching the wrapper change at
@@ -1973,8 +2018,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1973
2018
  transition list into the inline spec below so the
1974
2019
  letter-spacing tween rides alongside without snapping
1975
2020
  the border-color flip — border-color 200ms ease-out
1976
- keeps R268's theme-toggle smoothness intact. */
1977
- style={{ borderColor: pal.containerBorder, transition: 'background-color 150ms ease, color 150ms ease, border-color 200ms ease-out, letter-spacing 200ms ease-out' }}
2021
+ keeps R268's theme-toggle smoothness intact.
2022
+ R492 adds `transform 150ms ease-out` so active:scale-95
2023
+ eases smoothly. */
2024
+ style={{ borderColor: pal.containerBorder, transition: 'background-color 150ms ease, color 150ms ease, border-color 200ms ease-out, letter-spacing 200ms ease-out, transform 150ms ease-out' }}
1978
2025
  >
1979
2026
  Grid
1980
2027
  </button>
@@ -2091,9 +2138,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2091
2138
  // to R355 filter pin pill inner-span hover-brighten.
2092
2139
  // Hover-brighten family extends from filter pills to
2093
2140
  // chip-row chips at the inner-span scope.
2141
+ // Round 494 / Loop — chip-row working chip joins the
2142
+ // active:scale-95 press-feedback family (R492 Ring/Grid +
2143
+ // R493 chrome-strip rest). Gated on the clickable branch
2144
+ // (workingCount > 0) — when the chip is a placeholder
2145
+ // at count=0, scale-95 stays off to match the existing
2146
+ // R398 hover-lift conditional. Composes with hover:-
2147
+ // translate-y-px for the same lift-and-compress
2148
+ // tactile signature R493 brought to reset/fullscreen.
2094
2149
  className={`group tabular-nums font-medium px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors transition-transform duration-200 ease-out transform-gpu ${
2095
2150
  workingCount > 0
2096
- ? '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'
2151
+ ? '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 active:scale-95'
2097
2152
  : 'bg-green-500/10 text-green-300 border-green-500/20'
2098
2153
  }`}
2099
2154
  data-chip-hover-lift={workingCount > 0 ? 'true' : 'false'}
@@ -2193,9 +2248,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2193
2248
  same digit-jitter physics on count crossings). */
2194
2249
  // R398: hover translate-y lift on clickable variant — see working chip above.
2195
2250
  // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
2251
+ // R494 sibling — online chip joins the active:scale-95 press
2252
+ // family (gated on onlineNodes.length > 0 clickable branch,
2253
+ // same conditional pattern as the working chip above).
2196
2254
  className={`group tabular-nums font-medium px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-colors transition-transform duration-200 ease-out transform-gpu ${
2197
2255
  onlineNodes.length > 0
2198
- ? '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'
2256
+ ? '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 active:scale-95'
2199
2257
  : 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20'
2200
2258
  }`}
2201
2259
  data-chip-hover-lift={onlineNodes.length > 0 ? 'true' : 'false'}
@@ -2470,7 +2528,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2470
2528
  // R355: `group` lets the inner opacity-70 spans (prefix
2471
2529
  // `filter:` + count `· N`) brighten to 100 % on pill hover.
2472
2530
  // Sibling treatment on group + vendor pills below.
2473
- 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"
2531
+ // R495 filter pills (3 sibling `group` variants) join the
2532
+ // active:scale-95 press-feedback family. R490's !important
2533
+ // transition list on .anet-topo-chip-focus already covers
2534
+ // transform, so just appending active:scale-95 to the
2535
+ // className wires the press tactile in one token. Compound
2536
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2537
+ 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 active:scale-95 transform-gpu" data-topo-filter-pill-hover-lift="true"
2474
2538
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2475
2539
  onClick={() => setPinnedStatus(null)}
2476
2540
  style={{
@@ -2534,7 +2598,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2534
2598
  data-filter-match-count={matchCount}
2535
2599
  data-filter-match-aliases={matchAliases.join(',')}
2536
2600
  // R355 sibling — `group` parent + group-hover on inner spans.
2537
- 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"
2601
+ // R495 filter pills (3 sibling `group` variants) join the
2602
+ // active:scale-95 press-feedback family. R490's !important
2603
+ // transition list on .anet-topo-chip-focus already covers
2604
+ // transform, so just appending active:scale-95 to the
2605
+ // className wires the press tactile in one token. Compound
2606
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2607
+ 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 active:scale-95 transform-gpu" data-topo-filter-pill-hover-lift="true"
2538
2608
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2539
2609
  onClick={() => setPinnedGroup(null)}
2540
2610
  style={{
@@ -2600,7 +2670,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2600
2670
  data-filter-match-count={matchCount}
2601
2671
  data-filter-match-aliases={matchAliases.join(',')}
2602
2672
  // R355 sibling — `group` parent + group-hover on inner spans.
2603
- 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"
2673
+ // R495 filter pills (3 sibling `group` variants) join the
2674
+ // active:scale-95 press-feedback family. R490's !important
2675
+ // transition list on .anet-topo-chip-focus already covers
2676
+ // transform, so just appending active:scale-95 to the
2677
+ // className wires the press tactile in one token. Compound
2678
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2679
+ 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 active:scale-95 transform-gpu" data-topo-filter-pill-hover-lift="true"
2604
2680
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
2605
2681
  onClick={() => setPinnedVendor(null)}
2606
2682
  style={{
@@ -2663,7 +2739,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2663
2739
  data-filter-match-count={link.count}
2664
2740
  data-filter-match-aliases={`${link.from},${link.to}`}
2665
2741
  data-active-filter-edge-hot={isHot ? 'true' : 'false'}
2666
- 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"
2742
+ // R495 sibling 4th filter pill (no `group` prefix variant)
2743
+ // joins active:scale-95 press family alongside the 3 group
2744
+ // variants above. Same recipe.
2745
+ 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 active:scale-95 transform-gpu" data-topo-filter-pill-hover-lift="true"
2667
2746
  title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
2668
2747
  onClick={() => setPinnedEdgeKey(null)}
2669
2748
  style={{
@@ -3013,7 +3092,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3013
3092
  // — sibling to R355 filter-pill prefix/suffix + R414
3014
3093
  // chip-row unit brighten. Closes the inner-span
3015
3094
  // hover-brighten family at the vendor chip surface.
3016
- className="group tabular-nums font-medium inline-flex items-baseline gap-0.5 px-1 rounded anet-topo-chip-focus transition-transform duration-200 ease-out transform-gpu hover:-translate-y-px"
3095
+ // R496 vendor letter chip joins active:scale-95 press
3096
+ // family. Last vendor-row clickable joining the family
3097
+ // R495 cashed via R490's transition-cascade dividend.
3098
+ // Same compound w/ R401 hover-lift idiom — lift-and-
3099
+ // compress on press, springs back on release.
3100
+ className="group tabular-nums font-medium inline-flex items-baseline gap-0.5 px-1 rounded anet-topo-chip-focus transition-transform duration-200 ease-out transform-gpu hover:-translate-y-px active:scale-95"
3017
3101
  data-vendor-letter={v.initial}
3018
3102
  data-vendor-letter-count={v.count}
3019
3103
  data-vendor-letter-hover-lift="true"
@@ -3251,9 +3335,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3251
3335
  data-chip-hover-lift attr exposes the lift surface
3252
3336
  state ('true' clickable, 'false' empty) for tests. */
3253
3337
  // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
3338
+ // R496 — active-links chip joins active:scale-95 press
3339
+ // family. Sibling to working+online chips (R494). Gated
3340
+ // on `isInteractive` (flowLinks.length > 0) — same R399
3341
+ // conditional pattern used for hover-lift.
3254
3342
  className={`group tabular-nums font-medium hidden sm:inline px-2.5 py-1 rounded-md border anet-topo-chip-focus transition-transform duration-200 ease-out transform-gpu ${
3255
3343
  isInteractive
3256
- ? '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'
3344
+ ? '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 active:scale-95'
3257
3345
  : 'bg-gray-500/10 text-gray-400 border-gray-500/20'
3258
3346
  }`}
3259
3347
  data-chip-hover-lift={isInteractive ? 'true' : 'false'}
@@ -3500,6 +3588,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3500
3588
  on the canvas root for non-visual consumers.
3501
3589
  Composed from existing onlineNodes / workingCount /
3502
3590
  offlineNodes / flowLinks — no new state. */
3591
+ /* Round 502 / Loop — categorical density-tier paired with the
3592
+ R469 numeric counts. data-topo-fleet-density-tier classifies
3593
+ the fleet size into 5 buckets so external consumers (CSS
3594
+ selectors, Playwright probes, future density-conditional
3595
+ polish gates like R109 dense-label collapse at 16+ nodes)
3596
+ can branch on a stable tier name without re-deriving the
3597
+ threshold logic from the raw numeric. Buckets:
3598
+ 'empty' — onlineNodes.length === 0
3599
+ 'sparse' — 1-3 nodes
3600
+ 'normal' — 4-15 nodes
3601
+ 'dense' — 16-30 nodes (matches R109 collapse gate)
3602
+ 'very-dense' — 31+ nodes
3603
+ Picks the gate boundaries that already drive CONDITIONAL
3604
+ RENDER decisions elsewhere (R109 denseLayout = >16, R110
3605
+ plain-text fallback) so the tier name is semantically
3606
+ aligned with the visual mode the canvas already switches
3607
+ to. Composed from existing onlineNodes — no new state.
3608
+ 12th attr in the canvas state surface set (R462/R466/R467/
3609
+ R469×4/R471×2/R487/R488/R502). 12 attrs covers: build
3610
+ identity, transient/sticky inspection modes, fleet split
3611
+ numerics, fleet density tier, canvas layout/theme, canvas
3612
+ zoom, hover identity. A test harness can snapshot the
3613
+ full canvas state with 12 getAttribute calls. */
3614
+ data-topo-fleet-density-tier={
3615
+ onlineNodes.length === 0 ? 'empty' :
3616
+ onlineNodes.length <= 3 ? 'sparse' :
3617
+ onlineNodes.length <= 15 ? 'normal' :
3618
+ onlineNodes.length <= 30 ? 'dense' :
3619
+ 'very-dense'
3620
+ }
3503
3621
  data-topo-online-count={onlineNodes.length}
3504
3622
  data-topo-working-count={workingCount}
3505
3623
  data-topo-offline-count={offlineNodes.length}
@@ -3565,6 +3683,119 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3565
3683
  categorical) — separate dedicated attrs if/when needed.
3566
3684
  Root svg attribute set now 11 attrs total. */
3567
3685
  data-topo-hovered-alias={hoveredAlias ?? ''}
3686
+ /* Round 504 / Loop — categorical pin-aspect attr paired with
3687
+ R467 any-pinned boolean and R488 hovered-alias identity.
3688
+ Pre-R504 the canvas state surface set told tests WHETHER
3689
+ any pin was active (R467 boolean) but tests had to enumerate
3690
+ 4 individual state vars to determine WHICH pin axis fired:
3691
+ pinnedStatus legend-row status filter
3692
+ pinnedGroup prefix-cluster lock
3693
+ pinnedVendor vendor-chip filter
3694
+ pinnedEdgeKey edge-focus
3695
+ R504 surfaces the active aspect as a single categorical
3696
+ attribute: data-topo-pinned-aspect ∈
3697
+ 'none' no pin active
3698
+ 'status' pinnedStatus only
3699
+ 'group' pinnedGroup only
3700
+ 'vendor' pinnedVendor only
3701
+ 'edge' pinnedEdgeKey only
3702
+ 'multi' 2 or more pins active simultaneously
3703
+ ('multi' covers cross-cutting filters — e.g. user pins
3704
+ status='working' AND vendor='claude' simultaneously to
3705
+ narrow the canvas. Each pin axis is independently
3706
+ dismissable via Esc / individual chip click, so multi
3707
+ states are reachable and worth surfacing as a distinct
3708
+ tier.)
3709
+ 13th attr in the canvas state surface set after R502.
3710
+ Composed from 4 existing state vars — no new state. */
3711
+ /* Round 512 / Loop — 14th canvas state attr. groupBoxes.length
3712
+ surfaces the count of cluster boxes currently rendered in
3713
+ grid layout (always 0 in ring). Paired with R502 categorical
3714
+ density tier + R469 fleet numerics for a complete cluster-
3715
+ cardinality surface:
3716
+ R469 data-topo-online-count node-count
3717
+ R502 data-topo-fleet-density-tier categorical
3718
+ R512 data-topo-cluster-count cluster-count ← this round
3719
+ Use cases:
3720
+ - Playwright: assert orphan-band existence by
3721
+ `cluster-count === N + 1` vs prefix-only `=== N`
3722
+ - external CSS: `[data-topo-cluster-count='1']` to apply
3723
+ single-cluster grid-specific layout adjustments
3724
+ - future polish gates: cluster-count > N could trigger
3725
+ dense-grid mode
3726
+ Composed from existing `groupBoxes.length` — no new state.
3727
+ Always renders (0 in ring layout, N in grid), so tests can
3728
+ rely on attribute presence + value. */
3729
+ data-topo-cluster-count={groupBoxes.length}
3730
+ /* Round 513 / Loop — 15th canvas state attr. Surfaces the
3731
+ user's prefers-reduced-motion preference directly on the
3732
+ root SVG so external CSS / Playwright tests can branch on
3733
+ a11y state without re-reading the media query.
3734
+ reducedMotion is already in component scope (R29 a11y
3735
+ blanket reads it via a useEffect listener); R513 just
3736
+ exposes it as a stable attribute handle.
3737
+ Use cases:
3738
+ - Playwright: assert reduced-motion gates from one attr
3739
+ read instead of mocking media-query state per test
3740
+ - External CSS hooks: `[data-topo-prefers-reduced-motion=
3741
+ "true"]` to apply paint-only overrides (e.g. mute
3742
+ hover glows entirely on a11y instead of just
3743
+ disabling transitions)
3744
+ - Future polish rounds: any motion-gated render can
3745
+ read this attr server-side without the media-query
3746
+ hydration mismatch risk
3747
+ 'true' / 'false' string values (consistent with R466/R467
3748
+ boolean attrs). */
3749
+ data-topo-prefers-reduced-motion={reducedMotion ? 'true' : 'false'}
3750
+ /* Round 515 / Loop — 16th canvas state attr. Surfaces the
3751
+ fullscreen-mode state directly on root SVG so external
3752
+ consumers don't have to traverse the chrome strip's
3753
+ `data-topo-chrome-fullscreen-active` button attr (which
3754
+ measures the BUTTON state, not the canvas state — they
3755
+ agree, but reading from the root is semantically cleaner
3756
+ for canvas-state probes).
3757
+ Composed from existing isFullscreen React state (R103
3758
+ fullscreen toggle).
3759
+ Use cases:
3760
+ - Playwright: assert canvas mode in one attr read
3761
+ (paired with R471 data-topo-layout for ring/grid +
3762
+ R487 data-topo-zoom for zoom level + R513 reduced-
3763
+ motion for a11y mode = 4-axis canvas-mode probe)
3764
+ - External CSS: `[data-topo-fullscreen="true"]` to
3765
+ apply fullscreen-only paint adjustments outside the
3766
+ React tree (e.g. body-level scrollbar hide)
3767
+ 'true' / 'false' string values (consistent with R466/
3768
+ R467/R513 boolean attrs). */
3769
+ data-topo-fullscreen={isFullscreen ? 'true' : 'false'}
3770
+ /* Round 516 / Loop — 17th canvas state attr. Surfaces the
3771
+ grid layout's content-bottom y-coordinate so tests can
3772
+ verify grid content doesn't extend past the viewBox or
3773
+ collide with chrome elements positioned below the canvas.
3774
+ Composed from existing gridContentBottom derived state
3775
+ (computed at line ~915 from gy0 + totalRows * cellH + 8).
3776
+ In ring layout, gridContentBottom is 0 (no grid). In grid
3777
+ layout it's the actual pixel y-coordinate where the
3778
+ cluster bands end.
3779
+ Use cases:
3780
+ - Playwright: assert grid layout doesn't exceed viewBox
3781
+ height (680) without re-computing the layout math
3782
+ - External CSS: `[data-topo-grid-content-bottom='0']` to
3783
+ distinguish ring-mode (no grid content) from grid-mode
3784
+ in CSS without parsing layout attr
3785
+ - Future polish gates: if cluster count grows large
3786
+ enough to push grid bottom past viewBox, can trigger
3787
+ a 'compact' mode automatically */
3788
+ data-topo-grid-content-bottom={gridContentBottom}
3789
+ data-topo-pinned-aspect={(() => {
3790
+ const aspects: string[] = [];
3791
+ if (pinnedStatus) aspects.push('status');
3792
+ if (pinnedGroup) aspects.push('group');
3793
+ if (pinnedVendor) aspects.push('vendor');
3794
+ if (pinnedEdgeKey) aspects.push('edge');
3795
+ if (aspects.length === 0) return 'none';
3796
+ if (aspects.length === 1) return aspects[0];
3797
+ return 'multi';
3798
+ })()}
3568
3799
  /* Round 466 / Loop — aggregate hover signal on the root SVG.
3569
3800
  Exposes a single boolean `data-topo-any-hover` that
3570
3801
  reflects whether ANY hover state in the topology is
@@ -4494,12 +4725,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4494
4725
  // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
4495
4726
  // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
4496
4727
  // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
4497
- fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
4498
- : isHovered ? (isLight ? 0.05 : 0.09)
4499
- : (isLight ? 0.025 : 0.045)}
4728
+ /* Round 506 / Loop category-differentiation family
4729
+ 3rd anchor. Orphan band rest-state fillOpacity drops
4730
+ slightly below prefix-group rest (0.025/0.045
4731
+ 0.015/0.028). Adds a 3rd independent paint
4732
+ differentiator to the orphan visual signature:
4733
+ R499 fontStyle: italic (label text)
4734
+ R503 '3 6' dash pattern (rect stroke)
4735
+ R506 lower fillOpacity (rect fill) ← this round
4736
+ Three independent channels (typography + stroke
4737
+ pattern + fill density) collectively encode the
4738
+ catchall semantic at rest. Pin and hover branches
4739
+ UNCHANGED (still 0.05/0.09 hover, 0.08/0.13 pin) —
4740
+ orphan box gets full visual emphasis on inspection
4741
+ identical to prefix groups; the differentiation
4742
+ lives ONLY in the unsolicited rest state. The
4743
+ ~40% drop (0.045 → 0.028 cyber, 0.025 → 0.015
4744
+ light) is subtle enough that the orphan box stays
4745
+ visible at rest, just quieter — matches the
4746
+ "misc bucket, less attention-deserving" semantic
4747
+ without losing the visual anchor.
4748
+ Pure paint axis; bbox unchanged; R51 SVG sentinel
4749
+ safety untouched (overlap-test gates to g[data-
4750
+ node], cluster rect invisible to it).
4751
+ data-group-box-fill-opacity attr surfaces the
4752
+ resolved value for tests. */
4753
+ fillOpacity={
4754
+ isPinned ? (isLight ? 0.08 : 0.13)
4755
+ : isHovered ? (isLight ? 0.05 : 0.09)
4756
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4757
+ : (isLight ? 0.025 : 0.045)
4758
+ }
4759
+ data-group-box-fill-opacity={
4760
+ isPinned ? (isLight ? 0.08 : 0.13)
4761
+ : isHovered ? (isLight ? 0.05 : 0.09)
4762
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4763
+ : (isLight ? 0.025 : 0.045)
4764
+ }
4500
4765
  stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
4501
4766
  strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
4502
- strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
4767
+ /* Round 503 / Loop category-differentiation family
4768
+ 2nd anchor (R499 = orphan label italic, 1st anchor).
4769
+ Orphan band rest-state strokeDasharray switches from
4770
+ '6 6' (prefix-group default) to '3 6' (tighter
4771
+ dashes). Pre-R503 the rect dash pattern was uniform
4772
+ across all bands; combined with R499's italic label,
4773
+ the orphan box now has TWO independent paint/
4774
+ typography differentiators at rest:
4775
+ R499 fontStyle: italic (label text)
4776
+ R503 '3 6' dash pattern (rect stroke) ← this round
4777
+ The R85 marching-ants animation continues to work
4778
+ with the new dash size (uses --march-dur custom
4779
+ property, dash-length-agnostic) — orphan's ants
4780
+ just have a different visual rhythm than prefix-
4781
+ group ants, reinforcing the catchall semantic.
4782
+ Pinned/hovered orphan still gets 'none' (solid
4783
+ stroke) so the hover/pin affordance is preserved
4784
+ — the differentiation lives ONLY in the rest
4785
+ state, never blocking inspection.
4786
+ Pure paint axis; no geometry change; bbox unchanged
4787
+ (strokeDasharray is paint-only). R51 SVG sentinel
4788
+ safety untouched (overlap-test gates to g[data-
4789
+ node], this cluster rect is invisible to it).
4790
+ data-group-box-orphan attr surfaces the gate for
4791
+ tests + future polish references. */
4792
+ strokeDasharray={
4793
+ (isPinned || isHovered) ? 'none' :
4794
+ box.isOrphan ? '3 6' : '6 6'
4795
+ }
4796
+ data-group-box-orphan={box.isOrphan ? 'true' : 'false'}
4503
4797
  /* Round 380 / Loop: cluster box stroke gets round
4504
4798
  linecap + round linejoin. Sibling SVG stroke-
4505
4799
  softening polish to R378 flow-rail linecap + R379
@@ -4844,16 +5138,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4844
5138
  ease-out' alongside the existing fill/ls/fw/opacity
4845
5139
  200ms tweens. */
4846
5140
  data-group-label-glow={isPinned ? 'true' : 'false'}
5141
+ /* Round 499 / Loop — orphan band "其他" label gets
5142
+ fontStyle: italic to visually distinguish the
5143
+ catchall from real prefix-group bands. Pre-R499
5144
+ the orphan box label rendered identically to
5145
+ prefix-group labels (Hero D fontSize=9, fw=700,
5146
+ opacity 0.55 rest), so users had to read the
5147
+ literal text "其他" to identify the catchall. R499
5148
+ adds a pure-typography differentiation: italic
5149
+ signals "this is the misc bucket, not a real
5150
+ named group" while preserving full opacity
5151
+ affordance on hover/pin — the orphan box stays
5152
+ equally inspectable, just typographically marked
5153
+ as a different category. No geometry change
5154
+ (italic shifts glyph slant within the same bbox),
5155
+ no opacity loss, no behavior change. Sibling to
5156
+ R432 letter-spacing 3-tier + R457 pin fw-lift +
5157
+ R479 pin drop-shadow at the group-label scope.
5158
+ Falls under 配色 / 节点视觉 themes per the prompt;
5159
+ advances the "信息密度" axis by encoding
5160
+ category-distinction into a single typography
5161
+ channel without adding visual chrome. */
4847
5162
  style={{
4848
5163
  transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
4849
5164
  letterSpacing: isPinned ? '0.5px' :
4850
5165
  isHovered ? '0.25px' : '0px',
5166
+ fontStyle: box.isOrphan ? 'italic' : undefined,
4851
5167
  filter: isPinned
4852
5168
  ? `drop-shadow(0 0 3px ${pal.legendAccent}80)`
4853
5169
  : undefined,
4854
5170
  }}
4855
5171
  data-group-label={box.key}
4856
5172
  data-group-label-pinned={isPinned ? 'true' : 'false'}
5173
+ data-group-label-orphan={box.isOrphan ? 'true' : 'false'}
4857
5174
  >
4858
5175
  {box.key}
4859
5176
  {/* Round 19 / Loop: member-count chip. Inline tspan stays
@@ -6468,11 +6785,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6468
6785
  so the glow eases under the same cadence as the
6469
6786
  scale + fw + fill axes. */
6470
6787
  data-topo-hub-working-count-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6788
+ /* Round 507 / Loop — focal recede. When ANY non-hub
6789
+ canvas surface is hovered (a node / an edge / a
6790
+ group label / a legend row / a vendor chip), the
6791
+ hub-center workingCount digit fades to 0.85 opacity,
6792
+ signaling "you're inspecting elsewhere, hub recedes
6793
+ to background." When the user un-hovers (or hovers
6794
+ the hub itself), opacity returns to 1.0. Pure paint
6795
+ polish at the canvas's most prominent focal point.
6796
+ Hits 信息密度 + 动效 themes — the hub digit gives
6797
+ way visually to the surface under inspection,
6798
+ reinforcing the "this is the focal point right now"
6799
+ gesture without requiring users to track which
6800
+ surface holds attention.
6801
+ Gate excludes hoveredHub specifically: hovering the
6802
+ hub itself should LIFT the digit (R425 fw bump +
6803
+ R476 glow + R209 scale 1.08) — the existing hover-
6804
+ on-hub signature is intact; only inspection
6805
+ ELSEWHERE recedes the hub.
6806
+ Composed from existing hoveredAlias / hoveredEdge-
6807
+ Key / hoveredGroupLabel / hoveredStatus / hovered-
6808
+ Vendor — no new state. 300ms ease-out opacity
6809
+ transition already in the style list (existing R213
6810
+ transition spec), so the fade rides on existing
6811
+ infrastructure.
6812
+ data-topo-hub-recede attr surfaces the gate state
6813
+ for tests. */
6814
+ data-topo-hub-recede={
6815
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6816
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
6817
+ }
6471
6818
  style={{
6472
6819
  pointerEvents: 'none',
6473
6820
  transform: !reducedMotion && hoveredHub ? 'scale(1.08)' : 'scale(1)',
6474
6821
  transformBox: 'fill-box',
6475
6822
  transformOrigin: 'center',
6823
+ opacity: (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6824
+ hoveredStatus || hoveredVendor) && !hoveredHub
6825
+ ? 0.85
6826
+ : 1,
6476
6827
  filter: !reducedMotion && hoveredHub
6477
6828
  ? (isLight
6478
6829
  ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.6))'
@@ -6482,7 +6833,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6482
6833
  bump 700 → 800 eases under the same cadence as
6483
6834
  R209 scale + R253 fill + R213 opacity.
6484
6835
  R476: filter 200ms appended so the new drop-
6485
- shadow glow eases at the same cadence. */
6836
+ shadow glow eases at the same cadence.
6837
+ R507: opacity 300ms (existing in list) covers
6838
+ the new focal-recede fade. */
6486
6839
  transition: 'transform 200ms ease-out, opacity 300ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, filter 200ms ease-out',
6487
6840
  fontVariantNumeric: 'tabular-nums',
6488
6841
  }}
@@ -6529,19 +6882,138 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6529
6882
  + R213 always-mount opacity-gate + pointerEvents:none
6530
6883
  + R365 r=5.5 all preserved. data-topo-hub-highlight-
6531
6884
  opacity attr exposes the resolved value for tests. */}
6532
- <circle
6533
- cx={cx} cy={cy} r="5.5"
6534
- fill="#d1fae5"
6535
- opacity={workingCount > 0 ? 0 : 0.95}
6536
- data-topo-hub-highlight
6537
- data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
6538
- data-topo-hub-highlight-radius="5.5"
6539
- data-topo-hub-highlight-opacity={workingCount > 0 ? 0 : 0.95}
6540
- style={{
6541
- pointerEvents: 'none',
6542
- transition: 'opacity 300ms ease-out',
6543
- }}
6544
- />
6885
+ {/* Round 508 / Loop — focal-recede pattern 2nd anchor.
6886
+ Extends R507's hub-digit recede to the hub-highlight
6887
+ circle so the hub focal CLUSTER (digit at z-top + this
6888
+ idle-state highlight beneath) recedes as a unit when
6889
+ canvas attention is elsewhere. Computed once: a single
6890
+ non-hub-hover gate drives BOTH the digit (R507) AND
6891
+ this highlight (R508) so they always co-move.
6892
+ Recede multiplies the visible opacity by 0.85 — when
6893
+ workingCount===0 the rest opacity 0.95 becomes 0.81
6894
+ during external-hover; when workingCount>0 the
6895
+ opacity stays 0 (invisible) regardless of recede.
6896
+ Additionally, when recede is active the SMIL breath
6897
+ animation halts (animate node un-mounts) so the
6898
+ receded state reads as quietly static, not pulsing
6899
+ at 0.85↔1.0 against the recede multiplier (which
6900
+ would visually conflict — competing 15% drops). On
6901
+ un-hover the animate re-mounts and breath resumes.
6902
+ data-topo-hub-recede on both digit AND highlight
6903
+ provides a stable test handle for the unified-recede
6904
+ gate.
6905
+ Composed from existing hover state vars — no new
6906
+ state. Pure paint axis. */}
6907
+ {(() => {
6908
+ const hubRecede = !!((hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6909
+ hoveredStatus || hoveredVendor) && !hoveredHub);
6910
+ /* Round 511 / Loop — hub-highlight gains a 3rd opacity tier
6911
+ (hovered-amplify). Pre-R511 the highlight had 2 visible
6912
+ states: rest (0.95) and recede (0.81 = 0.95 × 0.85).
6913
+ When the hub itself was hovered, the digit got R425 fw
6914
+ lift + R476 drop-shadow + R209 scale-1.08, but the
6915
+ highlight disc sibling stayed at 0.95 — the focal
6916
+ cluster lifted in 3 channels (typography/paint/scale)
6917
+ but the highlight didn't participate.
6918
+ R511 closes that asymmetry: when hoveredHub is true,
6919
+ highlight base opacity lifts to 1.0 (5% boost from
6920
+ rest 0.95). Cluster now lifts as a unit on hub-hover,
6921
+ just like it recedes as a unit on non-hub-hover
6922
+ (R508).
6923
+ 3-state opacity ladder:
6924
+ hub-hovered: baseOpacity = 1.0 (R511 NEW)
6925
+ rest (no hover): baseOpacity = 0.95 (existing)
6926
+ non-hub canvas hov: baseOpacity × 0.85 = 0.81 (R508)
6927
+ Composes cleanly: hubRecede gate requires !hoveredHub,
6928
+ so the hovered-amplify and recede states are mutually
6929
+ exclusive (they can't both fire). breathActive
6930
+ continues to halt on either non-rest state (recede OR
6931
+ hub-hover would visually compete with the 0.85↔1
6932
+ breath — clean for the unit-lift semantic too). */
6933
+ const baseOpacity = workingCount > 0 ? 0
6934
+ : hoveredHub ? 1.0
6935
+ : 0.95;
6936
+ const resolvedOpacity = hubRecede ? baseOpacity * 0.85 : baseOpacity;
6937
+ const breathActive = !reducedMotion && workingCount === 0 && !hubRecede && !hoveredHub;
6938
+ return (
6939
+ <circle
6940
+ cx={cx} cy={cy} r="5.5"
6941
+ /* Round 509 / Loop — 配色 cross-theme cleanup. Pre-R509
6942
+ the hub-highlight fill was hardcoded `#d1fae5`
6943
+ (emerald-100, a pale tone). On the light theme this
6944
+ near-white green ran against a pale background at
6945
+ 0.95 opacity — the disc was effectively invisible.
6946
+ Matches the existing R253 halo theme-inversion
6947
+ pattern (line ~6481): light theme picks the dark
6948
+ vibrant emerald (#10b981 emerald-600), dark theme
6949
+ keeps the pale emerald (#d1fae5 emerald-100). Both
6950
+ read at the same 0.95 opacity against their
6951
+ respective backdrops — light gets a saturated
6952
+ focal dot; dark keeps the soft glow signature.
6953
+ Pure paint axis (fill change only); bbox unchanged;
6954
+ R51 SVG sentinel safety untouched.
6955
+ transition list already includes `fill 200ms`?
6956
+ Actually the existing transition spec is `opacity
6957
+ 300ms ease-out` — fill change on theme toggle
6958
+ will be instant. That's acceptable: theme toggle
6959
+ is a discrete event, and the halo (line 6500)
6960
+ already snaps fill on theme toggle the same way
6961
+ (`fill 200ms ease-out` was added later to halo
6962
+ via R253). Future round could add `fill 200ms`
6963
+ to highlight too if theme-switch flicker is
6964
+ noticed. */
6965
+ fill={isLight ? '#10b981' : '#d1fae5'}
6966
+ opacity={resolvedOpacity}
6967
+ data-topo-hub-highlight
6968
+ data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
6969
+ data-topo-hub-highlight-radius="5.5"
6970
+ data-topo-hub-highlight-opacity={resolvedOpacity}
6971
+ data-topo-hub-highlight-breath={breathActive ? 'true' : 'false'}
6972
+ data-topo-hub-highlight-recede={hubRecede ? 'true' : 'false'}
6973
+ /* Round 510 / Loop — R509 follow-on: theme-toggle fill
6974
+ ease. Pre-R510 the hub-highlight transition spec only
6975
+ listed `opacity 300ms ease-out`. When R509 introduced
6976
+ theme-conditional fill (#d1fae5 ↔ #10b981), the fill
6977
+ change SNAPPED on theme toggle because the transition
6978
+ list didn't include `fill`. R510 extends to `fill
6979
+ 200ms ease-out` so theme cycles smoothly through the
6980
+ emerald palette. 200ms timing matches the R253 halo
6981
+ fill transition (line ~6500) — both hub-cluster
6982
+ theme transitions now share a cadence so the focal
6983
+ cluster (digit + highlight + halo) eases as a unit.
6984
+ R508's recede opacity transition unchanged (300ms);
6985
+ fill is independent. */
6986
+ style={{
6987
+ pointerEvents: 'none',
6988
+ transition: 'opacity 300ms ease-out, fill 200ms ease-out',
6989
+ }}
6990
+ >
6991
+ {/* Round 497 / Loop — idle-state breath (呼吸感 theme pivot
6992
+ from the R492-R496 press-family arc). Pre-R497 the hub
6993
+ idle highlight read as a static dim disc — present but
6994
+ motionless, visually mute. R497 adds a 4s opacity breath
6995
+ (0.85 ↔ 1.0 ↔ 0.85) so the hub reads "alive but quiet"
6996
+ instead of "frozen", giving the empty-fleet state a
6997
+ subtle living signature.
6998
+ Gates:
6999
+ - !reducedMotion (R29 a11y blanket) — reducedMotion
7000
+ users see static 0.95 disc, no animate
7001
+ - workingCount === 0 — when fleet is busy, the
7002
+ highlight is invisible (opacity=0) so the animate
7003
+ would waste paint cycles. Gating saves work.
7004
+ SMIL <animate> overrides the static opacity={0.95}
7005
+ during its run; falls back to 0.95 when reducedMotion
7006
+ flips on (the animate node simply doesn't render).
7007
+ 4s cycle is long enough to feel like ambient breath
7008
+ rather than a pulse, matching the "quiet" semantic.
7009
+ data-topo-hub-highlight-breath attr exposes the
7010
+ resolved gate state for tests. */}
7011
+ {breathActive && (
7012
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
7013
+ )}
7014
+ </circle>
7015
+ );
7016
+ })()}
6545
7017
  {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
6546
7018
  that fades in when the hub is hovered — the same idea
6547
7019
  R44 used for node avatars (group-hover stroke). r=14
@@ -7526,6 +7998,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7526
7998
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
7527
7999
 
7528
8000
  if (isIntern || internByAlias || vendor.logo) {
8001
+ /* Round 501 / Loop — vendor avatar inside node circles
8002
+ gains a hover-gated brightness lift. Pre-R501 the
8003
+ avatar <image> was the only per-node surface with
8004
+ NO hover treatment: R26 lifted the card, R242 tinted
8005
+ the card stroke, R427 spread the alias letter-
8006
+ spacing, R500 added the alias drop-shadow, R208
8007
+ lifted the runtime badge ring, R443 thickened
8008
+ the badge icon stroke, R177 brightened the
8009
+ halo — but the most visually-prominent element
8010
+ (the vendor logo / 书生 coin centred in each node)
8011
+ stayed paint-static. R501 closes the per-node
8012
+ hover-affordance arc by adding a 15% brightness
8013
+ lift on hover.
8014
+ Implementation: CSS filter: brightness(1.15)
8015
+ when hoveredAlias === session.alias. Pure paint
8016
+ axis on the <image> element — no geometry change,
8017
+ no bbox shift. Modern-browser supported (Chrome 64+
8018
+ / FF 56+ / Safari 9.1+).
8019
+ Hits 节点视觉 theme. data-node-avatar-hovered
8020
+ attr surfaces the gate for tests.
8021
+ Gated on !reducedMotion as a courtesy (brightness
8022
+ transition < ~50ms still feels instant; the gate
8023
+ avoids the transition cycle for a11y users). */
8024
+ const isAvatarHovered = !reducedMotion && hoveredAlias === session.alias;
7529
8025
  return (
7530
8026
  <image
7531
8027
  href={vendor.logo ?? '/intern_avatar.png'}
@@ -7534,6 +8030,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7534
8030
  width={size}
7535
8031
  height={size}
7536
8032
  preserveAspectRatio="xMidYMid meet"
8033
+ data-node-avatar={session.alias}
8034
+ data-node-avatar-hovered={isAvatarHovered ? 'true' : 'false'}
8035
+ style={{
8036
+ filter: isAvatarHovered ? 'brightness(1.15)' : undefined,
8037
+ transition: 'filter 200ms ease-out',
8038
+ }}
7537
8039
  />
7538
8040
  );
7539
8041
  }
@@ -7957,6 +8459,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7957
8459
  R211 fill 300ms + R305 letter-spacing 200ms
7958
8460
  transition list preserved; only the
7959
8461
  conditional gets a middle case. */}
8462
+ {/* Round 500 / Loop — milestone round, opens
8463
+ per-node alias drop-shadow polish. Extends the
8464
+ R476-R481 drop-shadow visual-polish family to a
8465
+ 7th anchor: hovered alias text gains a soft
8466
+ status-coloured text-glow. Pre-R500 hover on
8467
+ a node triggered card-lift (R26 translateY) +
8468
+ card-stroke (R242 tint) + alias letter-spacing
8469
+ (R427 0.3px tier) but the alias TEXT itself had
8470
+ no paint-axis cue beyond fill (R211). R500 adds
8471
+ a drop-shadow on the text glyph itself, so the
8472
+ identity glyph itself lights up under attention
8473
+ — matching the R476 idiom (hub-digit emerald
8474
+ glow on hover) at the per-node identity scope.
8475
+ 2px blur radius at 50% alpha — subtler than the
8476
+ R476 hub-digit (3px at 60%) because the alias
8477
+ text is smaller and more numerous (1 per node)
8478
+ so an aggressive glow would multiply into
8479
+ visual noise. Status-coloured (status.text) so
8480
+ the glow inherits the node's working/idle/
8481
+ offline palette — green/cyan/gray respectively.
8482
+ Drop-shadow visual-polish family — 7 anchors:
8483
+ R476 hub digit hover-gated emerald
8484
+ R477 legend pin-ring pin-gated row.fill
8485
+ R478 recent-row pip fresh-gated cyan
8486
+ R479 group-label text pin-gated cyan
8487
+ R480 hot-lane edge hot-gated amber
8488
+ R481 zoom-state minimap zoom-gated cyan
8489
+ R500 node alias text hover-gated status.text ← this round
8490
+ Filter is paint-only; bbox unchanged; overlap-
8491
+ test invariants hold (R51 selector gated to
8492
+ g[data-node] descendants with strokeWidth
8493
+ sentinels; text element doesn't carry stroke).
8494
+ transition list extends to include 'filter
8495
+ 200ms ease-out' alongside the existing fill
8496
+ 300ms + letter-spacing 200ms tweens.
8497
+ data-node-alias-glow attr surfaces the hover
8498
+ gate for tests. */}
7960
8499
  <text
7961
8500
  x="0" y="1" textAnchor="middle"
7962
8501
  fill={status.text}
@@ -7964,11 +8503,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7964
8503
  data-node-alias-text={session.alias}
7965
8504
  data-node-alias-chat-target={chatAlias === session.alias ? 'true' : 'false'}
7966
8505
  data-node-alias-hovered={hoveredAlias === session.alias ? 'true' : 'false'}
8506
+ data-node-alias-glow={!reducedMotion && hoveredAlias === session.alias ? 'true' : 'false'}
7967
8507
  style={{
7968
- transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out',
8508
+ transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out, filter 200ms ease-out',
7969
8509
  letterSpacing:
7970
8510
  chatAlias === session.alias ? '0.5px' :
7971
8511
  hoveredAlias === session.alias ? '0.3px' : '0px',
8512
+ filter: !reducedMotion && hoveredAlias === session.alias
8513
+ ? `drop-shadow(0 0 2px ${status.text}80)`
8514
+ : undefined,
7972
8515
  }}
7973
8516
  >
7974
8517
  {truncate(session.alias, fullMax)}
@@ -9378,12 +9921,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9378
9921
  under the same R320 fill cadence. data-
9379
9922
  recent-row-count-pinned attr exposes the
9380
9923
  pin gate for tests. */}
9924
+ {/* Round 498 / Loop — hot-count subtle pulse. Pre-
9925
+ R498 the hot row count signaled via color (R127
9926
+ amber fill) + weight (R320 fw-700) + (R445 pin
9927
+ lift) but stayed visually motionless. R498 adds
9928
+ a 3s opacity breath (0.85↔1.0) on the digit when
9929
+ isHot && !reducedMotion — gentle "alive" signal
9930
+ on the lane carrying ≥ 10 messages, drawing
9931
+ glance without becoming noisy. Sibling of R497
9932
+ hub-idle-breath in the 呼吸感 theme arc; same
9933
+ 0.85↔1.0 amplitude. Class adds an animation-
9934
+ only paint axis; no layout / bbox change. R29
9935
+ blanket also catches `animation-duration` for
9936
+ reducedMotion users, but the component-side
9937
+ gate makes the intent explicit and avoids
9938
+ a node tree thrash for those users (className
9939
+ stays absent rather than present-but-paused). */}
9381
9940
  <tspan
9382
9941
  fill={isHot ? hotStroke : undefined}
9383
9942
  fontWeight={(isHot || isRowPinned) ? '700' : '600'}
9943
+ className={isHot && !reducedMotion ? 'anet-recent-hot-pulse' : undefined}
9384
9944
  data-recent-row-count
9385
9945
  data-recent-row-count-pinned={isRowPinned ? 'true' : 'false'}
9386
9946
  data-recent-row-count-font-weight={(isHot || isRowPinned) ? '700' : '600'}
9947
+ data-recent-row-count-hot-pulse={isHot && !reducedMotion ? 'true' : 'false'}
9387
9948
  {...(isHot ? { 'data-recent-row-count-hot': 'true' } : {})}
9388
9949
  style={{
9389
9950
  transition: 'fill 300ms ease-out, font-weight 200ms ease-out',
@@ -10967,7 +11528,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10967
11528
  / fullscreen) preview their active state on hover.
10968
11529
  Pure actions (zoom -/+, reset) stay white — they
10969
11530
  aren't toggles, have no active state to preview. */
10970
- className={`px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'}${chromePopping === popKey ? ' anet-chrome-pop' : ''}`}
11531
+ // Round 493 / Loop extends R492 chrome-strip press-feedback
11532
+ // family to nodeSize S/M/L buttons. Adds active:scale-95
11533
+ // alongside the existing color-deepen (R196) + chrome-pop
11534
+ // (R249). transition-transform + duration-200 + ease-out
11535
+ // + transform-gpu added since this className previously had
11536
+ // transition-colors only — without the transform transition,
11537
+ // active:scale-95 would hard-cut. transform-gpu promotes the
11538
+ // layer so scale doesn't trigger paint thrash.
11539
+ className={`px-2 py-1 transition-colors transition-transform duration-200 ease-out transform-gpu active:scale-95 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'}${chromePopping === popKey ? ' anet-chrome-pop' : ''}`}
10971
11540
  style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
10972
11541
  >
10973
11542
  {lbl}
@@ -11009,7 +11578,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11009
11578
  // → white/10) so mouse-down has a tactile dim before the
11010
11579
  // R186 icon pop fires on release.
11011
11580
  // R352: `group` lets the inner svg respond via group-hover.
11012
- 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"
11581
+ // R493 zoom +/− buttons join the chrome-strip active:scale-95
11582
+ // press-feedback family (R492 + nodeSize above). transition-
11583
+ // transform + duration-200 + ease-out + transform-gpu added
11584
+ // since the className had only transition-colors.
11585
+ className="group px-2 py-1 hover:bg-white/5 active:bg-white/10 transition-colors transition-transform duration-200 ease-out transform-gpu active:scale-95 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
11013
11586
  style={{ color: pal.legendText }}
11014
11587
  aria-label="Zoom out"
11015
11588
  title="Zoom out (−)"
@@ -11149,7 +11722,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11149
11722
  data-topo-chrome-zoom-in-popping={chromePopping === 'zoom-in' ? 'true' : 'false'}
11150
11723
  // R196: press-state (mirror of zoom-out above).
11151
11724
  // R352: `group` lets the inner svg respond via group-hover.
11152
- 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"
11725
+ // R493 zoom +/− buttons join the chrome-strip active:scale-95
11726
+ // press-feedback family (R492 + nodeSize above). transition-
11727
+ // transform + duration-200 + ease-out + transform-gpu added
11728
+ // since the className had only transition-colors.
11729
+ className="group px-2 py-1 hover:bg-white/5 active:bg-white/10 transition-colors transition-transform duration-200 ease-out transform-gpu active:scale-95 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset"
11153
11730
  style={{ color: pal.legendText }}
11154
11731
  aria-label="Zoom in"
11155
11732
  title="Zoom in (+)"
@@ -11201,7 +11778,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11201
11778
  Every standalone interactive HTML surface in TopoGraph
11202
11779
  now lifts on hover. data-topo-chrome-reset-hover-lift
11203
11780
  attr surfaces the lift for tests. */
11204
- 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"
11781
+ // R493 reset button joins the chrome-strip active:scale-95
11782
+ // press-feedback family. The button already has transition-
11783
+ // transform + transform-gpu (R350 reset spin + R400 hover lift),
11784
+ // so just appending active:scale-95 plugs straight in. Compound
11785
+ // active state during press = hover-lift (-1px) + scale-95
11786
+ // composes as translateY(-1px) scale(0.95) — lift-and-compress
11787
+ // for tactile click feel.
11788
+ className="p-1.5 rounded-md border hover:bg-white/5 active:bg-white/10 hover:-translate-y-px active:scale-95 transition-colors transition-transform duration-200 ease-out transform-gpu focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60"
11205
11789
  data-topo-chrome-reset-hover-lift="true"
11206
11790
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
11207
11791
  aria-label="Reset view"
@@ -11249,8 +11833,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11249
11833
  // owns transform during its 450ms run. transformOrigin
11250
11834
  // 'center' so rotation pivots around the icon's centre
11251
11835
  // (default would be top-left and the icon would arc).
11836
+ /* Round 514 / Loop — extends R352/R353 chrome icon hover-
11837
+ scale family to the reset button. Pre-R514 the reset
11838
+ icon had hover-rotate (-8°, R350) + hover-sw (2.5→2.8,
11839
+ R453) but no hover-scale, while zoom-out (R352), zoom-
11840
+ in (R352), and fullscreen (R353) icons all carried
11841
+ `group-hover:scale-110`. R514 brings the reset icon
11842
+ into the same 3-axis hover signature (rotate + sw +
11843
+ scale) as the rest of the chrome strip.
11844
+ Implementation: inline transform composes rotate +
11845
+ scale into one string. `transform: rotate(-8deg)
11846
+ scale(1.1)` on hover; `rotate(0) scale(1)` at rest.
11847
+ transformOrigin 'center' applies to both — rotation
11848
+ pivots around centre AND scale grows from centre.
11849
+ The Tailwind `group-hover:scale-110` approach can't
11850
+ work here because inline `style.transform` overrides
11851
+ className-based transforms; compose the multi-axis
11852
+ transform inline instead.
11853
+ Chrome icon hover gesture parity (post-R514):
11854
+ zoom-out scale-110 + sw-lift (R352/R454)
11855
+ zoom-in scale-110 + sw-lift (R352/R454)
11856
+ fullscreen scale-110 + sw-lift (R353/R455)
11857
+ reset scale-1.1 + sw-lift + rotate -8°
11858
+ (R514 + R453 + R350)
11859
+ reset gets the EXTRA rotate axis because R350's spin
11860
+ preview semantic is reset-specific — the rotation
11861
+ hints at the click-spin (R184) the button will fire. */
11252
11862
  style={{
11253
- transform: hoveredReset && !resetSpinning ? 'rotate(-8deg)' : 'rotate(0deg)',
11863
+ transform: hoveredReset && !resetSpinning ? 'rotate(-8deg) scale(1.1)' : 'rotate(0deg) scale(1)',
11254
11864
  transformOrigin: 'center',
11255
11865
  transition: 'transform 200ms ease-out, stroke-width 200ms ease-out',
11256
11866
  }}
@@ -11294,7 +11904,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11294
11904
  // fullscreen now all carry an icon-level hover gesture in
11295
11905
  // addition to the bg hover).
11296
11906
  // R400: hover translateY(-1px) lift — see reset button above for family doc.
11297
- 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 ${
11907
+ // R493 fullscreen joins active:scale-95 press family (same as
11908
+ // reset above: lift-and-compress compound transform on press).
11909
+ className={`group p-1.5 rounded-md border hover:-translate-y-px active:scale-95 transition-colors transition-transform duration-200 ease-out transform-gpu focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 ${
11298
11910
  isFullscreen
11299
11911
  ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25'
11300
11912
  : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'