@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.
- package/README.md +85 -14
- package/README_zh.md +81 -7
- package/assets/nexusDist/assets/index-BqepxJ7E.css +1 -0
- package/assets/nexusDist/assets/index-CTWH6QRh.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +624 -624
- package/cordis.patch.yml +21 -21
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +744 -0
- package/lib/index.js +139 -737
- package/lib/recursion-guard.js +78 -0
- package/lib/runtime.js +1 -0
- package/lib/skill/SKILL.md +34 -9
- package/lib/skill/references/cli.md +24 -7
- package/lib/skill/references/discovery.md +5 -2
- package/lib/skill/references/pitfalls.md +2 -1
- package/lib/skill/references/routing.md +18 -8
- package/lib/skill/references/setup.md +152 -0
- package/lib/task-ledger.js +6 -0
- package/lib/types/faces/admin.d.ts +132 -0
- package/lib/types/index.d.ts +11 -3
- package/lib/types/recursion-guard.d.ts +56 -0
- package/lib/types/runtime.d.ts +34 -0
- package/lib/types/task-ledger.d.ts +6 -0
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/src/canvas-ops.ts +175 -175
- package/nexus-stage/src/canvas-wire.ts +156 -156
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/federation-overlay.ts +103 -103
- package/nexus-stage/src/federation.ts +240 -240
- package/nexus-stage/src/layout-doc.ts +143 -143
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/planning-card-text.ts +73 -0
- package/nexus-stage/src/planning-fit.ts +45 -0
- package/nexus-stage/src/planning-legend.ts +38 -0
- package/nexus-stage/src/planning-overlap.ts +134 -0
- package/nexus-stage/src/planning-relayout.ts +121 -0
- package/nexus-stage/src/planning-search.ts +168 -0
- package/nexus-stage/src/planning-view.ts +63 -88
- package/nexus-stage/src/planning.css +39 -8
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/nexus-stage/src/viewport.ts +24 -1
- package/nexus-stage/src/wb-chat.ts +20 -6
- package/nexus-stage/src/wb-ledger.ts +20 -7
- package/nexus-stage/src/wb-names.ts +43 -0
- package/nexus-stage/src/workbench.css +8 -2
- package/nexus-stage/src/workbench.ts +55 -17
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +216 -214
- package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
- package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
- 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.
|
|
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
|
-
|
|
222
|
-
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
//
|
|
435
|
-
|
|
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 !==
|
|
425
|
+
if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
|
|
441
426
|
const subEl = el.querySelector<HTMLElement>('.sub')!
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
935
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
-
|
|
1399
|
-
|
|
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
|
-
|
|
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:
|
|
144
|
-
|
|
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.
|
|
153
|
-
the
|
|
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{
|
|
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-
|
|
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){
|
package/nexus-stage/src/seat.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|