@sleep2agi/agent-network-dashboard 0.5.3-preview.9 → 0.5.3

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 (177) 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/0fuba20p57-zo.js +1 -0
  145. package/.next/static/chunks/{0hndl9yzpqajt.css → 0m.1mvl~t.avc.css} +1 -1
  146. package/.next/static/chunks/{00d~vl~k~7f75.js → 0ph1in3421~o-.js} +1 -1
  147. package/.next/static/chunks/{03a4--7ncekmk.js → 0v4-5tng.uh.7.js} +2 -2
  148. package/.next/static/chunks/10cj-86disers.js +1 -0
  149. package/.next/static/chunks/158h2g7f-nxbt.js +4 -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 +442 -21
  154. package/app/globals.css +23 -0
  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-fleet-density-tier-test.mjs +84 -0
  160. package/scripts/topo-freshness-chip-fade-test.mjs +105 -0
  161. package/scripts/topo-hub-highlight-amplify-test.mjs +139 -0
  162. package/scripts/topo-hub-highlight-fill-transition-test.mjs +84 -0
  163. package/scripts/topo-hub-highlight-recede-test.mjs +144 -0
  164. package/scripts/topo-hub-highlight-theme-fill-test.mjs +83 -0
  165. package/scripts/topo-hub-idle-breath-test.mjs +109 -0
  166. package/scripts/topo-hub-recede-test.mjs +124 -0
  167. package/scripts/topo-orphan-box-dash-test.mjs +89 -0
  168. package/scripts/topo-orphan-fill-opacity-test.mjs +91 -0
  169. package/scripts/topo-orphan-label-italic-test.mjs +90 -0
  170. package/scripts/topo-pinned-aspect-test.mjs +89 -0
  171. package/scripts/topo-recent-hot-pulse-test.mjs +102 -0
  172. package/.next/static/chunks/0jn4lbsj97vfl.js +0 -1
  173. package/.next/static/chunks/0rz3-6.xs78~d.js +0 -1
  174. package/.next/static/chunks/0~~xbw42y4m3v.js +0 -4
  175. /package/.next/static/{nbpD7Lhttq59fvvIw9gTX → tQ1EtcEiS7ikgAoAyZWm7}/_buildManifest.js +0 -0
  176. /package/.next/static/{nbpD7Lhttq59fvvIw9gTX → tQ1EtcEiS7ikgAoAyZWm7}/_clientMiddlewareManifest.js +0 -0
  177. /package/.next/static/{nbpD7Lhttq59fvvIw9gTX → tQ1EtcEiS7ikgAoAyZWm7}/_ssgManifest.js +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-turbopack","duration":7108570,"timestamp":190137123558,"id":14,"parentId":1,"tags":{},"startTime":1778998109370,"traceId":"a61172da4b6d626e"},{"name":"turbopack-build-events","duration":80,"timestamp":190137585436,"id":15,"parentId":1,"tags":{},"startTime":1778998109832,"traceId":"a61172da4b6d626e"},{"name":"run-typescript","duration":10780998,"timestamp":190144258686,"id":16,"parentId":1,"tags":{},"startTime":1778998116505,"traceId":"a61172da4b6d626e"},{"name":"static-check","duration":1028848,"timestamp":190155323939,"id":19,"parentId":1,"tags":{},"startTime":1778998127570,"traceId":"a61172da4b6d626e"},{"name":"static-generation","duration":675445,"timestamp":190156371940,"id":109,"parentId":1,"tags":{},"startTime":1778998128618,"traceId":"a61172da4b6d626e"},{"name":"telemetry-flush","duration":16953,"timestamp":190157086237,"id":118,"parentId":1,"tags":{},"startTime":1778998129333,"traceId":"a61172da4b6d626e"},{"name":"next-build","duration":20286498,"timestamp":190136816719,"id":1,"tags":{"buildMode":"default","version":"16.2.3","bundler":"turbopack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1778998109063,"traceId":"a61172da4b6d626e"}]
1
+ [{"name":"run-turbopack","duration":9293296,"timestamp":202412784691,"id":14,"parentId":1,"tags":{},"startTime":1779010385031,"traceId":"bf6144689cc003d8"},{"name":"turbopack-build-events","duration":130,"timestamp":202413127727,"id":15,"parentId":1,"tags":{},"startTime":1779010385374,"traceId":"bf6144689cc003d8"},{"name":"run-typescript","duration":11991419,"timestamp":202422088287,"id":16,"parentId":1,"tags":{},"startTime":1779010394335,"traceId":"bf6144689cc003d8"},{"name":"static-check","duration":1155509,"timestamp":202434315044,"id":19,"parentId":1,"tags":{},"startTime":1779010406561,"traceId":"bf6144689cc003d8"},{"name":"static-generation","duration":792871,"timestamp":202435491023,"id":109,"parentId":1,"tags":{},"startTime":1779010407737,"traceId":"bf6144689cc003d8"},{"name":"telemetry-flush","duration":230819,"timestamp":202436320728,"id":118,"parentId":1,"tags":{},"startTime":1779010408567,"traceId":"bf6144689cc003d8"},{"name":"next-build","duration":24041344,"timestamp":202412510263,"id":1,"tags":{"buildMode":"default","version":"16.2.3","bundler":"turbopack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1779010384757,"traceId":"bf6144689cc003d8"}]
@@ -151,8 +151,18 @@ function HealthBadge({ cpu, mem, disk }: { cpu: number | null; mem: number | nul
151
151
  function AgentList({ agents }: { agents?: ServerAgent[] }) {
152
152
  if (!agents || agents.length === 0) {
153
153
  return (
154
- <div className="text-[9px] text-[var(--fg-dim)] font-mono italic px-1 py-1">
155
- agent rollup pending hub 0.8.2-preview
154
+ <div className="text-[9px] text-[var(--fg-dim)] font-mono italic px-1 py-1" data-server-agents-missing="true">
155
+ {/* #157 fix copy update. Pre-#157 the placeholder read
156
+ "agent rollup pending hub ≥ 0.8.2-preview". commhub-server@
157
+ 0.8.2 is LIVE in prod (Vincent screenshot 5560 verified) but
158
+ still doesn't ship `agents[]`. Version-specific text was
159
+ misleading — implied upgrade-needed when hub already
160
+ crossed the threshold. New copy drops the version pinning
161
+ and just states the data-shape: hub hasn't reported the
162
+ agent rollup for this server (could be hub-side feature
163
+ gap or session-source gap). data-server-agents-missing
164
+ attr surfaces the gate for tests. */}
165
+ agent rollup not reported by hub
156
166
  </div>
157
167
  );
158
168
  }
@@ -350,7 +360,10 @@ export function ServersDrawer() {
350
360
  {diskPct != null ? (
351
361
  <Bar pct={diskPct} label={`DISK ${s.disk_used_gb!.toFixed(1)}/${s.disk_total_gb!.toFixed(1)}G`} />
352
362
  ) : (
353
- <div className="text-[9px] text-[var(--fg-dim)] font-mono italic">disk metric pending hub ≥ 0.8.2-preview</div>
363
+ /* #157 sibling fix same misleading version-pin copy
364
+ dropped at the disk-metric placeholder. Same
365
+ rationale as the agent-rollup copy above. */
366
+ <div className="text-[9px] text-[var(--fg-dim)] font-mono italic" data-server-disk-missing="true">disk metric not reported by hub</div>
354
367
  )}
355
368
  {s.cpu_history && s.cpu_history.length >= 2 && (
356
369
  <div className="space-y-0.5">
@@ -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
  };
@@ -3562,6 +3588,36 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3562
3588
  on the canvas root for non-visual consumers.
3563
3589
  Composed from existing onlineNodes / workingCount /
3564
3590
  offlineNodes / flowLinks — no new state. */
3591
+ /* Round 502 / Loop — categorical density-tier paired with the
3592
+ R469 numeric counts. data-topo-fleet-density-tier classifies
3593
+ the fleet size into 5 buckets so external consumers (CSS
3594
+ selectors, Playwright probes, future density-conditional
3595
+ polish gates like R109 dense-label collapse at 16+ nodes)
3596
+ can branch on a stable tier name without re-deriving the
3597
+ threshold logic from the raw numeric. Buckets:
3598
+ 'empty' — onlineNodes.length === 0
3599
+ 'sparse' — 1-3 nodes
3600
+ 'normal' — 4-15 nodes
3601
+ 'dense' — 16-30 nodes (matches R109 collapse gate)
3602
+ 'very-dense' — 31+ nodes
3603
+ Picks the gate boundaries that already drive CONDITIONAL
3604
+ RENDER decisions elsewhere (R109 denseLayout = >16, R110
3605
+ plain-text fallback) so the tier name is semantically
3606
+ aligned with the visual mode the canvas already switches
3607
+ to. Composed from existing onlineNodes — no new state.
3608
+ 12th attr in the canvas state surface set (R462/R466/R467/
3609
+ R469×4/R471×2/R487/R488/R502). 12 attrs covers: build
3610
+ identity, transient/sticky inspection modes, fleet split
3611
+ numerics, fleet density tier, canvas layout/theme, canvas
3612
+ zoom, hover identity. A test harness can snapshot the
3613
+ full canvas state with 12 getAttribute calls. */
3614
+ data-topo-fleet-density-tier={
3615
+ onlineNodes.length === 0 ? 'empty' :
3616
+ onlineNodes.length <= 3 ? 'sparse' :
3617
+ onlineNodes.length <= 15 ? 'normal' :
3618
+ onlineNodes.length <= 30 ? 'dense' :
3619
+ 'very-dense'
3620
+ }
3565
3621
  data-topo-online-count={onlineNodes.length}
3566
3622
  data-topo-working-count={workingCount}
3567
3623
  data-topo-offline-count={offlineNodes.length}
@@ -3627,6 +3683,41 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3627
3683
  categorical) — separate dedicated attrs if/when needed.
3628
3684
  Root svg attribute set now 11 attrs total. */
3629
3685
  data-topo-hovered-alias={hoveredAlias ?? ''}
3686
+ /* Round 504 / Loop — categorical pin-aspect attr paired with
3687
+ R467 any-pinned boolean and R488 hovered-alias identity.
3688
+ Pre-R504 the canvas state surface set told tests WHETHER
3689
+ any pin was active (R467 boolean) but tests had to enumerate
3690
+ 4 individual state vars to determine WHICH pin axis fired:
3691
+ pinnedStatus legend-row status filter
3692
+ pinnedGroup prefix-cluster lock
3693
+ pinnedVendor vendor-chip filter
3694
+ pinnedEdgeKey edge-focus
3695
+ R504 surfaces the active aspect as a single categorical
3696
+ attribute: data-topo-pinned-aspect ∈
3697
+ 'none' no pin active
3698
+ 'status' pinnedStatus only
3699
+ 'group' pinnedGroup only
3700
+ 'vendor' pinnedVendor only
3701
+ 'edge' pinnedEdgeKey only
3702
+ 'multi' 2 or more pins active simultaneously
3703
+ ('multi' covers cross-cutting filters — e.g. user pins
3704
+ status='working' AND vendor='claude' simultaneously to
3705
+ narrow the canvas. Each pin axis is independently
3706
+ dismissable via Esc / individual chip click, so multi
3707
+ states are reachable and worth surfacing as a distinct
3708
+ tier.)
3709
+ 13th attr in the canvas state surface set after R502.
3710
+ Composed from 4 existing state vars — no new state. */
3711
+ data-topo-pinned-aspect={(() => {
3712
+ const aspects: string[] = [];
3713
+ if (pinnedStatus) aspects.push('status');
3714
+ if (pinnedGroup) aspects.push('group');
3715
+ if (pinnedVendor) aspects.push('vendor');
3716
+ if (pinnedEdgeKey) aspects.push('edge');
3717
+ if (aspects.length === 0) return 'none';
3718
+ if (aspects.length === 1) return aspects[0];
3719
+ return 'multi';
3720
+ })()}
3630
3721
  /* Round 466 / Loop — aggregate hover signal on the root SVG.
3631
3722
  Exposes a single boolean `data-topo-any-hover` that
3632
3723
  reflects whether ANY hover state in the topology is
@@ -4556,12 +4647,75 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4556
4647
  // pinned → fill 0.08 / 0.13, stroke 3 px (locked)
4557
4648
  // hovered → fill 0.05 / 0.09, stroke 2 px (inspecting)
4558
4649
  // idle → fill 0.025 / 0.045, stroke 1.5 px dashed
4559
- fillOpacity={isPinned ? (isLight ? 0.08 : 0.13)
4560
- : isHovered ? (isLight ? 0.05 : 0.09)
4561
- : (isLight ? 0.025 : 0.045)}
4650
+ /* Round 506 / Loop category-differentiation family
4651
+ 3rd anchor. Orphan band rest-state fillOpacity drops
4652
+ slightly below prefix-group rest (0.025/0.045
4653
+ 0.015/0.028). Adds a 3rd independent paint
4654
+ differentiator to the orphan visual signature:
4655
+ R499 fontStyle: italic (label text)
4656
+ R503 '3 6' dash pattern (rect stroke)
4657
+ R506 lower fillOpacity (rect fill) ← this round
4658
+ Three independent channels (typography + stroke
4659
+ pattern + fill density) collectively encode the
4660
+ catchall semantic at rest. Pin and hover branches
4661
+ UNCHANGED (still 0.05/0.09 hover, 0.08/0.13 pin) —
4662
+ orphan box gets full visual emphasis on inspection
4663
+ identical to prefix groups; the differentiation
4664
+ lives ONLY in the unsolicited rest state. The
4665
+ ~40% drop (0.045 → 0.028 cyber, 0.025 → 0.015
4666
+ light) is subtle enough that the orphan box stays
4667
+ visible at rest, just quieter — matches the
4668
+ "misc bucket, less attention-deserving" semantic
4669
+ without losing the visual anchor.
4670
+ Pure paint axis; bbox unchanged; R51 SVG sentinel
4671
+ safety untouched (overlap-test gates to g[data-
4672
+ node], cluster rect invisible to it).
4673
+ data-group-box-fill-opacity attr surfaces the
4674
+ resolved value for tests. */
4675
+ fillOpacity={
4676
+ isPinned ? (isLight ? 0.08 : 0.13)
4677
+ : isHovered ? (isLight ? 0.05 : 0.09)
4678
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4679
+ : (isLight ? 0.025 : 0.045)
4680
+ }
4681
+ data-group-box-fill-opacity={
4682
+ isPinned ? (isLight ? 0.08 : 0.13)
4683
+ : isHovered ? (isLight ? 0.05 : 0.09)
4684
+ : box.isOrphan ? (isLight ? 0.015 : 0.028)
4685
+ : (isLight ? 0.025 : 0.045)
4686
+ }
4562
4687
  stroke={(isPinned || isHovered) ? pal.legendAccent : pal.ringStroke}
4563
4688
  strokeWidth={isPinned ? 3 : isHovered ? 2 : 1.5}
4564
- strokeDasharray={(isPinned || isHovered) ? 'none' : '6 6'}
4689
+ /* Round 503 / Loop category-differentiation family
4690
+ 2nd anchor (R499 = orphan label italic, 1st anchor).
4691
+ Orphan band rest-state strokeDasharray switches from
4692
+ '6 6' (prefix-group default) to '3 6' (tighter
4693
+ dashes). Pre-R503 the rect dash pattern was uniform
4694
+ across all bands; combined with R499's italic label,
4695
+ the orphan box now has TWO independent paint/
4696
+ typography differentiators at rest:
4697
+ R499 fontStyle: italic (label text)
4698
+ R503 '3 6' dash pattern (rect stroke) ← this round
4699
+ The R85 marching-ants animation continues to work
4700
+ with the new dash size (uses --march-dur custom
4701
+ property, dash-length-agnostic) — orphan's ants
4702
+ just have a different visual rhythm than prefix-
4703
+ group ants, reinforcing the catchall semantic.
4704
+ Pinned/hovered orphan still gets 'none' (solid
4705
+ stroke) so the hover/pin affordance is preserved
4706
+ — the differentiation lives ONLY in the rest
4707
+ state, never blocking inspection.
4708
+ Pure paint axis; no geometry change; bbox unchanged
4709
+ (strokeDasharray is paint-only). R51 SVG sentinel
4710
+ safety untouched (overlap-test gates to g[data-
4711
+ node], this cluster rect is invisible to it).
4712
+ data-group-box-orphan attr surfaces the gate for
4713
+ tests + future polish references. */
4714
+ strokeDasharray={
4715
+ (isPinned || isHovered) ? 'none' :
4716
+ box.isOrphan ? '3 6' : '6 6'
4717
+ }
4718
+ data-group-box-orphan={box.isOrphan ? 'true' : 'false'}
4565
4719
  /* Round 380 / Loop: cluster box stroke gets round
4566
4720
  linecap + round linejoin. Sibling SVG stroke-
4567
4721
  softening polish to R378 flow-rail linecap + R379
@@ -4906,16 +5060,39 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4906
5060
  ease-out' alongside the existing fill/ls/fw/opacity
4907
5061
  200ms tweens. */
4908
5062
  data-group-label-glow={isPinned ? 'true' : 'false'}
5063
+ /* Round 499 / Loop — orphan band "其他" label gets
5064
+ fontStyle: italic to visually distinguish the
5065
+ catchall from real prefix-group bands. Pre-R499
5066
+ the orphan box label rendered identically to
5067
+ prefix-group labels (Hero D fontSize=9, fw=700,
5068
+ opacity 0.55 rest), so users had to read the
5069
+ literal text "其他" to identify the catchall. R499
5070
+ adds a pure-typography differentiation: italic
5071
+ signals "this is the misc bucket, not a real
5072
+ named group" while preserving full opacity
5073
+ affordance on hover/pin — the orphan box stays
5074
+ equally inspectable, just typographically marked
5075
+ as a different category. No geometry change
5076
+ (italic shifts glyph slant within the same bbox),
5077
+ no opacity loss, no behavior change. Sibling to
5078
+ R432 letter-spacing 3-tier + R457 pin fw-lift +
5079
+ R479 pin drop-shadow at the group-label scope.
5080
+ Falls under 配色 / 节点视觉 themes per the prompt;
5081
+ advances the "信息密度" axis by encoding
5082
+ category-distinction into a single typography
5083
+ channel without adding visual chrome. */
4909
5084
  style={{
4910
5085
  transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
4911
5086
  letterSpacing: isPinned ? '0.5px' :
4912
5087
  isHovered ? '0.25px' : '0px',
5088
+ fontStyle: box.isOrphan ? 'italic' : undefined,
4913
5089
  filter: isPinned
4914
5090
  ? `drop-shadow(0 0 3px ${pal.legendAccent}80)`
4915
5091
  : undefined,
4916
5092
  }}
4917
5093
  data-group-label={box.key}
4918
5094
  data-group-label-pinned={isPinned ? 'true' : 'false'}
5095
+ data-group-label-orphan={box.isOrphan ? 'true' : 'false'}
4919
5096
  >
4920
5097
  {box.key}
4921
5098
  {/* Round 19 / Loop: member-count chip. Inline tspan stays
@@ -6530,11 +6707,45 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6530
6707
  so the glow eases under the same cadence as the
6531
6708
  scale + fw + fill axes. */
6532
6709
  data-topo-hub-working-count-glow={!reducedMotion && hoveredHub ? 'true' : 'false'}
6710
+ /* Round 507 / Loop — focal recede. When ANY non-hub
6711
+ canvas surface is hovered (a node / an edge / a
6712
+ group label / a legend row / a vendor chip), the
6713
+ hub-center workingCount digit fades to 0.85 opacity,
6714
+ signaling "you're inspecting elsewhere, hub recedes
6715
+ to background." When the user un-hovers (or hovers
6716
+ the hub itself), opacity returns to 1.0. Pure paint
6717
+ polish at the canvas's most prominent focal point.
6718
+ Hits 信息密度 + 动效 themes — the hub digit gives
6719
+ way visually to the surface under inspection,
6720
+ reinforcing the "this is the focal point right now"
6721
+ gesture without requiring users to track which
6722
+ surface holds attention.
6723
+ Gate excludes hoveredHub specifically: hovering the
6724
+ hub itself should LIFT the digit (R425 fw bump +
6725
+ R476 glow + R209 scale 1.08) — the existing hover-
6726
+ on-hub signature is intact; only inspection
6727
+ ELSEWHERE recedes the hub.
6728
+ Composed from existing hoveredAlias / hoveredEdge-
6729
+ Key / hoveredGroupLabel / hoveredStatus / hovered-
6730
+ Vendor — no new state. 300ms ease-out opacity
6731
+ transition already in the style list (existing R213
6732
+ transition spec), so the fade rides on existing
6733
+ infrastructure.
6734
+ data-topo-hub-recede attr surfaces the gate state
6735
+ for tests. */
6736
+ data-topo-hub-recede={
6737
+ (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6738
+ hoveredStatus || hoveredVendor) && !hoveredHub ? 'true' : 'false'
6739
+ }
6533
6740
  style={{
6534
6741
  pointerEvents: 'none',
6535
6742
  transform: !reducedMotion && hoveredHub ? 'scale(1.08)' : 'scale(1)',
6536
6743
  transformBox: 'fill-box',
6537
6744
  transformOrigin: 'center',
6745
+ opacity: (hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6746
+ hoveredStatus || hoveredVendor) && !hoveredHub
6747
+ ? 0.85
6748
+ : 1,
6538
6749
  filter: !reducedMotion && hoveredHub
6539
6750
  ? (isLight
6540
6751
  ? 'drop-shadow(0 0 2px rgba(16, 185, 129, 0.6))'
@@ -6544,7 +6755,9 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6544
6755
  bump 700 → 800 eases under the same cadence as
6545
6756
  R209 scale + R253 fill + R213 opacity.
6546
6757
  R476: filter 200ms appended so the new drop-
6547
- shadow glow eases at the same cadence. */
6758
+ shadow glow eases at the same cadence.
6759
+ R507: opacity 300ms (existing in list) covers
6760
+ the new focal-recede fade. */
6548
6761
  transition: 'transform 200ms ease-out, opacity 300ms ease-out, fill 200ms ease-out, font-weight 200ms ease-out, filter 200ms ease-out',
6549
6762
  fontVariantNumeric: 'tabular-nums',
6550
6763
  }}
@@ -6591,19 +6804,138 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6591
6804
  + R213 always-mount opacity-gate + pointerEvents:none
6592
6805
  + R365 r=5.5 all preserved. data-topo-hub-highlight-
6593
6806
  opacity attr exposes the resolved value for tests. */}
6594
- <circle
6595
- cx={cx} cy={cy} r="5.5"
6596
- fill="#d1fae5"
6597
- opacity={workingCount > 0 ? 0 : 0.95}
6598
- data-topo-hub-highlight
6599
- data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
6600
- data-topo-hub-highlight-radius="5.5"
6601
- data-topo-hub-highlight-opacity={workingCount > 0 ? 0 : 0.95}
6602
- style={{
6603
- pointerEvents: 'none',
6604
- transition: 'opacity 300ms ease-out',
6605
- }}
6606
- />
6807
+ {/* Round 508 / Loop — focal-recede pattern 2nd anchor.
6808
+ Extends R507's hub-digit recede to the hub-highlight
6809
+ circle so the hub focal CLUSTER (digit at z-top + this
6810
+ idle-state highlight beneath) recedes as a unit when
6811
+ canvas attention is elsewhere. Computed once: a single
6812
+ non-hub-hover gate drives BOTH the digit (R507) AND
6813
+ this highlight (R508) so they always co-move.
6814
+ Recede multiplies the visible opacity by 0.85 — when
6815
+ workingCount===0 the rest opacity 0.95 becomes 0.81
6816
+ during external-hover; when workingCount>0 the
6817
+ opacity stays 0 (invisible) regardless of recede.
6818
+ Additionally, when recede is active the SMIL breath
6819
+ animation halts (animate node un-mounts) so the
6820
+ receded state reads as quietly static, not pulsing
6821
+ at 0.85↔1.0 against the recede multiplier (which
6822
+ would visually conflict — competing 15% drops). On
6823
+ un-hover the animate re-mounts and breath resumes.
6824
+ data-topo-hub-recede on both digit AND highlight
6825
+ provides a stable test handle for the unified-recede
6826
+ gate.
6827
+ Composed from existing hover state vars — no new
6828
+ state. Pure paint axis. */}
6829
+ {(() => {
6830
+ const hubRecede = !!((hoveredAlias || hoveredEdgeKey || hoveredGroupLabel ||
6831
+ hoveredStatus || hoveredVendor) && !hoveredHub);
6832
+ /* Round 511 / Loop — hub-highlight gains a 3rd opacity tier
6833
+ (hovered-amplify). Pre-R511 the highlight had 2 visible
6834
+ states: rest (0.95) and recede (0.81 = 0.95 × 0.85).
6835
+ When the hub itself was hovered, the digit got R425 fw
6836
+ lift + R476 drop-shadow + R209 scale-1.08, but the
6837
+ highlight disc sibling stayed at 0.95 — the focal
6838
+ cluster lifted in 3 channels (typography/paint/scale)
6839
+ but the highlight didn't participate.
6840
+ R511 closes that asymmetry: when hoveredHub is true,
6841
+ highlight base opacity lifts to 1.0 (5% boost from
6842
+ rest 0.95). Cluster now lifts as a unit on hub-hover,
6843
+ just like it recedes as a unit on non-hub-hover
6844
+ (R508).
6845
+ 3-state opacity ladder:
6846
+ hub-hovered: baseOpacity = 1.0 (R511 NEW)
6847
+ rest (no hover): baseOpacity = 0.95 (existing)
6848
+ non-hub canvas hov: baseOpacity × 0.85 = 0.81 (R508)
6849
+ Composes cleanly: hubRecede gate requires !hoveredHub,
6850
+ so the hovered-amplify and recede states are mutually
6851
+ exclusive (they can't both fire). breathActive
6852
+ continues to halt on either non-rest state (recede OR
6853
+ hub-hover would visually compete with the 0.85↔1
6854
+ breath — clean for the unit-lift semantic too). */
6855
+ const baseOpacity = workingCount > 0 ? 0
6856
+ : hoveredHub ? 1.0
6857
+ : 0.95;
6858
+ const resolvedOpacity = hubRecede ? baseOpacity * 0.85 : baseOpacity;
6859
+ const breathActive = !reducedMotion && workingCount === 0 && !hubRecede && !hoveredHub;
6860
+ return (
6861
+ <circle
6862
+ cx={cx} cy={cy} r="5.5"
6863
+ /* Round 509 / Loop — 配色 cross-theme cleanup. Pre-R509
6864
+ the hub-highlight fill was hardcoded `#d1fae5`
6865
+ (emerald-100, a pale tone). On the light theme this
6866
+ near-white green ran against a pale background at
6867
+ 0.95 opacity — the disc was effectively invisible.
6868
+ Matches the existing R253 halo theme-inversion
6869
+ pattern (line ~6481): light theme picks the dark
6870
+ vibrant emerald (#10b981 emerald-600), dark theme
6871
+ keeps the pale emerald (#d1fae5 emerald-100). Both
6872
+ read at the same 0.95 opacity against their
6873
+ respective backdrops — light gets a saturated
6874
+ focal dot; dark keeps the soft glow signature.
6875
+ Pure paint axis (fill change only); bbox unchanged;
6876
+ R51 SVG sentinel safety untouched.
6877
+ transition list already includes `fill 200ms`?
6878
+ Actually the existing transition spec is `opacity
6879
+ 300ms ease-out` — fill change on theme toggle
6880
+ will be instant. That's acceptable: theme toggle
6881
+ is a discrete event, and the halo (line 6500)
6882
+ already snaps fill on theme toggle the same way
6883
+ (`fill 200ms ease-out` was added later to halo
6884
+ via R253). Future round could add `fill 200ms`
6885
+ to highlight too if theme-switch flicker is
6886
+ noticed. */
6887
+ fill={isLight ? '#10b981' : '#d1fae5'}
6888
+ opacity={resolvedOpacity}
6889
+ data-topo-hub-highlight
6890
+ data-topo-hub-highlight-visible={workingCount > 0 ? 'false' : 'true'}
6891
+ data-topo-hub-highlight-radius="5.5"
6892
+ data-topo-hub-highlight-opacity={resolvedOpacity}
6893
+ data-topo-hub-highlight-breath={breathActive ? 'true' : 'false'}
6894
+ data-topo-hub-highlight-recede={hubRecede ? 'true' : 'false'}
6895
+ /* Round 510 / Loop — R509 follow-on: theme-toggle fill
6896
+ ease. Pre-R510 the hub-highlight transition spec only
6897
+ listed `opacity 300ms ease-out`. When R509 introduced
6898
+ theme-conditional fill (#d1fae5 ↔ #10b981), the fill
6899
+ change SNAPPED on theme toggle because the transition
6900
+ list didn't include `fill`. R510 extends to `fill
6901
+ 200ms ease-out` so theme cycles smoothly through the
6902
+ emerald palette. 200ms timing matches the R253 halo
6903
+ fill transition (line ~6500) — both hub-cluster
6904
+ theme transitions now share a cadence so the focal
6905
+ cluster (digit + highlight + halo) eases as a unit.
6906
+ R508's recede opacity transition unchanged (300ms);
6907
+ fill is independent. */
6908
+ style={{
6909
+ pointerEvents: 'none',
6910
+ transition: 'opacity 300ms ease-out, fill 200ms ease-out',
6911
+ }}
6912
+ >
6913
+ {/* Round 497 / Loop — idle-state breath (呼吸感 theme pivot
6914
+ from the R492-R496 press-family arc). Pre-R497 the hub
6915
+ idle highlight read as a static dim disc — present but
6916
+ motionless, visually mute. R497 adds a 4s opacity breath
6917
+ (0.85 ↔ 1.0 ↔ 0.85) so the hub reads "alive but quiet"
6918
+ instead of "frozen", giving the empty-fleet state a
6919
+ subtle living signature.
6920
+ Gates:
6921
+ - !reducedMotion (R29 a11y blanket) — reducedMotion
6922
+ users see static 0.95 disc, no animate
6923
+ - workingCount === 0 — when fleet is busy, the
6924
+ highlight is invisible (opacity=0) so the animate
6925
+ would waste paint cycles. Gating saves work.
6926
+ SMIL <animate> overrides the static opacity={0.95}
6927
+ during its run; falls back to 0.95 when reducedMotion
6928
+ flips on (the animate node simply doesn't render).
6929
+ 4s cycle is long enough to feel like ambient breath
6930
+ rather than a pulse, matching the "quiet" semantic.
6931
+ data-topo-hub-highlight-breath attr exposes the
6932
+ resolved gate state for tests. */}
6933
+ {breathActive && (
6934
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
6935
+ )}
6936
+ </circle>
6937
+ );
6938
+ })()}
6607
6939
  {/* R115 / Loop: hover hint ring. Stroke-only circle at r=14
6608
6940
  that fades in when the hub is hovered — the same idea
6609
6941
  R44 used for node avatars (group-hover stroke). r=14
@@ -7588,6 +7920,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7588
7920
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
7589
7921
 
7590
7922
  if (isIntern || internByAlias || vendor.logo) {
7923
+ /* Round 501 / Loop — vendor avatar inside node circles
7924
+ gains a hover-gated brightness lift. Pre-R501 the
7925
+ avatar <image> was the only per-node surface with
7926
+ NO hover treatment: R26 lifted the card, R242 tinted
7927
+ the card stroke, R427 spread the alias letter-
7928
+ spacing, R500 added the alias drop-shadow, R208
7929
+ lifted the runtime badge ring, R443 thickened
7930
+ the badge icon stroke, R177 brightened the
7931
+ halo — but the most visually-prominent element
7932
+ (the vendor logo / 书生 coin centred in each node)
7933
+ stayed paint-static. R501 closes the per-node
7934
+ hover-affordance arc by adding a 15% brightness
7935
+ lift on hover.
7936
+ Implementation: CSS filter: brightness(1.15)
7937
+ when hoveredAlias === session.alias. Pure paint
7938
+ axis on the <image> element — no geometry change,
7939
+ no bbox shift. Modern-browser supported (Chrome 64+
7940
+ / FF 56+ / Safari 9.1+).
7941
+ Hits 节点视觉 theme. data-node-avatar-hovered
7942
+ attr surfaces the gate for tests.
7943
+ Gated on !reducedMotion as a courtesy (brightness
7944
+ transition < ~50ms still feels instant; the gate
7945
+ avoids the transition cycle for a11y users). */
7946
+ const isAvatarHovered = !reducedMotion && hoveredAlias === session.alias;
7591
7947
  return (
7592
7948
  <image
7593
7949
  href={vendor.logo ?? '/intern_avatar.png'}
@@ -7596,6 +7952,12 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
7596
7952
  width={size}
7597
7953
  height={size}
7598
7954
  preserveAspectRatio="xMidYMid meet"
7955
+ data-node-avatar={session.alias}
7956
+ data-node-avatar-hovered={isAvatarHovered ? 'true' : 'false'}
7957
+ style={{
7958
+ filter: isAvatarHovered ? 'brightness(1.15)' : undefined,
7959
+ transition: 'filter 200ms ease-out',
7960
+ }}
7599
7961
  />
7600
7962
  );
7601
7963
  }
@@ -8019,6 +8381,43 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8019
8381
  R211 fill 300ms + R305 letter-spacing 200ms
8020
8382
  transition list preserved; only the
8021
8383
  conditional gets a middle case. */}
8384
+ {/* Round 500 / Loop — milestone round, opens
8385
+ per-node alias drop-shadow polish. Extends the
8386
+ R476-R481 drop-shadow visual-polish family to a
8387
+ 7th anchor: hovered alias text gains a soft
8388
+ status-coloured text-glow. Pre-R500 hover on
8389
+ a node triggered card-lift (R26 translateY) +
8390
+ card-stroke (R242 tint) + alias letter-spacing
8391
+ (R427 0.3px tier) but the alias TEXT itself had
8392
+ no paint-axis cue beyond fill (R211). R500 adds
8393
+ a drop-shadow on the text glyph itself, so the
8394
+ identity glyph itself lights up under attention
8395
+ — matching the R476 idiom (hub-digit emerald
8396
+ glow on hover) at the per-node identity scope.
8397
+ 2px blur radius at 50% alpha — subtler than the
8398
+ R476 hub-digit (3px at 60%) because the alias
8399
+ text is smaller and more numerous (1 per node)
8400
+ so an aggressive glow would multiply into
8401
+ visual noise. Status-coloured (status.text) so
8402
+ the glow inherits the node's working/idle/
8403
+ offline palette — green/cyan/gray respectively.
8404
+ Drop-shadow visual-polish family — 7 anchors:
8405
+ R476 hub digit hover-gated emerald
8406
+ R477 legend pin-ring pin-gated row.fill
8407
+ R478 recent-row pip fresh-gated cyan
8408
+ R479 group-label text pin-gated cyan
8409
+ R480 hot-lane edge hot-gated amber
8410
+ R481 zoom-state minimap zoom-gated cyan
8411
+ R500 node alias text hover-gated status.text ← this round
8412
+ Filter is paint-only; bbox unchanged; overlap-
8413
+ test invariants hold (R51 selector gated to
8414
+ g[data-node] descendants with strokeWidth
8415
+ sentinels; text element doesn't carry stroke).
8416
+ transition list extends to include 'filter
8417
+ 200ms ease-out' alongside the existing fill
8418
+ 300ms + letter-spacing 200ms tweens.
8419
+ data-node-alias-glow attr surfaces the hover
8420
+ gate for tests. */}
8022
8421
  <text
8023
8422
  x="0" y="1" textAnchor="middle"
8024
8423
  fill={status.text}
@@ -8026,11 +8425,15 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8026
8425
  data-node-alias-text={session.alias}
8027
8426
  data-node-alias-chat-target={chatAlias === session.alias ? 'true' : 'false'}
8028
8427
  data-node-alias-hovered={hoveredAlias === session.alias ? 'true' : 'false'}
8428
+ data-node-alias-glow={!reducedMotion && hoveredAlias === session.alias ? 'true' : 'false'}
8029
8429
  style={{
8030
- transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out',
8430
+ transition: 'fill 300ms ease-out, letter-spacing 200ms ease-out, filter 200ms ease-out',
8031
8431
  letterSpacing:
8032
8432
  chatAlias === session.alias ? '0.5px' :
8033
8433
  hoveredAlias === session.alias ? '0.3px' : '0px',
8434
+ filter: !reducedMotion && hoveredAlias === session.alias
8435
+ ? `drop-shadow(0 0 2px ${status.text}80)`
8436
+ : undefined,
8034
8437
  }}
8035
8438
  >
8036
8439
  {truncate(session.alias, fullMax)}
@@ -9440,12 +9843,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9440
9843
  under the same R320 fill cadence. data-
9441
9844
  recent-row-count-pinned attr exposes the
9442
9845
  pin gate for tests. */}
9846
+ {/* Round 498 / Loop — hot-count subtle pulse. Pre-
9847
+ R498 the hot row count signaled via color (R127
9848
+ amber fill) + weight (R320 fw-700) + (R445 pin
9849
+ lift) but stayed visually motionless. R498 adds
9850
+ a 3s opacity breath (0.85↔1.0) on the digit when
9851
+ isHot && !reducedMotion — gentle "alive" signal
9852
+ on the lane carrying ≥ 10 messages, drawing
9853
+ glance without becoming noisy. Sibling of R497
9854
+ hub-idle-breath in the 呼吸感 theme arc; same
9855
+ 0.85↔1.0 amplitude. Class adds an animation-
9856
+ only paint axis; no layout / bbox change. R29
9857
+ blanket also catches `animation-duration` for
9858
+ reducedMotion users, but the component-side
9859
+ gate makes the intent explicit and avoids
9860
+ a node tree thrash for those users (className
9861
+ stays absent rather than present-but-paused). */}
9443
9862
  <tspan
9444
9863
  fill={isHot ? hotStroke : undefined}
9445
9864
  fontWeight={(isHot || isRowPinned) ? '700' : '600'}
9865
+ className={isHot && !reducedMotion ? 'anet-recent-hot-pulse' : undefined}
9446
9866
  data-recent-row-count
9447
9867
  data-recent-row-count-pinned={isRowPinned ? 'true' : 'false'}
9448
9868
  data-recent-row-count-font-weight={(isHot || isRowPinned) ? '700' : '600'}
9869
+ data-recent-row-count-hot-pulse={isHot && !reducedMotion ? 'true' : 'false'}
9449
9870
  {...(isHot ? { 'data-recent-row-count-hot': 'true' } : {})}
9450
9871
  style={{
9451
9872
  transition: 'fill 300ms ease-out, font-weight 200ms ease-out',