@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.29

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 (55) hide show
  1. package/README.md +85 -14
  2. package/README_zh.md +81 -7
  3. package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
  4. package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +624 -624
  7. package/cordis.patch.yml +21 -21
  8. package/lib/client.js +64 -64
  9. package/lib/client.js.map +1 -1
  10. package/lib/faces/admin.js +744 -0
  11. package/lib/index.js +139 -737
  12. package/lib/recursion-guard.js +78 -0
  13. package/lib/runtime.js +1 -0
  14. package/lib/skill/SKILL.md +34 -9
  15. package/lib/skill/references/cli.md +24 -7
  16. package/lib/skill/references/discovery.md +5 -2
  17. package/lib/skill/references/pitfalls.md +2 -1
  18. package/lib/skill/references/routing.md +18 -8
  19. package/lib/skill/references/setup.md +152 -0
  20. package/lib/task-ledger.js +6 -0
  21. package/lib/types/faces/admin.d.ts +132 -0
  22. package/lib/types/index.d.ts +11 -3
  23. package/lib/types/recursion-guard.d.ts +56 -0
  24. package/lib/types/runtime.d.ts +34 -0
  25. package/lib/types/task-ledger.d.ts +6 -0
  26. package/nexus-stage/package-lock.json +2262 -2262
  27. package/nexus-stage/src/canvas-ops.ts +175 -175
  28. package/nexus-stage/src/canvas-wire.ts +156 -156
  29. package/nexus-stage/src/edges.ts +72 -72
  30. package/nexus-stage/src/federation-overlay.ts +103 -103
  31. package/nexus-stage/src/federation.ts +240 -240
  32. package/nexus-stage/src/layout-doc.ts +143 -143
  33. package/nexus-stage/src/layout-wire.ts +158 -158
  34. package/nexus-stage/src/planning-card-text.ts +73 -0
  35. package/nexus-stage/src/planning-fit.ts +45 -0
  36. package/nexus-stage/src/planning-legend.ts +38 -0
  37. package/nexus-stage/src/planning-overlap.ts +134 -0
  38. package/nexus-stage/src/planning-relayout.ts +121 -0
  39. package/nexus-stage/src/planning-search.ts +168 -0
  40. package/nexus-stage/src/planning-view.ts +63 -88
  41. package/nexus-stage/src/planning.css +39 -8
  42. package/nexus-stage/src/seat.ts +35 -35
  43. package/nexus-stage/src/stage-name.ts +13 -13
  44. package/nexus-stage/src/tokens.ts +31 -31
  45. package/nexus-stage/src/viewport.ts +24 -1
  46. package/nexus-stage/src/wb-chat.ts +20 -6
  47. package/nexus-stage/src/wb-ledger.ts +20 -7
  48. package/nexus-stage/src/wb-names.ts +43 -0
  49. package/nexus-stage/src/workbench.css +8 -2
  50. package/nexus-stage/src/workbench.ts +55 -17
  51. package/nexus-stage/src/world.ts +5 -1
  52. package/package.json +216 -214
  53. package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
  54. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  55. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
@@ -36,11 +36,17 @@ import {
36
36
  type RemoteTeamRow,
37
37
  } from './federation'
38
38
  import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
39
+ import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
39
40
  import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
40
41
  import { applyAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
41
42
  import { FRAME_HUES } from './tokens'
42
43
  import { createPlanningMenus } from './planning-menus'
44
+ import { createNodeFlyer, mountBoardSearch } from './planning-search'
45
+ import { applyBoardRelayout } from './planning-relayout'
46
+ import { settleBoard, tidyMembers } from './planning-overlap'
43
47
  import { createNodeList } from './planning-nodelist'
48
+ import { cardText } from './planning-card-text'
49
+ import { createLegend } from './planning-legend'
44
50
  import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
45
51
  import './planning.css'
46
52
 
@@ -78,6 +84,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
78
84
  let renderedRevision = -1
79
85
  /** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
80
86
  let firstFitPending = true
87
+ let settledOnce = false
81
88
  let viewportAdopted = false
82
89
  const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
83
90
  const markDirty = (): void => {
@@ -151,11 +158,15 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
151
158
  b.addEventListener('click', onClick, signal)
152
159
  return b
153
160
  }
161
+ // Review 2026-10-27: the toolbar reads as three groups — view | arrange |
162
+ // network — split by separators; every action stays one click (owner orders).
163
+ const sep = (): HTMLElement => { const s = document.createElement('span'); s.className = 'p-sep'; s.setAttribute('role', 'separator'); s.setAttribute('aria-orientation', 'vertical'); return s }
154
164
  toolbar.append(
155
165
  mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
156
166
  mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
157
167
  zoomLabel,
158
168
  mkButton('+', '放大', () => zoomCenter(1.2)),
169
+ sep(),
159
170
  mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
160
171
  // gh#48 (owner order, review-seat spec): one-click tidy — inside every
161
172
  // team frame, the members that belong to EXACTLY that one team re-seat
@@ -165,6 +176,13 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
165
176
  // ride the same rule; the move persists through the ordinary layout
166
177
  // save loop exactly like a hand drag.
167
178
  mkButton('整理成员', '每队框内仅属一队的成员自动排齐(多队成员不动)', () => tidyTeamMembers()),
179
+ // Supervisor order v2 (upgrades gh#48): one-click WHOLE-BOARD relayout —
180
+ // the two buttons split the job (整理成员 = cards inside one frame;
181
+ // 一键重排 = every local frame AND its cards). Frame size derives from
182
+ // content (width and height both); frames tile in a row flow
183
+ // (constructively disjoint); multi-team members never enter the size
184
+ // math and never move; remote org frames and frameless nodes stay put.
185
+ mkButton('一键重排', '全板队框+成员重排:框尺寸随内容自适应、框位行流式平铺(远端队框与多归属成员不动)', () => relayoutBoard()),
168
186
  )
169
187
  // Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
170
188
  // migration made the planning view the join surface — without this, an
@@ -172,7 +190,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
172
190
  const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
173
191
  netBtn.className = 'p-netbtn'
174
192
  netBtn.setAttribute('aria-haspopup', 'menu')
175
- toolbar.appendChild(netBtn)
193
+ toolbar.append(sep(), netBtn)
194
+ const search = mountBoardSearch(toolbar, model, displayName, id => flyToNode(id))
176
195
  // Node list (network-membership-display): the canvas shows teamed nodes
177
196
  // only; every other session — teamless joined, unjoined, and each peer's
178
197
  // published nodes — is managed from this list, switched by host.
@@ -218,20 +237,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
218
237
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
219
238
  }, signal)
220
239
 
221
- const legend = document.createElement('div')
222
- legend.className = 'p-legend'
223
- const legendRow = (dashed: boolean, text: string, color?: string): void => {
224
- const row = document.createElement('span')
225
- const bar = document.createElement('i')
226
- if (dashed) bar.className = 'dashed'
227
- if (color !== undefined) bar.style.borderTopColor = color
228
- row.append(bar, document.createTextNode(text))
229
- legend.appendChild(row)
230
- }
231
- legendRow(false, '成员边(星形,框→成员)')
232
- legendRow(true, '跨队多属(虚线)')
233
- legendRow(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
234
- legendRow(true, '联邦线(→peer)', 'var(--federal)')
240
+ // Edge key: a closed-by-default fold (planning-legend), not a fixed panel.
241
+ const legend = createLegend()
235
242
 
236
243
  const noticeStack = document.createElement('div')
237
244
  noticeStack.className = 'p-notice-stack'
@@ -269,31 +276,22 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
269
276
  * — the same persistence path a hand drag takes.
270
277
  */
271
278
  function tidyTeamMembers(): void {
272
- const PAD = 28
273
- const GAP_X = 16
274
- const GAP_Y = 14
275
- for (const [name, rect] of model.allFrames()) {
276
- const members = model.teamMemberIds(name)
277
- const sole = members
278
- .map(id => model.getNode(id))
279
- .filter((n): n is NonNullable<typeof n> => n !== undefined)
280
- .filter(n => n.memberships.length === 1)
281
- if (sole.length === 0) continue // empty frame / only shared members: untouched
282
- const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
283
- const rows = Math.ceil(sole.length / perRow)
284
- const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
285
- if (needed > rect.h) {
286
- model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
287
- }
288
- const startX = rect.x + PAD + NODE_W / 2
289
- let y = rect.y + PAD + NODE_H / 2
290
- sole.forEach((n, i) => {
291
- const col = i % perRow
292
- if (i > 0 && col === 0) y += NODE_H + GAP_Y
293
- const x = startX + col * (NODE_W + GAP_X)
294
- model.dragNodes([n.id], x - n.x, y - n.y)
295
- })
296
- }
279
+ tidyMembers(model) // gh#48 grid (planning-overlap); then nothing may overlap
280
+ settleBoard(model, new Set(remoteFramesRef.keys()))
281
+ markDirty()
282
+ render()
283
+ }
284
+
285
+ /**
286
+ * Supervisor order v2: whole-board deterministic relayout (the planner
287
+ * and the model adapter live in planning-relayout.ts): LOCAL frames +
288
+ * sole members only — remote org frames keep gh#44 live-rect-wins,
289
+ * frameless nodes and multi-team members never move; the same
290
+ * markDirty()/render() persistence path a hand drag takes.
291
+ */
292
+ function relayoutBoard(): void {
293
+ if (!applyBoardRelayout(model, new Set(remoteFramesRef.keys()))) return
294
+ settleBoard(model, new Set(remoteFramesRef.keys())) // vs remote frames + stray cards
297
295
  markDirty()
298
296
  render()
299
297
  }
@@ -381,16 +379,6 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
381
379
  }
382
380
  }
383
381
 
384
- /** Remote card sub line: score + remote team count/names behind this peer. */
385
- function remoteSubText(host: string): string {
386
- const rows = lastRemoteTeams.get(host) ?? []
387
- const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
388
- const head = names.slice(0, 2).join('、')
389
- const more = names.length > 2 ? ` +${names.length - 2}` : ''
390
- const score = peerScoreByText.get(host)
391
- return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
392
- }
393
-
394
382
  /** The mono route line `<host>/canvas/<name>` when the payload carries it. */
395
383
  function routeText(name: string): string {
396
384
  const t = lastTeams.find(x => x.name === name)
@@ -431,25 +419,13 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
431
419
  el.classList.toggle('selected', model.isSelected(n.id))
432
420
  el.setAttribute('aria-selected', String(model.isSelected(n.id)))
433
421
  const host = n.id.slice('peer-'.length)
434
- // gh#38: member cards show their display NAME as the title (the whole
435
- // point is picking a person); peer HOST cards keep the host spelling.
436
- const nmText = n.id.startsWith('member-')
437
- ? (n.name !== undefined && n.name !== '' ? n.name : n.team)
438
- : n.remote === true ? host : displayName(n)
422
+ // Title / second line / tooltip rules: planning-card-text.
423
+ const txt = cardText(n, n.remote === true ? lastRemoteTeams.get(host) ?? [] : [], peerScoreByText.get(host))
439
424
  const nmEl = el.querySelector<HTMLElement>('.nm-text')!
440
- if (nmEl.textContent !== nmText) nmEl.textContent = nmText
425
+ if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
441
426
  const subEl = el.querySelector<HTMLElement>('.sub')!
442
- const subBase = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
443
- // When the title fell back to the team label the sub line would repeat
444
- // it verbatim; collapse to the cross-team suffix alone in that case.
445
- const subText = n.id.startsWith('member-')
446
- ? n.team
447
- : n.remote === true
448
- ? remoteSubText(host)
449
- : (subBase === nmText ? '' : subBase)
450
- + (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
451
- + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
452
- if (subEl.textContent !== subText) subEl.textContent = subText
427
+ if (subEl.textContent !== txt.sub) subEl.textContent = txt.sub
428
+ if (el.title !== txt.tip) el.title = txt.tip
453
429
  const prioEl = el.querySelector<HTMLElement>('.prio')!
454
430
  if (n.remote === true) {
455
431
  // gh#20 W-A: the peer badge — link class colors the chip (linked
@@ -470,6 +446,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
470
446
  for (const [id, el] of [...nodeEls]) {
471
447
  if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
472
448
  }
449
+ search.refresh()
473
450
  }
474
451
 
475
452
  // ── edges: stateless, rebuilt wholesale ──
@@ -515,6 +492,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
515
492
  markDirty()
516
493
  }
517
494
 
495
+ /** W1 pick: frame one node at ~100% and pulse its card. */
496
+ const flyToNode = createNodeFlyer({ model, viewSize: measure, setVp: next => { vp = next }, applyViewport, markDirty, cardEl: id => nodeEls.get(id) })
518
497
  // ── status counts ──
519
498
  function renderStatus(input: PlanningInput): void {
520
499
  const live = input.sessions.filter(s => s.live !== false).length
@@ -595,7 +574,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
595
574
  const saved = layoutDoc?.nodes[id]
596
575
  const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
597
576
  model.upsertNode({
598
- id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name,
577
+ id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name, description: s.description,
599
578
  live: s.live !== false, memberships: [],
600
579
  ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
601
580
  })
@@ -725,6 +704,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
725
704
  ev.preventDefault()
726
705
  return
727
706
  }
707
+ if (target !== null && target.closest('button, input, textarea, select, .p-toolbar, .p-search, .p-lamp, .p-status, .p-legend') !== null) return // HUD chrome owns its presses (no marquee / preventDefault: that stole the search box's focus — and, first, its native right-click menu)
728
708
  const p = localXY(ev)
729
709
  // 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
730
710
  // Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
@@ -735,7 +715,6 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
735
715
  try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
736
716
  return
737
717
  }
738
- if (target !== null && (target.closest('button') !== null || target.closest('.p-lamp') !== null || target.closest('.p-status') !== null)) return
739
718
  const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
740
719
  const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
741
720
  if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
@@ -930,10 +909,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
930
909
  for (const el of frameEls.values()) el.classList.remove('drop-target')
931
910
  gesture.el?.classList.remove('dragging')
932
911
  render()
933
- } else if (gesture.kind === 'frame') {
934
- if (gesture.moved) markDirty()
935
- } else if (gesture.kind === 'frame-resize') {
936
- if (gesture.moved) markDirty() // the resized rect persists with the layout
912
+ } else if (gesture.kind === 'frame' || gesture.kind === 'frame-resize') {
913
+ // The frame stays where it was put; neighbours and stray cards yield (planning-overlap).
914
+ if (gesture.moved) { settleBoard(model, new Set(remoteFramesRef.keys()), gesture.name); markDirty(); render() }
937
915
  } else if (gesture.kind === 'pan') {
938
916
  if (gesture.moved) markDirty() // the viewport persists with the layout
939
917
  } else if (gesture.kind === 'marquee') {
@@ -977,13 +955,14 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
977
955
 
978
956
  function fitToContent(): void {
979
957
  syncViewSize()
980
- vp = fitView(model.contentBounds(), viewW, viewH)
958
+ vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
981
959
  applyViewport()
982
960
  markDirty()
983
961
  }
984
962
 
985
963
  function key(ev: SeamKey): void {
986
- if (menus.isDialogOpen()) return // modal: the dialog input handles Enter/Esc itself
964
+ if (menus.isDialogOpen() || (ev.target as Element | null)?.closest?.('.p-search') !== null) return // modal / search box own their keys
965
+ if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k') { ev.preventDefault(); search.focus(); return } // W1 Ctrl+K
987
966
  if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
988
967
  if (menus.isMenuOpen()) {
989
968
  // The menu owns most keys (its own handler navigates); Escape still
@@ -1098,7 +1077,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1098
1077
  root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
1099
1078
  root.addEventListener('contextmenu', (ev) => {
1100
1079
  const e = ev as unknown as SeamPointer
1101
- e.preventDefault()
1080
+ if ((e.target as Element | null)?.closest?.('input, textarea, .p-search') != null) return // text fields keep the native menu (paste)
1102
1081
  // Right-drag pans (suppressNextMenu); a clean right-click opens the
1103
1082
  // menu programmatically at pointerup. Either way the native menu dies.
1104
1083
  e.preventDefault()
@@ -1174,7 +1153,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1174
1153
  if (model.getNode(s.id) !== undefined) {
1175
1154
  model.upsertNode({
1176
1155
  id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
1177
- label: s.label, team: s.team, name: s.name, live: s.live !== false,
1156
+ label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1178
1157
  memberships: effective,
1179
1158
  ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1180
1159
  })
@@ -1188,7 +1167,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1188
1167
  y = seat.y
1189
1168
  }
1190
1169
  model.upsertNode({
1191
- id: s.id, x, y, label: s.label, team: s.team, name: s.name, live: s.live !== false,
1170
+ id: s.id, x, y, label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1192
1171
  memberships: effective,
1193
1172
  ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1194
1173
  })
@@ -1389,21 +1368,17 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1389
1368
  // opened empty. Fit once, the first time there is something to frame.
1390
1369
  // Not marked dirty — nothing the user arranged changed; a saved layout
1391
1370
  // (adopted at any point) keeps its own viewport and cancels this.
1371
+ // Once per load, a board that arrives overlapped is shown settled (not
1372
+ // saved: no write without a user action; the next edit persists it).
1373
+ if (!settledOnce && gesture.kind === 'none' && model.nodeIds().length > 0) { settledOnce = true; settleBoard(model, new Set(remoteFramesRef.keys())) }
1392
1374
  if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
1393
1375
  syncViewSize()
1394
1376
  if (viewW > 0 && viewH > 0) {
1395
1377
  firstFitPending = false
1396
1378
  // Peer cards hang outside the local content bounds — frame them too,
1397
- // or the federation column opens clipped at the board's edge.
1398
- let bounds = model.contentBounds()
1399
- for (const n of model.allNodes()) {
1400
- if (n.remote !== true) continue
1401
- const r = nodeRect(n)
1402
- bounds = bounds === null
1403
- ? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
1404
- : { minX: Math.min(bounds.minX, r.x), minY: Math.min(bounds.minY, r.y), maxX: Math.max(bounds.maxX, r.x + r.w), maxY: Math.max(bounds.maxY, r.y + r.h) }
1405
- }
1406
- vp = fitView(bounds, viewW, viewH, 1) // frame, never magnify
1379
+ // or the federation column opens clipped at the board's edge. Same
1380
+ // framing as 适配 (planning-fit): ≤100%, below the HUD.
1381
+ vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
1407
1382
  }
1408
1383
  }
1409
1384
  render()
@@ -97,12 +97,31 @@
97
97
  #dsh-plan .p-node .nm{font-weight:600; display:flex; align-items:center; gap:7px;
98
98
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
99
99
  /* The name span clips with an ellipsis (min-width:0 lets it shrink inside
100
- the flex row) — without it a long peer address was hard-cut mid-digit. */
101
- #dsh-plan .p-node .nm-text{min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
100
+ the flex row) — without it a long peer address was hard-cut mid-digit.
101
+ 属主令 W2: TWO-LINE clamp for the name (long Chinese team titles read
102
+ fully); the card's 172x56 geometry is untouched — the clamp absorbs the
103
+ overflow. The handle line (.sub) stays single-line ellipsis below. */
104
+ #dsh-plan .p-node .nm-text{min-width:0; overflow:hidden; white-space:normal;
105
+ display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; line-height:1.25}
106
+ /* A peer address has no break point, so the two-line clamp clipped it to one
107
+ line under the badge; let it wrap anywhere onto the second line. */
108
+ #dsh-plan .p-node.remote .nm-text{overflow-wrap:anywhere}
102
109
  #dsh-plan .p-node .dot{width:8px; height:8px; border-radius:50%; background:var(--edge-live); flex:none}
103
110
  #dsh-plan .p-node.cold .dot{background:transparent; border:1.5px solid var(--edge-cold)}
104
111
  #dsh-plan .p-node .sub{font-size:11px; color:var(--ink-muted); margin-top:2px;
105
112
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
113
+ /* W1: a picked search hit pulses its card for 1.2s (class removed on timer). */
114
+ #dsh-plan .p-node.pulse{box-shadow:0 0 0 2px var(--accent), 0 0 14px 4px var(--uupm-wash-15); border-color:var(--accent)}
115
+ /* W1: toolbar search box + hit list. */
116
+ #dsh-plan .p-search{position:relative}
117
+ #dsh-plan .p-search-input{width:170px; min-height:28px; padding:2px 8px; font-size:12px;
118
+ background:transparent; color:var(--ink); border:1px solid var(--card-line); border-radius:6px}
119
+ #dsh-plan .p-search-input:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
120
+ #dsh-plan .p-search-list{position:absolute; top:100%; left:0; margin-top:4px; display:none;
121
+ min-width:240px; max-height:220px; overflow:auto; z-index:9;
122
+ background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:6px}
123
+ #dsh-plan .p-search-hit{padding:5px 10px; font-size:12px; color:var(--ink); cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
124
+ #dsh-plan .p-search-hit:hover, #dsh-plan .p-search-hit.active{background:var(--uupm-wash-15)}
106
125
  #dsh-plan .p-node .prio{position:absolute; top:6px; right:8px; font-size:10px; color:var(--ink-muted);
107
126
  border:1px solid var(--card-line); border-radius:4px; padding:0 5px}
108
127
 
@@ -126,6 +145,8 @@
126
145
  #dsh-plan .p-toolbar button:active{background:var(--uupm-wash-15)}
127
146
  #dsh-plan .p-toolbar button:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
128
147
  #dsh-plan .p-zoom{min-width:44px; text-align:center; font-size:11px; color:var(--ink-muted)}
148
+ /* Toolbar groups (view | arrange | network): a hairline between them. */
149
+ #dsh-plan .p-sep{align-self:stretch; width:1px; margin:2px 4px; background:var(--card-line)}
129
150
  #dsh-plan .p-lamp{display:flex; align-items:center; gap:6px; font-size:11px; color:var(--ink-muted); padding:0 6px}
130
151
  #dsh-plan .p-lamp i{width:7px; height:7px; border-radius:50%; background:var(--ink-muted); transition:background 140ms ease}
131
152
  #dsh-plan .p-lamp.saved i{background:var(--edge-live)}
@@ -140,8 +161,11 @@
140
161
  #dsh-plan .ok{color:var(--edge-live)} #dsh-plan .warn{color:var(--uupm-warn)} #dsh-plan .mut{color:var(--ink-muted)}
141
162
 
142
163
  #dsh-plan .p-legend{position:absolute; bottom:16px; left:16px; z-index:3; font-size:11px; color:var(--ink-muted);
143
- background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:8px; padding:8px 12px;
144
- display:grid; gap:4px}
164
+ background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:8px; padding:4px 10px}
165
+ #dsh-plan .p-legend summary{cursor:pointer; padding:2px 0; user-select:none}
166
+ #dsh-plan .p-legend summary:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
167
+ #dsh-plan .p-legend[open]{padding-bottom:8px}
168
+ #dsh-plan .p-legend-rows{display:grid; gap:4px; margin-top:4px}
145
169
  #dsh-plan .p-legend i{display:inline-block; width:18px; border-top:2px solid var(--uupm-edge-member);
146
170
  vertical-align:middle; margin-right:6px}
147
171
  #dsh-plan .p-legend i.dashed{border-top-style:dashed}
@@ -149,12 +173,19 @@
149
173
  /* ── narrow board (<680px): the HUD stacks instead of colliding ──
150
174
  The toolbar, status chip and legend are absolutely placed for a wide
151
175
  board; on a phone they overlapped each other and the toolbar's CJK
152
- labels wrapped one glyph per line. Toolbar scrolls sideways on one row,
153
- the status chip drops under it, the legend (reference, not state) hides. */
176
+ labels wrapped one glyph per line. The one-row sideways scroll that
177
+ followed hid the search box and half the actions off-screen (review
178
+ 2026-10-27), so the toolbar and status chip now flow at the top of the
179
+ board instead: the toolbar wraps, search first on its own row, and the
180
+ status chip sits under whatever height that takes. The legend (reference,
181
+ not state) hides. */
154
182
  @media (max-width:680px){
155
- #dsh-plan .p-toolbar{left:8px; right:8px; top:8px; overflow-x:auto; scrollbar-width:none}
183
+ #dsh-plan .p-toolbar{position:relative; top:auto; left:auto; margin:8px 8px 0; flex-wrap:wrap; row-gap:6px}
156
184
  #dsh-plan .p-toolbar > *{flex:none}
157
- #dsh-plan .p-status{left:8px; right:auto; top:58px; padding:6px 10px; gap:10px; flex-wrap:wrap}
185
+ #dsh-plan .p-toolbar .p-search{order:-1; flex:1 1 100%}
186
+ #dsh-plan .p-toolbar .p-sep{display:none}
187
+ #dsh-plan .p-toolbar .p-search-input{width:100%; box-sizing:border-box}
188
+ #dsh-plan .p-status{position:relative; top:auto; right:auto; left:auto; width:fit-content; margin:6px 8px 0; padding:6px 10px; gap:10px; flex-wrap:wrap}
158
189
  #dsh-plan .p-legend{display:none}
159
190
  }
160
191
  @media (pointer:coarse){
@@ -1,35 +1,35 @@
1
- /**
2
- * Deterministic fallback seating: a session id hashes to a stable polar
3
- * seat, so a reload reproduces the same star map even when no layout is
4
- * persisted (saved layouts still win - main.ts checks them first). This
5
- * replaces three Math.random() calls whose only product was a star map
6
- * that reshuffled on every reload, making screenshots and sessions
7
- * incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
8
- * dependencies so the unit test pins stability/bounds without three.
9
- * @module nexus-stage/seat
10
- */
11
-
12
- /** Radial distance bounds, mirroring the historical random-seat envelope. */
13
- export const SEAT_DIST_MIN = 12
14
- export const SEAT_DIST_MAX = 30
15
- /** Vertical jitter bound in world units above/below the grid plane. */
16
- export const SEAT_Y_SPAN = 1
17
-
18
- /** FNV-1a 32-bit - the same determinism family as getHue. */
19
- export function hashId(sid: string): number {
20
- let h = 0x811c9dc5
21
- for (let i = 0; i < sid.length; i++) {
22
- h ^= sid.charCodeAt(i)
23
- h = Math.imul(h, 0x01000193) >>> 0
24
- }
25
- return h >>> 0
26
- }
27
-
28
- /** Deterministic world seat (x, vertical jitter, z) for one session id. */
29
- export function seatFor(sid: string): { x: number; y: number; z: number } {
30
- const h = hashId(sid)
31
- const angle = ((h % 3600) / 3600) * Math.PI * 2
32
- const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
33
- const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
34
- return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
35
- }
1
+ /**
2
+ * Deterministic fallback seating: a session id hashes to a stable polar
3
+ * seat, so a reload reproduces the same star map even when no layout is
4
+ * persisted (saved layouts still win - main.ts checks them first). This
5
+ * replaces three Math.random() calls whose only product was a star map
6
+ * that reshuffled on every reload, making screenshots and sessions
7
+ * incomparable. Same hash family as getHue (FNV-1a 32-bit); zero
8
+ * dependencies so the unit test pins stability/bounds without three.
9
+ * @module nexus-stage/seat
10
+ */
11
+
12
+ /** Radial distance bounds, mirroring the historical random-seat envelope. */
13
+ export const SEAT_DIST_MIN = 12
14
+ export const SEAT_DIST_MAX = 30
15
+ /** Vertical jitter bound in world units above/below the grid plane. */
16
+ export const SEAT_Y_SPAN = 1
17
+
18
+ /** FNV-1a 32-bit - the same determinism family as getHue. */
19
+ export function hashId(sid: string): number {
20
+ let h = 0x811c9dc5
21
+ for (let i = 0; i < sid.length; i++) {
22
+ h ^= sid.charCodeAt(i)
23
+ h = Math.imul(h, 0x01000193) >>> 0
24
+ }
25
+ return h >>> 0
26
+ }
27
+
28
+ /** Deterministic world seat (x, vertical jitter, z) for one session id. */
29
+ export function seatFor(sid: string): { x: number; y: number; z: number } {
30
+ const h = hashId(sid)
31
+ const angle = ((h % 3600) / 3600) * Math.PI * 2
32
+ const dist = SEAT_DIST_MIN + (((h >>> 9) % 1800) / 1800) * (SEAT_DIST_MAX - SEAT_DIST_MIN)
33
+ const y = (((h >>> 18) % 2000) / 1999) * 2 * SEAT_Y_SPAN - SEAT_Y_SPAN
34
+ return { x: Math.cos(angle) * dist, y, z: Math.sin(angle) * dist }
35
+ }
@@ -1,13 +1,13 @@
1
- /**
2
- * Display name for one stage node: the session title when present, then the
3
- * routable team label (`team/<id8>`), then the host fallback label
4
- * (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
5
- * without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
6
- * user report: the planning canvas showed a dozen unreadable label cards).
7
- * @module nexus-stage/stage-name
8
- */
9
-
10
- /** One node's display title. */
11
- export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
12
- return node.name ?? node.team ?? node.label ?? ''
13
- }
1
+ /**
2
+ * Display name for one stage node: the session title when present, then the
3
+ * routable team label (`team/<id8>`), then the host fallback label
4
+ * (`dsh-host-<node>-<id8>`). Untitled cold joined sessions carry no title and
5
+ * without the middle step the canvas drowned in dsh-host-* noise (2026-08-30
6
+ * user report: the planning canvas showed a dozen unreadable label cards).
7
+ * @module nexus-stage/stage-name
8
+ */
9
+
10
+ /** One node's display title. */
11
+ export function displayName(node: { readonly name?: string; readonly team?: string; readonly label?: string }): string {
12
+ return node.name ?? node.team ?? node.label ?? ''
13
+ }
@@ -1,32 +1,32 @@
1
- /**
2
- * A2A Nexus design tokens.
3
- * Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
4
- */
5
-
6
- export const C = {
7
- bg0: '#060a12',
8
- bg1: '#0d1322',
9
- ink: '#dde3f0',
10
- inkMuted: '#7a86a0',
11
- accent: '#8b5cf6',
12
- liveGreen: '#4ade80',
13
- coldGrey: '#4a5568',
14
- lineFederal: '#6366f1',
15
- membershipLine: '#22d3ee',
16
- } as const;
17
-
18
- /**
19
- * Semantic layer: the scene-facing names the viewer actually consumes,
20
- * each pinned to its primitive above. This is the `S` main.ts reached for
21
- * as `T.S.*` — the layer was documented but never exported, so every seat
22
- * attempt threw and cycle()'s empty catch buried it: an eternally empty
23
- * stage behind a healthy-looking render loop.
24
- */
25
- export const S = {
26
- /** Mesh color + emissive for live session nodes (primitive liveGreen). */
27
- nodeLive: 0x4ade80,
28
- /** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
29
- nodeCold: 0x4a5568,
30
- } as const;
31
-
1
+ /**
2
+ * A2A Nexus design tokens.
3
+ * Three-layer: primitive(C) -> semantic(S) -> component-level inline styles.
4
+ */
5
+
6
+ export const C = {
7
+ bg0: '#060a12',
8
+ bg1: '#0d1322',
9
+ ink: '#dde3f0',
10
+ inkMuted: '#7a86a0',
11
+ accent: '#8b5cf6',
12
+ liveGreen: '#4ade80',
13
+ coldGrey: '#4a5568',
14
+ lineFederal: '#6366f1',
15
+ membershipLine: '#22d3ee',
16
+ } as const;
17
+
18
+ /**
19
+ * Semantic layer: the scene-facing names the viewer actually consumes,
20
+ * each pinned to its primitive above. This is the `S` main.ts reached for
21
+ * as `T.S.*` — the layer was documented but never exported, so every seat
22
+ * attempt threw and cycle()'s empty catch buried it: an eternally empty
23
+ * stage behind a healthy-looking render loop.
24
+ */
25
+ export const S = {
26
+ /** Mesh color + emissive for live session nodes (primitive liveGreen). */
27
+ nodeLive: 0x4ade80,
28
+ /** Mesh color + emissive for cold/joined-offline nodes (primitive coldGrey). */
29
+ nodeCold: 0x4a5568,
30
+ } as const;
31
+
32
32
  export const FRAME_HUES = [0x8b5cf6, 0x3b82f6, 0x14b8a6, 0xf59e0b, 0xec4899, 0x84cc16];
@@ -60,16 +60,39 @@ export function zoomAt(vp: LayoutViewport, factor: number, ax: number, ay: numbe
60
60
  /** Content bounds in world space (null = nothing to fit). */
61
61
  export interface WorldBounds { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
62
62
 
63
+ /** A fit never shrinks past a readable floor: below {@link NARROW_FIT_WIDTH}
64
+ * (the board's phone breakpoint) {@link NARROW_FIT_FLOOR}, at any wider
65
+ * viewport {@link WIDE_FIT_FLOOR}. Fitting a whole board drew 13px card
66
+ * names at ~7px on a phone, and a real 66-card board at 30% on a desktop
67
+ * (review 2026-10-27, real-host check). Past the floor the fit anchors the
68
+ * content's top-left corner instead of its centre; the rest is a pan away. */
69
+ export const NARROW_FIT_WIDTH = 680
70
+ export const NARROW_FIT_FLOOR = 0.8
71
+ export const WIDE_FIT_FLOOR = 0.6
72
+ const FIT_MARGIN = 16
73
+
63
74
  /**
64
75
  * Fit the content bounds into a viewW x viewH viewport at 90% fill,
65
76
  * clamped to the persisted scale envelope; empty bounds reset to identity.
77
+ * The scale is floored (see {@link WIDE_FIT_FLOOR}); an explicit maxScale
78
+ * below the floor still wins.
66
79
  */
67
80
  export function fitView(bounds: WorldBounds | null, viewW: number, viewH: number, maxScale = Number.POSITIVE_INFINITY): LayoutViewport {
68
81
  if (bounds === null || viewW <= 0 || viewH <= 0) return IDENTITY_VIEWPORT
69
82
  const bw = bounds.maxX - bounds.minX
70
83
  const bh = bounds.maxY - bounds.minY
71
84
  if (!(bw > 0) || !(bh > 0)) return IDENTITY_VIEWPORT
72
- const scale = clampScale(Math.min(maxScale, 0.9 * Math.min(viewW / bw, viewH / bh)))
85
+ // The floor is about content that does not FIT at a readable scale, so it
86
+ // is judged on the natural fit — a caller's maxScale cap below the floor
87
+ // must not turn a board that fits into a top-left-anchored one (PR #79 review).
88
+ const natural = 0.9 * Math.min(viewW / bw, viewH / bh)
89
+ const fitted = Math.min(maxScale, natural)
90
+ const floor = viewW < NARROW_FIT_WIDTH ? NARROW_FIT_FLOOR : WIDE_FIT_FLOOR
91
+ if (natural < floor) {
92
+ const scale = clampScale(Math.min(maxScale, floor))
93
+ return { x: bounds.minX - FIT_MARGIN / scale, y: bounds.minY - FIT_MARGIN / scale, scale }
94
+ }
95
+ const scale = clampScale(fitted)
73
96
  return {
74
97
  x: (bounds.minX + bounds.maxX) / 2 - viewW / (2 * scale),
75
98
  y: (bounds.minY + bounds.maxY) / 2 - viewH / (2 * scale),