@sleep2agi/agent-network-dashboard 0.5.3-preview.6 → 0.5.3-preview.61

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 (217) 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/{16qx24lc72~7v.js → 0.oqi5e71_k-b.js} +1 -1
  145. package/.next/static/chunks/{03a4--7ncekmk.js → 0v4-5tng.uh.7.js} +2 -2
  146. package/.next/static/chunks/0zop07q5qaq.x.js +4 -0
  147. package/.next/static/chunks/11ue0vx~aooky.css +2 -0
  148. package/.next/static/chunks/147n27~o0ha5z.js +1 -0
  149. package/.next/static/chunks/16_ei2l8ob67~.js +1 -0
  150. package/.next/trace +2 -2
  151. package/.next/trace-build +1 -1
  152. package/app/components/ServersDrawer.tsx +16 -3
  153. package/app/components/TopoGraph.tsx +1621 -80
  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-digit-ls-test.mjs +135 -0
  160. package/scripts/topo-chip-row-press-test.mjs +93 -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-edge-pill-glow-test.mjs +67 -0
  166. package/scripts/topo-filter-pill-glow-test.mjs +90 -0
  167. package/scripts/topo-filter-pills-press-test.mjs +96 -0
  168. package/scripts/topo-fleet-density-tier-test.mjs +84 -0
  169. package/scripts/topo-freshness-chip-fade-test.mjs +105 -0
  170. package/scripts/topo-fullscreen-attr-test.mjs +73 -0
  171. package/scripts/topo-grid-content-bottom-attr-test.mjs +72 -0
  172. package/scripts/topo-group-label-hover-glow-test.mjs +86 -0
  173. package/scripts/topo-group-pill-glow-test.mjs +76 -0
  174. package/scripts/topo-hub-digit-ls-test.mjs +119 -0
  175. package/scripts/topo-hub-halo-glow-test.mjs +96 -0
  176. package/scripts/topo-hub-highlight-amplify-test.mjs +139 -0
  177. package/scripts/topo-hub-highlight-fill-transition-test.mjs +84 -0
  178. package/scripts/topo-hub-highlight-glow-test.mjs +99 -0
  179. package/scripts/topo-hub-highlight-r-test.mjs +112 -0
  180. package/scripts/topo-hub-highlight-recede-test.mjs +144 -0
  181. package/scripts/topo-hub-highlight-theme-fill-test.mjs +83 -0
  182. package/scripts/topo-hub-hover-ring-glow-test.mjs +97 -0
  183. package/scripts/topo-hub-idle-breath-test.mjs +109 -0
  184. package/scripts/topo-hub-recede-test.mjs +124 -0
  185. package/scripts/topo-hub-spoke-glow-test.mjs +112 -0
  186. package/scripts/topo-layout-hover-fw-test.mjs +98 -0
  187. package/scripts/topo-legend-count-letter-spacing-test.mjs +108 -0
  188. package/scripts/topo-legend-label-fw-test.mjs +107 -0
  189. package/scripts/topo-legend-swatch-glow-test.mjs +109 -0
  190. package/scripts/topo-minimap-hover-glow-test.mjs +109 -0
  191. package/scripts/topo-nodesize-hover-fw-test.mjs +99 -0
  192. package/scripts/topo-orphan-box-dash-test.mjs +89 -0
  193. package/scripts/topo-orphan-fill-opacity-test.mjs +91 -0
  194. package/scripts/topo-orphan-label-italic-test.mjs +90 -0
  195. package/scripts/topo-pill-x-rotate-test.mjs +96 -0
  196. package/scripts/topo-pinned-aspect-test.mjs +89 -0
  197. package/scripts/topo-pressure-seg-glow-test.mjs +92 -0
  198. package/scripts/topo-pressure-seg-motion-test.mjs +101 -0
  199. package/scripts/topo-recent-hot-pulse-test.mjs +102 -0
  200. package/scripts/topo-recent-more-fw-test.mjs +126 -0
  201. package/scripts/topo-recent-row-fw-test.mjs +115 -0
  202. package/scripts/topo-reduced-motion-attr-test.mjs +69 -0
  203. package/scripts/topo-reset-icon-hover-scale-test.mjs +102 -0
  204. package/scripts/topo-starfield-hue-test.mjs +109 -0
  205. package/scripts/topo-vendor-activelinks-press-test.mjs +100 -0
  206. package/scripts/topo-vendor-chip-glow-test.mjs +97 -0
  207. package/scripts/topo-vendor-pill-glow-test.mjs +98 -0
  208. package/scripts/topo-watermark-breath-test.mjs +100 -0
  209. package/scripts/topo-watermark-recede-test.mjs +114 -0
  210. package/scripts/topo-zoom-level-color-test.mjs +105 -0
  211. package/.next/static/chunks/018~fceya_6uk.css +0 -2
  212. package/.next/static/chunks/0ic678xqvd4ys.js +0 -4
  213. package/.next/static/chunks/0swbhc-5l4rz9.js +0 -1
  214. package/.next/static/chunks/17v63m4g4.i5h.js +0 -1
  215. /package/.next/static/{FpDDygQl1AAn4qwXBn3mt → htr2G1aFLFScIt2YRzHa7}/_buildManifest.js +0 -0
  216. /package/.next/static/{FpDDygQl1AAn4qwXBn3mt → htr2G1aFLFScIt2YRzHa7}/_clientMiddlewareManifest.js +0 -0
  217. /package/.next/static/{FpDDygQl1AAn4qwXBn3mt → htr2G1aFLFScIt2YRzHa7}/_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'}
@@ -2198,7 +2263,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2198
2263
  at the chip-count scope. Sibling edits on the
2199
2264
  online + active-links chip digits below. data-
2200
2265
  working-chip-digit attr exposes the digit span. */}
2201
- <span className="font-semibold transition-[font-weight] duration-200 group-hover:font-bold" data-working-chip-digit>{workingCount}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-working-chip-unit> working</span>
2266
+ {/* Round 539 / Loop chip-row digit gains group-hover:
2267
+ tracking-wide alongside the existing R362 group-
2268
+ hover:font-bold. Pre-R539 the chip digit lifted
2269
+ only on the font-weight axis (600 → 700 on chip
2270
+ hover); R539 adds the kerning axis (tracking
2271
+ normal → tracking-wide ≈ 0.025em ≈ 0.3px on a 12px
2272
+ digit) so hover lifts BOTH typography axes
2273
+ together — same idiom R420/R517 establish at the
2274
+ chrome zoom-level (letter-spacing + fontWeight
2275
+ hover delta) and R531/R530 mirror at the panel
2276
+ label scope. transition-[font-weight] extends to
2277
+ transition-[font-weight,letter-spacing] for the
2278
+ smooth dual-axis tween.
2279
+ Sibling treatment across the 3 chip-row digits
2280
+ (working / online / active-links) — single concept
2281
+ replicated at 3 surfaces by replace_all.
2282
+ Hover-letter-spacing family extension (12 anchors
2283
+ now): R344/R345/R347/R420/R427/R431/R432/R433/
2284
+ R434/R517/R518 + R539 (this round). */}
2285
+ <span className="font-semibold transition-[font-weight,letter-spacing] duration-200 group-hover:font-bold group-hover:tracking-wide" data-working-chip-digit>{workingCount}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-working-chip-unit> working</span>
2202
2286
  </span>
2203
2287
  <span
2204
2288
  // Round 201 / Loop: online chip — mirror of the working
@@ -2214,9 +2298,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2214
2298
  same digit-jitter physics on count crossings). */
2215
2299
  // R398: hover translate-y lift on clickable variant — see working chip above.
2216
2300
  // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
2301
+ // R494 sibling — online chip joins the active:scale-95 press
2302
+ // family (gated on onlineNodes.length > 0 clickable branch,
2303
+ // same conditional pattern as the working chip above).
2217
2304
  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
2305
  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'
2306
+ ? '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
2307
  : 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20'
2221
2308
  }`}
2222
2309
  data-chip-hover-lift={onlineNodes.length > 0 ? 'true' : 'false'}
@@ -2266,7 +2353,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2266
2353
  >
2267
2354
  {/* R337 sibling — online chip unit demotion. */}
2268
2355
  {/* R362 sibling — online-chip digit gains font-semibold. */}
2269
- <span className="font-semibold transition-[font-weight] duration-200 group-hover:font-bold" data-online-chip-digit>{onlineNodes.length}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-online-chip-unit> online</span>
2356
+ {/* R539 sibling online chip digit. Same idiom as
2357
+ working chip above (group-hover:tracking-wide). */}
2358
+ <span className="font-semibold transition-[font-weight,letter-spacing] duration-200 group-hover:font-bold group-hover:tracking-wide" data-online-chip-digit>{onlineNodes.length}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-online-chip-unit> online</span>
2270
2359
  </span>
2271
2360
  </>
2272
2361
  );
@@ -2355,7 +2444,41 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2355
2444
  height: '100%',
2356
2445
  cursor: 'pointer',
2357
2446
  boxShadow: isPinned ? `inset 0 0 0 1px ${color}, inset 0 0 0 2px rgba(255,255,255,0.6)` : undefined,
2358
- filter: hoveredStatus === key ? 'brightness(1.2)' : undefined,
2447
+ /* Round 542 / Loop pressure-bar segments gain
2448
+ drop-shadow tier-color glow on hover, stacked
2449
+ on R210 brightness(1.2). Sibling to R537 legend
2450
+ swatch + R541 vendor chip glow at the chip-row
2451
+ scope — three same-pattern surfaces (legend
2452
+ swatch / vendor chip / pressure segment) all
2453
+ radiate their identity color on hover.
2454
+ 3rd anchor in the chip-row tier-color paint
2455
+ glow sub-family:
2456
+ R537 legend swatch row.fill (status hex)
2457
+ R541 vendor chip v.color (hsl via color-mix)
2458
+ R542 pressure seg color (status hex) ← this round
2459
+ Stacked filter syntax (brightness + drop-shadow
2460
+ in same filter declaration): `brightness(1.2)
2461
+ drop-shadow(...)`. CSS filter supports multiple
2462
+ functions; they apply left-to-right. Brightness
2463
+ boosts the segment's own color, drop-shadow
2464
+ paints the outer halo. Together: hovered seg
2465
+ looks "lit up" with both inner glow + outer
2466
+ halo in its tier color.
2467
+ Hue: `${color}99` hex+alpha (60%) — color here
2468
+ is a 6-char hex (e.g., '#22c55e' for working
2469
+ cyber, '#0d9488' for idle light), not hsl, so
2470
+ hex+alpha concat works (unlike R541 vendor
2471
+ which needed color-mix for hsl). Banked
2472
+ pattern: hex sources use hex+alpha; hsl/color()
2473
+ sources use color-mix.
2474
+ 2px blur (vs R537's 3px) since pressure-seg is
2475
+ small (h-2 = 8px tall, variable width) — a
2476
+ smaller blur keeps the glow tight to the
2477
+ segment without bleeding into neighbors.
2478
+ filter is paint-only; bbox unchanged; R51
2479
+ overlap-test invariants hold. Transition list
2480
+ already includes `filter` (post-R524). */
2481
+ filter: hoveredStatus === key ? `brightness(1.2) drop-shadow(0 0 2px ${color}99)` : undefined,
2359
2482
  transition: 'width 220ms ease-out, box-shadow 150ms ease-out, filter 150ms ease-out',
2360
2483
  }}
2361
2484
  onClick={(e) => {
@@ -2491,9 +2614,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2491
2614
  // R355: `group` lets the inner opacity-70 spans (prefix
2492
2615
  // `filter:` + count `· N`) brighten to 100 % on pill hover.
2493
2616
  // 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"
2617
+ // R495 filter pills (3 sibling `group` variants) join the
2618
+ // active:scale-95 press-feedback family. R490's !important
2619
+ // transition list on .anet-topo-chip-focus already covers
2620
+ // transform, so just appending active:scale-95 to the
2621
+ // className wires the press tactile in one token. Compound
2622
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2623
+ 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
2624
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2496
2625
  onClick={() => setPinnedStatus(null)}
2626
+ /* Round 543 / Loop — status filter pill gains always-on
2627
+ tier-color drop-shadow when rendered. Pre-R543 the
2628
+ pill carried bg-tint + tier-color text + border but
2629
+ no outer paint extent — it sat as a flat tinted chip
2630
+ in the chip row. R543 adds an outer glow at the
2631
+ pill's text color so the pill radiates a soft tier-
2632
+ colored halo signaling "this filter is active." Pin
2633
+ pill only renders when pinnedStatus is set (the JSX
2634
+ gate above), so the drop-shadow appearing reinforces
2635
+ the visual "active pin" state.
2636
+ Sibling pattern: R477 legend pin-ring also paints a
2637
+ pin-gated tier-color drop-shadow. Pin pill follows
2638
+ the same "pin-gated paint glow" semantics but at the
2639
+ chip-row scope vs the panel-row scope. The chip-row
2640
+ tier-color glow trio (R537/R541/R542 hover-gated)
2641
+ plus R543 (pin-gated, this round) closes the chip-
2642
+ row paint-glow family across BOTH gate types
2643
+ (hover for transient affordance, pin for sticky
2644
+ active-state visual).
2645
+ Hue: explicit tier color (extracted from the
2646
+ existing `color` ternary). 0x99 alpha (~60%) +
2647
+ 3px blur. Stays inside the same color hierarchy
2648
+ as the pill's own text/border (currentColor).
2649
+ R543 status pill scope only — R543's pattern can
2650
+ future-extend to group/vendor/edge filter pills
2651
+ (3 more variants at lines 2683/2755/2824). Out of
2652
+ scope to keep R543 single-pill. */
2497
2653
  style={{
2498
2654
  background: pinnedStatus === 'working' ? (isLight ? '#05966914' : '#22c55e1f')
2499
2655
  : pinnedStatus === 'idle' ? (isLight ? '#0d948814' : '#2dd4bf1f')
@@ -2503,6 +2659,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2503
2659
  : (isLight ? '#475569' : '#9ca3af'),
2504
2660
  borderColor: 'currentColor',
2505
2661
  cursor: 'pointer',
2662
+ filter: `drop-shadow(0 0 3px ${
2663
+ pinnedStatus === 'working' ? (isLight ? '#047857' : '#86efac')
2664
+ : pinnedStatus === 'idle' ? (isLight ? '#0f766e' : '#5eead4')
2665
+ : (isLight ? '#475569' : '#9ca3af')
2666
+ }99)`,
2506
2667
  }}
2507
2668
  >
2508
2669
  {/* Round 412 / Loop: filter pin pill VALUE picks up the
@@ -2535,7 +2696,24 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2535
2696
  inline-block is default for <button> so no display
2536
2697
  tweak needed. replace_all covers all 4 filter pin
2537
2698
  pills (status / group / vendor / edge) at once. */
2538
- className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2699
+ /* Round 547 / Loop — extends pill × close-button hover
2700
+ gesture from scale-110 (R356) + opacity-70 to ALSO
2701
+ include rotate-12 on hover. Pre-R547 the × dimmed
2702
+ and grew on hover; R547 adds a 12° twist so the
2703
+ close action telegraphs "discarding/spinning away"
2704
+ with a small delight gesture. Composes with
2705
+ transition-transform (existing) — Tailwind's
2706
+ hover:rotate-12 + hover:scale-110 stack into one
2707
+ transform under the same 200ms ease-out tween.
2708
+ Applied to all 4 pill × buttons (status / group /
2709
+ vendor / edge) via replace_all since the className
2710
+ is identical. Closes the pill × hover gesture
2711
+ vocabulary at 3 axes:
2712
+ hover:opacity-70 paint dim
2713
+ hover:scale-110 geometry grow (R356)
2714
+ hover:rotate-12 geometry twist (R547, this round)
2715
+ Hover-gesture parity across the 4-pill family. */
2716
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 hover:rotate-12 transform-gpu"
2539
2717
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2540
2718
  >×</button>
2541
2719
  </span>
@@ -2555,14 +2733,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2555
2733
  data-filter-match-count={matchCount}
2556
2734
  data-filter-match-aliases={matchAliases.join(',')}
2557
2735
  // 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"
2736
+ // R495 filter pills (3 sibling `group` variants) join the
2737
+ // active:scale-95 press-feedback family. R490's !important
2738
+ // transition list on .anet-topo-chip-focus already covers
2739
+ // transform, so just appending active:scale-95 to the
2740
+ // className wires the press tactile in one token. Compound
2741
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2742
+ 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
2743
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear filter'}
2560
2744
  onClick={() => setPinnedGroup(null)}
2745
+ /* Round 544 / Loop — extends R543 pin-active filter-pill
2746
+ drop-shadow pattern to the GROUP pill (2nd of 4 pill
2747
+ variants). Pre-R544 the group pill carried bg-tint +
2748
+ pal.legendAccent text/border but no outer paint glow.
2749
+ R544 adds the matching cyan-accent drop-shadow so the
2750
+ group pin pill radiates the same paint glow as R543
2751
+ status pill — pin-active visual signal at chip-row
2752
+ scope.
2753
+ Hue: pal.legendAccent (cyber #67e8f9 cyan-300 /
2754
+ light #0d9488 teal-600). Uses color-mix() syntax
2755
+ because pal.legendAccent may resolve to hex; same
2756
+ syntax works for both hex and hsl sources (banked
2757
+ R541 lesson). 60% alpha + 3px blur — same intensity
2758
+ as R543 status pill so the pin-active visual signal
2759
+ reads with matching brightness across pill variants.
2760
+ Pin-active tier-color paint glow sub-family
2761
+ (CLOSED progressively):
2762
+ R477 legend pin-ring (panel-row, row.fill)
2763
+ R543 status pill (chip-row, tier-color)
2764
+ R544 group pill (chip-row, legendAccent)
2765
+ ← this round
2766
+ Out of scope: vendor pill (line ~2755) + edge pill
2767
+ (line ~2824) — can future-extend in subsequent
2768
+ rounds (R545/R546). Both use the same R543 idiom:
2769
+ always-on drop-shadow when rendered, color from the
2770
+ pill's existing text color. */
2561
2771
  style={{
2562
2772
  background: isLight ? '#67e8f914' : '#67e8f91f',
2563
2773
  color: pal.legendAccent,
2564
2774
  borderColor: 'currentColor',
2565
2775
  cursor: 'pointer',
2776
+ filter: `drop-shadow(0 0 3px color-mix(in srgb, ${pal.legendAccent} 60%, transparent))`,
2566
2777
  }}
2567
2778
  >
2568
2779
  {/* R412: see status pill above — filter value fw=600 data tier. */}
@@ -2585,7 +2796,24 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2585
2796
  inline-block is default for <button> so no display
2586
2797
  tweak needed. replace_all covers all 4 filter pin
2587
2798
  pills (status / group / vendor / edge) at once. */
2588
- className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2799
+ /* Round 547 / Loop — extends pill × close-button hover
2800
+ gesture from scale-110 (R356) + opacity-70 to ALSO
2801
+ include rotate-12 on hover. Pre-R547 the × dimmed
2802
+ and grew on hover; R547 adds a 12° twist so the
2803
+ close action telegraphs "discarding/spinning away"
2804
+ with a small delight gesture. Composes with
2805
+ transition-transform (existing) — Tailwind's
2806
+ hover:rotate-12 + hover:scale-110 stack into one
2807
+ transform under the same 200ms ease-out tween.
2808
+ Applied to all 4 pill × buttons (status / group /
2809
+ vendor / edge) via replace_all since the className
2810
+ is identical. Closes the pill × hover gesture
2811
+ vocabulary at 3 axes:
2812
+ hover:opacity-70 paint dim
2813
+ hover:scale-110 geometry grow (R356)
2814
+ hover:rotate-12 geometry twist (R547, this round)
2815
+ Hover-gesture parity across the 4-pill family. */
2816
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 hover:rotate-12 transform-gpu"
2589
2817
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2590
2818
  >×</button>
2591
2819
  </span>
@@ -2621,14 +2849,40 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2621
2849
  data-filter-match-count={matchCount}
2622
2850
  data-filter-match-aliases={matchAliases.join(',')}
2623
2851
  // 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"
2852
+ // R495 filter pills (3 sibling `group` variants) join the
2853
+ // active:scale-95 press-feedback family. R490's !important
2854
+ // transition list on .anet-topo-chip-focus already covers
2855
+ // transform, so just appending active:scale-95 to the
2856
+ // className wires the press tactile in one token. Compound
2857
+ // with R400-era hover:-translate-y-px gives lift-and-compress.
2858
+ 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
2859
  title={matchCount > 0 ? `${matchPreview}${matchSuffix} — click to clear` : 'Click to clear vendor filter'}
2626
2860
  onClick={() => setPinnedVendor(null)}
2861
+ /* Round 545 / Loop — extends pin-active filter-pill drop-
2862
+ shadow pattern to VENDOR pill (3rd of 4 pill variants
2863
+ after R543 status + R544 group). vendorColor is HSL
2864
+ format (banked R541 lesson — vendorDist.color sources
2865
+ from mono.text in vendorIdentity.ts, which is `hsl(...)`),
2866
+ so the filter uses color-mix() syntax — same as R544.
2867
+ 60% alpha + 3px blur, matching R543/R544 intensity for
2868
+ consistent pin-active visual signal across all pill
2869
+ variants.
2870
+ Pin-active tier-color paint glow sub-family (progressive
2871
+ extension, 1 pill variant remaining):
2872
+ R477 legend pin-ring (panel-row, row.fill, hex+alpha)
2873
+ R543 status pill (chip-row, tier-color, hex+alpha)
2874
+ R544 group pill (chip-row, legendAccent, color-mix)
2875
+ R545 vendor pill (chip-row, vendorColor, color-mix)
2876
+ ← this round
2877
+ Out of scope: edge pill (line ~2824 pre-R545, now ~2900+).
2878
+ Final 1/4 pill remaining for a future round closes the
2879
+ sub-family. */
2627
2880
  style={{
2628
2881
  background: `${vendorColor}1f`,
2629
2882
  color: vendorColor,
2630
2883
  borderColor: 'currentColor',
2631
2884
  cursor: 'pointer',
2885
+ filter: `drop-shadow(0 0 3px color-mix(in srgb, ${vendorColor} 60%, transparent))`,
2632
2886
  }}
2633
2887
  >
2634
2888
  {/* R412: see status pill above — filter value fw=600 data tier. */}
@@ -2651,7 +2905,24 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2651
2905
  inline-block is default for <button> so no display
2652
2906
  tweak needed. replace_all covers all 4 filter pin
2653
2907
  pills (status / group / vendor / edge) at once. */
2654
- className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
2908
+ /* Round 547 / Loop — extends pill × close-button hover
2909
+ gesture from scale-110 (R356) + opacity-70 to ALSO
2910
+ include rotate-12 on hover. Pre-R547 the × dimmed
2911
+ and grew on hover; R547 adds a 12° twist so the
2912
+ close action telegraphs "discarding/spinning away"
2913
+ with a small delight gesture. Composes with
2914
+ transition-transform (existing) — Tailwind's
2915
+ hover:rotate-12 + hover:scale-110 stack into one
2916
+ transform under the same 200ms ease-out tween.
2917
+ Applied to all 4 pill × buttons (status / group /
2918
+ vendor / edge) via replace_all since the className
2919
+ is identical. Closes the pill × hover gesture
2920
+ vocabulary at 3 axes:
2921
+ hover:opacity-70 paint dim
2922
+ hover:scale-110 geometry grow (R356)
2923
+ hover:rotate-12 geometry twist (R547, this round)
2924
+ Hover-gesture parity across the 4-pill family. */
2925
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 hover:rotate-12 transform-gpu"
2655
2926
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2656
2927
  >×</button>
2657
2928
  </span>
@@ -2684,14 +2955,40 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2684
2955
  data-filter-match-count={link.count}
2685
2956
  data-filter-match-aliases={`${link.from},${link.to}`}
2686
2957
  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"
2958
+ // R495 sibling 4th filter pill (no `group` prefix variant)
2959
+ // joins active:scale-95 press family alongside the 3 group
2960
+ // variants above. Same recipe.
2961
+ 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
2962
  title={`${link.from} → ${link.to} (${link.count} msg${link.count === 1 ? '' : 's'}${isHot ? ', hot lane · ≥ 10' : ''}) — click to clear`}
2689
2963
  onClick={() => setPinnedEdgeKey(null)}
2964
+ /* Round 546 / Loop — CLOSES pin-active filter-pill drop-
2965
+ shadow sub-family at the 4th and final pill variant
2966
+ (edge pill). R543 (status) + R544 (group) + R545
2967
+ (vendor) covered the first three; R546 closes at
2968
+ edge.
2969
+ Pin-active tier-color paint glow sub-family CLOSED
2970
+ (4 anchors):
2971
+ R477 legend pin-ring (panel-row, row.fill)
2972
+ R543 status pill (chip-row, tier-color text)
2973
+ R544 group pill (chip-row, legendAccent)
2974
+ R545 vendor pill (chip-row, vendorColor)
2975
+ R546 edge pill (chip-row, pal.flowEdge)
2976
+ ← this round, family CLOSED
2977
+ All 4 filter pin pills now radiate paint glow in the
2978
+ same hue family as their text/border on render —
2979
+ pin-active visual signal uniform across the chip-row
2980
+ pill family.
2981
+ pal.flowEdge is theme-driven (dynamic); color-mix
2982
+ syntax safe-defaults regardless of resolved format
2983
+ (banked R541/R544/R545 pattern). 60% alpha + 3px blur
2984
+ — same intensity as R543/R544/R545 for consistent
2985
+ cross-pill visual signal. */
2690
2986
  style={{
2691
2987
  background: isLight ? `${pal.flowEdge}14` : `${pal.flowEdge}1f`,
2692
2988
  color: pal.flowEdge,
2693
2989
  borderColor: 'currentColor',
2694
2990
  cursor: 'pointer',
2991
+ filter: `drop-shadow(0 0 3px color-mix(in srgb, ${pal.flowEdge} 60%, transparent))`,
2695
2992
  }}
2696
2993
  >
2697
2994
  {/* R412: filter pin pill value (edge variant) picks up fw=600.
@@ -2744,7 +3041,24 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2744
3041
  inline-block is default for <button> so no display
2745
3042
  tweak needed. replace_all covers all 4 filter pin
2746
3043
  pills (status / group / vendor / edge) at once. */
2747
- className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 transform-gpu"
3044
+ /* Round 547 / Loop — extends pill × close-button hover
3045
+ gesture from scale-110 (R356) + opacity-70 to ALSO
3046
+ include rotate-12 on hover. Pre-R547 the × dimmed
3047
+ and grew on hover; R547 adds a 12° twist so the
3048
+ close action telegraphs "discarding/spinning away"
3049
+ with a small delight gesture. Composes with
3050
+ transition-transform (existing) — Tailwind's
3051
+ hover:rotate-12 + hover:scale-110 stack into one
3052
+ transform under the same 200ms ease-out tween.
3053
+ Applied to all 4 pill × buttons (status / group /
3054
+ vendor / edge) via replace_all since the className
3055
+ is identical. Closes the pill × hover gesture
3056
+ vocabulary at 3 axes:
3057
+ hover:opacity-70 paint dim
3058
+ hover:scale-110 geometry grow (R356)
3059
+ hover:rotate-12 geometry twist (R547, this round)
3060
+ Hover-gesture parity across the 4-pill family. */
3061
+ className="ml-0.5 leading-none hover:opacity-70 transition-transform duration-200 ease-out hover:scale-110 hover:rotate-12 transform-gpu"
2748
3062
  style={{ background: 'transparent', color: 'inherit', cursor: 'pointer', padding: 0 }}
2749
3063
  >×</button>
2750
3064
  </span>
@@ -3034,7 +3348,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3034
3348
  // — sibling to R355 filter-pill prefix/suffix + R414
3035
3349
  // chip-row unit brighten. Closes the inner-span
3036
3350
  // 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"
3351
+ // R496 vendor letter chip joins active:scale-95 press
3352
+ // family. Last vendor-row clickable joining the family
3353
+ // R495 cashed via R490's transition-cascade dividend.
3354
+ // Same compound w/ R401 hover-lift idiom — lift-and-
3355
+ // compress on press, springs back on release.
3356
+ 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
3357
  data-vendor-letter={v.initial}
3039
3358
  data-vendor-letter-count={v.count}
3040
3359
  data-vendor-letter-hover-lift="true"
@@ -3061,6 +3380,41 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3061
3380
  // for older browsers the chip falls back to its idle
3062
3381
  // transparent bg (graceful degradation — the canvas-
3063
3382
  // dim effect still fires regardless).
3383
+ /* Round 541 / Loop — vendor letter chip gains drop-
3384
+ shadow glow on hover/pin using its OWN vendor
3385
+ identity color (v.color). Sibling to R537 legend-
3386
+ swatch tier-color glow at the chip-row scope.
3387
+ Pre-R541 the vendor chip lifted on multiple
3388
+ axes (R354 inner-glyph scale-1.1, R202 bg color-
3389
+ mix tint, R180 box-shadow pin-mirror inset, R401
3390
+ hover-translate-y -1px, R496 active:scale-95
3391
+ press) but no paint-axis glow extending past
3392
+ the chip's bbox. R541 adds the outer glow at
3393
+ the paint axis so the vendor chip's identity
3394
+ color radiates beyond the chip on attention —
3395
+ same idiom as legend swatch tier-color glow.
3396
+ 2-tier alpha ladder (mirrors R538 group-label):
3397
+ pin (committed) v.color 99 (~60%)
3398
+ hover (preview) v.color 66 (~40%)
3399
+ rest none
3400
+ Pin is brighter to distinguish locked vs preview
3401
+ at the paint axis. The R180 inset box-shadow
3402
+ (pin-mirror) and R541 outer drop-shadow compose
3403
+ at pin — inside chrome reads as "this is pinned"
3404
+ (inset white double-ring), outside paint reads
3405
+ as "vendor identity is locked" (vendor-colour
3406
+ outer glow). Hover gets only the outer glow.
3407
+ 3px blur tuned to read as soft chip-halo without
3408
+ overwhelming adjacent chips in the chip row.
3409
+ filter property is in the .anet-topo-chip-focus
3410
+ class transition list (R524 banked fix), so the
3411
+ filter eases at 200ms naturally.
3412
+ Drop-shadow visual-polish family — R541 adds
3413
+ chip-row tier-color paint glow as another anchor
3414
+ in the same family pattern R537 established.
3415
+ data-vendor-glow attr ('pin' | 'hover' | 'false')
3416
+ exposes the gate state for tests. */
3417
+ data-vendor-glow={isPinned ? 'pin' : hoveredVendor === v.initial ? 'hover' : 'false'}
3064
3418
  style={{
3065
3419
  cursor: 'pointer',
3066
3420
  backgroundColor: (hoveredVendor === v.initial && !isPinned)
@@ -3069,7 +3423,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3069
3423
  boxShadow: isPinned
3070
3424
  ? `inset 0 0 0 1px ${v.color}, inset 0 0 0 2px rgba(255,255,255,0.45)`
3071
3425
  : undefined,
3072
- transition: 'box-shadow 150ms ease-out, background-color 200ms ease-out',
3426
+ filter: isPinned
3427
+ ? `drop-shadow(0 0 3px color-mix(in srgb, ${v.color} 60%, transparent))`
3428
+ : hoveredVendor === v.initial
3429
+ ? `drop-shadow(0 0 3px color-mix(in srgb, ${v.color} 40%, transparent))`
3430
+ : undefined,
3431
+ transition: 'box-shadow 150ms ease-out, background-color 200ms ease-out, filter 200ms ease-out',
3073
3432
  }}
3074
3433
  onMouseEnter={() => setHoveredVendor(v.initial)}
3075
3434
  onMouseLeave={() => setHoveredVendor(prev => prev === v.initial ? null : prev)}
@@ -3272,9 +3631,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3272
3631
  data-chip-hover-lift attr exposes the lift surface
3273
3632
  state ('true' clickable, 'false' empty) for tests. */
3274
3633
  // R414: `group` parent + inner unit span group-hover-brighten — see working chip above.
3634
+ // R496 — active-links chip joins active:scale-95 press
3635
+ // family. Sibling to working+online chips (R494). Gated
3636
+ // on `isInteractive` (flowLinks.length > 0) — same R399
3637
+ // conditional pattern used for hover-lift.
3275
3638
  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
3639
  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'
3640
+ ? '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
3641
  : 'bg-gray-500/10 text-gray-400 border-gray-500/20'
3279
3642
  }`}
3280
3643
  data-chip-hover-lift={isInteractive ? 'true' : 'false'}
@@ -3307,7 +3670,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3307
3670
  chip-internal-hierarchy arc. data-active-links-
3308
3671
  chip-unit exposes the unit span for tests. */}
3309
3672
  {/* R362 sibling — active-links chip digit gains font-semibold. */}
3310
- <span className="font-semibold transition-[font-weight] duration-200 group-hover:font-bold" data-active-links-chip-digit>{flowLinks.length}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-active-links-chip-unit> active link{flowLinks.length === 1 ? '' : 's'}</span>
3673
+ {/* R539 sibling active-links chip digit. Same idiom
3674
+ as working + online above. */}
3675
+ <span className="font-semibold transition-[font-weight,letter-spacing] duration-200 group-hover:font-bold group-hover:tracking-wide" data-active-links-chip-digit>{flowLinks.length}</span><span className="opacity-70 transition-opacity duration-200 group-hover:opacity-100" data-active-links-chip-unit> active link{flowLinks.length === 1 ? '' : 's'}</span>
3311
3676
  {rel ? (() => {
3312
3677
  // Round 161 / Loop: extend R160's recency-pip
3313
3678
  // vocabulary up one scope — from per-flow row to
@@ -3521,6 +3886,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3521
3886
  on the canvas root for non-visual consumers.
3522
3887
  Composed from existing onlineNodes / workingCount /
3523
3888
  offlineNodes / flowLinks — no new state. */
3889
+ /* Round 502 / Loop — categorical density-tier paired with the
3890
+ R469 numeric counts. data-topo-fleet-density-tier classifies
3891
+ the fleet size into 5 buckets so external consumers (CSS
3892
+ selectors, Playwright probes, future density-conditional
3893
+ polish gates like R109 dense-label collapse at 16+ nodes)
3894
+ can branch on a stable tier name without re-deriving the
3895
+ threshold logic from the raw numeric. Buckets:
3896
+ 'empty' — onlineNodes.length === 0
3897
+ 'sparse' — 1-3 nodes
3898
+ 'normal' — 4-15 nodes
3899
+ 'dense' — 16-30 nodes (matches R109 collapse gate)
3900
+ 'very-dense' — 31+ nodes
3901
+ Picks the gate boundaries that already drive CONDITIONAL
3902
+ RENDER decisions elsewhere (R109 denseLayout = >16, R110
3903
+ plain-text fallback) so the tier name is semantically
3904
+ aligned with the visual mode the canvas already switches
3905
+ to. Composed from existing onlineNodes — no new state.
3906
+ 12th attr in the canvas state surface set (R462/R466/R467/
3907
+ R469×4/R471×2/R487/R488/R502). 12 attrs covers: build
3908
+ identity, transient/sticky inspection modes, fleet split
3909
+ numerics, fleet density tier, canvas layout/theme, canvas
3910
+ zoom, hover identity. A test harness can snapshot the
3911
+ full canvas state with 12 getAttribute calls. */
3912
+ data-topo-fleet-density-tier={
3913
+ onlineNodes.length === 0 ? 'empty' :
3914
+ onlineNodes.length <= 3 ? 'sparse' :
3915
+ onlineNodes.length <= 15 ? 'normal' :
3916
+ onlineNodes.length <= 30 ? 'dense' :
3917
+ 'very-dense'
3918
+ }
3524
3919
  data-topo-online-count={onlineNodes.length}
3525
3920
  data-topo-working-count={workingCount}
3526
3921
  data-topo-offline-count={offlineNodes.length}
@@ -3586,6 +3981,119 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3586
3981
  categorical) — separate dedicated attrs if/when needed.
3587
3982
  Root svg attribute set now 11 attrs total. */
3588
3983
  data-topo-hovered-alias={hoveredAlias ?? ''}
3984
+ /* Round 504 / Loop — categorical pin-aspect attr paired with
3985
+ R467 any-pinned boolean and R488 hovered-alias identity.
3986
+ Pre-R504 the canvas state surface set told tests WHETHER
3987
+ any pin was active (R467 boolean) but tests had to enumerate
3988
+ 4 individual state vars to determine WHICH pin axis fired:
3989
+ pinnedStatus legend-row status filter
3990
+ pinnedGroup prefix-cluster lock
3991
+ pinnedVendor vendor-chip filter
3992
+ pinnedEdgeKey edge-focus
3993
+ R504 surfaces the active aspect as a single categorical
3994
+ attribute: data-topo-pinned-aspect ∈
3995
+ 'none' no pin active
3996
+ 'status' pinnedStatus only
3997
+ 'group' pinnedGroup only
3998
+ 'vendor' pinnedVendor only
3999
+ 'edge' pinnedEdgeKey only
4000
+ 'multi' 2 or more pins active simultaneously
4001
+ ('multi' covers cross-cutting filters — e.g. user pins
4002
+ status='working' AND vendor='claude' simultaneously to
4003
+ narrow the canvas. Each pin axis is independently
4004
+ dismissable via Esc / individual chip click, so multi
4005
+ states are reachable and worth surfacing as a distinct
4006
+ tier.)
4007
+ 13th attr in the canvas state surface set after R502.
4008
+ Composed from 4 existing state vars — no new state. */
4009
+ /* Round 512 / Loop — 14th canvas state attr. groupBoxes.length
4010
+ surfaces the count of cluster boxes currently rendered in
4011
+ grid layout (always 0 in ring). Paired with R502 categorical
4012
+ density tier + R469 fleet numerics for a complete cluster-
4013
+ cardinality surface:
4014
+ R469 data-topo-online-count node-count
4015
+ R502 data-topo-fleet-density-tier categorical
4016
+ R512 data-topo-cluster-count cluster-count ← this round
4017
+ Use cases:
4018
+ - Playwright: assert orphan-band existence by
4019
+ `cluster-count === N + 1` vs prefix-only `=== N`
4020
+ - external CSS: `[data-topo-cluster-count='1']` to apply
4021
+ single-cluster grid-specific layout adjustments
4022
+ - future polish gates: cluster-count > N could trigger
4023
+ dense-grid mode
4024
+ Composed from existing `groupBoxes.length` — no new state.
4025
+ Always renders (0 in ring layout, N in grid), so tests can
4026
+ rely on attribute presence + value. */
4027
+ data-topo-cluster-count={groupBoxes.length}
4028
+ /* Round 513 / Loop — 15th canvas state attr. Surfaces the
4029
+ user's prefers-reduced-motion preference directly on the
4030
+ root SVG so external CSS / Playwright tests can branch on
4031
+ a11y state without re-reading the media query.
4032
+ reducedMotion is already in component scope (R29 a11y
4033
+ blanket reads it via a useEffect listener); R513 just
4034
+ exposes it as a stable attribute handle.
4035
+ Use cases:
4036
+ - Playwright: assert reduced-motion gates from one attr
4037
+ read instead of mocking media-query state per test
4038
+ - External CSS hooks: `[data-topo-prefers-reduced-motion=
4039
+ "true"]` to apply paint-only overrides (e.g. mute
4040
+ hover glows entirely on a11y instead of just
4041
+ disabling transitions)
4042
+ - Future polish rounds: any motion-gated render can
4043
+ read this attr server-side without the media-query
4044
+ hydration mismatch risk
4045
+ 'true' / 'false' string values (consistent with R466/R467
4046
+ boolean attrs). */
4047
+ data-topo-prefers-reduced-motion={reducedMotion ? 'true' : 'false'}
4048
+ /* Round 515 / Loop — 16th canvas state attr. Surfaces the
4049
+ fullscreen-mode state directly on root SVG so external
4050
+ consumers don't have to traverse the chrome strip's
4051
+ `data-topo-chrome-fullscreen-active` button attr (which
4052
+ measures the BUTTON state, not the canvas state — they
4053
+ agree, but reading from the root is semantically cleaner
4054
+ for canvas-state probes).
4055
+ Composed from existing isFullscreen React state (R103
4056
+ fullscreen toggle).
4057
+ Use cases:
4058
+ - Playwright: assert canvas mode in one attr read
4059
+ (paired with R471 data-topo-layout for ring/grid +
4060
+ R487 data-topo-zoom for zoom level + R513 reduced-
4061
+ motion for a11y mode = 4-axis canvas-mode probe)
4062
+ - External CSS: `[data-topo-fullscreen="true"]` to
4063
+ apply fullscreen-only paint adjustments outside the
4064
+ React tree (e.g. body-level scrollbar hide)
4065
+ 'true' / 'false' string values (consistent with R466/
4066
+ R467/R513 boolean attrs). */
4067
+ data-topo-fullscreen={isFullscreen ? 'true' : 'false'}
4068
+ /* Round 516 / Loop — 17th canvas state attr. Surfaces the
4069
+ grid layout's content-bottom y-coordinate so tests can
4070
+ verify grid content doesn't extend past the viewBox or
4071
+ collide with chrome elements positioned below the canvas.
4072
+ Composed from existing gridContentBottom derived state
4073
+ (computed at line ~915 from gy0 + totalRows * cellH + 8).
4074
+ In ring layout, gridContentBottom is 0 (no grid). In grid
4075
+ layout it's the actual pixel y-coordinate where the
4076
+ cluster bands end.
4077
+ Use cases:
4078
+ - Playwright: assert grid layout doesn't exceed viewBox
4079
+ height (680) without re-computing the layout math
4080
+ - External CSS: `[data-topo-grid-content-bottom='0']` to
4081
+ distinguish ring-mode (no grid content) from grid-mode
4082
+ in CSS without parsing layout attr
4083
+ - Future polish gates: if cluster count grows large
4084
+ enough to push grid bottom past viewBox, can trigger
4085
+ a 'compact' mode automatically */
4086
+ data-topo-grid-content-bottom={gridContentBottom}
4087
+ data-topo-pinned-aspect={(() => {
4088
+ const aspects: string[] = [];
4089
+ if (pinnedStatus) aspects.push('status');
4090
+ if (pinnedGroup) aspects.push('group');
4091
+ if (pinnedVendor) aspects.push('vendor');
4092
+ if (pinnedEdgeKey) aspects.push('edge');
4093
+ if (aspects.length === 0) return 'none';
4094
+ if (aspects.length === 1) return aspects[0];
4095
+ return 'multi';
4096
+ })()}
3589
4097
  /* Round 466 / Loop — aggregate hover signal on the root SVG.
3590
4098
  Exposes a single boolean `data-topo-any-hover` that
3591
4099
  reflects whether ANY hover state in the topology is
@@ -3828,7 +4336,38 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3828
4336
  const x = ((seed * 13) % 1000);
3829
4337
  const y = ((seed * 7) % 680);
3830
4338
  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} />;
4339
+ /* Round 523 / Loop 配色 family extension to a 3rd anchor.
4340
+ Pre-R523 all 14 starfield dots painted at the same
4341
+ hardcoded `#a5b4fc` (indigo-300). The starfield's role
4342
+ is atmospheric depth (R45, R291 comment), but a flat
4343
+ single-hue field reads more like a regular dot grid
4344
+ than a star field — real starlight has color
4345
+ temperature variation (blue-white hot stars / yellow
4346
+ sun-like / cool red).
4347
+ R523 cycles a 3-color deterministic rotation based on
4348
+ `i % 3`:
4349
+ i % 3 === 0 → #a5b4fc indigo-300 (original, cool)
4350
+ i % 3 === 1 → #67e8f9 cyan-300 (cyber accent, hot)
4351
+ i % 3 === 2 → #cbd5e1 slate-300 (neutral, warm white)
4352
+ All three hues sit inside the cyber theme's palette
4353
+ family (indigo / cyan / slate) so the starfield reads
4354
+ varied-but-coherent rather than rainbow. At opacity
4355
+ 0.5 (parent <g>) * 0.35-0.50 (per-dot) the temperature
4356
+ shifts are gentle but perceptible — closes the gap
4357
+ between 'dot grid' and 'star field'.
4358
+ 配色 family extension (3 anchors): R509/R510 hub-
4359
+ highlight cross-theme fill + R523 starfield color
4360
+ temperature variation. Light theme unaffected
4361
+ (starfield gated `!isLight` so light theme stays
4362
+ clean per R45's original 'white surface stays clean'
4363
+ intent).
4364
+ Deterministic on `i` — no JS hydration mismatch,
4365
+ same SSR/client output. data-topo-starfield-dot-hue
4366
+ attr exposes the resolved hue category for tests. */
4367
+ const hues = ['#a5b4fc', '#67e8f9', '#cbd5e1'] as const;
4368
+ const hueNames = ['indigo', 'cyan', 'slate'] as const;
4369
+ const hueIdx = i % 3;
4370
+ 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
4371
  })}
3833
4372
  </g>
3834
4373
  )}
@@ -4366,8 +4905,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4366
4905
  data-topo-hub-spoke-stroke-width={spokeStrokeWidth}
4367
4906
  data-topo-hub-spoke-stroke-width-active="2.25"
4368
4907
  data-topo-hub-spoke-linecap="round"
4908
+ /* Round 533 / Loop — extends drop-shadow visual-polish
4909
+ family to a 9th anchor: hub spokes gain filter:drop-
4910
+ shadow glow on hub-hover. Subtle 1.5px cyan/teal blur
4911
+ applied across ALL spokes simultaneously when the
4912
+ user hovers the hub — the network mesh visually
4913
+ "lights up" in response to focal attention. Sibling
4914
+ to R476 hub-digit + R532 hub-highlight glow at the
4915
+ same gate (hoveredHub && !reducedMotion); together
4916
+ the three anchors (digit + highlight disc + spokes)
4917
+ form a unified focal-cluster glow that signals
4918
+ "you're focused on the hub" across geometry,
4919
+ paint, and mesh-extent axes.
4920
+ Theme-aware glow palette matches the spoke stroke
4921
+ family:
4922
+ light: rgba(13, 148, 136, 0.4) teal-600
4923
+ cyber: rgba(34, 211, 238, 0.4) cyan-400
4924
+ 0.4 alpha keeps the glow subtle across N spokes
4925
+ (30+ at peak fleet sizes) — loud bloom across many
4926
+ edges would compete with the focal cluster itself.
4927
+ 1.5px blur is conservative; tuned so each spoke
4928
+ gains a faint outer halo rather than a wide bloom.
4929
+ filter is paint-only; bbox unchanged; existing
4930
+ R241 transition list extends to 'filter 250ms
4931
+ ease-out' matching the spoke transition cadence
4932
+ (250ms, distinct from the 200ms hub-cluster
4933
+ cadence — spokes ease slightly slower since they
4934
+ respond to per-alias state, not just hub state).
4935
+ data-topo-hub-spoke-glow attr exposes the gate
4936
+ state for tests. */
4937
+ data-topo-hub-spoke-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
4369
4938
  style={{
4370
- transition: 'stroke 250ms ease-out, stroke-width 250ms ease-out, opacity 250ms ease-out',
4939
+ transition: 'stroke 250ms ease-out, stroke-width 250ms ease-out, opacity 250ms ease-out, filter 250ms ease-out',
4940
+ filter: !reducedMotion && hoveredHub
4941
+ ? (isLight
4942
+ ? 'drop-shadow(0 0 1.5px rgba(13, 148, 136, 0.4))'
4943
+ : 'drop-shadow(0 0 1.5px rgba(34, 211, 238, 0.4))')
4944
+ : undefined,
4371
4945
  ...(isActiveSpoke ? {} : {
4372
4946
  animationDelay: `${-(idx * 0.25)}s`,
4373
4947
  // CSS var consumed by `.anet-topo-spoke-flow`
@@ -4515,12 +5089,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4515
5089
  // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
4516
5090
  // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
4517
5091
  // 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)}
5092
+ /* Round 506 / Loop category-differentiation family
5093
+ 3rd anchor. Orphan band rest-state fillOpacity drops
5094
+ slightly below prefix-group rest (0.025/0.045
5095
+ 0.015/0.028). Adds a 3rd independent paint
5096
+ differentiator to the orphan visual signature:
5097
+ R499 fontStyle: italic (label text)
5098
+ R503 '3 6' dash pattern (rect stroke)
5099
+ R506 lower fillOpacity (rect fill) ← this round
5100
+ Three independent channels (typography + stroke
5101
+ pattern + fill density) collectively encode the
5102
+ catchall semantic at rest. Pin and hover branches
5103
+ UNCHANGED (still 0.05/0.09 hover, 0.08/0.13 pin) —
5104
+ orphan box gets full visual emphasis on inspection
5105
+ identical to prefix groups; the differentiation
5106
+ lives ONLY in the unsolicited rest state. The
5107
+ ~40% drop (0.045 → 0.028 cyber, 0.025 → 0.015
5108
+ light) is subtle enough that the orphan box stays
5109
+ visible at rest, just quieter — matches the
5110
+ "misc bucket, less attention-deserving" semantic
5111
+ without losing the visual anchor.
5112
+ Pure paint axis; bbox unchanged; R51 SVG sentinel
5113
+ safety untouched (overlap-test gates to g[data-
5114
+ node], cluster rect invisible to it).
5115
+ data-group-box-fill-opacity attr surfaces the
5116
+ resolved value for tests. */
5117
+ fillOpacity={
5118
+ isPinned ? (isLight ? 0.08 : 0.13)
5119
+ : isHovered ? (isLight ? 0.05 : 0.09)
5120
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
5121
+ : (isLight ? 0.025 : 0.045)
5122
+ }
5123
+ data-group-box-fill-opacity={
5124
+ isPinned ? (isLight ? 0.08 : 0.13)
5125
+ : isHovered ? (isLight ? 0.05 : 0.09)
5126
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
5127
+ : (isLight ? 0.025 : 0.045)
5128
+ }
4521
5129
  stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
4522
5130
  strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
4523
- strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
5131
+ /* Round 503 / Loop category-differentiation family
5132
+ 2nd anchor (R499 = orphan label italic, 1st anchor).
5133
+ Orphan band rest-state strokeDasharray switches from
5134
+ '6 6' (prefix-group default) to '3 6' (tighter
5135
+ dashes). Pre-R503 the rect dash pattern was uniform
5136
+ across all bands; combined with R499's italic label,
5137
+ the orphan box now has TWO independent paint/
5138
+ typography differentiators at rest:
5139
+ R499 fontStyle: italic (label text)
5140
+ R503 '3 6' dash pattern (rect stroke) ← this round
5141
+ The R85 marching-ants animation continues to work
5142
+ with the new dash size (uses --march-dur custom
5143
+ property, dash-length-agnostic) — orphan's ants
5144
+ just have a different visual rhythm than prefix-
5145
+ group ants, reinforcing the catchall semantic.
5146
+ Pinned/hovered orphan still gets 'none' (solid
5147
+ stroke) so the hover/pin affordance is preserved
5148
+ — the differentiation lives ONLY in the rest
5149
+ state, never blocking inspection.
5150
+ Pure paint axis; no geometry change; bbox unchanged
5151
+ (strokeDasharray is paint-only). R51 SVG sentinel
5152
+ safety untouched (overlap-test gates to g[data-
5153
+ node], this cluster rect is invisible to it).
5154
+ data-group-box-orphan attr surfaces the gate for
5155
+ tests + future polish references. */
5156
+ strokeDasharray={
5157
+ (isPinned || isHovered) ? 'none' :
5158
+ box.isOrphan ? '3 6' : '6 6'
5159
+ }
5160
+ data-group-box-orphan={box.isOrphan ? 'true' : 'false'}
4524
5161
  /* Round 380 / Loop: cluster box stroke gets round
4525
5162
  linecap + round linejoin. Sibling SVG stroke-
4526
5163
  softening polish to R378 flow-rail linecap + R379
@@ -4864,17 +5501,63 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4864
5501
  transition list extends to include 'filter 200ms
4865
5502
  ease-out' alongside the existing fill/ls/fw/opacity
4866
5503
  200ms tweens. */
4867
- data-group-label-glow={isPinned ? 'true' : 'false'}
5504
+ /* Round 538 / Loop — extends R479 group-label drop-
5505
+ shadow from pin-only to ALSO fire on hover, with
5506
+ a 2-tier alpha ladder matching the R432 letter-
5507
+ spacing 3-tier (hover at 0.25px / pin at 0.5px)
5508
+ pattern. Pre-R538 the paint axis was binary (lit
5509
+ on pin, dark on hover); R538 adds a softer hover
5510
+ glow that distinguishes from the stronger pin
5511
+ glow without losing the "active state lights up"
5512
+ gesture.
5513
+ 2-tier alpha ladder:
5514
+ pin (committed) cyan 80 hex (~50% alpha)
5515
+ hover (preview) cyan 4d hex (~30% alpha)
5516
+ rest none
5517
+ Pin signature stays distinctively brighter, but
5518
+ hover now telegraphs paint-axis attention too.
5519
+ Sibling to R534 edge-badge hover-precedence
5520
+ extension at the drop-shadow family. R479 hue
5521
+ (pal.legendAccent) preserved across both tiers.
5522
+ data-group-label-glow attr upgraded from binary
5523
+ ('true'/'false') to 3-value ('pin' | 'hover' |
5524
+ 'false') so tests can distinguish gate cause. */
5525
+ data-group-label-glow={isPinned ? 'pin' : isHovered ? 'hover' : 'false'}
5526
+ /* Round 499 / Loop — orphan band "其他" label gets
5527
+ fontStyle: italic to visually distinguish the
5528
+ catchall from real prefix-group bands. Pre-R499
5529
+ the orphan box label rendered identically to
5530
+ prefix-group labels (Hero D fontSize=9, fw=700,
5531
+ opacity 0.55 rest), so users had to read the
5532
+ literal text "其他" to identify the catchall. R499
5533
+ adds a pure-typography differentiation: italic
5534
+ signals "this is the misc bucket, not a real
5535
+ named group" while preserving full opacity
5536
+ affordance on hover/pin — the orphan box stays
5537
+ equally inspectable, just typographically marked
5538
+ as a different category. No geometry change
5539
+ (italic shifts glyph slant within the same bbox),
5540
+ no opacity loss, no behavior change. Sibling to
5541
+ R432 letter-spacing 3-tier + R457 pin fw-lift +
5542
+ R479 pin drop-shadow at the group-label scope.
5543
+ Falls under 配色 / 节点视觉 themes per the prompt;
5544
+ advances the "信息密度" axis by encoding
5545
+ category-distinction into a single typography
5546
+ channel without adding visual chrome. */
4868
5547
  style={{
4869
5548
  transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
4870
5549
  letterSpacing: isPinned ? '0.5px' :
4871
5550
  isHovered ? '0.25px' : '0px',
5551
+ fontStyle: box.isOrphan ? 'italic' : undefined,
4872
5552
  filter: isPinned
4873
5553
  ? `drop-shadow(0 0 3px ${pal.legendAccent}80)`
4874
- : undefined,
5554
+ : isHovered
5555
+ ? `drop-shadow(0 0 3px ${pal.legendAccent}4d)`
5556
+ : undefined,
4875
5557
  }}
4876
5558
  data-group-label={box.key}
4877
5559
  data-group-label-pinned={isPinned ? 'true' : 'false'}
5560
+ data-group-label-orphan={box.isOrphan ? 'true' : 'false'}
4878
5561
  >
4879
5562
  {box.key}
4880
5563
  {/* Round 19 / Loop: member-count chip. Inline tspan stays
@@ -5961,11 +6644,48 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5961
6644
  data-edge-badge-opacity-rest={isLight ? 0.95 : 0.85}
5962
6645
  data-edge-badge-opacity-hover="1"
5963
6646
  data-edge-badge-opacity-active="1"
5964
- data-edge-badge-glow={isHot ? 'true' : 'false'}
6647
+ data-edge-badge-glow={(isHoveredEdge || isPinned) ? 'hover' : isHot ? 'hot' : 'false'}
6648
+ /* Round 534 / Loop — extends edge-badge drop-shadow
6649
+ coverage from hot-only (R480 amber) to also fire
6650
+ on hover/pin with a cyan accent glow. Pre-R534
6651
+ the badge's hover/pin lifted r (R164 9 → 10.5)
6652
+ + sw (R394 1.25 → 1.5) + opacity (R395/R396 →
6653
+ 1.0), but the paint axis stayed at the badge's
6654
+ rest fill — no glow to telegraph "in focus" at
6655
+ the paint layer. R534 closes that 4-axis hover-
6656
+ lift parity by adding drop-shadow glow on
6657
+ (hovered || pinned).
6658
+ Precedence: (hover || pin) wins over isHot when
6659
+ BOTH true — interactive signal (user is
6660
+ inspecting) overrides informational signal
6661
+ (hot lane). When only isHot fires (no hover/
6662
+ pin) the amber R480 glow remains; the hover/
6663
+ pin case paints cyan/teal `pal.legendAccent`
6664
+ at 0x99 alpha (~60%) — bright enough to read
6665
+ as "lit" but won't overwhelm at small badge
6666
+ size (r=10.5).
6667
+ Edge-badge 4-axis hover-lift parity now:
6668
+ R164 r 9 → 10.5
6669
+ R394 stroke-wd 1.25 → 1.5
6670
+ R395 opacity rest → 1.0
6671
+ R534 filter none → drop-shadow glow ← this round
6672
+ Drop-shadow visual-polish family extension —
6673
+ edge-badge surface upgraded from single-gate
6674
+ (R480 isHot) to two-gate (isHot OR hover-pin).
6675
+ transition list already includes filter 200ms
6676
+ ease-out (R480). data-edge-badge-glow attr
6677
+ upgraded from `isHot ? true : false` to a
6678
+ 3-value string: 'hot' | 'hover' | 'false' so
6679
+ tests can distinguish gate cause.
6680
+ R51 sentinel safety: badge is edge-internal
6681
+ (not g[data-node] ancestor); filter is paint-
6682
+ only; bbox unchanged. */
5965
6683
  style={{
5966
- filter: isHot
5967
- ? `drop-shadow(0 0 3px ${hotStroke}80)`
5968
- : undefined,
6684
+ filter: (isHoveredEdge || isPinned)
6685
+ ? `drop-shadow(0 0 3px ${pal.legendAccent}99)`
6686
+ : isHot
6687
+ ? `drop-shadow(0 0 3px ${hotStroke}80)`
6688
+ : undefined,
5969
6689
  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
6690
  }}
5971
6691
  />
@@ -6265,6 +6985,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6265
6985
  data-hub-busyness={busy}
6266
6986
  data-topo-hub-halo-radius={haloR}
6267
6987
  data-topo-hub-halo-hovered={isHaloHovered ? 'true' : 'false'}
6988
+ data-topo-hub-halo-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6268
6989
  data-topo-hub-halo-trough={isLight ? troughLight : troughDark}
6269
6990
  data-topo-hub-halo-peak={isLight ? peakLight : peakDark}
6270
6991
  /* Round 253 / Loop: hub grounding halo fill transition
@@ -6276,10 +6997,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6276
6997
  conflict.
6277
6998
  R451: r as CSS property (R197/R198 idiom) so the
6278
6999
  hover-radius tween eases smoothly under the same
6279
- 200ms cadence as fill. */
7000
+ 200ms cadence as fill.
7001
+ R536: extends hub-cluster glow QUARTET (R476 digit
7002
+ + R532 disc + R535 ring + R533 spokes) to a 5th
7003
+ tier — the halo gains drop-shadow at the outermost
7004
+ concentric ring on hub-hover. 2px blur + 0.3 alpha
7005
+ keeps the halo's glow subtle since (a) the halo is
7006
+ the LARGEST hub element (r=22 hover) and a heavier
7007
+ glow would bleed visibly past the ring tier into
7008
+ the spoke origin, and (b) the halo already SMIL-
7009
+ animates opacity (R84/R244 breath), so the visible
7010
+ glow pulses with the breath — an atmospheric
7011
+ "breathing glow" idiom rather than a static rim.
7012
+ Hub-cluster glow QUINTET (R476/R532/R533/R535/R536):
7013
+ digit (typo center) 3px emerald 0.6
7014
+ disc (r=5.5/6) 3px emerald 0.6
7015
+ ring (r=14/17) 3px emerald 0.5
7016
+ halo (r=20/22) 2px emerald 0.3 ← this round
7017
+ spokes (mesh) 1.5px cyan/teal 0.4
7018
+ Emerald palette continues through the focal-disc
7019
+ family (digit/disc/ring/halo); spokes break out
7020
+ into cyan/teal at the mesh tier. The 4-step alpha
7021
+ ladder 0.6→0.6→0.5→0.3 reads as the focal cluster
7022
+ fading outward — the OUTERMOST emerald glow is the
7023
+ softest, the focal digit is the brightest.
7024
+ filter is paint-only; SMIL animate on opacity
7025
+ continues independently (attribute vs CSS-property
7026
+ non-conflicting). transition list extends to
7027
+ 'filter 200ms ease-out' alongside fill + r.
7028
+ Drop-shadow visual-polish family extension (12
7029
+ anchors). preview.50 milestone round. data-topo-
7030
+ hub-halo-glow attr exposes the gate state. */
6280
7031
  style={{
6281
7032
  r: `${haloR}px`,
6282
- transition: 'fill 200ms ease-out, r 200ms ease-out',
7033
+ filter: !reducedMotion && hoveredHub
7034
+ ? (isLight
7035
+ ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.3))'
7036
+ : 'drop-shadow(0 0 2px rgba(52, 211, 153, 0.3))')
7037
+ : undefined,
7038
+ transition: 'fill 200ms ease-out, r 200ms ease-out, filter 200ms ease-out',
6283
7039
  } as React.CSSProperties}
6284
7040
  >
6285
7041
  {/* Round 244 / Loop: hub grounding halo breath gets
@@ -6489,22 +7245,91 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6489
7245
  so the glow eases under the same cadence as the
6490
7246
  scale + fw + fill axes. */
6491
7247
  data-topo-hub-working-count-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
7248
+ /* Round 507 / Loop — focal recede. When ANY non-hub
7249
+ canvas surface is hovered (a node / an edge / a
7250
+ group label / a legend row / a vendor chip), the
7251
+ hub-center workingCount digit fades to 0.85 opacity,
7252
+ signaling "you're inspecting elsewhere, hub recedes
7253
+ to background." When the user un-hovers (or hovers
7254
+ the hub itself), opacity returns to 1.0. Pure paint
7255
+ polish at the canvas's most prominent focal point.
7256
+ Hits 信息密度 + 动效 themes — the hub digit gives
7257
+ way visually to the surface under inspection,
7258
+ reinforcing the "this is the focal point right now"
7259
+ gesture without requiring users to track which
7260
+ surface holds attention.
7261
+ Gate excludes hoveredHub specifically: hovering the
7262
+ hub itself should LIFT the digit (R425 fw bump +
7263
+ R476 glow + R209 scale 1.08) — the existing hover-
7264
+ on-hub signature is intact; only inspection
7265
+ ELSEWHERE recedes the hub.
7266
+ Composed from existing hoveredAlias / hoveredEdge-
7267
+ Key / hoveredGroupLabel / hoveredStatus / hovered-
7268
+ Vendor — no new state. 300ms ease-out opacity
7269
+ transition already in the style list (existing R213
7270
+ transition spec), so the fade rides on existing
7271
+ infrastructure.
7272
+ data-topo-hub-recede attr surfaces the gate state
7273
+ for tests. */
7274
+ data-topo-hub-recede={
7275
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
7276
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
7277
+ }
7278
+ /* Round 527 / Loop — focal-amplify family extension to a
7279
+ 2nd anchor. R511 introduced focal-amplify at the hub-
7280
+ highlight disc (base opacity 0.95 → 1.0 on hover); R527
7281
+ extends to the hub-center workingCount digit with a
7282
+ letter-spacing tween 0 → 0.3px on hub-hover.
7283
+ Composes with existing 3-axis hub-hover signature on
7284
+ this element:
7285
+ R209 transform scale(1.08) geometry
7286
+ R425 fontWeight 700 → 800 typography weight
7287
+ R476 filter drop-shadow glow paint
7288
+ R527 letter-spacing 0 → 0.3px typography kerning ← this round
7289
+ tabular-nums (R225) preserved — each digit cell keeps
7290
+ fixed width; the inter-digit advance grows by 0.3px
7291
+ per gap. Single-digit counts (1-9) show no kerning
7292
+ effect; multi-digit counts (10+) show the spread as
7293
+ info-density signaling. Sibling to R427/R431/R432/
7294
+ R433/R434 (hover-letter-spacing family at panel-text
7295
+ scope) — R527 brings the same idiom to the canvas's
7296
+ most-read scalar.
7297
+ Reduced-motion gate matches R209 scale, R425 fw, R476
7298
+ filter — !reducedMotion gates the lift; reducedMotion
7299
+ users see static digit baseline regardless of hover.
7300
+ Focal-amplify family extension (2 anchors): R511 hub-
7301
+ highlight opacity / R527 hub-digit letter-spacing.
7302
+ transition list extends to include `letter-spacing
7303
+ 200ms ease-out`, matching the cadence of the other
7304
+ hub-hover axes. data-topo-hub-working-count-letter-
7305
+ spacing attr exposes the resolved value for tests. */
7306
+ data-topo-hub-working-count-letter-spacing={!reducedMotion && hoveredHub ? '0.3px' : '0px'}
6492
7307
  style={{
6493
7308
  pointerEvents: 'none',
6494
7309
  transform: !reducedMotion && hoveredHub ? 'scale(1.08)' : 'scale(1)',
6495
7310
  transformBox: 'fill-box',
6496
7311
  transformOrigin: 'center',
7312
+ opacity: (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
7313
+ hoveredStatus || hoveredVendor) && !hoveredHub
7314
+ ? 0.85
7315
+ : 1,
6497
7316
  filter: !reducedMotion && hoveredHub
6498
7317
  ? (isLight
6499
7318
  ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.6))'
6500
7319
  : 'drop-shadow(0 0 3px rgba(52, 211, 153, 0.6))')
6501
7320
  : undefined,
7321
+ letterSpacing: !reducedMotion && hoveredHub ? '0.3px' : '0px',
6502
7322
  /* R425: font-weight 200ms appended so the hover fw
6503
7323
  bump 700 → 800 eases under the same cadence as
6504
7324
  R209 scale + R253 fill + R213 opacity.
6505
7325
  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',
7326
+ shadow glow eases at the same cadence.
7327
+ R507: opacity 300ms (existing in list) covers
7328
+ the new focal-recede fade.
7329
+ R527: letter-spacing 200ms appended so the new
7330
+ hover-kerning bump eases at the same cadence
7331
+ as the other axes. */
7332
+ 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
7333
  fontVariantNumeric: 'tabular-nums',
6509
7334
  }}
6510
7335
  >
@@ -6550,19 +7375,205 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6550
7375
  + R213 always-mount opacity-gate + pointerEvents:none
6551
7376
  + R365 r=5.5 all preserved. data-topo-hub-highlight-
6552
7377
  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
- />
7378
+ {/* Round 508 / Loop — focal-recede pattern 2nd anchor.
7379
+ Extends R507's hub-digit recede to the hub-highlight
7380
+ circle so the hub focal CLUSTER (digit at z-top + this
7381
+ idle-state highlight beneath) recedes as a unit when
7382
+ canvas attention is elsewhere. Computed once: a single
7383
+ non-hub-hover gate drives BOTH the digit (R507) AND
7384
+ this highlight (R508) so they always co-move.
7385
+ Recede multiplies the visible opacity by 0.85 — when
7386
+ workingCount===0 the rest opacity 0.95 becomes 0.81
7387
+ during external-hover; when workingCount>0 the
7388
+ opacity stays 0 (invisible) regardless of recede.
7389
+ Additionally, when recede is active the SMIL breath
7390
+ animation halts (animate node un-mounts) so the
7391
+ receded state reads as quietly static, not pulsing
7392
+ at 0.85↔1.0 against the recede multiplier (which
7393
+ would visually conflict — competing 15% drops). On
7394
+ un-hover the animate re-mounts and breath resumes.
7395
+ data-topo-hub-recede on both digit AND highlight
7396
+ provides a stable test handle for the unified-recede
7397
+ gate.
7398
+ Composed from existing hover state vars — no new
7399
+ state. Pure paint axis. */}
7400
+ {(() => {
7401
+ const hubRecede = !!((hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
7402
+ hoveredStatus || hoveredVendor) && !hoveredHub);
7403
+ /* Round 511 / Loop — hub-highlight gains a 3rd opacity tier
7404
+ (hovered-amplify). Pre-R511 the highlight had 2 visible
7405
+ states: rest (0.95) and recede (0.81 = 0.95 × 0.85).
7406
+ When the hub itself was hovered, the digit got R425 fw
7407
+ lift + R476 drop-shadow + R209 scale-1.08, but the
7408
+ highlight disc sibling stayed at 0.95 — the focal
7409
+ cluster lifted in 3 channels (typography/paint/scale)
7410
+ but the highlight didn't participate.
7411
+ R511 closes that asymmetry: when hoveredHub is true,
7412
+ highlight base opacity lifts to 1.0 (5% boost from
7413
+ rest 0.95). Cluster now lifts as a unit on hub-hover,
7414
+ just like it recedes as a unit on non-hub-hover
7415
+ (R508).
7416
+ 3-state opacity ladder:
7417
+ hub-hovered: baseOpacity = 1.0 (R511 NEW)
7418
+ rest (no hover): baseOpacity = 0.95 (existing)
7419
+ non-hub canvas hov: baseOpacity × 0.85 = 0.81 (R508)
7420
+ Composes cleanly: hubRecede gate requires !hoveredHub,
7421
+ so the hovered-amplify and recede states are mutually
7422
+ exclusive (they can't both fire). breathActive
7423
+ continues to halt on either non-rest state (recede OR
7424
+ hub-hover would visually compete with the 0.85↔1
7425
+ breath — clean for the unit-lift semantic too). */
7426
+ const baseOpacity = workingCount > 0 ? 0
7427
+ : hoveredHub ? 1.0
7428
+ : 0.95;
7429
+ const resolvedOpacity = hubRecede ? baseOpacity * 0.85 : baseOpacity;
7430
+ const breathActive = !reducedMotion && workingCount === 0 && !hubRecede && !hoveredHub;
7431
+ /* Round 529 / Loop — focal-amplify family 3rd anchor.
7432
+ Hub-highlight gains geometric amplify r 5.5 → 6 on
7433
+ hub-hover, mirroring R451's hub-halo r 20 → 22 hover
7434
+ pattern. Pre-R529 the highlight had paint-axis
7435
+ amplify only (R511 opacity 0.95 → 1.0 on hub-hover);
7436
+ R529 adds geometric amplify so the focal disc
7437
+ BREATHES outward on hub attention, like the halo
7438
+ does. Composes with the existing 2-axis hub-hover
7439
+ lift on this element:
7440
+ R511 opacity 0.95 → 1.0 paint (focal-amplify 1st)
7441
+ R529 r 5.5 → 6 geometry (this round)
7442
+ Implementation matches R451: CSS `r` property
7443
+ (R197/R198 idiom) for smooth interpolation. SVG
7444
+ attribute `r="5.5"` provides SSR fallback and serves
7445
+ as default; inline style.r overrides for animated
7446
+ value. transition list extends to include `r 200ms
7447
+ ease-out`, matching the fill cadence (also 200ms);
7448
+ opacity transition stays at 300ms (existing).
7449
+ r 6 sits well inside the existing visual envelope
7450
+ (next-larger sibling r=10 hub core, r=14 hub hover
7451
+ ring). The 0.5px lift is +9% radius / +19% area —
7452
+ enough to read as 'lift' without breaching the core
7453
+ boundary or invalidating overlap-test invariants.
7454
+ SMIL animate on opacity continues independently
7455
+ (animateAttr='opacity' vs CSS-property r — non-
7456
+ conflicting, same pattern R451 noted for halo).
7457
+ Focal-amplify family extension (3 anchors):
7458
+ R511 hub-highlight opacity 0.95 → 1.0
7459
+ R527 hub-digit letter-spacing 0 → 0.3px
7460
+ R529 hub-highlight radius 5.5 → 6 ← this round
7461
+ data-topo-hub-highlight-radius attr now reports the
7462
+ dynamic value (was static '5.5'). */
7463
+ const highlightR = !reducedMotion && hoveredHub ? 6 : 5.5;
7464
+ return (
7465
+ <circle
7466
+ cx={cx} cy={cy} r="5.5"
7467
+ /* Round 509 / Loop — 配色 cross-theme cleanup. Pre-R509
7468
+ the hub-highlight fill was hardcoded `#d1fae5`
7469
+ (emerald-100, a pale tone). On the light theme this
7470
+ near-white green ran against a pale background at
7471
+ 0.95 opacity — the disc was effectively invisible.
7472
+ Matches the existing R253 halo theme-inversion
7473
+ pattern (line ~6481): light theme picks the dark
7474
+ vibrant emerald (#10b981 emerald-600), dark theme
7475
+ keeps the pale emerald (#d1fae5 emerald-100). Both
7476
+ read at the same 0.95 opacity against their
7477
+ respective backdrops — light gets a saturated
7478
+ focal dot; dark keeps the soft glow signature.
7479
+ Pure paint axis (fill change only); bbox unchanged;
7480
+ R51 SVG sentinel safety untouched.
7481
+ transition list already includes `fill 200ms`?
7482
+ Actually the existing transition spec is `opacity
7483
+ 300ms ease-out` — fill change on theme toggle
7484
+ will be instant. That's acceptable: theme toggle
7485
+ is a discrete event, and the halo (line 6500)
7486
+ already snaps fill on theme toggle the same way
7487
+ (`fill 200ms ease-out` was added later to halo
7488
+ via R253). Future round could add `fill 200ms`
7489
+ to highlight too if theme-switch flicker is
7490
+ noticed. */
7491
+ fill={isLight ? '#10b981' : '#d1fae5'}
7492
+ opacity={resolvedOpacity}
7493
+ data-topo-hub-highlight
7494
+ data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
7495
+ data-topo-hub-highlight-radius={highlightR}
7496
+ data-topo-hub-highlight-opacity={resolvedOpacity}
7497
+ data-topo-hub-highlight-breath={breathActive ? 'true' : 'false'}
7498
+ data-topo-hub-highlight-recede={hubRecede ? 'true' : 'false'}
7499
+ data-topo-hub-highlight-hovered={!reducedMotion && hoveredHub ? 'true' : 'false'}
7500
+ data-topo-hub-highlight-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
7501
+ /* Round 510 / Loop — R509 follow-on: theme-toggle fill
7502
+ ease. Pre-R510 the hub-highlight transition spec only
7503
+ listed `opacity 300ms ease-out`. When R509 introduced
7504
+ theme-conditional fill (#d1fae5 ↔ #10b981), the fill
7505
+ change SNAPPED on theme toggle because the transition
7506
+ list didn't include `fill`. R510 extends to `fill
7507
+ 200ms ease-out` so theme cycles smoothly through the
7508
+ emerald palette. 200ms timing matches the R253 halo
7509
+ fill transition (line ~6500) — both hub-cluster
7510
+ theme transitions now share a cadence so the focal
7511
+ cluster (digit + highlight + halo) eases as a unit.
7512
+ R508's recede opacity transition unchanged (300ms);
7513
+ fill is independent.
7514
+ R529: r as CSS property (R197/R198 idiom) + `r
7515
+ 200ms ease-out` appended to transition list so
7516
+ the new hub-hover radius lift (5.5 → 6) eases
7517
+ under the same fill cadence. SVG attr r="5.5"
7518
+ above provides SSR fallback; inline style.r
7519
+ wins the cascade for the dynamic value.
7520
+ R532: filter drop-shadow glow on hub-hover —
7521
+ sibling to R476 hub-digit drop-shadow at the
7522
+ same gate (hoveredHub && !reducedMotion). Two
7523
+ adjacent hub focal elements (digit + highlight
7524
+ disc) now BOTH glow on hub-hover, reading as
7525
+ one unified focal cluster. Emerald palette
7526
+ matches R476:
7527
+ light: drop-shadow(0 0 2px rgba(16,185,129,0.6)) emerald-500
7528
+ cyber: drop-shadow(0 0 3px rgba(52,211,153,0.6)) emerald-400
7529
+ filter is paint-only (bbox unchanged); SMIL
7530
+ animate on opacity continues independently
7531
+ (animateAttr='opacity' vs CSS-property filter
7532
+ — non-conflicting). transition list extends to
7533
+ 'filter 200ms ease-out' alongside fill/r.
7534
+ Drop-shadow visual-polish family extension
7535
+ (8 anchors): R476 hub-digit / R477 legend pin-
7536
+ ring / R478 recent freshness / hot edge / group
7537
+ label / zoom-state / node alias + R532 hub-
7538
+ highlight (this round). data-topo-hub-highlight-
7539
+ glow attr exposes the gate state. */
7540
+ style={{
7541
+ pointerEvents: 'none',
7542
+ r: `${highlightR}px`,
7543
+ filter: !reducedMotion && hoveredHub
7544
+ ? (isLight
7545
+ ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.6))'
7546
+ : 'drop-shadow(0 0 3px rgba(52, 211, 153, 0.6))')
7547
+ : undefined,
7548
+ transition: 'opacity 300ms ease-out, fill 200ms ease-out, r 200ms ease-out, filter 200ms ease-out',
7549
+ } as React.CSSProperties}
7550
+ >
7551
+ {/* Round 497 / Loop — idle-state breath (呼吸感 theme pivot
7552
+ from the R492-R496 press-family arc). Pre-R497 the hub
7553
+ idle highlight read as a static dim disc — present but
7554
+ motionless, visually mute. R497 adds a 4s opacity breath
7555
+ (0.85 ↔ 1.0 ↔ 0.85) so the hub reads "alive but quiet"
7556
+ instead of "frozen", giving the empty-fleet state a
7557
+ subtle living signature.
7558
+ Gates:
7559
+ - !reducedMotion (R29 a11y blanket) — reducedMotion
7560
+ users see static 0.95 disc, no animate
7561
+ - workingCount === 0 — when fleet is busy, the
7562
+ highlight is invisible (opacity=0) so the animate
7563
+ would waste paint cycles. Gating saves work.
7564
+ SMIL <animate> overrides the static opacity={0.95}
7565
+ during its run; falls back to 0.95 when reducedMotion
7566
+ flips on (the animate node simply doesn't render).
7567
+ 4s cycle is long enough to feel like ambient breath
7568
+ rather than a pulse, matching the "quiet" semantic.
7569
+ data-topo-hub-highlight-breath attr exposes the
7570
+ resolved gate state for tests. */}
7571
+ {breathActive && (
7572
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
7573
+ )}
7574
+ </circle>
7575
+ );
7576
+ })()}
6566
7577
  {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
6567
7578
  that fades in when the hub is hovered — the same idea
6568
7579
  R44 used for node avatars (group-hover stroke). r=14
@@ -6628,13 +7639,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6628
7639
  data-topo-hub-hover-ring-radius={hoveredHub ? 17 : 14}
6629
7640
  data-topo-hub-hover-ring-stroke-width="1.75"
6630
7641
  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. */
7642
+ /* Round 535 / Loop completes the hub-cluster glow
7643
+ QUARTET by adding drop-shadow to the hub-hover-ring.
7644
+ Pre-R535 the hub-hover trio (R476 digit + R532 highlight
7645
+ disc + R533 spokes) glowed in unified emerald (digit/
7646
+ disc) + cyan/teal (spokes) on hub-hover, but the ring
7647
+ itself — the outermost solid emerald boundary at
7648
+ r=14→17 — stayed flat. R535 adds the matching emerald
7649
+ drop-shadow to the ring so the FULL hub-cluster glows
7650
+ across all four concentric surfaces on hub-hover:
7651
+ digit (typography center) drop-shadow 0 0 3px emerald
7652
+ highlight disc (r=5.5/6) drop-shadow 0 0 3px emerald
7653
+ hover-ring (r=14/17) drop-shadow 0 0 3px emerald ← this round
7654
+ spokes (mesh) drop-shadow 0 0 1.5px cyan/teal
7655
+ The ring is only visible on hub-hover (opacity=0 rest);
7656
+ adding drop-shadow at the same gate means the glow shows
7657
+ the moment the ring shows — no extra state needed.
7658
+ Same R476/R532 emerald palette since the ring sits
7659
+ inside the focal-disc tier (its color is also emerald
7660
+ #059669/#10b981).
7661
+ transition list extends to include 'filter 200ms ease-
7662
+ out' alongside the existing 180ms opacity/r — slight
7663
+ cadence mismatch (180 vs 200) is acceptable; the filter
7664
+ only appears AFTER the ring fades in via opacity, and
7665
+ the 200ms vs 180ms 20ms tail difference is below
7666
+ perceptual threshold.
7667
+ Drop-shadow visual-polish family extension (11 anchors):
7668
+ the hub-cluster glow quartet (R476/R532/R533/R535) plus
7669
+ the 7 non-hub anchors (R477/R478/R479/R480/R481/R483/
7670
+ R534) makes for a thoroughly polished glow vocabulary
7671
+ across the canvas.
7672
+ data-topo-hub-hover-ring-glow attr exposes the gate
7673
+ state for tests. */
7674
+ data-topo-hub-hover-ring-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6635
7675
  style={{
6636
7676
  pointerEvents: 'none',
6637
- transition: 'opacity 180ms ease-out, r 180ms ease-out, stroke 200ms ease-out',
7677
+ filter: !reducedMotion && hoveredHub
7678
+ ? (isLight
7679
+ ? 'drop-shadow(0 0 3px rgba(16, 185, 129, 0.5))'
7680
+ : 'drop-shadow(0 0 3px rgba(52, 211, 153, 0.5))')
7681
+ : undefined,
7682
+ transition: 'opacity 180ms ease-out, r 180ms ease-out, stroke 200ms ease-out, filter 200ms ease-out',
6638
7683
  }}
6639
7684
  />
6640
7685
  </g>)}
@@ -7547,6 +8592,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7547
8592
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
7548
8593
 
7549
8594
  if (isIntern || internByAlias || vendor.logo) {
8595
+ /* Round 501 / Loop — vendor avatar inside node circles
8596
+ gains a hover-gated brightness lift. Pre-R501 the
8597
+ avatar <image> was the only per-node surface with
8598
+ NO hover treatment: R26 lifted the card, R242 tinted
8599
+ the card stroke, R427 spread the alias letter-
8600
+ spacing, R500 added the alias drop-shadow, R208
8601
+ lifted the runtime badge ring, R443 thickened
8602
+ the badge icon stroke, R177 brightened the
8603
+ halo — but the most visually-prominent element
8604
+ (the vendor logo / 书生 coin centred in each node)
8605
+ stayed paint-static. R501 closes the per-node
8606
+ hover-affordance arc by adding a 15% brightness
8607
+ lift on hover.
8608
+ Implementation: CSS filter: brightness(1.15)
8609
+ when hoveredAlias === session.alias. Pure paint
8610
+ axis on the <image> element — no geometry change,
8611
+ no bbox shift. Modern-browser supported (Chrome 64+
8612
+ / FF 56+ / Safari 9.1+).
8613
+ Hits 节点视觉 theme. data-node-avatar-hovered
8614
+ attr surfaces the gate for tests.
8615
+ Gated on !reducedMotion as a courtesy (brightness
8616
+ transition < ~50ms still feels instant; the gate
8617
+ avoids the transition cycle for a11y users). */
8618
+ const isAvatarHovered = !reducedMotion && hoveredAlias === session.alias;
7550
8619
  return (
7551
8620
  <image
7552
8621
  href={vendor.logo ?? '/intern_avatar.png'}
@@ -7555,6 +8624,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7555
8624
  width={size}
7556
8625
  height={size}
7557
8626
  preserveAspectRatio="xMidYMid meet"
8627
+ data-node-avatar={session.alias}
8628
+ data-node-avatar-hovered={isAvatarHovered ? 'true' : 'false'}
8629
+ style={{
8630
+ filter: isAvatarHovered ? 'brightness(1.15)' : undefined,
8631
+ transition: 'filter 200ms ease-out',
8632
+ }}
7558
8633
  />
7559
8634
  );
7560
8635
  }
@@ -7978,6 +9053,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7978
9053
  R211 fill 300ms + R305 letter-spacing 200ms
7979
9054
  transition list preserved; only the
7980
9055
  conditional gets a middle case. */}
9056
+ {/* Round 500 / Loop — milestone round, opens
9057
+ per-node alias drop-shadow polish. Extends the
9058
+ R476-R481 drop-shadow visual-polish family to a
9059
+ 7th anchor: hovered alias text gains a soft
9060
+ status-coloured text-glow. Pre-R500 hover on
9061
+ a node triggered card-lift (R26 translateY) +
9062
+ card-stroke (R242 tint) + alias letter-spacing
9063
+ (R427 0.3px tier) but the alias TEXT itself had
9064
+ no paint-axis cue beyond fill (R211). R500 adds
9065
+ a drop-shadow on the text glyph itself, so the
9066
+ identity glyph itself lights up under attention
9067
+ — matching the R476 idiom (hub-digit emerald
9068
+ glow on hover) at the per-node identity scope.
9069
+ 2px blur radius at 50% alpha — subtler than the
9070
+ R476 hub-digit (3px at 60%) because the alias
9071
+ text is smaller and more numerous (1 per node)
9072
+ so an aggressive glow would multiply into
9073
+ visual noise. Status-coloured (status.text) so
9074
+ the glow inherits the node's working/idle/
9075
+ offline palette — green/cyan/gray respectively.
9076
+ Drop-shadow visual-polish family — 7 anchors:
9077
+ R476 hub digit hover-gated emerald
9078
+ R477 legend pin-ring pin-gated row.fill
9079
+ R478 recent-row pip fresh-gated cyan
9080
+ R479 group-label text pin-gated cyan
9081
+ R480 hot-lane edge hot-gated amber
9082
+ R481 zoom-state minimap zoom-gated cyan
9083
+ R500 node alias text hover-gated status.text ← this round
9084
+ Filter is paint-only; bbox unchanged; overlap-
9085
+ test invariants hold (R51 selector gated to
9086
+ g[data-node] descendants with strokeWidth
9087
+ sentinels; text element doesn't carry stroke).
9088
+ transition list extends to include 'filter
9089
+ 200ms ease-out' alongside the existing fill
9090
+ 300ms + letter-spacing 200ms tweens.
9091
+ data-node-alias-glow attr surfaces the hover
9092
+ gate for tests. */}
7981
9093
  <text
7982
9094
  x="0" y="1" textAnchor="middle"
7983
9095
  fill={status.text}
@@ -7985,11 +9097,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7985
9097
  data-node-alias-text={session.alias}
7986
9098
  data-node-alias-chat-target={chatAlias === session.alias ? 'true' : 'false'}
7987
9099
  data-node-alias-hovered={hoveredAlias === session.alias ? 'true' : 'false'}
9100
+ data-node-alias-glow={!reducedMotion && hoveredAlias === session.alias ? 'true' : 'false'}
7988
9101
  style={{
7989
- transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out',
9102
+ transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out, filter 200ms ease-out',
7990
9103
  letterSpacing:
7991
9104
  chatAlias === session.alias ? '0.5px' :
7992
9105
  hoveredAlias === session.alias ? '0.3px' : '0px',
9106
+ filter: !reducedMotion && hoveredAlias === session.alias
9107
+ ? `drop-shadow(0 0 2px ${status.text}80)`
9108
+ : undefined,
7993
9109
  }}
7994
9110
  >
7995
9111
  {truncate(session.alias, fullMax)}
@@ -9261,11 +10377,46 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9261
10377
  tier without disturbing the surrounding family
9262
10378
  baseline. data-recent-row-text-font-weight attr
9263
10379
  exposes the value for tests. */
9264
- fontWeight="500"
10380
+ /* Round 530 / Loop — extends hover-fw family
10381
+ (R416/R420/R425/R520/R521/R522, 6 anchors) to
10382
+ a 7th anchor: recent-row alias text gains
10383
+ fontWeight 500 → 600 on (isRowHovered ||
10384
+ isRowPinned). Pre-R530 R363 set fw=500
10385
+ statically; hover/pin lifted other axes
10386
+ (R55 fill brighten / R434 letter-spacing
10387
+ 3-tier / R143 translateY / R104 row bg-
10388
+ tint / R474 cadence) but the fw stayed
10389
+ flat — same asymmetry R520 closed at the
10390
+ +N more footer.
10391
+ R530 mirrors R520's pattern at the row-
10392
+ text scope. Hover OR pin (isRowActive
10393
+ union) lifts fw to 600, matching the count
10394
+ tspan's cold-state tier (R320 fw=600), so
10395
+ on active state the alias label reads at
10396
+ the same data tier as the count it sits
10397
+ next to. Inner count tspan has its own
10398
+ explicit fontWeight (600 or 700 per R320/
10399
+ R445) so parent fw lift doesn't bleed
10400
+ (inheritance overridden).
10401
+ Hover-fw family extension (7 anchors):
10402
+ R416 chip-row count digit
10403
+ R420 chrome zoom-level
10404
+ R425 hub-center digit
10405
+ R520 +N more flows footer
10406
+ R521 chrome nodeSize S/M/L inactive
10407
+ R522 chrome layout Ring/Grid inactive
10408
+ R530 recent-row alias text ← this round
10409
+ transition list extends to include
10410
+ 'font-weight 200ms ease-out', matching the
10411
+ R474 cadence of the existing fill +
10412
+ letter-spacing axes on this element.
10413
+ data-recent-row-text-font-weight attr
10414
+ flips '500' → '600' on isRowActive. */
10415
+ fontWeight={(isRowHovered || isRowPinned) ? '600' : '500'}
9265
10416
  data-recent-row-text={link.key}
9266
10417
  data-recent-row-text-pinned={isRowPinned ? 'true' : 'false'}
9267
10418
  data-recent-row-text-hovered={!isRowPinned && isRowHovered ? 'true' : 'false'}
9268
- data-recent-row-text-font-weight="500"
10419
+ data-recent-row-text-font-weight={(isRowHovered || isRowPinned) ? '600' : '500'}
9269
10420
  /* Round 434 / Loop: recent-signal row text extends
9270
10421
  from R220's pin-only letter-spacing (0 → 0.5 on
9271
10422
  isRowPinned) to a 3-tier scale matching R433
@@ -9316,7 +10467,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9316
10467
  shifts. */
9317
10468
  data-recent-row-text-transition="200ms"
9318
10469
  style={{
9319
- transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out',
10470
+ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out',
9320
10471
  letterSpacing: isRowPinned ? '0.5px' :
9321
10472
  isRowHovered ? '0.25px' : '0px',
9322
10473
  }}
@@ -9399,12 +10550,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9399
10550
  under the same R320 fill cadence. data-
9400
10551
  recent-row-count-pinned attr exposes the
9401
10552
  pin gate for tests. */}
10553
+ {/* Round 498 / Loop — hot-count subtle pulse. Pre-
10554
+ R498 the hot row count signaled via color (R127
10555
+ amber fill) + weight (R320 fw-700) + (R445 pin
10556
+ lift) but stayed visually motionless. R498 adds
10557
+ a 3s opacity breath (0.85↔1.0) on the digit when
10558
+ isHot && !reducedMotion — gentle "alive" signal
10559
+ on the lane carrying ≥ 10 messages, drawing
10560
+ glance without becoming noisy. Sibling of R497
10561
+ hub-idle-breath in the 呼吸感 theme arc; same
10562
+ 0.85↔1.0 amplitude. Class adds an animation-
10563
+ only paint axis; no layout / bbox change. R29
10564
+ blanket also catches `animation-duration` for
10565
+ reducedMotion users, but the component-side
10566
+ gate makes the intent explicit and avoids
10567
+ a node tree thrash for those users (className
10568
+ stays absent rather than present-but-paused). */}
9402
10569
  <tspan
9403
10570
  fill={isHot ? hotStroke : undefined}
9404
10571
  fontWeight={(isHot || isRowPinned) ? '700' : '600'}
10572
+ className={isHot && !reducedMotion ? 'anet-recent-hot-pulse' : undefined}
9405
10573
  data-recent-row-count
9406
10574
  data-recent-row-count-pinned={isRowPinned ? 'true' : 'false'}
9407
10575
  data-recent-row-count-font-weight={(isHot || isRowPinned) ? '700' : '600'}
10576
+ data-recent-row-count-hot-pulse={isHot && !reducedMotion ? 'true' : 'false'}
9408
10577
  {...(isHot ? { 'data-recent-row-count-hot': 'true' } : {})}
9409
10578
  style={{
9410
10579
  transition: 'fill 300ms ease-out, font-weight 200ms ease-out',
@@ -9648,6 +10817,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9648
10817
  stays as is, so the rest-vs-hover delta still
9649
10818
  reads clearly. data-recent-panel-more-font-weight
9650
10819
  attr exposes the value for tests. */}
10820
+ {/* Round 520 / Loop — extends the `+N more flows` footer
10821
+ to a 5-axis hover signature by adding fontWeight
10822
+ 500 → 600 on hover. Pre-R520 the footer carried 4
10823
+ hover axes:
10824
+ R195 fill legendText → legendAccent
10825
+ R325 letter-spacing 0.2 → 0.3px (R344 tween)
10826
+ R325 opacity 0.55 → 0.85
10827
+ R133 underline none → underline
10828
+ R368 had set fontWeight 500 statically as a sibling
10829
+ to R363/R364/R366 small-text fw lift family — but
10830
+ the footer's hover state didn't carry a fontWeight
10831
+ DELTA the way other interactive surfaces do (chip-
10832
+ row counts R416, chrome zoom-level R420, hub digit
10833
+ R425). R520 adds the missing weight axis: fw 500
10834
+ → 600 on hover, so the footer reads "thickening AND
10835
+ lighting up" under cursor — same idiom as the
10836
+ chrome zoom-level R420 / chip-row digit R416 hover-
10837
+ bold pattern, applied at the panel nav-action
10838
+ surface.
10839
+ data-recent-panel-more-font-weight attr value
10840
+ flips from '500' → '600' on hover (was static
10841
+ '500' pre-R520).
10842
+ Bonus closure — R475 panel-text cadence: pre-R520
10843
+ the footer's transition list had `opacity 150ms`
10844
+ while R475 unified panel-text transitions at
10845
+ 200ms. R518 closed the same gap at legend-count.
10846
+ R520 closes the LAST panel-text 150ms holdout
10847
+ here AND adds the new font-weight 200ms axis. All
10848
+ 4 transition properties (opacity / fill / letter-
10849
+ spacing / font-weight) now uniform 200ms at the
10850
+ footer — same cadence as legend-label / legend-
10851
+ count / recent-row alias / recent-row count /
10852
+ group-label. */}
9651
10853
  <text
9652
10854
  x="115" y="82"
9653
10855
  textAnchor="middle"
@@ -9655,14 +10857,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9655
10857
  fontSize="9"
9656
10858
  fontFamily="monospace"
9657
10859
  fontStyle="italic"
9658
- fontWeight="500"
10860
+ fontWeight={hoveredRecentMore ? '600' : '500'}
9659
10861
  letterSpacing={hoveredRecentMore ? '0.3' : '0.2'}
9660
10862
  opacity={hoveredRecentMore ? 0.85 : 0.55}
9661
10863
  textDecoration={hoveredRecentMore ? 'underline' : 'none'}
9662
10864
  data-recent-panel-more={moreCount}
9663
10865
  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' }}
10866
+ data-recent-panel-more-font-weight={hoveredRecentMore ? '600' : '500'}
10867
+ data-recent-panel-more-transition="200ms"
10868
+ style={{ transition: 'opacity 200ms ease-out, fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out' }}
9666
10869
  >
9667
10870
  {`+ ${moreCount}`}
9668
10871
  <tspan opacity="0.7" data-recent-panel-more-unit>{` more flow${moreCount === 1 ? '' : 's'}`}</tspan>
@@ -10058,15 +11261,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10058
11261
  R181 pin ring (6 + 0 stroke vs 8 - 0.75 inner
10059
11262
  ≈ 7.25). data-legend-swatch is unchanged so
10060
11263
  R197 / R55 / R61 tests probe the same handle. */}
11264
+ {/* Round 537 / Loop — extends drop-shadow visual-polish
11265
+ family (12 anchors after R536) to a 13th anchor: the
11266
+ legend swatch gains drop-shadow glow on hover/pin
11267
+ using its OWN row fill color (working green / idle
11268
+ teal / offline slate). Pre-R537 the swatch lifted
11269
+ only r (R197/R295 6 → 7) on attention — geometry
11270
+ axis only, no paint glow. R537 adds the paint axis,
11271
+ composing with R181/R402 pin-ring (separate concen-
11272
+ tric circle in the same row.fill color) so on
11273
+ hover/pin the SWATCH AND its pin-ring both contri-
11274
+ bute to a unified tier-coloured glow signature.
11275
+ Hue: row.fill (status hex) concatenated with `99`
11276
+ hex alpha (~60%). Working green / idle teal /
11277
+ offline slate each glow in their OWN tier color
11278
+ — the legend acts as a color-keyed status mirror.
11279
+ 3px blur reads soft; 60% alpha legible without
11280
+ overwhelming the swatch's own paint.
11281
+ Drop-shadow visual-polish family extension (13
11282
+ anchors). filter is paint-only; bbox unchanged.
11283
+ transition list extends to include 'filter 150ms
11284
+ ease-out', matching the existing R197 r 150ms
11285
+ cadence at this swatch. data-legend-swatch-glow
11286
+ attr exposes the gate state for tests. */}
10061
11287
  <circle
10062
11288
  cx="16" cy={row.y0}
10063
11289
  r="6"
10064
11290
  fill={row.fill}
10065
11291
  data-legend-swatch={row.key}
10066
11292
  data-legend-swatch-state={isPinned ? 'pinned' : isRowHovered ? 'hover' : 'idle'}
11293
+ data-legend-swatch-glow={(isRowHovered || isPinned) ? 'true' : 'false'}
10067
11294
  style={{
10068
11295
  r: isRowHovered || isPinned ? '7px' : '6px',
10069
- transition: 'r 150ms ease-out',
11296
+ filter: (isRowHovered || isPinned)
11297
+ ? `drop-shadow(0 0 3px ${row.fill}99)`
11298
+ : undefined,
11299
+ transition: 'r 150ms ease-out, filter 150ms ease-out',
10070
11300
  } as React.CSSProperties}
10071
11301
  />
10072
11302
  {/* R61 pinned-state ring — concentric stroke at r=8 in
@@ -10198,11 +11428,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10198
11428
  the value for tests. R219 letter-spacing pin
10199
11429
  tween + R55 fill transition + R181 always-mount
10200
11430
  pin ring all preserved. */
10201
- fontWeight="500"
11431
+ /* Round 531 / Loop — extends hover-fw family (R416/
11432
+ R420/R425/R520/R521/R522/R530, 7 anchors) to an
11433
+ 8th anchor at the legend-row label. Pre-R531
11434
+ R364 set fw=500 statically; hover/pin lifted
11435
+ other axes (R55 fill brighten / R433 letter-
11436
+ spacing 3-tier / R181 pin ring) but the fw
11437
+ stayed flat. R531 mirrors R530's recent-row
11438
+ alias pattern at the legend-row label scope.
11439
+ Hover OR pin (hoveredStatus===row.key ||
11440
+ isPinned) lifts fw to 600, matching the
11441
+ legend-row count tier (R309 fw=600 / R446
11442
+ pin lift 600→700). Active label now reads at
11443
+ the count's data tier — sibling treatment to
11444
+ R530 recent-row.
11445
+ Hover-fw family extension (8 anchors):
11446
+ R416 chip-row count digit
11447
+ R420 chrome zoom-level
11448
+ R425 hub-center digit
11449
+ R520 +N more flows footer
11450
+ R521 chrome nodeSize S/M/L inactive
11451
+ R522 chrome layout Ring/Grid inactive
11452
+ R530 recent-row alias text
11453
+ R531 legend-row label ← this round
11454
+ Two panel-row label surfaces (R530 recent-
11455
+ row alias + R531 legend-row label) now have
11456
+ parallel hover-fw signatures. R475 cadence
11457
+ at 200ms already covers font-weight via the
11458
+ existing transition list extension at this
11459
+ element. data-legend-row-label-font-weight
11460
+ attr flips '500' → '600' on isActive (was
11461
+ static '500' pre-R531). */
11462
+ fontWeight={(hoveredStatus === row.key || isPinned) ? '600' : '500'}
10202
11463
  data-legend-row-label={row.key}
10203
11464
  data-legend-row-label-pinned={isPinned ? 'true' : 'false'}
10204
11465
  data-legend-row-label-hovered={!isPinned && hoveredStatus === row.key ? 'true' : 'false'}
10205
- data-legend-row-label-font-weight="500"
11466
+ data-legend-row-label-font-weight={(hoveredStatus === row.key || isPinned) ? '600' : '500'}
10206
11467
  /* Round 433 / Loop: legend-row text extends from
10207
11468
  R219's pin-only letter-spacing (0px → 0.5px on
10208
11469
  isPinned) to a 3-tier scale matching the R432
@@ -10245,7 +11506,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10245
11506
  the timing axis shifts. */
10246
11507
  data-legend-row-label-transition="200ms"
10247
11508
  style={{
10248
- transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out',
11509
+ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out',
10249
11510
  letterSpacing: isPinned ? '0.5px' :
10250
11511
  hoveredStatus === row.key ? '0.25px' : '0px',
10251
11512
  }}
@@ -10379,7 +11640,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10379
11640
  data-legend-count-pinned={isPinned ? 'true' : 'false'}
10380
11641
  data-legend-count-font-weight={isPinned ? '700' : '600'}
10381
11642
  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' }}
11643
+ /* Round 518 / Loop extends R433's 3-tier hover-
11644
+ letter-spacing tween from the legend-row LABEL
11645
+ (text at x=30) to the SIBLING legend-row COUNT
11646
+ digit (this text at x=215). Pre-R518 the row's
11647
+ label spread on hover/pin (R433: 0/0.25/0.5px)
11648
+ while the count digit at the row's right edge
11649
+ stayed dead-typographic — same row, two halves,
11650
+ asymmetric kerning gesture. R518 mirrors the
11651
+ 3-tier scale at the count so the WHOLE row's
11652
+ typography reads as one unit under cursor: label
11653
+ + count spread together at matching values.
11654
+ Tabular-nums (R225) makes the kerning still
11655
+ visible on 2-digit counts — each digit cell
11656
+ keeps its fixed width, but the inter-digit
11657
+ advance grows. R518 also closes R475's panel-
11658
+ row TEXT cadence at the count surface — R475
11659
+ lifted the label text transitions to 200ms but
11660
+ the count was missed; R518 lifts opacity / fill
11661
+ / font-weight from 150 → 200ms AND adds the new
11662
+ letter-spacing axis at 200ms. One transition
11663
+ list, one cadence, one motion-coherent multi-
11664
+ axis hover/pin signature across the row.
11665
+ Hover-letter-spacing family extension (10
11666
+ anchors now): R344/R345/R347/R420/R427/R431/
11667
+ R432/R433/R517/R518. R518 closes the legend-
11668
+ row pair (label R433 + count R518). data-
11669
+ legend-count-letter-spacing attr exposes the
11670
+ resolved value for tests. */
11671
+ data-legend-count-letter-spacing={isPinned ? '0.5px' : hoveredStatus === row.key ? '0.25px' : '0px'}
11672
+ data-legend-count-transition="200ms"
11673
+ style={{
11674
+ pointerEvents: 'none',
11675
+ transition: 'opacity 200ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, letter-spacing 200ms ease-out',
11676
+ fontVariantNumeric: 'tabular-nums',
11677
+ letterSpacing: isPinned ? '0.5px' :
11678
+ hoveredStatus === row.key ? '0.25px' : '0px',
11679
+ }}
10383
11680
  >{row.count}</text>
10384
11681
  </g>
10385
11682
  );
@@ -10438,6 +11735,74 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10438
11735
  spacing as typographic intent. Stays well inside the
10439
11736
  bottom-left corner; opacity 0.4 unchanged so the
10440
11737
  watermark stays a watermark. */}
11738
+ {/* Round 519 / Loop — 呼吸感 family 3rd anchor. Pre-R519 the
11739
+ breath family had 2 anchors (R497 hub idle digit + R498
11740
+ recent-row hot pulse). Both signal active state — the
11741
+ digit when canvas is idle (no work pending), the recent
11742
+ row when fresh signal arrives. R519 adds a SLOW ambient
11743
+ breath to the brand watermark — present always, not gated
11744
+ on activity state. The watermark IS the canvas-corner
11745
+ register that says "the canvas is alive even when nothing
11746
+ is happening"; a 6s opacity pulse around its 0.4 mean
11747
+ (±0.08 swing → 0.32 ↔ 0.48) reads as ambient liveness
11748
+ rather than foreground signal.
11749
+ Why 6s (not R497's 4s): the breath family now spans
11750
+ activity registers (R497 4s — idle-focal: present and
11751
+ waiting; R498 ~3s — hot signal: just arrived) and now
11752
+ ambient register (R519 6s — corner watermark: always-on
11753
+ background). Slower cadence keeps the watermark in the
11754
+ background; ~10 pct slower than R497 keeps it out of
11755
+ phase so the two anchors never beat together visibly.
11756
+ Gate: !reducedMotion. Inside the prefers-reduced-motion
11757
+ media query, SMIL animate isn't covered by globals.css
11758
+ R29 (which only kills CSS animation property), so we
11759
+ gate at JSX level — when reducedMotion is true the
11760
+ <animate> child isn't mounted and opacity stays at the
11761
+ static 0.4. data-topo-brand-watermark-breath attr
11762
+ exposes the gate state for tests.
11763
+ 呼吸感 family extension (3 anchors): R497 hub idle / R498
11764
+ recent-row hot / R519 brand watermark ambient. */}
11765
+ {/* Round 525 / Loop — focal-recede family 3rd anchor. R507
11766
+ receded the hub-center workingCount digit; R508 receded
11767
+ the hub-highlight disc; both fade to 0.85× when any non-
11768
+ hub canvas surface is hovered (alias / edge / group /
11769
+ status / vendor) — the "you're inspecting elsewhere"
11770
+ gesture. R525 extends the pattern to the brand watermark
11771
+ at canvas bottom-left, the always-on decorative brand
11772
+ element. Pre-R525 the watermark stayed at its R519
11773
+ breath baseline (0.32-0.48 SMIL pulse) regardless of
11774
+ canvas attention; post-R525 it fades to 70% wrapper
11775
+ opacity (effective 0.224-0.336 with breath) when canvas
11776
+ attention is elsewhere, matching the same focal-recede
11777
+ semantic R507/R508 establish at the hub focal cluster.
11778
+ Implementation: wrap the existing <text> in a <g>
11779
+ wrapper whose opacity multiplies with the inner text's
11780
+ SMIL-animated opacity. SVG opacity composes
11781
+ multiplicatively across the parent/child chain, so:
11782
+ normal: g.opacity=1.0 × text.opacity(SMIL 0.32-0.48) = 0.32-0.48
11783
+ recede: g.opacity=0.7 × text.opacity(SMIL 0.32-0.48) = 0.224-0.336
11784
+ SMIL on inner text continues running through both
11785
+ states; only the wrapper opacity flips. 300ms ease-out
11786
+ transition on wrapper (matches R508 hub-highlight recede
11787
+ transition).
11788
+ Gate matches R507/R508 — focal-recede is a UNIFIED
11789
+ non-hub-canvas-hover signal driving multiple anchors,
11790
+ so all three (hub digit / hub-highlight / brand
11791
+ watermark) fade together as the canvas's decorative
11792
+ register, leaving only the surface under inspection
11793
+ foregrounded.
11794
+ Focal-recede family extension (3 anchors): R507 hub
11795
+ digit / R508 hub-highlight / R525 brand watermark. */}
11796
+ <g
11797
+ opacity={(hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11798
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 0.7 : 1}
11799
+ data-topo-brand-watermark-wrapper
11800
+ data-topo-brand-watermark-recede={
11801
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11802
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
11803
+ }
11804
+ style={{ pointerEvents: 'none', transition: 'opacity 300ms ease-out' }}
11805
+ >
10441
11806
  <text
10442
11807
  x="16" y="672"
10443
11808
  fontSize="11" fontFamily="monospace" fontWeight="600"
@@ -10445,8 +11810,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10445
11810
  fill={pal.legendText}
10446
11811
  opacity="0.4"
10447
11812
  data-topo-brand-watermark
11813
+ data-topo-brand-watermark-breath={reducedMotion ? 'false' : 'true'}
10448
11814
  style={{ pointerEvents: 'none', transition: 'fill 200ms ease-out' }}
10449
- >sleep2agi</text>
11815
+ >sleep2agi{!reducedMotion && (
11816
+ <animate attributeName="opacity" values="0.32;0.48;0.32" dur="6s" repeatCount="indefinite" />
11817
+ )}</text>
11818
+ </g>
10450
11819
  {/* v0.10.0 Hero 3 Wave 1 / RFC §3.I (Vincent 5215 + 通信龙
10451
11820
  lead-autonomy Q4 dual-anchor minimal): canvas top-left
10452
11821
  crescent moon brand mark, visible ONLY when the
@@ -10481,10 +11850,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10481
11850
  the R175 panel-fade-in uses for cascade rhythm. data-
10482
11851
  topo-brand-canvas-mark-visible exposes the gate for
10483
11852
  tests. */}
11853
+ {/* Round 526 / Loop — focal-recede family 4th anchor.
11854
+ Symmetric polish to R525 (watermark recede). The
11855
+ brand crescent at canvas top-left is the second
11856
+ decorative brand element on the canvas; pre-R526 it
11857
+ stayed at flat opacity 0.35 (when visible) regardless
11858
+ of canvas attention. R526 multiplies its visible
11859
+ opacity by 0.7 when ANY non-hub canvas surface is
11860
+ hovered, matching R525's deeper-recede semantic for
11861
+ decorative brand elements (vs hub focal cluster's
11862
+ 0.85× recede at R507/R508).
11863
+ Composes cleanly with existing flowLinks gate:
11864
+ normal, flowLinks=0: opacity = 0.35 * 1.0 = 0.350
11865
+ recede, flowLinks=0: opacity = 0.35 * 0.7 = 0.245
11866
+ invisible, flowLinks>0: opacity = 0.00 * any = 0.000
11867
+ Multiplicative chain means recede only matters when
11868
+ crescent is visible (quiet canvas, flowLinks=0) —
11869
+ exactly when canvas attention elsewhere should
11870
+ dim the decorative register. 300ms transition
11871
+ already covers both axes (the existing visibility
11872
+ opacity ramp + the new recede multiplier easing).
11873
+ Focal-recede family extension (4 anchors): R507 hub
11874
+ digit / R508 hub-highlight / R525 watermark / R526
11875
+ crescent (this round). Canvas brand surfaces (R525
11876
+ watermark + R526 crescent) now BOTH carry focal-
11877
+ recede at the same 0.7 multiplier, fading as a
11878
+ decorative pair when the canvas's focal attention
11879
+ shifts elsewhere.
11880
+ data-topo-brand-canvas-mark-recede attr exposes the
11881
+ gate state for tests. */}
10484
11882
  <g
10485
- opacity={flowLinks.length === 0 ? 0.35 : 0}
11883
+ opacity={(flowLinks.length === 0 ? 0.35 : 0) * (
11884
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11885
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 0.7 : 1
11886
+ )}
10486
11887
  data-topo-brand-canvas-mark
10487
11888
  data-topo-brand-canvas-mark-visible={flowLinks.length === 0 ? 'true' : 'false'}
11889
+ data-topo-brand-canvas-mark-recede={
11890
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
11891
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
11892
+ }
11893
+ data-topo-brand-canvas-mark-breath={reducedMotion ? 'false' : 'true'}
10488
11894
  style={{ pointerEvents: 'none', transition: 'opacity 300ms ease-out, fill 200ms ease-out' }}
10489
11895
  >
10490
11896
  <defs>
@@ -10494,11 +11900,44 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10494
11900
  <circle cx="17.5" cy="13" r="10" fill="black" />
10495
11901
  </mask>
10496
11902
  </defs>
11903
+ {/* Round 528 / Loop — 呼吸感 family 4th anchor. Symmetric
11904
+ polish to R519 watermark ambient breath. The brand
11905
+ crescent at canvas top-left is the second decorative
11906
+ canvas brand surface; pre-R528 it stayed at the static
11907
+ composed opacity (wrapper 0.35 × no inner anim = flat).
11908
+ Post-R528 the inner <rect>'s fill-opacity breathes
11909
+ 0.8 ↔ 1.0 on a 7s cycle, composing multiplicatively
11910
+ with the wrapper's recede gate:
11911
+ normal visible: 0.35 × (0.8-1.0) = 0.280-0.350
11912
+ recede visible: 0.245 × (0.8-1.0) = 0.196-0.245
11913
+ invisible: 0 × any = 0
11914
+ 7s cadence intentionally OUT OF PHASE with R519
11915
+ watermark's 6s — the two ambient anchors never beat
11916
+ together visibly when both visible. R497 hub idle
11917
+ breath (4s) is the loudest; R498 recent-row hot pulse
11918
+ (~3s) is the most-active; R519 watermark (6s) +
11919
+ R528 crescent (7s) are the quietest ambient pair.
11920
+ 呼吸感 family extension (4 anchors):
11921
+ R497 hub idle digit 4s active-idle register
11922
+ R498 recent-row hot pulse 3s active-fresh register
11923
+ R519 watermark ambient 6s ambient (always-on)
11924
+ R528 crescent ambient 7s ambient (quiet-only) ← this round
11925
+ SMIL <animate> on fill-opacity (not parent opacity) so
11926
+ the wrapper's React-controlled gate compositions stay
11927
+ intact. Gated on !reducedMotion at JSX level —
11928
+ reducedMotion users see the inner rect at default
11929
+ fill-opacity=1.0 (no SMIL mounted, wrapper's static
11930
+ composed opacity wins). data-topo-brand-canvas-mark-
11931
+ breath attr exposes the gate state. */}
10497
11932
  <rect
10498
11933
  x="16" y="16" width="28" height="28"
10499
11934
  fill={pal.legendText}
10500
11935
  mask="url(#s2a-canvas-corner-mask)"
10501
- />
11936
+ >
11937
+ {!reducedMotion && (
11938
+ <animate attributeName="fill-opacity" values="0.8;1;0.8" dur="7s" repeatCount="indefinite" />
11939
+ )}
11940
+ </rect>
10502
11941
  </g>
10503
11942
  </svg>
10504
11943
 
@@ -10850,12 +12289,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10850
12289
  (the minimap viewport is small, ~120×82 px).
10851
12290
  Filter is paint-only — bbox unchanged. transition
10852
12291
  list extends to include 'filter 200ms ease-out'
10853
- so the glow eases when zoom crosses 1.5x. */
10854
- data-topo-minimap-viewport-glow={view.zoom > 1.5 ? 'true' : 'false'}
12292
+ so the glow eases when zoom crosses 1.5x.
12293
+ R540: extends the drop-shadow to also fire on
12294
+ hoveredMinimap with HOVER PRECEDENCE over zoom-
12295
+ state. Pre-R540 the viewport drop-shadow was
12296
+ zoom-only (single gate); R540 adds an
12297
+ interactional gate at lighter blur intensity.
12298
+ Hover wins when both true — interactional signal
12299
+ (user is inspecting) trumps informational signal
12300
+ (you're zoomed). Sibling to R534 edge-badge
12301
+ hover-precedence + R538 group-label hover-tier
12302
+ extensions.
12303
+ 2-tier alpha ladder:
12304
+ hover (interactional) legendAccent 99 (~60%)
12305
+ zoom > 1.5 (info) legendAccent 80 (~50%)
12306
+ rest none
12307
+ data-topo-minimap-viewport-glow attr upgraded
12308
+ binary ('true'/'false') → 3-value ('hover' |
12309
+ 'zoom' | 'false') so tests can distinguish
12310
+ gate cause. */
12311
+ data-topo-minimap-viewport-glow={hoveredMinimap ? 'hover' : view.zoom > 1.5 ? 'zoom' : 'false'}
10855
12312
  style={{
10856
- filter: view.zoom > 1.5
10857
- ? `drop-shadow(0 0 2px ${pal.legendAccent}80)`
10858
- : undefined,
12313
+ filter: hoveredMinimap
12314
+ ? `drop-shadow(0 0 2px ${pal.legendAccent}99)`
12315
+ : view.zoom > 1.5
12316
+ ? `drop-shadow(0 0 2px ${pal.legendAccent}80)`
12317
+ : undefined,
10859
12318
  transition: smoothView
10860
12319
  ? 'x 280ms ease-out, y 280ms ease-out, width 280ms ease-out, height 280ms ease-out, stroke-width 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out'
10861
12320
  : 'stroke-width 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
@@ -10996,7 +12455,41 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10996
12455
  // transition-colors only — without the transform transition,
10997
12456
  // active:scale-95 would hard-cut. transform-gpu promotes the
10998
12457
  // layer so scale doesn't trigger paint thrash.
10999
- className={`px-2 py-1 transition-colors transition-transform duration-200 ease-out transform-gpu active:scale-95 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset ${idx > 0 ? 'border-l' : ''} ${nodeScale === v ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 active:bg-cyan-500/25' : 'hover:bg-cyan-500/5 active:bg-cyan-500/15'}${chromePopping === popKey ? ' anet-chrome-pop' : ''}`}
12458
+ /* Round 521 / Loop extends R270's hover-preview pattern
12459
+ (inactive toggle hover previews the active state's
12460
+ visual register) to the TYPOGRAPHY axis at the chrome
12461
+ nodeSize S/M/L surface. Pre-R521 the inactive variant
12462
+ had `hover:bg-cyan-500/5` (R270 bg preview) but no
12463
+ typography preview — active variant uses `font-medium`
12464
+ (fw 500), inactive variant sat at default fw 400 even
12465
+ on hover.
12466
+ R521 adds `hover:font-medium` + `transition-[font-
12467
+ weight]` to the inactive variant so hovering an
12468
+ inactive S/M/L letter thickens the glyph 400 → 500,
12469
+ previewing the typography of the active state the
12470
+ click would commit to. Sibling to R421 chrome zoom-
12471
+ level fontWeight hover delta (rest 500 → hover 600)
12472
+ and R520 footer fontWeight hover (500 → 600) — same
12473
+ idiom: thicken-on-hover for chrome surfaces with a
12474
+ pre-commit gesture.
12475
+ `font-medium` (500) matches the ACTIVE variant's
12476
+ fw exactly — the inactive hover landing weight equals
12477
+ the active locked weight, so clicking commits to a
12478
+ typography state the eye already saw 'on the way in'.
12479
+ Hover-fw family extension (5 anchors now):
12480
+ R416 chip-row count digit rest 500 → hover 700/600
12481
+ R420 chrome zoom-level rest 500 → hover 600
12482
+ R425 hub-center digit rest 700 → hover 800
12483
+ R520 +N more flows footer rest 500 → hover 600
12484
+ R521 chrome nodeSize S/M/L inactive rest 400 → hover 500 ← this round
12485
+ Active variant `font-medium` unchanged so the rest-vs-
12486
+ active typography distinction stays intact when the
12487
+ user IS clicked-in (active stays at fw 500, inactive
12488
+ rest at fw 400, inactive hover preview at fw 500).
12489
+ data-topo-chrome-nodesize-hover-preview-fw="500" attr
12490
+ exposes the polish for tests. */
12491
+ 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' : ''}`}
12492
+ data-topo-chrome-nodesize-hover-preview-fw={nodeScale === v ? null : '500'}
11000
12493
  style={{ color: nodeScale === v ? undefined : pal.legendText, borderColor: pal.containerBorder }}
11001
12494
  >
11002
12495
  {lbl}
@@ -11134,10 +12627,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11134
12627
  ? 'true' : 'false'
11135
12628
  }
11136
12629
  data-topo-chrome-zoom-level-hover={hoveredZoomLevel ? 'true' : 'false'}
12630
+ /* Round 517 / Loop — extends the chrome zoom-level readout
12631
+ from 2-axis (R347 letter-spacing + R420 fontWeight) to
12632
+ 3-axis hover signature by adding a color brighten to
12633
+ pal.legendHeadline. Pre-R517 the readout's color stayed
12634
+ at pal.legendText on hover; the digits got tighter
12635
+ kerning (0→0.5px) and heavier weight (500→600) but
12636
+ stayed the same legendText gray tone. R517 lifts color
12637
+ to legendHeadline on hover so the readout brightens
12638
+ into the headline tier at the same beat — matching the
12639
+ R55/R197/R239 hover-deepen-own-hue idiom that legend-
12640
+ row label + count carry at panel scope. Chrome strip's
12641
+ only data display now has full 3-axis hover signature
12642
+ (letter-spacing + fontWeight + color), parity with the
12643
+ chip-row chips' own hover-brighten pattern.
12644
+ Implementation: inline color uses the same hoveredZoom-
12645
+ Level state as R347/R420 — no new state. Transition
12646
+ already includes 'color 200ms ease-out' (R264) so the
12647
+ brighten eases under the same cadence as the kerning +
12648
+ weight tweens — one motion-coherent 3-axis lift.
12649
+ data-topo-chrome-zoom-level-color attr exposes the
12650
+ resolved color string for tests. */
12651
+ data-topo-chrome-zoom-level-color={hoveredZoomLevel ? 'headline' : 'text'}
11137
12652
  onMouseEnter={() => setHoveredZoomLevel(true)}
11138
12653
  onMouseLeave={() => setHoveredZoomLevel(false)}
11139
12654
  style={{
11140
- color: pal.legendText,
12655
+ color: hoveredZoomLevel ? pal.legendHeadline : pal.legendText,
11141
12656
  borderColor: pal.containerBorder,
11142
12657
  minWidth: 46,
11143
12658
  display: 'inline-block',
@@ -11293,8 +12808,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11293
12808
  // owns transform during its 450ms run. transformOrigin
11294
12809
  // 'center' so rotation pivots around the icon's centre
11295
12810
  // (default would be top-left and the icon would arc).
12811
+ /* Round 514 / Loop — extends R352/R353 chrome icon hover-
12812
+ scale family to the reset button. Pre-R514 the reset
12813
+ icon had hover-rotate (-8°, R350) + hover-sw (2.5→2.8,
12814
+ R453) but no hover-scale, while zoom-out (R352), zoom-
12815
+ in (R352), and fullscreen (R353) icons all carried
12816
+ `group-hover:scale-110`. R514 brings the reset icon
12817
+ into the same 3-axis hover signature (rotate + sw +
12818
+ scale) as the rest of the chrome strip.
12819
+ Implementation: inline transform composes rotate +
12820
+ scale into one string. `transform: rotate(-8deg)
12821
+ scale(1.1)` on hover; `rotate(0) scale(1)` at rest.
12822
+ transformOrigin 'center' applies to both — rotation
12823
+ pivots around centre AND scale grows from centre.
12824
+ The Tailwind `group-hover:scale-110` approach can't
12825
+ work here because inline `style.transform` overrides
12826
+ className-based transforms; compose the multi-axis
12827
+ transform inline instead.
12828
+ Chrome icon hover gesture parity (post-R514):
12829
+ zoom-out scale-110 + sw-lift (R352/R454)
12830
+ zoom-in scale-110 + sw-lift (R352/R454)
12831
+ fullscreen scale-110 + sw-lift (R353/R455)
12832
+ reset scale-1.1 + sw-lift + rotate -8°
12833
+ (R514 + R453 + R350)
12834
+ reset gets the EXTRA rotate axis because R350's spin
12835
+ preview semantic is reset-specific — the rotation
12836
+ hints at the click-spin (R184) the button will fire. */
11296
12837
  style={{
11297
- transform: hoveredReset && !resetSpinning ? 'rotate(-8deg)' : 'rotate(0deg)',
12838
+ transform: hoveredReset && !resetSpinning ? 'rotate(-8deg) scale(1.1)' : 'rotate(0deg) scale(1)',
11298
12839
  transformOrigin: 'center',
11299
12840
  transition: 'transform 200ms ease-out, stroke-width 200ms ease-out',
11300
12841
  }}