@sleep2agi/agent-network-dashboard 0.5.3-preview.5 → 0.5.3-preview.51

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 (208) 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/08i3yp3wnkvdd.js +4 -0
  145. package/.next/static/chunks/0ib0ojh9.ofrs.js +1 -0
  146. package/.next/static/chunks/0sht0y-y7x71m.css +2 -0
  147. package/.next/static/chunks/{03a4--7ncekmk.js → 0v4-5tng.uh.7.js} +2 -2
  148. package/.next/static/chunks/12rya_znu46dn.js +1 -0
  149. package/.next/static/chunks/{031p2u1xb1~uk.js → 142gisyxu_fy5.js} +1 -1
  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 +1324 -68
  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-cluster-count-attr-test.mjs +80 -0
  162. package/scripts/topo-crescent-breath-test.mjs +104 -0
  163. package/scripts/topo-crescent-recede-test.mjs +111 -0
  164. package/scripts/topo-edge-badge-hover-glow-test.mjs +90 -0
  165. package/scripts/topo-filter-pills-press-test.mjs +96 -0
  166. package/scripts/topo-fleet-density-tier-test.mjs +84 -0
  167. package/scripts/topo-freshness-chip-fade-test.mjs +105 -0
  168. package/scripts/topo-fullscreen-attr-test.mjs +73 -0
  169. package/scripts/topo-grid-content-bottom-attr-test.mjs +72 -0
  170. package/scripts/topo-hub-digit-ls-test.mjs +119 -0
  171. package/scripts/topo-hub-halo-glow-test.mjs +96 -0
  172. package/scripts/topo-hub-highlight-amplify-test.mjs +139 -0
  173. package/scripts/topo-hub-highlight-fill-transition-test.mjs +84 -0
  174. package/scripts/topo-hub-highlight-glow-test.mjs +99 -0
  175. package/scripts/topo-hub-highlight-r-test.mjs +112 -0
  176. package/scripts/topo-hub-highlight-recede-test.mjs +144 -0
  177. package/scripts/topo-hub-highlight-theme-fill-test.mjs +83 -0
  178. package/scripts/topo-hub-hover-ring-glow-test.mjs +97 -0
  179. package/scripts/topo-hub-idle-breath-test.mjs +109 -0
  180. package/scripts/topo-hub-recede-test.mjs +124 -0
  181. package/scripts/topo-hub-spoke-glow-test.mjs +112 -0
  182. package/scripts/topo-layout-hover-fw-test.mjs +98 -0
  183. package/scripts/topo-legend-count-letter-spacing-test.mjs +108 -0
  184. package/scripts/topo-legend-label-fw-test.mjs +107 -0
  185. package/scripts/topo-legend-swatch-glow-test.mjs +109 -0
  186. package/scripts/topo-nodesize-hover-fw-test.mjs +99 -0
  187. package/scripts/topo-orphan-box-dash-test.mjs +89 -0
  188. package/scripts/topo-orphan-fill-opacity-test.mjs +91 -0
  189. package/scripts/topo-orphan-label-italic-test.mjs +90 -0
  190. package/scripts/topo-pinned-aspect-test.mjs +89 -0
  191. package/scripts/topo-pressure-seg-motion-test.mjs +101 -0
  192. package/scripts/topo-recent-hot-pulse-test.mjs +102 -0
  193. package/scripts/topo-recent-more-fw-test.mjs +126 -0
  194. package/scripts/topo-recent-row-fw-test.mjs +115 -0
  195. package/scripts/topo-reduced-motion-attr-test.mjs +69 -0
  196. package/scripts/topo-reset-icon-hover-scale-test.mjs +102 -0
  197. package/scripts/topo-starfield-hue-test.mjs +109 -0
  198. package/scripts/topo-vendor-activelinks-press-test.mjs +100 -0
  199. package/scripts/topo-watermark-breath-test.mjs +100 -0
  200. package/scripts/topo-watermark-recede-test.mjs +114 -0
  201. package/scripts/topo-zoom-level-color-test.mjs +105 -0
  202. package/.next/static/chunks/024l23a_mex8..js +0 -4
  203. package/.next/static/chunks/09q4enn24qy1u.js +0 -1
  204. package/.next/static/chunks/0iwmx1skjhecc.js +0 -1
  205. package/.next/static/chunks/0yj_zl0p6_4ws.css +0 -2
  206. /package/.next/static/{3Apg3z__jP_I-Dl6oqXnF → -N6yPJbjnHbgai9z-vmKs}/_buildManifest.js +0 -0
  207. /package/.next/static/{3Apg3z__jP_I-Dl6oqXnF → -N6yPJbjnHbgai9z-vmKs}/_clientMiddlewareManifest.js +0 -0
  208. /package/.next/static/{3Apg3z__jP_I-Dl6oqXnF → -N6yPJbjnHbgai9z-vmKs}/_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
  };
@@ -1959,8 +1985,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1959
1985
  // overlays the release-pop. Matching `transform-gpu`
1960
1986
  // promotes the layer so the scale doesn't trigger
1961
1987
  // layout/paint thrash. Sibling change on Grid below.
1962
- className={`px-2.5 py-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset hover:tracking-wide active:scale-95 transform-gpu ${layout === 'ring' ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'text-gray-400 hover:text-cyan-300 hover:bg-cyan-500/5 active:bg-cyan-500/15'} ${chromePopping === 'layout-ring' ? ' anet-chrome-pop' : ''}`}
1963
- style={{ transition: 'background-color 150ms ease, color 150ms ease, letter-spacing 200ms ease-out, transform 150ms ease-out' }}
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' }}
1964
2016
  >
1965
2017
  Ring
1966
2018
  </button>
@@ -1982,7 +2034,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1982
2034
  // R492 sibling — Grid button picks up active:scale-95
1983
2035
  // press feedback + transform in transition list. Same
1984
2036
  // vocabulary as Ring above.
1985
- className={`px-2.5 py-1 border-l focus:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset hover:tracking-wide active:scale-95 transform-gpu ${layout === 'grid' ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'text-gray-400 hover:text-cyan-300 hover:bg-cyan-500/5 active:bg-cyan-500/15'} ${chromePopping === 'layout-grid' ? ' anet-chrome-pop' : ''}`}
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'}
1986
2043
  /* Round 268 / Loop: Grid button's left border (the
1987
2044
  internal divider between Ring and Grid) picks up
1988
2045
  pal.containerBorder, matching the wrapper change at
@@ -1995,7 +2052,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1995
2052
  keeps R268's theme-toggle smoothness intact.
1996
2053
  R492 adds `transform 150ms ease-out` so active:scale-95
1997
2054
  eases smoothly. */
1998
- 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' }}
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' }}
1999
2056
  >
2000
2057
  Grid
2001
2058
  </button>
@@ -2112,9 +2169,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2112
2169
  // to R355 filter pin pill inner-span hover-brighten.
2113
2170
  // Hover-brighten family extends from filter pills to
2114
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.
2115
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 ${
2116
2181
  workingCount > 0
2117
- ? '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'
2118
2183
  : 'bg-green-500/10 text-green-300 border-green-500/20'
2119
2184
  }`}
2120
2185
  data-chip-hover-lift={workingCount > 0 ? 'true' : 'false'}
@@ -2214,9 +2279,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2214
2279
  same digit-jitter physics on count crossings). */
2215
2280
  // R398: hover translate-y lift on clickable variant — see working chip above.
2216
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).
2217
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 ${
2218
2286
  onlineNodes.length > 0
2219
- ? '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'
2220
2288
  : 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20'
2221
2289
  }`}
2222
2290
  data-chip-hover-lift={onlineNodes.length > 0 ? 'true' : 'false'}
@@ -2491,7 +2559,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2491
2559
  // R355: `group` lets the inner opacity-70 spans (prefix
2492
2560
  // `filter:` + count `· N`) brighten to 100 % on pill hover.
2493
2561
  // Sibling treatment on group + vendor pills below.
2494
- 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"
2495
2569
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2496
2570
  onClick={() => setPinnedStatus(null)}
2497
2571
  style={{
@@ -2555,7 +2629,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2555
2629
  data-filter-match-count={matchCount}
2556
2630
  data-filter-match-aliases={matchAliases.join(',')}
2557
2631
  // R355 sibling — `group` parent + group-hover on inner spans.
2558
- 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"
2559
2639
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2560
2640
  onClick={() => setPinnedGroup(null)}
2561
2641
  style={{
@@ -2621,7 +2701,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2621
2701
  data-filter-match-count={matchCount}
2622
2702
  data-filter-match-aliases={matchAliases.join(',')}
2623
2703
  // R355 sibling — `group` parent + group-hover on inner spans.
2624
- 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"
2625
2711
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
2626
2712
  onClick={() => setPinnedVendor(null)}
2627
2713
  style={{
@@ -2684,7 +2770,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2684
2770
  data-filter-match-count={link.count}
2685
2771
  data-filter-match-aliases={`${link.from},${link.to}`}
2686
2772
  data-active-filter-edge-hot={isHot ? 'true' : 'false'}
2687
- 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"
2688
2777
  title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
2689
2778
  onClick={() => setPinnedEdgeKey(null)}
2690
2779
  style={{
@@ -3034,7 +3123,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3034
3123
  // — sibling to R355 filter-pill prefix/suffix + R414
3035
3124
  // chip-row unit brighten. Closes the inner-span
3036
3125
  // hover-brighten family at the vendor chip surface.
3037
- 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"
3038
3132
  data-vendor-letter={v.initial}
3039
3133
  data-vendor-letter-count={v.count}
3040
3134
  data-vendor-letter-hover-lift="true"
@@ -3272,9 +3366,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3272
3366
  data-chip-hover-lift attr exposes the lift surface
3273
3367
  state ('true' clickable, 'false' empty) for tests. */
3274
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.
3275
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 ${
3276
3374
  isInteractive
3277
- ? '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'
3278
3376
  : 'bg-gray-500/10 text-gray-400 border-gray-500/20'
3279
3377
  }`}
3280
3378
  data-chip-hover-lift={isInteractive ? 'true' : 'false'}
@@ -3521,6 +3619,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3521
3619
  on the canvas root for non-visual consumers.
3522
3620
  Composed from existing onlineNodes / workingCount /
3523
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
+ }
3524
3652
  data-topo-online-count={onlineNodes.length}
3525
3653
  data-topo-working-count={workingCount}
3526
3654
  data-topo-offline-count={offlineNodes.length}
@@ -3586,6 +3714,119 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3586
3714
  categorical) — separate dedicated attrs if/when needed.
3587
3715
  Root svg attribute set now 11 attrs total. */
3588
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
+ })()}
3589
3830
  /* Round 466 / Loop — aggregate hover signal on the root SVG.
3590
3831
  Exposes a single boolean `data-topo-any-hover` that
3591
3832
  reflects whether ANY hover state in the topology is
@@ -3828,7 +4069,38 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3828
4069
  const x = ((seed * 13) % 1000);
3829
4070
  const y = ((seed * 7) % 680);
3830
4071
  const r = (i % 3 === 0) ? 1.2 : 0.7;
3831
- 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]} />;
3832
4104
  })}
3833
4105
  </g>
3834
4106
  )}
@@ -4366,8 +4638,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4366
4638
  data-topo-hub-spoke-stroke-width={spokeStrokeWidth}
4367
4639
  data-topo-hub-spoke-stroke-width-active="2.25"
4368
4640
  data-topo-hub-spoke-linecap="round"
4641
+ /* Round 533 / Loop — extends drop-shadow visual-polish
4642
+ family to a 9th anchor: hub spokes gain filter:drop-
4643
+ shadow glow on hub-hover. Subtle 1.5px cyan/teal blur
4644
+ applied across ALL spokes simultaneously when the
4645
+ user hovers the hub — the network mesh visually
4646
+ "lights up" in response to focal attention. Sibling
4647
+ to R476 hub-digit + R532 hub-highlight glow at the
4648
+ same gate (hoveredHub && !reducedMotion); together
4649
+ the three anchors (digit + highlight disc + spokes)
4650
+ form a unified focal-cluster glow that signals
4651
+ "you're focused on the hub" across geometry,
4652
+ paint, and mesh-extent axes.
4653
+ Theme-aware glow palette matches the spoke stroke
4654
+ family:
4655
+ light: rgba(13, 148, 136, 0.4) teal-600
4656
+ cyber: rgba(34, 211, 238, 0.4) cyan-400
4657
+ 0.4 alpha keeps the glow subtle across N spokes
4658
+ (30+ at peak fleet sizes) — loud bloom across many
4659
+ edges would compete with the focal cluster itself.
4660
+ 1.5px blur is conservative; tuned so each spoke
4661
+ gains a faint outer halo rather than a wide bloom.
4662
+ filter is paint-only; bbox unchanged; existing
4663
+ R241 transition list extends to 'filter 250ms
4664
+ ease-out' matching the spoke transition cadence
4665
+ (250ms, distinct from the 200ms hub-cluster
4666
+ cadence — spokes ease slightly slower since they
4667
+ respond to per-alias state, not just hub state).
4668
+ data-topo-hub-spoke-glow attr exposes the gate
4669
+ state for tests. */
4670
+ data-topo-hub-spoke-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
4369
4671
  style={{
4370
- transition: 'stroke 250ms ease-out, stroke-width 250ms ease-out, opacity 250ms ease-out',
4672
+ transition: 'stroke 250ms ease-out, stroke-width 250ms ease-out, opacity 250ms ease-out, filter 250ms ease-out',
4673
+ filter: !reducedMotion && hoveredHub
4674
+ ? (isLight
4675
+ ? 'drop-shadow(0 0 1.5px rgba(13, 148, 136, 0.4))'
4676
+ : 'drop-shadow(0 0 1.5px rgba(34, 211, 238, 0.4))')
4677
+ : undefined,
4371
4678
  ...(isActiveSpoke ? {} : {
4372
4679
  animationDelay: `${-(idx * 0.25)}s`,
4373
4680
  // CSS var consumed by `.anet-topo-spoke-flow`
@@ -4515,12 +4822,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4515
4822
  // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
4516
4823
  // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
4517
4824
  // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
4518
- fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
4519
- : isHovered ? (isLight ? 0.05 : 0.09)
4520
- : (isLight ? 0.025 : 0.045)}
4825
+ /* Round 506 / Loop category-differentiation family
4826
+ 3rd anchor. Orphan band rest-state fillOpacity drops
4827
+ slightly below prefix-group rest (0.025/0.045
4828
+ 0.015/0.028). Adds a 3rd independent paint
4829
+ differentiator to the orphan visual signature:
4830
+ R499 fontStyle: italic (label text)
4831
+ R503 '3 6' dash pattern (rect stroke)
4832
+ R506 lower fillOpacity (rect fill) ← this round
4833
+ Three independent channels (typography + stroke
4834
+ pattern + fill density) collectively encode the
4835
+ catchall semantic at rest. Pin and hover branches
4836
+ UNCHANGED (still 0.05/0.09 hover, 0.08/0.13 pin) —
4837
+ orphan box gets full visual emphasis on inspection
4838
+ identical to prefix groups; the differentiation
4839
+ lives ONLY in the unsolicited rest state. The
4840
+ ~40% drop (0.045 → 0.028 cyber, 0.025 → 0.015
4841
+ light) is subtle enough that the orphan box stays
4842
+ visible at rest, just quieter — matches the
4843
+ "misc bucket, less attention-deserving" semantic
4844
+ without losing the visual anchor.
4845
+ Pure paint axis; bbox unchanged; R51 SVG sentinel
4846
+ safety untouched (overlap-test gates to g[data-
4847
+ node], cluster rect invisible to it).
4848
+ data-group-box-fill-opacity attr surfaces the
4849
+ resolved value for tests. */
4850
+ fillOpacity={
4851
+ isPinned ? (isLight ? 0.08 : 0.13)
4852
+ : isHovered ? (isLight ? 0.05 : 0.09)
4853
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4854
+ : (isLight ? 0.025 : 0.045)
4855
+ }
4856
+ data-group-box-fill-opacity={
4857
+ isPinned ? (isLight ? 0.08 : 0.13)
4858
+ : isHovered ? (isLight ? 0.05 : 0.09)
4859
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4860
+ : (isLight ? 0.025 : 0.045)
4861
+ }
4521
4862
  stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
4522
4863
  strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
4523
- strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
4864
+ /* Round 503 / Loop category-differentiation family
4865
+ 2nd anchor (R499 = orphan label italic, 1st anchor).
4866
+ Orphan band rest-state strokeDasharray switches from
4867
+ '6 6' (prefix-group default) to '3 6' (tighter
4868
+ dashes). Pre-R503 the rect dash pattern was uniform
4869
+ across all bands; combined with R499's italic label,
4870
+ the orphan box now has TWO independent paint/
4871
+ typography differentiators at rest:
4872
+ R499 fontStyle: italic (label text)
4873
+ R503 '3 6' dash pattern (rect stroke) ← this round
4874
+ The R85 marching-ants animation continues to work
4875
+ with the new dash size (uses --march-dur custom
4876
+ property, dash-length-agnostic) — orphan's ants
4877
+ just have a different visual rhythm than prefix-
4878
+ group ants, reinforcing the catchall semantic.
4879
+ Pinned/hovered orphan still gets 'none' (solid
4880
+ stroke) so the hover/pin affordance is preserved
4881
+ — the differentiation lives ONLY in the rest
4882
+ state, never blocking inspection.
4883
+ Pure paint axis; no geometry change; bbox unchanged
4884
+ (strokeDasharray is paint-only). R51 SVG sentinel
4885
+ safety untouched (overlap-test gates to g[data-
4886
+ node], this cluster rect is invisible to it).
4887
+ data-group-box-orphan attr surfaces the gate for
4888
+ tests + future polish references. */
4889
+ strokeDasharray={
4890
+ (isPinned || isHovered) ? 'none' :
4891
+ box.isOrphan ? '3 6' : '6 6'
4892
+ }
4893
+ data-group-box-orphan={box.isOrphan ? 'true' : 'false'}
4524
4894
  /* Round 380 / Loop: cluster box stroke gets round
4525
4895
  linecap + round linejoin. Sibling SVG stroke-
4526
4896
  softening polish to R378 flow-rail linecap + R379
@@ -4865,16 +5235,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4865
5235
  ease-out' alongside the existing fill/ls/fw/opacity
4866
5236
  200ms tweens. */
4867
5237
  data-group-label-glow={isPinned ? 'true' : 'false'}
5238
+ /* Round 499 / Loop — orphan band "其他" label gets
5239
+ fontStyle: italic to visually distinguish the
5240
+ catchall from real prefix-group bands. Pre-R499
5241
+ the orphan box label rendered identically to
5242
+ prefix-group labels (Hero D fontSize=9, fw=700,
5243
+ opacity 0.55 rest), so users had to read the
5244
+ literal text "其他" to identify the catchall. R499
5245
+ adds a pure-typography differentiation: italic
5246
+ signals "this is the misc bucket, not a real
5247
+ named group" while preserving full opacity
5248
+ affordance on hover/pin — the orphan box stays
5249
+ equally inspectable, just typographically marked
5250
+ as a different category. No geometry change
5251
+ (italic shifts glyph slant within the same bbox),
5252
+ no opacity loss, no behavior change. Sibling to
5253
+ R432 letter-spacing 3-tier + R457 pin fw-lift +
5254
+ R479 pin drop-shadow at the group-label scope.
5255
+ Falls under 配色 / 节点视觉 themes per the prompt;
5256
+ advances the "信息密度" axis by encoding
5257
+ category-distinction into a single typography
5258
+ channel without adding visual chrome. */
4868
5259
  style={{
4869
5260
  transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
4870
5261
  letterSpacing: isPinned ? '0.5px' :
4871
5262
  isHovered ? '0.25px' : '0px',
5263
+ fontStyle: box.isOrphan ? 'italic' : undefined,
4872
5264
  filter: isPinned
4873
5265
  ? `drop-shadow(0 0 3px ${pal.legendAccent}80)`
4874
5266
  : undefined,
4875
5267
  }}
4876
5268
  data-group-label={box.key}
4877
5269
  data-group-label-pinned={isPinned ? 'true' : 'false'}
5270
+ data-group-label-orphan={box.isOrphan ? 'true' : 'false'}
4878
5271
  >
4879
5272
  {box.key}
4880
5273
  {/* Round 19 / Loop: member-count chip. Inline tspan stays
@@ -5961,11 +6354,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5961
6354
  data-edge-badge-opacity-rest={isLight ? 0.95 : 0.85}
5962
6355
  data-edge-badge-opacity-hover="1"
5963
6356
  data-edge-badge-opacity-active="1"
5964
- data-edge-badge-glow={isHot ? 'true' : 'false'}
6357
+ data-edge-badge-glow={(isHoveredEdge || isPinned) ? 'hover' : isHot ? 'hot' : 'false'}
6358
+ /* Round 534 / Loop — extends edge-badge drop-shadow
6359
+ coverage from hot-only (R480 amber) to also fire
6360
+ on hover/pin with a cyan accent glow. Pre-R534
6361
+ the badge's hover/pin lifted r (R164 9 → 10.5)
6362
+ + sw (R394 1.25 → 1.5) + opacity (R395/R396 →
6363
+ 1.0), but the paint axis stayed at the badge's
6364
+ rest fill — no glow to telegraph "in focus" at
6365
+ the paint layer. R534 closes that 4-axis hover-
6366
+ lift parity by adding drop-shadow glow on
6367
+ (hovered || pinned).
6368
+ Precedence: (hover || pin) wins over isHot when
6369
+ BOTH true — interactive signal (user is
6370
+ inspecting) overrides informational signal
6371
+ (hot lane). When only isHot fires (no hover/
6372
+ pin) the amber R480 glow remains; the hover/
6373
+ pin case paints cyan/teal `pal.legendAccent`
6374
+ at 0x99 alpha (~60%) — bright enough to read
6375
+ as "lit" but won't overwhelm at small badge
6376
+ size (r=10.5).
6377
+ Edge-badge 4-axis hover-lift parity now:
6378
+ R164 r 9 → 10.5
6379
+ R394 stroke-wd 1.25 → 1.5
6380
+ R395 opacity rest → 1.0
6381
+ R534 filter none → drop-shadow glow ← this round
6382
+ Drop-shadow visual-polish family extension —
6383
+ edge-badge surface upgraded from single-gate
6384
+ (R480 isHot) to two-gate (isHot OR hover-pin).
6385
+ transition list already includes filter 200ms
6386
+ ease-out (R480). data-edge-badge-glow attr
6387
+ upgraded from `isHot ? true : false` to a
6388
+ 3-value string: 'hot' | 'hover' | 'false' so
6389
+ tests can distinguish gate cause.
6390
+ R51 sentinel safety: badge is edge-internal
6391
+ (not g[data-node] ancestor); filter is paint-
6392
+ only; bbox unchanged. */
5965
6393
  style={{
5966
- filter: isHot
5967
- ? `drop-shadow(0 0 3px ${hotStroke}80)`
5968
- : undefined,
6394
+ filter: (isHoveredEdge || isPinned)
6395
+ ? `drop-shadow(0 0 3px ${pal.legendAccent}99)`
6396
+ : isHot
6397
+ ? `drop-shadow(0 0 3px ${hotStroke}80)`
6398
+ : undefined,
5969
6399
  transition: 'r 180ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out, fill 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
5970
6400
  }}
5971
6401
  />
@@ -6265,6 +6695,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6265
6695
  data-hub-busyness={busy}
6266
6696
  data-topo-hub-halo-radius={haloR}
6267
6697
  data-topo-hub-halo-hovered={isHaloHovered ? 'true' : 'false'}
6698
+ data-topo-hub-halo-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6268
6699
  data-topo-hub-halo-trough={isLight ? troughLight : troughDark}
6269
6700
  data-topo-hub-halo-peak={isLight ? peakLight : peakDark}
6270
6701
  /* Round 253 / Loop: hub grounding halo fill transition
@@ -6276,10 +6707,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6276
6707
  conflict.
6277
6708
  R451: r as CSS property (R197/R198 idiom) so the
6278
6709
  hover-radius tween eases smoothly under the same
6279
- 200ms cadence as fill. */
6710
+ 200ms cadence as fill.
6711
+ R536: extends hub-cluster glow QUARTET (R476 digit
6712
+ + R532 disc + R535 ring + R533 spokes) to a 5th
6713
+ tier — the halo gains drop-shadow at the outermost
6714
+ concentric ring on hub-hover. 2px blur + 0.3 alpha
6715
+ keeps the halo's glow subtle since (a) the halo is
6716
+ the LARGEST hub element (r=22 hover) and a heavier
6717
+ glow would bleed visibly past the ring tier into
6718
+ the spoke origin, and (b) the halo already SMIL-
6719
+ animates opacity (R84/R244 breath), so the visible
6720
+ glow pulses with the breath — an atmospheric
6721
+ "breathing glow" idiom rather than a static rim.
6722
+ Hub-cluster glow QUINTET (R476/R532/R533/R535/R536):
6723
+ digit (typo center) 3px emerald 0.6
6724
+ disc (r=5.5/6) 3px emerald 0.6
6725
+ ring (r=14/17) 3px emerald 0.5
6726
+ halo (r=20/22) 2px emerald 0.3 ← this round
6727
+ spokes (mesh) 1.5px cyan/teal 0.4
6728
+ Emerald palette continues through the focal-disc
6729
+ family (digit/disc/ring/halo); spokes break out
6730
+ into cyan/teal at the mesh tier. The 4-step alpha
6731
+ ladder 0.6→0.6→0.5→0.3 reads as the focal cluster
6732
+ fading outward — the OUTERMOST emerald glow is the
6733
+ softest, the focal digit is the brightest.
6734
+ filter is paint-only; SMIL animate on opacity
6735
+ continues independently (attribute vs CSS-property
6736
+ non-conflicting). transition list extends to
6737
+ 'filter 200ms ease-out' alongside fill + r.
6738
+ Drop-shadow visual-polish family extension (12
6739
+ anchors). preview.50 milestone round. data-topo-
6740
+ hub-halo-glow attr exposes the gate state. */
6280
6741
  style={{
6281
6742
  r: `${haloR}px`,
6282
- transition: 'fill 200ms ease-out, r 200ms ease-out',
6743
+ filter: !reducedMotion && hoveredHub
6744
+ ? (isLight
6745
+ ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.3))'
6746
+ : 'drop-shadow(0 0 2px rgba(52, 211, 153, 0.3))')
6747
+ : undefined,
6748
+ transition: 'fill 200ms ease-out, r 200ms ease-out, filter 200ms ease-out',
6283
6749
  } as React.CSSProperties}
6284
6750
  >
6285
6751
  {/* Round 244 / Loop: hub grounding halo breath gets
@@ -6489,22 +6955,91 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6489
6955
  so the glow eases under the same cadence as the
6490
6956
  scale + fw + fill axes. */
6491
6957
  data-topo-hub-working-count-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6958
+ /* Round 507 / Loop — focal recede. When ANY non-hub
6959
+ canvas surface is hovered (a node / an edge / a
6960
+ group label / a legend row / a vendor chip), the
6961
+ hub-center workingCount digit fades to 0.85 opacity,
6962
+ signaling "you're inspecting elsewhere, hub recedes
6963
+ to background." When the user un-hovers (or hovers
6964
+ the hub itself), opacity returns to 1.0. Pure paint
6965
+ polish at the canvas's most prominent focal point.
6966
+ Hits 信息密度 + 动效 themes — the hub digit gives
6967
+ way visually to the surface under inspection,
6968
+ reinforcing the "this is the focal point right now"
6969
+ gesture without requiring users to track which
6970
+ surface holds attention.
6971
+ Gate excludes hoveredHub specifically: hovering the
6972
+ hub itself should LIFT the digit (R425 fw bump +
6973
+ R476 glow + R209 scale 1.08) — the existing hover-
6974
+ on-hub signature is intact; only inspection
6975
+ ELSEWHERE recedes the hub.
6976
+ Composed from existing hoveredAlias / hoveredEdge-
6977
+ Key / hoveredGroupLabel / hoveredStatus / hovered-
6978
+ Vendor — no new state. 300ms ease-out opacity
6979
+ transition already in the style list (existing R213
6980
+ transition spec), so the fade rides on existing
6981
+ infrastructure.
6982
+ data-topo-hub-recede attr surfaces the gate state
6983
+ for tests. */
6984
+ data-topo-hub-recede={
6985
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6986
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
6987
+ }
6988
+ /* Round 527 / Loop — focal-amplify family extension to a
6989
+ 2nd anchor. R511 introduced focal-amplify at the hub-
6990
+ highlight disc (base opacity 0.95 → 1.0 on hover); R527
6991
+ extends to the hub-center workingCount digit with a
6992
+ letter-spacing tween 0 → 0.3px on hub-hover.
6993
+ Composes with existing 3-axis hub-hover signature on
6994
+ this element:
6995
+ R209 transform scale(1.08) geometry
6996
+ R425 fontWeight 700 → 800 typography weight
6997
+ R476 filter drop-shadow glow paint
6998
+ R527 letter-spacing 0 → 0.3px typography kerning ← this round
6999
+ tabular-nums (R225) preserved — each digit cell keeps
7000
+ fixed width; the inter-digit advance grows by 0.3px
7001
+ per gap. Single-digit counts (1-9) show no kerning
7002
+ effect; multi-digit counts (10+) show the spread as
7003
+ info-density signaling. Sibling to R427/R431/R432/
7004
+ R433/R434 (hover-letter-spacing family at panel-text
7005
+ scope) — R527 brings the same idiom to the canvas's
7006
+ most-read scalar.
7007
+ Reduced-motion gate matches R209 scale, R425 fw, R476
7008
+ filter — !reducedMotion gates the lift; reducedMotion
7009
+ users see static digit baseline regardless of hover.
7010
+ Focal-amplify family extension (2 anchors): R511 hub-
7011
+ highlight opacity / R527 hub-digit letter-spacing.
7012
+ transition list extends to include `letter-spacing
7013
+ 200ms ease-out`, matching the cadence of the other
7014
+ hub-hover axes. data-topo-hub-working-count-letter-
7015
+ spacing attr exposes the resolved value for tests. */
7016
+ data-topo-hub-working-count-letter-spacing={!reducedMotion && hoveredHub ? '0.3px' : '0px'}
6492
7017
  style={{
6493
7018
  pointerEvents: 'none',
6494
7019
  transform: !reducedMotion && hoveredHub ? 'scale(1.08)' : 'scale(1)',
6495
7020
  transformBox: 'fill-box',
6496
7021
  transformOrigin: 'center',
7022
+ opacity: (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
7023
+ hoveredStatus || hoveredVendor) && !hoveredHub
7024
+ ? 0.85
7025
+ : 1,
6497
7026
  filter: !reducedMotion && hoveredHub
6498
7027
  ? (isLight
6499
7028
  ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.6))'
6500
7029
  : 'drop-shadow(0 0 3px rgba(52, 211, 153, 0.6))')
6501
7030
  : undefined,
7031
+ letterSpacing: !reducedMotion && hoveredHub ? '0.3px' : '0px',
6502
7032
  /* R425: font-weight 200ms appended so the hover fw
6503
7033
  bump 700 → 800 eases under the same cadence as
6504
7034
  R209 scale + R253 fill + R213 opacity.
6505
7035
  R476: filter 200ms appended so the new drop-
6506
- shadow glow eases at the same cadence. */
6507
- transition: 'transform 200ms ease-out, opacity 300ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, filter 200ms ease-out',
7036
+ shadow glow eases at the same cadence.
7037
+ R507: opacity 300ms (existing in list) covers
7038
+ the new focal-recede fade.
7039
+ R527: letter-spacing 200ms appended so the new
7040
+ hover-kerning bump eases at the same cadence
7041
+ as the other axes. */
7042
+ 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',
6508
7043
  fontVariantNumeric: 'tabular-nums',
6509
7044
  }}
6510
7045
  >
@@ -6550,19 +7085,205 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6550
7085
  + R213 always-mount opacity-gate + pointerEvents:none
6551
7086
  + R365 r=5.5 all preserved. data-topo-hub-highlight-
6552
7087
  opacity attr exposes the resolved value for tests. */}
6553
- <circle
6554
- cx={cx} cy={cy} r="5.5"
6555
- fill="#d1fae5"
6556
- opacity={workingCount > 0 ? 0 : 0.95}
6557
- data-topo-hub-highlight
6558
- data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
6559
- data-topo-hub-highlight-radius="5.5"
6560
- data-topo-hub-highlight-opacity={workingCount > 0 ? 0 : 0.95}
6561
- style={{
6562
- pointerEvents: 'none',
6563
- transition: 'opacity 300ms ease-out',
6564
- }}
6565
- />
7088
+ {/* Round 508 / Loop — focal-recede pattern 2nd anchor.
7089
+ Extends R507's hub-digit recede to the hub-highlight
7090
+ circle so the hub focal CLUSTER (digit at z-top + this
7091
+ idle-state highlight beneath) recedes as a unit when
7092
+ canvas attention is elsewhere. Computed once: a single
7093
+ non-hub-hover gate drives BOTH the digit (R507) AND
7094
+ this highlight (R508) so they always co-move.
7095
+ Recede multiplies the visible opacity by 0.85 — when
7096
+ workingCount===0 the rest opacity 0.95 becomes 0.81
7097
+ during external-hover; when workingCount>0 the
7098
+ opacity stays 0 (invisible) regardless of recede.
7099
+ Additionally, when recede is active the SMIL breath
7100
+ animation halts (animate node un-mounts) so the
7101
+ receded state reads as quietly static, not pulsing
7102
+ at 0.85↔1.0 against the recede multiplier (which
7103
+ would visually conflict — competing 15% drops). On
7104
+ un-hover the animate re-mounts and breath resumes.
7105
+ data-topo-hub-recede on both digit AND highlight
7106
+ provides a stable test handle for the unified-recede
7107
+ gate.
7108
+ Composed from existing hover state vars — no new
7109
+ state. Pure paint axis. */}
7110
+ {(() => {
7111
+ const hubRecede = !!((hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
7112
+ hoveredStatus || hoveredVendor) && !hoveredHub);
7113
+ /* Round 511 / Loop — hub-highlight gains a 3rd opacity tier
7114
+ (hovered-amplify). Pre-R511 the highlight had 2 visible
7115
+ states: rest (0.95) and recede (0.81 = 0.95 × 0.85).
7116
+ When the hub itself was hovered, the digit got R425 fw
7117
+ lift + R476 drop-shadow + R209 scale-1.08, but the
7118
+ highlight disc sibling stayed at 0.95 — the focal
7119
+ cluster lifted in 3 channels (typography/paint/scale)
7120
+ but the highlight didn't participate.
7121
+ R511 closes that asymmetry: when hoveredHub is true,
7122
+ highlight base opacity lifts to 1.0 (5% boost from
7123
+ rest 0.95). Cluster now lifts as a unit on hub-hover,
7124
+ just like it recedes as a unit on non-hub-hover
7125
+ (R508).
7126
+ 3-state opacity ladder:
7127
+ hub-hovered: baseOpacity = 1.0 (R511 NEW)
7128
+ rest (no hover): baseOpacity = 0.95 (existing)
7129
+ non-hub canvas hov: baseOpacity × 0.85 = 0.81 (R508)
7130
+ Composes cleanly: hubRecede gate requires !hoveredHub,
7131
+ so the hovered-amplify and recede states are mutually
7132
+ exclusive (they can't both fire). breathActive
7133
+ continues to halt on either non-rest state (recede OR
7134
+ hub-hover would visually compete with the 0.85↔1
7135
+ breath — clean for the unit-lift semantic too). */
7136
+ const baseOpacity = workingCount > 0 ? 0
7137
+ : hoveredHub ? 1.0
7138
+ : 0.95;
7139
+ const resolvedOpacity = hubRecede ? baseOpacity * 0.85 : baseOpacity;
7140
+ const breathActive = !reducedMotion && workingCount === 0 && !hubRecede && !hoveredHub;
7141
+ /* Round 529 / Loop — focal-amplify family 3rd anchor.
7142
+ Hub-highlight gains geometric amplify r 5.5 → 6 on
7143
+ hub-hover, mirroring R451's hub-halo r 20 → 22 hover
7144
+ pattern. Pre-R529 the highlight had paint-axis
7145
+ amplify only (R511 opacity 0.95 → 1.0 on hub-hover);
7146
+ R529 adds geometric amplify so the focal disc
7147
+ BREATHES outward on hub attention, like the halo
7148
+ does. Composes with the existing 2-axis hub-hover
7149
+ lift on this element:
7150
+ R511 opacity 0.95 → 1.0 paint (focal-amplify 1st)
7151
+ R529 r 5.5 → 6 geometry (this round)
7152
+ Implementation matches R451: CSS `r` property
7153
+ (R197/R198 idiom) for smooth interpolation. SVG
7154
+ attribute `r="5.5"` provides SSR fallback and serves
7155
+ as default; inline style.r overrides for animated
7156
+ value. transition list extends to include `r 200ms
7157
+ ease-out`, matching the fill cadence (also 200ms);
7158
+ opacity transition stays at 300ms (existing).
7159
+ r 6 sits well inside the existing visual envelope
7160
+ (next-larger sibling r=10 hub core, r=14 hub hover
7161
+ ring). The 0.5px lift is +9% radius / +19% area —
7162
+ enough to read as 'lift' without breaching the core
7163
+ boundary or invalidating overlap-test invariants.
7164
+ SMIL animate on opacity continues independently
7165
+ (animateAttr='opacity' vs CSS-property r — non-
7166
+ conflicting, same pattern R451 noted for halo).
7167
+ Focal-amplify family extension (3 anchors):
7168
+ R511 hub-highlight opacity 0.95 → 1.0
7169
+ R527 hub-digit letter-spacing 0 → 0.3px
7170
+ R529 hub-highlight radius 5.5 → 6 ← this round
7171
+ data-topo-hub-highlight-radius attr now reports the
7172
+ dynamic value (was static '5.5'). */
7173
+ const highlightR = !reducedMotion && hoveredHub ? 6 : 5.5;
7174
+ return (
7175
+ <circle
7176
+ cx={cx} cy={cy} r="5.5"
7177
+ /* Round 509 / Loop — 配色 cross-theme cleanup. Pre-R509
7178
+ the hub-highlight fill was hardcoded `#d1fae5`
7179
+ (emerald-100, a pale tone). On the light theme this
7180
+ near-white green ran against a pale background at
7181
+ 0.95 opacity — the disc was effectively invisible.
7182
+ Matches the existing R253 halo theme-inversion
7183
+ pattern (line ~6481): light theme picks the dark
7184
+ vibrant emerald (#10b981 emerald-600), dark theme
7185
+ keeps the pale emerald (#d1fae5 emerald-100). Both
7186
+ read at the same 0.95 opacity against their
7187
+ respective backdrops — light gets a saturated
7188
+ focal dot; dark keeps the soft glow signature.
7189
+ Pure paint axis (fill change only); bbox unchanged;
7190
+ R51 SVG sentinel safety untouched.
7191
+ transition list already includes `fill 200ms`?
7192
+ Actually the existing transition spec is `opacity
7193
+ 300ms ease-out` — fill change on theme toggle
7194
+ will be instant. That's acceptable: theme toggle
7195
+ is a discrete event, and the halo (line 6500)
7196
+ already snaps fill on theme toggle the same way
7197
+ (`fill 200ms ease-out` was added later to halo
7198
+ via R253). Future round could add `fill 200ms`
7199
+ to highlight too if theme-switch flicker is
7200
+ noticed. */
7201
+ fill={isLight ? '#10b981' : '#d1fae5'}
7202
+ opacity={resolvedOpacity}
7203
+ data-topo-hub-highlight
7204
+ data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
7205
+ data-topo-hub-highlight-radius={highlightR}
7206
+ data-topo-hub-highlight-opacity={resolvedOpacity}
7207
+ data-topo-hub-highlight-breath={breathActive ? 'true' : 'false'}
7208
+ data-topo-hub-highlight-recede={hubRecede ? 'true' : 'false'}
7209
+ data-topo-hub-highlight-hovered={!reducedMotion && hoveredHub ? 'true' : 'false'}
7210
+ data-topo-hub-highlight-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
7211
+ /* Round 510 / Loop — R509 follow-on: theme-toggle fill
7212
+ ease. Pre-R510 the hub-highlight transition spec only
7213
+ listed `opacity 300ms ease-out`. When R509 introduced
7214
+ theme-conditional fill (#d1fae5 ↔ #10b981), the fill
7215
+ change SNAPPED on theme toggle because the transition
7216
+ list didn't include `fill`. R510 extends to `fill
7217
+ 200ms ease-out` so theme cycles smoothly through the
7218
+ emerald palette. 200ms timing matches the R253 halo
7219
+ fill transition (line ~6500) — both hub-cluster
7220
+ theme transitions now share a cadence so the focal
7221
+ cluster (digit + highlight + halo) eases as a unit.
7222
+ R508's recede opacity transition unchanged (300ms);
7223
+ fill is independent.
7224
+ R529: r as CSS property (R197/R198 idiom) + `r
7225
+ 200ms ease-out` appended to transition list so
7226
+ the new hub-hover radius lift (5.5 → 6) eases
7227
+ under the same fill cadence. SVG attr r="5.5"
7228
+ above provides SSR fallback; inline style.r
7229
+ wins the cascade for the dynamic value.
7230
+ R532: filter drop-shadow glow on hub-hover —
7231
+ sibling to R476 hub-digit drop-shadow at the
7232
+ same gate (hoveredHub && !reducedMotion). Two
7233
+ adjacent hub focal elements (digit + highlight
7234
+ disc) now BOTH glow on hub-hover, reading as
7235
+ one unified focal cluster. Emerald palette
7236
+ matches R476:
7237
+ light: drop-shadow(0 0 2px rgba(16,185,129,0.6)) emerald-500
7238
+ cyber: drop-shadow(0 0 3px rgba(52,211,153,0.6)) emerald-400
7239
+ filter is paint-only (bbox unchanged); SMIL
7240
+ animate on opacity continues independently
7241
+ (animateAttr='opacity' vs CSS-property filter
7242
+ — non-conflicting). transition list extends to
7243
+ 'filter 200ms ease-out' alongside fill/r.
7244
+ Drop-shadow visual-polish family extension
7245
+ (8 anchors): R476 hub-digit / R477 legend pin-
7246
+ ring / R478 recent freshness / hot edge / group
7247
+ label / zoom-state / node alias + R532 hub-
7248
+ highlight (this round). data-topo-hub-highlight-
7249
+ glow attr exposes the gate state. */
7250
+ style={{
7251
+ pointerEvents: 'none',
7252
+ r: `${highlightR}px`,
7253
+ filter: !reducedMotion && hoveredHub
7254
+ ? (isLight
7255
+ ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.6))'
7256
+ : 'drop-shadow(0 0 3px rgba(52, 211, 153, 0.6))')
7257
+ : undefined,
7258
+ transition: 'opacity 300ms ease-out, fill 200ms ease-out, r 200ms ease-out, filter 200ms ease-out',
7259
+ } as React.CSSProperties}
7260
+ >
7261
+ {/* Round 497 / Loop — idle-state breath (呼吸感 theme pivot
7262
+ from the R492-R496 press-family arc). Pre-R497 the hub
7263
+ idle highlight read as a static dim disc — present but
7264
+ motionless, visually mute. R497 adds a 4s opacity breath
7265
+ (0.85 ↔ 1.0 ↔ 0.85) so the hub reads "alive but quiet"
7266
+ instead of "frozen", giving the empty-fleet state a
7267
+ subtle living signature.
7268
+ Gates:
7269
+ - !reducedMotion (R29 a11y blanket) — reducedMotion
7270
+ users see static 0.95 disc, no animate
7271
+ - workingCount === 0 — when fleet is busy, the
7272
+ highlight is invisible (opacity=0) so the animate
7273
+ would waste paint cycles. Gating saves work.
7274
+ SMIL <animate> overrides the static opacity={0.95}
7275
+ during its run; falls back to 0.95 when reducedMotion
7276
+ flips on (the animate node simply doesn't render).
7277
+ 4s cycle is long enough to feel like ambient breath
7278
+ rather than a pulse, matching the "quiet" semantic.
7279
+ data-topo-hub-highlight-breath attr exposes the
7280
+ resolved gate state for tests. */}
7281
+ {breathActive && (
7282
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
7283
+ )}
7284
+ </circle>
7285
+ );
7286
+ })()}
6566
7287
  {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
6567
7288
  that fades in when the hub is hovered — the same idea
6568
7289
  R44 used for node avatars (group-hover stroke). r=14
@@ -6628,13 +7349,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6628
7349
  data-topo-hub-hover-ring-radius={hoveredHub ? 17 : 14}
6629
7350
  data-topo-hub-hover-ring-stroke-width="1.75"
6630
7351
  data-topo-hub-hover-ring-opacity={hoveredHub ? (isLight ? 0.85 : 0.8) : 0}
6631
- /* Round 253 / Loop: hub hover ring also gets stroke
6632
- transition for theme toggle (cyber #10b981 ↔ light
6633
- #059669). The opacity + r transitions stay for hover
6634
- lift; stroke closes the theme-snap. */
7352
+ /* Round 535 / Loop completes the hub-cluster glow
7353
+ QUARTET by adding drop-shadow to the hub-hover-ring.
7354
+ Pre-R535 the hub-hover trio (R476 digit + R532 highlight
7355
+ disc + R533 spokes) glowed in unified emerald (digit/
7356
+ disc) + cyan/teal (spokes) on hub-hover, but the ring
7357
+ itself — the outermost solid emerald boundary at
7358
+ r=14→17 — stayed flat. R535 adds the matching emerald
7359
+ drop-shadow to the ring so the FULL hub-cluster glows
7360
+ across all four concentric surfaces on hub-hover:
7361
+ digit (typography center) drop-shadow 0 0 3px emerald
7362
+ highlight disc (r=5.5/6) drop-shadow 0 0 3px emerald
7363
+ hover-ring (r=14/17) drop-shadow 0 0 3px emerald ← this round
7364
+ spokes (mesh) drop-shadow 0 0 1.5px cyan/teal
7365
+ The ring is only visible on hub-hover (opacity=0 rest);
7366
+ adding drop-shadow at the same gate means the glow shows
7367
+ the moment the ring shows — no extra state needed.
7368
+ Same R476/R532 emerald palette since the ring sits
7369
+ inside the focal-disc tier (its color is also emerald
7370
+ #059669/#10b981).
7371
+ transition list extends to include 'filter 200ms ease-
7372
+ out' alongside the existing 180ms opacity/r — slight
7373
+ cadence mismatch (180 vs 200) is acceptable; the filter
7374
+ only appears AFTER the ring fades in via opacity, and
7375
+ the 200ms vs 180ms 20ms tail difference is below
7376
+ perceptual threshold.
7377
+ Drop-shadow visual-polish family extension (11 anchors):
7378
+ the hub-cluster glow quartet (R476/R532/R533/R535) plus
7379
+ the 7 non-hub anchors (R477/R478/R479/R480/R481/R483/
7380
+ R534) makes for a thoroughly polished glow vocabulary
7381
+ across the canvas.
7382
+ data-topo-hub-hover-ring-glow attr exposes the gate
7383
+ state for tests. */
7384
+ data-topo-hub-hover-ring-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6635
7385
  style={{
6636
7386
  pointerEvents: 'none',
6637
- transition: 'opacity 180ms ease-out, r 180ms ease-out, stroke 200ms ease-out',
7387
+ filter: !reducedMotion && hoveredHub
7388
+ ? (isLight
7389
+ ? 'drop-shadow(0 0 3px rgba(16, 185, 129, 0.5))'
7390
+ : 'drop-shadow(0 0 3px rgba(52, 211, 153, 0.5))')
7391
+ : undefined,
7392
+ transition: 'opacity 180ms ease-out, r 180ms ease-out, stroke 200ms ease-out, filter 200ms ease-out',
6638
7393
  }}
6639
7394
  />
6640
7395
  </g>)}
@@ -7547,6 +8302,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7547
8302
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
7548
8303
 
7549
8304
  if (isIntern || internByAlias || vendor.logo) {
8305
+ /* Round 501 / Loop — vendor avatar inside node circles
8306
+ gains a hover-gated brightness lift. Pre-R501 the
8307
+ avatar <image> was the only per-node surface with
8308
+ NO hover treatment: R26 lifted the card, R242 tinted
8309
+ the card stroke, R427 spread the alias letter-
8310
+ spacing, R500 added the alias drop-shadow, R208
8311
+ lifted the runtime badge ring, R443 thickened
8312
+ the badge icon stroke, R177 brightened the
8313
+ halo — but the most visually-prominent element
8314
+ (the vendor logo / 书生 coin centred in each node)
8315
+ stayed paint-static. R501 closes the per-node
8316
+ hover-affordance arc by adding a 15% brightness
8317
+ lift on hover.
8318
+ Implementation: CSS filter: brightness(1.15)
8319
+ when hoveredAlias === session.alias. Pure paint
8320
+ axis on the <image> element — no geometry change,
8321
+ no bbox shift. Modern-browser supported (Chrome 64+
8322
+ / FF 56+ / Safari 9.1+).
8323
+ Hits 节点视觉 theme. data-node-avatar-hovered
8324
+ attr surfaces the gate for tests.
8325
+ Gated on !reducedMotion as a courtesy (brightness
8326
+ transition < ~50ms still feels instant; the gate
8327
+ avoids the transition cycle for a11y users). */
8328
+ const isAvatarHovered = !reducedMotion && hoveredAlias === session.alias;
7550
8329
  return (
7551
8330
  <image
7552
8331
  href={vendor.logo ?? '/intern_avatar.png'}
@@ -7555,6 +8334,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7555
8334
  width={size}
7556
8335
  height={size}
7557
8336
  preserveAspectRatio="xMidYMid meet"
8337
+ data-node-avatar={session.alias}
8338
+ data-node-avatar-hovered={isAvatarHovered ? 'true' : 'false'}
8339
+ style={{
8340
+ filter: isAvatarHovered ? 'brightness(1.15)' : undefined,
8341
+ transition: 'filter 200ms ease-out',
8342
+ }}
7558
8343
  />
7559
8344
  );
7560
8345
  }
@@ -7978,6 +8763,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7978
8763
  R211 fill 300ms + R305 letter-spacing 200ms
7979
8764
  transition list preserved; only the
7980
8765
  conditional gets a middle case. */}
8766
+ {/* Round 500 / Loop — milestone round, opens
8767
+ per-node alias drop-shadow polish. Extends the
8768
+ R476-R481 drop-shadow visual-polish family to a
8769
+ 7th anchor: hovered alias text gains a soft
8770
+ status-coloured text-glow. Pre-R500 hover on
8771
+ a node triggered card-lift (R26 translateY) +
8772
+ card-stroke (R242 tint) + alias letter-spacing
8773
+ (R427 0.3px tier) but the alias TEXT itself had
8774
+ no paint-axis cue beyond fill (R211). R500 adds
8775
+ a drop-shadow on the text glyph itself, so the
8776
+ identity glyph itself lights up under attention
8777
+ — matching the R476 idiom (hub-digit emerald
8778
+ glow on hover) at the per-node identity scope.
8779
+ 2px blur radius at 50% alpha — subtler than the
8780
+ R476 hub-digit (3px at 60%) because the alias
8781
+ text is smaller and more numerous (1 per node)
8782
+ so an aggressive glow would multiply into
8783
+ visual noise. Status-coloured (status.text) so
8784
+ the glow inherits the node's working/idle/
8785
+ offline palette — green/cyan/gray respectively.
8786
+ Drop-shadow visual-polish family — 7 anchors:
8787
+ R476 hub digit hover-gated emerald
8788
+ R477 legend pin-ring pin-gated row.fill
8789
+ R478 recent-row pip fresh-gated cyan
8790
+ R479 group-label text pin-gated cyan
8791
+ R480 hot-lane edge hot-gated amber
8792
+ R481 zoom-state minimap zoom-gated cyan
8793
+ R500 node alias text hover-gated status.text ← this round
8794
+ Filter is paint-only; bbox unchanged; overlap-
8795
+ test invariants hold (R51 selector gated to
8796
+ g[data-node] descendants with strokeWidth
8797
+ sentinels; text element doesn't carry stroke).
8798
+ transition list extends to include 'filter
8799
+ 200ms ease-out' alongside the existing fill
8800
+ 300ms + letter-spacing 200ms tweens.
8801
+ data-node-alias-glow attr surfaces the hover
8802
+ gate for tests. */}
7981
8803
  <text
7982
8804
  x="0" y="1" textAnchor="middle"
7983
8805
  fill={status.text}
@@ -7985,11 +8807,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7985
8807
  data-node-alias-text={session.alias}
7986
8808
  data-node-alias-chat-target={chatAlias === session.alias ? 'true' : 'false'}
7987
8809
  data-node-alias-hovered={hoveredAlias === session.alias ? 'true' : 'false'}
8810
+ data-node-alias-glow={!reducedMotion && hoveredAlias === session.alias ? 'true' : 'false'}
7988
8811
  style={{
7989
- transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out',
8812
+ transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out, filter 200ms ease-out',
7990
8813
  letterSpacing:
7991
8814
  chatAlias === session.alias ? '0.5px' :
7992
8815
  hoveredAlias === session.alias ? '0.3px' : '0px',
8816
+ filter: !reducedMotion && hoveredAlias === session.alias
8817
+ ? `drop-shadow(0 0 2px ${status.text}80)`
8818
+ : undefined,
7993
8819
  }}
7994
8820
  >
7995
8821
  {truncate(session.alias, fullMax)}
@@ -9261,11 +10087,46 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9261
10087
  tier without disturbing the surrounding family
9262
10088
  baseline. data-recent-row-text-font-weight attr
9263
10089
  exposes the value for tests. */
9264
- fontWeight="500"
10090
+ /* Round 530 / Loop — extends hover-fw family
10091
+ (R416/R420/R425/R520/R521/R522, 6 anchors) to
10092
+ a 7th anchor: recent-row alias text gains
10093
+ fontWeight 500 → 600 on (isRowHovered ||
10094
+ isRowPinned). Pre-R530 R363 set fw=500
10095
+ statically; hover/pin lifted other axes
10096
+ (R55 fill brighten / R434 letter-spacing
10097
+ 3-tier / R143 translateY / R104 row bg-
10098
+ tint / R474 cadence) but the fw stayed
10099
+ flat — same asymmetry R520 closed at the
10100
+ +N more footer.
10101
+ R530 mirrors R520's pattern at the row-
10102
+ text scope. Hover OR pin (isRowActive
10103
+ union) lifts fw to 600, matching the count
10104
+ tspan's cold-state tier (R320 fw=600), so
10105
+ on active state the alias label reads at
10106
+ the same data tier as the count it sits
10107
+ next to. Inner count tspan has its own
10108
+ explicit fontWeight (600 or 700 per R320/
10109
+ R445) so parent fw lift doesn't bleed
10110
+ (inheritance overridden).
10111
+ Hover-fw family extension (7 anchors):
10112
+ R416 chip-row count digit
10113
+ R420 chrome zoom-level
10114
+ R425 hub-center digit
10115
+ R520 +N more flows footer
10116
+ R521 chrome nodeSize S/M/L inactive
10117
+ R522 chrome layout Ring/Grid inactive
10118
+ R530 recent-row alias text ← this round
10119
+ transition list extends to include
10120
+ 'font-weight 200ms ease-out', matching the
10121
+ R474 cadence of the existing fill +
10122
+ letter-spacing axes on this element.
10123
+ data-recent-row-text-font-weight attr
10124
+ flips '500' → '600' on isRowActive. */
10125
+ fontWeight={(isRowHovered || isRowPinned) ? '600' : '500'}
9265
10126
  data-recent-row-text={link.key}
9266
10127
  data-recent-row-text-pinned={isRowPinned ? 'true' : 'false'}
9267
10128
  data-recent-row-text-hovered={!isRowPinned && isRowHovered ? 'true' : 'false'}
9268
- data-recent-row-text-font-weight="500"
10129
+ data-recent-row-text-font-weight={(isRowHovered || isRowPinned) ? '600' : '500'}
9269
10130
  /* Round 434 / Loop: recent-signal row text extends
9270
10131
  from R220's pin-only letter-spacing (0 → 0.5 on
9271
10132
  isRowPinned) to a 3-tier scale matching R433
@@ -9316,7 +10177,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9316
10177
  shifts. */
9317
10178
  data-recent-row-text-transition="200ms"
9318
10179
  style={{
9319
- transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out',
10180
+ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out',
9320
10181
  letterSpacing: isRowPinned ? '0.5px' :
9321
10182
  isRowHovered ? '0.25px' : '0px',
9322
10183
  }}
@@ -9399,12 +10260,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9399
10260
  under the same R320 fill cadence. data-
9400
10261
  recent-row-count-pinned attr exposes the
9401
10262
  pin gate for tests. */}
10263
+ {/* Round 498 / Loop — hot-count subtle pulse. Pre-
10264
+ R498 the hot row count signaled via color (R127
10265
+ amber fill) + weight (R320 fw-700) + (R445 pin
10266
+ lift) but stayed visually motionless. R498 adds
10267
+ a 3s opacity breath (0.85↔1.0) on the digit when
10268
+ isHot && !reducedMotion — gentle "alive" signal
10269
+ on the lane carrying ≥ 10 messages, drawing
10270
+ glance without becoming noisy. Sibling of R497
10271
+ hub-idle-breath in the 呼吸感 theme arc; same
10272
+ 0.85↔1.0 amplitude. Class adds an animation-
10273
+ only paint axis; no layout / bbox change. R29
10274
+ blanket also catches `animation-duration` for
10275
+ reducedMotion users, but the component-side
10276
+ gate makes the intent explicit and avoids
10277
+ a node tree thrash for those users (className
10278
+ stays absent rather than present-but-paused). */}
9402
10279
  <tspan
9403
10280
  fill={isHot ? hotStroke : undefined}
9404
10281
  fontWeight={(isHot || isRowPinned) ? '700' : '600'}
10282
+ className={isHot && !reducedMotion ? 'anet-recent-hot-pulse' : undefined}
9405
10283
  data-recent-row-count
9406
10284
  data-recent-row-count-pinned={isRowPinned ? 'true' : 'false'}
9407
10285
  data-recent-row-count-font-weight={(isHot || isRowPinned) ? '700' : '600'}
10286
+ data-recent-row-count-hot-pulse={isHot && !reducedMotion ? 'true' : 'false'}
9408
10287
  {...(isHot ? { 'data-recent-row-count-hot': 'true' } : {})}
9409
10288
  style={{
9410
10289
  transition: 'fill 300ms ease-out, font-weight 200ms ease-out',
@@ -9648,6 +10527,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9648
10527
  stays as is, so the rest-vs-hover delta still
9649
10528
  reads clearly. data-recent-panel-more-font-weight
9650
10529
  attr exposes the value for tests. */}
10530
+ {/* Round 520 / Loop — extends the `+N more flows` footer
10531
+ to a 5-axis hover signature by adding fontWeight
10532
+ 500 → 600 on hover. Pre-R520 the footer carried 4
10533
+ hover axes:
10534
+ R195 fill legendText → legendAccent
10535
+ R325 letter-spacing 0.2 → 0.3px (R344 tween)
10536
+ R325 opacity 0.55 → 0.85
10537
+ R133 underline none → underline
10538
+ R368 had set fontWeight 500 statically as a sibling
10539
+ to R363/R364/R366 small-text fw lift family — but
10540
+ the footer's hover state didn't carry a fontWeight
10541
+ DELTA the way other interactive surfaces do (chip-
10542
+ row counts R416, chrome zoom-level R420, hub digit
10543
+ R425). R520 adds the missing weight axis: fw 500
10544
+ → 600 on hover, so the footer reads "thickening AND
10545
+ lighting up" under cursor — same idiom as the
10546
+ chrome zoom-level R420 / chip-row digit R416 hover-
10547
+ bold pattern, applied at the panel nav-action
10548
+ surface.
10549
+ data-recent-panel-more-font-weight attr value
10550
+ flips from '500' → '600' on hover (was static
10551
+ '500' pre-R520).
10552
+ Bonus closure — R475 panel-text cadence: pre-R520
10553
+ the footer's transition list had `opacity 150ms`
10554
+ while R475 unified panel-text transitions at
10555
+ 200ms. R518 closed the same gap at legend-count.
10556
+ R520 closes the LAST panel-text 150ms holdout
10557
+ here AND adds the new font-weight 200ms axis. All
10558
+ 4 transition properties (opacity / fill / letter-
10559
+ spacing / font-weight) now uniform 200ms at the
10560
+ footer — same cadence as legend-label / legend-
10561
+ count / recent-row alias / recent-row count /
10562
+ group-label. */}
9651
10563
  <text
9652
10564
  x="115" y="82"
9653
10565
  textAnchor="middle"
@@ -9655,14 +10567,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9655
10567
  fontSize="9"
9656
10568
  fontFamily="monospace"
9657
10569
  fontStyle="italic"
9658
- fontWeight="500"
10570
+ fontWeight={hoveredRecentMore ? '600' : '500'}
9659
10571
  letterSpacing={hoveredRecentMore ? '0.3' : '0.2'}
9660
10572
  opacity={hoveredRecentMore ? 0.85 : 0.55}
9661
10573
  textDecoration={hoveredRecentMore ? 'underline' : 'none'}
9662
10574
  data-recent-panel-more={moreCount}
9663
10575
  data-recent-panel-more-hovered={hoveredRecentMore ? 'true' : 'false'}
9664
- data-recent-panel-more-font-weight="500"
9665
- style={{ transition: 'opacity 150ms ease-out, fill 200ms ease-out, letter-spacing 200ms ease-out' }}
10576
+ data-recent-panel-more-font-weight={hoveredRecentMore ? '600' : '500'}
10577
+ data-recent-panel-more-transition="200ms"
10578
+ style={{ transition: 'opacity 200ms ease-out, fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out' }}
9666
10579
  >
9667
10580
  {`+ ${moreCount}`}
9668
10581
  <tspan opacity="0.7" data-recent-panel-more-unit>{` more flow${moreCount === 1 ? '' : 's'}`}</tspan>
@@ -10058,15 +10971,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10058
10971
  R181 pin ring (6 + 0 stroke vs 8 - 0.75 inner
10059
10972
  ≈ 7.25). data-legend-swatch is unchanged so
10060
10973
  R197 / R55 / R61 tests probe the same handle. */}
10974
+ {/* Round 537 / Loop — extends drop-shadow visual-polish
10975
+ family (12 anchors after R536) to a 13th anchor: the
10976
+ legend swatch gains drop-shadow glow on hover/pin
10977
+ using its OWN row fill color (working green / idle
10978
+ teal / offline slate). Pre-R537 the swatch lifted
10979
+ only r (R197/R295 6 → 7) on attention — geometry
10980
+ axis only, no paint glow. R537 adds the paint axis,
10981
+ composing with R181/R402 pin-ring (separate concen-
10982
+ tric circle in the same row.fill color) so on
10983
+ hover/pin the SWATCH AND its pin-ring both contri-
10984
+ bute to a unified tier-coloured glow signature.
10985
+ Hue: row.fill (status hex) concatenated with `99`
10986
+ hex alpha (~60%). Working green / idle teal /
10987
+ offline slate each glow in their OWN tier color
10988
+ — the legend acts as a color-keyed status mirror.
10989
+ 3px blur reads soft; 60% alpha legible without
10990
+ overwhelming the swatch's own paint.
10991
+ Drop-shadow visual-polish family extension (13
10992
+ anchors). filter is paint-only; bbox unchanged.
10993
+ transition list extends to include 'filter 150ms
10994
+ ease-out', matching the existing R197 r 150ms
10995
+ cadence at this swatch. data-legend-swatch-glow
10996
+ attr exposes the gate state for tests. */}
10061
10997
  <circle
10062
10998
  cx="16" cy={row.y0}
10063
10999
  r="6"
10064
11000
  fill={row.fill}
10065
11001
  data-legend-swatch={row.key}
10066
11002
  data-legend-swatch-state={isPinned ? 'pinned' : isRowHovered ? 'hover' : 'idle'}
11003
+ data-legend-swatch-glow={(isRowHovered || isPinned) ? 'true' : 'false'}
10067
11004
  style={{
10068
11005
  r: isRowHovered || isPinned ? '7px' : '6px',
10069
- transition: 'r 150ms ease-out',
11006
+ filter: (isRowHovered || isPinned)
11007
+ ? `drop-shadow(0 0 3px ${row.fill}99)`
11008
+ : undefined,
11009
+ transition: 'r 150ms ease-out, filter 150ms ease-out',
10070
11010
  } as React.CSSProperties}
10071
11011
  />
10072
11012
  {/* R61 pinned-state ring — concentric stroke at r=8 in
@@ -10198,11 +11138,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10198
11138
  the value for tests. R219 letter-spacing pin
10199
11139
  tween + R55 fill transition + R181 always-mount
10200
11140
  pin ring all preserved. */
10201
- fontWeight="500"
11141
+ /* Round 531 / Loop — extends hover-fw family (R416/
11142
+ R420/R425/R520/R521/R522/R530, 7 anchors) to an
11143
+ 8th anchor at the legend-row label. Pre-R531
11144
+ R364 set fw=500 statically; hover/pin lifted
11145
+ other axes (R55 fill brighten / R433 letter-
11146
+ spacing 3-tier / R181 pin ring) but the fw
11147
+ stayed flat. R531 mirrors R530's recent-row
11148
+ alias pattern at the legend-row label scope.
11149
+ Hover OR pin (hoveredStatus===row.key ||
11150
+ isPinned) lifts fw to 600, matching the
11151
+ legend-row count tier (R309 fw=600 / R446
11152
+ pin lift 600→700). Active label now reads at
11153
+ the count's data tier — sibling treatment to
11154
+ R530 recent-row.
11155
+ Hover-fw family extension (8 anchors):
11156
+ R416 chip-row count digit
11157
+ R420 chrome zoom-level
11158
+ R425 hub-center digit
11159
+ R520 +N more flows footer
11160
+ R521 chrome nodeSize S/M/L inactive
11161
+ R522 chrome layout Ring/Grid inactive
11162
+ R530 recent-row alias text
11163
+ R531 legend-row label ← this round
11164
+ Two panel-row label surfaces (R530 recent-
11165
+ row alias + R531 legend-row label) now have
11166
+ parallel hover-fw signatures. R475 cadence
11167
+ at 200ms already covers font-weight via the
11168
+ existing transition list extension at this
11169
+ element. data-legend-row-label-font-weight
11170
+ attr flips '500' → '600' on isActive (was
11171
+ static '500' pre-R531). */
11172
+ fontWeight={(hoveredStatus === row.key || isPinned) ? '600' : '500'}
10202
11173
  data-legend-row-label={row.key}
10203
11174
  data-legend-row-label-pinned={isPinned ? 'true' : 'false'}
10204
11175
  data-legend-row-label-hovered={!isPinned && hoveredStatus === row.key ? 'true' : 'false'}
10205
- data-legend-row-label-font-weight="500"
11176
+ data-legend-row-label-font-weight={(hoveredStatus === row.key || isPinned) ? '600' : '500'}
10206
11177
  /* Round 433 / Loop: legend-row text extends from
10207
11178
  R219's pin-only letter-spacing (0px → 0.5px on
10208
11179
  isPinned) to a 3-tier scale matching the R432
@@ -10245,7 +11216,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10245
11216
  the timing axis shifts. */
10246
11217
  data-legend-row-label-transition="200ms"
10247
11218
  style={{
10248
- transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out',
11219
+ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out',
10249
11220
  letterSpacing: isPinned ? '0.5px' :
10250
11221
  hoveredStatus === row.key ? '0.25px' : '0px',
10251
11222
  }}
@@ -10379,7 +11350,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10379
11350
  data-legend-count-pinned={isPinned ? 'true' : 'false'}
10380
11351
  data-legend-count-font-weight={isPinned ? '700' : '600'}
10381
11352
  data-legend-count-fill={row.count > 0 && (hoveredStatus === row.key || isPinned) ? 'tier' : 'neutral'}
10382
- style={{ pointerEvents: 'none', transition: 'opacity 150ms ease-out, fill 150ms ease-out, font-weight 150ms ease-out', fontVariantNumeric: 'tabular-nums' }}
11353
+ /* Round 518 / Loop extends R433's 3-tier hover-
11354
+ letter-spacing tween from the legend-row LABEL
11355
+ (text at x=30) to the SIBLING legend-row COUNT
11356
+ digit (this text at x=215). Pre-R518 the row's
11357
+ label spread on hover/pin (R433: 0/0.25/0.5px)
11358
+ while the count digit at the row's right edge
11359
+ stayed dead-typographic — same row, two halves,
11360
+ asymmetric kerning gesture. R518 mirrors the
11361
+ 3-tier scale at the count so the WHOLE row's
11362
+ typography reads as one unit under cursor: label
11363
+ + count spread together at matching values.
11364
+ Tabular-nums (R225) makes the kerning still
11365
+ visible on 2-digit counts — each digit cell
11366
+ keeps its fixed width, but the inter-digit
11367
+ advance grows. R518 also closes R475's panel-
11368
+ row TEXT cadence at the count surface — R475
11369
+ lifted the label text transitions to 200ms but
11370
+ the count was missed; R518 lifts opacity / fill
11371
+ / font-weight from 150 → 200ms AND adds the new
11372
+ letter-spacing axis at 200ms. One transition
11373
+ list, one cadence, one motion-coherent multi-
11374
+ axis hover/pin signature across the row.
11375
+ Hover-letter-spacing family extension (10
11376
+ anchors now): R344/R345/R347/R420/R427/R431/
11377
+ R432/R433/R517/R518. R518 closes the legend-
11378
+ row pair (label R433 + count R518). data-
11379
+ legend-count-letter-spacing attr exposes the
11380
+ resolved value for tests. */
11381
+ data-legend-count-letter-spacing={isPinned ? '0.5px' : hoveredStatus === row.key ? '0.25px' : '0px'}
11382
+ data-legend-count-transition="200ms"
11383
+ style={{
11384
+ pointerEvents: 'none',
11385
+ transition: 'opacity 200ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, letter-spacing 200ms ease-out',
11386
+ fontVariantNumeric: 'tabular-nums',
11387
+ letterSpacing: isPinned ? '0.5px' :
11388
+ hoveredStatus === row.key ? '0.25px' : '0px',
11389
+ }}
10383
11390
  >{row.count}</text>
10384
11391
  </g>
10385
11392
  );
@@ -10438,6 +11445,74 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10438
11445
  spacing as typographic intent. Stays well inside the
10439
11446
  bottom-left corner; opacity 0.4 unchanged so the
10440
11447
  watermark stays a watermark. */}
11448
+ {/* Round 519 / Loop — 呼吸感 family 3rd anchor. Pre-R519 the
11449
+ breath family had 2 anchors (R497 hub idle digit + R498
11450
+ recent-row hot pulse). Both signal active state — the
11451
+ digit when canvas is idle (no work pending), the recent
11452
+ row when fresh signal arrives. R519 adds a SLOW ambient
11453
+ breath to the brand watermark — present always, not gated
11454
+ on activity state. The watermark IS the canvas-corner
11455
+ register that says "the canvas is alive even when nothing
11456
+ is happening"; a 6s opacity pulse around its 0.4 mean
11457
+ (±0.08 swing → 0.32 ↔ 0.48) reads as ambient liveness
11458
+ rather than foreground signal.
11459
+ Why 6s (not R497's 4s): the breath family now spans
11460
+ activity registers (R497 4s — idle-focal: present and
11461
+ waiting; R498 ~3s — hot signal: just arrived) and now
11462
+ ambient register (R519 6s — corner watermark: always-on
11463
+ background). Slower cadence keeps the watermark in the
11464
+ background; ~10 pct slower than R497 keeps it out of
11465
+ phase so the two anchors never beat together visibly.
11466
+ Gate: !reducedMotion. Inside the prefers-reduced-motion
11467
+ media query, SMIL animate isn't covered by globals.css
11468
+ R29 (which only kills CSS animation property), so we
11469
+ gate at JSX level — when reducedMotion is true the
11470
+ <animate> child isn't mounted and opacity stays at the
11471
+ static 0.4. data-topo-brand-watermark-breath attr
11472
+ exposes the gate state for tests.
11473
+ 呼吸感 family extension (3 anchors): R497 hub idle / R498
11474
+ recent-row hot / R519 brand watermark ambient. */}
11475
+ {/* Round 525 / Loop — focal-recede family 3rd anchor. R507
11476
+ receded the hub-center workingCount digit; R508 receded
11477
+ the hub-highlight disc; both fade to 0.85× when any non-
11478
+ hub canvas surface is hovered (alias / edge / group /
11479
+ status / vendor) — the "you're inspecting elsewhere"
11480
+ gesture. R525 extends the pattern to the brand watermark
11481
+ at canvas bottom-left, the always-on decorative brand
11482
+ element. Pre-R525 the watermark stayed at its R519
11483
+ breath baseline (0.32-0.48 SMIL pulse) regardless of
11484
+ canvas attention; post-R525 it fades to 70% wrapper
11485
+ opacity (effective 0.224-0.336 with breath) when canvas
11486
+ attention is elsewhere, matching the same focal-recede
11487
+ semantic R507/R508 establish at the hub focal cluster.
11488
+ Implementation: wrap the existing <text> in a <g>
11489
+ wrapper whose opacity multiplies with the inner text's
11490
+ SMIL-animated opacity. SVG opacity composes
11491
+ multiplicatively across the parent/child chain, so:
11492
+ normal: g.opacity=1.0 × text.opacity(SMIL 0.32-0.48) = 0.32-0.48
11493
+ recede: g.opacity=0.7 × text.opacity(SMIL 0.32-0.48) = 0.224-0.336
11494
+ SMIL on inner text continues running through both
11495
+ states; only the wrapper opacity flips. 300ms ease-out
11496
+ transition on wrapper (matches R508 hub-highlight recede
11497
+ transition).
11498
+ Gate matches R507/R508 — focal-recede is a UNIFIED
11499
+ non-hub-canvas-hover signal driving multiple anchors,
11500
+ so all three (hub digit / hub-highlight / brand
11501
+ watermark) fade together as the canvas's decorative
11502
+ register, leaving only the surface under inspection
11503
+ foregrounded.
11504
+ Focal-recede family extension (3 anchors): R507 hub
11505
+ digit / R508 hub-highlight / R525 brand watermark. */}
11506
+ <g
11507
+ opacity={(hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11508
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 0.7 : 1}
11509
+ data-topo-brand-watermark-wrapper
11510
+ data-topo-brand-watermark-recede={
11511
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11512
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
11513
+ }
11514
+ style={{ pointerEvents: 'none', transition: 'opacity 300ms ease-out' }}
11515
+ >
10441
11516
  <text
10442
11517
  x="16" y="672"
10443
11518
  fontSize="11" fontFamily="monospace" fontWeight="600"
@@ -10445,8 +11520,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10445
11520
  fill={pal.legendText}
10446
11521
  opacity="0.4"
10447
11522
  data-topo-brand-watermark
11523
+ data-topo-brand-watermark-breath={reducedMotion ? 'false' : 'true'}
10448
11524
  style={{ pointerEvents: 'none', transition: 'fill 200ms ease-out' }}
10449
- >sleep2agi</text>
11525
+ >sleep2agi{!reducedMotion && (
11526
+ <animate attributeName="opacity" values="0.32;0.48;0.32" dur="6s" repeatCount="indefinite" />
11527
+ )}</text>
11528
+ </g>
10450
11529
  {/* v0.10.0 Hero 3 Wave 1 / RFC §3.I (Vincent 5215 + 通信龙
10451
11530
  lead-autonomy Q4 dual-anchor minimal): canvas top-left
10452
11531
  crescent moon brand mark, visible ONLY when the
@@ -10481,10 +11560,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10481
11560
  the R175 panel-fade-in uses for cascade rhythm. data-
10482
11561
  topo-brand-canvas-mark-visible exposes the gate for
10483
11562
  tests. */}
11563
+ {/* Round 526 / Loop — focal-recede family 4th anchor.
11564
+ Symmetric polish to R525 (watermark recede). The
11565
+ brand crescent at canvas top-left is the second
11566
+ decorative brand element on the canvas; pre-R526 it
11567
+ stayed at flat opacity 0.35 (when visible) regardless
11568
+ of canvas attention. R526 multiplies its visible
11569
+ opacity by 0.7 when ANY non-hub canvas surface is
11570
+ hovered, matching R525's deeper-recede semantic for
11571
+ decorative brand elements (vs hub focal cluster's
11572
+ 0.85× recede at R507/R508).
11573
+ Composes cleanly with existing flowLinks gate:
11574
+ normal, flowLinks=0: opacity = 0.35 * 1.0 = 0.350
11575
+ recede, flowLinks=0: opacity = 0.35 * 0.7 = 0.245
11576
+ invisible, flowLinks>0: opacity = 0.00 * any = 0.000
11577
+ Multiplicative chain means recede only matters when
11578
+ crescent is visible (quiet canvas, flowLinks=0) —
11579
+ exactly when canvas attention elsewhere should
11580
+ dim the decorative register. 300ms transition
11581
+ already covers both axes (the existing visibility
11582
+ opacity ramp + the new recede multiplier easing).
11583
+ Focal-recede family extension (4 anchors): R507 hub
11584
+ digit / R508 hub-highlight / R525 watermark / R526
11585
+ crescent (this round). Canvas brand surfaces (R525
11586
+ watermark + R526 crescent) now BOTH carry focal-
11587
+ recede at the same 0.7 multiplier, fading as a
11588
+ decorative pair when the canvas's focal attention
11589
+ shifts elsewhere.
11590
+ data-topo-brand-canvas-mark-recede attr exposes the
11591
+ gate state for tests. */}
10484
11592
  <g
10485
- opacity={flowLinks.length === 0 ? 0.35 : 0}
11593
+ opacity={(flowLinks.length === 0 ? 0.35 : 0) * (
11594
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11595
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 0.7 : 1
11596
+ )}
10486
11597
  data-topo-brand-canvas-mark
10487
11598
  data-topo-brand-canvas-mark-visible={flowLinks.length === 0 ? 'true' : 'false'}
11599
+ data-topo-brand-canvas-mark-recede={
11600
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11601
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
11602
+ }
11603
+ data-topo-brand-canvas-mark-breath={reducedMotion ? 'false' : 'true'}
10488
11604
  style={{ pointerEvents: 'none', transition: 'opacity 300ms ease-out, fill 200ms ease-out' }}
10489
11605
  >
10490
11606
  <defs>
@@ -10494,11 +11610,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10494
11610
  <circle cx="17.5" cy="13" r="10" fill="black" />
10495
11611
  </mask>
10496
11612
  </defs>
11613
+ {/* Round 528 / Loop — 呼吸感 family 4th anchor. Symmetric
11614
+ polish to R519 watermark ambient breath. The brand
11615
+ crescent at canvas top-left is the second decorative
11616
+ canvas brand surface; pre-R528 it stayed at the static
11617
+ composed opacity (wrapper 0.35 × no inner anim = flat).
11618
+ Post-R528 the inner <rect>'s fill-opacity breathes
11619
+ 0.8 ↔ 1.0 on a 7s cycle, composing multiplicatively
11620
+ with the wrapper's recede gate:
11621
+ normal visible: 0.35 × (0.8-1.0) = 0.280-0.350
11622
+ recede visible: 0.245 × (0.8-1.0) = 0.196-0.245
11623
+ invisible: 0 × any = 0
11624
+ 7s cadence intentionally OUT OF PHASE with R519
11625
+ watermark's 6s — the two ambient anchors never beat
11626
+ together visibly when both visible. R497 hub idle
11627
+ breath (4s) is the loudest; R498 recent-row hot pulse
11628
+ (~3s) is the most-active; R519 watermark (6s) +
11629
+ R528 crescent (7s) are the quietest ambient pair.
11630
+ 呼吸感 family extension (4 anchors):
11631
+ R497 hub idle digit 4s active-idle register
11632
+ R498 recent-row hot pulse 3s active-fresh register
11633
+ R519 watermark ambient 6s ambient (always-on)
11634
+ R528 crescent ambient 7s ambient (quiet-only) ← this round
11635
+ SMIL <animate> on fill-opacity (not parent opacity) so
11636
+ the wrapper's React-controlled gate compositions stay
11637
+ intact. Gated on !reducedMotion at JSX level —
11638
+ reducedMotion users see the inner rect at default
11639
+ fill-opacity=1.0 (no SMIL mounted, wrapper's static
11640
+ composed opacity wins). data-topo-brand-canvas-mark-
11641
+ breath attr exposes the gate state. */}
10497
11642
  <rect
10498
11643
  x="16" y="16" width="28" height="28"
10499
11644
  fill={pal.legendText}
10500
11645
  mask="url(#s2a-canvas-corner-mask)"
10501
- />
11646
+ >
11647
+ {!reducedMotion && (
11648
+ <animate attributeName="fill-opacity" values="0.8;1;0.8" dur="7s" repeatCount="indefinite" />
11649
+ )}
11650
+ </rect>
10502
11651
  </g>
10503
11652
  </svg>
10504
11653
 
@@ -10988,7 +12137,49 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10988
12137
  / fullscreen) preview their active state on hover.
10989
12138
  Pure actions (zoom -/+, reset) stay white — they
10990
12139
  aren't toggles, have no active state to preview. */
10991
- 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' : ''}`}
12140
+ // Round 493 / Loop extends R492 chrome-strip press-feedback
12141
+ // family to nodeSize S/M/L buttons. Adds active:scale-95
12142
+ // alongside the existing color-deepen (R196) + chrome-pop
12143
+ // (R249). transition-transform + duration-200 + ease-out
12144
+ // + transform-gpu added since this className previously had
12145
+ // transition-colors only — without the transform transition,
12146
+ // active:scale-95 would hard-cut. transform-gpu promotes the
12147
+ // layer so scale doesn't trigger paint thrash.
12148
+ /* Round 521 / Loop — extends R270's hover-preview pattern
12149
+ (inactive toggle hover previews the active state's
12150
+ visual register) to the TYPOGRAPHY axis at the chrome
12151
+ nodeSize S/M/L surface. Pre-R521 the inactive variant
12152
+ had `hover:bg-cyan-500/5` (R270 bg preview) but no
12153
+ typography preview — active variant uses `font-medium`
12154
+ (fw 500), inactive variant sat at default fw 400 even
12155
+ on hover.
12156
+ R521 adds `hover:font-medium` + `transition-[font-
12157
+ weight]` to the inactive variant so hovering an
12158
+ inactive S/M/L letter thickens the glyph 400 → 500,
12159
+ previewing the typography of the active state the
12160
+ click would commit to. Sibling to R421 chrome zoom-
12161
+ level fontWeight hover delta (rest 500 → hover 600)
12162
+ and R520 footer fontWeight hover (500 → 600) — same
12163
+ idiom: thicken-on-hover for chrome surfaces with a
12164
+ pre-commit gesture.
12165
+ `font-medium` (500) matches the ACTIVE variant's
12166
+ fw exactly — the inactive hover landing weight equals
12167
+ the active locked weight, so clicking commits to a
12168
+ typography state the eye already saw 'on the way in'.
12169
+ Hover-fw family extension (5 anchors now):
12170
+ R416 chip-row count digit rest 500 → hover 700/600
12171
+ R420 chrome zoom-level rest 500 → hover 600
12172
+ R425 hub-center digit rest 700 → hover 800
12173
+ R520 +N more flows footer rest 500 → hover 600
12174
+ R521 chrome nodeSize S/M/L inactive rest 400 → hover 500 ← this round
12175
+ Active variant `font-medium` unchanged so the rest-vs-
12176
+ active typography distinction stays intact when the
12177
+ user IS clicked-in (active stays at fw 500, inactive
12178
+ rest at fw 400, inactive hover preview at fw 500).
12179
+ data-topo-chrome-nodesize-hover-preview-fw="500" attr
12180
+ exposes the polish for tests. */
12181
+ 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' : ''}`}
12182
+ data-topo-chrome-nodesize-hover-preview-fw={nodeScale === v ? null : '500'}
10992
12183
  style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
10993
12184
  >
10994
12185
  {lbl}
@@ -11030,7 +12221,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11030
12221
  // → white/10) so mouse-down has a tactile dim before the
11031
12222
  // R186 icon pop fires on release.
11032
12223
  // R352: `group` lets the inner svg respond via group-hover.
11033
- 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"
12224
+ // R493 zoom +/− buttons join the chrome-strip active:scale-95
12225
+ // press-feedback family (R492 + nodeSize above). transition-
12226
+ // transform + duration-200 + ease-out + transform-gpu added
12227
+ // since the className had only transition-colors.
12228
+ 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"
11034
12229
  style={{ color: pal.legendText }}
11035
12230
  aria-label="Zoom out"
11036
12231
  title="Zoom out (−)"
@@ -11122,10 +12317,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11122
12317
  ? 'true' : 'false'
11123
12318
  }
11124
12319
  data-topo-chrome-zoom-level-hover={hoveredZoomLevel ? 'true' : 'false'}
12320
+ /* Round 517 / Loop — extends the chrome zoom-level readout
12321
+ from 2-axis (R347 letter-spacing + R420 fontWeight) to
12322
+ 3-axis hover signature by adding a color brighten to
12323
+ pal.legendHeadline. Pre-R517 the readout's color stayed
12324
+ at pal.legendText on hover; the digits got tighter
12325
+ kerning (0→0.5px) and heavier weight (500→600) but
12326
+ stayed the same legendText gray tone. R517 lifts color
12327
+ to legendHeadline on hover so the readout brightens
12328
+ into the headline tier at the same beat — matching the
12329
+ R55/R197/R239 hover-deepen-own-hue idiom that legend-
12330
+ row label + count carry at panel scope. Chrome strip's
12331
+ only data display now has full 3-axis hover signature
12332
+ (letter-spacing + fontWeight + color), parity with the
12333
+ chip-row chips' own hover-brighten pattern.
12334
+ Implementation: inline color uses the same hoveredZoom-
12335
+ Level state as R347/R420 — no new state. Transition
12336
+ already includes 'color 200ms ease-out' (R264) so the
12337
+ brighten eases under the same cadence as the kerning +
12338
+ weight tweens — one motion-coherent 3-axis lift.
12339
+ data-topo-chrome-zoom-level-color attr exposes the
12340
+ resolved color string for tests. */
12341
+ data-topo-chrome-zoom-level-color={hoveredZoomLevel ? 'headline' : 'text'}
11125
12342
  onMouseEnter={() => setHoveredZoomLevel(true)}
11126
12343
  onMouseLeave={() => setHoveredZoomLevel(false)}
11127
12344
  style={{
11128
- color: pal.legendText,
12345
+ color: hoveredZoomLevel ? pal.legendHeadline : pal.legendText,
11129
12346
  borderColor: pal.containerBorder,
11130
12347
  minWidth: 46,
11131
12348
  display: 'inline-block',
@@ -11170,7 +12387,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11170
12387
  data-topo-chrome-zoom-in-popping={chromePopping === 'zoom-in' ? 'true' : 'false'}
11171
12388
  // R196: press-state (mirror of zoom-out above).
11172
12389
  // R352: `group` lets the inner svg respond via group-hover.
11173
- 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"
12390
+ // R493 zoom +/− buttons join the chrome-strip active:scale-95
12391
+ // press-feedback family (R492 + nodeSize above). transition-
12392
+ // transform + duration-200 + ease-out + transform-gpu added
12393
+ // since the className had only transition-colors.
12394
+ 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"
11174
12395
  style={{ color: pal.legendText }}
11175
12396
  aria-label="Zoom in"
11176
12397
  title="Zoom in (+)"
@@ -11222,7 +12443,14 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11222
12443
  Every standalone interactive HTML surface in TopoGraph
11223
12444
  now lifts on hover. data-topo-chrome-reset-hover-lift
11224
12445
  attr surfaces the lift for tests. */
11225
- 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"
12446
+ // R493 reset button joins the chrome-strip active:scale-95
12447
+ // press-feedback family. The button already has transition-
12448
+ // transform + transform-gpu (R350 reset spin + R400 hover lift),
12449
+ // so just appending active:scale-95 plugs straight in. Compound
12450
+ // active state during press = hover-lift (-1px) + scale-95
12451
+ // composes as translateY(-1px) scale(0.95) — lift-and-compress
12452
+ // for tactile click feel.
12453
+ 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"
11226
12454
  data-topo-chrome-reset-hover-lift="true"
11227
12455
  style={{ background: pal.legendBox.fill, borderColor: pal.containerBorder, color: pal.legendText }}
11228
12456
  aria-label="Reset view"
@@ -11270,8 +12498,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11270
12498
  // owns transform during its 450ms run. transformOrigin
11271
12499
  // 'center' so rotation pivots around the icon's centre
11272
12500
  // (default would be top-left and the icon would arc).
12501
+ /* Round 514 / Loop — extends R352/R353 chrome icon hover-
12502
+ scale family to the reset button. Pre-R514 the reset
12503
+ icon had hover-rotate (-8°, R350) + hover-sw (2.5→2.8,
12504
+ R453) but no hover-scale, while zoom-out (R352), zoom-
12505
+ in (R352), and fullscreen (R353) icons all carried
12506
+ `group-hover:scale-110`. R514 brings the reset icon
12507
+ into the same 3-axis hover signature (rotate + sw +
12508
+ scale) as the rest of the chrome strip.
12509
+ Implementation: inline transform composes rotate +
12510
+ scale into one string. `transform: rotate(-8deg)
12511
+ scale(1.1)` on hover; `rotate(0) scale(1)` at rest.
12512
+ transformOrigin 'center' applies to both — rotation
12513
+ pivots around centre AND scale grows from centre.
12514
+ The Tailwind `group-hover:scale-110` approach can't
12515
+ work here because inline `style.transform` overrides
12516
+ className-based transforms; compose the multi-axis
12517
+ transform inline instead.
12518
+ Chrome icon hover gesture parity (post-R514):
12519
+ zoom-out scale-110 + sw-lift (R352/R454)
12520
+ zoom-in scale-110 + sw-lift (R352/R454)
12521
+ fullscreen scale-110 + sw-lift (R353/R455)
12522
+ reset scale-1.1 + sw-lift + rotate -8°
12523
+ (R514 + R453 + R350)
12524
+ reset gets the EXTRA rotate axis because R350's spin
12525
+ preview semantic is reset-specific — the rotation
12526
+ hints at the click-spin (R184) the button will fire. */
11273
12527
  style={{
11274
- transform: hoveredReset && !resetSpinning ? 'rotate(-8deg)' : 'rotate(0deg)',
12528
+ transform: hoveredReset && !resetSpinning ? 'rotate(-8deg) scale(1.1)' : 'rotate(0deg) scale(1)',
11275
12529
  transformOrigin: 'center',
11276
12530
  transition: 'transform 200ms ease-out, stroke-width 200ms ease-out',
11277
12531
  }}
@@ -11315,7 +12569,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11315
12569
  // fullscreen now all carry an icon-level hover gesture in
11316
12570
  // addition to the bg hover).
11317
12571
  // R400: hover translateY(-1px) lift — see reset button above for family doc.
11318
- 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 ${
12572
+ // R493 fullscreen joins active:scale-95 press family (same as
12573
+ // reset above: lift-and-compress compound transform on press).
12574
+ 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 ${
11319
12575
  isFullscreen
11320
12576
  ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25'
11321
12577
  : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'