@sleep2agi/agent-network-dashboard 0.5.3-preview.4 → 0.5.3-preview.41

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 (199) 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/00ab7-2qe329w.js +1 -0
  145. package/.next/static/chunks/{0qoc2qe1owx6n.js → 0a8uvs-n.i-9q.js} +1 -1
  146. package/.next/static/chunks/0r24yayh0p77~.js +4 -0
  147. package/.next/static/chunks/0sht0y-y7x71m.css +2 -0
  148. package/.next/static/chunks/{03a4--7ncekmk.js → 0v4-5tng.uh.7.js} +2 -2
  149. package/.next/static/chunks/15oi0n86t2zis.js +1 -0
  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 +990 -49
  154. package/app/globals.css +55 -7
  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-crescent-recede-test.mjs +111 -0
  164. package/scripts/topo-filter-pills-press-test.mjs +96 -0
  165. package/scripts/topo-fleet-density-tier-test.mjs +84 -0
  166. package/scripts/topo-freshness-chip-fade-test.mjs +105 -0
  167. package/scripts/topo-fullscreen-attr-test.mjs +73 -0
  168. package/scripts/topo-grid-content-bottom-attr-test.mjs +72 -0
  169. package/scripts/topo-hub-digit-ls-test.mjs +119 -0
  170. package/scripts/topo-hub-highlight-amplify-test.mjs +139 -0
  171. package/scripts/topo-hub-highlight-fill-transition-test.mjs +84 -0
  172. package/scripts/topo-hub-highlight-recede-test.mjs +144 -0
  173. package/scripts/topo-hub-highlight-theme-fill-test.mjs +83 -0
  174. package/scripts/topo-hub-idle-breath-test.mjs +109 -0
  175. package/scripts/topo-hub-recede-test.mjs +124 -0
  176. package/scripts/topo-layout-hover-fw-test.mjs +98 -0
  177. package/scripts/topo-legend-count-letter-spacing-test.mjs +108 -0
  178. package/scripts/topo-nodesize-hover-fw-test.mjs +99 -0
  179. package/scripts/topo-orphan-box-dash-test.mjs +89 -0
  180. package/scripts/topo-orphan-fill-opacity-test.mjs +91 -0
  181. package/scripts/topo-orphan-label-italic-test.mjs +90 -0
  182. package/scripts/topo-pinned-aspect-test.mjs +89 -0
  183. package/scripts/topo-pressure-seg-motion-test.mjs +101 -0
  184. package/scripts/topo-recent-hot-pulse-test.mjs +102 -0
  185. package/scripts/topo-recent-more-fw-test.mjs +126 -0
  186. package/scripts/topo-reduced-motion-attr-test.mjs +69 -0
  187. package/scripts/topo-reset-icon-hover-scale-test.mjs +102 -0
  188. package/scripts/topo-starfield-hue-test.mjs +109 -0
  189. package/scripts/topo-vendor-activelinks-press-test.mjs +100 -0
  190. package/scripts/topo-watermark-breath-test.mjs +100 -0
  191. package/scripts/topo-watermark-recede-test.mjs +114 -0
  192. package/scripts/topo-zoom-level-color-test.mjs +105 -0
  193. package/.next/static/chunks/05_0r-qhmggvj.css +0 -2
  194. package/.next/static/chunks/072cwlbf~3a0t.js +0 -1
  195. package/.next/static/chunks/0a~3lmgl2.3sm.js +0 -4
  196. package/.next/static/chunks/0t_.58jc2y.3r.js +0 -1
  197. /package/.next/static/{_cPig_Uyv0NsISAzU7MHR → yehN0b3PBTLD_A8kaTDUx}/_buildManifest.js +0 -0
  198. /package/.next/static/{_cPig_Uyv0NsISAzU7MHR → yehN0b3PBTLD_A8kaTDUx}/_clientMiddlewareManifest.js +0 -0
  199. /package/.next/static/{_cPig_Uyv0NsISAzU7MHR → yehN0b3PBTLD_A8kaTDUx}/_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,50 @@ 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
+ /* Round 522 / Loop — extends R521's typography-preview
1989
+ idiom (chrome nodeSize hover:font-medium 400 → 500) to
1990
+ the Ring/Grid layout toggle's inactive variant. Pre-
1991
+ R522 the inactive Ring/Grid had `hover:text-cyan-300
1992
+ hover:bg-cyan-500/5` (R270 color + bg previews of the
1993
+ active state) but no typography preview — the active
1994
+ variant uses `font-medium` (fw 500), inactive sat at
1995
+ default fw 400 even on hover. R522 adds `hover:font-
1996
+ medium` to the inactive Ring/Grid so the rest-vs-hover
1997
+ transition previews the typography state the click
1998
+ would commit to, matching the click commits's locked
1999
+ weight.
2000
+ font-weight 150ms appended to the transition list
2001
+ matching the existing 150ms color/bg cadence at this
2002
+ button — when hover lifts color (gray-400 → cyan-300)
2003
+ + bg (transparent → cyan-500/5) + fw (400 → 500), all
2004
+ 3 ease at the same 150ms beat.
2005
+ Hover-fw family extension (6 anchors): R416/R420/R425/
2006
+ R520/R521/R522. R522 closes the chrome toggle group
2007
+ typography preview at the last remaining toggle —
2008
+ layout (Ring/Grid). After R521 (nodeSize) + R522
2009
+ (layout), every multi-state chrome toggle has hover-
2010
+ fw preview on its inactive variant.
2011
+ data-topo-chrome-layout-hover-preview-fw="500" attr
2012
+ on inactive button exposes the polish for tests. */
2013
+ 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 hover:font-medium'} ${chromePopping === 'layout-ring' ? ' anet-chrome-pop' : ''}`}
2014
+ data-topo-chrome-layout-hover-preview-fw={layout === 'ring' ? null : '500'}
2015
+ style={{ transition: 'background-color 150ms ease, color 150ms ease, letter-spacing 200ms ease-out, transform 150ms ease-out, font-weight 150ms ease' }}
1948
2016
  >
1949
2017
  Ring
1950
2018
  </button>
@@ -1963,7 +2031,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1963
2031
  // all chrome buttons.
1964
2032
  // R351 sibling — Grid button picks up hover:tracking-wide
1965
2033
  // + 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' : ''}`}
2034
+ // R492 sibling Grid button picks up active:scale-95
2035
+ // press feedback + transform in transition list. Same
2036
+ // vocabulary as Ring above.
2037
+ /* Round 522 sibling — Grid button mirrors Ring above:
2038
+ inactive variant gains `hover:font-medium` typography
2039
+ preview + font-weight 150ms in inline transition list.
2040
+ Same idiom, same family (R522 chrome layout). */
2041
+ 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 hover:font-medium'} ${chromePopping === 'layout-grid' ? ' anet-chrome-pop' : ''}`}
2042
+ data-topo-chrome-layout-hover-preview-fw={layout === 'grid' ? null : '500'}
1967
2043
  /* Round 268 / Loop: Grid button's left border (the
1968
2044
  internal divider between Ring and Grid) picks up
1969
2045
  pal.containerBorder, matching the wrapper change at
@@ -1973,8 +2049,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1973
2049
  transition list into the inline spec below so the
1974
2050
  letter-spacing tween rides alongside without snapping
1975
2051
  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' }}
2052
+ keeps R268's theme-toggle smoothness intact.
2053
+ R492 adds `transform 150ms ease-out` so active:scale-95
2054
+ eases smoothly. */
2055
+ 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, font-weight 150ms ease' }}
1978
2056
  >
1979
2057
  Grid
1980
2058
  </button>
@@ -2091,9 +2169,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2091
2169
  // to R355 filter pin pill inner-span hover-brighten.
2092
2170
  // Hover-brighten family extends from filter pills to
2093
2171
  // chip-row chips at the inner-span scope.
2172
+ // Round 494 / Loop — chip-row working chip joins the
2173
+ // active:scale-95 press-feedback family (R492 Ring/Grid +
2174
+ // R493 chrome-strip rest). Gated on the clickable branch
2175
+ // (workingCount > 0) — when the chip is a placeholder
2176
+ // at count=0, scale-95 stays off to match the existing
2177
+ // R398 hover-lift conditional. Composes with hover:-
2178
+ // translate-y-px for the same lift-and-compress
2179
+ // tactile signature R493 brought to reset/fullscreen.
2094
2180
  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
2181
  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'
2182
+ ? '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
2183
  : 'bg-green-500/10 text-green-300 border-green-500/20'
2098
2184
  }`}
2099
2185
  data-chip-hover-lift={workingCount > 0 ? 'true' : 'false'}
@@ -2193,9 +2279,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2193
2279
  same digit-jitter physics on count crossings). */
2194
2280
  // R398: hover translate-y lift on clickable variant — see working chip above.
2195
2281
  // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
2282
+ // R494 sibling — online chip joins the active:scale-95 press
2283
+ // family (gated on onlineNodes.length > 0 clickable branch,
2284
+ // same conditional pattern as the working chip above).
2196
2285
  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
2286
  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'
2287
+ ? '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
2288
  : 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20'
2200
2289
  }`}
2201
2290
  data-chip-hover-lift={onlineNodes.length > 0 ? 'true' : 'false'}
@@ -2470,7 +2559,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2470
2559
  // R355: `group` lets the inner opacity-70 spans (prefix
2471
2560
  // `filter:` + count `· N`) brighten to 100 % on pill hover.
2472
2561
  // 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"
2562
+ // R495 filter pills (3 sibling `group` variants) join the
2563
+ // active:scale-95 press-feedback family. R490's !important
2564
+ // transition list on .anet-topo-chip-focus already covers
2565
+ // transform, so just appending active:scale-95 to the
2566
+ // className wires the press tactile in one token. Compound
2567
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2568
+ 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
2569
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2475
2570
  onClick={() => setPinnedStatus(null)}
2476
2571
  style={{
@@ -2534,7 +2629,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2534
2629
  data-filter-match-count={matchCount}
2535
2630
  data-filter-match-aliases={matchAliases.join(',')}
2536
2631
  // 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"
2632
+ // R495 filter pills (3 sibling `group` variants) join the
2633
+ // active:scale-95 press-feedback family. R490's !important
2634
+ // transition list on .anet-topo-chip-focus already covers
2635
+ // transform, so just appending active:scale-95 to the
2636
+ // className wires the press tactile in one token. Compound
2637
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2638
+ 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
2639
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2539
2640
  onClick={() => setPinnedGroup(null)}
2540
2641
  style={{
@@ -2600,7 +2701,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2600
2701
  data-filter-match-count={matchCount}
2601
2702
  data-filter-match-aliases={matchAliases.join(',')}
2602
2703
  // 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"
2704
+ // R495 filter pills (3 sibling `group` variants) join the
2705
+ // active:scale-95 press-feedback family. R490's !important
2706
+ // transition list on .anet-topo-chip-focus already covers
2707
+ // transform, so just appending active:scale-95 to the
2708
+ // className wires the press tactile in one token. Compound
2709
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2710
+ 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
2711
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
2605
2712
  onClick={() => setPinnedVendor(null)}
2606
2713
  style={{
@@ -2663,7 +2770,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2663
2770
  data-filter-match-count={link.count}
2664
2771
  data-filter-match-aliases={`${link.from},${link.to}`}
2665
2772
  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"
2773
+ // R495 sibling 4th filter pill (no `group` prefix variant)
2774
+ // joins active:scale-95 press family alongside the 3 group
2775
+ // variants above. Same recipe.
2776
+ 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
2777
  title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
2668
2778
  onClick={() => setPinnedEdgeKey(null)}
2669
2779
  style={{
@@ -3013,7 +3123,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3013
3123
  // — sibling to R355 filter-pill prefix/suffix + R414
3014
3124
  // chip-row unit brighten. Closes the inner-span
3015
3125
  // 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"
3126
+ // R496 vendor letter chip joins active:scale-95 press
3127
+ // family. Last vendor-row clickable joining the family
3128
+ // R495 cashed via R490's transition-cascade dividend.
3129
+ // Same compound w/ R401 hover-lift idiom — lift-and-
3130
+ // compress on press, springs back on release.
3131
+ 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
3132
  data-vendor-letter={v.initial}
3018
3133
  data-vendor-letter-count={v.count}
3019
3134
  data-vendor-letter-hover-lift="true"
@@ -3251,9 +3366,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3251
3366
  data-chip-hover-lift attr exposes the lift surface
3252
3367
  state ('true' clickable, 'false' empty) for tests. */
3253
3368
  // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
3369
+ // R496 — active-links chip joins active:scale-95 press
3370
+ // family. Sibling to working+online chips (R494). Gated
3371
+ // on `isInteractive` (flowLinks.length > 0) — same R399
3372
+ // conditional pattern used for hover-lift.
3254
3373
  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
3374
  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'
3375
+ ? '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
3376
  : 'bg-gray-500/10 text-gray-400 border-gray-500/20'
3258
3377
  }`}
3259
3378
  data-chip-hover-lift={isInteractive ? 'true' : 'false'}
@@ -3500,6 +3619,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3500
3619
  on the canvas root for non-visual consumers.
3501
3620
  Composed from existing onlineNodes / workingCount /
3502
3621
  offlineNodes / flowLinks — no new state. */
3622
+ /* Round 502 / Loop — categorical density-tier paired with the
3623
+ R469 numeric counts. data-topo-fleet-density-tier classifies
3624
+ the fleet size into 5 buckets so external consumers (CSS
3625
+ selectors, Playwright probes, future density-conditional
3626
+ polish gates like R109 dense-label collapse at 16+ nodes)
3627
+ can branch on a stable tier name without re-deriving the
3628
+ threshold logic from the raw numeric. Buckets:
3629
+ 'empty' — onlineNodes.length === 0
3630
+ 'sparse' — 1-3 nodes
3631
+ 'normal' — 4-15 nodes
3632
+ 'dense' — 16-30 nodes (matches R109 collapse gate)
3633
+ 'very-dense' — 31+ nodes
3634
+ Picks the gate boundaries that already drive CONDITIONAL
3635
+ RENDER decisions elsewhere (R109 denseLayout = >16, R110
3636
+ plain-text fallback) so the tier name is semantically
3637
+ aligned with the visual mode the canvas already switches
3638
+ to. Composed from existing onlineNodes — no new state.
3639
+ 12th attr in the canvas state surface set (R462/R466/R467/
3640
+ R469×4/R471×2/R487/R488/R502). 12 attrs covers: build
3641
+ identity, transient/sticky inspection modes, fleet split
3642
+ numerics, fleet density tier, canvas layout/theme, canvas
3643
+ zoom, hover identity. A test harness can snapshot the
3644
+ full canvas state with 12 getAttribute calls. */
3645
+ data-topo-fleet-density-tier={
3646
+ onlineNodes.length === 0 ? 'empty' :
3647
+ onlineNodes.length <= 3 ? 'sparse' :
3648
+ onlineNodes.length <= 15 ? 'normal' :
3649
+ onlineNodes.length <= 30 ? 'dense' :
3650
+ 'very-dense'
3651
+ }
3503
3652
  data-topo-online-count={onlineNodes.length}
3504
3653
  data-topo-working-count={workingCount}
3505
3654
  data-topo-offline-count={offlineNodes.length}
@@ -3565,6 +3714,119 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3565
3714
  categorical) — separate dedicated attrs if/when needed.
3566
3715
  Root svg attribute set now 11 attrs total. */
3567
3716
  data-topo-hovered-alias={hoveredAlias ?? ''}
3717
+ /* Round 504 / Loop — categorical pin-aspect attr paired with
3718
+ R467 any-pinned boolean and R488 hovered-alias identity.
3719
+ Pre-R504 the canvas state surface set told tests WHETHER
3720
+ any pin was active (R467 boolean) but tests had to enumerate
3721
+ 4 individual state vars to determine WHICH pin axis fired:
3722
+ pinnedStatus legend-row status filter
3723
+ pinnedGroup prefix-cluster lock
3724
+ pinnedVendor vendor-chip filter
3725
+ pinnedEdgeKey edge-focus
3726
+ R504 surfaces the active aspect as a single categorical
3727
+ attribute: data-topo-pinned-aspect ∈
3728
+ 'none' no pin active
3729
+ 'status' pinnedStatus only
3730
+ 'group' pinnedGroup only
3731
+ 'vendor' pinnedVendor only
3732
+ 'edge' pinnedEdgeKey only
3733
+ 'multi' 2 or more pins active simultaneously
3734
+ ('multi' covers cross-cutting filters — e.g. user pins
3735
+ status='working' AND vendor='claude' simultaneously to
3736
+ narrow the canvas. Each pin axis is independently
3737
+ dismissable via Esc / individual chip click, so multi
3738
+ states are reachable and worth surfacing as a distinct
3739
+ tier.)
3740
+ 13th attr in the canvas state surface set after R502.
3741
+ Composed from 4 existing state vars — no new state. */
3742
+ /* Round 512 / Loop — 14th canvas state attr. groupBoxes.length
3743
+ surfaces the count of cluster boxes currently rendered in
3744
+ grid layout (always 0 in ring). Paired with R502 categorical
3745
+ density tier + R469 fleet numerics for a complete cluster-
3746
+ cardinality surface:
3747
+ R469 data-topo-online-count node-count
3748
+ R502 data-topo-fleet-density-tier categorical
3749
+ R512 data-topo-cluster-count cluster-count ← this round
3750
+ Use cases:
3751
+ - Playwright: assert orphan-band existence by
3752
+ `cluster-count === N + 1` vs prefix-only `=== N`
3753
+ - external CSS: `[data-topo-cluster-count='1']` to apply
3754
+ single-cluster grid-specific layout adjustments
3755
+ - future polish gates: cluster-count > N could trigger
3756
+ dense-grid mode
3757
+ Composed from existing `groupBoxes.length` — no new state.
3758
+ Always renders (0 in ring layout, N in grid), so tests can
3759
+ rely on attribute presence + value. */
3760
+ data-topo-cluster-count={groupBoxes.length}
3761
+ /* Round 513 / Loop — 15th canvas state attr. Surfaces the
3762
+ user's prefers-reduced-motion preference directly on the
3763
+ root SVG so external CSS / Playwright tests can branch on
3764
+ a11y state without re-reading the media query.
3765
+ reducedMotion is already in component scope (R29 a11y
3766
+ blanket reads it via a useEffect listener); R513 just
3767
+ exposes it as a stable attribute handle.
3768
+ Use cases:
3769
+ - Playwright: assert reduced-motion gates from one attr
3770
+ read instead of mocking media-query state per test
3771
+ - External CSS hooks: `[data-topo-prefers-reduced-motion=
3772
+ "true"]` to apply paint-only overrides (e.g. mute
3773
+ hover glows entirely on a11y instead of just
3774
+ disabling transitions)
3775
+ - Future polish rounds: any motion-gated render can
3776
+ read this attr server-side without the media-query
3777
+ hydration mismatch risk
3778
+ 'true' / 'false' string values (consistent with R466/R467
3779
+ boolean attrs). */
3780
+ data-topo-prefers-reduced-motion={reducedMotion ? 'true' : 'false'}
3781
+ /* Round 515 / Loop — 16th canvas state attr. Surfaces the
3782
+ fullscreen-mode state directly on root SVG so external
3783
+ consumers don't have to traverse the chrome strip's
3784
+ `data-topo-chrome-fullscreen-active` button attr (which
3785
+ measures the BUTTON state, not the canvas state — they
3786
+ agree, but reading from the root is semantically cleaner
3787
+ for canvas-state probes).
3788
+ Composed from existing isFullscreen React state (R103
3789
+ fullscreen toggle).
3790
+ Use cases:
3791
+ - Playwright: assert canvas mode in one attr read
3792
+ (paired with R471 data-topo-layout for ring/grid +
3793
+ R487 data-topo-zoom for zoom level + R513 reduced-
3794
+ motion for a11y mode = 4-axis canvas-mode probe)
3795
+ - External CSS: `[data-topo-fullscreen="true"]` to
3796
+ apply fullscreen-only paint adjustments outside the
3797
+ React tree (e.g. body-level scrollbar hide)
3798
+ 'true' / 'false' string values (consistent with R466/
3799
+ R467/R513 boolean attrs). */
3800
+ data-topo-fullscreen={isFullscreen ? 'true' : 'false'}
3801
+ /* Round 516 / Loop — 17th canvas state attr. Surfaces the
3802
+ grid layout's content-bottom y-coordinate so tests can
3803
+ verify grid content doesn't extend past the viewBox or
3804
+ collide with chrome elements positioned below the canvas.
3805
+ Composed from existing gridContentBottom derived state
3806
+ (computed at line ~915 from gy0 + totalRows * cellH + 8).
3807
+ In ring layout, gridContentBottom is 0 (no grid). In grid
3808
+ layout it's the actual pixel y-coordinate where the
3809
+ cluster bands end.
3810
+ Use cases:
3811
+ - Playwright: assert grid layout doesn't exceed viewBox
3812
+ height (680) without re-computing the layout math
3813
+ - External CSS: `[data-topo-grid-content-bottom='0']` to
3814
+ distinguish ring-mode (no grid content) from grid-mode
3815
+ in CSS without parsing layout attr
3816
+ - Future polish gates: if cluster count grows large
3817
+ enough to push grid bottom past viewBox, can trigger
3818
+ a 'compact' mode automatically */
3819
+ data-topo-grid-content-bottom={gridContentBottom}
3820
+ data-topo-pinned-aspect={(() => {
3821
+ const aspects: string[] = [];
3822
+ if (pinnedStatus) aspects.push('status');
3823
+ if (pinnedGroup) aspects.push('group');
3824
+ if (pinnedVendor) aspects.push('vendor');
3825
+ if (pinnedEdgeKey) aspects.push('edge');
3826
+ if (aspects.length === 0) return 'none';
3827
+ if (aspects.length === 1) return aspects[0];
3828
+ return 'multi';
3829
+ })()}
3568
3830
  /* Round 466 / Loop — aggregate hover signal on the root SVG.
3569
3831
  Exposes a single boolean `data-topo-any-hover` that
3570
3832
  reflects whether ANY hover state in the topology is
@@ -3807,7 +4069,38 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3807
4069
  const x = ((seed * 13) % 1000);
3808
4070
  const y = ((seed * 7) % 680);
3809
4071
  const r = (i % 3 === 0) ? 1.2 : 0.7;
3810
- return <circle key={i} cx={x} cy={y} r={r} fill="#a5b4fc" opacity={0.35 + (i % 4) * 0.05} data-topo-starfield-dot={i} />;
4072
+ /* Round 523 / Loop 配色 family extension to a 3rd anchor.
4073
+ Pre-R523 all 14 starfield dots painted at the same
4074
+ hardcoded `#a5b4fc` (indigo-300). The starfield's role
4075
+ is atmospheric depth (R45, R291 comment), but a flat
4076
+ single-hue field reads more like a regular dot grid
4077
+ than a star field — real starlight has color
4078
+ temperature variation (blue-white hot stars / yellow
4079
+ sun-like / cool red).
4080
+ R523 cycles a 3-color deterministic rotation based on
4081
+ `i % 3`:
4082
+ i % 3 === 0 → #a5b4fc indigo-300 (original, cool)
4083
+ i % 3 === 1 → #67e8f9 cyan-300 (cyber accent, hot)
4084
+ i % 3 === 2 → #cbd5e1 slate-300 (neutral, warm white)
4085
+ All three hues sit inside the cyber theme's palette
4086
+ family (indigo / cyan / slate) so the starfield reads
4087
+ varied-but-coherent rather than rainbow. At opacity
4088
+ 0.5 (parent <g>) * 0.35-0.50 (per-dot) the temperature
4089
+ shifts are gentle but perceptible — closes the gap
4090
+ between 'dot grid' and 'star field'.
4091
+ 配色 family extension (3 anchors): R509/R510 hub-
4092
+ highlight cross-theme fill + R523 starfield color
4093
+ temperature variation. Light theme unaffected
4094
+ (starfield gated `!isLight` so light theme stays
4095
+ clean per R45's original 'white surface stays clean'
4096
+ intent).
4097
+ Deterministic on `i` — no JS hydration mismatch,
4098
+ same SSR/client output. data-topo-starfield-dot-hue
4099
+ attr exposes the resolved hue category for tests. */
4100
+ const hues = ['#a5b4fc', '#67e8f9', '#cbd5e1'] as const;
4101
+ const hueNames = ['indigo', 'cyan', 'slate'] as const;
4102
+ const hueIdx = i % 3;
4103
+ return <circle key={i} cx={x} cy={y} r={r} fill={hues[hueIdx]} opacity={0.35 + (i % 4) * 0.05} data-topo-starfield-dot={i} data-topo-starfield-dot-hue={hueNames[hueIdx]} />;
3811
4104
  })}
3812
4105
  </g>
3813
4106
  )}
@@ -4494,12 +4787,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4494
4787
  // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
4495
4788
  // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
4496
4789
  // 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)}
4790
+ /* Round 506 / Loop category-differentiation family
4791
+ 3rd anchor. Orphan band rest-state fillOpacity drops
4792
+ slightly below prefix-group rest (0.025/0.045
4793
+ 0.015/0.028). Adds a 3rd independent paint
4794
+ differentiator to the orphan visual signature:
4795
+ R499 fontStyle: italic (label text)
4796
+ R503 '3 6' dash pattern (rect stroke)
4797
+ R506 lower fillOpacity (rect fill) ← this round
4798
+ Three independent channels (typography + stroke
4799
+ pattern + fill density) collectively encode the
4800
+ catchall semantic at rest. Pin and hover branches
4801
+ UNCHANGED (still 0.05/0.09 hover, 0.08/0.13 pin) —
4802
+ orphan box gets full visual emphasis on inspection
4803
+ identical to prefix groups; the differentiation
4804
+ lives ONLY in the unsolicited rest state. The
4805
+ ~40% drop (0.045 → 0.028 cyber, 0.025 → 0.015
4806
+ light) is subtle enough that the orphan box stays
4807
+ visible at rest, just quieter — matches the
4808
+ "misc bucket, less attention-deserving" semantic
4809
+ without losing the visual anchor.
4810
+ Pure paint axis; bbox unchanged; R51 SVG sentinel
4811
+ safety untouched (overlap-test gates to g[data-
4812
+ node], cluster rect invisible to it).
4813
+ data-group-box-fill-opacity attr surfaces the
4814
+ resolved value for tests. */
4815
+ fillOpacity={
4816
+ isPinned ? (isLight ? 0.08 : 0.13)
4817
+ : isHovered ? (isLight ? 0.05 : 0.09)
4818
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4819
+ : (isLight ? 0.025 : 0.045)
4820
+ }
4821
+ data-group-box-fill-opacity={
4822
+ isPinned ? (isLight ? 0.08 : 0.13)
4823
+ : isHovered ? (isLight ? 0.05 : 0.09)
4824
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4825
+ : (isLight ? 0.025 : 0.045)
4826
+ }
4500
4827
  stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
4501
4828
  strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
4502
- strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
4829
+ /* Round 503 / Loop category-differentiation family
4830
+ 2nd anchor (R499 = orphan label italic, 1st anchor).
4831
+ Orphan band rest-state strokeDasharray switches from
4832
+ '6 6' (prefix-group default) to '3 6' (tighter
4833
+ dashes). Pre-R503 the rect dash pattern was uniform
4834
+ across all bands; combined with R499's italic label,
4835
+ the orphan box now has TWO independent paint/
4836
+ typography differentiators at rest:
4837
+ R499 fontStyle: italic (label text)
4838
+ R503 '3 6' dash pattern (rect stroke) ← this round
4839
+ The R85 marching-ants animation continues to work
4840
+ with the new dash size (uses --march-dur custom
4841
+ property, dash-length-agnostic) — orphan's ants
4842
+ just have a different visual rhythm than prefix-
4843
+ group ants, reinforcing the catchall semantic.
4844
+ Pinned/hovered orphan still gets 'none' (solid
4845
+ stroke) so the hover/pin affordance is preserved
4846
+ — the differentiation lives ONLY in the rest
4847
+ state, never blocking inspection.
4848
+ Pure paint axis; no geometry change; bbox unchanged
4849
+ (strokeDasharray is paint-only). R51 SVG sentinel
4850
+ safety untouched (overlap-test gates to g[data-
4851
+ node], this cluster rect is invisible to it).
4852
+ data-group-box-orphan attr surfaces the gate for
4853
+ tests + future polish references. */
4854
+ strokeDasharray={
4855
+ (isPinned || isHovered) ? 'none' :
4856
+ box.isOrphan ? '3 6' : '6 6'
4857
+ }
4858
+ data-group-box-orphan={box.isOrphan ? 'true' : 'false'}
4503
4859
  /* Round 380 / Loop: cluster box stroke gets round
4504
4860
  linecap + round linejoin. Sibling SVG stroke-
4505
4861
  softening polish to R378 flow-rail linecap + R379
@@ -4844,16 +5200,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4844
5200
  ease-out' alongside the existing fill/ls/fw/opacity
4845
5201
  200ms tweens. */
4846
5202
  data-group-label-glow={isPinned ? 'true' : 'false'}
5203
+ /* Round 499 / Loop — orphan band "其他" label gets
5204
+ fontStyle: italic to visually distinguish the
5205
+ catchall from real prefix-group bands. Pre-R499
5206
+ the orphan box label rendered identically to
5207
+ prefix-group labels (Hero D fontSize=9, fw=700,
5208
+ opacity 0.55 rest), so users had to read the
5209
+ literal text "其他" to identify the catchall. R499
5210
+ adds a pure-typography differentiation: italic
5211
+ signals "this is the misc bucket, not a real
5212
+ named group" while preserving full opacity
5213
+ affordance on hover/pin — the orphan box stays
5214
+ equally inspectable, just typographically marked
5215
+ as a different category. No geometry change
5216
+ (italic shifts glyph slant within the same bbox),
5217
+ no opacity loss, no behavior change. Sibling to
5218
+ R432 letter-spacing 3-tier + R457 pin fw-lift +
5219
+ R479 pin drop-shadow at the group-label scope.
5220
+ Falls under 配色 / 节点视觉 themes per the prompt;
5221
+ advances the "信息密度" axis by encoding
5222
+ category-distinction into a single typography
5223
+ channel without adding visual chrome. */
4847
5224
  style={{
4848
5225
  transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
4849
5226
  letterSpacing: isPinned ? '0.5px' :
4850
5227
  isHovered ? '0.25px' : '0px',
5228
+ fontStyle: box.isOrphan ? 'italic' : undefined,
4851
5229
  filter: isPinned
4852
5230
  ? `drop-shadow(0 0 3px ${pal.legendAccent}80)`
4853
5231
  : undefined,
4854
5232
  }}
4855
5233
  data-group-label={box.key}
4856
5234
  data-group-label-pinned={isPinned ? 'true' : 'false'}
5235
+ data-group-label-orphan={box.isOrphan ? 'true' : 'false'}
4857
5236
  >
4858
5237
  {box.key}
4859
5238
  {/* Round 19 / Loop: member-count chip. Inline tspan stays
@@ -6468,22 +6847,91 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6468
6847
  so the glow eases under the same cadence as the
6469
6848
  scale + fw + fill axes. */
6470
6849
  data-topo-hub-working-count-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6850
+ /* Round 507 / Loop — focal recede. When ANY non-hub
6851
+ canvas surface is hovered (a node / an edge / a
6852
+ group label / a legend row / a vendor chip), the
6853
+ hub-center workingCount digit fades to 0.85 opacity,
6854
+ signaling "you're inspecting elsewhere, hub recedes
6855
+ to background." When the user un-hovers (or hovers
6856
+ the hub itself), opacity returns to 1.0. Pure paint
6857
+ polish at the canvas's most prominent focal point.
6858
+ Hits 信息密度 + 动效 themes — the hub digit gives
6859
+ way visually to the surface under inspection,
6860
+ reinforcing the "this is the focal point right now"
6861
+ gesture without requiring users to track which
6862
+ surface holds attention.
6863
+ Gate excludes hoveredHub specifically: hovering the
6864
+ hub itself should LIFT the digit (R425 fw bump +
6865
+ R476 glow + R209 scale 1.08) — the existing hover-
6866
+ on-hub signature is intact; only inspection
6867
+ ELSEWHERE recedes the hub.
6868
+ Composed from existing hoveredAlias / hoveredEdge-
6869
+ Key / hoveredGroupLabel / hoveredStatus / hovered-
6870
+ Vendor — no new state. 300ms ease-out opacity
6871
+ transition already in the style list (existing R213
6872
+ transition spec), so the fade rides on existing
6873
+ infrastructure.
6874
+ data-topo-hub-recede attr surfaces the gate state
6875
+ for tests. */
6876
+ data-topo-hub-recede={
6877
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6878
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
6879
+ }
6880
+ /* Round 527 / Loop — focal-amplify family extension to a
6881
+ 2nd anchor. R511 introduced focal-amplify at the hub-
6882
+ highlight disc (base opacity 0.95 → 1.0 on hover); R527
6883
+ extends to the hub-center workingCount digit with a
6884
+ letter-spacing tween 0 → 0.3px on hub-hover.
6885
+ Composes with existing 3-axis hub-hover signature on
6886
+ this element:
6887
+ R209 transform scale(1.08) geometry
6888
+ R425 fontWeight 700 → 800 typography weight
6889
+ R476 filter drop-shadow glow paint
6890
+ R527 letter-spacing 0 → 0.3px typography kerning ← this round
6891
+ tabular-nums (R225) preserved — each digit cell keeps
6892
+ fixed width; the inter-digit advance grows by 0.3px
6893
+ per gap. Single-digit counts (1-9) show no kerning
6894
+ effect; multi-digit counts (10+) show the spread as
6895
+ info-density signaling. Sibling to R427/R431/R432/
6896
+ R433/R434 (hover-letter-spacing family at panel-text
6897
+ scope) — R527 brings the same idiom to the canvas's
6898
+ most-read scalar.
6899
+ Reduced-motion gate matches R209 scale, R425 fw, R476
6900
+ filter — !reducedMotion gates the lift; reducedMotion
6901
+ users see static digit baseline regardless of hover.
6902
+ Focal-amplify family extension (2 anchors): R511 hub-
6903
+ highlight opacity / R527 hub-digit letter-spacing.
6904
+ transition list extends to include `letter-spacing
6905
+ 200ms ease-out`, matching the cadence of the other
6906
+ hub-hover axes. data-topo-hub-working-count-letter-
6907
+ spacing attr exposes the resolved value for tests. */
6908
+ data-topo-hub-working-count-letter-spacing={!reducedMotion && hoveredHub ? '0.3px' : '0px'}
6471
6909
  style={{
6472
6910
  pointerEvents: 'none',
6473
6911
  transform: !reducedMotion && hoveredHub ? 'scale(1.08)' : 'scale(1)',
6474
6912
  transformBox: 'fill-box',
6475
6913
  transformOrigin: 'center',
6914
+ opacity: (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6915
+ hoveredStatus || hoveredVendor) && !hoveredHub
6916
+ ? 0.85
6917
+ : 1,
6476
6918
  filter: !reducedMotion && hoveredHub
6477
6919
  ? (isLight
6478
6920
  ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.6))'
6479
6921
  : 'drop-shadow(0 0 3px rgba(52, 211, 153, 0.6))')
6480
6922
  : undefined,
6923
+ letterSpacing: !reducedMotion && hoveredHub ? '0.3px' : '0px',
6481
6924
  /* R425: font-weight 200ms appended so the hover fw
6482
6925
  bump 700 → 800 eases under the same cadence as
6483
6926
  R209 scale + R253 fill + R213 opacity.
6484
6927
  R476: filter 200ms appended so the new drop-
6485
- shadow glow eases at the same cadence. */
6486
- transition: 'transform 200ms ease-out, opacity 300ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, filter 200ms ease-out',
6928
+ shadow glow eases at the same cadence.
6929
+ R507: opacity 300ms (existing in list) covers
6930
+ the new focal-recede fade.
6931
+ R527: letter-spacing 200ms appended so the new
6932
+ hover-kerning bump eases at the same cadence
6933
+ as the other axes. */
6934
+ transition: 'transform 200ms ease-out, opacity 300ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, filter 200ms ease-out, letter-spacing 200ms ease-out',
6487
6935
  fontVariantNumeric: 'tabular-nums',
6488
6936
  }}
6489
6937
  >
@@ -6529,19 +6977,138 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6529
6977
  + R213 always-mount opacity-gate + pointerEvents:none
6530
6978
  + R365 r=5.5 all preserved. data-topo-hub-highlight-
6531
6979
  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
- />
6980
+ {/* Round 508 / Loop — focal-recede pattern 2nd anchor.
6981
+ Extends R507's hub-digit recede to the hub-highlight
6982
+ circle so the hub focal CLUSTER (digit at z-top + this
6983
+ idle-state highlight beneath) recedes as a unit when
6984
+ canvas attention is elsewhere. Computed once: a single
6985
+ non-hub-hover gate drives BOTH the digit (R507) AND
6986
+ this highlight (R508) so they always co-move.
6987
+ Recede multiplies the visible opacity by 0.85 — when
6988
+ workingCount===0 the rest opacity 0.95 becomes 0.81
6989
+ during external-hover; when workingCount>0 the
6990
+ opacity stays 0 (invisible) regardless of recede.
6991
+ Additionally, when recede is active the SMIL breath
6992
+ animation halts (animate node un-mounts) so the
6993
+ receded state reads as quietly static, not pulsing
6994
+ at 0.85↔1.0 against the recede multiplier (which
6995
+ would visually conflict — competing 15% drops). On
6996
+ un-hover the animate re-mounts and breath resumes.
6997
+ data-topo-hub-recede on both digit AND highlight
6998
+ provides a stable test handle for the unified-recede
6999
+ gate.
7000
+ Composed from existing hover state vars — no new
7001
+ state. Pure paint axis. */}
7002
+ {(() => {
7003
+ const hubRecede = !!((hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
7004
+ hoveredStatus || hoveredVendor) && !hoveredHub);
7005
+ /* Round 511 / Loop — hub-highlight gains a 3rd opacity tier
7006
+ (hovered-amplify). Pre-R511 the highlight had 2 visible
7007
+ states: rest (0.95) and recede (0.81 = 0.95 × 0.85).
7008
+ When the hub itself was hovered, the digit got R425 fw
7009
+ lift + R476 drop-shadow + R209 scale-1.08, but the
7010
+ highlight disc sibling stayed at 0.95 — the focal
7011
+ cluster lifted in 3 channels (typography/paint/scale)
7012
+ but the highlight didn't participate.
7013
+ R511 closes that asymmetry: when hoveredHub is true,
7014
+ highlight base opacity lifts to 1.0 (5% boost from
7015
+ rest 0.95). Cluster now lifts as a unit on hub-hover,
7016
+ just like it recedes as a unit on non-hub-hover
7017
+ (R508).
7018
+ 3-state opacity ladder:
7019
+ hub-hovered: baseOpacity = 1.0 (R511 NEW)
7020
+ rest (no hover): baseOpacity = 0.95 (existing)
7021
+ non-hub canvas hov: baseOpacity × 0.85 = 0.81 (R508)
7022
+ Composes cleanly: hubRecede gate requires !hoveredHub,
7023
+ so the hovered-amplify and recede states are mutually
7024
+ exclusive (they can't both fire). breathActive
7025
+ continues to halt on either non-rest state (recede OR
7026
+ hub-hover would visually compete with the 0.85↔1
7027
+ breath — clean for the unit-lift semantic too). */
7028
+ const baseOpacity = workingCount > 0 ? 0
7029
+ : hoveredHub ? 1.0
7030
+ : 0.95;
7031
+ const resolvedOpacity = hubRecede ? baseOpacity * 0.85 : baseOpacity;
7032
+ const breathActive = !reducedMotion && workingCount === 0 && !hubRecede && !hoveredHub;
7033
+ return (
7034
+ <circle
7035
+ cx={cx} cy={cy} r="5.5"
7036
+ /* Round 509 / Loop — 配色 cross-theme cleanup. Pre-R509
7037
+ the hub-highlight fill was hardcoded `#d1fae5`
7038
+ (emerald-100, a pale tone). On the light theme this
7039
+ near-white green ran against a pale background at
7040
+ 0.95 opacity — the disc was effectively invisible.
7041
+ Matches the existing R253 halo theme-inversion
7042
+ pattern (line ~6481): light theme picks the dark
7043
+ vibrant emerald (#10b981 emerald-600), dark theme
7044
+ keeps the pale emerald (#d1fae5 emerald-100). Both
7045
+ read at the same 0.95 opacity against their
7046
+ respective backdrops — light gets a saturated
7047
+ focal dot; dark keeps the soft glow signature.
7048
+ Pure paint axis (fill change only); bbox unchanged;
7049
+ R51 SVG sentinel safety untouched.
7050
+ transition list already includes `fill 200ms`?
7051
+ Actually the existing transition spec is `opacity
7052
+ 300ms ease-out` — fill change on theme toggle
7053
+ will be instant. That's acceptable: theme toggle
7054
+ is a discrete event, and the halo (line 6500)
7055
+ already snaps fill on theme toggle the same way
7056
+ (`fill 200ms ease-out` was added later to halo
7057
+ via R253). Future round could add `fill 200ms`
7058
+ to highlight too if theme-switch flicker is
7059
+ noticed. */
7060
+ fill={isLight ? '#10b981' : '#d1fae5'}
7061
+ opacity={resolvedOpacity}
7062
+ data-topo-hub-highlight
7063
+ data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
7064
+ data-topo-hub-highlight-radius="5.5"
7065
+ data-topo-hub-highlight-opacity={resolvedOpacity}
7066
+ data-topo-hub-highlight-breath={breathActive ? 'true' : 'false'}
7067
+ data-topo-hub-highlight-recede={hubRecede ? 'true' : 'false'}
7068
+ /* Round 510 / Loop — R509 follow-on: theme-toggle fill
7069
+ ease. Pre-R510 the hub-highlight transition spec only
7070
+ listed `opacity 300ms ease-out`. When R509 introduced
7071
+ theme-conditional fill (#d1fae5 ↔ #10b981), the fill
7072
+ change SNAPPED on theme toggle because the transition
7073
+ list didn't include `fill`. R510 extends to `fill
7074
+ 200ms ease-out` so theme cycles smoothly through the
7075
+ emerald palette. 200ms timing matches the R253 halo
7076
+ fill transition (line ~6500) — both hub-cluster
7077
+ theme transitions now share a cadence so the focal
7078
+ cluster (digit + highlight + halo) eases as a unit.
7079
+ R508's recede opacity transition unchanged (300ms);
7080
+ fill is independent. */
7081
+ style={{
7082
+ pointerEvents: 'none',
7083
+ transition: 'opacity 300ms ease-out, fill 200ms ease-out',
7084
+ }}
7085
+ >
7086
+ {/* Round 497 / Loop — idle-state breath (呼吸感 theme pivot
7087
+ from the R492-R496 press-family arc). Pre-R497 the hub
7088
+ idle highlight read as a static dim disc — present but
7089
+ motionless, visually mute. R497 adds a 4s opacity breath
7090
+ (0.85 ↔ 1.0 ↔ 0.85) so the hub reads "alive but quiet"
7091
+ instead of "frozen", giving the empty-fleet state a
7092
+ subtle living signature.
7093
+ Gates:
7094
+ - !reducedMotion (R29 a11y blanket) — reducedMotion
7095
+ users see static 0.95 disc, no animate
7096
+ - workingCount === 0 — when fleet is busy, the
7097
+ highlight is invisible (opacity=0) so the animate
7098
+ would waste paint cycles. Gating saves work.
7099
+ SMIL <animate> overrides the static opacity={0.95}
7100
+ during its run; falls back to 0.95 when reducedMotion
7101
+ flips on (the animate node simply doesn't render).
7102
+ 4s cycle is long enough to feel like ambient breath
7103
+ rather than a pulse, matching the "quiet" semantic.
7104
+ data-topo-hub-highlight-breath attr exposes the
7105
+ resolved gate state for tests. */}
7106
+ {breathActive && (
7107
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
7108
+ )}
7109
+ </circle>
7110
+ );
7111
+ })()}
6545
7112
  {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
6546
7113
  that fades in when the hub is hovered — the same idea
6547
7114
  R44 used for node avatars (group-hover stroke). r=14
@@ -7526,6 +8093,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7526
8093
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
7527
8094
 
7528
8095
  if (isIntern || internByAlias || vendor.logo) {
8096
+ /* Round 501 / Loop — vendor avatar inside node circles
8097
+ gains a hover-gated brightness lift. Pre-R501 the
8098
+ avatar <image> was the only per-node surface with
8099
+ NO hover treatment: R26 lifted the card, R242 tinted
8100
+ the card stroke, R427 spread the alias letter-
8101
+ spacing, R500 added the alias drop-shadow, R208
8102
+ lifted the runtime badge ring, R443 thickened
8103
+ the badge icon stroke, R177 brightened the
8104
+ halo — but the most visually-prominent element
8105
+ (the vendor logo / 书生 coin centred in each node)
8106
+ stayed paint-static. R501 closes the per-node
8107
+ hover-affordance arc by adding a 15% brightness
8108
+ lift on hover.
8109
+ Implementation: CSS filter: brightness(1.15)
8110
+ when hoveredAlias === session.alias. Pure paint
8111
+ axis on the <image> element — no geometry change,
8112
+ no bbox shift. Modern-browser supported (Chrome 64+
8113
+ / FF 56+ / Safari 9.1+).
8114
+ Hits 节点视觉 theme. data-node-avatar-hovered
8115
+ attr surfaces the gate for tests.
8116
+ Gated on !reducedMotion as a courtesy (brightness
8117
+ transition < ~50ms still feels instant; the gate
8118
+ avoids the transition cycle for a11y users). */
8119
+ const isAvatarHovered = !reducedMotion && hoveredAlias === session.alias;
7529
8120
  return (
7530
8121
  <image
7531
8122
  href={vendor.logo ?? '/intern_avatar.png'}
@@ -7534,6 +8125,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7534
8125
  width={size}
7535
8126
  height={size}
7536
8127
  preserveAspectRatio="xMidYMid meet"
8128
+ data-node-avatar={session.alias}
8129
+ data-node-avatar-hovered={isAvatarHovered ? 'true' : 'false'}
8130
+ style={{
8131
+ filter: isAvatarHovered ? 'brightness(1.15)' : undefined,
8132
+ transition: 'filter 200ms ease-out',
8133
+ }}
7537
8134
  />
7538
8135
  );
7539
8136
  }
@@ -7957,6 +8554,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7957
8554
  R211 fill 300ms + R305 letter-spacing 200ms
7958
8555
  transition list preserved; only the
7959
8556
  conditional gets a middle case. */}
8557
+ {/* Round 500 / Loop — milestone round, opens
8558
+ per-node alias drop-shadow polish. Extends the
8559
+ R476-R481 drop-shadow visual-polish family to a
8560
+ 7th anchor: hovered alias text gains a soft
8561
+ status-coloured text-glow. Pre-R500 hover on
8562
+ a node triggered card-lift (R26 translateY) +
8563
+ card-stroke (R242 tint) + alias letter-spacing
8564
+ (R427 0.3px tier) but the alias TEXT itself had
8565
+ no paint-axis cue beyond fill (R211). R500 adds
8566
+ a drop-shadow on the text glyph itself, so the
8567
+ identity glyph itself lights up under attention
8568
+ — matching the R476 idiom (hub-digit emerald
8569
+ glow on hover) at the per-node identity scope.
8570
+ 2px blur radius at 50% alpha — subtler than the
8571
+ R476 hub-digit (3px at 60%) because the alias
8572
+ text is smaller and more numerous (1 per node)
8573
+ so an aggressive glow would multiply into
8574
+ visual noise. Status-coloured (status.text) so
8575
+ the glow inherits the node's working/idle/
8576
+ offline palette — green/cyan/gray respectively.
8577
+ Drop-shadow visual-polish family — 7 anchors:
8578
+ R476 hub digit hover-gated emerald
8579
+ R477 legend pin-ring pin-gated row.fill
8580
+ R478 recent-row pip fresh-gated cyan
8581
+ R479 group-label text pin-gated cyan
8582
+ R480 hot-lane edge hot-gated amber
8583
+ R481 zoom-state minimap zoom-gated cyan
8584
+ R500 node alias text hover-gated status.text ← this round
8585
+ Filter is paint-only; bbox unchanged; overlap-
8586
+ test invariants hold (R51 selector gated to
8587
+ g[data-node] descendants with strokeWidth
8588
+ sentinels; text element doesn't carry stroke).
8589
+ transition list extends to include 'filter
8590
+ 200ms ease-out' alongside the existing fill
8591
+ 300ms + letter-spacing 200ms tweens.
8592
+ data-node-alias-glow attr surfaces the hover
8593
+ gate for tests. */}
7960
8594
  <text
7961
8595
  x="0" y="1" textAnchor="middle"
7962
8596
  fill={status.text}
@@ -7964,11 +8598,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7964
8598
  data-node-alias-text={session.alias}
7965
8599
  data-node-alias-chat-target={chatAlias === session.alias ? 'true' : 'false'}
7966
8600
  data-node-alias-hovered={hoveredAlias === session.alias ? 'true' : 'false'}
8601
+ data-node-alias-glow={!reducedMotion && hoveredAlias === session.alias ? 'true' : 'false'}
7967
8602
  style={{
7968
- transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out',
8603
+ transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out, filter 200ms ease-out',
7969
8604
  letterSpacing:
7970
8605
  chatAlias === session.alias ? '0.5px' :
7971
8606
  hoveredAlias === session.alias ? '0.3px' : '0px',
8607
+ filter: !reducedMotion && hoveredAlias === session.alias
8608
+ ? `drop-shadow(0 0 2px ${status.text}80)`
8609
+ : undefined,
7972
8610
  }}
7973
8611
  >
7974
8612
  {truncate(session.alias, fullMax)}
@@ -9378,12 +10016,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9378
10016
  under the same R320 fill cadence. data-
9379
10017
  recent-row-count-pinned attr exposes the
9380
10018
  pin gate for tests. */}
10019
+ {/* Round 498 / Loop — hot-count subtle pulse. Pre-
10020
+ R498 the hot row count signaled via color (R127
10021
+ amber fill) + weight (R320 fw-700) + (R445 pin
10022
+ lift) but stayed visually motionless. R498 adds
10023
+ a 3s opacity breath (0.85↔1.0) on the digit when
10024
+ isHot && !reducedMotion — gentle "alive" signal
10025
+ on the lane carrying ≥ 10 messages, drawing
10026
+ glance without becoming noisy. Sibling of R497
10027
+ hub-idle-breath in the 呼吸感 theme arc; same
10028
+ 0.85↔1.0 amplitude. Class adds an animation-
10029
+ only paint axis; no layout / bbox change. R29
10030
+ blanket also catches `animation-duration` for
10031
+ reducedMotion users, but the component-side
10032
+ gate makes the intent explicit and avoids
10033
+ a node tree thrash for those users (className
10034
+ stays absent rather than present-but-paused). */}
9381
10035
  <tspan
9382
10036
  fill={isHot ? hotStroke : undefined}
9383
10037
  fontWeight={(isHot || isRowPinned) ? '700' : '600'}
10038
+ className={isHot && !reducedMotion ? 'anet-recent-hot-pulse' : undefined}
9384
10039
  data-recent-row-count
9385
10040
  data-recent-row-count-pinned={isRowPinned ? 'true' : 'false'}
9386
10041
  data-recent-row-count-font-weight={(isHot || isRowPinned) ? '700' : '600'}
10042
+ data-recent-row-count-hot-pulse={isHot && !reducedMotion ? 'true' : 'false'}
9387
10043
  {...(isHot ? { 'data-recent-row-count-hot': 'true' } : {})}
9388
10044
  style={{
9389
10045
  transition: 'fill 300ms ease-out, font-weight 200ms ease-out',
@@ -9627,6 +10283,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9627
10283
  stays as is, so the rest-vs-hover delta still
9628
10284
  reads clearly. data-recent-panel-more-font-weight
9629
10285
  attr exposes the value for tests. */}
10286
+ {/* Round 520 / Loop — extends the `+N more flows` footer
10287
+ to a 5-axis hover signature by adding fontWeight
10288
+ 500 → 600 on hover. Pre-R520 the footer carried 4
10289
+ hover axes:
10290
+ R195 fill legendText → legendAccent
10291
+ R325 letter-spacing 0.2 → 0.3px (R344 tween)
10292
+ R325 opacity 0.55 → 0.85
10293
+ R133 underline none → underline
10294
+ R368 had set fontWeight 500 statically as a sibling
10295
+ to R363/R364/R366 small-text fw lift family — but
10296
+ the footer's hover state didn't carry a fontWeight
10297
+ DELTA the way other interactive surfaces do (chip-
10298
+ row counts R416, chrome zoom-level R420, hub digit
10299
+ R425). R520 adds the missing weight axis: fw 500
10300
+ → 600 on hover, so the footer reads "thickening AND
10301
+ lighting up" under cursor — same idiom as the
10302
+ chrome zoom-level R420 / chip-row digit R416 hover-
10303
+ bold pattern, applied at the panel nav-action
10304
+ surface.
10305
+ data-recent-panel-more-font-weight attr value
10306
+ flips from '500' → '600' on hover (was static
10307
+ '500' pre-R520).
10308
+ Bonus closure — R475 panel-text cadence: pre-R520
10309
+ the footer's transition list had `opacity 150ms`
10310
+ while R475 unified panel-text transitions at
10311
+ 200ms. R518 closed the same gap at legend-count.
10312
+ R520 closes the LAST panel-text 150ms holdout
10313
+ here AND adds the new font-weight 200ms axis. All
10314
+ 4 transition properties (opacity / fill / letter-
10315
+ spacing / font-weight) now uniform 200ms at the
10316
+ footer — same cadence as legend-label / legend-
10317
+ count / recent-row alias / recent-row count /
10318
+ group-label. */}
9630
10319
  <text
9631
10320
  x="115" y="82"
9632
10321
  textAnchor="middle"
@@ -9634,14 +10323,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9634
10323
  fontSize="9"
9635
10324
  fontFamily="monospace"
9636
10325
  fontStyle="italic"
9637
- fontWeight="500"
10326
+ fontWeight={hoveredRecentMore ? '600' : '500'}
9638
10327
  letterSpacing={hoveredRecentMore ? '0.3' : '0.2'}
9639
10328
  opacity={hoveredRecentMore ? 0.85 : 0.55}
9640
10329
  textDecoration={hoveredRecentMore ? 'underline' : 'none'}
9641
10330
  data-recent-panel-more={moreCount}
9642
10331
  data-recent-panel-more-hovered={hoveredRecentMore ? 'true' : 'false'}
9643
- data-recent-panel-more-font-weight="500"
9644
- style={{ transition: 'opacity 150ms ease-out, fill 200ms ease-out, letter-spacing 200ms ease-out' }}
10332
+ data-recent-panel-more-font-weight={hoveredRecentMore ? '600' : '500'}
10333
+ data-recent-panel-more-transition="200ms"
10334
+ style={{ transition: 'opacity 200ms ease-out, fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out' }}
9645
10335
  >
9646
10336
  {`+ ${moreCount}`}
9647
10337
  <tspan opacity="0.7" data-recent-panel-more-unit>{` more flow${moreCount === 1 ? '' : 's'}`}</tspan>
@@ -10358,7 +11048,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10358
11048
  data-legend-count-pinned={isPinned ? 'true' : 'false'}
10359
11049
  data-legend-count-font-weight={isPinned ? '700' : '600'}
10360
11050
  data-legend-count-fill={row.count > 0 && (hoveredStatus === row.key || isPinned) ? 'tier' : 'neutral'}
10361
- style={{ pointerEvents: 'none', transition: 'opacity 150ms ease-out, fill 150ms ease-out, font-weight 150ms ease-out', fontVariantNumeric: 'tabular-nums' }}
11051
+ /* Round 518 / Loop extends R433's 3-tier hover-
11052
+ letter-spacing tween from the legend-row LABEL
11053
+ (text at x=30) to the SIBLING legend-row COUNT
11054
+ digit (this text at x=215). Pre-R518 the row's
11055
+ label spread on hover/pin (R433: 0/0.25/0.5px)
11056
+ while the count digit at the row's right edge
11057
+ stayed dead-typographic — same row, two halves,
11058
+ asymmetric kerning gesture. R518 mirrors the
11059
+ 3-tier scale at the count so the WHOLE row's
11060
+ typography reads as one unit under cursor: label
11061
+ + count spread together at matching values.
11062
+ Tabular-nums (R225) makes the kerning still
11063
+ visible on 2-digit counts — each digit cell
11064
+ keeps its fixed width, but the inter-digit
11065
+ advance grows. R518 also closes R475's panel-
11066
+ row TEXT cadence at the count surface — R475
11067
+ lifted the label text transitions to 200ms but
11068
+ the count was missed; R518 lifts opacity / fill
11069
+ / font-weight from 150 → 200ms AND adds the new
11070
+ letter-spacing axis at 200ms. One transition
11071
+ list, one cadence, one motion-coherent multi-
11072
+ axis hover/pin signature across the row.
11073
+ Hover-letter-spacing family extension (10
11074
+ anchors now): R344/R345/R347/R420/R427/R431/
11075
+ R432/R433/R517/R518. R518 closes the legend-
11076
+ row pair (label R433 + count R518). data-
11077
+ legend-count-letter-spacing attr exposes the
11078
+ resolved value for tests. */
11079
+ data-legend-count-letter-spacing={isPinned ? '0.5px' : hoveredStatus === row.key ? '0.25px' : '0px'}
11080
+ data-legend-count-transition="200ms"
11081
+ style={{
11082
+ pointerEvents: 'none',
11083
+ transition: 'opacity 200ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, letter-spacing 200ms ease-out',
11084
+ fontVariantNumeric: 'tabular-nums',
11085
+ letterSpacing: isPinned ? '0.5px' :
11086
+ hoveredStatus === row.key ? '0.25px' : '0px',
11087
+ }}
10362
11088
  >{row.count}</text>
10363
11089
  </g>
10364
11090
  );
@@ -10417,6 +11143,74 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10417
11143
  spacing as typographic intent. Stays well inside the
10418
11144
  bottom-left corner; opacity 0.4 unchanged so the
10419
11145
  watermark stays a watermark. */}
11146
+ {/* Round 519 / Loop — 呼吸感 family 3rd anchor. Pre-R519 the
11147
+ breath family had 2 anchors (R497 hub idle digit + R498
11148
+ recent-row hot pulse). Both signal active state — the
11149
+ digit when canvas is idle (no work pending), the recent
11150
+ row when fresh signal arrives. R519 adds a SLOW ambient
11151
+ breath to the brand watermark — present always, not gated
11152
+ on activity state. The watermark IS the canvas-corner
11153
+ register that says "the canvas is alive even when nothing
11154
+ is happening"; a 6s opacity pulse around its 0.4 mean
11155
+ (±0.08 swing → 0.32 ↔ 0.48) reads as ambient liveness
11156
+ rather than foreground signal.
11157
+ Why 6s (not R497's 4s): the breath family now spans
11158
+ activity registers (R497 4s — idle-focal: present and
11159
+ waiting; R498 ~3s — hot signal: just arrived) and now
11160
+ ambient register (R519 6s — corner watermark: always-on
11161
+ background). Slower cadence keeps the watermark in the
11162
+ background; ~10 pct slower than R497 keeps it out of
11163
+ phase so the two anchors never beat together visibly.
11164
+ Gate: !reducedMotion. Inside the prefers-reduced-motion
11165
+ media query, SMIL animate isn't covered by globals.css
11166
+ R29 (which only kills CSS animation property), so we
11167
+ gate at JSX level — when reducedMotion is true the
11168
+ <animate> child isn't mounted and opacity stays at the
11169
+ static 0.4. data-topo-brand-watermark-breath attr
11170
+ exposes the gate state for tests.
11171
+ 呼吸感 family extension (3 anchors): R497 hub idle / R498
11172
+ recent-row hot / R519 brand watermark ambient. */}
11173
+ {/* Round 525 / Loop — focal-recede family 3rd anchor. R507
11174
+ receded the hub-center workingCount digit; R508 receded
11175
+ the hub-highlight disc; both fade to 0.85× when any non-
11176
+ hub canvas surface is hovered (alias / edge / group /
11177
+ status / vendor) — the "you're inspecting elsewhere"
11178
+ gesture. R525 extends the pattern to the brand watermark
11179
+ at canvas bottom-left, the always-on decorative brand
11180
+ element. Pre-R525 the watermark stayed at its R519
11181
+ breath baseline (0.32-0.48 SMIL pulse) regardless of
11182
+ canvas attention; post-R525 it fades to 70% wrapper
11183
+ opacity (effective 0.224-0.336 with breath) when canvas
11184
+ attention is elsewhere, matching the same focal-recede
11185
+ semantic R507/R508 establish at the hub focal cluster.
11186
+ Implementation: wrap the existing <text> in a <g>
11187
+ wrapper whose opacity multiplies with the inner text's
11188
+ SMIL-animated opacity. SVG opacity composes
11189
+ multiplicatively across the parent/child chain, so:
11190
+ normal: g.opacity=1.0 × text.opacity(SMIL 0.32-0.48) = 0.32-0.48
11191
+ recede: g.opacity=0.7 × text.opacity(SMIL 0.32-0.48) = 0.224-0.336
11192
+ SMIL on inner text continues running through both
11193
+ states; only the wrapper opacity flips. 300ms ease-out
11194
+ transition on wrapper (matches R508 hub-highlight recede
11195
+ transition).
11196
+ Gate matches R507/R508 — focal-recede is a UNIFIED
11197
+ non-hub-canvas-hover signal driving multiple anchors,
11198
+ so all three (hub digit / hub-highlight / brand
11199
+ watermark) fade together as the canvas's decorative
11200
+ register, leaving only the surface under inspection
11201
+ foregrounded.
11202
+ Focal-recede family extension (3 anchors): R507 hub
11203
+ digit / R508 hub-highlight / R525 brand watermark. */}
11204
+ <g
11205
+ opacity={(hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11206
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 0.7 : 1}
11207
+ data-topo-brand-watermark-wrapper
11208
+ data-topo-brand-watermark-recede={
11209
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11210
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
11211
+ }
11212
+ style={{ pointerEvents: 'none', transition: 'opacity 300ms ease-out' }}
11213
+ >
10420
11214
  <text
10421
11215
  x="16" y="672"
10422
11216
  fontSize="11" fontFamily="monospace" fontWeight="600"
@@ -10424,8 +11218,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10424
11218
  fill={pal.legendText}
10425
11219
  opacity="0.4"
10426
11220
  data-topo-brand-watermark
11221
+ data-topo-brand-watermark-breath={reducedMotion ? 'false' : 'true'}
10427
11222
  style={{ pointerEvents: 'none', transition: 'fill 200ms ease-out' }}
10428
- >sleep2agi</text>
11223
+ >sleep2agi{!reducedMotion && (
11224
+ <animate attributeName="opacity" values="0.32;0.48;0.32" dur="6s" repeatCount="indefinite" />
11225
+ )}</text>
11226
+ </g>
10429
11227
  {/* v0.10.0 Hero 3 Wave 1 / RFC §3.I (Vincent 5215 + 通信龙
10430
11228
  lead-autonomy Q4 dual-anchor minimal): canvas top-left
10431
11229
  crescent moon brand mark, visible ONLY when the
@@ -10460,10 +11258,46 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10460
11258
  the R175 panel-fade-in uses for cascade rhythm. data-
10461
11259
  topo-brand-canvas-mark-visible exposes the gate for
10462
11260
  tests. */}
11261
+ {/* Round 526 / Loop — focal-recede family 4th anchor.
11262
+ Symmetric polish to R525 (watermark recede). The
11263
+ brand crescent at canvas top-left is the second
11264
+ decorative brand element on the canvas; pre-R526 it
11265
+ stayed at flat opacity 0.35 (when visible) regardless
11266
+ of canvas attention. R526 multiplies its visible
11267
+ opacity by 0.7 when ANY non-hub canvas surface is
11268
+ hovered, matching R525's deeper-recede semantic for
11269
+ decorative brand elements (vs hub focal cluster's
11270
+ 0.85× recede at R507/R508).
11271
+ Composes cleanly with existing flowLinks gate:
11272
+ normal, flowLinks=0: opacity = 0.35 * 1.0 = 0.350
11273
+ recede, flowLinks=0: opacity = 0.35 * 0.7 = 0.245
11274
+ invisible, flowLinks>0: opacity = 0.00 * any = 0.000
11275
+ Multiplicative chain means recede only matters when
11276
+ crescent is visible (quiet canvas, flowLinks=0) —
11277
+ exactly when canvas attention elsewhere should
11278
+ dim the decorative register. 300ms transition
11279
+ already covers both axes (the existing visibility
11280
+ opacity ramp + the new recede multiplier easing).
11281
+ Focal-recede family extension (4 anchors): R507 hub
11282
+ digit / R508 hub-highlight / R525 watermark / R526
11283
+ crescent (this round). Canvas brand surfaces (R525
11284
+ watermark + R526 crescent) now BOTH carry focal-
11285
+ recede at the same 0.7 multiplier, fading as a
11286
+ decorative pair when the canvas's focal attention
11287
+ shifts elsewhere.
11288
+ data-topo-brand-canvas-mark-recede attr exposes the
11289
+ gate state for tests. */}
10463
11290
  <g
10464
- opacity={flowLinks.length === 0 ? 0.35 : 0}
11291
+ opacity={(flowLinks.length === 0 ? 0.35 : 0) * (
11292
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11293
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 0.7 : 1
11294
+ )}
10465
11295
  data-topo-brand-canvas-mark
10466
11296
  data-topo-brand-canvas-mark-visible={flowLinks.length === 0 ? 'true' : 'false'}
11297
+ data-topo-brand-canvas-mark-recede={
11298
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11299
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
11300
+ }
10467
11301
  style={{ pointerEvents: 'none', transition: 'opacity 300ms ease-out, fill 200ms ease-out' }}
10468
11302
  >
10469
11303
  <defs>
@@ -10967,7 +11801,49 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10967
11801
  / fullscreen) preview their active state on hover.
10968
11802
  Pure actions (zoom -/+, reset) stay white — they
10969
11803
  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' : ''}`}
11804
+ // Round 493 / Loop extends R492 chrome-strip press-feedback
11805
+ // family to nodeSize S/M/L buttons. Adds active:scale-95
11806
+ // alongside the existing color-deepen (R196) + chrome-pop
11807
+ // (R249). transition-transform + duration-200 + ease-out
11808
+ // + transform-gpu added since this className previously had
11809
+ // transition-colors only — without the transform transition,
11810
+ // active:scale-95 would hard-cut. transform-gpu promotes the
11811
+ // layer so scale doesn't trigger paint thrash.
11812
+ /* Round 521 / Loop — extends R270's hover-preview pattern
11813
+ (inactive toggle hover previews the active state's
11814
+ visual register) to the TYPOGRAPHY axis at the chrome
11815
+ nodeSize S/M/L surface. Pre-R521 the inactive variant
11816
+ had `hover:bg-cyan-500/5` (R270 bg preview) but no
11817
+ typography preview — active variant uses `font-medium`
11818
+ (fw 500), inactive variant sat at default fw 400 even
11819
+ on hover.
11820
+ R521 adds `hover:font-medium` + `transition-[font-
11821
+ weight]` to the inactive variant so hovering an
11822
+ inactive S/M/L letter thickens the glyph 400 → 500,
11823
+ previewing the typography of the active state the
11824
+ click would commit to. Sibling to R421 chrome zoom-
11825
+ level fontWeight hover delta (rest 500 → hover 600)
11826
+ and R520 footer fontWeight hover (500 → 600) — same
11827
+ idiom: thicken-on-hover for chrome surfaces with a
11828
+ pre-commit gesture.
11829
+ `font-medium` (500) matches the ACTIVE variant's
11830
+ fw exactly — the inactive hover landing weight equals
11831
+ the active locked weight, so clicking commits to a
11832
+ typography state the eye already saw 'on the way in'.
11833
+ Hover-fw family extension (5 anchors now):
11834
+ R416 chip-row count digit rest 500 → hover 700/600
11835
+ R420 chrome zoom-level rest 500 → hover 600
11836
+ R425 hub-center digit rest 700 → hover 800
11837
+ R520 +N more flows footer rest 500 → hover 600
11838
+ R521 chrome nodeSize S/M/L inactive rest 400 → hover 500 ← this round
11839
+ Active variant `font-medium` unchanged so the rest-vs-
11840
+ active typography distinction stays intact when the
11841
+ user IS clicked-in (active stays at fw 500, inactive
11842
+ rest at fw 400, inactive hover preview at fw 500).
11843
+ data-topo-chrome-nodesize-hover-preview-fw="500" attr
11844
+ exposes the polish for tests. */
11845
+ className={`px-2 py-1 transition-colors transition-transform transition-[font-weight] 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 hover:font-medium'}${chromePopping === popKey ? ' anet-chrome-pop' : ''}`}
11846
+ data-topo-chrome-nodesize-hover-preview-fw={nodeScale === v ? null : '500'}
10971
11847
  style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
10972
11848
  >
10973
11849
  {lbl}
@@ -11009,7 +11885,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11009
11885
  // → white/10) so mouse-down has a tactile dim before the
11010
11886
  // R186 icon pop fires on release.
11011
11887
  // 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"
11888
+ // R493 zoom +/− buttons join the chrome-strip active:scale-95
11889
+ // press-feedback family (R492 + nodeSize above). transition-
11890
+ // transform + duration-200 + ease-out + transform-gpu added
11891
+ // since the className had only transition-colors.
11892
+ 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
11893
  style={{ color: pal.legendText }}
11014
11894
  aria-label="Zoom out"
11015
11895
  title="Zoom out (−)"
@@ -11101,10 +11981,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11101
11981
  ? 'true' : 'false'
11102
11982
  }
11103
11983
  data-topo-chrome-zoom-level-hover={hoveredZoomLevel ? 'true' : 'false'}
11984
+ /* Round 517 / Loop — extends the chrome zoom-level readout
11985
+ from 2-axis (R347 letter-spacing + R420 fontWeight) to
11986
+ 3-axis hover signature by adding a color brighten to
11987
+ pal.legendHeadline. Pre-R517 the readout's color stayed
11988
+ at pal.legendText on hover; the digits got tighter
11989
+ kerning (0→0.5px) and heavier weight (500→600) but
11990
+ stayed the same legendText gray tone. R517 lifts color
11991
+ to legendHeadline on hover so the readout brightens
11992
+ into the headline tier at the same beat — matching the
11993
+ R55/R197/R239 hover-deepen-own-hue idiom that legend-
11994
+ row label + count carry at panel scope. Chrome strip's
11995
+ only data display now has full 3-axis hover signature
11996
+ (letter-spacing + fontWeight + color), parity with the
11997
+ chip-row chips' own hover-brighten pattern.
11998
+ Implementation: inline color uses the same hoveredZoom-
11999
+ Level state as R347/R420 — no new state. Transition
12000
+ already includes 'color 200ms ease-out' (R264) so the
12001
+ brighten eases under the same cadence as the kerning +
12002
+ weight tweens — one motion-coherent 3-axis lift.
12003
+ data-topo-chrome-zoom-level-color attr exposes the
12004
+ resolved color string for tests. */
12005
+ data-topo-chrome-zoom-level-color={hoveredZoomLevel ? 'headline' : 'text'}
11104
12006
  onMouseEnter={() => setHoveredZoomLevel(true)}
11105
12007
  onMouseLeave={() => setHoveredZoomLevel(false)}
11106
12008
  style={{
11107
- color: pal.legendText,
12009
+ color: hoveredZoomLevel ? pal.legendHeadline : pal.legendText,
11108
12010
  borderColor: pal.containerBorder,
11109
12011
  minWidth: 46,
11110
12012
  display: 'inline-block',
@@ -11149,7 +12051,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11149
12051
  data-topo-chrome-zoom-in-popping={chromePopping === 'zoom-in' ? 'true' : 'false'}
11150
12052
  // R196: press-state (mirror of zoom-out above).
11151
12053
  // 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"
12054
+ // R493 zoom +/− buttons join the chrome-strip active:scale-95
12055
+ // press-feedback family (R492 + nodeSize above). transition-
12056
+ // transform + duration-200 + ease-out + transform-gpu added
12057
+ // since the className had only transition-colors.
12058
+ 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
12059
  style={{ color: pal.legendText }}
11154
12060
  aria-label="Zoom in"
11155
12061
  title="Zoom in (+)"
@@ -11201,7 +12107,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11201
12107
  Every standalone interactive HTML surface in TopoGraph
11202
12108
  now lifts on hover. data-topo-chrome-reset-hover-lift
11203
12109
  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"
12110
+ // R493 reset button joins the chrome-strip active:scale-95
12111
+ // press-feedback family. The button already has transition-
12112
+ // transform + transform-gpu (R350 reset spin + R400 hover lift),
12113
+ // so just appending active:scale-95 plugs straight in. Compound
12114
+ // active state during press = hover-lift (-1px) + scale-95
12115
+ // composes as translateY(-1px) scale(0.95) — lift-and-compress
12116
+ // for tactile click feel.
12117
+ 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
12118
  data-topo-chrome-reset-hover-lift="true"
11206
12119
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
11207
12120
  aria-label="Reset view"
@@ -11249,8 +12162,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11249
12162
  // owns transform during its 450ms run. transformOrigin
11250
12163
  // 'center' so rotation pivots around the icon's centre
11251
12164
  // (default would be top-left and the icon would arc).
12165
+ /* Round 514 / Loop — extends R352/R353 chrome icon hover-
12166
+ scale family to the reset button. Pre-R514 the reset
12167
+ icon had hover-rotate (-8°, R350) + hover-sw (2.5→2.8,
12168
+ R453) but no hover-scale, while zoom-out (R352), zoom-
12169
+ in (R352), and fullscreen (R353) icons all carried
12170
+ `group-hover:scale-110`. R514 brings the reset icon
12171
+ into the same 3-axis hover signature (rotate + sw +
12172
+ scale) as the rest of the chrome strip.
12173
+ Implementation: inline transform composes rotate +
12174
+ scale into one string. `transform: rotate(-8deg)
12175
+ scale(1.1)` on hover; `rotate(0) scale(1)` at rest.
12176
+ transformOrigin 'center' applies to both — rotation
12177
+ pivots around centre AND scale grows from centre.
12178
+ The Tailwind `group-hover:scale-110` approach can't
12179
+ work here because inline `style.transform` overrides
12180
+ className-based transforms; compose the multi-axis
12181
+ transform inline instead.
12182
+ Chrome icon hover gesture parity (post-R514):
12183
+ zoom-out scale-110 + sw-lift (R352/R454)
12184
+ zoom-in scale-110 + sw-lift (R352/R454)
12185
+ fullscreen scale-110 + sw-lift (R353/R455)
12186
+ reset scale-1.1 + sw-lift + rotate -8°
12187
+ (R514 + R453 + R350)
12188
+ reset gets the EXTRA rotate axis because R350's spin
12189
+ preview semantic is reset-specific — the rotation
12190
+ hints at the click-spin (R184) the button will fire. */
11252
12191
  style={{
11253
- transform: hoveredReset && !resetSpinning ? 'rotate(-8deg)' : 'rotate(0deg)',
12192
+ transform: hoveredReset && !resetSpinning ? 'rotate(-8deg) scale(1.1)' : 'rotate(0deg) scale(1)',
11254
12193
  transformOrigin: 'center',
11255
12194
  transition: 'transform 200ms ease-out, stroke-width 200ms ease-out',
11256
12195
  }}
@@ -11294,7 +12233,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11294
12233
  // fullscreen now all carry an icon-level hover gesture in
11295
12234
  // addition to the bg hover).
11296
12235
  // 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 ${
12236
+ // R493 fullscreen joins active:scale-95 press family (same as
12237
+ // reset above: lift-and-compress compound transform on press).
12238
+ 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
12239
  isFullscreen
11299
12240
  ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25'
11300
12241
  : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'