@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24

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 (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. package/package.json +83 -22
@@ -0,0 +1,1313 @@
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, starEdges } from './edges'
31
+ import { displayName } from './stage-name'
32
+ import {
33
+ activityEdges, federalEdges, flyBounds, groupRemoteTeams, peerNodeId, peerHostOf, placePeers,
34
+ type FrameAnchor, type InFlightRow, type PeerPlacement, type PeerRow,
35
+ type RemoteTeamRow,
36
+ } from './federation'
37
+ import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
38
+ import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters } from './world'
39
+ import { applyAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
40
+ import { FRAME_HUES } from './tokens'
41
+ import { createPlanningMenus } from './planning-menus'
42
+ import { createNodeList } from './planning-nodelist'
43
+ import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
44
+ import './planning.css'
45
+
46
+ export { viewportBucket } from './planning-types'
47
+ export type { PlanningDeps, PlanningInput, PlanningSession, PlanningTeam, PlanningView, SeamKey, SeamPointer, ViewportBucket } from './planning-types'
48
+
49
+ /** Team-name hue, same 31-polynomial family as the 3D scene's getHue. */
50
+ function frameHue(name: string): { line: string; bg: string } {
51
+ let h = 0
52
+ for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
53
+ const rgb = FRAME_HUES[h % FRAME_HUES.length]!
54
+ const line = '#' + rgb.toString(16).padStart(6, '0')
55
+ // 5% alpha fill over the dark base, per the design system.
56
+ const r = (rgb >> 16) & 0xff
57
+ const g = (rgb >> 8) & 0xff
58
+ const b = rgb & 0xff
59
+ return { line, bg: `rgba(${r},${g},${b},0.05)` }
60
+ }
61
+
62
+
63
+ type Gesture =
64
+ | { readonly kind: 'none' }
65
+ | { readonly kind: 'node'; ids: readonly string[]; clicked: string; last: { x: number; y: number }; moved: boolean; el: Element | null }
66
+ | { readonly kind: 'frame'; name: string; snap: NonNullable<ReturnType<WorldModel['beginFrameDrag']>>; origin: { x: number; y: number }; moved: boolean }
67
+ | { readonly kind: 'frame-resize'; name: string; dir: string; startRect: { x: number; y: number; w: number; h: number }; origin: { x: number; y: number }; moved: boolean }
68
+ | { readonly kind: 'pan'; last: { x: number; y: number }; moved: boolean }
69
+ | { readonly kind: 'marquee'; origin: { x: number; y: number }; last: { x: number; y: number }; additive: boolean }
70
+
71
+ export function createPlanningView(deps: PlanningDeps): PlanningView {
72
+ const model = new WorldModel()
73
+ let layoutDoc: LayoutDoc | undefined
74
+ let vp: LayoutViewport = { ...model.viewport }
75
+ let gesture: Gesture = { kind: 'none' }
76
+ let spaceDown = false
77
+ let renderedRevision = -1
78
+ /** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
79
+ let firstFitPending = true
80
+ let viewportAdopted = false
81
+ const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
82
+ const markDirty = (): void => {
83
+ deps.viewportStore?.set(bucket(), vp)
84
+ deps.onDirty()
85
+ }
86
+ const clock = deps.now ?? Date.now
87
+ const ac = new AbortController()
88
+ const noticeTimers: Array<ReturnType<typeof setTimeout>> = []
89
+ /** Active right-button press: hold-drag pans; a clean release opens the
90
+ * context menu (the contextmenu event itself is always suppressed). */
91
+ let rightPress: { x: number; y: number; target: Element | null; moved: boolean } | null = null
92
+ let suppressNextMenu = false
93
+ const signal = { signal: ac.signal } as AddEventListenerOptions
94
+
95
+ // ── DOM shell (built once) ──
96
+ const root = document.createElement('section')
97
+ root.id = 'dsh-plan'
98
+ root.setAttribute('role', 'tabpanel')
99
+ root.setAttribute('aria-label', 'A2A 规划画布')
100
+ root.tabIndex = 0
101
+ root.style.display = 'none'
102
+
103
+ const grid = document.createElement('div')
104
+ grid.className = 'p-grid'
105
+
106
+ const world = document.createElement('div')
107
+ world.className = 'p-world'
108
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
109
+ svg.setAttribute('class', 'p-edges')
110
+ // NOTE: the svg is intentionally NOT aria-hidden — its .edge-label texts
111
+ // carry the route ages that reduced-motion users rely on.
112
+ // Stable layers: frames must ALWAYS paint (and lose hit-testing) below
113
+ // cards, regardless of which keyed diff created its element first — a
114
+ // frame created after the cards used to sit on top of them and swallow
115
+ // every real-pointer drag inside the team (synthetic tests bypassed it
116
+ // by targeting cards directly).
117
+ const frameLayer = document.createElement('div')
118
+ frameLayer.className = 'p-layer p-frames'
119
+ /** The selected frame keeps its resize handles visible without hover. */
120
+ let selectedFrame: string | undefined
121
+ const markSelectedFrame = (name: string | undefined): void => {
122
+ if (selectedFrame !== undefined) {
123
+ frameEls.get(selectedFrame)?.classList.remove('selected')
124
+ }
125
+ selectedFrame = name
126
+ if (name !== undefined) {
127
+ frameEls.get(name)?.classList.add('selected')
128
+ }
129
+ }
130
+ const nodeLayer = document.createElement('div')
131
+ nodeLayer.className = 'p-layer p-nodes'
132
+ nodeLayer.setAttribute('role', 'listbox')
133
+ nodeLayer.setAttribute('aria-multiselectable', 'true')
134
+ nodeLayer.setAttribute('aria-label', '会话节点')
135
+ world.append(svg, frameLayer, nodeLayer)
136
+
137
+ const marquee = document.createElement('div')
138
+ marquee.className = 'p-marquee'
139
+
140
+ const toolbar = document.createElement('div')
141
+ toolbar.className = 'p-toolbar'
142
+ const zoomLabel = document.createElement('span')
143
+ zoomLabel.className = 'p-zoom mono'
144
+ zoomLabel.textContent = '100%'
145
+ const mkButton = (text: string, title: string, onClick: () => void): HTMLButtonElement => {
146
+ const b = document.createElement('button')
147
+ b.type = 'button'
148
+ b.textContent = text
149
+ b.title = title
150
+ b.addEventListener('click', onClick, signal)
151
+ return b
152
+ }
153
+ toolbar.append(
154
+ mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
155
+ mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
156
+ zoomLabel,
157
+ mkButton('+', '放大', () => zoomCenter(1.2)),
158
+ mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
159
+ )
160
+ // Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
161
+ // migration made the planning view the join surface — without this, an
162
+ // unjoined session had no GUI path into the mesh at all.
163
+ const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
164
+ netBtn.className = 'p-netbtn'
165
+ netBtn.setAttribute('aria-haspopup', 'menu')
166
+ toolbar.appendChild(netBtn)
167
+ // Node list (network-membership-display): the canvas shows teamed nodes
168
+ // only; every other session — teamless joined, unjoined, and each peer's
169
+ // published nodes — is managed from this list, switched by host.
170
+ const nodesBtn = mkButton('节点', '节点清单:本机与对等节点(未组队/未入网在此管理)', () => nodes.toggle())
171
+ nodesBtn.className = 'p-nodebtn'
172
+ nodesBtn.setAttribute('aria-haspopup', 'dialog')
173
+ toolbar.appendChild(nodesBtn)
174
+ const lamp = document.createElement('span')
175
+ lamp.setAttribute('aria-live', 'polite')
176
+ lamp.className = 'p-lamp'
177
+ const lampDot = document.createElement('i')
178
+ const lampText = document.createElement('span')
179
+ lampText.textContent = '布局'
180
+ lamp.append(lampDot, lampText)
181
+ lamp.addEventListener('click', () => { if (lamp.classList.contains('error')) deps.onLampClick() }, signal)
182
+ lamp.addEventListener('keydown', (ev) => {
183
+ const e = ev as KeyboardEvent
184
+ if ((e.key === 'Enter' || e.key === ' ') && lamp.classList.contains('error')) {
185
+ e.preventDefault()
186
+ deps.onLampClick()
187
+ }
188
+ }, signal)
189
+ toolbar.appendChild(lamp)
190
+
191
+ const status = document.createElement('div')
192
+ status.className = 'p-status'
193
+ const statusLive = document.createElement('span')
194
+ const statusCold = document.createElement('span')
195
+ const statusAct = document.createElement('span')
196
+ statusAct.className = 'mut'
197
+ const statusMut = document.createElement('span')
198
+ statusMut.className = 'mut'
199
+ status.append(statusLive, statusCold, statusAct, statusMut)
200
+ // Fly control (PR D): the whole statusbar fits the frames ∪ peers region,
201
+ // keyboard-parity via Enter/Space with a focus ring (a11y parity with 0).
202
+ status.tabIndex = 0
203
+ status.setAttribute('role', 'button')
204
+ status.setAttribute('aria-label', '定位在途路由与联邦对端')
205
+ status.title = '定位在途与联邦(回车)'
206
+ status.addEventListener('click', () => flyToFederation(), signal)
207
+ status.addEventListener('keydown', (ev) => {
208
+ const e = ev as KeyboardEvent
209
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
210
+ }, signal)
211
+
212
+ const legend = document.createElement('div')
213
+ legend.className = 'p-legend'
214
+ const legendRow = (dashed: boolean, text: string, color?: string): void => {
215
+ const row = document.createElement('span')
216
+ const bar = document.createElement('i')
217
+ if (dashed) bar.className = 'dashed'
218
+ if (color !== undefined) bar.style.borderTopColor = color
219
+ row.append(bar, document.createTextNode(text))
220
+ legend.appendChild(row)
221
+ }
222
+ legendRow(false, '成员边(星形,框→成员)')
223
+ legendRow(true, '跨队多属(虚线)')
224
+ legendRow(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
225
+ legendRow(true, '联邦线(→peer)', 'var(--federal)')
226
+
227
+ const noticeStack = document.createElement('div')
228
+ noticeStack.className = 'p-notice-stack'
229
+ noticeStack.setAttribute('role', 'log')
230
+ noticeStack.setAttribute('aria-live', 'polite')
231
+
232
+ root.append(grid, world, marquee, toolbar, status, legend, noticeStack)
233
+
234
+ // ── viewport application ──
235
+ let viewW = 0
236
+ let viewH = 0
237
+ const measure = (): { w: number; h: number } =>
238
+ deps.viewSize ? deps.viewSize() : { w: root.clientWidth, h: root.clientHeight }
239
+ const syncViewSize = (): void => {
240
+ const size = measure()
241
+ viewW = size.w
242
+ viewH = size.h
243
+ }
244
+
245
+ function applyViewport(): void {
246
+ world.style.transform = worldTransformCss(vp)
247
+ const step = 24 * vp.scale
248
+ grid.style.backgroundSize = `${step}px ${step}px`
249
+ grid.style.backgroundPosition = `${-vp.x * vp.scale}px ${-vp.y * vp.scale}px`
250
+ zoomLabel.textContent = `${Math.round(vp.scale * 100)}%`
251
+ }
252
+
253
+ function render(): void {
254
+ applyViewport()
255
+ if (model.revision !== renderedRevision) {
256
+ renderedRevision = model.revision
257
+ renderFrames()
258
+ renderNodes()
259
+ renderEdges()
260
+ }
261
+ renderFederation()
262
+ }
263
+
264
+ // ── keyed diff: frames ──
265
+ // Element maps instead of selector lookups: keyed identity for focus and
266
+ // transitions, no CSS.escape dependency, O(1) per row.
267
+ const frameEls = new Map<string, HTMLElement>()
268
+ const nodeEls = new Map<string, HTMLElement>()
269
+
270
+ function renderFrames(): void {
271
+ const seen = new Set<string>()
272
+ for (const [name, rect] of model.allFrames()) {
273
+ seen.add(name)
274
+ let el = frameEls.get(name)
275
+ if (el === undefined) {
276
+ el = document.createElement('div')
277
+ el.className = 'p-frame'
278
+ el.dataset.name = name
279
+ const head = document.createElement('div')
280
+ head.className = 'p-frame-head'
281
+ head.dataset.frame = name
282
+ head.tabIndex = 0 // keyboard: Shift+F10 menu, Delete 散队
283
+ head.setAttribute('role', 'button')
284
+ head.setAttribute('aria-haspopup', 'menu')
285
+ head.setAttribute('aria-label', `团队框 ${name}(拖动整组,回车菜单)`)
286
+ const title = document.createElement('span')
287
+ title.className = 'ttl'
288
+ const cnt = document.createElement('span')
289
+ cnt.className = 'cnt mono'
290
+ const route = document.createElement('span')
291
+ route.className = 'route mono'
292
+ head.append(title, cnt, route)
293
+ el.appendChild(head)
294
+ // Resize handles (owner ruling: the frame canvas is stretchable and
295
+ // adjustable): eight directions, pointer seam routes them to the
296
+ // frame-resize gesture. Purely visual affordance + hit targets.
297
+ for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
298
+ const handle = document.createElement('div')
299
+ handle.className = `p-frame-handle p-h-${dir}`
300
+ handle.dataset.frame = name
301
+ handle.dataset.dir = dir
302
+ el.appendChild(handle)
303
+ }
304
+ frameLayer.appendChild(el)
305
+ frameEls.set(name, el)
306
+ }
307
+ const hue = frameHue(name)
308
+ el.style.setProperty('--frame-line', hue.line)
309
+ el.style.setProperty('--frame-bg', hue.bg)
310
+ el.style.left = `${rect.x}px`
311
+ el.style.top = `${rect.y}px`
312
+ el.style.width = `${rect.w}px`
313
+ el.style.height = `${rect.h}px`
314
+ const title = el.querySelector<HTMLElement>('.ttl')!
315
+ const cnt = el.querySelector<HTMLElement>('.cnt')!
316
+ const route = el.querySelector<HTMLElement>('.route')!
317
+ if (title.textContent !== name) title.textContent = name
318
+ let members = 0
319
+ let cross = 0
320
+ for (const m of model.allNodes()) {
321
+ const mem = m.memberships.find(x => x.team === name)
322
+ if (mem === undefined) continue
323
+ members += 1
324
+ if (m.memberships.length > 1) cross += 1
325
+ }
326
+ const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
327
+ if (cnt.textContent !== cntText) cnt.textContent = cntText
328
+ const routeText0 = routeText(name)
329
+ if (route.textContent !== routeText0) route.textContent = routeText0
330
+ }
331
+ for (const [name, el] of [...frameEls]) {
332
+ if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
333
+ }
334
+ }
335
+
336
+ /** Remote card sub line: score + remote team count/names behind this peer. */
337
+ function remoteSubText(host: string): string {
338
+ const rows = lastRemoteTeams.get(host) ?? []
339
+ const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
340
+ const head = names.slice(0, 2).join('、')
341
+ const more = names.length > 2 ? ` +${names.length - 2}` : ''
342
+ const score = peerScoreByText.get(host)
343
+ return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
344
+ }
345
+
346
+ /** The mono route line `<host>/canvas/<name>` when the payload carries it. */
347
+ function routeText(name: string): string {
348
+ const t = lastTeams.find(x => x.name === name)
349
+ return t?.team ?? name
350
+ }
351
+
352
+ // ── keyed diff: nodes ──
353
+ function renderNodes(): void {
354
+ const seen = new Set<string>()
355
+ for (const n of model.allNodes()) {
356
+ seen.add(n.id)
357
+ let el = nodeEls.get(n.id)
358
+ if (el === undefined) {
359
+ el = document.createElement('div')
360
+ el.className = 'p-node'
361
+ el.dataset.id = n.id
362
+ el.tabIndex = 0
363
+ const nm = document.createElement('div')
364
+ nm.className = 'nm'
365
+ const dot = document.createElement('span')
366
+ dot.className = 'dot'
367
+ const name = document.createElement('span')
368
+ name.className = 'nm-text'
369
+ nm.append(dot, name)
370
+ const sub = document.createElement('div')
371
+ sub.className = 'sub mono'
372
+ const prio = document.createElement('span')
373
+ prio.className = 'prio mono'
374
+ el.append(nm, sub, prio)
375
+ nodeLayer.appendChild(el)
376
+ nodeEls.set(n.id, el)
377
+ }
378
+ const r = nodeRect(n)
379
+ el.style.left = `${r.x}px`
380
+ el.style.top = `${r.y}px`
381
+ el.classList.toggle('cold', !n.live)
382
+ el.classList.toggle('remote', n.remote === true)
383
+ el.classList.toggle('selected', model.isSelected(n.id))
384
+ el.setAttribute('aria-selected', String(model.isSelected(n.id)))
385
+ const host = n.id.slice('peer-'.length)
386
+ const nmText = n.remote === true ? host : displayName(n)
387
+ const nmEl = el.querySelector<HTMLElement>('.nm-text')!
388
+ if (nmEl.textContent !== nmText) nmEl.textContent = nmText
389
+ const subEl = el.querySelector<HTMLElement>('.sub')!
390
+ const subBase = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
391
+ // When the title fell back to the team label the sub line would repeat
392
+ // it verbatim; collapse to the cross-team suffix alone in that case.
393
+ const subText = n.remote === true
394
+ ? remoteSubText(host)
395
+ : (subBase === nmText ? '' : subBase)
396
+ + (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
397
+ + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
398
+ if (subEl.textContent !== subText) subEl.textContent = subText
399
+ const prioEl = el.querySelector<HTMLElement>('.prio')!
400
+ const prioText = n.remote === true ? 'peer' : (n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : '')
401
+ if (prioEl.textContent !== prioText) prioEl.textContent = prioText
402
+ }
403
+ for (const [id, el] of [...nodeEls]) {
404
+ if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
405
+ }
406
+ }
407
+
408
+ // ── edges: stateless, rebuilt wholesale ──
409
+ // Derived from the MODEL (not lastTeams): optimistic membership changes
410
+ // must repaint their edges immediately, before the host confirms.
411
+ function renderEdges(): void {
412
+ while (svg.firstChild !== null) svg.firstChild.remove()
413
+ const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
414
+ for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
415
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
416
+ line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
417
+ line.setAttribute('x1', String(e.x1))
418
+ line.setAttribute('y1', String(e.y1))
419
+ line.setAttribute('x2', String(e.x2))
420
+ line.setAttribute('y2', String(e.y2))
421
+ svg.appendChild(line)
422
+ }
423
+ }
424
+
425
+ // ── federation overlay (PR D): peer chips + activity/federal edges ──
426
+ // Runs on EVERY render, outside the model-revision gate: the poll's
427
+ // inFlight ages must tick even when the model is unchanged, and the
428
+ // badge column tracks the content bounds each reconcile. Its SVG
429
+ // children are tracked separately because renderEdges clears the svg
430
+ // wholesale - this appends after it (render() orders the two).
431
+ let fedSvg: SVGElement[] = []
432
+ let lastPlacements: PeerPlacement[] = []
433
+ let lastPeers: ReadonlyArray<PeerRow> = []
434
+ let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
435
+ const peerScoreByText = new Map<string, number | undefined>()
436
+ let lastInFlight: ReadonlyArray<InFlightRow> = []
437
+
438
+ function renderFederation(): void {
439
+ for (const el of fedSvg) el.remove()
440
+ fedSvg = []
441
+ // Peer placements come from the MODEL's remote nodes (v1 ruling: peers
442
+ // are cards in the same pipeline, dragged/saved like sessions) — the
443
+ // old chip column is gone.
444
+ const placements: PeerPlacement[] = model.allNodes()
445
+ .filter(n => n.remote === true)
446
+ .map(n => ({ url: n.peerUrl ?? n.id.slice(5), score: n.score, x: n.x, y: n.y }))
447
+ lastPlacements = placements
448
+ const bounds = model.contentBounds()
449
+ if (bounds !== null) {
450
+ // Edges: titlebar anchors (fx + fw/2, fy + 11, edges.ts lockstep).
451
+ const anchors = new Map<string, FrameAnchor>()
452
+ for (const [name, rect] of model.allFrames()) {
453
+ anchors.set(name, { name, x: rect.x + rect.w / 2, y: rect.y + FRAME_HEAD_LIFT })
454
+ }
455
+ for (const e of activityEdges(lastInFlight, anchors, placements, clock())) {
456
+ fedSvg.push(...fedEdge(e, 'e-activity', e.label))
457
+ }
458
+ for (const e of federalEdges(placements, bounds)) {
459
+ fedSvg.push(...fedEdge(e, 'e-federal', e.label))
460
+ }
461
+ }
462
+ }
463
+
464
+ /** One overlay line + its optional midpoint label (offset +14/+18). */
465
+ function fedEdge(
466
+ e: { x1: number; y1: number; x2: number; y2: number },
467
+ cls: string,
468
+ label: string,
469
+ ): SVGElement[] {
470
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
471
+ line.setAttribute('class', cls)
472
+ line.setAttribute('x1', String(e.x1))
473
+ line.setAttribute('y1', String(e.y1))
474
+ line.setAttribute('x2', String(e.x2))
475
+ line.setAttribute('y2', String(e.y2))
476
+ svg.appendChild(line)
477
+ const out: SVGElement[] = [line]
478
+ if (label !== '') {
479
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
480
+ t.setAttribute('class', 'edge-label mono')
481
+ t.setAttribute('x', String((e.x1 + e.x2) / 2 + 14))
482
+ t.setAttribute('y', String((e.y1 + e.y2) / 2 + 18))
483
+ t.textContent = label
484
+ svg.appendChild(t)
485
+ out.push(t)
486
+ }
487
+ return out
488
+ }
489
+
490
+ /** Statusbar fly: fit the frames ∪ peers region (flyBounds pads it). */
491
+ function flyToFederation(): void {
492
+ vp = fitView(flyBounds(model.contentBounds(), lastPlacements), root.clientWidth || viewW, root.clientHeight || viewH)
493
+ applyViewport()
494
+ markDirty()
495
+ }
496
+
497
+ // ── status counts ──
498
+ function renderStatus(input: PlanningInput): void {
499
+ const live = input.sessions.filter(s => s.live !== false).length
500
+ const cold = input.sessions.length - live
501
+ statusLive.textContent = ''
502
+ const bLive = document.createElement('b')
503
+ bLive.className = 'ok'
504
+ bLive.textContent = `● ${live} live`
505
+ statusLive.appendChild(bLive)
506
+ statusCold.textContent = ''
507
+ const bCold = document.createElement('b')
508
+ bCold.className = 'warn'
509
+ bCold.textContent = `● ${cold} cold`
510
+ statusCold.appendChild(bCold)
511
+ statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
512
+ statusMut.className = 'mut'
513
+ statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
514
+ }
515
+
516
+ // ── canvas write actions: optimistic apply + team-scoped undo ──
517
+ // Per-team REFERENCE COUNT: two queued actions on one team keep the guard
518
+ // up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
519
+ // the second action's optimistic membership).
520
+ const pendingTeams = new Map<string, number>()
521
+ const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1) }
522
+ const pendingRelease = (team: string): void => {
523
+ const n = (pendingTeams.get(team) ?? 0) - 1
524
+ if (n <= 0) pendingTeams.delete(team)
525
+ else pendingTeams.set(team, n)
526
+ }
527
+
528
+ function emitAction(a: CanvasAction): void {
529
+ // Optimistic seats: a write may reference a session the teamed gate
530
+ // keeps OFF the model (no canvas card yet). The write actions read and
531
+ // write memberships through the node table, so materialize those ids
532
+ // from the last poll's inventory first — the next reconcile refreshes
533
+ // label/live/teams and keeps the saved-or-seat position.
534
+ if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
535
+ const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
536
+ for (const id of ids) {
537
+ if (id === '' || id.startsWith('peer-')) continue
538
+ if (model.getNode(id) !== undefined) continue
539
+ const s = lastSessions.find(row => row.id === id)
540
+ if (s === undefined) continue
541
+ const saved = layoutDoc?.nodes[id]
542
+ const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
543
+ model.upsertNode({
544
+ id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name,
545
+ live: s.live !== false, memberships: [],
546
+ ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
547
+ })
548
+ }
549
+ }
550
+ const undo = applyAction(model, a)
551
+ const team = actionTeam(a)
552
+ pendingAdd(team)
553
+ render()
554
+ void deps.onCanvasAction(a).then(
555
+ ok => {
556
+ pendingRelease(team)
557
+ if (!ok) undo() // host refused: roll this action's optimistic delta back
558
+ render()
559
+ },
560
+ () => {
561
+ // Contract-robustness: a rejecting action channel must not pin the
562
+ // team's guard (and its optimistic state) forever — and per §3.4
563
+ // 不吞错, the rollback surfaces to the user.
564
+ pendingRelease(team)
565
+ undo()
566
+ notice('error', '操作通道异常,已回滚')
567
+ render()
568
+ },
569
+ )
570
+ }
571
+
572
+ // ── notices (host errors verbatim; design.md §3.4 不吞错) ──
573
+ function notice(kind: 'error' | 'info', text: string): void {
574
+ while (noticeStack.childElementCount >= 3) noticeStack.firstElementChild?.remove()
575
+ const item = document.createElement('div')
576
+ item.className = 'p-notice' + (kind === 'error' ? ' error' : '')
577
+ item.setAttribute('role', kind === 'error' ? 'alert' : 'status')
578
+ item.textContent = text
579
+ noticeStack.appendChild(item)
580
+ noticeTimers.push(setTimeout(() => item.remove(), 4000))
581
+ }
582
+
583
+ // ── popovers: 建队 dialog, context menu, 未入网 dropdown (planning-menus) ──
584
+ const menus = createPlanningMenus({
585
+ root, netBtn, signal, model,
586
+ teams: () => lastTeams,
587
+ anchorFor: (t) => (t.kind === 'node' ? nodeEls.get(t.id) : frameEls.get(t.name)) ?? null,
588
+ emit: emitAction,
589
+ notice,
590
+ })
591
+
592
+ // ── 节点列表 drawer (planning-nodelist) ──
593
+ let lastSessions: ReadonlyArray<PlanningSession> = []
594
+ let lastCanvasMemberIds: ReadonlySet<string> = new Set()
595
+ const nodes = createNodeList({
596
+ root, trigger: nodesBtn, signal, model,
597
+ facts: () => ({ sessions: lastSessions, canvasMemberIds: lastCanvasMemberIds, remoteTeams: lastRemoteTeams, teams: lastTeams }),
598
+ menuItem: menus.menuItem,
599
+ emit: emitAction,
600
+ openNameDialog: menus.openNameDialog,
601
+ locate: locateNode,
602
+ })
603
+
604
+ /** Center the viewport on one canvas node (the 已组队 row's 定位). */
605
+ function locateNode(id: string): void {
606
+ const n = model.getNode(id)
607
+ if (n === undefined) return
608
+ nodes.close()
609
+ const size = deps.viewSize?.() ?? { w: root.clientWidth > 0 ? root.clientWidth : 800, h: root.clientHeight > 0 ? root.clientHeight : 600 }
610
+ vp = clampViewport({ scale: vp.scale, x: n.x - size.w / (2 * vp.scale), y: n.y - size.h / (2 * vp.scale) })
611
+ applyViewport()
612
+ renderedRevision = -1
613
+ render()
614
+ }
615
+
616
+ // ── gestures ──
617
+ function localXY(ev: { clientX: number; clientY: number }): { x: number; y: number } {
618
+ const rect = root.getBoundingClientRect()
619
+ return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }
620
+ }
621
+
622
+ /** Drop dispatch for a moved node drag: 入队 / 队内 y 排序. Leaving a team
623
+ * is an explicit menu action only — dragging out to blank is a position
624
+ * move (owner removal 2026-09-02: the drag-out-to-blank 离队 path is
625
+ * removed; accidental blank drops wrote whole-group removals). */
626
+ function dropDispatch(gesture: Extract<Gesture, { kind: 'node' }>, ev: SeamPointer): void {
627
+ const p = localXY(ev)
628
+ const w = screenToWorld(vp, p.x, p.y)
629
+ const f = innermostFrameAt(new Map(model.allFrames()), w)
630
+ // 契约不变量 1:成员必须 joined session——对等卡永远不进名单写面。
631
+ const localIds = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
632
+ if (f !== undefined) {
633
+ const fresh = localIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
634
+ if (fresh.length > 0) {
635
+ emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
636
+ return
637
+ }
638
+ // Reorder only for LOCAL single-card drops: dropping a peer card is a
639
+ // position move, never a roster write (P2 from the incremental seat).
640
+ if (gesture.ids.length === 1 && model.getNode(gesture.clicked)?.remote !== true) {
641
+ // Already a member: an in-frame drop sorts that frame's members by y.
642
+ const current = model.teamMemberIds(f)
643
+ const desired = yOrderedMembers(model, f)
644
+ if (desired.join('\u0000') !== current.join('\u0000')) {
645
+ emitAction({ type: 'reorder', team: f, ops: reorderOps(current, desired) })
646
+ }
647
+ }
648
+ }
649
+ // Blank drop: a position move only. No roster writes — the explicit
650
+ // context-menu 离队 (and the node drawer) remain the removal paths.
651
+ }
652
+
653
+ function pointerDown(ev: SeamPointer): void {
654
+ if (viewW === 0) syncViewSize()
655
+ if (menus.isDialogOpen()) { ev.preventDefault(); return } // modal: no gestures behind the dialog
656
+ const target = ev.target as Element | null
657
+ // Context-menu dismissal: the first pointerdown outside an open menu
658
+ // closes it and is swallowed; a press on the menu's own chrome is
659
+ // swallowed too — only its items act.
660
+ if (menus.isMenuOpen()) {
661
+ if (target !== null && target.closest('.p-menu') !== null) return
662
+ menus.closeMenu()
663
+ ev.preventDefault()
664
+ return
665
+ }
666
+ const p = localXY(ev)
667
+ // 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
668
+ // Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
669
+ // a clean press falls through to the context menu at pointerup.
670
+ if (ev.button === 2) {
671
+ rightPress = { x: p.x, y: p.y, target: ev.target, moved: false }
672
+ ev.preventDefault()
673
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
674
+ return
675
+ }
676
+ if (target !== null && (target.closest('button') !== null || target.closest('.p-lamp') !== null || target.closest('.p-status') !== null)) return
677
+ const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
678
+ const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
679
+ if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
680
+ // Middle-button anywhere — including on cards and frame heads — pans
681
+ // (baf51a1 claimed this; the guards used to swallow it before the pan
682
+ // branch was reachable, and skipped preventDefault, re-enabling
683
+ // native autoscroll).
684
+ gesture = { kind: 'pan', last: p, moved: false }
685
+ ev.preventDefault()
686
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
687
+ return
688
+ }
689
+ const handleEl = target !== null ? target.closest<HTMLElement>('.p-frame-handle') : null
690
+ // Selection clearing: any press NOT on the selected frame's own chrome
691
+ // (head/handle/body) drops the frame selection.
692
+ if (selectedFrame !== undefined) {
693
+ const selFrameEl = frameEls.get(selectedFrame)
694
+ const onSelectedChrome = target !== null && selFrameEl !== undefined &&
695
+ (selFrameEl.contains(target) || selFrameEl === target)
696
+ const onSelectedHead = headEl !== null && headEl.dataset.frame === selectedFrame
697
+ if (!onSelectedChrome && !onSelectedHead) markSelectedFrame(undefined)
698
+ }
699
+ if (handleEl !== null && ev.button === 0) {
700
+ // Frame resize (owner ruling): record the frame's current rect and
701
+ // the world-space origin; the move branch re-solves the rect per
702
+ // direction with a minimum size clamp.
703
+ const name = handleEl.dataset.frame ?? ''
704
+ markSelectedFrame(name)
705
+ const rect = model.getFrame(name)
706
+ if (rect === undefined) return
707
+ gesture = { kind: 'frame-resize', name, dir: handleEl.dataset.dir ?? 'se', startRect: { ...rect }, origin: screenToWorld(vp, p.x, p.y), moved: false }
708
+ ev.preventDefault()
709
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
710
+ return
711
+ }
712
+ if (nodeEl !== null) {
713
+ if (ev.button !== 0) return // right on a card: contextmenu only, never a card drag (F3)
714
+ const id = nodeEl.dataset.id ?? ''
715
+ const already = model.isSelected(id)
716
+ if (!already && !ev.shiftKey) model.setSelection([id])
717
+ const ids = ev.shiftKey || already ? (model.isSelected(id) ? model.selectedIds() : [...model.selectedIds(), id]) : [id]
718
+ gesture = { kind: 'node', clicked: id, ids, last: screenToWorld(vp, p.x, p.y), moved: false, el: nodeEl }
719
+ nodeEl.classList.add('dragging')
720
+ } else if (headEl !== null) {
721
+ if (ev.button !== 0) return
722
+ const name = headEl.dataset.frame ?? ''
723
+ // Selecting a frame keeps its resize handles visible (hover-only
724
+ // handles vanished the moment the pointer left).
725
+ markSelectedFrame(name)
726
+ const snap = model.beginFrameDrag(name)
727
+ if (snap === undefined) return
728
+ gesture = { kind: 'frame', name, snap, origin: screenToWorld(vp, p.x, p.y), moved: false }
729
+ } else {
730
+ if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
731
+ gesture = { kind: 'pan', last: p, moved: false }
732
+ } else if (ev.button === 0) {
733
+ gesture = { kind: 'marquee', origin: p, last: p, additive: ev.shiftKey }
734
+ marquee.style.display = 'block'
735
+ marquee.style.left = `${p.x}px`
736
+ marquee.style.top = `${p.y}px`
737
+ marquee.style.width = '0px'
738
+ marquee.style.height = '0px'
739
+ } else return
740
+ }
741
+ ev.preventDefault()
742
+ try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom: no capture */ }
743
+ }
744
+
745
+ function pointerMove(ev: SeamPointer): void {
746
+ const p = localXY(ev)
747
+ if (rightPress !== null) {
748
+ // Right-hold pan: incremental viewport follow once past the threshold.
749
+ // User ruling (2026-08-30): the drag moves the CONTENT, not the window —
750
+ // drag right and the canvas follows the cursor (Figma convention), so
751
+ // the viewport origin moves OPPOSITE the pointer delta. The wheel and
752
+ // middle/space pans keep their own established directions.
753
+ const dx = p.x - rightPress.x
754
+ const dy = p.y - rightPress.y
755
+ if (Math.hypot(dx, dy) > 4) {
756
+ rightPress.moved = true
757
+ vp = clampViewport(panBy(vp, -dx, -dy))
758
+ rightPress.x = p.x
759
+ rightPress.y = p.y
760
+ applyViewport()
761
+ }
762
+ return
763
+ }
764
+ if (gesture.kind === 'node') {
765
+ const w = screenToWorld(vp, p.x, p.y)
766
+ const dx = w.x - gesture.last.x
767
+ const dy = w.y - gesture.last.y
768
+ if (dx === 0 && dy === 0) return
769
+ gesture.moved = true
770
+ gesture.last = w
771
+ model.dragNodes(gesture.ids, dx, dy)
772
+ // Drop-target highlight: the innermost frame under the pointer, but
773
+ // only when the drop would change something (some id not yet member).
774
+ const f = innermostFrameAt(new Map(model.allFrames()), w)
775
+ const joinables = gesture.ids.filter(id => model.getNode(id)?.remote !== true)
776
+ const relevant = f !== undefined && joinables.length > 0
777
+ for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
778
+ render()
779
+ } else if (gesture.kind === 'frame') {
780
+ const w = screenToWorld(vp, p.x, p.y)
781
+ const dx = w.x - gesture.origin.x
782
+ const dy = w.y - gesture.origin.y
783
+ if (!gesture.moved && dx === 0 && dy === 0) return
784
+ gesture.moved = true
785
+ model.applyFrameDrag(gesture.snap, dx, dy)
786
+ render()
787
+ } else if (gesture.kind === 'frame-resize') {
788
+ // Re-solve the rect from the start rect plus the world delta, per
789
+ // direction. Minimum 220x120 keeps the titlebar and成员 badges
790
+ // readable; the frame rect is independent of member positions, so
791
+ // no node moves — star edges follow on the next render.
792
+ const w = screenToWorld(vp, p.x, p.y)
793
+ const dx = w.x - gesture.origin.x
794
+ const dy = w.y - gesture.origin.y
795
+ if (!gesture.moved && dx === 0 && dy === 0) return
796
+ gesture.moved = true
797
+ const MIN_W = 220
798
+ const MIN_H = 120
799
+ const r = { ...gesture.startRect }
800
+ let { x, y, w: width, h: height } = r
801
+ if (gesture.dir.includes('e')) width = Math.max(MIN_W, r.w + dx)
802
+ if (gesture.dir.includes('s')) height = Math.max(MIN_H, r.h + dy)
803
+ if (gesture.dir.includes('w')) {
804
+ width = Math.max(MIN_W, r.w - dx)
805
+ x = r.x + (r.w - width)
806
+ }
807
+ if (gesture.dir.includes('n')) {
808
+ height = Math.max(MIN_H, r.h - dy)
809
+ y = r.y + (r.h - height)
810
+ }
811
+ model.setFrame(gesture.name, { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height) })
812
+ render()
813
+ } else if (gesture.kind === 'pan') {
814
+ vp = panBy(vp, p.x - gesture.last.x, p.y - gesture.last.y)
815
+ gesture.last = p
816
+ gesture.moved = true
817
+ applyViewport()
818
+ } else if (gesture.kind === 'marquee') {
819
+ const x = Math.min(gesture.origin.x, p.x)
820
+ const y = Math.min(gesture.origin.y, p.y)
821
+ marquee.style.left = `${x}px`
822
+ marquee.style.top = `${y}px`
823
+ marquee.style.width = `${Math.abs(p.x - gesture.origin.x)}px`
824
+ marquee.style.height = `${Math.abs(p.y - gesture.origin.y)}px`
825
+ gesture.last = p
826
+ }
827
+ }
828
+
829
+ function pointerUp(ev: SeamPointer): void {
830
+ if (rightPress !== null) {
831
+ const wasPan = rightPress.moved
832
+ const pressTarget = rightPress.target
833
+ const x = rightPress.x
834
+ const y = rightPress.y
835
+ rightPress = null
836
+ if (wasPan) {
837
+ suppressNextMenu = true
838
+ markDirty() // the viewport persists with the layout
839
+ } else if (pressTarget !== null) {
840
+ menus.contextMenuAt(x, y, pressTarget) // clean right-click: context menu
841
+ }
842
+ return
843
+ }
844
+ if (gesture.kind === 'node') {
845
+ const id = gesture.clicked
846
+ if (!gesture.moved) {
847
+ // A click (no drag) is pure selection — NEVER a drop. The F1 fix:
848
+ // an unmoved multi-select click used to fall through to dropDispatch
849
+ // and write an entire group's 离队 to the host.
850
+ if (ev.shiftKey) {
851
+ const current = model.selectedIds()
852
+ model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
853
+ } else {
854
+ model.setSelection([id])
855
+ // 0.6.13: a clean single click is also a node pick — the host opens
856
+ // the chat drawer. Shift-click (multi-select) stays a pure selection.
857
+ deps.onNodePick?.(id)
858
+ }
859
+ } else {
860
+ dropDispatch(gesture, ev)
861
+ markDirty()
862
+ }
863
+ for (const el of frameEls.values()) el.classList.remove('drop-target')
864
+ gesture.el?.classList.remove('dragging')
865
+ render()
866
+ } else if (gesture.kind === 'frame') {
867
+ if (gesture.moved) markDirty()
868
+ } else if (gesture.kind === 'frame-resize') {
869
+ if (gesture.moved) markDirty() // the resized rect persists with the layout
870
+ } else if (gesture.kind === 'pan') {
871
+ if (gesture.moved) markDirty() // the viewport persists with the layout
872
+ } else if (gesture.kind === 'marquee') {
873
+ const p = gesture.last
874
+ const a = screenToWorld(vp, Math.min(gesture.origin.x, p.x), Math.min(gesture.origin.y, p.y))
875
+ const b = screenToWorld(vp, Math.max(gesture.origin.x, p.x), Math.max(gesture.origin.y, p.y))
876
+ model.marqueeSelect({ x: a.x, y: a.y, w: b.x - a.x, h: b.y - a.y }, gesture.additive)
877
+ marquee.style.display = 'none'
878
+ render()
879
+ }
880
+ gesture = { kind: 'none' }
881
+ try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
882
+ }
883
+
884
+ function wheel(ev: SeamPointer): void {
885
+ if (menus.isMenuOpen()) menus.closeMenu()
886
+ const p = localXY(ev)
887
+ // Wheel = pointer-anchored zoom (owner ruling 2026-09-01: 鼠标滚动 =
888
+ // 放大缩小), with or without Ctrl — both muscle memories land on the
889
+ // same action. Shift+wheel keeps the natural-scroll pan as the escape
890
+ // hatch. Line-mode wheels (Firefox, deltaMode=1) report ~3 per notch.
891
+ if (ev.shiftKey && !ev.ctrlKey) {
892
+ const line = ev.deltaMode === 1 ? 16 : 1
893
+ vp = clampViewport(panBy(vp, (ev.deltaX ?? 0) * line, (ev.deltaY ?? 0) * line))
894
+ } else {
895
+ const line = ev.deltaMode === 1 ? 16 : 1
896
+ const factor = Math.exp(-((ev.deltaY ?? 0) * line) * 0.0015)
897
+ vp = zoomAt(vp, factor, p.x, p.y)
898
+ }
899
+ ev.preventDefault()
900
+ applyViewport()
901
+ markDirty()
902
+ }
903
+
904
+ function zoomCenter(factor: number): void {
905
+ syncViewSize()
906
+ vp = zoomAt(vp, factor, viewW / 2, viewH / 2)
907
+ applyViewport()
908
+ markDirty()
909
+ }
910
+
911
+ function fitToContent(): void {
912
+ syncViewSize()
913
+ vp = fitView(model.contentBounds(), viewW, viewH)
914
+ applyViewport()
915
+ markDirty()
916
+ }
917
+
918
+ function key(ev: SeamKey): void {
919
+ if (menus.isDialogOpen()) return // modal: the dialog input handles Enter/Esc itself
920
+ if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
921
+ if (menus.isMenuOpen()) {
922
+ // The menu owns most keys (its own handler navigates); Escape still
923
+ // closes from here so a seam/keyboard Esc never gets lost.
924
+ if (ev.key === 'Escape') { menus.closeMenu(); ev.preventDefault() }
925
+ return
926
+ }
927
+ if (ev.key === ' ' && ev.target === root) { spaceDown = true; ev.preventDefault(); return }
928
+ // 键盘全等路径(WCAG 2.2 Dragging Movements):
929
+ const headTarget = (ev.target as Element | null)?.closest?.('.p-frame-head') ?? null
930
+ if ((ev.key === 'Enter' || ev.key === ' ') && headTarget !== null) {
931
+ ev.preventDefault()
932
+ const r = headTarget.getBoundingClientRect()
933
+ menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, headTarget)
934
+ return
935
+ }
936
+ if (ev.key === ' ' && (ev.target as Element | null)?.classList?.contains('p-node')) {
937
+ const el = ev.target as HTMLElement
938
+ const id = el.dataset.id ?? ''
939
+ const current = model.selectedIds()
940
+ model.setSelection(current.includes(id) ? current.filter(x => x !== id) : [...current, id])
941
+ render()
942
+ ev.preventDefault()
943
+ return
944
+ }
945
+ if ((ev.key === 'F10' && ev.shiftKey) || ev.key === 'ContextMenu') {
946
+ ev.preventDefault()
947
+ const fe = (document.activeElement as Element | null) ?? null
948
+ const r = fe?.getBoundingClientRect?.() ?? { left: 0, top: 0, width: 0, height: 0 }
949
+ menus.contextMenuAt(r.left + r.width / 2, r.top + r.height / 2, fe)
950
+ return
951
+ }
952
+ if (ev.key === 'Delete' || ev.key === 'Backspace') { ev.preventDefault(); deleteFocused(); return }
953
+ if (ev.altKey && (ev.key === 'ArrowUp' || ev.key === 'ArrowDown')) { ev.preventDefault(); altMove(ev.key === 'ArrowUp'); return }
954
+ if (ev.key === 'g' || ev.key === 'G') { menus.startCreateFromSelection(); return }
955
+ if (ev.key === '0') { fitToContent(); return }
956
+ if (ev.key === '=' || ev.key === '+') { zoomCenter(1.2); return }
957
+ if (ev.key === '-') { zoomCenter(1 / 1.2); return }
958
+ if (ev.key === 'Escape') {
959
+ if (menus.isMenuOpen()) { menus.closeMenu(); render(); return } // menu > clear selection
960
+ model.setSelection([])
961
+ render()
962
+ return
963
+ }
964
+ const step = ev.shiftKey ? 40 : 8
965
+ const dxdy: Record<string, [number, number]> = {
966
+ ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step],
967
+ }
968
+ const d = dxdy[ev.key]
969
+ if (d !== undefined) {
970
+ ev.preventDefault()
971
+ const ids = model.selectedIds()
972
+ if (ids.length > 0) {
973
+ model.nudge(ids, d[0]!, d[1]!)
974
+ render()
975
+ markDirty()
976
+ }
977
+ }
978
+ }
979
+
980
+ /** Delete 按焦点对象:单队卡=离队、多队卡=下钻离队菜单、无队=info、框头=散队。 */
981
+ function deleteFocused(): void {
982
+ const fe = document.activeElement as HTMLElement | null
983
+ const headEl = fe?.closest?.<HTMLElement>('.p-frame-head') ?? null
984
+ const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
985
+ if (headEl !== null) {
986
+ emitAction({ type: 'remove-team', name: headEl.dataset.frame ?? '' })
987
+ return
988
+ }
989
+ if (nodeEl !== null) {
990
+ const id = nodeEl.dataset.id ?? ''
991
+ const teams = model.getNode(id)?.memberships.map(m => m.team) ?? []
992
+ if (teams.length === 0) { notice('info', '该节点未加入任何团队'); return }
993
+ if (teams.length === 1) {
994
+ emitAction({ type: 'remove-member', team: teams[0]!, ids: [id] })
995
+ return
996
+ }
997
+ menus.openMenu(window.innerWidth / 2, window.innerHeight / 2, { kind: 'node', id }, 'leave', nodeEl)
998
+ return
999
+ }
1000
+ notice('info', '焦点不在节点或团队框上')
1001
+ }
1002
+
1003
+ /** Alt+↑/↓: move the focused card within its single team's priority. */
1004
+ function altMove(up: boolean): void {
1005
+ const fe = document.activeElement as HTMLElement | null
1006
+ const nodeEl = fe?.closest?.<HTMLElement>('.p-node') ?? null
1007
+ if (nodeEl === null) return
1008
+ const id = nodeEl.dataset.id ?? ''
1009
+ const mem = model.getNode(id)?.memberships ?? []
1010
+ if (mem.length !== 1) return
1011
+ const team = mem[0]!.team
1012
+ const current = model.teamMemberIds(team)
1013
+ const idx = current.indexOf(id)
1014
+ const to = up ? Math.max(0, idx - 1) : Math.min(current.length - 1, idx + 1)
1015
+ if (to === idx) return
1016
+ const desired = [...current]
1017
+ desired.splice(idx, 1)
1018
+ desired.splice(to, 0, id)
1019
+ emitAction({ type: 'reorder', team, ops: reorderOps(current, desired) })
1020
+ }
1021
+
1022
+ function keyUp(ev: SeamKey): void {
1023
+ if (ev.key === ' ') spaceDown = false
1024
+ }
1025
+
1026
+ root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
1027
+ root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
1028
+ root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
1029
+ root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
1030
+ root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
1031
+ root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
1032
+ root.addEventListener('contextmenu', (ev) => {
1033
+ const e = ev as unknown as SeamPointer
1034
+ e.preventDefault()
1035
+ // Right-drag pans (suppressNextMenu); a clean right-click opens the
1036
+ // menu programmatically at pointerup. Either way the native menu dies.
1037
+ e.preventDefault()
1038
+ if (suppressNextMenu) { suppressNextMenu = false; return }
1039
+ menus.contextMenuAt(e.clientX, e.clientY, e.target)
1040
+ }, signal)
1041
+
1042
+ // ── poll reconcile: incremental, never repositions existing nodes ──
1043
+ let lastTeams: ReadonlyArray<PlanningTeam> = []
1044
+
1045
+ function reconcile(input: PlanningInput): void {
1046
+ lastTeams = input.teams
1047
+ lastPeers = input.peers ?? []
1048
+ lastInFlight = input.inFlight ?? []
1049
+ // Unjoined sessions have no node on the canvas — the panel-slim
1050
+ // migration made this view the join surface, so they ride a compact
1051
+ // toolbar chip instead of vanishing entirely.
1052
+ lastSessions = input.sessions
1053
+ menus.setUnjoined(input.sessions.filter(s => s.joined !== true && !s.id.startsWith('peer-')))
1054
+ const memberships = new Map<string, Array<{ team: string; index: number }>>()
1055
+ for (const team of input.teams) {
1056
+ for (let i = 0; i < team.members.length; i++) {
1057
+ const id = team.members[i]!.id
1058
+ const list = memberships.get(id) ?? []
1059
+ list.push({ team: team.name, index: i })
1060
+ memberships.set(id, list)
1061
+ }
1062
+ }
1063
+ lastCanvasMemberIds = new Set(memberships.keys())
1064
+ const seen = new Set<string>()
1065
+ // In-flight write guard: membership entries of teams with unsettled
1066
+ // writes come from the model, not the (stale) payload — a poll racing
1067
+ // an optimistic add must not snap the new edge away.
1068
+ const resolveMemberships = (id: string): Array<{ team: string; index: number }> => {
1069
+ const payload = memberships.get(id) ?? []
1070
+ if (pendingTeams.size === 0) return payload
1071
+ const keep = payload.filter(m => !pendingTeams.has(m.team))
1072
+ // In-flight teams read their in-membership from the team roster
1073
+ // (teamMemberIds), NOT from getNode(id).memberships: an optimistic
1074
+ // add can target a node the teamed gate kept OFF the model (no card
1075
+ // yet) — its membership lives only in the roster map, and dropping
1076
+ // it here would pop the node right back off the canvas mid-flight.
1077
+ const pending: Array<{ team: string; index: number }> = []
1078
+ for (const [team] of pendingTeams) {
1079
+ const roster = model.teamMemberIds(team)
1080
+ const index = roster.indexOf(id)
1081
+ if (index >= 0) pending.push({ team, index })
1082
+ }
1083
+ return [...keep, ...pending]
1084
+ }
1085
+ // First build with nothing saved: seat the fleet team by team (see
1086
+ // planTeamClusters). Later arrivals, or any board with a saved layout,
1087
+ // keep the ring fallback.
1088
+ let clusterSeats: Map<string, { x: number; y: number }> | undefined
1089
+ if (layoutDoc === undefined && !model.allNodes().some(n => n.remote !== true)) {
1090
+ const seatIds = input.sessions
1091
+ .filter(s => s.joined === true && (resolveMemberships(s.id).length > 0 || (s.teams?.length ?? 0) > 0))
1092
+ .map(s => s.id)
1093
+ syncViewSize()
1094
+ clusterSeats = planTeamClusters(input.teams, seatIds, viewW > 0 ? viewW : undefined)
1095
+ }
1096
+ for (const s of input.sessions) {
1097
+ if (s.joined !== true) continue
1098
+ // Teamed-only canvas (network-membership-display ruling): a joined
1099
+ // session earns a canvas node only with a team — canvas membership
1100
+ // (optimistic-aware) or a declared roster team. Teamless joined
1101
+ // sessions ride the node list's 未组队 group instead; sessions absent
1102
+ // from `seen` are swept from the model below.
1103
+ const effective = resolveMemberships(s.id)
1104
+ if (effective.length === 0 && (s.teams === undefined || s.teams.length === 0)) continue
1105
+ seen.add(s.id)
1106
+ if (model.getNode(s.id) !== undefined) {
1107
+ model.upsertNode({
1108
+ id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
1109
+ label: s.label, team: s.team, name: s.name, live: s.live !== false,
1110
+ memberships: effective,
1111
+ ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1112
+ })
1113
+ } else {
1114
+ const saved = layoutDoc?.nodes[s.id]
1115
+ let x: number
1116
+ let y: number
1117
+ if (saved !== undefined) { x = saved.x; y = saved.y } else {
1118
+ const seat = clusterSeats?.get(s.id) ?? seatFor2(s.id)
1119
+ x = seat.x
1120
+ y = seat.y
1121
+ }
1122
+ model.upsertNode({
1123
+ id: s.id, x, y, label: s.label, team: s.team, name: s.name, live: s.live !== false,
1124
+ memberships: effective,
1125
+ ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1126
+ })
1127
+ }
1128
+ }
1129
+ for (const id of model.nodeIds()) {
1130
+ if (!seen.has(id) && !id.startsWith('peer-')) model.removeNode(id)
1131
+ }
1132
+
1133
+ // Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
1134
+ // on. Saved positions win; new peers take the deterministic column over
1135
+ // the LOCAL content bounds (remote cards hang outside the field and are
1136
+ // excluded from bounds, so the column cannot feed back into itself).
1137
+ const peerSeen = new Set<string>()
1138
+ lastRemoteTeams = groupRemoteTeams(input.remoteTeams ?? [])
1139
+ peerScoreByText.clear()
1140
+ for (const p of input.peers ?? []) {
1141
+ peerScoreByText.set(peerHostOf(p.url), p.score)
1142
+ }
1143
+ if (input.peers !== undefined) {
1144
+ const localBounds = model.contentBounds()
1145
+ const placements = placePeers(input.peers, localBounds)
1146
+ input.peers.forEach((p, i) => {
1147
+ const id = peerNodeId(p.url)
1148
+ peerSeen.add(id)
1149
+ const saved = layoutDoc?.nodes[id]
1150
+ const existing = model.getNode(id)
1151
+ const base = existing !== undefined
1152
+ ? { x: existing.x, y: existing.y }
1153
+ : saved !== undefined
1154
+ ? { x: saved.x, y: saved.y }
1155
+ : { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
1156
+ model.upsertNode({
1157
+ id, x: base.x, y: base.y,
1158
+ label: p.url, team: '', name: p.url, peerUrl: p.url,
1159
+ live: true, remote: true, memberships: [],
1160
+ })
1161
+ model.upsertScore(id, p.score)
1162
+ })
1163
+ }
1164
+ for (const id of model.nodeIds()) {
1165
+ if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
1166
+ }
1167
+ const homes = homeTeams(input.teams)
1168
+ for (const team of input.teams) {
1169
+ if (model.getFrame(team.name) !== undefined) continue // live rect wins
1170
+ if (pendingTeams.has(team.name)) continue // 散队 in flight: don't resurrect
1171
+ const saved = layoutDoc?.frames[team.name]
1172
+ if (saved !== undefined) { model.setFrame(team.name, { ...saved }); continue }
1173
+ // A derived frame wraps its HOME members only: a cross-team member is
1174
+ // reached by its dashed edge, not by stretching this frame over its
1175
+ // home frame. A team made only of members homed elsewhere falls back
1176
+ // to wrapping them all.
1177
+ const rects: LayoutRect[] = []
1178
+ const all: LayoutRect[] = []
1179
+ for (const m of team.members) {
1180
+ const n = model.getNode(m.id)
1181
+ if (n === undefined) continue
1182
+ all.push(nodeRect(n))
1183
+ if (homes.get(m.id) === team.name) rects.push(nodeRect(n))
1184
+ }
1185
+ const wrap = rects.length > 0 ? rects : all
1186
+ if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
1187
+ }
1188
+ for (const [name] of model.allFrames()) {
1189
+ if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
1190
+ if (!input.teams.some(t => t.name === name)) model.removeFrame(name)
1191
+ }
1192
+ // An open node panel follows the poll: facts (joins, leaves, team
1193
+ // declarations, peer arrivals) land within one cadence.
1194
+ nodes.refresh()
1195
+ renderStatus(input)
1196
+ // First-run framing: with no saved layout the ring seats are centered on
1197
+ // world (0,0) while the viewport origin is the board's top-left corner,
1198
+ // so half the fleet landed above/left of the view and a healthy board
1199
+ // opened empty. Fit once, the first time there is something to frame.
1200
+ // Not marked dirty — nothing the user arranged changed; a saved layout
1201
+ // (adopted at any point) keeps its own viewport and cancels this.
1202
+ if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
1203
+ syncViewSize()
1204
+ if (viewW > 0 && viewH > 0) {
1205
+ firstFitPending = false
1206
+ // Peer cards hang outside the local content bounds — frame them too,
1207
+ // or the federation column opens clipped at the board's edge.
1208
+ let bounds = model.contentBounds()
1209
+ for (const n of model.allNodes()) {
1210
+ if (n.remote !== true) continue
1211
+ const r = nodeRect(n)
1212
+ bounds = bounds === null
1213
+ ? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
1214
+ : { minX: Math.min(bounds.minX, r.x), minY: Math.min(bounds.minY, r.y), maxX: Math.max(bounds.maxX, r.x + r.w), maxY: Math.max(bounds.maxY, r.y + r.h) }
1215
+ }
1216
+ vp = fitView(bounds, viewW, viewH, 1) // frame, never magnify
1217
+ }
1218
+ }
1219
+ render()
1220
+ }
1221
+
1222
+ // Same fallback contract as WorldModel.fromState: the card-scaled ring.
1223
+ function seatFor2(id: string): { x: number; y: number } {
1224
+ return planSeatClear(id, model.allNodes().filter(n => n.remote !== true))
1225
+ }
1226
+
1227
+ function adoptExternalLayout(layout: unknown): void {
1228
+ const doc = clampDoc(layout)
1229
+ if (doc === undefined) return
1230
+ layoutDoc = doc
1231
+ const store = deps.viewportStore
1232
+ if (store === undefined) {
1233
+ firstFitPending = false
1234
+ if (gesture.kind === 'none') vp = clampViewport({ ...doc.viewport })
1235
+ } else if (!viewportAdopted && gesture.kind === 'none') {
1236
+ viewportAdopted = true
1237
+ const local = store.get(bucket()) as Partial<LayoutViewport> | null | undefined
1238
+ if (local != null && Number.isFinite(local.x) && Number.isFinite(local.y) && Number.isFinite(local.scale)) {
1239
+ vp = clampViewport({ x: local.x!, y: local.y!, scale: local.scale! })
1240
+ firstFitPending = false
1241
+ } else if (bucket() === 'wide') {
1242
+ vp = clampViewport({ ...doc.viewport })
1243
+ firstFitPending = false
1244
+ } // narrow/medium with no memory of its own: keep firstFitPending, frame on reconcile
1245
+ }
1246
+ renderedRevision = -1 // force a repaint of frames/edges on next render
1247
+ render()
1248
+ }
1249
+
1250
+ function snapshotDoc(): unknown {
1251
+ return buildLayoutDoc(vp, model.positions(), model.frameRects())
1252
+ }
1253
+
1254
+ function setLamp(state: LampState, savedAt?: string): void {
1255
+ lamp.classList.remove('pending', 'saved', 'error')
1256
+ if (state === 'pending') lamp.classList.add('pending')
1257
+ if (state === 'saved') lamp.classList.add('saved')
1258
+ if (state === 'error') lamp.classList.add('error')
1259
+ // Error state is a keyboard-reachable retry control (review P0-2).
1260
+ if (state === 'error') {
1261
+ lamp.tabIndex = 0
1262
+ lamp.setAttribute('role', 'button')
1263
+ lamp.setAttribute('aria-label', '布局保存失败,按回车重试')
1264
+ } else {
1265
+ lamp.removeAttribute('tabindex')
1266
+ lamp.removeAttribute('role')
1267
+ lamp.removeAttribute('aria-label')
1268
+ }
1269
+ const text = state === 'pending' ? '布局待保存…'
1270
+ : state === 'saved' ? `布局已保存 ${savedAt ?? ''}`.trimEnd()
1271
+ : state === 'error' ? '保存失败 · 回车重试'
1272
+ : '布局'
1273
+ lampText.textContent = text
1274
+ }
1275
+
1276
+ function activate(): void {
1277
+ root.style.display = 'block'
1278
+ syncViewSize()
1279
+ applyViewport()
1280
+ root.focus()
1281
+ }
1282
+
1283
+ function deactivate(): void { root.style.display = 'none' }
1284
+
1285
+ function destroy(): void {
1286
+ ac.abort()
1287
+ for (const t of noticeTimers) clearTimeout(t)
1288
+ }
1289
+
1290
+ return {
1291
+ root,
1292
+ reconcile,
1293
+ activate,
1294
+ deactivate,
1295
+ adoptExternalLayout,
1296
+ snapshotDoc,
1297
+ setLamp,
1298
+ notice,
1299
+ destroy,
1300
+ seam: {
1301
+ pointerDown,
1302
+ pointerMove,
1303
+ pointerUp,
1304
+ wheel,
1305
+ key,
1306
+ keyUp,
1307
+ contextMenu(ev: SeamPointer): void {
1308
+ ev.preventDefault()
1309
+ menus.contextMenuAt(ev.clientX, ev.clientY, ev.target)
1310
+ },
1311
+ },
1312
+ }
1313
+ }