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

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 (258) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/diagnostics/route-bundle-stats.json +69 -55
  5. package/.next/fallback-build-manifest.json +3 -3
  6. package/.next/prerender-manifest.json +3 -3
  7. package/.next/routes-manifest.json +6 -0
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +2 -2
  17. package/.next/server/app/_not-found.rsc +12 -12
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
  19. package/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  20. package/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  23. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  24. package/.next/server/app/admin/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/admin.html +2 -2
  26. package/.next/server/app/admin.rsc +14 -14
  27. package/.next/server/app/admin.segments/_full.segment.rsc +14 -14
  28. package/.next/server/app/admin.segments/_head.segment.rsc +4 -4
  29. package/.next/server/app/admin.segments/_index.segment.rsc +7 -7
  30. package/.next/server/app/admin.segments/_tree.segment.rsc +2 -2
  31. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +4 -4
  32. package/.next/server/app/admin.segments/admin.segment.rsc +3 -3
  33. package/.next/server/app/api/hub/servers/route/app-paths-manifest.json +3 -0
  34. package/.next/server/app/api/hub/servers/route/build-manifest.json +9 -0
  35. package/.next/server/app/api/hub/servers/route/server-reference-manifest.json +4 -0
  36. package/.next/server/app/api/hub/servers/route.js +7 -0
  37. package/.next/server/app/api/hub/servers/route.js.map +5 -0
  38. package/.next/server/app/api/hub/servers/route.js.nft.json +1 -0
  39. package/.next/server/app/api/hub/servers/route_client-reference-manifest.js +3 -0
  40. package/.next/server/app/index.html +2 -2
  41. package/.next/server/app/index.rsc +14 -14
  42. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  43. package/.next/server/app/index.segments/_full.segment.rsc +14 -14
  44. package/.next/server/app/index.segments/_head.segment.rsc +4 -4
  45. package/.next/server/app/index.segments/_index.segment.rsc +7 -7
  46. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  47. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/login.html +2 -2
  49. package/.next/server/app/login.rsc +14 -14
  50. package/.next/server/app/login.segments/_full.segment.rsc +14 -14
  51. package/.next/server/app/login.segments/_head.segment.rsc +4 -4
  52. package/.next/server/app/login.segments/_index.segment.rsc +7 -7
  53. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  54. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +4 -4
  55. package/.next/server/app/login.segments/login.segment.rsc +3 -3
  56. package/.next/server/app/logs/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/logs.html +2 -2
  58. package/.next/server/app/logs.rsc +14 -14
  59. package/.next/server/app/logs.segments/_full.segment.rsc +14 -14
  60. package/.next/server/app/logs.segments/_head.segment.rsc +4 -4
  61. package/.next/server/app/logs.segments/_index.segment.rsc +7 -7
  62. package/.next/server/app/logs.segments/_tree.segment.rsc +2 -2
  63. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +4 -4
  64. package/.next/server/app/logs.segments/logs.segment.rsc +3 -3
  65. package/.next/server/app/messages/page_client-reference-manifest.js +1 -1
  66. package/.next/server/app/messages.html +2 -2
  67. package/.next/server/app/messages.rsc +14 -14
  68. package/.next/server/app/messages.segments/_full.segment.rsc +14 -14
  69. package/.next/server/app/messages.segments/_head.segment.rsc +4 -4
  70. package/.next/server/app/messages.segments/_index.segment.rsc +7 -7
  71. package/.next/server/app/messages.segments/_tree.segment.rsc +2 -2
  72. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +4 -4
  73. package/.next/server/app/messages.segments/messages.segment.rsc +3 -3
  74. package/.next/server/app/node/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/node.html +2 -2
  76. package/.next/server/app/node.rsc +14 -14
  77. package/.next/server/app/node.segments/_full.segment.rsc +14 -14
  78. package/.next/server/app/node.segments/_head.segment.rsc +4 -4
  79. package/.next/server/app/node.segments/_index.segment.rsc +7 -7
  80. package/.next/server/app/node.segments/_tree.segment.rsc +2 -2
  81. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +4 -4
  82. package/.next/server/app/node.segments/node.segment.rsc +3 -3
  83. package/.next/server/app/nodes/page_client-reference-manifest.js +1 -1
  84. package/.next/server/app/nodes.html +2 -2
  85. package/.next/server/app/nodes.rsc +14 -14
  86. package/.next/server/app/nodes.segments/_full.segment.rsc +14 -14
  87. package/.next/server/app/nodes.segments/_head.segment.rsc +4 -4
  88. package/.next/server/app/nodes.segments/_index.segment.rsc +7 -7
  89. package/.next/server/app/nodes.segments/_tree.segment.rsc +2 -2
  90. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +4 -4
  91. package/.next/server/app/nodes.segments/nodes.segment.rsc +3 -3
  92. package/.next/server/app/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app/server-logs/page_client-reference-manifest.js +1 -1
  94. package/.next/server/app/server-logs.html +2 -2
  95. package/.next/server/app/server-logs.rsc +14 -14
  96. package/.next/server/app/server-logs.segments/_full.segment.rsc +14 -14
  97. package/.next/server/app/server-logs.segments/_head.segment.rsc +4 -4
  98. package/.next/server/app/server-logs.segments/_index.segment.rsc +7 -7
  99. package/.next/server/app/server-logs.segments/_tree.segment.rsc +2 -2
  100. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +4 -4
  101. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +3 -3
  102. package/.next/server/app/settings/networks/page_client-reference-manifest.js +1 -1
  103. package/.next/server/app/settings/networks.html +2 -2
  104. package/.next/server/app/settings/networks.rsc +14 -14
  105. package/.next/server/app/settings/networks.segments/_full.segment.rsc +14 -14
  106. package/.next/server/app/settings/networks.segments/_head.segment.rsc +4 -4
  107. package/.next/server/app/settings/networks.segments/_index.segment.rsc +7 -7
  108. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +2 -2
  109. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +4 -4
  110. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +3 -3
  111. package/.next/server/app/settings/networks.segments/settings.segment.rsc +3 -3
  112. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  113. package/.next/server/app/settings/tokens/page_client-reference-manifest.js +1 -1
  114. package/.next/server/app/settings/tokens.html +2 -2
  115. package/.next/server/app/settings/tokens.rsc +14 -14
  116. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +14 -14
  117. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +4 -4
  118. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +7 -7
  119. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +2 -2
  120. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +4 -4
  121. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +3 -3
  122. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +3 -3
  123. package/.next/server/app/settings.html +2 -2
  124. package/.next/server/app/settings.rsc +14 -14
  125. package/.next/server/app/settings.segments/_full.segment.rsc +14 -14
  126. package/.next/server/app/settings.segments/_head.segment.rsc +4 -4
  127. package/.next/server/app/settings.segments/_index.segment.rsc +7 -7
  128. package/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  129. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
  130. package/.next/server/app/settings.segments/settings.segment.rsc +3 -3
  131. package/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
  132. package/.next/server/app/tasks/page_client-reference-manifest.js +1 -1
  133. package/.next/server/app/tasks.html +2 -2
  134. package/.next/server/app/tasks.rsc +14 -14
  135. package/.next/server/app/tasks.segments/_full.segment.rsc +14 -14
  136. package/.next/server/app/tasks.segments/_head.segment.rsc +4 -4
  137. package/.next/server/app/tasks.segments/_index.segment.rsc +7 -7
  138. package/.next/server/app/tasks.segments/_tree.segment.rsc +2 -2
  139. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +4 -4
  140. package/.next/server/app/tasks.segments/tasks.segment.rsc +3 -3
  141. package/.next/server/app-paths-manifest.json +1 -0
  142. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js +3 -0
  143. package/.next/server/chunks/0ykm__next-internal_server_app_api_hub_servers_route_actions_08-m-cs.js.map +1 -0
  144. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js +3 -0
  145. package/.next/server/chunks/[root-of-the-server]__0tx8s8i._.js.map +1 -0
  146. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js +1 -1
  147. package/.next/server/chunks/ssr/[root-of-the-server]__03b.f76._.js.map +1 -1
  148. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js +1 -1
  149. package/.next/server/chunks/ssr/[root-of-the-server]__0fhoq8i._.js.map +1 -1
  150. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js +1 -1
  151. package/.next/server/chunks/ssr/[root-of-the-server]__0nw~zhp._.js.map +1 -1
  152. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +2 -1
  153. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  154. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  155. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  156. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js +1 -1
  157. package/.next/server/chunks/ssr/agent-network-dashboard_app_057q.ne._.js.map +1 -1
  158. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  159. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  160. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js +1 -1
  161. package/.next/server/chunks/ssr/agent-network-dashboard_app_0_d45-d._.js.map +1 -1
  162. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js +1 -1
  163. package/.next/server/chunks/ssr/agent-network-dashboard_app_0i3759l._.js.map +1 -1
  164. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js +1 -1
  165. package/.next/server/chunks/ssr/agent-network-dashboard_app_0xgney8._.js.map +1 -1
  166. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js +1 -1
  167. package/.next/server/chunks/ssr/agent-network-dashboard_app_10hjgv4._.js.map +1 -1
  168. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js +1 -1
  169. package/.next/server/chunks/ssr/agent-network-dashboard_app_1153xeb._.js.map +1 -1
  170. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js +1 -1
  171. package/.next/server/chunks/ssr/agent-network-dashboard_app_12l4oto._.js.map +1 -1
  172. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js +1 -1
  173. package/.next/server/chunks/ssr/agent-network-dashboard_app_components_0mvyi-4._.js.map +1 -1
  174. package/.next/server/middleware-build-manifest.js +3 -3
  175. package/.next/server/pages/404.html +2 -2
  176. package/.next/server/pages/500.html +1 -1
  177. package/.next/server/server-reference-manifest.js +1 -1
  178. package/.next/server/server-reference-manifest.json +1 -1
  179. package/.next/static/chunks/0-mpa_947ipeq.js +1 -0
  180. package/.next/static/chunks/01lmbsd37fybu.js +1 -0
  181. package/.next/static/chunks/03o.h6kvmw4l_.js +1 -0
  182. package/.next/static/chunks/03~~oirxz7~vc.js +1 -0
  183. package/.next/static/chunks/05uk96gc~9mni.js +1 -0
  184. package/.next/static/chunks/088ouqy7tekty.js +2 -0
  185. package/.next/static/chunks/08kf-.bgs10ze.js +1 -0
  186. package/.next/static/chunks/0glmrdj9112tc.js +7 -0
  187. package/.next/static/chunks/0hyl7mdapsz38.js +1 -0
  188. package/.next/static/chunks/0mqwn-l7wtcdo.js +7 -0
  189. package/.next/static/chunks/0rxrez_cjn.eh.js +1 -0
  190. package/.next/static/chunks/0tvn2l1pc.h65.js +1 -0
  191. package/.next/static/chunks/0x8x.29hkju5a.js +1 -0
  192. package/.next/static/chunks/127kw7~wsnyu7.css +2 -0
  193. package/.next/static/chunks/16yfxp9ak2gdm.js +1 -0
  194. package/.next/static/chunks/174re5ar~kxne.js +1 -0
  195. package/.next/trace +2 -2
  196. package/.next/trace-build +1 -1
  197. package/.next/types/routes.d.ts +2 -1
  198. package/.next/types/validator.ts +9 -0
  199. package/app/api/hub/servers/route.ts +39 -0
  200. package/app/components/AppShell.tsx +2 -0
  201. package/app/components/ChatPopover.tsx +38 -2
  202. package/app/components/HelpOverlay.tsx +18 -0
  203. package/app/components/ServersDrawer.tsx +206 -0
  204. package/app/components/TopoGraph.tsx +833 -138
  205. package/app/components/utils.ts +8 -7
  206. package/app/lib/time.ts +36 -0
  207. package/package.json +1 -1
  208. package/scripts/help-overlay-topo-test.mjs +44 -0
  209. package/scripts/servers-drawer-live-test.mjs +96 -0
  210. package/scripts/topo-active-links-chip-test.mjs +73 -0
  211. package/scripts/topo-arrow-tier-test.mjs +61 -0
  212. package/scripts/topo-auto-fit-test.mjs +73 -0
  213. package/scripts/topo-chat-focus-ring-test.mjs +77 -0
  214. package/scripts/topo-click-ripple-test.mjs +66 -0
  215. package/scripts/topo-cwd-tooltip-test.mjs +64 -0
  216. package/scripts/topo-dblclick-reset-test.mjs +74 -0
  217. package/scripts/topo-edge-hover-test.mjs +85 -0
  218. package/scripts/topo-edge-tooltip-test.mjs +70 -0
  219. package/scripts/topo-fit-key-test.mjs +106 -0
  220. package/scripts/topo-freshness-chip-test.mjs +74 -0
  221. package/scripts/topo-group-count-test.mjs +63 -0
  222. package/scripts/topo-group-hover-test.mjs +82 -0
  223. package/scripts/topo-hub-title-test.mjs +88 -0
  224. package/scripts/topo-label-lift-test.mjs +115 -0
  225. package/scripts/topo-label-scale-test.mjs +78 -0
  226. package/scripts/topo-lastseen-tooltip-test.mjs +59 -0
  227. package/scripts/topo-layout-key-test.mjs +91 -0
  228. package/scripts/topo-minimap-test.mjs +68 -0
  229. package/scripts/topo-mobile-chips-test.mjs +103 -0
  230. package/scripts/topo-offline-dim-test.mjs +70 -0
  231. package/scripts/topo-overlap-test.mjs +32 -5
  232. package/scripts/topo-pan-cursor-test.mjs +62 -0
  233. package/scripts/topo-popover-meta-test.mjs +78 -0
  234. package/scripts/topo-pressure-bar-test.mjs +69 -0
  235. package/scripts/topo-pulse-rate-test.mjs +71 -0
  236. package/scripts/topo-recent-signal-empty-test.mjs +63 -0
  237. package/scripts/topo-reduced-motion-test.mjs +77 -0
  238. package/scripts/topo-tz-ghost-test.mjs +69 -0
  239. package/scripts/topo-wheel-zoom-test.mjs +79 -0
  240. package/scripts/topo-zoom-keys-test.mjs +89 -0
  241. package/.next/static/chunks/0-7iowzu4.798.js +0 -1
  242. package/.next/static/chunks/02t5jr4shh.hy.js +0 -1
  243. package/.next/static/chunks/0_qfg5s5uzs7..js +0 -7
  244. package/.next/static/chunks/0_rmxom9~1lmk.css +0 -2
  245. package/.next/static/chunks/0cj5qr_a8dzky.js +0 -7
  246. package/.next/static/chunks/0elalb9n_.~di.js +0 -1
  247. package/.next/static/chunks/0j_q__o8p6g83.js +0 -1
  248. package/.next/static/chunks/0jl6_w50zh4l5.js +0 -1
  249. package/.next/static/chunks/0kgak.tfh2-~i.js +0 -1
  250. package/.next/static/chunks/0r4q73.-1bkyl.js +0 -1
  251. package/.next/static/chunks/0r_c8eccg3z2o.js +0 -1
  252. package/.next/static/chunks/10oi7l-tycgl9.js +0 -1
  253. package/.next/static/chunks/11uk1izuv3~0t.js +0 -1
  254. package/.next/static/chunks/131.zrqhdbu5x.js +0 -1
  255. package/.next/static/chunks/165lqf5.z.n10.js +0 -1
  256. /package/.next/static/{T0hK2WE0kVtyFouPsSpos → 23MxP0RBVA4BaOJ_HMYmM}/_buildManifest.js +0 -0
  257. /package/.next/static/{T0hK2WE0kVtyFouPsSpos → 23MxP0RBVA4BaOJ_HMYmM}/_clientMiddlewareManifest.js +0 -0
  258. /package/.next/static/{T0hK2WE0kVtyFouPsSpos → 23MxP0RBVA4BaOJ_HMYmM}/_ssgManifest.js +0 -0
@@ -0,0 +1,115 @@
1
+ /* Round 26 verification: hovering a node lifts its label group up by
2
+ * 1.5 px (CSS transform stacked on the SVG positioning translate). Both
3
+ * the full-card path (sparse fleet) and the dense plain-text path
4
+ * (large fleet) should respond. */
5
+ import { chromium } from 'playwright';
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
9
+ const browser = await chromium.launch({ headless: true });
10
+
11
+ async function probe(fleetSize) {
12
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 1600 } });
13
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
14
+ await ctx.addInitScript(() => {
15
+ try {
16
+ localStorage.setItem('anet-theme', 'cyber');
17
+ localStorage.setItem('anet-topo-layout', 'grid');
18
+ localStorage.setItem('anet-topo-nodescale', '1');
19
+ sessionStorage.setItem('anet_v3_auth', '1');
20
+ } catch {}
21
+ });
22
+ const fresh = new Date(Date.now() - 60 * 1000).toISOString();
23
+ await ctx.route('**/api/hub/status*', async (route) => {
24
+ const r = await route.fetch();
25
+ const b = await r.json();
26
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
27
+ const sessions = Array.from({ length: fleetSize }, (_, i) => ({
28
+ alias: `lab${i + 1}`, status: 'idle', network_id: nid, project_dir: null,
29
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
30
+ }));
31
+ await route.fulfill({ response: r, json: { ...b, sessions } });
32
+ });
33
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
34
+ const page = await ctx.newPage();
35
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
36
+ await page.waitForFunction((n) => {
37
+ const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
38
+ return !!svg && svg.querySelectorAll('g[data-node]').length === n;
39
+ }, fleetSize, { timeout: 30000 }).catch(() => {});
40
+ await page.waitForTimeout(500);
41
+
42
+ // Re-query the label element fresh each sample — React re-renders on
43
+ // hover (hoveredAlias state change) so any element handle captured
44
+ // before the hover would be detached/stale by the time we read its
45
+ // post-hover position.
46
+ const readLabelTop = () => page.evaluate(() => {
47
+ const node = document.querySelector('g[data-node="lab1"]');
48
+ if (!node) return null;
49
+ const full = [...node.querySelectorAll('g')].find(g => /group-hover:-translate-y/.test(g.getAttribute('class') || ''));
50
+ const el = full || [...node.querySelectorAll('text')].find(t => /group-hover:-translate-y/.test(t.getAttribute('class') || ''));
51
+ return el ? el.getBoundingClientRect().top : null;
52
+ });
53
+
54
+ const beforeY = await readLabelTop();
55
+ await page.locator('g[data-node="lab1"]').hover();
56
+ await page.waitForTimeout(300);
57
+ const afterY = await readLabelTop();
58
+
59
+ await ctx.close();
60
+ return { beforeY, afterY };
61
+ }
62
+
63
+ const sparse = await probe(4);
64
+ // Note: in dense mode the same alias's hover triggers `showFullLabel`,
65
+ // which swaps the plain-text element for the full-card <g>. That path
66
+ // switch makes a same-element before/after comparison meaningless.
67
+ // The plain-text element still carries the same hover-lift class, so
68
+ // the lift wiring is verified at the DOM level via the second probe.
69
+ const denseClassPresent = await (async () => {
70
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 1600 } });
71
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
72
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
73
+ await ctx.addInitScript(() => {
74
+ localStorage.setItem('anet-topo-layout', 'grid');
75
+ localStorage.setItem('anet-topo-nodescale', '1');
76
+ sessionStorage.setItem('anet_v3_auth', '1');
77
+ });
78
+ const fresh = new Date(Date.now() - 60 * 1000).toISOString();
79
+ await ctx.route('**/api/hub/status*', async (route) => {
80
+ const r = await route.fetch();
81
+ const b = await r.json();
82
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
83
+ const sessions = Array.from({ length: 28 }, (_, i) => ({
84
+ alias: `lab${i + 1}`, status: 'idle', network_id: nid, project_dir: null,
85
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
86
+ }));
87
+ await route.fulfill({ response: r, json: { ...b, sessions } });
88
+ });
89
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
90
+ const page = await ctx.newPage();
91
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
92
+ await page.waitForFunction(() => document.querySelectorAll('g[data-node]').length === 28, { timeout: 30000 }).catch(() => {});
93
+ await page.waitForTimeout(500);
94
+ const has = await page.evaluate(() => {
95
+ const node = document.querySelector('g[data-node="lab2"]'); // not the one we'd hover
96
+ if (!node) return false;
97
+ const t = [...node.querySelectorAll('text')].find(x => /group-hover:-translate-y/.test(x.getAttribute('class') || ''));
98
+ return !!t;
99
+ });
100
+ await ctx.close();
101
+ return has;
102
+ })();
103
+
104
+ await browser.close();
105
+
106
+ const lifted = (m) => m.beforeY != null && m.afterY != null && m.beforeY - m.afterY >= 1 && m.beforeY - m.afterY <= 2.5;
107
+
108
+ const results = {
109
+ sparseLabelLifts: lifted(sparse),
110
+ densePlainTextHasLiftClass: denseClassPresent,
111
+ };
112
+ const ok = Object.values(results).every(Boolean);
113
+ console.log(`${ok ? '✅' : '❌'} label hover lift:`, JSON.stringify(results),
114
+ `sparse=Δ${(sparse.beforeY - sparse.afterY)?.toFixed(2)}px`);
115
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,78 @@
1
+ /* Round 15 verification: at nodeScale=S the label card + fonts step down
2
+ * one tier (rect 100×42 → 88×36, alias 12 → 11, sub 9 → 8); M and L keep
3
+ * the existing sizes. */
4
+ import { chromium } from 'playwright';
5
+ import { readFileSync } from 'node:fs';
6
+
7
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
8
+ const browser = await chromium.launch({ headless: true });
9
+
10
+ async function measure(scaleVal) {
11
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
12
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
13
+ await ctx.addInitScript((s) => {
14
+ try {
15
+ localStorage.setItem('anet-theme', 'cyber');
16
+ localStorage.removeItem('anet-brand');
17
+ localStorage.removeItem('anet-topo-view');
18
+ localStorage.setItem('anet-topo-layout', 'grid');
19
+ localStorage.setItem('anet-topo-nodescale', s);
20
+ sessionStorage.setItem('anet_v3_auth', '1');
21
+ } catch {}
22
+ }, scaleVal);
23
+ await ctx.route('**/api/hub/status*', async (route) => {
24
+ const r = await route.fetch();
25
+ const b = await r.json();
26
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
27
+ // 4 nodes only → sparse, so the full-card label path renders.
28
+ const sessions = ['lab1', 'lab2', 'lab3', 'lab4'].map(a => ({
29
+ alias: a, status: 'idle', network_id: nid, project_dir: null,
30
+ created_at: '2026-05-15T00:00:00Z', updated_at: '2026-05-15T00:00:00Z',
31
+ last_seen_at: new Date().toISOString(),
32
+ }));
33
+ await route.fulfill({ response: r, json: { ...b, sessions } });
34
+ });
35
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
36
+ const page = await ctx.newPage();
37
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
38
+ await page.waitForFunction(() => {
39
+ const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
40
+ return !!svg && svg.querySelectorAll('g[data-node]').length === 4;
41
+ }, { timeout: 30000 }).catch(() => {});
42
+ await page.waitForTimeout(400);
43
+ const m = await page.$eval('g[data-node="lab1"]', g => {
44
+ // label card = the <g transform="translate(...)" > inside the node <g>
45
+ // that contains the rect+two texts; pick the rect we just changed.
46
+ const labelG = [...g.querySelectorAll('g')].find(x => x.querySelector('rect') && x.querySelectorAll('text').length === 2);
47
+ if (!labelG) return null;
48
+ const rect = labelG.querySelector('rect');
49
+ const texts = labelG.querySelectorAll('text');
50
+ return {
51
+ w: +rect.getAttribute('width'),
52
+ h: +rect.getAttribute('height'),
53
+ aliasFs: +texts[0].getAttribute('font-size'),
54
+ subFs: +texts[1].getAttribute('font-size'),
55
+ };
56
+ });
57
+ await ctx.close();
58
+ return m;
59
+ }
60
+
61
+ const S = await measure('0.7');
62
+ const M = await measure('0.84');
63
+ const L = await measure('1');
64
+ await browser.close();
65
+
66
+ const results = {
67
+ smallCardW: S?.w === 88,
68
+ smallCardH: S?.h === 36,
69
+ smallAliasFs: S?.aliasFs === 11,
70
+ smallSubFs: S?.subFs === 8,
71
+ mediumKeepsW: M?.w === 100,
72
+ mediumKeepsAliasFs: M?.aliasFs === 12,
73
+ largeKeepsW: L?.w === 100,
74
+ largeKeepsAliasFs: L?.aliasFs === 12,
75
+ };
76
+ const ok = Object.values(results).every(Boolean);
77
+ console.log(`${ok ? '✅' : '❌'} label scale:`, JSON.stringify(results), 'S=', S, 'M=', M, 'L=', L);
78
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,59 @@
1
+ /* Round 34 verification: offline node tooltip carries "last seen: <relative>";
2
+ * online nodes skip the line. SQL-style timestamp parsing is exercised in
3
+ * production (CommHub format) but the existing isGhost filter has a
4
+ * separate TZ-sensitivity issue that ghosts SQL-without-Z entries on
5
+ * non-UTC browsers — out of Round 34 scope. The helper supports both
6
+ * formats; the SQL path is covered by manual inspection. */
7
+ import { chromium } from 'playwright';
8
+ import { readFileSync } from 'node:fs';
9
+
10
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
11
+ const browser = await chromium.launch({ headless: true });
12
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
13
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
14
+ await ctx.addInitScript(() => {
15
+ try {
16
+ localStorage.setItem('anet-theme', 'cyber');
17
+ localStorage.setItem('anet-topo-layout', 'grid');
18
+ sessionStorage.setItem('anet_v3_auth', '1');
19
+ } catch {}
20
+ });
21
+ // One online, two offline at different ages (both ISO).
22
+ const now = Date.now();
23
+ const isoMin = (m) => new Date(now - m * 60 * 1000).toISOString();
24
+ await ctx.route('**/api/hub/status*', async (route) => {
25
+ const r = await route.fetch();
26
+ const b = await r.json();
27
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
28
+ const fresh = isoMin(0.1);
29
+ const sessions = [
30
+ { alias: 'live', status: 'idle', network_id: nid, project_dir: null,
31
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh },
32
+ { alias: 'off6m', status: 'offline', network_id: nid, project_dir: null,
33
+ created_at: fresh, updated_at: fresh, last_seen_at: isoMin(6) },
34
+ { alias: 'off45m', status: 'offline', network_id: nid, project_dir: null,
35
+ created_at: fresh, updated_at: fresh, last_seen_at: isoMin(45) },
36
+ ];
37
+ await route.fulfill({ response: r, json: { ...b, sessions } });
38
+ });
39
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
40
+ const page = await ctx.newPage();
41
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
42
+ await page.waitForFunction(() => document.querySelectorAll('g[data-node]').length === 3, { timeout: 30000 });
43
+ await page.waitForTimeout(400);
44
+
45
+ const titleOf = alias => page.$eval(`g[data-node="${alias}"] title`, el => el.textContent);
46
+ const live = await titleOf('live');
47
+ const off6m = await titleOf('off6m');
48
+ const off45m = await titleOf('off45m');
49
+
50
+ await browser.close();
51
+ const results = {
52
+ liveSkipsLastSeen: !/last seen:/.test(live),
53
+ off6mShows6mAgo: /last seen: [56]m ago/.test(off6m), // 6m exactly, ±1 minute slack
54
+ off45mShows45mAgo: /last seen: 4[45]m ago/.test(off45m), // 45m exactly, ±1 minute slack
55
+ };
56
+ const ok = Object.values(results).every(Boolean);
57
+ console.log(`${ok ? '✅' : '❌'} last-seen tooltip:`, JSON.stringify(results),
58
+ `\n live=${JSON.stringify(live)}\n off6m=${JSON.stringify(off6m)}\n off45m=${JSON.stringify(off45m)}`);
59
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,91 @@
1
+ /* Round 32 verification: `l` (and `L`) keyboard shortcut toggles
2
+ * ring ↔ grid; titles + Help overlay surface the hint; input-focus
3
+ * suppression still blocks it. */
4
+ import { chromium } from 'playwright';
5
+ import { readFileSync } from 'node:fs';
6
+
7
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
8
+ const browser = await chromium.launch({ headless: true });
9
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 1600 } });
10
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
11
+ await ctx.addInitScript(() => {
12
+ try {
13
+ localStorage.setItem('anet-theme', 'cyber');
14
+ localStorage.setItem('anet-topo-layout', 'grid'); // start on grid
15
+ sessionStorage.setItem('anet_v3_auth', '1');
16
+ } catch {}
17
+ });
18
+ const fresh = new Date(Date.now() - 60 * 1000).toISOString();
19
+ await ctx.route('**/api/hub/status*', async (route) => {
20
+ const r = await route.fetch();
21
+ const b = await r.json();
22
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
23
+ const sessions = ['a', 'b', 'c'].map(a => ({
24
+ alias: a, status: 'idle', network_id: nid, project_dir: null,
25
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
26
+ }));
27
+ await route.fulfill({ response: r, json: { ...b, sessions } });
28
+ });
29
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
30
+ const page = await ctx.newPage();
31
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
32
+ await page.waitForSelector('button[aria-label="Topology layout"], button[title*="Ring layout"]', { timeout: 30000 });
33
+ await page.waitForTimeout(400);
34
+
35
+ const layoutPressed = () => page.evaluate(() => {
36
+ const ring = document.querySelector('button[title^="Ring layout"]');
37
+ const grid = document.querySelector('button[title^="Grid layout"]');
38
+ return { ring: ring?.getAttribute('aria-pressed'), grid: grid?.getAttribute('aria-pressed') };
39
+ });
40
+
41
+ const initial = await layoutPressed();
42
+
43
+ // Press `l` → should toggle to ring
44
+ await page.keyboard.press('l');
45
+ await page.waitForTimeout(150);
46
+ const afterL = await layoutPressed();
47
+
48
+ // Press `L` (shift+l) → toggle back to grid
49
+ await page.keyboard.press('Shift+L');
50
+ await page.waitForTimeout(150);
51
+ const afterShiftL = await layoutPressed();
52
+
53
+ // Titles carry the hint
54
+ const titles = await page.evaluate(() => ({
55
+ ring: document.querySelector('button[title^="Ring layout"]')?.getAttribute('title') || '',
56
+ grid: document.querySelector('button[title^="Grid layout"]')?.getAttribute('title') || '',
57
+ }));
58
+
59
+ // Help overlay lists the shortcut
60
+ await page.keyboard.press('?');
61
+ await page.waitForTimeout(200);
62
+ const helpHasLayout = await page.evaluate(() => {
63
+ const heading = [...document.querySelectorAll('[role="dialog"] div')].find(el => el.textContent === 'Topology canvas');
64
+ if (!heading) return false;
65
+ return /Toggle layout/i.test(heading.parentElement?.textContent || '');
66
+ });
67
+
68
+ // Input-focus suppression — Cmd+K input, press l, no toggle
69
+ await page.keyboard.press('Escape');
70
+ await page.keyboard.press('Meta+k');
71
+ await page.waitForTimeout(200);
72
+ const palette = page.locator('input[placeholder*="Search"], input[type="search"], [role="dialog"] input').first();
73
+ await palette.focus().catch(() => {});
74
+ const beforeFocusToggle = await layoutPressed();
75
+ await page.keyboard.press('l');
76
+ await page.waitForTimeout(150);
77
+ const afterFocusToggle = await layoutPressed();
78
+ await page.keyboard.press('Escape');
79
+
80
+ await browser.close();
81
+ const results = {
82
+ startsGrid: initial.grid === 'true' && initial.ring === 'false',
83
+ lTogglesToRing: afterL.ring === 'true' && afterL.grid === 'false',
84
+ shiftLTogglesBack: afterShiftL.grid === 'true' && afterShiftL.ring === 'false',
85
+ titlesPresent: /l to toggle/i.test(titles.ring) && /l to toggle/i.test(titles.grid),
86
+ helpListsLayout: helpHasLayout,
87
+ inputFocusSuppressed: beforeFocusToggle.grid === afterFocusToggle.grid && beforeFocusToggle.ring === afterFocusToggle.ring,
88
+ };
89
+ const ok = Object.values(results).every(Boolean);
90
+ console.log(`${ok ? '✅' : '❌'} layout key:`, JSON.stringify(results), `titles=`, titles);
91
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,68 @@
1
+ /* Round 30 verification: minimap.
2
+ * - not mounted at default view (1×, centered)
3
+ * - mounted when zoomed in (one node dot per session, viewport rect)
4
+ * - click recenters the canvas (view.x / view.y change) */
5
+ import { chromium } from 'playwright';
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
9
+ const browser = await chromium.launch({ headless: true });
10
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 1600 } });
11
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
12
+ await ctx.addInitScript(() => {
13
+ try {
14
+ localStorage.setItem('anet-theme', 'cyber');
15
+ localStorage.setItem('anet-topo-layout', 'grid');
16
+ localStorage.setItem('anet-topo-view', JSON.stringify({ zoom: 1, x: 0, y: 0 }));
17
+ sessionStorage.setItem('anet_v3_auth', '1');
18
+ } catch {}
19
+ });
20
+ const fresh = new Date(Date.now() - 60 * 1000).toISOString();
21
+ await ctx.route('**/api/hub/status*', async (route) => {
22
+ const r = await route.fetch();
23
+ const b = await r.json();
24
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
25
+ const sessions = ['A站', 'B站', 'C站', 'D站', 'E站', 'F站'].flatMap(g =>
26
+ Array.from({ length: 3 }, (_, i) => ({
27
+ alias: `${g}${i + 1}`, status: 'idle', network_id: nid, project_dir: null,
28
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh,
29
+ })),
30
+ );
31
+ await route.fulfill({ response: r, json: { ...b, sessions } });
32
+ });
33
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
34
+ const page = await ctx.newPage();
35
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
36
+ await page.waitForSelector('svg[viewBox="0 0 1000 680"]', { timeout: 30000 });
37
+ await page.waitForTimeout(500);
38
+
39
+ const minimap = '[data-topo-minimap]';
40
+ const notMountedAtDefault = (await page.locator(minimap).count()) === 0;
41
+
42
+ // Zoom in via `+` twice → view changes → minimap mounts
43
+ await page.keyboard.press('=');
44
+ await page.keyboard.press('=');
45
+ await page.waitForTimeout(200);
46
+ const mountedAfterZoom = (await page.locator(minimap).count()) === 1;
47
+ const dotCount = await page.locator(`${minimap} svg circle`).count();
48
+ const hasViewportRect = await page.locator(`${minimap} svg rect`).count() > 0;
49
+
50
+ // Click on the minimap's far left → expect view.x to become more positive (canvas pans right)
51
+ const beforeView = await page.evaluate(() => JSON.parse(localStorage.getItem('anet-topo-view')));
52
+ const mmBox = await page.locator(minimap).boundingBox();
53
+ await page.mouse.click(mmBox.x + 10, mmBox.y + mmBox.height / 2);
54
+ await page.waitForTimeout(200);
55
+ const afterView = await page.evaluate(() => JSON.parse(localStorage.getItem('anet-topo-view')));
56
+ const clickRecenters = afterView.x !== beforeView.x || afterView.y !== beforeView.y;
57
+
58
+ await browser.close();
59
+ const results = {
60
+ notMountedAtDefault,
61
+ mountedAfterZoom,
62
+ oneDotPerSession: dotCount === 18, // 6 groups × 3 = 18 nodes
63
+ viewportRectPresent: hasViewportRect,
64
+ clickRecenters,
65
+ };
66
+ const ok = Object.values(results).every(Boolean);
67
+ console.log(`${ok ? '✅' : '❌'} minimap:`, JSON.stringify(results), `dots=${dotCount} view before=`, beforeView, `after=`, afterView);
68
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,103 @@
1
+ /* Round 47 verification: mobile chip collapse.
2
+ * - At narrow viewport (375px), pressure / vendor-dist / active-links /
3
+ * freshness chips are display:none; only essential chips render.
4
+ * - At sm+ (≥640px), the full chip row is visible.
5
+ * - Essential chips (Ring/Grid toggle + working + online) render at both. */
6
+ import { chromium } from 'playwright';
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
10
+ const browser = await chromium.launch({ headless: true });
11
+
12
+ async function probe(width) {
13
+ const ctx = await browser.newContext({ viewport: { width, height: 900 } });
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
+ localStorage.setItem('anet-topo-layout', 'grid');
19
+ sessionStorage.setItem('anet_v3_auth', '1');
20
+ } catch {}
21
+ });
22
+ const fresh = new Date(Date.now() - 60 * 1000).toISOString();
23
+ await ctx.route('**/api/hub/status*', async (route) => {
24
+ const r = await route.fetch();
25
+ const b = await r.json();
26
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
27
+ const sessions = [
28
+ { alias: 'wkr', status: 'working', network_id: nid, project_dir: null,
29
+ model: 'claude-opus-4', runtime: 'cli-claude-code',
30
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh },
31
+ { alias: 'idl', status: 'idle', network_id: nid, project_dir: null,
32
+ model: 'gpt-5', runtime: 'codex-cli',
33
+ created_at: fresh, updated_at: fresh, last_seen_at: fresh },
34
+ ];
35
+ await route.fulfill({ response: r, json: { ...b, sessions } });
36
+ });
37
+ await ctx.route('**/api/hub/messages*', (route) => route.fulfill({ json: { messages: [
38
+ { from_alias: 'wkr', to_alias: 'idl', content: 'm', created_at: new Date().toISOString() },
39
+ ] } }));
40
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
41
+ const page = await ctx.newPage();
42
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
43
+ // The Overview page auto-hides the topology when window.innerWidth < 1024
44
+ // (the lg breakpoint). On narrow viewports the user clicks "Show
45
+ // Topology" to surface it; mimic that here so the chip-row tests reach
46
+ // the actual code path. Wait for SWR to deliver sessions first so the
47
+ // button renders before we look for it.
48
+ await page.waitForTimeout(1500);
49
+ const showBtn = page.locator('button', { hasText: /Show Topology/i });
50
+ if (await showBtn.count() > 0) await showBtn.first().click();
51
+ await page.waitForSelector('svg[viewBox="0 0 1000 680"]', { timeout: 30000 });
52
+ await page.waitForTimeout(600);
53
+ const visible = (sel) => page.evaluate(s => {
54
+ const el = document.querySelector(s);
55
+ if (!el) return null;
56
+ const r = el.getBoundingClientRect();
57
+ return r.width > 0 && r.height > 0;
58
+ }, sel);
59
+ const result = {
60
+ workingChipVisible: await page.evaluate(() => {
61
+ // "{N} working" chip — find the green-tinted one
62
+ const spans = [...document.querySelectorAll('span')];
63
+ const chip = spans.find(s => /^\d+ working$/.test((s.textContent || '').trim()));
64
+ if (!chip) return false;
65
+ const r = chip.getBoundingClientRect();
66
+ return r.width > 0 && r.height > 0;
67
+ }),
68
+ onlineChipVisible: await page.evaluate(() => {
69
+ const spans = [...document.querySelectorAll('span')];
70
+ const chip = spans.find(s => /^\d+ online$/.test((s.textContent || '').trim()));
71
+ if (!chip) return false;
72
+ const r = chip.getBoundingClientRect();
73
+ return r.width > 0 && r.height > 0;
74
+ }),
75
+ pressureVisible: await visible('[data-fleet-pressure]'),
76
+ activeLinksVisible: await page.evaluate(() => {
77
+ const spans = [...document.querySelectorAll('span')];
78
+ const chip = spans.find(s => /active link/.test(s.textContent || ''));
79
+ if (!chip) return false;
80
+ const r = chip.getBoundingClientRect();
81
+ return r.width > 0 && r.height > 0;
82
+ }),
83
+ freshnessVisible: await visible('[data-freshness-chip]'),
84
+ };
85
+ await ctx.close();
86
+ return result;
87
+ }
88
+
89
+ const mobile = await probe(375);
90
+ const desktop = await probe(900);
91
+ await browser.close();
92
+
93
+ const results = {
94
+ mobileEssentialsVisible: mobile.workingChipVisible && mobile.onlineChipVisible,
95
+ mobilePressureHidden: mobile.pressureVisible === false,
96
+ mobileActiveLinksHidden: mobile.activeLinksVisible === false,
97
+ mobileFreshnessHidden: mobile.freshnessVisible === false,
98
+ desktopAllVisible: desktop.workingChipVisible && desktop.onlineChipVisible && desktop.pressureVisible && desktop.activeLinksVisible && desktop.freshnessVisible,
99
+ };
100
+ const ok = Object.values(results).every(Boolean);
101
+ console.log(`${ok ? '✅' : '❌'} mobile chips:`, JSON.stringify(results),
102
+ `\n mobile=`, mobile, `\n desktop=`, desktop);
103
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,70 @@
1
+ /* Round 17 verification: offline nodes render at opacity 0.6 at rest,
2
+ * online stay at 1, and the chat-focused node (even if offline) jumps
3
+ * to 1 when its ChatPopover opens. */
4
+ import { chromium } from 'playwright';
5
+ import { readFileSync } from 'node:fs';
6
+
7
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
8
+ const browser = await chromium.launch({ headless: true });
9
+
10
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
11
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
12
+ await ctx.addInitScript(() => {
13
+ try {
14
+ localStorage.setItem('anet-theme', 'cyber');
15
+ localStorage.removeItem('anet-brand');
16
+ localStorage.removeItem('anet-topo-view');
17
+ localStorage.setItem('anet-topo-layout', 'grid');
18
+ sessionStorage.setItem('anet_v3_auth', '1');
19
+ } catch {}
20
+ });
21
+ // fresh-enough so the ghost-age-out filter doesn't drop the offline node
22
+ const fresh = new Date(Date.now() - 60 * 1000).toISOString();
23
+ await ctx.route('**/api/hub/status*', async (route) => {
24
+ const r = await route.fetch();
25
+ const b = await r.json();
26
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
27
+ const sessions = [
28
+ { alias: 'on1', status: 'idle', network_id: nid, project_dir: null, created_at: fresh, updated_at: fresh, last_seen_at: fresh },
29
+ { alias: 'on2', status: 'working', network_id: nid, project_dir: null, created_at: fresh, updated_at: fresh, last_seen_at: fresh },
30
+ { alias: 'off1', status: 'offline', network_id: nid, project_dir: null, created_at: fresh, updated_at: fresh, last_seen_at: fresh },
31
+ { alias: 'off2', status: 'offline', network_id: nid, project_dir: null, created_at: fresh, updated_at: fresh, last_seen_at: fresh },
32
+ ];
33
+ await route.fulfill({ response: r, json: { ...b, sessions } });
34
+ });
35
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
36
+ const page = await ctx.newPage();
37
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
38
+ await page.waitForFunction(() => {
39
+ const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
40
+ return !!svg && svg.querySelectorAll('g[data-node]').length === 4;
41
+ }, { timeout: 30000 }).catch(() => {});
42
+ await page.waitForTimeout(500);
43
+
44
+ const opacityOf = (alias) => page.$eval(`g[data-node="${alias}"]`, el =>
45
+ parseFloat(getComputedStyle(el).opacity || el.style.opacity || '1'));
46
+
47
+ const restOn1 = await opacityOf('on1');
48
+ const restOn2 = await opacityOf('on2');
49
+ const restOff1 = await opacityOf('off1');
50
+ const restOff2 = await opacityOf('off2');
51
+
52
+ // Click an offline node — the chat popover should open and that node
53
+ // should jump to full opacity.
54
+ await page.locator('g[data-node="off1"]').click();
55
+ await page.waitForTimeout(300);
56
+ const focusedOff1 = await opacityOf('off1');
57
+ const otherOff2 = await opacityOf('off2');
58
+
59
+ await browser.close();
60
+ const results = {
61
+ onlineFullOpacity: Math.abs(restOn1 - 1) < 0.01 && Math.abs(restOn2 - 1) < 0.01,
62
+ offlineDimmedAtRest: Math.abs(restOff1 - 0.6) < 0.01 && Math.abs(restOff2 - 0.6) < 0.01,
63
+ chatFocusOfflineRestoresOpacity: Math.abs(focusedOff1 - 1) < 0.01,
64
+ otherOfflineStaysDimmed: Math.abs(otherOff2 - 0.6) < 0.01,
65
+ };
66
+ const ok = Object.values(results).every(Boolean);
67
+ console.log(`${ok ? '✅' : '❌'} offline dim:`, JSON.stringify(results),
68
+ `rest=on1:${restOn1} on2:${restOn2} off1:${restOff1} off2:${restOff2}`,
69
+ `clicked off1=${focusedOff1} off2=${otherOff2}`);
70
+ process.exit(ok ? 0 : 1);
@@ -3,7 +3,10 @@
3
3
  * - node ↔ node — status rings don't intersect
4
4
  * - node ↔ panel — no node circle intersects an overlay panel rect
5
5
  * - node ↔ group-label — no group-box label text covers a node
6
- * - group-box ↔ box — no two group boxes overlap */
6
+ * - group-box ↔ box — no two group boxes overlap
7
+ * - label ↔ label — no two text labels overlap (Round 27 — caught
8
+ * the dense-node-label vs group-label collision
9
+ * Vincent reported in preview.29) */
7
10
  import { chromium } from 'playwright';
8
11
  import { readFileSync, mkdirSync } from 'node:fs';
9
12
 
@@ -80,11 +83,24 @@ async function check(layout) {
80
83
  label: { x: bb.x, y: bb.y, w: bb.width, h: bb.height },
81
84
  };
82
85
  });
83
- return { nodes, panels, groups };
86
+ // Round 27 / P0: every visible label bbox (node alias text + group
87
+ // headings) so we can assert label↔label clearance. Skip elements
88
+ // with empty bboxes (e.g., text inside an unrendered <g>).
89
+ const allLabels = [];
90
+ for (const t of svg.querySelectorAll('g[data-node] text, g[data-group] text')) {
91
+ const bb = t.getBBox();
92
+ if (bb.width === 0 || bb.height === 0) continue;
93
+ allLabels.push({
94
+ kind: t.closest('g[data-group]') ? 'group' : 'node',
95
+ owner: (t.closest('g[data-group]')?.getAttribute('data-group')) || (t.closest('g[data-node]')?.getAttribute('data-node')) || '?',
96
+ x: bb.x, y: bb.y, w: bb.width, h: bb.height,
97
+ });
98
+ }
99
+ return { nodes, panels, groups, allLabels };
84
100
  });
85
101
 
86
102
  const TOL = 2; // px slack — touching edges is fine, real overlap is not
87
- let nodeNode = 0, nodePanel = 0, nodeLabel = 0, boxBox = 0;
103
+ let nodeNode = 0, nodePanel = 0, nodeLabel = 0, boxBox = 0, labelLabel = 0;
88
104
 
89
105
  // node ↔ node
90
106
  for (let i = 0; i < data.nodes.length; i++) {
@@ -119,12 +135,23 @@ async function check(layout) {
119
135
  overlaps1D(a.y, a.y + a.h, b.y, b.y + b.h, TOL)) boxBox++;
120
136
  }
121
137
  }
138
+ // label ↔ label (Round 27)
139
+ // Same-node node-labels (e.g. alias + sub-status in the full card) are
140
+ // intentionally close and adjacent — skip pairs that share an owner.
141
+ for (let i = 0; i < data.allLabels.length; i++) {
142
+ for (let j = i + 1; j < data.allLabels.length; j++) {
143
+ const a = data.allLabels[i], b = data.allLabels[j];
144
+ if (a.owner === b.owner) continue;
145
+ if (overlaps1D(a.x, a.x + a.w, b.x, b.x + b.w, TOL) &&
146
+ overlaps1D(a.y, a.y + a.h, b.y, b.y + b.h, TOL)) labelLabel++;
147
+ }
148
+ }
122
149
 
123
150
  await page.screenshot({ path: `/tmp/anet-issue-112/overlap-${layout}.png` });
124
151
  await ctx.close();
125
- const ok = nodeNode === 0 && nodePanel === 0 && nodeLabel === 0 && boxBox === 0;
152
+ const ok = nodeNode === 0 && nodePanel === 0 && nodeLabel === 0 && boxBox === 0 && labelLabel === 0;
126
153
  console.log(`${ok ? '✅' : '❌'} [${layout}] nodes=${data.nodes.length} groups=${data.groups.length} — ` +
127
- `node↔node:${nodeNode} node↔panel:${nodePanel} node↔label:${nodeLabel} box↔box:${boxBox}`);
154
+ `node↔node:${nodeNode} node↔panel:${nodePanel} node↔label:${nodeLabel} box↔box:${boxBox} label↔label:${labelLabel}`);
128
155
  return ok;
129
156
  }
130
157