@sleep2agi/agent-network-dashboard 0.5.2-preview.8 → 0.5.2

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 (156) 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 +7 -7
  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.html +1 -1
  13. package/.next/server/app/_not-found.rsc +1 -1
  14. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  20. package/.next/server/app/admin.html +1 -1
  21. package/.next/server/app/admin.rsc +1 -1
  22. package/.next/server/app/admin.segments/_full.segment.rsc +1 -1
  23. package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
  24. package/.next/server/app/admin.segments/_index.segment.rsc +1 -1
  25. package/.next/server/app/admin.segments/_tree.segment.rsc +1 -1
  26. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
  27. package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
  28. package/.next/server/app/index.html +2 -2
  29. package/.next/server/app/index.rsc +2 -2
  30. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  31. package/.next/server/app/index.segments/_full.segment.rsc +2 -2
  32. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  33. package/.next/server/app/index.segments/_index.segment.rsc +1 -1
  34. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  35. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  36. package/.next/server/app/login.html +2 -2
  37. package/.next/server/app/login.rsc +2 -2
  38. package/.next/server/app/login.segments/_full.segment.rsc +2 -2
  39. package/.next/server/app/login.segments/_head.segment.rsc +1 -1
  40. package/.next/server/app/login.segments/_index.segment.rsc +1 -1
  41. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  42. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  43. package/.next/server/app/login.segments/login.segment.rsc +1 -1
  44. package/.next/server/app/logs.html +1 -1
  45. package/.next/server/app/logs.rsc +1 -1
  46. package/.next/server/app/logs.segments/_full.segment.rsc +1 -1
  47. package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
  48. package/.next/server/app/logs.segments/_index.segment.rsc +1 -1
  49. package/.next/server/app/logs.segments/_tree.segment.rsc +1 -1
  50. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
  51. package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
  52. package/.next/server/app/messages.html +1 -1
  53. package/.next/server/app/messages.rsc +1 -1
  54. package/.next/server/app/messages.segments/_full.segment.rsc +1 -1
  55. package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
  56. package/.next/server/app/messages.segments/_index.segment.rsc +1 -1
  57. package/.next/server/app/messages.segments/_tree.segment.rsc +1 -1
  58. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
  59. package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
  60. package/.next/server/app/node.html +1 -1
  61. package/.next/server/app/node.rsc +1 -1
  62. package/.next/server/app/node.segments/_full.segment.rsc +1 -1
  63. package/.next/server/app/node.segments/_head.segment.rsc +1 -1
  64. package/.next/server/app/node.segments/_index.segment.rsc +1 -1
  65. package/.next/server/app/node.segments/_tree.segment.rsc +1 -1
  66. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +1 -1
  67. package/.next/server/app/node.segments/node.segment.rsc +1 -1
  68. package/.next/server/app/nodes.html +1 -1
  69. package/.next/server/app/nodes.rsc +1 -1
  70. package/.next/server/app/nodes.segments/_full.segment.rsc +1 -1
  71. package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
  72. package/.next/server/app/nodes.segments/_index.segment.rsc +1 -1
  73. package/.next/server/app/nodes.segments/_tree.segment.rsc +1 -1
  74. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +1 -1
  75. package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
  76. package/.next/server/app/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app/server-logs.html +1 -1
  78. package/.next/server/app/server-logs.rsc +1 -1
  79. package/.next/server/app/server-logs.segments/_full.segment.rsc +1 -1
  80. package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
  81. package/.next/server/app/server-logs.segments/_index.segment.rsc +1 -1
  82. package/.next/server/app/server-logs.segments/_tree.segment.rsc +1 -1
  83. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
  84. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
  85. package/.next/server/app/settings/networks.html +1 -1
  86. package/.next/server/app/settings/networks.rsc +1 -1
  87. package/.next/server/app/settings/networks.segments/_full.segment.rsc +1 -1
  88. package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
  89. package/.next/server/app/settings/networks.segments/_index.segment.rsc +1 -1
  90. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +1 -1
  91. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
  92. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
  93. package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
  94. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  95. package/.next/server/app/settings/tokens.html +1 -1
  96. package/.next/server/app/settings/tokens.rsc +1 -1
  97. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +1 -1
  98. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
  99. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +1 -1
  100. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +1 -1
  101. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
  102. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
  103. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
  104. package/.next/server/app/settings.html +2 -2
  105. package/.next/server/app/settings.rsc +2 -2
  106. package/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  107. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  108. package/.next/server/app/settings.segments/_index.segment.rsc +1 -1
  109. package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  110. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  111. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  112. package/.next/server/app/tasks.html +1 -1
  113. package/.next/server/app/tasks.rsc +1 -1
  114. package/.next/server/app/tasks.segments/_full.segment.rsc +1 -1
  115. package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
  116. package/.next/server/app/tasks.segments/_index.segment.rsc +1 -1
  117. package/.next/server/app/tasks.segments/_tree.segment.rsc +1 -1
  118. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
  119. package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
  120. package/.next/server/chunks/ssr/[root-of-the-server]__0sv~g.o._.js +1 -1
  121. package/.next/server/chunks/ssr/[root-of-the-server]__0sv~g.o._.js.map +1 -1
  122. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +3 -3
  123. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  124. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  125. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  126. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  127. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  128. package/.next/server/middleware-build-manifest.js +3 -3
  129. package/.next/server/pages/404.html +1 -1
  130. package/.next/server/pages/500.html +1 -1
  131. package/.next/static/chunks/{0kakzr~7pmm6f.js → 0f8e_np0z2f~m.js} +1 -1
  132. package/.next/static/chunks/0u-~k4hsu~8wq.js +4 -0
  133. package/.next/static/chunks/13ro4cw~e4_99.js +1 -0
  134. package/.next/static/chunks/14c.ui5lbvctz.js +1 -0
  135. package/.next/trace +2 -2
  136. package/.next/trace-build +1 -1
  137. package/app/components/TopoGraph.tsx +274 -30
  138. package/package.json +1 -1
  139. package/screenshots/v0.10.4-150-orphans/after.png +0 -0
  140. package/scripts/p150-orphan-layout-screenshot.mjs +82 -0
  141. package/scripts/topo-edge-badge-hot-glow-test.mjs +101 -0
  142. package/scripts/topo-edge-particle-opacity-lift-test.mjs +109 -0
  143. package/scripts/topo-group-label-glow-test.mjs +104 -0
  144. package/scripts/topo-legend-panel-title-fw-test.mjs +95 -0
  145. package/scripts/topo-minimap-dot-lift-test.mjs +115 -0
  146. package/scripts/topo-minimap-zoom-glow-test.mjs +86 -0
  147. package/scripts/topo-orphan-band-test.mjs +106 -0
  148. package/scripts/topo-recent-panel-title-fw-test.mjs +106 -0
  149. package/scripts/topo-recent-row-freshness-glow-test.mjs +100 -0
  150. package/scripts/topo-recent-row-ts-lift-test.mjs +97 -0
  151. package/.next/static/chunks/06ypqia1-atbo.js +0 -1
  152. package/.next/static/chunks/11s-p36ef7hy0.js +0 -4
  153. package/.next/static/chunks/17y6nangmpg~u.js +0 -1
  154. /package/.next/static/{wJxl4oP18MvCP4fry5nN7 → p5R7hozl9ewrkvLpgzAoX}/_buildManifest.js +0 -0
  155. /package/.next/static/{wJxl4oP18MvCP4fry5nN7 → p5R7hozl9ewrkvLpgzAoX}/_clientMiddlewareManifest.js +0 -0
  156. /package/.next/static/{wJxl4oP18MvCP4fry5nN7 → p5R7hozl9ewrkvLpgzAoX}/_ssgManifest.js +0 -0
@@ -759,28 +759,38 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
759
759
  else runs.push({ key: gk, members: [s] });
760
760
  }
761
761
 
762
- // Pass 1 — assign each run to a band: a multi-member group owns its
763
- // rows (left-aligned, so its bounding box is a tidy rect); contiguous
764
- // singletons pack into shared rows (centred). Collect total row count.
765
- type Band = { members: Session[]; startRow: number; centred: boolean; isGroup: boolean };
762
+ // Pass 1 — assign each run to a band.
763
+ //
764
+ // v0.10.4 #150 (Vincent /goal 5453): "不是一起的落单的怎么散落在中间了".
765
+ // Pre-#150 algo interleaved singletons between real groups as
766
+ // centred bands, so orphan nodes appeared scattered in the middle
767
+ // between cluster boxes. Vincent screenshot called this out as
768
+ // "layout 算法一点都不好". Fix: bundle ALL singletons into ONE
769
+ // band at the bottom of the grid + render an "其他" cluster box
770
+ // around them. Multi-member prefix groups still go first in
771
+ // alias order (existing #83/#111 behaviour). Net effect:
772
+ // row 0..N-1: real prefix groups (left-aligned, own cluster box)
773
+ // row N..M: single "其他" band collecting all orphans
774
+ // (left-aligned, single cluster box at bottom)
775
+ // No orphans → no orphan band → behaviour identical to pre-#150
776
+ // for fleets where every node has a prefix-group match.
777
+ type Band = { members: Session[]; startRow: number; centred: boolean; isGroup: boolean; isOrphan?: boolean };
766
778
  const bands: Band[] = [];
767
779
  let row = 0;
768
- let i = 0;
769
- while (i < runs.length) {
770
- if (runs[i].members.length >= 2) {
771
- bands.push({ members: runs[i].members, startRow: row, centred: false, isGroup: true });
772
- row += Math.ceil(runs[i].members.length / cols);
773
- i++;
780
+ const orphanMembers: Session[] = [];
781
+ for (const run of runs) {
782
+ if (run.members.length >= 2) {
783
+ bands.push({ members: run.members, startRow: row, centred: false, isGroup: true });
784
+ row += Math.ceil(run.members.length / cols);
774
785
  } else {
775
- const singles: Session[] = [];
776
- while (i < runs.length && runs[i].members.length < 2) {
777
- singles.push(runs[i].members[0]);
778
- i++;
779
- }
780
- bands.push({ members: singles, startRow: row, centred: true, isGroup: false });
781
- row += Math.ceil(singles.length / cols);
786
+ // single-member run collect for the bottom orphan band
787
+ orphanMembers.push(...run.members);
782
788
  }
783
789
  }
790
+ if (orphanMembers.length > 0) {
791
+ bands.push({ members: orphanMembers, startRow: row, centred: false, isGroup: true, isOrphan: true });
792
+ row += Math.ceil(orphanMembers.length / cols);
793
+ }
784
794
  const totalRows = Math.max(1, row);
785
795
  // #112: the group label sits in a band ABOVE the topmost node, so the
786
796
  // band must clear the node radius — GROUP_TOP is node-relative, never
@@ -852,8 +862,17 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
852
862
  else if (isOn) i++;
853
863
  else o++;
854
864
  }
865
+ // v0.10.4 #150 — orphan band (singletons bundled at bottom)
866
+ // renders with a "其他" cluster box; the box-key drives the
867
+ // R63 label render + R86 hover-pin keying + #99 tooltip
868
+ // member listing, so all the existing group-box machinery
869
+ // applies uniformly to the orphan bucket too.
855
870
  return {
856
- key: band.members.length ? groupKeys[band.members[0].alias] : '',
871
+ key: band.isOrphan
872
+ ? '其他'
873
+ : band.members.length
874
+ ? groupKeys[band.members[0].alias]
875
+ : '',
857
876
  count: band.members.length,
858
877
  statuses: { working: w, idle: i, offline: o },
859
878
  x: minX - GROUP_PAD,
@@ -4761,10 +4780,35 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
4761
4780
  opacity={isPinned || isHovered ? 1 : 0.55}
4762
4781
  data-group-label-hovered={isHovered && !isPinned ? 'true' : 'false'}
4763
4782
  data-group-label-font-weight={isPinned ? '800' : '700'}
4783
+ /* Round 479 / Loop — extend drop-shadow visual-polish
4784
+ family to a 4th anchor: group-label parent text
4785
+ on isPinned. Continues the R476/R477/R478 arc:
4786
+ R476 hub digit hover-gated emerald
4787
+ R477 legend pin-ring pin-gated row.fill
4788
+ R478 recent-row pip freshness-gated cyan
4789
+ R479 group-label text pin-gated cyan
4790
+ Hue: pal.legendAccent at 0x80 alpha (≈50%) — same
4791
+ accent family R107/R477 use for tint surfaces. 3px
4792
+ blur reads as a soft cyan halo around the locked
4793
+ cluster name. Stacks with the R432 letter-spacing
4794
+ spread + R457 fw lift + R63 fill brighten + R142
4795
+ drop-shadow on the parent rect — pin signature on
4796
+ group label scope now spans typography + chroma +
4797
+ paint + container-lift + text-glow.
4798
+ Filter is paint-only; bbox unchanged; overlap-test
4799
+ invariants hold (R51 selector gated to g[data-node]
4800
+ descendants, this label is invisible to the probe).
4801
+ transition list extends to include 'filter 200ms
4802
+ ease-out' alongside the existing fill/ls/fw/opacity
4803
+ 200ms tweens. */
4804
+ data-group-label-glow={isPinned ? 'true' : 'false'}
4764
4805
  style={{
4765
- transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out, opacity 200ms ease-out',
4806
+ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
4766
4807
  letterSpacing: isPinned ? '0.5px' :
4767
4808
  isHovered ? '0.25px' : '0px',
4809
+ filter: isPinned
4810
+ ? `drop-shadow(0 0 3px ${pal.legendAccent}80)`
4811
+ : undefined,
4768
4812
  }}
4769
4813
  data-group-label={box.key}
4770
4814
  data-group-label-pinned={isPinned ? 'true' : 'false'}
@@ -5338,10 +5382,32 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5338
5382
  r={(isHoveredEdge || isEndpointHoveredEdge) ? 5.5 : 4.5}
5339
5383
  fill={pal.flowParticle}
5340
5384
  filter={isLight ? undefined : 'url(#topo-glow)'}
5341
- opacity={Math.min(1, fresh * edgeOpacityMul)}
5385
+ /* Round 485 / Loop — extends R484's "inspection
5386
+ overrides encoding" pattern to a 2nd anchor:
5387
+ edge particle opacity lifts to 1.0 on
5388
+ isHoveredEdge OR isEndpointHoveredEdge (user
5389
+ hovering the edge directly OR hovering one
5390
+ of its endpoint nodes). Pre-R485 the particle
5391
+ inherited freshness × edgeOpacityMul decay
5392
+ so a stale edge's particle painted near the
5393
+ 0.30 floor even when the operator was
5394
+ inspecting it; R485 lifts to 1.0 on attention.
5395
+ data-recent-row-ts-alpha-attribute analog —
5396
+ freshness encoding preserved on rest tier,
5397
+ opacity override engages only on inspection.
5398
+ Sibling lift family — inspection-overrides-
5399
+ encoding pattern, now 2 anchors:
5400
+ R484 recent-row timestamp freshness → 1.0
5401
+ R485 edge particle freshness → 1.0 (this)
5402
+ data-edge-particle-opacity-lifted attr exposes
5403
+ the override gate; data-edge-particle-opacity-
5404
+ rest preserves the freshness reading. */
5405
+ opacity={(isHoveredEdge || isEndpointHoveredEdge) ? 1 : Math.min(1, fresh * edgeOpacityMul)}
5342
5406
  data-edge-particle={link.key}
5343
5407
  data-edge-particle-radius={(isHoveredEdge || isEndpointHoveredEdge) ? 5.5 : 4.5}
5344
5408
  data-edge-particle-lifted={(isHoveredEdge || isEndpointHoveredEdge) ? 'true' : 'false'}
5409
+ data-edge-particle-opacity-rest={Math.min(1, fresh * edgeOpacityMul).toFixed(2)}
5410
+ data-edge-particle-opacity-lifted={(isHoveredEdge || isEndpointHoveredEdge) ? 'true' : 'false'}
5345
5411
  style={{ transition: 'fill 200ms ease-out, opacity 200ms ease-out, r 200ms ease-out' }}
5346
5412
  >
5347
5413
  <animateMotion
@@ -5790,6 +5856,34 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5790
5856
  via the new -opacity-active attr; the
5791
5857
  legacy -opacity-hover attr kept for R395
5792
5858
  test compatibility. */}
5859
+ {/* Round 480 / Loop — 5th anchor in the drop-shadow
5860
+ visual-polish family. Gates on isHot (link.
5861
+ count >= 10, R129 hot-lane threshold) so the
5862
+ badge gets a warm-amber halo when its edge
5863
+ crosses the high-traffic boundary.
5864
+ Drop-shadow family ledger now:
5865
+ R476 hub digit hover-gated emerald
5866
+ R477 legend pin-ring pin-gated row.fill
5867
+ R478 freshness pip freshness-gated cyan
5868
+ R479 group label pin-gated cyan
5869
+ R480 edge badge hot-lane-gated amber ← this round
5870
+ 5th gate type — traffic volume — joins hover,
5871
+ pin, freshness, pin. Each polish anchor uses
5872
+ a distinct semantic gate but the same paint
5873
+ vocabulary. Hue: hotStroke (amber-tinted
5874
+ palette member) at 0x80 alpha — picks up the
5875
+ R126/R188 hot-edge accent colour family so
5876
+ the glow reads as a chromatic extension of
5877
+ the existing hot-lane stroke. 3-px blur
5878
+ radius reads as soft heat rather than
5879
+ emergency klaxon.
5880
+ R51 sentinel safety: badge sw=2 only matters
5881
+ when the overlap probe runs on g[data-node]
5882
+ descendants, which this edge-internal badge
5883
+ is not. Filter is paint-only, bbox unchanged.
5884
+ transition list extends to include 'filter
5885
+ 200ms ease-out' so the heat halo eases on
5886
+ the count-crosses-threshold flip. */}
5793
5887
  <circle
5794
5888
  cx={badgeX} cy={badgeY}
5795
5889
  r={isHoveredEdge || isPinned ? 10.5 : 9}
@@ -5804,7 +5898,13 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
5804
5898
  data-edge-badge-opacity-rest={isLight ? 0.95 : 0.85}
5805
5899
  data-edge-badge-opacity-hover="1"
5806
5900
  data-edge-badge-opacity-active="1"
5807
- style={{ transition: 'r 180ms ease-out, stroke 300ms ease-out, stroke-width 300ms ease-out, fill 200ms ease-out, opacity 200ms ease-out' }}
5901
+ data-edge-badge-glow={isHot ? 'true' : 'false'}
5902
+ style={{
5903
+ filter: isHot
5904
+ ? `drop-shadow(0 0 3px ${hotStroke}80)`
5905
+ : undefined,
5906
+ 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',
5907
+ }}
5808
5908
  />
5809
5909
  {/* Round 224 / Loop: edge badge text gains the 4th
5810
5910
  pin-signature typography. Pre-R224 the digit
@@ -8325,8 +8425,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8325
8425
  title scope: hovering the panel chrome spreads the
8326
8426
  title 0.1 px, signalling "this is a coherent unit
8327
8427
  you're entering". transition list extends letter-
8328
- spacing 200ms ease-out alongside existing fill 200ms. */}
8329
- <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700" letterSpacing={hoveredPanel === 'recent' ? '0.4' : '0.3'} style={{ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out' }} data-recent-panel-title>recent signal</text>
8428
+ spacing 200ms ease-out alongside existing fill 200ms.
8429
+ Round 482 / Loop add 2nd typographic axis to the
8430
+ title: fontWeight 700 → 800 on activeEdgeKey (any
8431
+ row hover OR pin propagates from hoveredEdgeKey ??
8432
+ pinnedEdgeKey). Pre-R482 the title only responded
8433
+ to panel-chrome hover via R345 ls; when a specific
8434
+ row was hovered/pinned inside the panel, the title
8435
+ stayed flat. R482 closes the gap: when ANY row is
8436
+ active inside the panel, the title tightens
8437
+ typographically alongside the row's own R143 lift +
8438
+ R472 tint + R474 text spread. data tightens under
8439
+ attention pattern extension (panel-scope variant
8440
+ following R416/R424/R425/R426/R444/R445/R446/R457
8441
+ at the chip / panel / hub / edge / count / parent-
8442
+ label tiers).
8443
+ transition list extends to include 'font-weight
8444
+ 200ms ease-out' alongside R345's ls + R55's fill
8445
+ 200ms. data-recent-panel-title-fw exposes the
8446
+ resolved weight for tests. */}
8447
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight={activeEdgeKey ? '800' : '700'} letterSpacing={hoveredPanel === 'recent' ? '0.4' : '0.3'} style={{ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out' }} data-recent-panel-title data-recent-panel-title-fw={activeEdgeKey ? '800' : '700'} data-recent-panel-title-active={activeEdgeKey ? 'true' : 'false'}>recent signal</text>
8330
8448
  {/* R96: header count now matches what the rows show. Pre-R96
8331
8449
  this read "X msgs" off the raw messages array, but the
8332
8450
  rows below render DEDUPED flowLinks — so a fleet with 10
@@ -8982,16 +9100,47 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
8982
9100
  to include 'r 200ms ease-out' matching the
8983
9101
  opacity cadence. data-recent-row-freshness-
8984
9102
  lifted attr exposes the gate for tests. */
9103
+ /* Round 478 / Loop — extend the R476/R477
9104
+ drop-shadow vocabulary to a third anchor:
9105
+ the recent-row freshness pip on `alpha
9106
+ > 0.7` (just-fired flow within ~30s per
9107
+ R10 freshness ramp). Gate is FRESHNESS-
9108
+ driven not pin/hover-driven, so the glow
9109
+ reads as "this signal is live" rather
9110
+ than "user is inspecting". As the alpha
9111
+ decays past 0.7 (≈45s after last fire),
9112
+ the glow eases off — natural breathing
9113
+ feel that tracks actual data freshness.
9114
+ Hue: pal.legendAccent at 0.5 alpha so
9115
+ the glow inherits the row's accent color
9116
+ family. 2.5-3px blur reads as soft
9117
+ radiance, not loud bloom.
9118
+ Drop-shadow visual-polish family now 3
9119
+ anchors:
9120
+ R476 hub digit hover-gated
9121
+ R477 legend pin-ring pin-gated
9122
+ R478 recent freshness freshness-gated
9123
+ Each anchor uses a different state gate
9124
+ but the same `filter: drop-shadow` paint
9125
+ vocabulary. Filter affects paint only —
9126
+ bbox unchanged, overlap-test invariants
9127
+ hold. Transition list extends to include
9128
+ 'filter 200ms ease-out' alongside
9129
+ R10/R447 opacity + r tweens. */
8985
9130
  fill={pal.legendAccent}
8986
9131
  opacity={alpha}
8987
9132
  data-recent-row-freshness={link.key}
8988
9133
  data-recent-row-freshness-alpha={alpha.toFixed(2)}
8989
9134
  data-recent-row-freshness-radius={(isRowHovered || isRowPinned) ? 2.5 : 2.0}
8990
9135
  data-recent-row-freshness-lifted={(isRowHovered || isRowPinned) ? 'true' : 'false'}
9136
+ data-recent-row-freshness-glow={alpha > 0.7 ? 'true' : 'false'}
8991
9137
  style={{
8992
9138
  pointerEvents: 'none',
8993
9139
  r: `${(isRowHovered || isRowPinned) ? 2.5 : 2.0}px`,
8994
- transition: 'opacity 200ms ease-out, r 200ms ease-out',
9140
+ filter: alpha > 0.7
9141
+ ? `drop-shadow(0 0 3px ${pal.legendAccent}80)`
9142
+ : undefined,
9143
+ transition: 'opacity 200ms ease-out, r 200ms ease-out, filter 200ms ease-out',
8995
9144
  } as React.CSSProperties}
8996
9145
  />
8997
9146
  );
@@ -9211,6 +9360,18 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9211
9360
  {' · '}
9212
9361
  <tspan opacity="0.7" data-recent-row-content-tspan>{truncate(link.content, 8)}</tspan>
9213
9362
  </text>
9363
+ {/* Round 484 / Loop — recent-row timestamp opacity
9364
+ lifts to 1.0 when isRowHovered || isRowPinned,
9365
+ regardless of freshness alpha. R191 origin
9366
+ decays tsAlpha along with the row's freshness;
9367
+ pre-R484 hovering/pinning the row left the
9368
+ timestamp dim — user inspecting stale data
9369
+ fought the freshness encoding. R484 lifts to
9370
+ 1.0 on attention. Sibling to R472/R474 in the
9371
+ recent-row state-flip family. data-recent-row-
9372
+ ts-lifted attr exposes the gate; original
9373
+ data-recent-row-ts-alpha preserved as R191
9374
+ freshness reading. */}
9214
9375
  {lastAt ? (
9215
9376
  /* Round 321 / Loop: lastAt freshness timestamp picks
9216
9377
  up fontVariantNumeric tabular-nums. The string
@@ -9234,9 +9395,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9234
9395
  fill={pal.legendText}
9235
9396
  fontSize="8"
9236
9397
  fontFamily="monospace"
9237
- opacity={tsAlpha}
9398
+ opacity={(isRowHovered || isRowPinned) ? 1 : tsAlpha}
9238
9399
  data-recent-row-ts={link.key}
9239
9400
  data-recent-row-ts-alpha={tsAlpha.toFixed(2)}
9401
+ data-recent-row-ts-lifted={(isRowHovered || isRowPinned) ? 'true' : 'false'}
9240
9402
  style={{
9241
9403
  pointerEvents: 'none',
9242
9404
  transition: 'opacity 200ms ease-out',
@@ -9511,9 +9673,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
9511
9673
  surrounding chrome eased; R266 closes both at once. */}
9512
9674
  {/* R301: sibling to recent-signal panel title above —
9513
9675
  same letterSpacing 0.3 for editorial parity. */}
9676
+ {/* Round 483 / Loop — sibling to R482 (recent-signal panel
9677
+ title): legend panel title fontWeight 700 → 800 on
9678
+ pinnedStatus (any legend row pinned propagates to the
9679
+ panel title). Pre-R483 the title responded only to
9680
+ panel-chrome hover via R345 ls; the pinnedStatus row
9681
+ highlighted its own swatch + tint via R181/R477 but
9682
+ the title stayed flat — no upstream tightening to
9683
+ signal "panel context = inspecting".
9684
+ R483 closes the symmetry with R482: both panel titles
9685
+ (recent-signal + legend) now tighten typographically
9686
+ when ANY row inside them is in the active filter
9687
+ state. Same idiom, mirrored at the legend-row scope.
9688
+ data tightens family — now 10 anchors:
9689
+ R416/R424/R425/R426 chip/panel/hub/edge digits
9690
+ R444/R445/R446 group/recent/legend counts
9691
+ R457 group-label parent
9692
+ R482 recent-panel title
9693
+ R483 legend-panel title (this round)
9694
+ transition list extends to include 'font-weight 200ms
9695
+ ease-out' alongside R345's ls + R55's fill 200ms.
9696
+ data-legend-panel-title-fw + -active exposed for tests. */}
9514
9697
  {/* R345 sibling — legend panel title same hover letter-
9515
9698
  spacing tween 0.3 → 0.4 on panel hover. */}
9516
- <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight="700" letterSpacing={hoveredPanel === 'legend' ? '0.4' : '0.3'} style={{ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out' }} data-legend-panel-title>legend</text>
9699
+ <text x="13" y="21" fill={pal.legendHeadline} fontSize="12" fontFamily="monospace" fontWeight={pinnedStatus ? '800' : '700'} letterSpacing={hoveredPanel === 'legend' ? '0.4' : '0.3'} style={{ transition: 'fill 200ms ease-out, letter-spacing 200ms ease-out, font-weight 200ms ease-out' }} data-legend-panel-title data-legend-panel-title-fw={pinnedStatus ? '800' : '700'} data-legend-panel-title-active={pinnedStatus ? 'true' : 'false'}>legend</text>
9517
9700
  {/* Round 257 / Loop: legend panel header count picks up the
9518
9701
  symmetric 13L/13R inner-padding pattern from the recent-
9519
9702
  signal panel. Pre-R257 the legend header was 13px from
@@ -10409,12 +10592,41 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10409
10592
  stale ones. data-topo-minimap-dot-opacity
10410
10593
  attr (R392) reflects the resolved hover-
10411
10594
  state value for tests. */
10595
+ /* Round 486 / Loop — 3rd anchor in the
10596
+ inspection-overrides-encoding pattern. Sibling
10597
+ to R484 (recent-row timestamp) + R485 (edge
10598
+ particle). When the operator hovers a node
10599
+ alias on the main canvas, the matching
10600
+ minimap dot lifts to opacity=1.0 regardless
10601
+ of the binary online/offline encoding —
10602
+ cross-reference cue between canvas focal
10603
+ and the minimap wayfinding overlay.
10604
+ Pre-R486 an offline node's minimap dot stayed
10605
+ at 0.6 even when the operator was inspecting
10606
+ it via canvas hover; R486 makes the
10607
+ inspection signal jump the minimap dot to
10608
+ full presence so the spatial reference is
10609
+ unambiguous.
10610
+ Encoding survives: data-topo-minimap-dot-
10611
+ online preserves the online/offline binary,
10612
+ data-topo-minimap-dot-opacity-rest preserves
10613
+ the would-be opacity. Only the LIVE painted
10614
+ opacity flips on inspection.
10615
+ inspection-overrides-encoding family — 3
10616
+ anchors now:
10617
+ R484 recent-row timestamp
10618
+ R485 edge particle
10619
+ R486 minimap dot ← this round
10620
+ data-topo-minimap-dot-lifted attr exposes
10621
+ the override gate. */
10412
10622
  r={isOn ? 1.9 : 1.2}
10413
10623
  fill={st.primary}
10414
- opacity={isOn ? (hoveredMinimap ? 1 : 0.95) : 0.6}
10624
+ opacity={hoveredAlias === s.alias ? 1 : (isOn ? (hoveredMinimap ? 1 : 0.95) : 0.6)}
10415
10625
  data-topo-minimap-dot={s.alias}
10416
10626
  data-topo-minimap-dot-online={isOn ? 'true' : 'false'}
10417
- data-topo-minimap-dot-opacity={isOn ? (hoveredMinimap ? 1 : 0.95) : 0.6}
10627
+ data-topo-minimap-dot-opacity={hoveredAlias === s.alias ? 1 : (isOn ? (hoveredMinimap ? 1 : 0.95) : 0.6)}
10628
+ data-topo-minimap-dot-opacity-rest={isOn ? (hoveredMinimap ? 1 : 0.95) : 0.6}
10629
+ data-topo-minimap-dot-lifted={hoveredAlias === s.alias ? 'true' : 'false'}
10418
10630
  data-topo-minimap-dot-radius={isOn ? 1.9 : 1.2}
10419
10631
  style={{
10420
10632
  transition: 'opacity 200ms ease-out, fill 200ms ease-out, r 200ms ease-out',
@@ -10538,10 +10750,42 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10538
10750
  data-topo-minimap-viewport-smooth={smoothView ? 'true' : 'false'}
10539
10751
  data-topo-minimap-viewport-hover={hoveredMinimap ? 'true' : 'false'}
10540
10752
  data-topo-minimap-viewport-linejoin="round"
10753
+ /* Round 481 / Loop — 6th anchor in the drop-shadow
10754
+ visual-polish family. New gate type: ZOOM STATE.
10755
+ When current canvas zoom > 1.5x (50% above the
10756
+ default 1.0 baseline), the minimap viewport rect
10757
+ gains a soft cyan halo signaling "you're zoomed
10758
+ in beyond default". The minimap viewport already
10759
+ shrinks as you zoom in (rectW = VIEWBOX_W /
10760
+ view.zoom * sx, so at zoom=2 it halves) — the
10761
+ glow tells you the wayfinding marker is now
10762
+ scaled-down rather than at canvas-default size.
10763
+ Drop-shadow family — 6 gate types covered:
10764
+ R476 hub digit hover-gated
10765
+ R477 legend pin-ring pin-gated
10766
+ R478 freshness pip freshness-gated
10767
+ R479 group label pin-gated
10768
+ R480 edge badge hot-lane-gated
10769
+ R481 minimap zoom-gated ← this round
10770
+ 6 distinct semantic gates (user interaction
10771
+ transient/sticky × 2, data freshness, data
10772
+ volume, canvas zoom state). Each anchor uses
10773
+ hue family appropriate to its semantic context.
10774
+ Hue: pal.legendAccent at 0x80 alpha — matches
10775
+ the existing R107 tint family and R478/R479
10776
+ cyan-tone choices. 2-px blur reads as subtle
10777
+ (the minimap viewport is small, ~120×82 px).
10778
+ Filter is paint-only — bbox unchanged. transition
10779
+ list extends to include 'filter 200ms ease-out'
10780
+ so the glow eases when zoom crosses 1.5x. */
10781
+ data-topo-minimap-viewport-glow={view.zoom > 1.5 ? 'true' : 'false'}
10541
10782
  style={{
10783
+ filter: view.zoom > 1.5
10784
+ ? `drop-shadow(0 0 2px ${pal.legendAccent}80)`
10785
+ : undefined,
10542
10786
  transition: smoothView
10543
- ? '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'
10544
- : 'stroke-width 200ms ease-out, opacity 200ms ease-out',
10787
+ ? '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'
10788
+ : 'stroke-width 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out',
10545
10789
  } as React.CSSProperties}
10546
10790
  />
10547
10791
  </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/agent-network-dashboard",
3
- "version": "0.5.2-preview.8",
3
+ "version": "0.5.2",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "scripts": {
6
6
  "dev": "next dev",
@@ -0,0 +1,82 @@
1
+ /* v0.10.4 #150 — orphan layout screenshot evidence.
2
+ *
3
+ * Fixture: 3 prefix groups (5+3+4 = 12 grouped) + 5 distinct-prefix
4
+ * singletons (orphans). Pre-#150 the singletons appeared scattered
5
+ * in centred bands between cluster boxes. Post-#150 they all bundle
6
+ * into one "其他" cluster box at the bottom. */
7
+ import { chromium } from 'playwright';
8
+ import { readFileSync } from 'node:fs';
9
+
10
+ const out = process.argv[2] || 'screenshots/v0.10.4-150-orphans/after';
11
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
12
+ const browser = await chromium.launch({ headless: true });
13
+ const ctx = await browser.newContext({ viewport: { width: 1600, height: 1500 }, deviceScaleFactor: 2 });
14
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
15
+ await ctx.addInitScript(() => {
16
+ try {
17
+ localStorage.setItem('anet-theme', 'cyber');
18
+ sessionStorage.setItem('anet_v3_auth', '1');
19
+ // Force grid layout so the orphan band is visible
20
+ localStorage.setItem('anet-topo-layout', 'grid');
21
+ } catch {}
22
+ });
23
+ const fresh = new Date(Date.now() - 60_000).toISOString();
24
+ const sessions = [];
25
+ // Group 1: ai-insight × 5
26
+ for (let i = 1; i <= 5; i++) sessions.push({
27
+ alias: `ai-insight-node-${i}`, status: i === 1 ? 'working' : 'idle',
28
+ model: 'claude-opus-4', runtime: 'claude-code-cli',
29
+ network_id: 'default', project_dir: null,
30
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
31
+ });
32
+ // Group 2: agent-network-dashboard × 3
33
+ for (let i = 1; i <= 3; i++) sessions.push({
34
+ alias: `agent-network-dashboard-${i}`, status: i === 1 ? 'working' : 'idle',
35
+ model: 'gpt-4o', runtime: 'codex-sdk',
36
+ network_id: 'default', project_dir: null,
37
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
38
+ });
39
+ // Group 3: p-station × 4
40
+ for (let i = 1; i <= 4; i++) sessions.push({
41
+ alias: `p-station-worker-${i}`, status: i === 1 ? 'working' : 'idle',
42
+ model: 'minimax/abab6', runtime: 'http-api',
43
+ network_id: 'default', project_dir: null,
44
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
45
+ });
46
+ // Orphans: 5 distinct-prefix singletons — should bundle into "其他" bottom band
47
+ ['monitor-prod', 'dispatcher-bot', 'runner-helper', 'oracle-svc', 'cron-worker'].forEach((alias, idx) => {
48
+ sessions.push({
49
+ alias, status: idx % 2 === 0 ? 'working' : 'idle',
50
+ model: 'claude-sonnet-4', runtime: 'claude-agent-sdk',
51
+ network_id: 'default', project_dir: null,
52
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
53
+ });
54
+ });
55
+ await ctx.route('**/api/hub/status*', async (route) => {
56
+ const r = await route.fetch();
57
+ const b = await r.json();
58
+ await route.fulfill({ response: r, json: { ...b, sessions } });
59
+ });
60
+ await ctx.route('**/api/hub/messages*', (r) => r.fulfill({ json: { messages: [] } }));
61
+ await ctx.route('**/api/hub/tasks*', (r) => r.fulfill({ json: { tasks: [] } }));
62
+ const page = await ctx.newPage();
63
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
64
+ await page.waitForFunction(() => document.querySelectorAll('g[data-node]').length >= 17, { timeout: 30000 });
65
+ await page.waitForTimeout(800);
66
+ const box = await page.evaluate(() => {
67
+ const chrome = document.querySelector('[data-topo-chrome]');
68
+ let card = chrome?.parentElement;
69
+ while (card && !card.querySelector(':scope > svg')) card = card?.parentElement;
70
+ if (!card) return null;
71
+ card.scrollIntoView({ block: 'start' });
72
+ return new Promise(resolve => requestAnimationFrame(() => {
73
+ const r = card.getBoundingClientRect();
74
+ resolve({ x: Math.max(0, r.x), y: Math.max(0, r.y), width: r.width, height: r.height });
75
+ }));
76
+ });
77
+ await page.waitForTimeout(400);
78
+ if (box && box.width > 100) {
79
+ await page.screenshot({ path: `${out}.png`, clip: { x: box.x, y: box.y, width: box.width, height: Math.min(box.height, 1200) } });
80
+ }
81
+ console.log(`✅ wrote ${out}.png`);
82
+ await browser.close();
@@ -0,0 +1,101 @@
1
+ /* Round 480 verification: edge badge circle gains filter: drop-
2
+ * shadow glow on isHot (link.count >= 10). 5th anchor in the
3
+ * R476/R477/R478/R479 drop-shadow family — first traffic-volume-
4
+ * gated variant.
5
+ *
6
+ * Contract:
7
+ * - cold edge (count < 10): data-edge-badge-glow='false' AND
8
+ * computed filter === 'none'
9
+ * - hot edge (count >= 10): glow='true' AND computed filter
10
+ * starts with 'drop-shadow' using hotStroke @ 0x80 alpha
11
+ * - source-file conditional wired
12
+ */
13
+ import { chromium } from 'playwright';
14
+ import { readFileSync } from 'node:fs';
15
+
16
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
17
+ const nowIso = () => new Date().toISOString();
18
+ const sessionFresh = new Date(Date.now() - 60 * 1000).toISOString();
19
+
20
+ const browser = await chromium.launch({ headless: true });
21
+ const ctx = await browser.newContext({ viewport: { width: 1500, height: 1200 } });
22
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
23
+ await ctx.addInitScript(() => {
24
+ try {
25
+ localStorage.setItem('anet-theme', 'cyber');
26
+ localStorage.setItem('anet-topo-layout', 'ring');
27
+ sessionStorage.setItem('anet_v3_auth', '1');
28
+ } catch {}
29
+ });
30
+ await ctx.route('**/api/hub/status*', async (route) => {
31
+ const r = await route.fetch();
32
+ const b = await r.json();
33
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
34
+ const mk = (alias, status) => ({
35
+ alias, status, model: 'claude-opus-4', runtime: 'claude-code-cli',
36
+ network_id: nid, project_dir: null,
37
+ created_at: sessionFresh, updated_at: sessionFresh, last_seen_at: sessionFresh,
38
+ });
39
+ await route.fulfill({ response: r, json: { ...b, sessions: [
40
+ mk('a·1', 'working'), mk('a·2', 'idle'), mk('b·1', 'working'),
41
+ ] } });
42
+ });
43
+ // Build messages: 12 from a·1 → a·2 (HOT, count=12 >= 10)
44
+ // 2 from b·1 → a·1 (cold, count=2 < 10)
45
+ const hotMessages = [];
46
+ for (let i = 0; i < 12; i++) {
47
+ hotMessages.push({
48
+ id: `hot-${i}`, from_alias: 'a·1', to_alias: 'a·2',
49
+ content: `m${i}`, created_at: nowIso(),
50
+ });
51
+ }
52
+ const coldMessages = [
53
+ { id: 'c1', from_alias: 'b·1', to_alias: 'a·1', content: 'c1', created_at: nowIso() },
54
+ { id: 'c2', from_alias: 'b·1', to_alias: 'a·1', content: 'c2', created_at: nowIso() },
55
+ ];
56
+ await ctx.route('**/api/hub/messages*', (route) => route.fulfill({ json: {
57
+ messages: [...hotMessages, ...coldMessages],
58
+ } }));
59
+ await ctx.route('**/api/hub/tasks*', (r) => r.fulfill({ json: { tasks: [] } }));
60
+
61
+ const page = await ctx.newPage();
62
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'networkidle' });
63
+ await page.waitForSelector('[data-edge-badge-glow]', { timeout: 15000 });
64
+ await page.waitForTimeout(500);
65
+
66
+ const probe = await page.evaluate(() => {
67
+ const badges = [...document.querySelectorAll('[data-edge-badge-glow]')];
68
+ return badges.map(b => {
69
+ const cs = getComputedStyle(b);
70
+ return {
71
+ glow: b.getAttribute('data-edge-badge-glow'),
72
+ filter: cs.filter,
73
+ };
74
+ });
75
+ });
76
+
77
+ const src = readFileSync('/home/vansin/agent-network-dashboard/app/components/TopoGraph.tsx', 'utf8');
78
+ const sourceGlowAttr = /data-edge-badge-glow=\{isHot/.test(src);
79
+ const sourceDropShadow = /drop-shadow\(0 0 3px \$\{hotStroke\}80\)/.test(src);
80
+ const sourceFilterTween = /filter 200ms ease-out/.test(src);
81
+
82
+ await browser.close();
83
+
84
+ const hot = probe.find(b => b.glow === 'true');
85
+ const cold = probe.find(b => b.glow === 'false');
86
+
87
+ const results = {
88
+ badges_count_ge_2: probe.length >= 2,
89
+ hot_badge_found: !!hot,
90
+ hot_filter_has_drop: hot && /drop-shadow/.test(hot.filter),
91
+ cold_badge_found: !!cold,
92
+ cold_filter_none: cold?.filter === 'none',
93
+ source_glow_attr: sourceGlowAttr,
94
+ source_drop_shadow: sourceDropShadow,
95
+ source_filter_tween: sourceFilterTween,
96
+ };
97
+ const ok = Object.values(results).every(Boolean);
98
+ console.log(`${ok ? '✅' : '❌'} edge badge hot-lane drop-shadow:`, JSON.stringify(results),
99
+ '\n hot badge:', JSON.stringify(hot),
100
+ '\n cold badge:', JSON.stringify(cold));
101
+ process.exit(ok ? 0 : 1);