@nelsonlongxiang/dsh-open-a2a-net 0.6.40 → 0.6.42

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 (76) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/a2a-client.js +3 -2
  7. package/lib/client.js +64 -64
  8. package/lib/client.js.map +1 -1
  9. package/lib/config.js +1 -0
  10. package/lib/faces/files.js +2 -2
  11. package/lib/faces/session-log.js +3 -3
  12. package/lib/faces/wire.js +11 -16
  13. package/lib/index.js +102 -18
  14. package/lib/link-keys.js +29 -0
  15. package/lib/origin-claim.js +4 -0
  16. package/lib/skill/SKILL.md +117 -117
  17. package/lib/skill/references/collaboration.md +428 -428
  18. package/lib/skill/references/discovery.md +78 -78
  19. package/lib/skill/references/intake.md +107 -107
  20. package/lib/skill/references/membership.md +73 -73
  21. package/lib/skill/references/pitfalls.md +51 -51
  22. package/lib/skill/references/router.md +96 -96
  23. package/lib/skill/references/routing.md +65 -65
  24. package/lib/skill/references/setup.md +5 -1
  25. package/lib/tools/files.js +3 -1
  26. package/lib/types/a2a-client.d.ts +3 -1
  27. package/lib/types/config.d.ts +6 -0
  28. package/lib/types/faces/files.d.ts +2 -1
  29. package/lib/types/faces/session-log.d.ts +2 -0
  30. package/lib/types/faces/wire.d.ts +2 -1
  31. package/lib/types/link-keys.d.ts +13 -0
  32. package/lib/types/origin-claim.d.ts +2 -0
  33. package/lib/types/tools/files.d.ts +1 -1
  34. package/nexus-stage/package.json +21 -21
  35. package/nexus-stage/scripts/mockhost.py +261 -261
  36. package/nexus-stage/src/canvas-ops.ts +191 -191
  37. package/nexus-stage/src/canvas-wire.ts +164 -164
  38. package/nexus-stage/src/cli-home-picker.ts +42 -42
  39. package/nexus-stage/src/fault.ts +56 -56
  40. package/nexus-stage/src/federation-overlay.ts +103 -103
  41. package/nexus-stage/src/federation.ts +241 -241
  42. package/nexus-stage/src/layout-doc.ts +145 -145
  43. package/nexus-stage/src/main.ts +513 -513
  44. package/nexus-stage/src/planning-card-text.ts +73 -73
  45. package/nexus-stage/src/planning-card.ts +63 -63
  46. package/nexus-stage/src/planning-edges.ts +26 -26
  47. package/nexus-stage/src/planning-fit.ts +45 -45
  48. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  49. package/nexus-stage/src/planning-legend.ts +39 -39
  50. package/nexus-stage/src/planning-member-handles.ts +61 -61
  51. package/nexus-stage/src/planning-menus.ts +422 -422
  52. package/nexus-stage/src/planning-mirror.ts +75 -75
  53. package/nexus-stage/src/planning-nodelist.ts +294 -294
  54. package/nexus-stage/src/planning-overlap.ts +159 -159
  55. package/nexus-stage/src/planning-reachability.ts +28 -28
  56. package/nexus-stage/src/planning-relayout.ts +121 -121
  57. package/nexus-stage/src/planning-roster.ts +15 -15
  58. package/nexus-stage/src/planning-search.ts +168 -168
  59. package/nexus-stage/src/planning-types.ts +115 -115
  60. package/nexus-stage/src/planning-undo.ts +68 -68
  61. package/nexus-stage/src/planning-view.ts +1499 -1499
  62. package/nexus-stage/src/planning.css +340 -340
  63. package/nexus-stage/src/seat-name-editor.ts +31 -31
  64. package/nexus-stage/src/session-drag.ts +64 -64
  65. package/nexus-stage/src/viewport.ts +106 -106
  66. package/nexus-stage/src/wb-chat.ts +684 -684
  67. package/nexus-stage/src/wb-directory.ts +237 -237
  68. package/nexus-stage/src/wb-ledger.ts +279 -279
  69. package/nexus-stage/src/wb-names.ts +43 -43
  70. package/nexus-stage/src/wb-rail.ts +147 -147
  71. package/nexus-stage/src/workbench.css +388 -388
  72. package/nexus-stage/src/workbench.ts +1272 -1272
  73. package/nexus-stage/src/world.ts +509 -509
  74. package/nexus-stage/tsconfig.json +24 -24
  75. package/nexus-stage/vite.config.ts +15 -15
  76. package/package.json +1 -1
@@ -1,1499 +1,1499 @@
1
- /**
2
- * The 2D planning-mode view: one section#dsh-plan holding a screen-fixed
3
- * dot-grid layer, the transformed world container (SVG star edges + team
4
- * frames + node cards), a marquee, and the HUD (toolbar / tabs host /
5
- * status / legend). This is the ONLY new DOM module; everything spatial
6
- * lives in the pure modules (viewport/world/edges/layout-doc).
7
- *
8
- * Reconcile discipline:
9
- * - the WorldModel persists across polls and is maintained incrementally
10
- * (upsert refreshes rendered attributes, never positions - a poll must
11
- * not snap a dragged card back to its last-saved spot);
12
- * - card/frame elements are keyed-diffed so focus, the pickup transition,
13
- * and an in-flight pointer capture survive a poll; SVG edges carry no
14
- * state and are rebuilt wholesale;
15
- * - the federation overlay (peer cards + activity/federal edges) renders
16
- * on every render, OUTSIDE the revision gate, so inFlight ages tick per
17
- * poll and the badge cards track the local content bounds;
18
- * - XSS: session labels/teams are attacker-shaped data and land only via
19
- * createElement + textContent.
20
- *
21
- * Gestures (Figma-style ruling): left-blank drag = marquee; pan = Space+drag
22
- * or middle-drag or Shift+wheel; wheel (with or without Ctrl) =
23
- * pointer-anchored zoom (owner ruling 2026-09-01).
24
- * Tests drive the same handlers production binds through `seam`.
25
- * @module nexus-stage/planning-view
26
- */
27
-
28
- import type { LampState } from './layout-wire'
29
- import { buildLayoutDoc, clampDoc, type LayoutDoc, type LayoutRect } from './layout-doc'
30
- import { FRAME_HEAD_LIFT } from './edges'
31
- import { drawMemberEdges } from './planning-edges'
32
- import { createFederationOverlay } from './federation-overlay'
33
- import { joinedLocalIdsByHandle, membershipIndex, mirrorMemberIds, remoteMemberBoardId } from './planning-member-handles'
34
- import { attachSessionDrop } from './session-drag'
35
- import { displayName } from './stage-name'
36
- import {
37
- activityEdges, federalEdges, flyBounds, groupRemoteTeams, peerNodeId, peerHostOf, placePeers,
38
- type FrameAnchor, type InFlightRow, type PeerPlacement, type PeerRow,
39
- type RemoteTeamRow,
40
- } from './federation'
41
- import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
42
- import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
43
- import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
44
- import { applyAction, settleAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
45
- import { frameHue } from './planning-frame-hue'
46
- import { rosterFrameTitle } from './planning-roster'
47
- import { createPlanningMenus } from './planning-menus'
48
- import { createNodeFlyer, mountBoardSearch } from './planning-search'
49
- import { applyBoardRelayout } from './planning-relayout'
50
- import { createDissolveGuard, mirrorRoute } from './planning-mirror'
51
- import { settleBoard, tidyMembers } from './planning-overlap'
52
- import { createLayoutUndo, type LayoutSnap } from './planning-undo'
53
- import { createNodeList } from './planning-nodelist'
54
- import { cardText } from './planning-card-text'
55
- import { createCardElement, paintPrio, workLabel } from './planning-card'
56
- import { createLegend } from './planning-legend'
57
- import { UNREACHABLE_HINT, showFrameReachability, unreachableRefs } from './planning-reachability'
58
- import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
59
- import './planning.css'
60
-
61
- export { viewportBucket } from './planning-types'
62
- export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
63
-
64
-
65
- type Gesture =
66
- | { readonly kind: 'none' }
67
- | { readonly kind: 'node'; ids: readonly string[]; clicked: string; rosterOrigin?: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
68
- | { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
69
- | { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
70
- | { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
71
- | { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
72
-
73
- // The card skeleton, its 工作中/空闲 chip and the right-hand chip live in planning-card (module-size cap).
74
- export { workLabel } from './planning-card'
75
-
76
- export function createPlanningView(deps: PlanningDeps): PlanningView {
77
- const model = new WorldModel()
78
- let layoutDoc: LayoutDoc | undefined
79
- let vp: LayoutViewport = { ...model.viewport }
80
- let gesture: Gesture = { kind: 'none' }
81
- let spaceDown = false
82
- let renderedRevision = -1
83
- /** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
84
- let firstFitPending = true
85
- let settledOnce = false
86
- const layoutUndo = createLayoutUndo(model) // 撤销 (planning-undo)
87
- let undoBefore: LayoutSnap | undefined
88
- let viewportAdopted = false
89
- const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
90
- const markDirty = (): void => {
91
- deps.viewportStore?.set(bucket(), vp)
92
- deps.onDirty()
93
- }
94
- const clock = deps.now ?? Date.now
95
- const ac = new AbortController()
96
- const noticeTimers: Array<ReturnType<typeof setTimeout>> = []
97
- /** Active right-button press: hold-drag pans; a clean release opens the
98
- * context menu (the contextmenu event itself is always suppressed). */
99
- let rightPress: { x: number; y: number; target: Element | null; moved: boolean } | null = null
100
- let suppressNextMenu = false
101
- const signal = { signal: ac.signal } as AddEventListenerOptions
102
-
103
- // ── DOM shell (built once) ──
104
- const root = document.createElement('section')
105
- root.id = 'dsh-plan'
106
- root.setAttribute('role', 'tabpanel')
107
- root.setAttribute('aria-label', 'A2A 规划画布')
108
- root.tabIndex = 0
109
- root.style.display = 'none'
110
-
111
- const grid = document.createElement('div')
112
- grid.className = 'p-grid'
113
-
114
- const world = document.createElement('div')
115
- world.className = 'p-world'
116
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
117
- svg.setAttribute('class', 'p-edges')
118
- // NOTE: the svg is intentionally NOT aria-hidden — its .edge-label texts
119
- // carry the route ages that reduced-motion users rely on.
120
- // Stable layers: frames must ALWAYS paint (and lose hit-testing) below
121
- // cards, regardless of which keyed diff created its element first — a
122
- // frame created after the cards used to sit on top of them and swallow
123
- // every real-pointer drag inside the team (synthetic tests bypassed it
124
- // by targeting cards directly).
125
- const frameLayer = document.createElement('div')
126
- frameLayer.className = 'p-layer p-frames'
127
- /** The selected frame keeps its resize handles visible without hover. */
128
- let selectedFrame: string | undefined
129
- const markSelectedFrame = (name: string | undefined): void => {
130
- if (selectedFrame !== undefined) {
131
- frameEls.get(selectedFrame)?.classList.remove('selected')
132
- }
133
- selectedFrame = name
134
- if (name !== undefined) {
135
- frameEls.get(name)?.classList.add('selected')
136
- }
137
- }
138
- const nodeLayer = document.createElement('div')
139
- nodeLayer.className = 'p-layer p-nodes'
140
- nodeLayer.setAttribute('role', 'listbox')
141
- nodeLayer.setAttribute('aria-multiselectable', 'true')
142
- nodeLayer.setAttribute('aria-label', '会话节点')
143
- world.append(svg, frameLayer, nodeLayer)
144
-
145
- const marquee = document.createElement('div')
146
- marquee.className = 'p-marquee'
147
-
148
- const toolbar = document.createElement('div')
149
- toolbar.className = 'p-toolbar'
150
- const zoomLabel = document.createElement('span')
151
- zoomLabel.className = 'p-zoom mono'
152
- zoomLabel.textContent = '100%'
153
- const mkButton = (text: string, title: string, onClick: () => void): HTMLButtonElement => {
154
- const b = document.createElement('button')
155
- b.type = 'button'
156
- b.textContent = text
157
- b.title = title
158
- b.addEventListener('click', onClick, signal)
159
- return b
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 }
164
- toolbar.append(
165
- mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
166
- mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
167
- zoomLabel,
168
- mkButton('+', '放大', () => zoomCenter(1.2)),
169
- sep(),
170
- mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
171
- // gh#48 (owner order, review-seat spec): one-click tidy — inside every
172
- // team frame, the members that belong to EXACTLY that one team re-seat
173
- // into a padded grid flow (the frame grows downward, bounded); members
174
- // of 2+ teams keep their positions (the dashed edges express their
175
- // membership). Idempotent; empty frames untouched; remote member cards
176
- // ride the same rule; the move persists through the ordinary layout
177
- // save loop exactly like a hand drag.
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()),
186
- mkButton('撤销', '撤销上一次布局改动:拖动、缩放、整理、重排、载入时避让(Ctrl+Z;入队离队不撤销)', () => undoLayout()),
187
- )
188
- // Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
189
- // migration made the planning view the join surface — without this, an
190
- // unjoined session had no GUI path into the mesh at all.
191
- const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
192
- netBtn.className = 'p-netbtn'
193
- netBtn.setAttribute('aria-haspopup', 'menu')
194
- toolbar.append(sep(), netBtn)
195
- const search = mountBoardSearch(toolbar, model, displayName, id => flyToNode(id))
196
- // Node list (network-membership-display): the canvas shows teamed nodes
197
- // only; every other session — teamless joined, unjoined, and each peer's
198
- // published nodes — is managed from this list, switched by host.
199
- const nodesBtn = mkButton('节点', '节点清单:本机与对等节点(未组队/未入网在此管理)', () => nodes.toggle())
200
- nodesBtn.className = 'p-nodebtn'
201
- nodesBtn.setAttribute('aria-haspopup', 'dialog')
202
- toolbar.appendChild(nodesBtn)
203
- const lamp = document.createElement('span')
204
- lamp.setAttribute('aria-live', 'polite')
205
- lamp.className = 'p-lamp'
206
- const lampDot = document.createElement('i')
207
- const lampText = document.createElement('span')
208
- lampText.textContent = '布局'
209
- lamp.append(lampDot, lampText)
210
- lamp.addEventListener('click', () => { if (lamp.classList.contains('error')) deps.onLampClick() }, signal)
211
- lamp.addEventListener('keydown', (ev) => {
212
- const e = ev as KeyboardEvent
213
- if ((e.key === 'Enter' || e.key === ' ') && lamp.classList.contains('error')) {
214
- e.preventDefault()
215
- deps.onLampClick()
216
- }
217
- }, signal)
218
- toolbar.appendChild(lamp)
219
-
220
- const status = document.createElement('div')
221
- status.className = 'p-status'
222
- const statusLive = document.createElement('span')
223
- const statusCold = document.createElement('span')
224
- const statusAct = document.createElement('span')
225
- statusAct.className = 'mut'
226
- const statusMut = document.createElement('span')
227
- statusMut.className = 'mut'
228
- status.append(statusLive, statusCold, statusAct, statusMut)
229
- // Fly control (PR D): the whole statusbar fits the frames ∪ peers region,
230
- // keyboard-parity via Enter/Space with a focus ring (a11y parity with 0).
231
- status.tabIndex = 0
232
- status.setAttribute('role', 'button')
233
- status.setAttribute('aria-label', '定位在途路由与联邦对端')
234
- status.title = '定位在途与联邦(回车)'
235
- status.addEventListener('click', () => flyToFederation(), signal)
236
- status.addEventListener('keydown', (ev) => {
237
- const e = ev as KeyboardEvent
238
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
239
- }, signal)
240
-
241
- // Edge key: a closed-by-default fold (planning-legend), not a fixed panel.
242
- const legend = createLegend()
243
-
244
- const noticeStack = document.createElement('div')
245
- noticeStack.className = 'p-notice-stack'
246
- noticeStack.setAttribute('role', 'log')
247
- noticeStack.setAttribute('aria-live', 'polite')
248
-
249
- root.append(grid, world, marquee, toolbar, status, legend, noticeStack)
250
-
251
- // ── viewport application ──
252
- let viewW = 0
253
- let viewH = 0
254
- const measure = (): { w: number; h: number } =>
255
- deps.viewSize ? deps.viewSize() : { w: root.clientWidth, h: root.clientHeight }
256
- const syncViewSize = (): void => {
257
- const size = measure()
258
- viewW = size.w
259
- viewH = size.h
260
- }
261
-
262
- function applyViewport(): void {
263
- world.style.transform = worldTransformCss(vp)
264
- const step = 24 * vp.scale
265
- grid.style.backgroundSize = `${step}px ${step}px`
266
- grid.style.backgroundPosition = `${-vp.x * vp.scale}px ${-vp.y * vp.scale}px`
267
- zoomLabel.textContent = `${Math.round(vp.scale * 100)}%`
268
- }
269
-
270
- /**
271
- * gh#48 (owner order): one-click member tidy. For every team frame, the
272
- * members belonging to EXACTLY that one team re-seat into a padded grid
273
- * flow inside the frame (the frame grows downward if the members need
274
- * more rows — bounded); members of 2+ teams stay put (their dashed edges
275
- * express membership). Remote member cards (member-) are ordinary board
276
- * cards and ride the same rule. The move goes through markDirty()/render()
277
- * — the same persistence path a hand drag takes.
278
- */
279
- function tidyTeamMembers(): void {
280
- const before = layoutUndo.capture()
281
- tidyMembers(model) // gh#48 grid (planning-overlap); then nothing may overlap
282
- settleBoard(model, new Set(remoteFramesRef.keys()))
283
- layoutUndo.commit(before)
284
- markDirty()
285
- render()
286
- }
287
-
288
- /**
289
- * Supervisor order v2: whole-board deterministic relayout (the planner
290
- * and the model adapter live in planning-relayout.ts): LOCAL frames +
291
- * sole members only — remote org frames keep gh#44 live-rect-wins,
292
- * frameless nodes and multi-team members never move; the same
293
- * markDirty()/render() persistence path a hand drag takes.
294
- */
295
- function relayoutBoard(): void {
296
- const before = layoutUndo.capture()
297
- if (!applyBoardRelayout(model, new Set(remoteFramesRef.keys()))) return
298
- settleBoard(model, new Set(remoteFramesRef.keys())) // vs remote frames + stray cards
299
- layoutUndo.commit(before)
300
- markDirty()
301
- render()
302
- }
303
-
304
- function render(): void {
305
- applyViewport()
306
- if (model.revision !== renderedRevision) {
307
- renderedRevision = model.revision
308
- renderFrames()
309
- renderNodes()
310
- drawMemberEdges(svg, model) // planning-edges
311
- }
312
- renderFederation()
313
- }
314
-
315
- // ── keyed diff: frames ──
316
- // Element maps instead of selector lookups: keyed identity for focus and
317
- // transitions, no CSS.escape dependency, O(1) per row.
318
- const frameEls = new Map<string, HTMLElement>()
319
- const nodeEls = new Map<string, HTMLElement>()
320
-
321
- function renderFrames(): void {
322
- const seen = new Set<string>()
323
- for (const [name, rect] of model.allFrames()) {
324
- seen.add(name)
325
- let el = frameEls.get(name)
326
- if (el === undefined) {
327
- el = document.createElement('div')
328
- el.className = 'p-frame'
329
- el.dataset.name = name
330
- const head = document.createElement('div')
331
- head.className = 'p-frame-head'
332
- head.dataset.frame = name
333
- head.tabIndex = 0 // keyboard: Shift+F10 menu, Delete 散队
334
- head.setAttribute('role', 'button')
335
- head.setAttribute('aria-haspopup', 'menu')
336
- head.setAttribute('aria-label', `团队框 ${name}(拖动整组,回车菜单)`)
337
- const title = document.createElement('span')
338
- title.className = 'ttl'
339
- const cnt = document.createElement('span')
340
- cnt.className = 'cnt mono'
341
- const route = document.createElement('span')
342
- route.className = 'route mono'
343
- head.append(title, cnt, route)
344
- el.appendChild(head)
345
- // Resize handles (owner ruling: the frame canvas is stretchable and
346
- // adjustable): eight directions, pointer seam routes them to the
347
- // frame-resize gesture. Purely visual affordance + hit targets.
348
- for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
349
- const handle = document.createElement('div')
350
- handle.className = `p-frame-handle p-h-${dir}`
351
- handle.dataset.frame = name
352
- handle.dataset.dir = dir
353
- el.appendChild(handle)
354
- }
355
- frameLayer.appendChild(el)
356
- frameEls.set(name, el)
357
- }
358
- const hue = frameHue(name)
359
- el.style.setProperty('--frame-line', hue.line)
360
- el.style.setProperty('--frame-bg', hue.bg)
361
- el.style.left = `${rect.x}px`
362
- el.style.top = `${rect.y}px`
363
- el.style.width = `${rect.w}px`
364
- el.style.height = `${rect.h}px`
365
- const title = el.querySelector<HTMLElement>('.ttl')!
366
- const cnt = el.querySelector<HTMLElement>('.cnt')!
367
- const route = el.querySelector<HTMLElement>('.route')!
368
- const isRoster = rosterFramesRef.has(name)
369
- const shownName = isRoster ? rosterFrameTitle(name) : name
370
- if (title.textContent !== shownName) title.textContent = shownName
371
- el.classList.toggle('is-roster', isRoster)
372
- let members = 0
373
- let cross = 0
374
- for (const m of model.allNodes()) {
375
- const mem = m.memberships.find(x => x.team === name)
376
- if (mem === undefined) continue
377
- members += 1
378
- if (m.memberships.length > 1) cross += 1
379
- }
380
- const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
381
- if (cnt.textContent !== cntText) cnt.textContent = cntText
382
- const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
383
- const unavailable = mirror !== undefined && reachability.hosts.has(peerHostOf(remoteFramesRef.get(name)!))
384
- const routeText0 = mirror?.text ?? (lastTeams.find(x => x.name === name)?.team ?? name)
385
- if (route.textContent !== routeText0) route.textContent = routeText0
386
- route.title = `${mirror?.title ?? ''}${unavailable ? ` · ${UNREACHABLE_HINT}` : ''}`
387
- showFrameReachability(el, unavailable)
388
- el.classList.toggle('is-mirror', mirror !== undefined)
389
- el.classList.toggle('is-syncing', mirror?.syncing === true)
390
- const label = `团队框 ${name}${mirror !== undefined ? `(${mirror.text})` : ''}${unavailable ? `(${UNREACHABLE_HINT})` : ''}(拖动整组,回车菜单)`
391
- const headEl = el.querySelector('.p-frame-head')!
392
- if (headEl.getAttribute('aria-label') !== label) headEl.setAttribute('aria-label', label) // a rewrite re-announces
393
- }
394
- for (const [name, el] of [...frameEls]) {
395
- if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
396
- }
397
- }
398
-
399
- // ── keyed diff: nodes ──
400
- function renderNodes(): void {
401
- const seen = new Set<string>()
402
- for (const n of model.allNodes()) {
403
- seen.add(n.id)
404
- let el = nodeEls.get(n.id)
405
- if (el === undefined) {
406
- el = createCardElement(n.id)
407
- nodeLayer.appendChild(el)
408
- nodeEls.set(n.id, el)
409
- }
410
- const r = nodeRect(n)
411
- el.style.left = `${r.x}px`
412
- el.style.top = `${r.y}px`
413
- el.classList.toggle('cold', !n.live)
414
- el.classList.toggle('remote', n.remote === true)
415
- // 工作中/空闲: live local sessions only; the host reports nothing for cold or remote nodes.
416
- const work = n.live && n.remote !== true ? lastWork.get(n.id) : undefined
417
- el.classList.toggle('is-working', work?.status === 'running')
418
- const workText = workLabel(work)
419
- const workEl = el.querySelector<HTMLElement>('.work')!
420
- if (workEl.textContent !== workText) workEl.textContent = workText
421
- el.classList.toggle('selected', model.isSelected(n.id))
422
- el.setAttribute('aria-selected', String(model.isSelected(n.id)))
423
- const host = n.id.slice('peer-'.length)
424
- // Title / second line / tooltip rules: planning-card-text.
425
- const txt = cardText(n, n.remote === true ? lastRemoteTeams.get(host) ?? [] : [], peerScoreByText.get(host))
426
- const unavailable = n.remote === true && (n.id.startsWith('member-') ? reachability.members.has(n.id) : reachability.hosts.has(host))
427
- el.classList.toggle('is-unreachable', unavailable)
428
- const nmEl = el.querySelector<HTMLElement>('.nm-text')!
429
- if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
430
- const subEl = el.querySelector<HTMLElement>('.sub')!
431
- const subText = unavailable ? `未应答 · ${txt.sub}` : txt.sub
432
- if (subEl.textContent !== subText) subEl.textContent = subText
433
- const tip = unavailable ? `${txt.tip} · ${UNREACHABLE_HINT}` : txt.tip
434
- if (el.title !== tip) el.title = tip
435
- // A member card's chip follows the link of the host that publishes its handle.
436
- const linkHost = n.id.startsWith('member-') ? memberHostByHandle.get(n.remoteHandle ?? '') : host
437
- paintPrio(el.querySelector<HTMLElement>('.prio')!, n, n.remote === true && linkHost !== undefined ? peerInfoByHost.get(linkHost) : undefined)
438
- }
439
- for (const [id, el] of [...nodeEls]) {
440
- if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
441
- }
442
- search.refresh()
443
- }
444
-
445
- // ── federation overlay (PR D): peer cards' activity/federal edges ──
446
- // Runs on EVERY render, outside the model-revision gate: the poll's
447
- // inFlight ages must tick even when the model is unchanged, and the
448
- // badge column tracks the content bounds each reconcile. Its SVG
449
- // children are tracked separately because drawMemberEdges clears the svg
450
- // wholesale - this appends after it (render() orders the two).
451
- // gh#49: the DOM drawing lives in federation-overlay.ts (module-size
452
- // ruling); this view keeps only the seams (facts + fly).
453
- const federation = createFederationOverlay(svg, model)
454
- let lastPeers: ReadonlyArray<PeerRow> = []
455
- let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
456
- let lastWork: ReadonlyMap<string, Pick<PlanningSession, 'status' | 'queued'>> = new Map()
457
- const peerScoreByText = new Map<string, number | undefined>()
458
- /** gh#20 W-A: per-host link/verified info for the peer badges. */
459
- const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
460
- let memberHostByHandle: ReadonlyMap<string, string> = new Map()
461
-
462
- function renderFederation(): void {
463
- federation.render(clock())
464
- }
465
-
466
- /** Statusbar fly: fit the frames ∪ peers region (flyBounds pads it). */
467
- function flyToFederation(): void {
468
- vp = fitView(flyBounds(model.contentBounds(), federation.placements()), root.clientWidth || viewW, root.clientHeight || viewH)
469
- applyViewport()
470
- markDirty()
471
- }
472
-
473
- /** W1 pick: frame one node at ~100% and pulse its card. */
474
- const flyToNode = createNodeFlyer({ model, viewSize: measure, setVp: next => { vp = next }, applyViewport, markDirty, cardEl: id => nodeEls.get(id) })
475
- // ── status counts ──
476
- function renderStatus(input: PlanningInput): void {
477
- const live = input.sessions.filter(s => s.live !== false).length
478
- const dot = (host: HTMLElement, cls: string, text: string): void => {
479
- const b = document.createElement('b')
480
- b.className = cls
481
- b.textContent = text
482
- host.replaceChildren(b)
483
- }
484
- dot(statusLive, 'ok', `● ${live} live`)
485
- dot(statusCold, 'warn', `● ${input.sessions.length - live} cold`)
486
- statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
487
- statusMut.className = 'mut'
488
- statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
489
- }
490
-
491
- // ── canvas write actions: optimistic apply + team-scoped undo ──
492
- // Per-team REFERENCE COUNT: two queued actions on one team keep the guard
493
- // up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
494
- // the second action's optimistic membership).
495
- const pendingTeams = new Map<string, number>()
496
- // Each change re-renders frames: the mirror 「同步中…」 marker reads pendingTeams, not the model revision.
497
- const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1); renderedRevision = -1 }
498
- const pendingRelease = (team: string): void => {
499
- renderedRevision = -1
500
- const n = (pendingTeams.get(team) ?? 0) - 1
501
- if (n <= 0) pendingTeams.delete(team)
502
- else pendingTeams.set(team, n)
503
- }
504
- // gh#41: remote-frame registry (frame name → peer URL), refreshed by
505
- // reconcile and read by emitAction's serverAction to fork remote drops.
506
- let remoteFramesRef = new Map<string, string>()
507
- let rosterFramesRef = new Set<string>()
508
- let reachability = unreachableRefs({})
509
- const dissolving = createDissolveGuard(deps.now)
510
-
511
- function emitAction(a: CanvasAction): void {
512
- const roster = (a.type === 'create-team' && a.roster === true) || rosterFramesRef.has(actionTeam(a))
513
- if (roster && a.type === 'remove-team' && !window.confirm(`解散网络队「${rosterFrameTitle(a.name)}」并撤回成员与白名单?`)) return
514
- if (roster && a.type === 'reorder') { notice('error', 'roster 队不支持置顶路由'); return }
515
- if (roster && (a.type === 'create-team' || a.type === 'add-member') && a.ids.some(id => model.getNode(id)?.remote === true || !lastSessions.some(s => s.id === id && s.joined === true))) {
516
- notice('error', 'roster 只接受本机已入网会话,远端或未入网成员未写入'); return
517
- }
518
- // gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
519
- // keeps its peer-<host> node id — the canvas member contract is
520
- // session-id-or-remote-ref. Translation happens here, one boundary;
521
- // the model action keeps board-local ids so rendering and undo stay
522
- // node-keyed. A peer without a known handle (no remote team rows yet)
523
- // is filtered out — the server would refuse the raw peer- id.
524
- function serverAction(act: CanvasAction): CanvasAction {
525
- const mapIds = (ids: readonly string[]): string[] => {
526
- const out: string[] = []
527
- for (const id of ids) {
528
- // Both remote card families translate to their zone/id8 handle
529
- // (gh#38: member- cards join teams like peer- cards do).
530
- if (!id.startsWith('peer-') && !id.startsWith('member-')) { out.push(id); continue }
531
- const handle = model.getNode(id)?.remoteHandle
532
- if (handle !== undefined && handle !== '') out.push(handle)
533
- }
534
- return out
535
- }
536
- if (act.type === 'add-member' || act.type === 'remove-member' || act.type === 'create-team') {
537
- const ids = mapIds(act.ids)
538
- // gh#41: a drop into a REMOTE frame carries the peer URL — main.ts
539
- // forks it to the fleet relay instead of the local canvas control.
540
- const fleetPeer = act.type !== 'create-team' ? remoteFramesRef.get(act.team) : undefined
541
- return ids.length > 0 || fleetPeer !== undefined
542
- ? { ...act, ids, ...(fleetPeer !== undefined ? { fleetPeer } : {}) }
543
- : act
544
- }
545
- // Dissolving a mirror frame is the owner's write as well (relay op 'remove').
546
- return act.type === 'remove-team' && remoteFramesRef.has(act.name) ? dissolving.fork(act, remoteFramesRef.get(act.name)!) : act
547
- }
548
-
549
- // Optimistic seats: a write may reference a session the teamed gate
550
- // keeps OFF the model (no canvas card yet). The write actions read and
551
- // write memberships through the node table, so materialize those ids
552
- // from the last poll's inventory first — the next reconcile refreshes
553
- // label/live/teams and keeps the saved-or-seat position.
554
- if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
555
- const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
556
- for (const id of ids) {
557
- if (id === '' || id.startsWith('peer-') || id.startsWith('member-')) continue
558
- if (model.getNode(id) !== undefined) continue
559
- const s = lastSessions.find(row => row.id === id)
560
- if (s === undefined) continue
561
- const saved = layoutDoc?.nodes[id]
562
- const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
563
- model.upsertNode({
564
- id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name, description: s.description,
565
- live: s.live !== false, memberships: [],
566
- ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
567
- })
568
- }
569
- }
570
- if (roster && a.type === 'create-team') rosterFramesRef.add(a.name)
571
- const undo = applyAction(model, a)
572
- const team = actionTeam(a)
573
- pendingAdd(team)
574
- render()
575
- const sent = serverAction(roster ? { ...a, roster: true } : a)
576
- void deps.onCanvasAction(sent).then(
577
- ok => {
578
- pendingRelease(team)
579
- if (!ok && a.type === 'create-team' && roster) rosterFramesRef.delete(team)
580
- layoutDoc = settleAction(model, a, ok, undo, layoutDoc); markDirty(); dissolving.settled(sent, ok)
581
- render()
582
- },
583
- () => {
584
- // Contract-robustness: a rejecting action channel must not pin the
585
- // team's guard (and its optimistic state) forever — and per §3.4
586
- // 不吞错, the rollback surfaces to the user.
587
- pendingRelease(team)
588
- if (a.type === 'create-team' && roster) rosterFramesRef.delete(team)
589
- layoutDoc = settleAction(model, a, false, undo, layoutDoc); markDirty(); dissolving.settled(sent, false)
590
- notice('error', '操作通道异常,已回滚')
591
- render()
592
- },
593
- )
594
- }
595
-
596
- // ── notices (host errors verbatim; design.md §3.4 不吞错) ──
597
- function notice(kind: 'error' | 'info', text: string): void {
598
- while (noticeStack.childElementCount >= 3) noticeStack.firstElementChild?.remove()
599
- const item = document.createElement('div')
600
- item.className = 'p-notice' + (kind === 'error' ? ' error' : '')
601
- item.setAttribute('role', kind === 'error' ? 'alert' : 'status')
602
- item.textContent = text
603
- noticeStack.appendChild(item)
604
- noticeTimers.push(setTimeout(() => item.remove(), 4000))
605
- }
606
-
607
- // ── popovers: 建队 dialog, context menu, 未入网 dropdown (planning-menus) ──
608
- const menus = createPlanningMenus({
609
- root, netBtn, signal, model,
610
- teams: () => lastTeams,
611
- joined: id => lastSessions.some(s => s.id === id && s.joined === true),
612
- isRoster: name => rosterFramesRef.has(name),
613
- anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
614
- emit: emitAction,
615
- notice,
616
- })
617
-
618
- // ── 节点列表 drawer (planning-nodelist) ──
619
- let lastSessions: ReadonlyArray<PlanningSession> = []
620
- let lastCanvasMemberIds: ReadonlySet<string> = new Set()
621
- const nodes = createNodeList({
622
- root, trigger: nodesBtn, signal, model,
623
- facts: () => ({ sessions: lastSessions, canvasMemberIds: lastCanvasMemberIds, remoteTeams: lastRemoteTeams, teams: lastTeams }),
624
- menuItem: menus.menuItem,
625
- emit: emitAction,
626
- openNameDialog: menus.openNameDialog,
627
- locate: locateNode,
628
- })
629
- // A rail 本机会话 row dropped on a frame joins that team, like a card drop (session-drag).
630
- attachSessionDrop(root, {
631
- signal, frames: () => frameEls,
632
- frameAt: (clientX, clientY) => { const p = localXY({ clientX, clientY }); return innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y)) },
633
- drop: (team, id) => model.getNode(id)?.memberships.some(m => m.team === team) === true ? notice('info', '该会话已在这个队里') : emitAction({ type: 'add-member', team, ids: [id] }),
634
- })
635
-
636
- /** Center the viewport on one canvas node (the 已组队 row's 定位). */
637
- function locateNode(id: string): void {
638
- const n = model.getNode(id)
639
- if (n === undefined) return
640
- nodes.close()
641
- const size = deps.viewSize?.() ?? { w: root.clientWidth > 0 ? root.clientWidth : 800, h: root.clientHeight > 0 ? root.clientHeight : 600 }
642
- vp = clampViewport({ scale: vp.scale, x: n.x - size.w / (2 * vp.scale), y: n.y - size.h / (2 * vp.scale) })
643
- applyViewport()
644
- renderedRevision = -1
645
- render()
646
- }
647
-
648
- // ── gestures ──
649
- function localXY(ev: { clientX: number; clientY: number }): { x: number; y: number } {
650
- const rect = root.getBoundingClientRect()
651
- return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }
652
- }
653
-
654
- /** Drop dispatch for a moved node drag: 入队 / 队内 y 排序. Leaving a team
655
- * is an explicit menu action only — dragging out to blank is a position
656
- * move (owner removal 2026-09-02: the drag-out-to-blank 离队 path is
657
- * removed; accidental blank drops wrote whole-group removals). */
658
- function dropDispatch(gesture: Extract<Gesture, { kind: 'node' }>, ev: SeamPointer): void {
659
- const p = localXY(ev)
660
- const w = screenToWorld(vp, p.x, p.y)
661
- const f = innermostFrameAt(new Map(model.allFrames()), w)
662
- // gh#20 W-B: peer cards join canvas teams too — a mixed local+remote
663
- // drop composes a cross-host team; the server boundary translates
664
- // peer ids to zone/id8 handles (emitAction's serverAction). Reorder
665
- // stays local-only (roster priority is a local-session fact).
666
- const memberIds = gesture.ids.filter(id => {
667
- const node = model.getNode(id)
668
- if (node === undefined) return false
669
- if (node.remote !== true) return true
670
- return node.remoteHandle !== undefined && node.remoteHandle !== ''
671
- })
672
- if (f !== undefined) {
673
- const fresh = memberIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
674
- if (fresh.length > 0) {
675
- emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
676
- return
677
- }
678
- // Reorder only for LOCAL single-card drops: dropping a peer card is a
679
- // position move, never a roster write (P2 from the incremental seat).
680
- if (gesture.ids.length === 1 && model.getNode(gesture.clicked)?.remote !== true) {
681
- // Already a member: an in-frame drop sorts that frame's members by y.
682
- const current = model.teamMemberIds(f)
683
- const desired = yOrderedMembers(model, f)
684
- if (desired.join('\u0000') !== current.join('\u0000')) {
685
- emitAction({ type: 'reorder', team: f, ops: reorderOps(current, desired) })
686
- }
687
- }
688
- }
689
- // Canvas blank drops move only; leaving a roster frame explicitly retracts those declarations.
690
- if (gesture.rosterOrigin !== undefined && f !== gesture.rosterOrigin) {
691
- const leaving = gesture.ids.filter(id => model.teamMemberIds(gesture.rosterOrigin!).includes(id))
692
- if (leaving.length > 0) emitAction({ type: 'remove-member', team: gesture.rosterOrigin, ids: leaving })
693
- }
694
- }
695
-
696
- function pointerDown(ev: SeamPointer): void {
697
- if (viewW === 0) syncViewSize()
698
- if (menus.isDialogOpen()) { ev.preventDefault(); return } // modal: no gestures behind the dialog
699
- const target = ev.target as Element | null
700
- // Context-menu dismissal: the first pointerdown outside an open menu
701
- // closes it and is swallowed; a press on the menu's own chrome is
702
- // swallowed too — only its items act.
703
- if (menus.isMenuOpen()) {
704
- if (target !== null && target.closest('.p-menu') !== null) return
705
- menus.closeMenu()
706
- ev.preventDefault()
707
- return
708
- }
709
- 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)
710
- undoBefore = layoutUndo.capture() // committed at pointerUp only if this press moved something
711
- const p = localXY(ev)
712
- // 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
713
- // Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
714
- // a clean press falls through to the context menu at pointerup.
715
- if (ev.button === 2) {
716
- rightPress = { x: p.x, y: p.y, target: ev.target, moved: false }
717
- ev.preventDefault()
718
- try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
719
- return
720
- }
721
- const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
722
- const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
723
- if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
724
- // Middle-button anywhere — including on cards and frame heads — pans
725
- // (baf51a1 claimed this; the guards used to swallow it before the pan
726
- // branch was reachable, and skipped preventDefault, re-enabling
727
- // native autoscroll).
728
- gesture = { kind: 'pan', last: p, moved: false }
729
- ev.preventDefault()
730
- try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
731
- return
732
- }
733
- const handleEl = target !== null ? target.closest<HTMLElement>('.p-frame-handle') : null
734
- // Selection clearing: any press NOT on the selected frame's own chrome
735
- // (head/handle/body) drops the frame selection.
736
- if (selectedFrame !== undefined) {
737
- const selFrameEl = frameEls.get(selectedFrame)
738
- const onSelectedChrome = target !== null && selFrameEl !== undefined &&
739
- (selFrameEl.contains(target) || selFrameEl === target)
740
- const onSelectedHead = headEl !== null && headEl.dataset.frame === selectedFrame
741
- if (!onSelectedChrome && !onSelectedHead) markSelectedFrame(undefined)
742
- }
743
- if (handleEl !== null && ev.button === 0) {
744
- // Frame resize (owner ruling): record the frame's current rect and
745
- // the world-space origin; the move branch re-solves the rect per
746
- // direction with a minimum size clamp.
747
- const name = handleEl.dataset.frame ?? ''
748
- markSelectedFrame(name)
749
- const rect = model.getFrame(name)
750
- if (rect === undefined) return
751
- gesture = { kind: 'frame-resize', name, dir: handleEl.dataset.dir ?? 'se', startRect: { ...rect }, origin: screenToWorld(vp, p.x, p.y), moved: false }
752
- ev.preventDefault()
753
- try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
754
- return
755
- }
756
- if (nodeEl !== null) {
757
- if (ev.button !== 0) return // right on a card: contextmenu only, never a card drag (F3)
758
- const id = nodeEl.dataset.id ?? ''
759
- const already = model.isSelected(id)
760
- if (!already && !ev.shiftKey) model.setSelection([id])
761
- const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
762
- const origin = innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y))
763
- gesture = { kind: 'node', clicked: id, ids, ...(origin && rosterFramesRef.has(origin) && model.teamMemberIds(origin).includes(id) ? { rosterOrigin: origin } : {}), last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
764
- nodeEl.classList.add('dragging')
765
- } else if (headEl !== null) {
766
- if (ev.button !== 0) return
767
- const name = headEl.dataset.frame ?? ''
768
- // Selecting a frame keeps its resize handles visible (hover-only
769
- // handles vanished the moment the pointer left).
770
- markSelectedFrame(name)
771
- const snap = model.beginFrameDrag(name)
772
- if (snap === undefined) return
773
- gesture = { kind: 'frame', name, snap, origin: screenToWorld(vp, p.x, p.y), moved: false }
774
- } else {
775
- if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
776
- gesture = { kind: 'pan', last: p, moved: false }
777
- } else if (ev.button === 0) {
778
- gesture = { kind: 'marquee', origin: p, last: p, additive: ev.shiftKey }
779
- marquee.style.display = 'block'
780
- marquee.style.left = `${p.x}px`
781
- marquee.style.top = `${p.y}px`
782
- marquee.style.width = '0px'
783
- marquee.style.height = '0px'
784
- } else return
785
- }
786
- ev.preventDefault()
787
- try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
788
- }
789
-
790
- function pointerMove(ev: SeamPointer): void {
791
- const p = localXY(ev)
792
- if (rightPress !== null) {
793
- // Right-hold pan: incremental viewport follow once past the threshold.
794
- // User ruling (2026-08-30): the drag moves the CONTENT, not the window —
795
- // drag right and the canvas follows the cursor (Figma convention), so
796
- // the viewport origin moves OPPOSITE the pointer delta. The wheel and
797
- // middle/space pans keep their own established directions.
798
- const dx = p.x - rightPress.x
799
- const dy = p.y - rightPress.y
800
- if (Math.hypot(dx, dy) > 4) {
801
- rightPress.moved = true
802
- vp = clampViewport(panBy(vp, -dx, -dy))
803
- rightPress.x = p.x
804
- rightPress.y = p.y
805
- applyViewport()
806
- }
807
- return
808
- }
809
- if (gesture.kind === 'node') {
810
- const w = screenToWorld(vp, p.x, p.y)
811
- const dx = w.x - gesture.last.x
812
- const dy = w.y - gesture.last.y
813
- if (dx === 0 && dy === 0) return
814
- gesture.moved = true
815
- gesture.last = w
816
- model.dragNodes(gesture.ids, dx, dy)
817
- // Drop-target highlight: the innermost frame under the pointer, but
818
- // only when the drop would change something (some id not yet member).
819
- const f = innermostFrameAt(new Map(model.allFrames()), w)
820
- // W-B: peers with a remote handle are droppable members too.
821
- const joinables = gesture.ids.filter(id => {
822
- const node = model.getNode(id)
823
- if (node === undefined) return false
824
- return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
825
- })
826
- const relevant = f !== undefined && joinables.length > 0
827
- for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
828
- render()
829
- } else if (gesture.kind === 'frame') {
830
- const w = screenToWorld(vp, p.x, p.y)
831
- const dx = w.x - gesture.origin.x
832
- const dy = w.y - gesture.origin.y
833
- if (!gesture.moved && dx === 0 && dy === 0) return
834
- gesture.moved = true
835
- model.applyFrameDrag(gesture.snap, dx, dy)
836
- render()
837
- } else if (gesture.kind === 'frame-resize') {
838
- // Re-solve the rect from the start rect plus the world delta, per
839
- // direction. Minimum 220x120 keeps the titlebar and成员 badges
840
- // readable; the frame rect is independent of member positions, so
841
- // no node moves — star edges follow on the next render.
842
- const w = screenToWorld(vp, p.x, p.y)
843
- const dx = w.x - gesture.origin.x
844
- const dy = w.y - gesture.origin.y
845
- if (!gesture.moved && dx === 0 && dy === 0) return
846
- gesture.moved = true
847
- const MIN_W = 220
848
- const MIN_H = 120
849
- const r = { ...gesture.startRect }
850
- let { x, y, w: width, h: height } = r
851
- if (gesture.dir.includes('e')) width = Math.max(MIN_W, r.w + dx)
852
- if (gesture.dir.includes('s')) height = Math.max(MIN_H, r.h + dy)
853
- if (gesture.dir.includes('w')) {
854
- width = Math.max(MIN_W, r.w - dx)
855
- x = r.x + (r.w - width)
856
- }
857
- if (gesture.dir.includes('n')) {
858
- height = Math.max(MIN_H, r.h - dy)
859
- y = r.y + (r.h - height)
860
- }
861
- model.setFrame(gesture.name, { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height) })
862
- render()
863
- } else if (gesture.kind === 'pan') {
864
- vp = panBy(vp, p.x - gesture.last.x, p.y - gesture.last.y)
865
- gesture.last = p
866
- gesture.moved = true
867
- applyViewport()
868
- } else if (gesture.kind === 'marquee') {
869
- const x = Math.min(gesture.origin.x, p.x)
870
- const y = Math.min(gesture.origin.y, p.y)
871
- marquee.style.left = `${x}px`
872
- marquee.style.top = `${y}px`
873
- marquee.style.width = `${Math.abs(p.x - gesture.origin.x)}px`
874
- marquee.style.height = `${Math.abs(p.y - gesture.origin.y)}px`
875
- gesture.last = p
876
- }
877
- }
878
-
879
- function pointerUp(ev: SeamPointer): void {
880
- if (rightPress !== null) {
881
- const wasPan = rightPress.moved
882
- const pressTarget = rightPress.target
883
- const x = rightPress.x
884
- const y = rightPress.y
885
- rightPress = null
886
- if (wasPan) {
887
- suppressNextMenu = true
888
- markDirty() // the viewport persists with the layout
889
- } else if (pressTarget !== null) {
890
- menus.contextMenuAt(x, y, pressTarget) // clean right-click: context menu
891
- }
892
- return
893
- }
894
- if (gesture.kind === 'node') {
895
- const id = gesture.clicked
896
- if (!gesture.moved) {
897
- // A click (no drag) is pure selection — NEVER a drop. The F1 fix:
898
- // an unmoved multi-select click used to fall through to dropDispatch
899
- // and write an entire group's 离队 to the host.
900
- if (ev.shiftKey) {
901
- const current = model.selectedIds()
902
- model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
903
- } else {
904
- model.setSelection([id])
905
- // 0.6.13: a clean single click is also a node pick — the host opens
906
- // the chat drawer. Shift-click (multi-select) stays a pure selection.
907
- deps.onNodePick?.(id)
908
- }
909
- } else {
910
- dropDispatch(gesture, ev)
911
- markDirty()
912
- }
913
- for (const el of frameEls.values()) el.classList.remove('drop-target')
914
- gesture.el?.classList.remove('dragging')
915
- render()
916
- } else if (gesture.kind === 'frame' || gesture.kind === 'frame-resize') {
917
- // The frame stays where it was put; neighbours and stray cards yield (planning-overlap).
918
- if (gesture.moved) { settleBoard(model, new Set(remoteFramesRef.keys()), gesture.name); markDirty(); render() }
919
- } else if (gesture.kind === 'pan') {
920
- if (gesture.moved) markDirty() // the viewport persists with the layout
921
- } else if (gesture.kind === 'marquee') {
922
- const p = gesture.last
923
- const a = screenToWorld(vp, Math.min(gesture.origin.x, p.x), Math.min(gesture.origin.y, p.y))
924
- const b = screenToWorld(vp, Math.max(gesture.origin.x, p.x), Math.max(gesture.origin.y, p.y))
925
- model.marqueeSelect({ x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }, gesture.additive)
926
- marquee.style.display = 'none'
927
- render()
928
- }
929
- gesture = { kind: 'none' }
930
- if (undoBefore !== undefined) { layoutUndo.commit(undoBefore); undoBefore = undefined }
931
- try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
932
- }
933
-
934
- /** The OS took the pointer (touch scroll, system gesture): abandon the press — no drop, no menu, layout back. */
935
- function pointerCancel(ev: SeamPointer): void {
936
- for (const el of frameEls.values()) el.classList.remove('drop-target')
937
- if (gesture.kind === 'node') gesture.el?.classList.remove('dragging')
938
- const panned = (gesture.kind === 'pan' && gesture.moved) || rightPress?.moved === true // right-drag pans live in rightPress
939
- marquee.style.display = 'none'
940
- rightPress = null
941
- gesture = { kind: 'none' }
942
- if (undoBefore !== undefined && layoutUndo.revert(undoBefore)) render()
943
- if (panned) markDirty() // the pan stays (it is the view, not the layout) and persists like a finished one
944
- undoBefore = undefined
945
- try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
946
- }
947
-
948
- function wheel(ev: SeamPointer): void {
949
- if (menus.isMenuOpen()) menus.closeMenu()
950
- const p = localXY(ev)
951
- // Wheel = pointer-anchored zoom (owner ruling 2026-09-01: 鼠标滚动 =
952
- // 放大缩小), with or without Ctrl — both muscle memories land on the
953
- // same action. Shift+wheel keeps the natural-scroll pan as the escape
954
- // hatch. Line-mode wheels (Firefox, deltaMode=1) report ~3 per notch.
955
- if (ev.shiftKey && !ev.ctrlKey) {
956
- const line = ev.deltaMode === 1 ? 16 : 1
957
- vp = clampViewport(panBy(vp, (ev.deltaX ?? 0) * line, (ev.deltaY ?? 0) * line))
958
- } else {
959
- const line = ev.deltaMode === 1 ? 16 : 1
960
- const factor = Math.exp(-((ev.deltaY ?? 0) * line) * 0.0015)
961
- vp = zoomAt(vp, factor, p.x, p.y)
962
- }
963
- ev.preventDefault()
964
- applyViewport()
965
- markDirty()
966
- }
967
-
968
- function zoomCenter(factor: number): void {
969
- syncViewSize()
970
- vp = zoomAt(vp, factor, viewW / 2, viewH / 2)
971
- applyViewport()
972
- markDirty()
973
- }
974
-
975
- function undoLayout(): void {
976
- if (layoutUndo.undo()) { markDirty(); render() } else notice('info', '没有可撤销的布局改动')
977
- }
978
-
979
- function fitToContent(): void {
980
- syncViewSize()
981
- vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
982
- applyViewport()
983
- markDirty()
984
- }
985
-
986
- function key(ev: SeamKey): void {
987
- if (menus.isDialogOpen() || (ev.target as Element | null)?.closest?.('.p-search') !== null) return // modal / search box own their keys
988
- if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k') { ev.preventDefault(); search.focus(); return } // W1 Ctrl+K
989
- if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey && ev.key.toLowerCase() === 'z') { ev.preventDefault(); undoLayout(); return }
990
- if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
991
- if (menus.isMenuOpen()) {
992
- // The menu owns most keys (its own handler navigates); Escape still
993
- // closes from here so a seam/keyboard Esc never gets lost.
994
- if (ev.key === 'Escape') { menus.closeMenu(); ev.preventDefault() }
995
- return
996
- }
997
- if (ev.key === ' ' && ev.target === root) { spaceDown = true; ev.preventDefault(); return }
998
- // 键盘全等路径(WCAG 2.2 Dragging Movements):
999
- const headTarget = (ev.target as Element | null)?.closest?.('.p-frame-head') ?? null
1000
- if ((ev.key === 'Enter' || ev.key === ' ') && headTarget !== null) {
1001
- ev.preventDefault()
1002
- const r = headTarget.getBoundingClientRect()
1003
- menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, headTarget)
1004
- return
1005
- }
1006
- if (ev.key === ' ' && (ev.target as Element | null)?.classList?.contains('p-node')) {
1007
- const el = ev.target as HTMLElement
1008
- const id = el.dataset.id ?? ''
1009
- const current = model.selectedIds()
1010
- model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
1011
- render()
1012
- ev.preventDefault()
1013
- return
1014
- }
1015
- if ((ev.key === 'F10' && ev.shiftKey) || ev.key === 'ContextMenu') {
1016
- ev.preventDefault()
1017
- const fe = (document.activeElement as Element | null) ?? null
1018
- const r = fe?.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 }
1019
- menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, fe)
1020
- return
1021
- }
1022
- if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteFocused(); return }
1023
- if (ev.altKey && (ev.key === 'ArrowUp' || ev.key === 'ArrowDown')) { ev.preventDefault(); altMove(ev.key === 'ArrowUp'); return }
1024
- if (ev.key === 'g' || ev.key === 'G') { menus.startCreateFromSelection(); return }
1025
- if (ev.key === '0') { fitToContent(); return }
1026
- if (ev.key === '=' || ev.key === '+') { zoomCenter(1.2); return }
1027
- if (ev.key === '-') { zoomCenter(1 / 1.2); return }
1028
- if (ev.key === 'Escape') {
1029
- if (menus.isMenuOpen()) { menus.closeMenu(); render(); return } // menu > clear selection
1030
- model.setSelection([])
1031
- render()
1032
- return
1033
- }
1034
- const step = ev.shiftKey ? 40 : 8
1035
- const dxdy: Record<string, [number, number]> = {
1036
- ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step],
1037
- }
1038
- const d = dxdy[ev.key]
1039
- if (d !== undefined) {
1040
- ev.preventDefault()
1041
- const ids = model.selectedIds()
1042
- if (ids.length > 0) {
1043
- const before = layoutUndo.capture()
1044
- model.nudge(ids, d[0]!, d[1]!)
1045
- layoutUndo.commit(before)
1046
- render()
1047
- markDirty()
1048
- }
1049
- }
1050
- }
1051
-
1052
- /** Delete 按焦点对象:单队卡=离队、多队卡=下钻离队菜单、无队=info、框头=散队。 */
1053
- function deleteFocused(): void {
1054
- const fe = document.activeElement as HTMLElement | null
1055
- const headEl = fe?.closest?.<HTMLElement>('.p-frame-head') ?? null
1056
- const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
1057
- if (headEl !== null) {
1058
- emitAction({ type: 'remove-team', name: headEl.dataset.frame ?? '' })
1059
- return
1060
- }
1061
- if (nodeEl !== null) {
1062
- const id = nodeEl.dataset.id ?? ''
1063
- const teams = model.getNode(id)?.memberships.map(m => m.team) ?? []
1064
- if (teams.length === 0) { notice('info', '该节点未加入任何团队'); return }
1065
- if (teams.length === 1) {
1066
- emitAction({ type: 'remove-member', team: teams[0]!, ids: [id] })
1067
- return
1068
- }
1069
- menus.openMenu(window.innerWidth / 2, window.innerHeight / 2, { kind: 'node', id }, 'leave', nodeEl)
1070
- return
1071
- }
1072
- notice('info', '焦点不在节点或团队框上')
1073
- }
1074
-
1075
- /** Alt+↑/↓: move the focused card within its single team's priority. */
1076
- function altMove(up: boolean): void {
1077
- const fe = document.activeElement as HTMLElement | null
1078
- const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
1079
- if (nodeEl === null) return
1080
- const id = nodeEl.dataset.id ?? ''
1081
- const mem = model.getNode(id)?.memberships ?? []
1082
- if (mem.length !== 1) return
1083
- const team = mem[0]!.team
1084
- const current = model.teamMemberIds(team)
1085
- const idx = current.indexOf(id)
1086
- const to = up ? Math.max(0, idx - 1) : Math.min(current.length - 1, idx + 1)
1087
- if (to === idx) return
1088
- const desired = [...current]
1089
- desired.splice(idx, 1)
1090
- desired.splice(to, 0, id)
1091
- emitAction({ type: 'reorder', team, ops: reorderOps(current, desired) })
1092
- }
1093
-
1094
- function keyUp(ev: SeamKey): void {
1095
- if (ev.key === ' ') spaceDown = false
1096
- }
1097
-
1098
- root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
1099
- root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
1100
- root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
1101
- root.addEventListener('pointercancel', (ev) => pointerCancel(ev as unknown as SeamPointer), signal)
1102
- root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
1103
- root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
1104
- root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
1105
- root.addEventListener('contextmenu', (ev) => {
1106
- const e = ev as unknown as SeamPointer
1107
- if ((e.target as Element | null)?.closest?.('input, textarea, .p-search') != null) return // text fields keep the native menu (paste)
1108
- // Right-drag pans (suppressNextMenu); a clean right-click opens the
1109
- // menu programmatically at pointerup. Either way the native menu dies.
1110
- e.preventDefault()
1111
- if (suppressNextMenu) { suppressNextMenu = false; return }
1112
- menus.contextMenuAt(e.clientX, e.clientY, e.target)
1113
- }, signal)
1114
-
1115
- // ── poll reconcile: incremental, never repositions existing nodes ──
1116
- let lastTeams: ReadonlyArray<PlanningTeam> = []
1117
-
1118
- function reconcile(input: PlanningInput): void {
1119
- // gh#41: remote-frame registry shared with serverAction (drop fork).
1120
- lastTeams = input.teams
1121
- rosterFramesRef = new Set([...input.teams.filter(t => t.kind === 'roster').map(t => t.name), ...[...rosterFramesRef].filter(n => pendingTeams.has(n))])
1122
- lastPeers = input.peers ?? []
1123
- federation.setInFlight(input.inFlight ?? [])
1124
- // Unjoined sessions have no node on the canvas — the panel-slim
1125
- // migration made this view the join surface, so they ride a compact
1126
- // toolbar chip instead of vanishing entirely.
1127
- lastSessions = input.sessions
1128
- menus.setUnjoined(input.sessions.filter(s => s.joined !== true && !s.id.startsWith('peer-')))
1129
- const memberships = membershipIndex(input.teams)
1130
- lastCanvasMemberIds = new Set(memberships.keys())
1131
- const seen = new Set<string>()
1132
- // In-flight write guard: membership entries of teams with unsettled
1133
- // writes come from the model, not the (stale) payload — a poll racing
1134
- // an optimistic add must not snap the new edge away.
1135
- const resolveMemberships = (id: string): Array<{ team: string; index: number }> => {
1136
- const payload = memberships.get(id) ?? []
1137
- if (pendingTeams.size === 0) return payload
1138
- const keep = payload.filter(m => !pendingTeams.has(m.team))
1139
- // In-flight teams read their in-membership from the team roster
1140
- // (teamMemberIds), NOT from getNode(id).memberships: an optimistic
1141
- // add can target a node the teamed gate kept OFF the model (no card
1142
- // yet) — its membership lives only in the roster map, and dropping
1143
- // it here would pop the node right back off the canvas mid-flight.
1144
- const pending: Array<{ team: string; index: number }> = []
1145
- for (const [team] of pendingTeams) {
1146
- const roster = model.teamMemberIds(team)
1147
- const index = roster.indexOf(id)
1148
- if (index >= 0) pending.push({ team, index })
1149
- }
1150
- return [...keep, ...pending]
1151
- }
1152
- // First build with nothing saved: seat the fleet team by team (see
1153
- // planTeamClusters). Later arrivals, or any board with a saved layout,
1154
- // keep the ring fallback.
1155
- let clusterSeats: Map<string, { x: number; y: number }> | undefined
1156
- if (layoutDoc === undefined && !model.allNodes().some(n => n.remote !== true)) {
1157
- const seatIds = input.sessions
1158
- .filter(s => s.joined === true && (resolveMemberships(s.id).length > 0 || (s.teams?.length ?? 0) > 0))
1159
- .map(s => s.id)
1160
- syncViewSize()
1161
- clusterSeats = planTeamClusters(input.teams, seatIds, viewW > 0 ? viewW : undefined)
1162
- }
1163
- // A joined session whose only team is a peer's (a mirror frame) is teamed too: without its
1164
- // card the mirror frame cannot show it, and 离队 has nothing to reach (owner report 2026-11-04).
1165
- const mirrorMembers = input.peerOrg !== undefined ? mirrorMemberIds(input.peerOrg, input.sessions, input.remoteTeams) : new Set<string>()
1166
- // The node list reads the same fact: such a session is teamed, not 未组队.
1167
- if (input.peerOrg !== undefined) lastCanvasMemberIds = new Set([...lastCanvasMemberIds, ...mirrorMembers])
1168
- for (const s of input.sessions) {
1169
- if (s.joined !== true) continue
1170
- // Teamed-only canvas (network-membership-display ruling): a joined
1171
- // session earns a canvas node only with a team — canvas membership
1172
- // (optimistic-aware), a declared roster team, or a mirror frame's
1173
- // membership. Teamless joined sessions ride the node list's 未组队
1174
- // group instead; sessions absent from `seen` are swept from the model below.
1175
- const effective = resolveMemberships(s.id)
1176
- if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0) && !mirrorMembers.has(s.id)) continue
1177
- seen.add(s.id)
1178
- if (model.getNode(s.id) !== undefined) {
1179
- model.upsertNode({
1180
- id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
1181
- label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1182
- memberships: effective,
1183
- ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1184
- })
1185
- } else {
1186
- const saved = layoutDoc?.nodes[s.id]
1187
- let x: number
1188
- let y: number
1189
- if (saved !== undefined) { x = saved.x; y = saved.y } else {
1190
- const seat = clusterSeats?.get(s.id) ?? seatFor2(s.id)
1191
- x = seat.x
1192
- y = seat.y
1193
- }
1194
- model.upsertNode({
1195
- id: s.id, x, y, label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1196
- memberships: effective,
1197
- ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1198
- })
1199
- }
1200
- }
1201
- for (const id of model.nodeIds()) {
1202
- // peer- and member- cards are reconciled by their own blocks below.
1203
- if (!seen.has(id) && !id.startsWith('peer-') && !id.startsWith('member-')) model.removeNode(id)
1204
- }
1205
-
1206
- // Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
1207
- // on. Saved positions win; new peers take the deterministic column over
1208
- // the LOCAL content bounds (remote cards hang outside the field and are
1209
- // excluded from bounds, so the column cannot feed back into itself).
1210
- const peerSeen = new Set<string>()
1211
- lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
1212
- memberHostByHandle = new Map([...lastRemoteTeams].flatMap(([h, rows]) => rows.map(row => [row.team, h] as const)))
1213
- lastWork = new Map(input.sessions.map(s => [s.id, { status: s.status, queued: s.queued }]))
1214
- reachability = unreachableRefs(input)
1215
- peerScoreByText.clear()
1216
- for (const p of input.peers ?? []) {
1217
- peerScoreByText.set(peerHostOf(p.url), p.score)
1218
- peerInfoByHost.set(peerHostOf(p.url), { link: p.link, verified: p.verified })
1219
- }
1220
- if (input.peers !== undefined) {
1221
- const localBounds = model.contentBounds()
1222
- const placements = placePeers(input.peers, localBounds)
1223
- input.peers.forEach((p, i) => {
1224
- const id = peerNodeId(p.url)
1225
- peerSeen.add(id)
1226
- const saved = layoutDoc?.nodes[id]
1227
- const existing = model.getNode(id)
1228
- const base = existing !== undefined
1229
- ? { x: existing.x, y: existing.y }
1230
- : saved !== undefined
1231
- ? { x: saved.x, y: saved.y }
1232
- : { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
1233
- // gh#20 W-B: the peer's server-side member form — its first remote
1234
- // team row's zone/id8 handle (canvas remote refs are exact handles
1235
- // per the D1 ruling). Absent until the first remote team row lands.
1236
- const host = peerHostOf(p.url)
1237
- // Owner report 2026-11-04: the host's rows now lead with its bare process team (e.g. 'dsh'), which the
1238
- // server reads as a local session id and refuses — take the first zone/id8 SESSION handle instead.
1239
- const handle = lastRemoteTeams.get(host)?.find(row => row.team.includes('/'))?.team
1240
- model.upsertNode({
1241
- id, x: base.x, y: base.y,
1242
- label: p.url, team: '', name: p.url, peerUrl: p.url,
1243
- remoteHandle: handle,
1244
- live: true, remote: true, memberships: [],
1245
- })
1246
- model.upsertScore(id, p.score)
1247
- })
1248
- }
1249
- for (const id of model.nodeIds()) {
1250
- if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
1251
- }
1252
- // gh#38 (owner ruling 「统一到画布里面拖拽」): every remote session-team
1253
- // HANDLE renders as its own draggable card, stacked beside its host's
1254
- // peer card — pulling a specific remote member into a local team is a
1255
- // plain drag (no picker, no hand-typed handle). Host cards stay for
1256
- // federation context; the member cards carry the actual join gesture.
1257
-
1258
- const memberSeen = new Set<string>()
1259
- for (const [host, rows] of lastRemoteTeams) {
1260
- const hostNode = model.allNodes().find(n => n.remote === true && peerHostOf(n.peerUrl ?? '') === host && !n.id.startsWith('member-'))
1261
- const baseX = (hostNode?.x ?? 400) + 190
1262
- let y = hostNode?.y ?? 0
1263
- for (const row of rows) {
1264
- if (!row.team.includes('/')) continue
1265
- const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
1266
- if (memberSeen.has(id)) continue
1267
- memberSeen.add(id)
1268
- const saved = layoutDoc?.nodes[id]
1269
- const existing = model.getNode(id)
1270
- const pos = existing !== undefined ? { x: existing.x, y: existing.y } : saved !== undefined ? { x: saved.x, y: saved.y } : { x: baseX, y }
1271
- y += 92
1272
- model.upsertNode({
1273
- id, x: pos.x, y: pos.y,
1274
- label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
1275
- remoteHandle: row.team,
1276
- live: true, remote: true, memberships: [],
1277
- })
1278
- }
1279
- }
1280
- for (const id of model.nodeIds()) {
1281
- if (id.startsWith('member-') && !memberSeen.has(id)) model.removeNode(id)
1282
- }
1283
- // gh#42 (owner report: dragged a remote peer into base, no edge): team
1284
- // edges derive from NODE memberships, but remote cards carried hardcoded
1285
- // empty memberships — the server truth (base contains dsh/d659a8f9)
1286
- // never re-attached after a poll or reload. Re-attach here: every local
1287
- // team's remote member handles resolve onto their cards (member- card
1288
- // preferred, the peer HOST card as fallback), in-flight teams excluded.
1289
- const handleToNode = new Map<string, string>()
1290
- for (const n of model.allNodes()) {
1291
- if (n.remote !== true || n.remoteHandle === undefined || n.remoteHandle === '') continue
1292
- if (!handleToNode.has(n.remoteHandle) || n.id.startsWith('member-')) handleToNode.set(n.remoteHandle, n.id)
1293
- }
1294
- for (const team of input.teams) {
1295
- if (pendingTeams.has(team.name)) continue
1296
- const resolved: string[] = []
1297
- for (const m of team.members) {
1298
- const id = handleToNode.get(m.id) ?? m.id
1299
- if (model.getNode(id) !== undefined) resolved.push(id)
1300
- }
1301
- const current = [...model.teamMemberIds(team.name)].sort().join('|')
1302
- const next = [...resolved].sort().join('|')
1303
- if (current !== next) model.setTeamMembers(team.name, resolved)
1304
- }
1305
- // gh#41 fleet planning: the peers' published canvas org renders as
1306
- // REMOTE team frames — the owner reads the other side's grouping from
1307
- // ANY node and regulates it in place. Members attach as board
1308
- // memberships; drops into a remote frame fork to the host relay (see
1309
- // serverAction: the action carries fleetPeer, and main.ts routes it to
1310
- // /__dsh_a2a/fleet-canvas-op instead of the local canvas control).
1311
- const remoteFrames = new Map<string, string>()
1312
- const prevRemoteFrames = remoteFramesRef
1313
- remoteFramesRef = remoteFrames
1314
- if (input.peerOrg !== undefined) {
1315
- let slot = 0
1316
- const localNames = new Set(input.teams.map(t => t.name))
1317
- dissolving.sweep(input.peerOrg)
1318
- for (const row of input.peerOrg) {
1319
- // Resolve joined local handles and id-form legacy members through the pure projection helper.
1320
- const publishedHandles = new Set((input.remoteTeams ?? []).map(session => session.team))
1321
- const localIdsByHandle = joinedLocalIdsByHandle(input.sessions, publishedHandles)
1322
- for (const team of row.org) {
1323
- if (team.name === '' || localNames.has(team.name) || dissolving.hides(team.name, row.url)) continue
1324
- const memberIds = team.members.map(member => remoteMemberBoardId(member, localIdsByHandle, input.remoteTeams, row.url)).filter(id => model.getNode(id) !== undefined)
1325
- const saved = layoutDoc?.frames?.[team.name]
1326
- // gh#44 (owner: frame position/size never sticks): the LOCAL-team
1327
- // path keeps an existing frame's live rect ("live rect wins" — a
1328
- // poll must not snap a frame the user just dragged, the save loop
1329
- // persists it a beat later). Remote frames re-set unconditionally
1330
- // every poll, so every drag snapped back before the save landed.
1331
- // Same contract here: only place the frame when it is absent.
1332
- if (model.getFrame(team.name) === undefined) {
1333
- model.setFrame(team.name, saved !== undefined ? { x: saved.x, y: saved.y, w: saved.w, h: saved.h } : { x: 200 + (slot % 4) * 280, y: 940 + Math.floor(slot / 4) * 160, w: 250, h: 130 })
1334
- }
1335
- model.setTeamMembers(team.name, memberIds)
1336
- remoteFrames.set(team.name, row.url)
1337
- slot += 1
1338
- }
1339
- }
1340
- }
1341
- for (const [name, url] of prevRemoteFrames) {
1342
- if (!remoteFrames.has(name)) {
1343
- model.removeFrame(name)
1344
- void url
1345
- }
1346
- }
1347
- // gh#20 W-B: the server echoes cross-host members as zone/id8 handles —
1348
- // map each handle back onto the peer node that carries it, so the
1349
- // reconcile keeps remote memberships instead of dropping them.
1350
- const handleToPeer = new Map<string, string>()
1351
- for (const n of model.allNodes()) {
1352
- if (n.remote === true && n.remoteHandle !== undefined && n.remoteHandle !== '') handleToPeer.set(n.remoteHandle, n.id)
1353
- }
1354
- const homes = homeTeams(input.teams)
1355
- for (const team of input.teams) {
1356
- if (model.getFrame(team.name) !== undefined) continue // live rect wins
1357
- if (pendingTeams.has(team.name)) continue // 散队 in flight: don't resurrect
1358
- const saved = layoutDoc?.frames[team.name]
1359
- if (saved !== undefined) { model.setFrame(team.name, { ...saved }); continue }
1360
- // A derived frame wraps its HOME members only: a cross-team member is
1361
- // reached by its dashed edge, not by stretching this frame over its
1362
- // home frame. A team made only of members homed elsewhere falls back
1363
- // to wrapping them all.
1364
- const rects: LayoutRect[] = []
1365
- const all: LayoutRect[] = []
1366
- for (const m of team.members) {
1367
- const n = model.getNode(handleToPeer.get(m.id) ?? m.id)
1368
- if (n === undefined) continue
1369
- all.push(nodeRect(n))
1370
- if (homes.get(n.id) === team.name) rects.push(nodeRect(n))
1371
- }
1372
- const wrap = rects.length > 0 ? rects : all
1373
- if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
1374
- }
1375
- for (const [name] of model.allFrames()) {
1376
- if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
1377
- // gh#41: remote org frames are reconciled by the peerOrg block above —
1378
- // the local-team cleanup must not remove them.
1379
- if (!input.teams.some(t => t.name === name) && !remoteFrames.has(name)) model.removeFrame(name)
1380
- }
1381
- // An open node panel follows the poll: facts (joins, leaves, team
1382
- // declarations, peer arrivals) land within one cadence.
1383
- nodes.refresh()
1384
- renderStatus(input)
1385
- // First-run framing: with no saved layout the ring seats are centered on
1386
- // world (0,0) while the viewport origin is the board's top-left corner,
1387
- // so half the fleet landed above/left of the view and a healthy board
1388
- // opened empty. Fit once, the first time there is something to frame.
1389
- // Not marked dirty — nothing the user arranged changed; a saved layout
1390
- // (adopted at any point) keeps its own viewport and cancels this.
1391
- // Once per load, a board that arrives overlapped is shown settled (not
1392
- // saved: no write without a user action; the next edit persists it).
1393
- if (!settledOnce && gesture.kind === 'none' && model.nodeIds().length > 0) { settledOnce = true; const b = layoutUndo.capture(); settleBoard(model, new Set(remoteFramesRef.keys())); layoutUndo.commit(b) }
1394
- if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
1395
- syncViewSize()
1396
- if (viewW > 0 && viewH > 0) {
1397
- firstFitPending = false
1398
- // Peer cards hang outside the local content bounds — frame them too,
1399
- // or the federation column opens clipped at the board's edge. Same
1400
- // framing as 适配 (planning-fit): ≤100%, below the HUD.
1401
- vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
1402
- }
1403
- }
1404
- render()
1405
- }
1406
-
1407
- // Same fallback contract as WorldModel.fromState: the card-scaled ring.
1408
- function seatFor2(id: string): { x: number; y: number } {
1409
- return planSeatClear(id, model.allNodes().filter(n => n.remote !== true))
1410
- }
1411
-
1412
- function adoptExternalLayout(layout: unknown): void {
1413
- const doc = clampDoc(layout)
1414
- if (doc === undefined) return
1415
- layoutDoc = doc
1416
- const store = deps.viewportStore
1417
- if (store === undefined) {
1418
- firstFitPending = false
1419
- if (gesture.kind === 'none') vp = clampViewport({ ...doc.viewport })
1420
- } else if (!viewportAdopted && gesture.kind === 'none') {
1421
- viewportAdopted = true
1422
- const local = store.get(bucket()) as Partial<LayoutViewport> | null | undefined
1423
- if (local != null && Number.isFinite(local.x) && Number.isFinite(local.y) && Number.isFinite(local.scale)) {
1424
- vp = clampViewport({ x: local.x!, y: local.y!, scale: local.scale! })
1425
- firstFitPending = false
1426
- } else if (bucket() === 'wide') {
1427
- vp = clampViewport({ ...doc.viewport })
1428
- firstFitPending = false
1429
- } // narrow/medium with no memory of its own: keep firstFitPending, frame on reconcile
1430
- }
1431
- renderedRevision = -1 // force a repaint of frames/edges on next render
1432
- render()
1433
- }
1434
-
1435
- function snapshotDoc(): unknown {
1436
- return buildLayoutDoc(vp, model.positions(), model.frameRects())
1437
- }
1438
-
1439
- function setLamp(state: LampState, savedAt?: string): void {
1440
- lamp.classList.remove('pending', 'saved', 'error')
1441
- if (state === 'pending') lamp.classList.add('pending')
1442
- if (state === 'saved') lamp.classList.add('saved')
1443
- if (state === 'error') lamp.classList.add('error')
1444
- // Error state is a keyboard-reachable retry control (review P0-2).
1445
- if (state === 'error') {
1446
- lamp.tabIndex = 0
1447
- lamp.setAttribute('role', 'button')
1448
- lamp.setAttribute('aria-label', '布局保存失败,按回车重试')
1449
- } else {
1450
- lamp.removeAttribute('tabindex')
1451
- lamp.removeAttribute('role')
1452
- lamp.removeAttribute('aria-label')
1453
- }
1454
- const text = state === 'pending' ? '布局待保存…'
1455
- : state === 'saved' ? `布局已保存 ${savedAt ?? ''}`.trimEnd()
1456
- : state === 'error' ? '保存失败 · 回车重试'
1457
- : '布局'
1458
- lampText.textContent = text
1459
- }
1460
-
1461
- function activate(): void {
1462
- root.style.display = 'block'
1463
- syncViewSize()
1464
- applyViewport()
1465
- root.focus()
1466
- }
1467
-
1468
- function deactivate(): void { root.style.display = 'none' }
1469
-
1470
- function destroy(): void {
1471
- ac.abort()
1472
- for (const t of noticeTimers) clearTimeout(t)
1473
- }
1474
-
1475
- return {
1476
- root,
1477
- reconcile,
1478
- activate,
1479
- deactivate,
1480
- adoptExternalLayout,
1481
- snapshotDoc,
1482
- setLamp,
1483
- notice,
1484
- destroy,
1485
- seam: {
1486
- pointerDown,
1487
- pointerMove,
1488
- pointerUp,
1489
- pointerCancel,
1490
- wheel,
1491
- key,
1492
- keyUp,
1493
- contextMenu(ev: SeamPointer): void {
1494
- ev.preventDefault()
1495
- menus.contextMenuAt(ev.clientX, ev.clientY, ev.target)
1496
- },
1497
- },
1498
- }
1499
- }
1
+ /**
2
+ * The 2D planning-mode view: one section#dsh-plan holding a screen-fixed
3
+ * dot-grid layer, the transformed world container (SVG star edges + team
4
+ * frames + node cards), a marquee, and the HUD (toolbar / tabs host /
5
+ * status / legend). This is the ONLY new DOM module; everything spatial
6
+ * lives in the pure modules (viewport/world/edges/layout-doc).
7
+ *
8
+ * Reconcile discipline:
9
+ * - the WorldModel persists across polls and is maintained incrementally
10
+ * (upsert refreshes rendered attributes, never positions - a poll must
11
+ * not snap a dragged card back to its last-saved spot);
12
+ * - card/frame elements are keyed-diffed so focus, the pickup transition,
13
+ * and an in-flight pointer capture survive a poll; SVG edges carry no
14
+ * state and are rebuilt wholesale;
15
+ * - the federation overlay (peer cards + activity/federal edges) renders
16
+ * on every render, OUTSIDE the revision gate, so inFlight ages tick per
17
+ * poll and the badge cards track the local content bounds;
18
+ * - XSS: session labels/teams are attacker-shaped data and land only via
19
+ * createElement + textContent.
20
+ *
21
+ * Gestures (Figma-style ruling): left-blank drag = marquee; pan = Space+drag
22
+ * or middle-drag or Shift+wheel; wheel (with or without Ctrl) =
23
+ * pointer-anchored zoom (owner ruling 2026-09-01).
24
+ * Tests drive the same handlers production binds through `seam`.
25
+ * @module nexus-stage/planning-view
26
+ */
27
+
28
+ import type { LampState } from './layout-wire'
29
+ import { buildLayoutDoc, clampDoc, type LayoutDoc, type LayoutRect } from './layout-doc'
30
+ import { FRAME_HEAD_LIFT } from './edges'
31
+ import { drawMemberEdges } from './planning-edges'
32
+ import { createFederationOverlay } from './federation-overlay'
33
+ import { joinedLocalIdsByHandle, membershipIndex, mirrorMemberIds, remoteMemberBoardId } from './planning-member-handles'
34
+ import { attachSessionDrop } from './session-drag'
35
+ import { displayName } from './stage-name'
36
+ import {
37
+ activityEdges, federalEdges, flyBounds, groupRemoteTeams, peerNodeId, peerHostOf, placePeers,
38
+ type FrameAnchor, type InFlightRow, type PeerPlacement, type PeerRow,
39
+ type RemoteTeamRow,
40
+ } from './federation'
41
+ import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
42
+ import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
43
+ import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
44
+ import { applyAction, settleAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
45
+ import { frameHue } from './planning-frame-hue'
46
+ import { rosterFrameTitle } from './planning-roster'
47
+ import { createPlanningMenus } from './planning-menus'
48
+ import { createNodeFlyer, mountBoardSearch } from './planning-search'
49
+ import { applyBoardRelayout } from './planning-relayout'
50
+ import { createDissolveGuard, mirrorRoute } from './planning-mirror'
51
+ import { settleBoard, tidyMembers } from './planning-overlap'
52
+ import { createLayoutUndo, type LayoutSnap } from './planning-undo'
53
+ import { createNodeList } from './planning-nodelist'
54
+ import { cardText } from './planning-card-text'
55
+ import { createCardElement, paintPrio, workLabel } from './planning-card'
56
+ import { createLegend } from './planning-legend'
57
+ import { UNREACHABLE_HINT, showFrameReachability, unreachableRefs } from './planning-reachability'
58
+ import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
59
+ import './planning.css'
60
+
61
+ export { viewportBucket } from './planning-types'
62
+ export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
63
+
64
+
65
+ type Gesture =
66
+ | { readonly kind: 'none' }
67
+ | { readonly kind: 'node'; ids: readonly string[]; clicked: string; rosterOrigin?: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
68
+ | { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
69
+ | { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
70
+ | { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
71
+ | { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
72
+
73
+ // The card skeleton, its 工作中/空闲 chip and the right-hand chip live in planning-card (module-size cap).
74
+ export { workLabel } from './planning-card'
75
+
76
+ export function createPlanningView(deps: PlanningDeps): PlanningView {
77
+ const model = new WorldModel()
78
+ let layoutDoc: LayoutDoc | undefined
79
+ let vp: LayoutViewport = { ...model.viewport }
80
+ let gesture: Gesture = { kind: 'none' }
81
+ let spaceDown = false
82
+ let renderedRevision = -1
83
+ /** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
84
+ let firstFitPending = true
85
+ let settledOnce = false
86
+ const layoutUndo = createLayoutUndo(model) // 撤销 (planning-undo)
87
+ let undoBefore: LayoutSnap | undefined
88
+ let viewportAdopted = false
89
+ const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
90
+ const markDirty = (): void => {
91
+ deps.viewportStore?.set(bucket(), vp)
92
+ deps.onDirty()
93
+ }
94
+ const clock = deps.now ?? Date.now
95
+ const ac = new AbortController()
96
+ const noticeTimers: Array<ReturnType<typeof setTimeout>> = []
97
+ /** Active right-button press: hold-drag pans; a clean release opens the
98
+ * context menu (the contextmenu event itself is always suppressed). */
99
+ let rightPress: { x: number; y: number; target: Element | null; moved: boolean } | null = null
100
+ let suppressNextMenu = false
101
+ const signal = { signal: ac.signal } as AddEventListenerOptions
102
+
103
+ // ── DOM shell (built once) ──
104
+ const root = document.createElement('section')
105
+ root.id = 'dsh-plan'
106
+ root.setAttribute('role', 'tabpanel')
107
+ root.setAttribute('aria-label', 'A2A 规划画布')
108
+ root.tabIndex = 0
109
+ root.style.display = 'none'
110
+
111
+ const grid = document.createElement('div')
112
+ grid.className = 'p-grid'
113
+
114
+ const world = document.createElement('div')
115
+ world.className = 'p-world'
116
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
117
+ svg.setAttribute('class', 'p-edges')
118
+ // NOTE: the svg is intentionally NOT aria-hidden — its .edge-label texts
119
+ // carry the route ages that reduced-motion users rely on.
120
+ // Stable layers: frames must ALWAYS paint (and lose hit-testing) below
121
+ // cards, regardless of which keyed diff created its element first — a
122
+ // frame created after the cards used to sit on top of them and swallow
123
+ // every real-pointer drag inside the team (synthetic tests bypassed it
124
+ // by targeting cards directly).
125
+ const frameLayer = document.createElement('div')
126
+ frameLayer.className = 'p-layer p-frames'
127
+ /** The selected frame keeps its resize handles visible without hover. */
128
+ let selectedFrame: string | undefined
129
+ const markSelectedFrame = (name: string | undefined): void => {
130
+ if (selectedFrame !== undefined) {
131
+ frameEls.get(selectedFrame)?.classList.remove('selected')
132
+ }
133
+ selectedFrame = name
134
+ if (name !== undefined) {
135
+ frameEls.get(name)?.classList.add('selected')
136
+ }
137
+ }
138
+ const nodeLayer = document.createElement('div')
139
+ nodeLayer.className = 'p-layer p-nodes'
140
+ nodeLayer.setAttribute('role', 'listbox')
141
+ nodeLayer.setAttribute('aria-multiselectable', 'true')
142
+ nodeLayer.setAttribute('aria-label', '会话节点')
143
+ world.append(svg, frameLayer, nodeLayer)
144
+
145
+ const marquee = document.createElement('div')
146
+ marquee.className = 'p-marquee'
147
+
148
+ const toolbar = document.createElement('div')
149
+ toolbar.className = 'p-toolbar'
150
+ const zoomLabel = document.createElement('span')
151
+ zoomLabel.className = 'p-zoom mono'
152
+ zoomLabel.textContent = '100%'
153
+ const mkButton = (text: string, title: string, onClick: () => void): HTMLButtonElement => {
154
+ const b = document.createElement('button')
155
+ b.type = 'button'
156
+ b.textContent = text
157
+ b.title = title
158
+ b.addEventListener('click', onClick, signal)
159
+ return b
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 }
164
+ toolbar.append(
165
+ mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
166
+ mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
167
+ zoomLabel,
168
+ mkButton('+', '放大', () => zoomCenter(1.2)),
169
+ sep(),
170
+ mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
171
+ // gh#48 (owner order, review-seat spec): one-click tidy — inside every
172
+ // team frame, the members that belong to EXACTLY that one team re-seat
173
+ // into a padded grid flow (the frame grows downward, bounded); members
174
+ // of 2+ teams keep their positions (the dashed edges express their
175
+ // membership). Idempotent; empty frames untouched; remote member cards
176
+ // ride the same rule; the move persists through the ordinary layout
177
+ // save loop exactly like a hand drag.
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()),
186
+ mkButton('撤销', '撤销上一次布局改动:拖动、缩放、整理、重排、载入时避让(Ctrl+Z;入队离队不撤销)', () => undoLayout()),
187
+ )
188
+ // Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
189
+ // migration made the planning view the join surface — without this, an
190
+ // unjoined session had no GUI path into the mesh at all.
191
+ const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
192
+ netBtn.className = 'p-netbtn'
193
+ netBtn.setAttribute('aria-haspopup', 'menu')
194
+ toolbar.append(sep(), netBtn)
195
+ const search = mountBoardSearch(toolbar, model, displayName, id => flyToNode(id))
196
+ // Node list (network-membership-display): the canvas shows teamed nodes
197
+ // only; every other session — teamless joined, unjoined, and each peer's
198
+ // published nodes — is managed from this list, switched by host.
199
+ const nodesBtn = mkButton('节点', '节点清单:本机与对等节点(未组队/未入网在此管理)', () => nodes.toggle())
200
+ nodesBtn.className = 'p-nodebtn'
201
+ nodesBtn.setAttribute('aria-haspopup', 'dialog')
202
+ toolbar.appendChild(nodesBtn)
203
+ const lamp = document.createElement('span')
204
+ lamp.setAttribute('aria-live', 'polite')
205
+ lamp.className = 'p-lamp'
206
+ const lampDot = document.createElement('i')
207
+ const lampText = document.createElement('span')
208
+ lampText.textContent = '布局'
209
+ lamp.append(lampDot, lampText)
210
+ lamp.addEventListener('click', () => { if (lamp.classList.contains('error')) deps.onLampClick() }, signal)
211
+ lamp.addEventListener('keydown', (ev) => {
212
+ const e = ev as KeyboardEvent
213
+ if ((e.key === 'Enter' || e.key === ' ') && lamp.classList.contains('error')) {
214
+ e.preventDefault()
215
+ deps.onLampClick()
216
+ }
217
+ }, signal)
218
+ toolbar.appendChild(lamp)
219
+
220
+ const status = document.createElement('div')
221
+ status.className = 'p-status'
222
+ const statusLive = document.createElement('span')
223
+ const statusCold = document.createElement('span')
224
+ const statusAct = document.createElement('span')
225
+ statusAct.className = 'mut'
226
+ const statusMut = document.createElement('span')
227
+ statusMut.className = 'mut'
228
+ status.append(statusLive, statusCold, statusAct, statusMut)
229
+ // Fly control (PR D): the whole statusbar fits the frames ∪ peers region,
230
+ // keyboard-parity via Enter/Space with a focus ring (a11y parity with 0).
231
+ status.tabIndex = 0
232
+ status.setAttribute('role', 'button')
233
+ status.setAttribute('aria-label', '定位在途路由与联邦对端')
234
+ status.title = '定位在途与联邦(回车)'
235
+ status.addEventListener('click', () => flyToFederation(), signal)
236
+ status.addEventListener('keydown', (ev) => {
237
+ const e = ev as KeyboardEvent
238
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
239
+ }, signal)
240
+
241
+ // Edge key: a closed-by-default fold (planning-legend), not a fixed panel.
242
+ const legend = createLegend()
243
+
244
+ const noticeStack = document.createElement('div')
245
+ noticeStack.className = 'p-notice-stack'
246
+ noticeStack.setAttribute('role', 'log')
247
+ noticeStack.setAttribute('aria-live', 'polite')
248
+
249
+ root.append(grid, world, marquee, toolbar, status, legend, noticeStack)
250
+
251
+ // ── viewport application ──
252
+ let viewW = 0
253
+ let viewH = 0
254
+ const measure = (): { w: number; h: number } =>
255
+ deps.viewSize ? deps.viewSize() : { w: root.clientWidth, h: root.clientHeight }
256
+ const syncViewSize = (): void => {
257
+ const size = measure()
258
+ viewW = size.w
259
+ viewH = size.h
260
+ }
261
+
262
+ function applyViewport(): void {
263
+ world.style.transform = worldTransformCss(vp)
264
+ const step = 24 * vp.scale
265
+ grid.style.backgroundSize = `${step}px ${step}px`
266
+ grid.style.backgroundPosition = `${-vp.x * vp.scale}px ${-vp.y * vp.scale}px`
267
+ zoomLabel.textContent = `${Math.round(vp.scale * 100)}%`
268
+ }
269
+
270
+ /**
271
+ * gh#48 (owner order): one-click member tidy. For every team frame, the
272
+ * members belonging to EXACTLY that one team re-seat into a padded grid
273
+ * flow inside the frame (the frame grows downward if the members need
274
+ * more rows — bounded); members of 2+ teams stay put (their dashed edges
275
+ * express membership). Remote member cards (member-) are ordinary board
276
+ * cards and ride the same rule. The move goes through markDirty()/render()
277
+ * — the same persistence path a hand drag takes.
278
+ */
279
+ function tidyTeamMembers(): void {
280
+ const before = layoutUndo.capture()
281
+ tidyMembers(model) // gh#48 grid (planning-overlap); then nothing may overlap
282
+ settleBoard(model, new Set(remoteFramesRef.keys()))
283
+ layoutUndo.commit(before)
284
+ markDirty()
285
+ render()
286
+ }
287
+
288
+ /**
289
+ * Supervisor order v2: whole-board deterministic relayout (the planner
290
+ * and the model adapter live in planning-relayout.ts): LOCAL frames +
291
+ * sole members only — remote org frames keep gh#44 live-rect-wins,
292
+ * frameless nodes and multi-team members never move; the same
293
+ * markDirty()/render() persistence path a hand drag takes.
294
+ */
295
+ function relayoutBoard(): void {
296
+ const before = layoutUndo.capture()
297
+ if (!applyBoardRelayout(model, new Set(remoteFramesRef.keys()))) return
298
+ settleBoard(model, new Set(remoteFramesRef.keys())) // vs remote frames + stray cards
299
+ layoutUndo.commit(before)
300
+ markDirty()
301
+ render()
302
+ }
303
+
304
+ function render(): void {
305
+ applyViewport()
306
+ if (model.revision !== renderedRevision) {
307
+ renderedRevision = model.revision
308
+ renderFrames()
309
+ renderNodes()
310
+ drawMemberEdges(svg, model) // planning-edges
311
+ }
312
+ renderFederation()
313
+ }
314
+
315
+ // ── keyed diff: frames ──
316
+ // Element maps instead of selector lookups: keyed identity for focus and
317
+ // transitions, no CSS.escape dependency, O(1) per row.
318
+ const frameEls = new Map<string, HTMLElement>()
319
+ const nodeEls = new Map<string, HTMLElement>()
320
+
321
+ function renderFrames(): void {
322
+ const seen = new Set<string>()
323
+ for (const [name, rect] of model.allFrames()) {
324
+ seen.add(name)
325
+ let el = frameEls.get(name)
326
+ if (el === undefined) {
327
+ el = document.createElement('div')
328
+ el.className = 'p-frame'
329
+ el.dataset.name = name
330
+ const head = document.createElement('div')
331
+ head.className = 'p-frame-head'
332
+ head.dataset.frame = name
333
+ head.tabIndex = 0 // keyboard: Shift+F10 menu, Delete 散队
334
+ head.setAttribute('role', 'button')
335
+ head.setAttribute('aria-haspopup', 'menu')
336
+ head.setAttribute('aria-label', `团队框 ${name}(拖动整组,回车菜单)`)
337
+ const title = document.createElement('span')
338
+ title.className = 'ttl'
339
+ const cnt = document.createElement('span')
340
+ cnt.className = 'cnt mono'
341
+ const route = document.createElement('span')
342
+ route.className = 'route mono'
343
+ head.append(title, cnt, route)
344
+ el.appendChild(head)
345
+ // Resize handles (owner ruling: the frame canvas is stretchable and
346
+ // adjustable): eight directions, pointer seam routes them to the
347
+ // frame-resize gesture. Purely visual affordance + hit targets.
348
+ for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
349
+ const handle = document.createElement('div')
350
+ handle.className = `p-frame-handle p-h-${dir}`
351
+ handle.dataset.frame = name
352
+ handle.dataset.dir = dir
353
+ el.appendChild(handle)
354
+ }
355
+ frameLayer.appendChild(el)
356
+ frameEls.set(name, el)
357
+ }
358
+ const hue = frameHue(name)
359
+ el.style.setProperty('--frame-line', hue.line)
360
+ el.style.setProperty('--frame-bg', hue.bg)
361
+ el.style.left = `${rect.x}px`
362
+ el.style.top = `${rect.y}px`
363
+ el.style.width = `${rect.w}px`
364
+ el.style.height = `${rect.h}px`
365
+ const title = el.querySelector<HTMLElement>('.ttl')!
366
+ const cnt = el.querySelector<HTMLElement>('.cnt')!
367
+ const route = el.querySelector<HTMLElement>('.route')!
368
+ const isRoster = rosterFramesRef.has(name)
369
+ const shownName = isRoster ? rosterFrameTitle(name) : name
370
+ if (title.textContent !== shownName) title.textContent = shownName
371
+ el.classList.toggle('is-roster', isRoster)
372
+ let members = 0
373
+ let cross = 0
374
+ for (const m of model.allNodes()) {
375
+ const mem = m.memberships.find(x => x.team === name)
376
+ if (mem === undefined) continue
377
+ members += 1
378
+ if (m.memberships.length > 1) cross += 1
379
+ }
380
+ const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
381
+ if (cnt.textContent !== cntText) cnt.textContent = cntText
382
+ const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
383
+ const unavailable = mirror !== undefined && reachability.hosts.has(peerHostOf(remoteFramesRef.get(name)!))
384
+ const routeText0 = mirror?.text ?? (lastTeams.find(x => x.name === name)?.team ?? name)
385
+ if (route.textContent !== routeText0) route.textContent = routeText0
386
+ route.title = `${mirror?.title ?? ''}${unavailable ? ` · ${UNREACHABLE_HINT}` : ''}`
387
+ showFrameReachability(el, unavailable)
388
+ el.classList.toggle('is-mirror', mirror !== undefined)
389
+ el.classList.toggle('is-syncing', mirror?.syncing === true)
390
+ const label = `团队框 ${name}${mirror !== undefined ? `(${mirror.text})` : ''}${unavailable ? `(${UNREACHABLE_HINT})` : ''}(拖动整组,回车菜单)`
391
+ const headEl = el.querySelector('.p-frame-head')!
392
+ if (headEl.getAttribute('aria-label') !== label) headEl.setAttribute('aria-label', label) // a rewrite re-announces
393
+ }
394
+ for (const [name, el] of [...frameEls]) {
395
+ if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
396
+ }
397
+ }
398
+
399
+ // ── keyed diff: nodes ──
400
+ function renderNodes(): void {
401
+ const seen = new Set<string>()
402
+ for (const n of model.allNodes()) {
403
+ seen.add(n.id)
404
+ let el = nodeEls.get(n.id)
405
+ if (el === undefined) {
406
+ el = createCardElement(n.id)
407
+ nodeLayer.appendChild(el)
408
+ nodeEls.set(n.id, el)
409
+ }
410
+ const r = nodeRect(n)
411
+ el.style.left = `${r.x}px`
412
+ el.style.top = `${r.y}px`
413
+ el.classList.toggle('cold', !n.live)
414
+ el.classList.toggle('remote', n.remote === true)
415
+ // 工作中/空闲: live local sessions only; the host reports nothing for cold or remote nodes.
416
+ const work = n.live && n.remote !== true ? lastWork.get(n.id) : undefined
417
+ el.classList.toggle('is-working', work?.status === 'running')
418
+ const workText = workLabel(work)
419
+ const workEl = el.querySelector<HTMLElement>('.work')!
420
+ if (workEl.textContent !== workText) workEl.textContent = workText
421
+ el.classList.toggle('selected', model.isSelected(n.id))
422
+ el.setAttribute('aria-selected', String(model.isSelected(n.id)))
423
+ const host = n.id.slice('peer-'.length)
424
+ // Title / second line / tooltip rules: planning-card-text.
425
+ const txt = cardText(n, n.remote === true ? lastRemoteTeams.get(host) ?? [] : [], peerScoreByText.get(host))
426
+ const unavailable = n.remote === true && (n.id.startsWith('member-') ? reachability.members.has(n.id) : reachability.hosts.has(host))
427
+ el.classList.toggle('is-unreachable', unavailable)
428
+ const nmEl = el.querySelector<HTMLElement>('.nm-text')!
429
+ if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
430
+ const subEl = el.querySelector<HTMLElement>('.sub')!
431
+ const subText = unavailable ? `未应答 · ${txt.sub}` : txt.sub
432
+ if (subEl.textContent !== subText) subEl.textContent = subText
433
+ const tip = unavailable ? `${txt.tip} · ${UNREACHABLE_HINT}` : txt.tip
434
+ if (el.title !== tip) el.title = tip
435
+ // A member card's chip follows the link of the host that publishes its handle.
436
+ const linkHost = n.id.startsWith('member-') ? memberHostByHandle.get(n.remoteHandle ?? '') : host
437
+ paintPrio(el.querySelector<HTMLElement>('.prio')!, n, n.remote === true && linkHost !== undefined ? peerInfoByHost.get(linkHost) : undefined)
438
+ }
439
+ for (const [id, el] of [...nodeEls]) {
440
+ if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
441
+ }
442
+ search.refresh()
443
+ }
444
+
445
+ // ── federation overlay (PR D): peer cards' activity/federal edges ──
446
+ // Runs on EVERY render, outside the model-revision gate: the poll's
447
+ // inFlight ages must tick even when the model is unchanged, and the
448
+ // badge column tracks the content bounds each reconcile. Its SVG
449
+ // children are tracked separately because drawMemberEdges clears the svg
450
+ // wholesale - this appends after it (render() orders the two).
451
+ // gh#49: the DOM drawing lives in federation-overlay.ts (module-size
452
+ // ruling); this view keeps only the seams (facts + fly).
453
+ const federation = createFederationOverlay(svg, model)
454
+ let lastPeers: ReadonlyArray<PeerRow> = []
455
+ let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
456
+ let lastWork: ReadonlyMap<string, Pick<PlanningSession, 'status' | 'queued'>> = new Map()
457
+ const peerScoreByText = new Map<string, number | undefined>()
458
+ /** gh#20 W-A: per-host link/verified info for the peer badges. */
459
+ const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
460
+ let memberHostByHandle: ReadonlyMap<string, string> = new Map()
461
+
462
+ function renderFederation(): void {
463
+ federation.render(clock())
464
+ }
465
+
466
+ /** Statusbar fly: fit the frames ∪ peers region (flyBounds pads it). */
467
+ function flyToFederation(): void {
468
+ vp = fitView(flyBounds(model.contentBounds(), federation.placements()), root.clientWidth || viewW, root.clientHeight || viewH)
469
+ applyViewport()
470
+ markDirty()
471
+ }
472
+
473
+ /** W1 pick: frame one node at ~100% and pulse its card. */
474
+ const flyToNode = createNodeFlyer({ model, viewSize: measure, setVp: next => { vp = next }, applyViewport, markDirty, cardEl: id => nodeEls.get(id) })
475
+ // ── status counts ──
476
+ function renderStatus(input: PlanningInput): void {
477
+ const live = input.sessions.filter(s => s.live !== false).length
478
+ const dot = (host: HTMLElement, cls: string, text: string): void => {
479
+ const b = document.createElement('b')
480
+ b.className = cls
481
+ b.textContent = text
482
+ host.replaceChildren(b)
483
+ }
484
+ dot(statusLive, 'ok', `● ${live} live`)
485
+ dot(statusCold, 'warn', `● ${input.sessions.length - live} cold`)
486
+ statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
487
+ statusMut.className = 'mut'
488
+ statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
489
+ }
490
+
491
+ // ── canvas write actions: optimistic apply + team-scoped undo ──
492
+ // Per-team REFERENCE COUNT: two queued actions on one team keep the guard
493
+ // up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
494
+ // the second action's optimistic membership).
495
+ const pendingTeams = new Map<string, number>()
496
+ // Each change re-renders frames: the mirror 「同步中…」 marker reads pendingTeams, not the model revision.
497
+ const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1); renderedRevision = -1 }
498
+ const pendingRelease = (team: string): void => {
499
+ renderedRevision = -1
500
+ const n = (pendingTeams.get(team) ?? 0) - 1
501
+ if (n <= 0) pendingTeams.delete(team)
502
+ else pendingTeams.set(team, n)
503
+ }
504
+ // gh#41: remote-frame registry (frame name → peer URL), refreshed by
505
+ // reconcile and read by emitAction's serverAction to fork remote drops.
506
+ let remoteFramesRef = new Map<string, string>()
507
+ let rosterFramesRef = new Set<string>()
508
+ let reachability = unreachableRefs({})
509
+ const dissolving = createDissolveGuard(deps.now)
510
+
511
+ function emitAction(a: CanvasAction): void {
512
+ const roster = (a.type === 'create-team' && a.roster === true) || rosterFramesRef.has(actionTeam(a))
513
+ if (roster && a.type === 'remove-team' && !window.confirm(`解散网络队「${rosterFrameTitle(a.name)}」并撤回成员与白名单?`)) return
514
+ if (roster && a.type === 'reorder') { notice('error', 'roster 队不支持置顶路由'); return }
515
+ if (roster && (a.type === 'create-team' || a.type === 'add-member') && a.ids.some(id => model.getNode(id)?.remote === true || !lastSessions.some(s => s.id === id && s.joined === true))) {
516
+ notice('error', 'roster 只接受本机已入网会话,远端或未入网成员未写入'); return
517
+ }
518
+ // gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
519
+ // keeps its peer-<host> node id — the canvas member contract is
520
+ // session-id-or-remote-ref. Translation happens here, one boundary;
521
+ // the model action keeps board-local ids so rendering and undo stay
522
+ // node-keyed. A peer without a known handle (no remote team rows yet)
523
+ // is filtered out — the server would refuse the raw peer- id.
524
+ function serverAction(act: CanvasAction): CanvasAction {
525
+ const mapIds = (ids: readonly string[]): string[] => {
526
+ const out: string[] = []
527
+ for (const id of ids) {
528
+ // Both remote card families translate to their zone/id8 handle
529
+ // (gh#38: member- cards join teams like peer- cards do).
530
+ if (!id.startsWith('peer-') && !id.startsWith('member-')) { out.push(id); continue }
531
+ const handle = model.getNode(id)?.remoteHandle
532
+ if (handle !== undefined && handle !== '') out.push(handle)
533
+ }
534
+ return out
535
+ }
536
+ if (act.type === 'add-member' || act.type === 'remove-member' || act.type === 'create-team') {
537
+ const ids = mapIds(act.ids)
538
+ // gh#41: a drop into a REMOTE frame carries the peer URL — main.ts
539
+ // forks it to the fleet relay instead of the local canvas control.
540
+ const fleetPeer = act.type !== 'create-team' ? remoteFramesRef.get(act.team) : undefined
541
+ return ids.length > 0 || fleetPeer !== undefined
542
+ ? { ...act, ids, ...(fleetPeer !== undefined ? { fleetPeer } : {}) }
543
+ : act
544
+ }
545
+ // Dissolving a mirror frame is the owner's write as well (relay op 'remove').
546
+ return act.type === 'remove-team' && remoteFramesRef.has(act.name) ? dissolving.fork(act, remoteFramesRef.get(act.name)!) : act
547
+ }
548
+
549
+ // Optimistic seats: a write may reference a session the teamed gate
550
+ // keeps OFF the model (no canvas card yet). The write actions read and
551
+ // write memberships through the node table, so materialize those ids
552
+ // from the last poll's inventory first — the next reconcile refreshes
553
+ // label/live/teams and keeps the saved-or-seat position.
554
+ if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
555
+ const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
556
+ for (const id of ids) {
557
+ if (id === '' || id.startsWith('peer-') || id.startsWith('member-')) continue
558
+ if (model.getNode(id) !== undefined) continue
559
+ const s = lastSessions.find(row => row.id === id)
560
+ if (s === undefined) continue
561
+ const saved = layoutDoc?.nodes[id]
562
+ const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
563
+ model.upsertNode({
564
+ id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name, description: s.description,
565
+ live: s.live !== false, memberships: [],
566
+ ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
567
+ })
568
+ }
569
+ }
570
+ if (roster && a.type === 'create-team') rosterFramesRef.add(a.name)
571
+ const undo = applyAction(model, a)
572
+ const team = actionTeam(a)
573
+ pendingAdd(team)
574
+ render()
575
+ const sent = serverAction(roster ? { ...a, roster: true } : a)
576
+ void deps.onCanvasAction(sent).then(
577
+ ok => {
578
+ pendingRelease(team)
579
+ if (!ok && a.type === 'create-team' && roster) rosterFramesRef.delete(team)
580
+ layoutDoc = settleAction(model, a, ok, undo, layoutDoc); markDirty(); dissolving.settled(sent, ok)
581
+ render()
582
+ },
583
+ () => {
584
+ // Contract-robustness: a rejecting action channel must not pin the
585
+ // team's guard (and its optimistic state) forever — and per §3.4
586
+ // 不吞错, the rollback surfaces to the user.
587
+ pendingRelease(team)
588
+ if (a.type === 'create-team' && roster) rosterFramesRef.delete(team)
589
+ layoutDoc = settleAction(model, a, false, undo, layoutDoc); markDirty(); dissolving.settled(sent, false)
590
+ notice('error', '操作通道异常,已回滚')
591
+ render()
592
+ },
593
+ )
594
+ }
595
+
596
+ // ── notices (host errors verbatim; design.md §3.4 不吞错) ──
597
+ function notice(kind: 'error' | 'info', text: string): void {
598
+ while (noticeStack.childElementCount >= 3) noticeStack.firstElementChild?.remove()
599
+ const item = document.createElement('div')
600
+ item.className = 'p-notice' + (kind === 'error' ? ' error' : '')
601
+ item.setAttribute('role', kind === 'error' ? 'alert' : 'status')
602
+ item.textContent = text
603
+ noticeStack.appendChild(item)
604
+ noticeTimers.push(setTimeout(() => item.remove(), 4000))
605
+ }
606
+
607
+ // ── popovers: 建队 dialog, context menu, 未入网 dropdown (planning-menus) ──
608
+ const menus = createPlanningMenus({
609
+ root, netBtn, signal, model,
610
+ teams: () => lastTeams,
611
+ joined: id => lastSessions.some(s => s.id === id && s.joined === true),
612
+ isRoster: name => rosterFramesRef.has(name),
613
+ anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
614
+ emit: emitAction,
615
+ notice,
616
+ })
617
+
618
+ // ── 节点列表 drawer (planning-nodelist) ──
619
+ let lastSessions: ReadonlyArray<PlanningSession> = []
620
+ let lastCanvasMemberIds: ReadonlySet<string> = new Set()
621
+ const nodes = createNodeList({
622
+ root, trigger: nodesBtn, signal, model,
623
+ facts: () => ({ sessions: lastSessions, canvasMemberIds: lastCanvasMemberIds, remoteTeams: lastRemoteTeams, teams: lastTeams }),
624
+ menuItem: menus.menuItem,
625
+ emit: emitAction,
626
+ openNameDialog: menus.openNameDialog,
627
+ locate: locateNode,
628
+ })
629
+ // A rail 本机会话 row dropped on a frame joins that team, like a card drop (session-drag).
630
+ attachSessionDrop(root, {
631
+ signal, frames: () => frameEls,
632
+ frameAt: (clientX, clientY) => { const p = localXY({ clientX, clientY }); return innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y)) },
633
+ drop: (team, id) => model.getNode(id)?.memberships.some(m => m.team === team) === true ? notice('info', '该会话已在这个队里') : emitAction({ type: 'add-member', team, ids: [id] }),
634
+ })
635
+
636
+ /** Center the viewport on one canvas node (the 已组队 row's 定位). */
637
+ function locateNode(id: string): void {
638
+ const n = model.getNode(id)
639
+ if (n === undefined) return
640
+ nodes.close()
641
+ const size = deps.viewSize?.() ?? { w: root.clientWidth > 0 ? root.clientWidth : 800, h: root.clientHeight > 0 ? root.clientHeight : 600 }
642
+ vp = clampViewport({ scale: vp.scale, x: n.x - size.w / (2 * vp.scale), y: n.y - size.h / (2 * vp.scale) })
643
+ applyViewport()
644
+ renderedRevision = -1
645
+ render()
646
+ }
647
+
648
+ // ── gestures ──
649
+ function localXY(ev: { clientX: number; clientY: number }): { x: number; y: number } {
650
+ const rect = root.getBoundingClientRect()
651
+ return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }
652
+ }
653
+
654
+ /** Drop dispatch for a moved node drag: 入队 / 队内 y 排序. Leaving a team
655
+ * is an explicit menu action only — dragging out to blank is a position
656
+ * move (owner removal 2026-09-02: the drag-out-to-blank 离队 path is
657
+ * removed; accidental blank drops wrote whole-group removals). */
658
+ function dropDispatch(gesture: Extract<Gesture, { kind: 'node' }>, ev: SeamPointer): void {
659
+ const p = localXY(ev)
660
+ const w = screenToWorld(vp, p.x, p.y)
661
+ const f = innermostFrameAt(new Map(model.allFrames()), w)
662
+ // gh#20 W-B: peer cards join canvas teams too — a mixed local+remote
663
+ // drop composes a cross-host team; the server boundary translates
664
+ // peer ids to zone/id8 handles (emitAction's serverAction). Reorder
665
+ // stays local-only (roster priority is a local-session fact).
666
+ const memberIds = gesture.ids.filter(id => {
667
+ const node = model.getNode(id)
668
+ if (node === undefined) return false
669
+ if (node.remote !== true) return true
670
+ return node.remoteHandle !== undefined && node.remoteHandle !== ''
671
+ })
672
+ if (f !== undefined) {
673
+ const fresh = memberIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
674
+ if (fresh.length > 0) {
675
+ emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
676
+ return
677
+ }
678
+ // Reorder only for LOCAL single-card drops: dropping a peer card is a
679
+ // position move, never a roster write (P2 from the incremental seat).
680
+ if (gesture.ids.length === 1 && model.getNode(gesture.clicked)?.remote !== true) {
681
+ // Already a member: an in-frame drop sorts that frame's members by y.
682
+ const current = model.teamMemberIds(f)
683
+ const desired = yOrderedMembers(model, f)
684
+ if (desired.join('\u0000') !== current.join('\u0000')) {
685
+ emitAction({ type: 'reorder', team: f, ops: reorderOps(current, desired) })
686
+ }
687
+ }
688
+ }
689
+ // Canvas blank drops move only; leaving a roster frame explicitly retracts those declarations.
690
+ if (gesture.rosterOrigin !== undefined && f !== gesture.rosterOrigin) {
691
+ const leaving = gesture.ids.filter(id => model.teamMemberIds(gesture.rosterOrigin!).includes(id))
692
+ if (leaving.length > 0) emitAction({ type: 'remove-member', team: gesture.rosterOrigin, ids: leaving })
693
+ }
694
+ }
695
+
696
+ function pointerDown(ev: SeamPointer): void {
697
+ if (viewW === 0) syncViewSize()
698
+ if (menus.isDialogOpen()) { ev.preventDefault(); return } // modal: no gestures behind the dialog
699
+ const target = ev.target as Element | null
700
+ // Context-menu dismissal: the first pointerdown outside an open menu
701
+ // closes it and is swallowed; a press on the menu's own chrome is
702
+ // swallowed too — only its items act.
703
+ if (menus.isMenuOpen()) {
704
+ if (target !== null && target.closest('.p-menu') !== null) return
705
+ menus.closeMenu()
706
+ ev.preventDefault()
707
+ return
708
+ }
709
+ 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)
710
+ undoBefore = layoutUndo.capture() // committed at pointerUp only if this press moved something
711
+ const p = localXY(ev)
712
+ // 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
713
+ // Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
714
+ // a clean press falls through to the context menu at pointerup.
715
+ if (ev.button === 2) {
716
+ rightPress = { x: p.x, y: p.y, target: ev.target, moved: false }
717
+ ev.preventDefault()
718
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
719
+ return
720
+ }
721
+ const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
722
+ const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
723
+ if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
724
+ // Middle-button anywhere — including on cards and frame heads — pans
725
+ // (baf51a1 claimed this; the guards used to swallow it before the pan
726
+ // branch was reachable, and skipped preventDefault, re-enabling
727
+ // native autoscroll).
728
+ gesture = { kind: 'pan', last: p, moved: false }
729
+ ev.preventDefault()
730
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
731
+ return
732
+ }
733
+ const handleEl = target !== null ? target.closest<HTMLElement>('.p-frame-handle') : null
734
+ // Selection clearing: any press NOT on the selected frame's own chrome
735
+ // (head/handle/body) drops the frame selection.
736
+ if (selectedFrame !== undefined) {
737
+ const selFrameEl = frameEls.get(selectedFrame)
738
+ const onSelectedChrome = target !== null && selFrameEl !== undefined &&
739
+ (selFrameEl.contains(target) || selFrameEl === target)
740
+ const onSelectedHead = headEl !== null && headEl.dataset.frame === selectedFrame
741
+ if (!onSelectedChrome && !onSelectedHead) markSelectedFrame(undefined)
742
+ }
743
+ if (handleEl !== null && ev.button === 0) {
744
+ // Frame resize (owner ruling): record the frame's current rect and
745
+ // the world-space origin; the move branch re-solves the rect per
746
+ // direction with a minimum size clamp.
747
+ const name = handleEl.dataset.frame ?? ''
748
+ markSelectedFrame(name)
749
+ const rect = model.getFrame(name)
750
+ if (rect === undefined) return
751
+ gesture = { kind: 'frame-resize', name, dir: handleEl.dataset.dir ?? 'se', startRect: { ...rect }, origin: screenToWorld(vp, p.x, p.y), moved: false }
752
+ ev.preventDefault()
753
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
754
+ return
755
+ }
756
+ if (nodeEl !== null) {
757
+ if (ev.button !== 0) return // right on a card: contextmenu only, never a card drag (F3)
758
+ const id = nodeEl.dataset.id ?? ''
759
+ const already = model.isSelected(id)
760
+ if (!already && !ev.shiftKey) model.setSelection([id])
761
+ const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
762
+ const origin = innermostFrameAt(new Map(model.allFrames()), screenToWorld(vp, p.x, p.y))
763
+ gesture = { kind: 'node', clicked: id, ids, ...(origin && rosterFramesRef.has(origin) && model.teamMemberIds(origin).includes(id) ? { rosterOrigin: origin } : {}), last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
764
+ nodeEl.classList.add('dragging')
765
+ } else if (headEl !== null) {
766
+ if (ev.button !== 0) return
767
+ const name = headEl.dataset.frame ?? ''
768
+ // Selecting a frame keeps its resize handles visible (hover-only
769
+ // handles vanished the moment the pointer left).
770
+ markSelectedFrame(name)
771
+ const snap = model.beginFrameDrag(name)
772
+ if (snap === undefined) return
773
+ gesture = { kind: 'frame', name, snap, origin: screenToWorld(vp, p.x, p.y), moved: false }
774
+ } else {
775
+ if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
776
+ gesture = { kind: 'pan', last: p, moved: false }
777
+ } else if (ev.button === 0) {
778
+ gesture = { kind: 'marquee', origin: p, last: p, additive: ev.shiftKey }
779
+ marquee.style.display = 'block'
780
+ marquee.style.left = `${p.x}px`
781
+ marquee.style.top = `${p.y}px`
782
+ marquee.style.width = '0px'
783
+ marquee.style.height = '0px'
784
+ } else return
785
+ }
786
+ ev.preventDefault()
787
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
788
+ }
789
+
790
+ function pointerMove(ev: SeamPointer): void {
791
+ const p = localXY(ev)
792
+ if (rightPress !== null) {
793
+ // Right-hold pan: incremental viewport follow once past the threshold.
794
+ // User ruling (2026-08-30): the drag moves the CONTENT, not the window —
795
+ // drag right and the canvas follows the cursor (Figma convention), so
796
+ // the viewport origin moves OPPOSITE the pointer delta. The wheel and
797
+ // middle/space pans keep their own established directions.
798
+ const dx = p.x - rightPress.x
799
+ const dy = p.y - rightPress.y
800
+ if (Math.hypot(dx, dy) > 4) {
801
+ rightPress.moved = true
802
+ vp = clampViewport(panBy(vp, -dx, -dy))
803
+ rightPress.x = p.x
804
+ rightPress.y = p.y
805
+ applyViewport()
806
+ }
807
+ return
808
+ }
809
+ if (gesture.kind === 'node') {
810
+ const w = screenToWorld(vp, p.x, p.y)
811
+ const dx = w.x - gesture.last.x
812
+ const dy = w.y - gesture.last.y
813
+ if (dx === 0 && dy === 0) return
814
+ gesture.moved = true
815
+ gesture.last = w
816
+ model.dragNodes(gesture.ids, dx, dy)
817
+ // Drop-target highlight: the innermost frame under the pointer, but
818
+ // only when the drop would change something (some id not yet member).
819
+ const f = innermostFrameAt(new Map(model.allFrames()), w)
820
+ // W-B: peers with a remote handle are droppable members too.
821
+ const joinables = gesture.ids.filter(id => {
822
+ const node = model.getNode(id)
823
+ if (node === undefined) return false
824
+ return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
825
+ })
826
+ const relevant = f !== undefined && joinables.length > 0
827
+ for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
828
+ render()
829
+ } else if (gesture.kind === 'frame') {
830
+ const w = screenToWorld(vp, p.x, p.y)
831
+ const dx = w.x - gesture.origin.x
832
+ const dy = w.y - gesture.origin.y
833
+ if (!gesture.moved && dx === 0 && dy === 0) return
834
+ gesture.moved = true
835
+ model.applyFrameDrag(gesture.snap, dx, dy)
836
+ render()
837
+ } else if (gesture.kind === 'frame-resize') {
838
+ // Re-solve the rect from the start rect plus the world delta, per
839
+ // direction. Minimum 220x120 keeps the titlebar and成员 badges
840
+ // readable; the frame rect is independent of member positions, so
841
+ // no node moves — star edges follow on the next render.
842
+ const w = screenToWorld(vp, p.x, p.y)
843
+ const dx = w.x - gesture.origin.x
844
+ const dy = w.y - gesture.origin.y
845
+ if (!gesture.moved && dx === 0 && dy === 0) return
846
+ gesture.moved = true
847
+ const MIN_W = 220
848
+ const MIN_H = 120
849
+ const r = { ...gesture.startRect }
850
+ let { x, y, w: width, h: height } = r
851
+ if (gesture.dir.includes('e')) width = Math.max(MIN_W, r.w + dx)
852
+ if (gesture.dir.includes('s')) height = Math.max(MIN_H, r.h + dy)
853
+ if (gesture.dir.includes('w')) {
854
+ width = Math.max(MIN_W, r.w - dx)
855
+ x = r.x + (r.w - width)
856
+ }
857
+ if (gesture.dir.includes('n')) {
858
+ height = Math.max(MIN_H, r.h - dy)
859
+ y = r.y + (r.h - height)
860
+ }
861
+ model.setFrame(gesture.name, { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height) })
862
+ render()
863
+ } else if (gesture.kind === 'pan') {
864
+ vp = panBy(vp, p.x - gesture.last.x, p.y - gesture.last.y)
865
+ gesture.last = p
866
+ gesture.moved = true
867
+ applyViewport()
868
+ } else if (gesture.kind === 'marquee') {
869
+ const x = Math.min(gesture.origin.x, p.x)
870
+ const y = Math.min(gesture.origin.y, p.y)
871
+ marquee.style.left = `${x}px`
872
+ marquee.style.top = `${y}px`
873
+ marquee.style.width = `${Math.abs(p.x - gesture.origin.x)}px`
874
+ marquee.style.height = `${Math.abs(p.y - gesture.origin.y)}px`
875
+ gesture.last = p
876
+ }
877
+ }
878
+
879
+ function pointerUp(ev: SeamPointer): void {
880
+ if (rightPress !== null) {
881
+ const wasPan = rightPress.moved
882
+ const pressTarget = rightPress.target
883
+ const x = rightPress.x
884
+ const y = rightPress.y
885
+ rightPress = null
886
+ if (wasPan) {
887
+ suppressNextMenu = true
888
+ markDirty() // the viewport persists with the layout
889
+ } else if (pressTarget !== null) {
890
+ menus.contextMenuAt(x, y, pressTarget) // clean right-click: context menu
891
+ }
892
+ return
893
+ }
894
+ if (gesture.kind === 'node') {
895
+ const id = gesture.clicked
896
+ if (!gesture.moved) {
897
+ // A click (no drag) is pure selection — NEVER a drop. The F1 fix:
898
+ // an unmoved multi-select click used to fall through to dropDispatch
899
+ // and write an entire group's 离队 to the host.
900
+ if (ev.shiftKey) {
901
+ const current = model.selectedIds()
902
+ model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
903
+ } else {
904
+ model.setSelection([id])
905
+ // 0.6.13: a clean single click is also a node pick — the host opens
906
+ // the chat drawer. Shift-click (multi-select) stays a pure selection.
907
+ deps.onNodePick?.(id)
908
+ }
909
+ } else {
910
+ dropDispatch(gesture, ev)
911
+ markDirty()
912
+ }
913
+ for (const el of frameEls.values()) el.classList.remove('drop-target')
914
+ gesture.el?.classList.remove('dragging')
915
+ render()
916
+ } else if (gesture.kind === 'frame' || gesture.kind === 'frame-resize') {
917
+ // The frame stays where it was put; neighbours and stray cards yield (planning-overlap).
918
+ if (gesture.moved) { settleBoard(model, new Set(remoteFramesRef.keys()), gesture.name); markDirty(); render() }
919
+ } else if (gesture.kind === 'pan') {
920
+ if (gesture.moved) markDirty() // the viewport persists with the layout
921
+ } else if (gesture.kind === 'marquee') {
922
+ const p = gesture.last
923
+ const a = screenToWorld(vp, Math.min(gesture.origin.x, p.x), Math.min(gesture.origin.y, p.y))
924
+ const b = screenToWorld(vp, Math.max(gesture.origin.x, p.x), Math.max(gesture.origin.y, p.y))
925
+ model.marqueeSelect({ x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }, gesture.additive)
926
+ marquee.style.display = 'none'
927
+ render()
928
+ }
929
+ gesture = { kind: 'none' }
930
+ if (undoBefore !== undefined) { layoutUndo.commit(undoBefore); undoBefore = undefined }
931
+ try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
932
+ }
933
+
934
+ /** The OS took the pointer (touch scroll, system gesture): abandon the press — no drop, no menu, layout back. */
935
+ function pointerCancel(ev: SeamPointer): void {
936
+ for (const el of frameEls.values()) el.classList.remove('drop-target')
937
+ if (gesture.kind === 'node') gesture.el?.classList.remove('dragging')
938
+ const panned = (gesture.kind === 'pan' && gesture.moved) || rightPress?.moved === true // right-drag pans live in rightPress
939
+ marquee.style.display = 'none'
940
+ rightPress = null
941
+ gesture = { kind: 'none' }
942
+ if (undoBefore !== undefined && layoutUndo.revert(undoBefore)) render()
943
+ if (panned) markDirty() // the pan stays (it is the view, not the layout) and persists like a finished one
944
+ undoBefore = undefined
945
+ try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
946
+ }
947
+
948
+ function wheel(ev: SeamPointer): void {
949
+ if (menus.isMenuOpen()) menus.closeMenu()
950
+ const p = localXY(ev)
951
+ // Wheel = pointer-anchored zoom (owner ruling 2026-09-01: 鼠标滚动 =
952
+ // 放大缩小), with or without Ctrl — both muscle memories land on the
953
+ // same action. Shift+wheel keeps the natural-scroll pan as the escape
954
+ // hatch. Line-mode wheels (Firefox, deltaMode=1) report ~3 per notch.
955
+ if (ev.shiftKey && !ev.ctrlKey) {
956
+ const line = ev.deltaMode === 1 ? 16 : 1
957
+ vp = clampViewport(panBy(vp, (ev.deltaX ?? 0) * line, (ev.deltaY ?? 0) * line))
958
+ } else {
959
+ const line = ev.deltaMode === 1 ? 16 : 1
960
+ const factor = Math.exp(-((ev.deltaY ?? 0) * line) * 0.0015)
961
+ vp = zoomAt(vp, factor, p.x, p.y)
962
+ }
963
+ ev.preventDefault()
964
+ applyViewport()
965
+ markDirty()
966
+ }
967
+
968
+ function zoomCenter(factor: number): void {
969
+ syncViewSize()
970
+ vp = zoomAt(vp, factor, viewW / 2, viewH / 2)
971
+ applyViewport()
972
+ markDirty()
973
+ }
974
+
975
+ function undoLayout(): void {
976
+ if (layoutUndo.undo()) { markDirty(); render() } else notice('info', '没有可撤销的布局改动')
977
+ }
978
+
979
+ function fitToContent(): void {
980
+ syncViewSize()
981
+ vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
982
+ applyViewport()
983
+ markDirty()
984
+ }
985
+
986
+ function key(ev: SeamKey): void {
987
+ if (menus.isDialogOpen() || (ev.target as Element | null)?.closest?.('.p-search') !== null) return // modal / search box own their keys
988
+ if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k') { ev.preventDefault(); search.focus(); return } // W1 Ctrl+K
989
+ if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey && ev.key.toLowerCase() === 'z') { ev.preventDefault(); undoLayout(); return }
990
+ if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
991
+ if (menus.isMenuOpen()) {
992
+ // The menu owns most keys (its own handler navigates); Escape still
993
+ // closes from here so a seam/keyboard Esc never gets lost.
994
+ if (ev.key === 'Escape') { menus.closeMenu(); ev.preventDefault() }
995
+ return
996
+ }
997
+ if (ev.key === ' ' && ev.target === root) { spaceDown = true; ev.preventDefault(); return }
998
+ // 键盘全等路径(WCAG 2.2 Dragging Movements):
999
+ const headTarget = (ev.target as Element | null)?.closest?.('.p-frame-head') ?? null
1000
+ if ((ev.key === 'Enter' || ev.key === ' ') && headTarget !== null) {
1001
+ ev.preventDefault()
1002
+ const r = headTarget.getBoundingClientRect()
1003
+ menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, headTarget)
1004
+ return
1005
+ }
1006
+ if (ev.key === ' ' && (ev.target as Element | null)?.classList?.contains('p-node')) {
1007
+ const el = ev.target as HTMLElement
1008
+ const id = el.dataset.id ?? ''
1009
+ const current = model.selectedIds()
1010
+ model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
1011
+ render()
1012
+ ev.preventDefault()
1013
+ return
1014
+ }
1015
+ if ((ev.key === 'F10' && ev.shiftKey) || ev.key === 'ContextMenu') {
1016
+ ev.preventDefault()
1017
+ const fe = (document.activeElement as Element | null) ?? null
1018
+ const r = fe?.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 }
1019
+ menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, fe)
1020
+ return
1021
+ }
1022
+ if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteFocused(); return }
1023
+ if (ev.altKey && (ev.key === 'ArrowUp' || ev.key === 'ArrowDown')) { ev.preventDefault(); altMove(ev.key === 'ArrowUp'); return }
1024
+ if (ev.key === 'g' || ev.key === 'G') { menus.startCreateFromSelection(); return }
1025
+ if (ev.key === '0') { fitToContent(); return }
1026
+ if (ev.key === '=' || ev.key === '+') { zoomCenter(1.2); return }
1027
+ if (ev.key === '-') { zoomCenter(1 / 1.2); return }
1028
+ if (ev.key === 'Escape') {
1029
+ if (menus.isMenuOpen()) { menus.closeMenu(); render(); return } // menu > clear selection
1030
+ model.setSelection([])
1031
+ render()
1032
+ return
1033
+ }
1034
+ const step = ev.shiftKey ? 40 : 8
1035
+ const dxdy: Record<string, [number, number]> = {
1036
+ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step],
1037
+ }
1038
+ const d = dxdy[ev.key]
1039
+ if (d !== undefined) {
1040
+ ev.preventDefault()
1041
+ const ids = model.selectedIds()
1042
+ if (ids.length > 0) {
1043
+ const before = layoutUndo.capture()
1044
+ model.nudge(ids, d[0]!, d[1]!)
1045
+ layoutUndo.commit(before)
1046
+ render()
1047
+ markDirty()
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ /** Delete 按焦点对象:单队卡=离队、多队卡=下钻离队菜单、无队=info、框头=散队。 */
1053
+ function deleteFocused(): void {
1054
+ const fe = document.activeElement as HTMLElement | null
1055
+ const headEl = fe?.closest?.<HTMLElement>('.p-frame-head') ?? null
1056
+ const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
1057
+ if (headEl !== null) {
1058
+ emitAction({ type: 'remove-team', name: headEl.dataset.frame ?? '' })
1059
+ return
1060
+ }
1061
+ if (nodeEl !== null) {
1062
+ const id = nodeEl.dataset.id ?? ''
1063
+ const teams = model.getNode(id)?.memberships.map(m => m.team) ?? []
1064
+ if (teams.length === 0) { notice('info', '该节点未加入任何团队'); return }
1065
+ if (teams.length === 1) {
1066
+ emitAction({ type: 'remove-member', team: teams[0]!, ids: [id] })
1067
+ return
1068
+ }
1069
+ menus.openMenu(window.innerWidth / 2, window.innerHeight / 2, { kind: 'node', id }, 'leave', nodeEl)
1070
+ return
1071
+ }
1072
+ notice('info', '焦点不在节点或团队框上')
1073
+ }
1074
+
1075
+ /** Alt+↑/↓: move the focused card within its single team's priority. */
1076
+ function altMove(up: boolean): void {
1077
+ const fe = document.activeElement as HTMLElement | null
1078
+ const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
1079
+ if (nodeEl === null) return
1080
+ const id = nodeEl.dataset.id ?? ''
1081
+ const mem = model.getNode(id)?.memberships ?? []
1082
+ if (mem.length !== 1) return
1083
+ const team = mem[0]!.team
1084
+ const current = model.teamMemberIds(team)
1085
+ const idx = current.indexOf(id)
1086
+ const to = up ? Math.max(0, idx - 1) : Math.min(current.length - 1, idx + 1)
1087
+ if (to === idx) return
1088
+ const desired = [...current]
1089
+ desired.splice(idx, 1)
1090
+ desired.splice(to, 0, id)
1091
+ emitAction({ type: 'reorder', team, ops: reorderOps(current, desired) })
1092
+ }
1093
+
1094
+ function keyUp(ev: SeamKey): void {
1095
+ if (ev.key === ' ') spaceDown = false
1096
+ }
1097
+
1098
+ root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
1099
+ root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
1100
+ root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
1101
+ root.addEventListener('pointercancel', (ev) => pointerCancel(ev as unknown as SeamPointer), signal)
1102
+ root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
1103
+ root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
1104
+ root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
1105
+ root.addEventListener('contextmenu', (ev) => {
1106
+ const e = ev as unknown as SeamPointer
1107
+ if ((e.target as Element | null)?.closest?.('input, textarea, .p-search') != null) return // text fields keep the native menu (paste)
1108
+ // Right-drag pans (suppressNextMenu); a clean right-click opens the
1109
+ // menu programmatically at pointerup. Either way the native menu dies.
1110
+ e.preventDefault()
1111
+ if (suppressNextMenu) { suppressNextMenu = false; return }
1112
+ menus.contextMenuAt(e.clientX, e.clientY, e.target)
1113
+ }, signal)
1114
+
1115
+ // ── poll reconcile: incremental, never repositions existing nodes ──
1116
+ let lastTeams: ReadonlyArray<PlanningTeam> = []
1117
+
1118
+ function reconcile(input: PlanningInput): void {
1119
+ // gh#41: remote-frame registry shared with serverAction (drop fork).
1120
+ lastTeams = input.teams
1121
+ rosterFramesRef = new Set([...input.teams.filter(t => t.kind === 'roster').map(t => t.name), ...[...rosterFramesRef].filter(n => pendingTeams.has(n))])
1122
+ lastPeers = input.peers ?? []
1123
+ federation.setInFlight(input.inFlight ?? [])
1124
+ // Unjoined sessions have no node on the canvas — the panel-slim
1125
+ // migration made this view the join surface, so they ride a compact
1126
+ // toolbar chip instead of vanishing entirely.
1127
+ lastSessions = input.sessions
1128
+ menus.setUnjoined(input.sessions.filter(s => s.joined !== true && !s.id.startsWith('peer-')))
1129
+ const memberships = membershipIndex(input.teams)
1130
+ lastCanvasMemberIds = new Set(memberships.keys())
1131
+ const seen = new Set<string>()
1132
+ // In-flight write guard: membership entries of teams with unsettled
1133
+ // writes come from the model, not the (stale) payload — a poll racing
1134
+ // an optimistic add must not snap the new edge away.
1135
+ const resolveMemberships = (id: string): Array<{ team: string; index: number }> => {
1136
+ const payload = memberships.get(id) ?? []
1137
+ if (pendingTeams.size === 0) return payload
1138
+ const keep = payload.filter(m => !pendingTeams.has(m.team))
1139
+ // In-flight teams read their in-membership from the team roster
1140
+ // (teamMemberIds), NOT from getNode(id).memberships: an optimistic
1141
+ // add can target a node the teamed gate kept OFF the model (no card
1142
+ // yet) — its membership lives only in the roster map, and dropping
1143
+ // it here would pop the node right back off the canvas mid-flight.
1144
+ const pending: Array<{ team: string; index: number }> = []
1145
+ for (const [team] of pendingTeams) {
1146
+ const roster = model.teamMemberIds(team)
1147
+ const index = roster.indexOf(id)
1148
+ if (index >= 0) pending.push({ team, index })
1149
+ }
1150
+ return [...keep, ...pending]
1151
+ }
1152
+ // First build with nothing saved: seat the fleet team by team (see
1153
+ // planTeamClusters). Later arrivals, or any board with a saved layout,
1154
+ // keep the ring fallback.
1155
+ let clusterSeats: Map<string, { x: number; y: number }> | undefined
1156
+ if (layoutDoc === undefined && !model.allNodes().some(n => n.remote !== true)) {
1157
+ const seatIds = input.sessions
1158
+ .filter(s => s.joined === true && (resolveMemberships(s.id).length > 0 || (s.teams?.length ?? 0) > 0))
1159
+ .map(s => s.id)
1160
+ syncViewSize()
1161
+ clusterSeats = planTeamClusters(input.teams, seatIds, viewW > 0 ? viewW : undefined)
1162
+ }
1163
+ // A joined session whose only team is a peer's (a mirror frame) is teamed too: without its
1164
+ // card the mirror frame cannot show it, and 离队 has nothing to reach (owner report 2026-11-04).
1165
+ const mirrorMembers = input.peerOrg !== undefined ? mirrorMemberIds(input.peerOrg, input.sessions, input.remoteTeams) : new Set<string>()
1166
+ // The node list reads the same fact: such a session is teamed, not 未组队.
1167
+ if (input.peerOrg !== undefined) lastCanvasMemberIds = new Set([...lastCanvasMemberIds, ...mirrorMembers])
1168
+ for (const s of input.sessions) {
1169
+ if (s.joined !== true) continue
1170
+ // Teamed-only canvas (network-membership-display ruling): a joined
1171
+ // session earns a canvas node only with a team — canvas membership
1172
+ // (optimistic-aware), a declared roster team, or a mirror frame's
1173
+ // membership. Teamless joined sessions ride the node list's 未组队
1174
+ // group instead; sessions absent from `seen` are swept from the model below.
1175
+ const effective = resolveMemberships(s.id)
1176
+ if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0) && !mirrorMembers.has(s.id)) continue
1177
+ seen.add(s.id)
1178
+ if (model.getNode(s.id) !== undefined) {
1179
+ model.upsertNode({
1180
+ id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
1181
+ label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1182
+ memberships: effective,
1183
+ ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1184
+ })
1185
+ } else {
1186
+ const saved = layoutDoc?.nodes[s.id]
1187
+ let x: number
1188
+ let y: number
1189
+ if (saved !== undefined) { x = saved.x; y = saved.y } else {
1190
+ const seat = clusterSeats?.get(s.id) ?? seatFor2(s.id)
1191
+ x = seat.x
1192
+ y = seat.y
1193
+ }
1194
+ model.upsertNode({
1195
+ id: s.id, x, y, label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1196
+ memberships: effective,
1197
+ ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1198
+ })
1199
+ }
1200
+ }
1201
+ for (const id of model.nodeIds()) {
1202
+ // peer- and member- cards are reconciled by their own blocks below.
1203
+ if (!seen.has(id) && !id.startsWith('peer-') && !id.startsWith('member-')) model.removeNode(id)
1204
+ }
1205
+
1206
+ // Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
1207
+ // on. Saved positions win; new peers take the deterministic column over
1208
+ // the LOCAL content bounds (remote cards hang outside the field and are
1209
+ // excluded from bounds, so the column cannot feed back into itself).
1210
+ const peerSeen = new Set<string>()
1211
+ lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
1212
+ memberHostByHandle = new Map([...lastRemoteTeams].flatMap(([h, rows]) => rows.map(row => [row.team, h] as const)))
1213
+ lastWork = new Map(input.sessions.map(s => [s.id, { status: s.status, queued: s.queued }]))
1214
+ reachability = unreachableRefs(input)
1215
+ peerScoreByText.clear()
1216
+ for (const p of input.peers ?? []) {
1217
+ peerScoreByText.set(peerHostOf(p.url), p.score)
1218
+ peerInfoByHost.set(peerHostOf(p.url), { link: p.link, verified: p.verified })
1219
+ }
1220
+ if (input.peers !== undefined) {
1221
+ const localBounds = model.contentBounds()
1222
+ const placements = placePeers(input.peers, localBounds)
1223
+ input.peers.forEach((p, i) => {
1224
+ const id = peerNodeId(p.url)
1225
+ peerSeen.add(id)
1226
+ const saved = layoutDoc?.nodes[id]
1227
+ const existing = model.getNode(id)
1228
+ const base = existing !== undefined
1229
+ ? { x: existing.x, y: existing.y }
1230
+ : saved !== undefined
1231
+ ? { x: saved.x, y: saved.y }
1232
+ : { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
1233
+ // gh#20 W-B: the peer's server-side member form — its first remote
1234
+ // team row's zone/id8 handle (canvas remote refs are exact handles
1235
+ // per the D1 ruling). Absent until the first remote team row lands.
1236
+ const host = peerHostOf(p.url)
1237
+ // Owner report 2026-11-04: the host's rows now lead with its bare process team (e.g. 'dsh'), which the
1238
+ // server reads as a local session id and refuses — take the first zone/id8 SESSION handle instead.
1239
+ const handle = lastRemoteTeams.get(host)?.find(row => row.team.includes('/'))?.team
1240
+ model.upsertNode({
1241
+ id, x: base.x, y: base.y,
1242
+ label: p.url, team: '', name: p.url, peerUrl: p.url,
1243
+ remoteHandle: handle,
1244
+ live: true, remote: true, memberships: [],
1245
+ })
1246
+ model.upsertScore(id, p.score)
1247
+ })
1248
+ }
1249
+ for (const id of model.nodeIds()) {
1250
+ if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
1251
+ }
1252
+ // gh#38 (owner ruling 「统一到画布里面拖拽」): every remote session-team
1253
+ // HANDLE renders as its own draggable card, stacked beside its host's
1254
+ // peer card — pulling a specific remote member into a local team is a
1255
+ // plain drag (no picker, no hand-typed handle). Host cards stay for
1256
+ // federation context; the member cards carry the actual join gesture.
1257
+
1258
+ const memberSeen = new Set<string>()
1259
+ for (const [host, rows] of lastRemoteTeams) {
1260
+ const hostNode = model.allNodes().find(n => n.remote === true && peerHostOf(n.peerUrl ?? '') === host && !n.id.startsWith('member-'))
1261
+ const baseX = (hostNode?.x ?? 400) + 190
1262
+ let y = hostNode?.y ?? 0
1263
+ for (const row of rows) {
1264
+ if (!row.team.includes('/')) continue
1265
+ const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
1266
+ if (memberSeen.has(id)) continue
1267
+ memberSeen.add(id)
1268
+ const saved = layoutDoc?.nodes[id]
1269
+ const existing = model.getNode(id)
1270
+ const pos = existing !== undefined ? { x: existing.x, y: existing.y } : saved !== undefined ? { x: saved.x, y: saved.y } : { x: baseX, y }
1271
+ y += 92
1272
+ model.upsertNode({
1273
+ id, x: pos.x, y: pos.y,
1274
+ label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
1275
+ remoteHandle: row.team,
1276
+ live: true, remote: true, memberships: [],
1277
+ })
1278
+ }
1279
+ }
1280
+ for (const id of model.nodeIds()) {
1281
+ if (id.startsWith('member-') && !memberSeen.has(id)) model.removeNode(id)
1282
+ }
1283
+ // gh#42 (owner report: dragged a remote peer into base, no edge): team
1284
+ // edges derive from NODE memberships, but remote cards carried hardcoded
1285
+ // empty memberships — the server truth (base contains dsh/d659a8f9)
1286
+ // never re-attached after a poll or reload. Re-attach here: every local
1287
+ // team's remote member handles resolve onto their cards (member- card
1288
+ // preferred, the peer HOST card as fallback), in-flight teams excluded.
1289
+ const handleToNode = new Map<string, string>()
1290
+ for (const n of model.allNodes()) {
1291
+ if (n.remote !== true || n.remoteHandle === undefined || n.remoteHandle === '') continue
1292
+ if (!handleToNode.has(n.remoteHandle) || n.id.startsWith('member-')) handleToNode.set(n.remoteHandle, n.id)
1293
+ }
1294
+ for (const team of input.teams) {
1295
+ if (pendingTeams.has(team.name)) continue
1296
+ const resolved: string[] = []
1297
+ for (const m of team.members) {
1298
+ const id = handleToNode.get(m.id) ?? m.id
1299
+ if (model.getNode(id) !== undefined) resolved.push(id)
1300
+ }
1301
+ const current = [...model.teamMemberIds(team.name)].sort().join('|')
1302
+ const next = [...resolved].sort().join('|')
1303
+ if (current !== next) model.setTeamMembers(team.name, resolved)
1304
+ }
1305
+ // gh#41 fleet planning: the peers' published canvas org renders as
1306
+ // REMOTE team frames — the owner reads the other side's grouping from
1307
+ // ANY node and regulates it in place. Members attach as board
1308
+ // memberships; drops into a remote frame fork to the host relay (see
1309
+ // serverAction: the action carries fleetPeer, and main.ts routes it to
1310
+ // /__dsh_a2a/fleet-canvas-op instead of the local canvas control).
1311
+ const remoteFrames = new Map<string, string>()
1312
+ const prevRemoteFrames = remoteFramesRef
1313
+ remoteFramesRef = remoteFrames
1314
+ if (input.peerOrg !== undefined) {
1315
+ let slot = 0
1316
+ const localNames = new Set(input.teams.map(t => t.name))
1317
+ dissolving.sweep(input.peerOrg)
1318
+ for (const row of input.peerOrg) {
1319
+ // Resolve joined local handles and id-form legacy members through the pure projection helper.
1320
+ const publishedHandles = new Set((input.remoteTeams ?? []).map(session => session.team))
1321
+ const localIdsByHandle = joinedLocalIdsByHandle(input.sessions, publishedHandles)
1322
+ for (const team of row.org) {
1323
+ if (team.name === '' || localNames.has(team.name) || dissolving.hides(team.name, row.url)) continue
1324
+ const memberIds = team.members.map(member => remoteMemberBoardId(member, localIdsByHandle, input.remoteTeams, row.url)).filter(id => model.getNode(id) !== undefined)
1325
+ const saved = layoutDoc?.frames?.[team.name]
1326
+ // gh#44 (owner: frame position/size never sticks): the LOCAL-team
1327
+ // path keeps an existing frame's live rect ("live rect wins" — a
1328
+ // poll must not snap a frame the user just dragged, the save loop
1329
+ // persists it a beat later). Remote frames re-set unconditionally
1330
+ // every poll, so every drag snapped back before the save landed.
1331
+ // Same contract here: only place the frame when it is absent.
1332
+ if (model.getFrame(team.name) === undefined) {
1333
+ model.setFrame(team.name, saved !== undefined ? { x: saved.x, y: saved.y, w: saved.w, h: saved.h } : { x: 200 + (slot % 4) * 280, y: 940 + Math.floor(slot / 4) * 160, w: 250, h: 130 })
1334
+ }
1335
+ model.setTeamMembers(team.name, memberIds)
1336
+ remoteFrames.set(team.name, row.url)
1337
+ slot += 1
1338
+ }
1339
+ }
1340
+ }
1341
+ for (const [name, url] of prevRemoteFrames) {
1342
+ if (!remoteFrames.has(name)) {
1343
+ model.removeFrame(name)
1344
+ void url
1345
+ }
1346
+ }
1347
+ // gh#20 W-B: the server echoes cross-host members as zone/id8 handles —
1348
+ // map each handle back onto the peer node that carries it, so the
1349
+ // reconcile keeps remote memberships instead of dropping them.
1350
+ const handleToPeer = new Map<string, string>()
1351
+ for (const n of model.allNodes()) {
1352
+ if (n.remote === true && n.remoteHandle !== undefined && n.remoteHandle !== '') handleToPeer.set(n.remoteHandle, n.id)
1353
+ }
1354
+ const homes = homeTeams(input.teams)
1355
+ for (const team of input.teams) {
1356
+ if (model.getFrame(team.name) !== undefined) continue // live rect wins
1357
+ if (pendingTeams.has(team.name)) continue // 散队 in flight: don't resurrect
1358
+ const saved = layoutDoc?.frames[team.name]
1359
+ if (saved !== undefined) { model.setFrame(team.name, { ...saved }); continue }
1360
+ // A derived frame wraps its HOME members only: a cross-team member is
1361
+ // reached by its dashed edge, not by stretching this frame over its
1362
+ // home frame. A team made only of members homed elsewhere falls back
1363
+ // to wrapping them all.
1364
+ const rects: LayoutRect[] = []
1365
+ const all: LayoutRect[] = []
1366
+ for (const m of team.members) {
1367
+ const n = model.getNode(handleToPeer.get(m.id) ?? m.id)
1368
+ if (n === undefined) continue
1369
+ all.push(nodeRect(n))
1370
+ if (homes.get(n.id) === team.name) rects.push(nodeRect(n))
1371
+ }
1372
+ const wrap = rects.length > 0 ? rects : all
1373
+ if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
1374
+ }
1375
+ for (const [name] of model.allFrames()) {
1376
+ if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
1377
+ // gh#41: remote org frames are reconciled by the peerOrg block above —
1378
+ // the local-team cleanup must not remove them.
1379
+ if (!input.teams.some(t => t.name === name) && !remoteFrames.has(name)) model.removeFrame(name)
1380
+ }
1381
+ // An open node panel follows the poll: facts (joins, leaves, team
1382
+ // declarations, peer arrivals) land within one cadence.
1383
+ nodes.refresh()
1384
+ renderStatus(input)
1385
+ // First-run framing: with no saved layout the ring seats are centered on
1386
+ // world (0,0) while the viewport origin is the board's top-left corner,
1387
+ // so half the fleet landed above/left of the view and a healthy board
1388
+ // opened empty. Fit once, the first time there is something to frame.
1389
+ // Not marked dirty — nothing the user arranged changed; a saved layout
1390
+ // (adopted at any point) keeps its own viewport and cancels this.
1391
+ // Once per load, a board that arrives overlapped is shown settled (not
1392
+ // saved: no write without a user action; the next edit persists it).
1393
+ if (!settledOnce && gesture.kind === 'none' && model.nodeIds().length > 0) { settledOnce = true; const b = layoutUndo.capture(); settleBoard(model, new Set(remoteFramesRef.keys())); layoutUndo.commit(b) }
1394
+ if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
1395
+ syncViewSize()
1396
+ if (viewW > 0 && viewH > 0) {
1397
+ firstFitPending = false
1398
+ // Peer cards hang outside the local content bounds — frame them too,
1399
+ // or the federation column opens clipped at the board's edge. Same
1400
+ // framing as 适配 (planning-fit): ≤100%, below the HUD.
1401
+ vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
1402
+ }
1403
+ }
1404
+ render()
1405
+ }
1406
+
1407
+ // Same fallback contract as WorldModel.fromState: the card-scaled ring.
1408
+ function seatFor2(id: string): { x: number; y: number } {
1409
+ return planSeatClear(id, model.allNodes().filter(n => n.remote !== true))
1410
+ }
1411
+
1412
+ function adoptExternalLayout(layout: unknown): void {
1413
+ const doc = clampDoc(layout)
1414
+ if (doc === undefined) return
1415
+ layoutDoc = doc
1416
+ const store = deps.viewportStore
1417
+ if (store === undefined) {
1418
+ firstFitPending = false
1419
+ if (gesture.kind === 'none') vp = clampViewport({ ...doc.viewport })
1420
+ } else if (!viewportAdopted && gesture.kind === 'none') {
1421
+ viewportAdopted = true
1422
+ const local = store.get(bucket()) as Partial<LayoutViewport> | null | undefined
1423
+ if (local != null && Number.isFinite(local.x) && Number.isFinite(local.y) && Number.isFinite(local.scale)) {
1424
+ vp = clampViewport({ x: local.x!, y: local.y!, scale: local.scale! })
1425
+ firstFitPending = false
1426
+ } else if (bucket() === 'wide') {
1427
+ vp = clampViewport({ ...doc.viewport })
1428
+ firstFitPending = false
1429
+ } // narrow/medium with no memory of its own: keep firstFitPending, frame on reconcile
1430
+ }
1431
+ renderedRevision = -1 // force a repaint of frames/edges on next render
1432
+ render()
1433
+ }
1434
+
1435
+ function snapshotDoc(): unknown {
1436
+ return buildLayoutDoc(vp, model.positions(), model.frameRects())
1437
+ }
1438
+
1439
+ function setLamp(state: LampState, savedAt?: string): void {
1440
+ lamp.classList.remove('pending', 'saved', 'error')
1441
+ if (state === 'pending') lamp.classList.add('pending')
1442
+ if (state === 'saved') lamp.classList.add('saved')
1443
+ if (state === 'error') lamp.classList.add('error')
1444
+ // Error state is a keyboard-reachable retry control (review P0-2).
1445
+ if (state === 'error') {
1446
+ lamp.tabIndex = 0
1447
+ lamp.setAttribute('role', 'button')
1448
+ lamp.setAttribute('aria-label', '布局保存失败,按回车重试')
1449
+ } else {
1450
+ lamp.removeAttribute('tabindex')
1451
+ lamp.removeAttribute('role')
1452
+ lamp.removeAttribute('aria-label')
1453
+ }
1454
+ const text = state === 'pending' ? '布局待保存…'
1455
+ : state === 'saved' ? `布局已保存 ${savedAt ?? ''}`.trimEnd()
1456
+ : state === 'error' ? '保存失败 · 回车重试'
1457
+ : '布局'
1458
+ lampText.textContent = text
1459
+ }
1460
+
1461
+ function activate(): void {
1462
+ root.style.display = 'block'
1463
+ syncViewSize()
1464
+ applyViewport()
1465
+ root.focus()
1466
+ }
1467
+
1468
+ function deactivate(): void { root.style.display = 'none' }
1469
+
1470
+ function destroy(): void {
1471
+ ac.abort()
1472
+ for (const t of noticeTimers) clearTimeout(t)
1473
+ }
1474
+
1475
+ return {
1476
+ root,
1477
+ reconcile,
1478
+ activate,
1479
+ deactivate,
1480
+ adoptExternalLayout,
1481
+ snapshotDoc,
1482
+ setLamp,
1483
+ notice,
1484
+ destroy,
1485
+ seam: {
1486
+ pointerDown,
1487
+ pointerMove,
1488
+ pointerUp,
1489
+ pointerCancel,
1490
+ wheel,
1491
+ key,
1492
+ keyUp,
1493
+ contextMenu(ev: SeamPointer): void {
1494
+ ev.preventDefault()
1495
+ menus.contextMenuAt(ev.clientX, ev.clientY, ev.target)
1496
+ },
1497
+ },
1498
+ }
1499
+ }