@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 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 (61) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -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/client.js +64 -64
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +57 -9
  9. package/lib/origin-claim.js +4 -0
  10. package/lib/skill/SKILL.md +117 -117
  11. package/lib/skill/references/collaboration.md +428 -428
  12. package/lib/skill/references/discovery.md +78 -78
  13. package/lib/skill/references/intake.md +107 -107
  14. package/lib/skill/references/membership.md +73 -73
  15. package/lib/skill/references/pitfalls.md +51 -51
  16. package/lib/skill/references/router.md +96 -96
  17. package/lib/skill/references/routing.md +65 -65
  18. package/lib/types/origin-claim.d.ts +2 -0
  19. package/nexus-stage/package.json +21 -21
  20. package/nexus-stage/scripts/mockhost.py +261 -261
  21. package/nexus-stage/src/canvas-ops.ts +191 -191
  22. package/nexus-stage/src/canvas-wire.ts +164 -164
  23. package/nexus-stage/src/cli-home-picker.ts +42 -42
  24. package/nexus-stage/src/fault.ts +56 -56
  25. package/nexus-stage/src/federation-overlay.ts +103 -103
  26. package/nexus-stage/src/federation.ts +241 -241
  27. package/nexus-stage/src/layout-doc.ts +145 -145
  28. package/nexus-stage/src/main.ts +513 -513
  29. package/nexus-stage/src/planning-card-text.ts +73 -73
  30. package/nexus-stage/src/planning-card.ts +63 -63
  31. package/nexus-stage/src/planning-edges.ts +26 -26
  32. package/nexus-stage/src/planning-fit.ts +45 -45
  33. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  34. package/nexus-stage/src/planning-legend.ts +39 -39
  35. package/nexus-stage/src/planning-member-handles.ts +61 -61
  36. package/nexus-stage/src/planning-menus.ts +422 -422
  37. package/nexus-stage/src/planning-mirror.ts +75 -75
  38. package/nexus-stage/src/planning-nodelist.ts +294 -294
  39. package/nexus-stage/src/planning-overlap.ts +159 -159
  40. package/nexus-stage/src/planning-reachability.ts +28 -28
  41. package/nexus-stage/src/planning-relayout.ts +121 -121
  42. package/nexus-stage/src/planning-roster.ts +15 -15
  43. package/nexus-stage/src/planning-search.ts +168 -168
  44. package/nexus-stage/src/planning-types.ts +115 -115
  45. package/nexus-stage/src/planning-undo.ts +68 -68
  46. package/nexus-stage/src/planning-view.ts +1499 -1499
  47. package/nexus-stage/src/planning.css +340 -340
  48. package/nexus-stage/src/seat-name-editor.ts +31 -31
  49. package/nexus-stage/src/session-drag.ts +64 -64
  50. package/nexus-stage/src/viewport.ts +106 -106
  51. package/nexus-stage/src/wb-chat.ts +684 -684
  52. package/nexus-stage/src/wb-directory.ts +237 -237
  53. package/nexus-stage/src/wb-ledger.ts +279 -279
  54. package/nexus-stage/src/wb-names.ts +43 -43
  55. package/nexus-stage/src/wb-rail.ts +147 -147
  56. package/nexus-stage/src/workbench.css +388 -388
  57. package/nexus-stage/src/workbench.ts +1272 -1272
  58. package/nexus-stage/src/world.ts +509 -509
  59. package/nexus-stage/tsconfig.json +24 -24
  60. package/nexus-stage/vite.config.ts +15 -15
  61. package/package.json +1 -1
@@ -1,159 +1,159 @@
1
- /**
2
- * Board overlap rules (owner, 2026-10-27: 团队方框需要避免遮盖和覆盖 — frames
3
- * must not overlap each other, and a card must not sit on a frame it is not
4
- * a member of).
5
- *
6
- * settleBoard() resolves both, deterministically:
7
- * - Frame vs frame: one frame of an overlapping pair steps aside along the
8
- * shortest of the four directions, carrying its members like a drag. The
9
- * frame the user just touched stays where it was put; a remote org frame
10
- * never moves on its own (gh#44: its rect is the user's), so a local frame
11
- * yields to it; two remote frames are left alone.
12
- * - Card vs frame: a card lying on a frame it does not belong to goes home
13
- * first — the first free grid seat inside its own frame (memberships in
14
- * state order) — and only a card with no frame (or no free seat) goes
15
- * outside: right of that frame, else below it, else down a column right of
16
- * every frame. No landing spot covers another card or a foreign frame, and
17
- * frames are never moved or resized by this step. Members are never moved
18
- * off their own frame; a member merely outside its frame (PR#85: a frame
19
- * moves only itself) is left where it is.
20
- * Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
21
- * and once when the board first has content — never on every poll (a poll
22
- * does not reposition what the user arranged).
23
- *
24
- * tidyMembers() is the gh#48 整理成员 grid, moved here from planning-view.
25
- * @module nexus-stage/planning-overlap
26
- */
27
- import { NODE_W, NODE_H, nodeRect, type FrameGeom, type WorldModel } from './world'
28
-
29
- /** Clearance kept between a moved frame or card and what it stepped off. */
30
- export const OVERLAP_GAP = 24
31
- /** Bound on resolution passes (a dense board settles in a few). */
32
- const MAX_PASSES = 200
33
-
34
- type Rect = { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
35
- const hit = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
36
-
37
- /** The shortest translation that takes `m` clear of `f` (plus the gap). */
38
- function stepAside(m: Rect, f: Rect): { dx: number; dy: number } {
39
- const right = f.x + f.w + OVERLAP_GAP - m.x
40
- const left = f.x - OVERLAP_GAP - (m.x + m.w)
41
- const down = f.y + f.h + OVERLAP_GAP - m.y
42
- const up = f.y - OVERLAP_GAP - (m.y + m.h)
43
- const dx = Math.abs(right) <= Math.abs(left) ? right : left
44
- const dy = Math.abs(down) <= Math.abs(up) ? down : up
45
- return Math.abs(dx) <= Math.abs(dy) ? { dx, dy: 0 } : { dx: 0, dy }
46
- }
47
-
48
- function moveFrame(model: WorldModel, name: string, dx: number, dy: number): void {
49
- const snap = model.beginFrameDrag(name)
50
- if (snap !== undefined) model.applyFrameDrag(snap, dx, dy)
51
- }
52
-
53
- /** Separate overlapping frames; true when anything moved. */
54
- export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
55
- let changed = false
56
- for (let pass = 0; pass < MAX_PASSES; pass++) {
57
- const frames = model.allFrames()
58
- let pair: [string, FrameGeom, string, FrameGeom] | undefined
59
- for (let i = 0; i < frames.length && pair === undefined; i++) {
60
- for (let j = i + 1; j < frames.length; j++) {
61
- const [a, ra] = frames[i]!
62
- const [b, rb] = frames[j]!
63
- if (remote.has(a) && remote.has(b)) continue
64
- if (hit(ra, rb)) { pair = [a, ra, b, rb]; break }
65
- }
66
- }
67
- if (pair === undefined) return changed
68
- const [a, ra, b, rb] = pair
69
- // Who yields: the lower stay score moves — remote 2 (never moves on its
70
- // own), the frame just placed 1, any other 0; a tie moves the later one
71
- // in board order. (A plain "a stays" check moved a remote b whenever a
72
- // was the frame just placed — order-dependent: PR #83 review.)
73
- const stay = (n: string): number => remote.has(n) ? 2 : n === touched ? 1 : 0
74
- const [mover, mr, fixed] = stay(a) < stay(b) ? [a, ra, rb] : [b, rb, ra]
75
- const { dx, dy } = stepAside(mr, fixed)
76
- moveFrame(model, mover, dx, dy)
77
- changed = true
78
- }
79
- return changed
80
- }
81
-
82
- /** Grid of the 整理成员 layout, shared by eviction seats. */
83
- const PAD = 28
84
- const GAP_X = 16
85
- const GAP_Y = 14
86
-
87
- /** Card-centred seats inside a frame, row by row (the 整理成员 grid). */
88
- function seatsIn(f: Rect): Array<{ x: number; y: number }> {
89
- const out: Array<{ x: number; y: number }> = []
90
- for (let y = f.y + PAD; y + NODE_H <= f.y + f.h - PAD; y += NODE_H + GAP_Y) {
91
- for (let x = f.x + PAD; x + NODE_W <= f.x + f.w - PAD; x += NODE_W + GAP_X) out.push({ x: x + NODE_W / 2, y: y + NODE_H / 2 })
92
- }
93
- return out
94
- }
95
-
96
- /** Move cards off frames they do not belong to; true when anything moved. */
97
- export function evictStrangers(model: WorldModel): boolean {
98
- let changed = false
99
- const frames = model.allFrames()
100
- const rectOf = new Map(frames)
101
- for (const n of model.allNodes()) {
102
- const own = new Set(n.memberships.map(m => m.team))
103
- const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
104
- if (host === undefined) continue
105
- const f = host[1]
106
- // Free = no foreign frame and no other card under it (cards read live: earlier evictions count).
107
- const free = (p: { x: number; y: number }): boolean => {
108
- const r = nodeRect(p)
109
- return frames.every(([name, g]) => own.has(name) || !hit(r, g)) &&
110
- model.allNodes().every(o => o.id === n.id || !hit(r, nodeRect(o)))
111
- }
112
- const home = n.memberships.map(m => rectOf.get(m.team)).filter((g): g is FrameGeom => g !== undefined)
113
- const seat = home.flatMap(seatsIn).find(free)
114
- const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
115
- const below = { x: n.x, y: f.y + f.h + OVERLAP_GAP + NODE_H / 2 }
116
- // Last resort: a column right of every frame, top-down from this frame. A card blocks
117
- // at most two seats of this pitch, so 2·cards+1 seats always hold a free one.
118
- const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
119
- const column = Array.from({ length: 2 * model.allNodes().length + 1 }, (_, i) => ({ x: farX, y: f.y + NODE_H / 2 + i * (NODE_H + GAP_Y) }))
120
- const to = seat ?? [right, below, ...column].find(free) ?? column[column.length - 1]!
121
- model.dragNodes([n.id], to.x - n.x, to.y - n.y)
122
- changed = true
123
- }
124
- return changed
125
- }
126
-
127
- /** Both rules, frames first (moving a frame can uncover or cover cards). */
128
- export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
129
- const framesMoved = separateFrames(model, remote, touched)
130
- const cardsMoved = evictStrangers(model)
131
- return framesMoved || cardsMoved
132
- }
133
-
134
- /**
135
- * gh#48 整理成员: inside every frame, the members that belong to exactly that
136
- * one team re-seat into a padded grid; the frame grows downward (bounded) to
137
- * fit them. Members of 2+ teams keep their positions.
138
- */
139
- export function tidyMembers(model: WorldModel): void {
140
- for (const [name, rect] of model.allFrames()) {
141
- const sole = model.teamMemberIds(name)
142
- .map(id => model.getNode(id))
143
- .filter((n): n is NonNullable<typeof n> => n !== undefined)
144
- .filter(n => n.memberships.length === 1)
145
- if (sole.length === 0) continue // empty frame / only shared members: untouched
146
- const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
147
- const rows = Math.ceil(sole.length / perRow)
148
- const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
149
- if (needed > rect.h) model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
150
- const startX = rect.x + PAD + NODE_W / 2
151
- let y = rect.y + PAD + NODE_H / 2
152
- sole.forEach((n, i) => {
153
- const col = i % perRow
154
- if (i > 0 && col === 0) y += NODE_H + GAP_Y
155
- const x = startX + col * (NODE_W + GAP_X)
156
- model.dragNodes([n.id], x - n.x, y - n.y)
157
- })
158
- }
159
- }
1
+ /**
2
+ * Board overlap rules (owner, 2026-10-27: 团队方框需要避免遮盖和覆盖 — frames
3
+ * must not overlap each other, and a card must not sit on a frame it is not
4
+ * a member of).
5
+ *
6
+ * settleBoard() resolves both, deterministically:
7
+ * - Frame vs frame: one frame of an overlapping pair steps aside along the
8
+ * shortest of the four directions, carrying its members like a drag. The
9
+ * frame the user just touched stays where it was put; a remote org frame
10
+ * never moves on its own (gh#44: its rect is the user's), so a local frame
11
+ * yields to it; two remote frames are left alone.
12
+ * - Card vs frame: a card lying on a frame it does not belong to goes home
13
+ * first — the first free grid seat inside its own frame (memberships in
14
+ * state order) — and only a card with no frame (or no free seat) goes
15
+ * outside: right of that frame, else below it, else down a column right of
16
+ * every frame. No landing spot covers another card or a foreign frame, and
17
+ * frames are never moved or resized by this step. Members are never moved
18
+ * off their own frame; a member merely outside its frame (PR#85: a frame
19
+ * moves only itself) is left where it is.
20
+ * Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
21
+ * and once when the board first has content — never on every poll (a poll
22
+ * does not reposition what the user arranged).
23
+ *
24
+ * tidyMembers() is the gh#48 整理成员 grid, moved here from planning-view.
25
+ * @module nexus-stage/planning-overlap
26
+ */
27
+ import { NODE_W, NODE_H, nodeRect, type FrameGeom, type WorldModel } from './world'
28
+
29
+ /** Clearance kept between a moved frame or card and what it stepped off. */
30
+ export const OVERLAP_GAP = 24
31
+ /** Bound on resolution passes (a dense board settles in a few). */
32
+ const MAX_PASSES = 200
33
+
34
+ type Rect = { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
35
+ const hit = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
36
+
37
+ /** The shortest translation that takes `m` clear of `f` (plus the gap). */
38
+ function stepAside(m: Rect, f: Rect): { dx: number; dy: number } {
39
+ const right = f.x + f.w + OVERLAP_GAP - m.x
40
+ const left = f.x - OVERLAP_GAP - (m.x + m.w)
41
+ const down = f.y + f.h + OVERLAP_GAP - m.y
42
+ const up = f.y - OVERLAP_GAP - (m.y + m.h)
43
+ const dx = Math.abs(right) <= Math.abs(left) ? right : left
44
+ const dy = Math.abs(down) <= Math.abs(up) ? down : up
45
+ return Math.abs(dx) <= Math.abs(dy) ? { dx, dy: 0 } : { dx: 0, dy }
46
+ }
47
+
48
+ function moveFrame(model: WorldModel, name: string, dx: number, dy: number): void {
49
+ const snap = model.beginFrameDrag(name)
50
+ if (snap !== undefined) model.applyFrameDrag(snap, dx, dy)
51
+ }
52
+
53
+ /** Separate overlapping frames; true when anything moved. */
54
+ export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
55
+ let changed = false
56
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
57
+ const frames = model.allFrames()
58
+ let pair: [string, FrameGeom, string, FrameGeom] | undefined
59
+ for (let i = 0; i < frames.length && pair === undefined; i++) {
60
+ for (let j = i + 1; j < frames.length; j++) {
61
+ const [a, ra] = frames[i]!
62
+ const [b, rb] = frames[j]!
63
+ if (remote.has(a) && remote.has(b)) continue
64
+ if (hit(ra, rb)) { pair = [a, ra, b, rb]; break }
65
+ }
66
+ }
67
+ if (pair === undefined) return changed
68
+ const [a, ra, b, rb] = pair
69
+ // Who yields: the lower stay score moves — remote 2 (never moves on its
70
+ // own), the frame just placed 1, any other 0; a tie moves the later one
71
+ // in board order. (A plain "a stays" check moved a remote b whenever a
72
+ // was the frame just placed — order-dependent: PR #83 review.)
73
+ const stay = (n: string): number => remote.has(n) ? 2 : n === touched ? 1 : 0
74
+ const [mover, mr, fixed] = stay(a) < stay(b) ? [a, ra, rb] : [b, rb, ra]
75
+ const { dx, dy } = stepAside(mr, fixed)
76
+ moveFrame(model, mover, dx, dy)
77
+ changed = true
78
+ }
79
+ return changed
80
+ }
81
+
82
+ /** Grid of the 整理成员 layout, shared by eviction seats. */
83
+ const PAD = 28
84
+ const GAP_X = 16
85
+ const GAP_Y = 14
86
+
87
+ /** Card-centred seats inside a frame, row by row (the 整理成员 grid). */
88
+ function seatsIn(f: Rect): Array<{ x: number; y: number }> {
89
+ const out: Array<{ x: number; y: number }> = []
90
+ for (let y = f.y + PAD; y + NODE_H <= f.y + f.h - PAD; y += NODE_H + GAP_Y) {
91
+ for (let x = f.x + PAD; x + NODE_W <= f.x + f.w - PAD; x += NODE_W + GAP_X) out.push({ x: x + NODE_W / 2, y: y + NODE_H / 2 })
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** Move cards off frames they do not belong to; true when anything moved. */
97
+ export function evictStrangers(model: WorldModel): boolean {
98
+ let changed = false
99
+ const frames = model.allFrames()
100
+ const rectOf = new Map(frames)
101
+ for (const n of model.allNodes()) {
102
+ const own = new Set(n.memberships.map(m => m.team))
103
+ const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
104
+ if (host === undefined) continue
105
+ const f = host[1]
106
+ // Free = no foreign frame and no other card under it (cards read live: earlier evictions count).
107
+ const free = (p: { x: number; y: number }): boolean => {
108
+ const r = nodeRect(p)
109
+ return frames.every(([name, g]) => own.has(name) || !hit(r, g)) &&
110
+ model.allNodes().every(o => o.id === n.id || !hit(r, nodeRect(o)))
111
+ }
112
+ const home = n.memberships.map(m => rectOf.get(m.team)).filter((g): g is FrameGeom => g !== undefined)
113
+ const seat = home.flatMap(seatsIn).find(free)
114
+ const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
115
+ const below = { x: n.x, y: f.y + f.h + OVERLAP_GAP + NODE_H / 2 }
116
+ // Last resort: a column right of every frame, top-down from this frame. A card blocks
117
+ // at most two seats of this pitch, so 2·cards+1 seats always hold a free one.
118
+ const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
119
+ const column = Array.from({ length: 2 * model.allNodes().length + 1 }, (_, i) => ({ x: farX, y: f.y + NODE_H / 2 + i * (NODE_H + GAP_Y) }))
120
+ const to = seat ?? [right, below, ...column].find(free) ?? column[column.length - 1]!
121
+ model.dragNodes([n.id], to.x - n.x, to.y - n.y)
122
+ changed = true
123
+ }
124
+ return changed
125
+ }
126
+
127
+ /** Both rules, frames first (moving a frame can uncover or cover cards). */
128
+ export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
129
+ const framesMoved = separateFrames(model, remote, touched)
130
+ const cardsMoved = evictStrangers(model)
131
+ return framesMoved || cardsMoved
132
+ }
133
+
134
+ /**
135
+ * gh#48 整理成员: inside every frame, the members that belong to exactly that
136
+ * one team re-seat into a padded grid; the frame grows downward (bounded) to
137
+ * fit them. Members of 2+ teams keep their positions.
138
+ */
139
+ export function tidyMembers(model: WorldModel): void {
140
+ for (const [name, rect] of model.allFrames()) {
141
+ const sole = model.teamMemberIds(name)
142
+ .map(id => model.getNode(id))
143
+ .filter((n): n is NonNullable<typeof n> => n !== undefined)
144
+ .filter(n => n.memberships.length === 1)
145
+ if (sole.length === 0) continue // empty frame / only shared members: untouched
146
+ const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
147
+ const rows = Math.ceil(sole.length / perRow)
148
+ const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
149
+ if (needed > rect.h) model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
150
+ const startX = rect.x + PAD + NODE_W / 2
151
+ let y = rect.y + PAD + NODE_H / 2
152
+ sole.forEach((n, i) => {
153
+ const col = i % perRow
154
+ if (i > 0 && col === 0) y += NODE_H + GAP_Y
155
+ const x = startX + col * (NODE_W + GAP_X)
156
+ model.dragNodes([n.id], x - n.x, y - n.y)
157
+ })
158
+ }
159
+ }
@@ -1,28 +1,28 @@
1
- /** Project the peer's last missed card read onto existing remote cards and frames. */
2
- import { peerHostOf, type RemoteTeamRow } from './federation'
3
-
4
- export const UNREACHABLE_HINT = '对端未应答(显示的是最后已知状态)'
5
-
6
- export function unreachableRefs(input: {
7
- remoteTeams?: ReadonlyArray<RemoteTeamRow>
8
- peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean }>
9
- }): { hosts: ReadonlySet<string>; members: ReadonlySet<string> } {
10
- const hosts = new Set<string>()
11
- for (const row of input.remoteTeams ?? []) if (row.unreachable === true && row.via) hosts.add(peerHostOf(row.via))
12
- for (const row of input.peerOrg ?? []) if (row.unreachable === true) hosts.add(peerHostOf(row.url))
13
- const members = new Set<string>()
14
- for (const row of input.remoteTeams ?? []) if (row.via && hosts.has(peerHostOf(row.via))) members.add(`member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`)
15
- return { hosts, members }
16
- }
17
-
18
- /** A mirror keeps its last known contents visible but warns until its owner responds again. */
19
- export function showFrameReachability(frame: HTMLElement, lost: boolean): void {
20
- frame.classList.toggle('is-unreachable', lost)
21
- const existing = frame.querySelector('.p-frame-unreachable')
22
- if (lost && existing === null) {
23
- const hint = document.createElement('span')
24
- hint.className = 'p-frame-unreachable'
25
- hint.textContent = UNREACHABLE_HINT
26
- frame.appendChild(hint)
27
- } else if (!lost) existing?.remove()
28
- }
1
+ /** Project the peer's last missed card read onto existing remote cards and frames. */
2
+ import { peerHostOf, type RemoteTeamRow } from './federation'
3
+
4
+ export const UNREACHABLE_HINT = '对端未应答(显示的是最后已知状态)'
5
+
6
+ export function unreachableRefs(input: {
7
+ remoteTeams?: ReadonlyArray<RemoteTeamRow>
8
+ peerOrg?: ReadonlyArray<{ url: string; unreachable?: boolean }>
9
+ }): { hosts: ReadonlySet<string>; members: ReadonlySet<string> } {
10
+ const hosts = new Set<string>()
11
+ for (const row of input.remoteTeams ?? []) if (row.unreachable === true && row.via) hosts.add(peerHostOf(row.via))
12
+ for (const row of input.peerOrg ?? []) if (row.unreachable === true) hosts.add(peerHostOf(row.url))
13
+ const members = new Set<string>()
14
+ for (const row of input.remoteTeams ?? []) if (row.via && hosts.has(peerHostOf(row.via))) members.add(`member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`)
15
+ return { hosts, members }
16
+ }
17
+
18
+ /** A mirror keeps its last known contents visible but warns until its owner responds again. */
19
+ export function showFrameReachability(frame: HTMLElement, lost: boolean): void {
20
+ frame.classList.toggle('is-unreachable', lost)
21
+ const existing = frame.querySelector('.p-frame-unreachable')
22
+ if (lost && existing === null) {
23
+ const hint = document.createElement('span')
24
+ hint.className = 'p-frame-unreachable'
25
+ hint.textContent = UNREACHABLE_HINT
26
+ frame.appendChild(hint)
27
+ } else if (!lost) existing?.remove()
28
+ }
@@ -1,121 +1,121 @@
1
- /**
2
- * 「一键重排」board relayout (supervisor order v2, upgrade of gh#48): a
3
- * deterministic whole-board re-seat — every LOCAL canvas team frame is
4
- * resized from its content (sole-member count drives cols/rows; width AND
5
- * height both derive from the grid), then the frames tile in a row flow
6
- * (constructively non-overlapping, no collision pass). Multi-team members
7
- * never enter the size derivation and never move (their dashed edges
8
- * express membership); remote org frames and frameless nodes are out of
9
- * scope. Pure geometry: same input, same output — pressing twice is
10
- * stable by construction.
11
- * @module nexus-stage/planning-relayout
12
- */
13
- import { NODE_W, NODE_H, type FrameGeom, type WorldModel } from './world'
14
-
15
- /** Inner padding between frame edge and the card grid (gh#48 family). */
16
- export const RELAYOUT_PAD = 28
17
- /** Card gaps inside a frame (same family as the tidy grid). */
18
- export const RELAYOUT_GAP_X = 16
19
- export const RELAYOUT_GAP_Y = 14
20
- /** Gap between neighbouring frames in the row-flow tiling. */
21
- export const FRAME_GAP = 48
22
- /** Column cap: perRow = min(4, ceil(sqrt(n))). */
23
- export const PER_ROW_CAP = 4
24
- /** Floor widths/heights: keeps the floating title band legible. */
25
- export const FRAME_MIN_W = 180
26
- export const FRAME_MIN_H = 90
27
- /** Default world-space wrap width of one tiling row. */
28
- export const RELAYOUT_WRAP = 1600
29
-
30
- /** One local frame to re-seat: its current rect and its SOLE member ids. */
31
- export interface RelayoutFrameInput {
32
- readonly name: string
33
- readonly rect: FrameGeom
34
- readonly soleIds: readonly string[]
35
- }
36
-
37
- /** The deterministic outcome: new frame rects and absolute card centers. */
38
- export interface RelayoutOutput {
39
- readonly frames: ReadonlyArray<{ name: string; rect: FrameGeom }>
40
- readonly seats: ReadonlyArray<{ id: string; x: number; y: number }>
41
- }
42
-
43
- /**
44
- * Derive the whole-board layout. Frame order in = tiling order out (the
45
- * caller passes a stable order, e.g. the model's insertion order, which
46
- * keeps the result deterministic across presses).
47
- */
48
- export function planBoardRelayout(input: {
49
- frames: ReadonlyArray<RelayoutFrameInput>
50
- wrapWidth?: number
51
- }): RelayoutOutput {
52
- const wrap = input.wrapWidth ?? RELAYOUT_WRAP
53
- // Pass 1 — content-derived size per frame (multi-team members excluded
54
- // upstream: soleIds carries exactly the re-seatable cards).
55
- const sized = input.frames.map(f => {
56
- const n = Math.max(f.soleIds.length, 1)
57
- const perRow = Math.min(PER_ROW_CAP, Math.max(1, Math.ceil(Math.sqrt(n))))
58
- const rows = Math.ceil(n / perRow)
59
- const w = Math.max(FRAME_MIN_W, RELAYOUT_PAD * 2 + perRow * NODE_W + (perRow - 1) * RELAYOUT_GAP_X)
60
- const h = Math.max(FRAME_MIN_H, RELAYOUT_PAD * 2 + rows * NODE_H + (rows - 1) * RELAYOUT_GAP_Y)
61
- return { name: f.name, w, h, rows, perRow, soleIds: f.soleIds }
62
- })
63
- // Pass 2 — row-flow tiling: left→right until the wrap width is full,
64
- // then advance one row by the tallest frame of the band. Constructive
65
- // disjointness: boxes on one band share the y band and never overlap in
66
- // x; bands stack with FRAME_GAP.
67
- const frames: Array<{ name: string; rect: FrameGeom }> = []
68
- const seats: Array<{ id: string; x: number; y: number }> = []
69
- let x = 0
70
- let y = 0
71
- let bandH = 0
72
- for (const f of sized) {
73
- if (x > 0 && x + f.w > wrap) {
74
- x = 0
75
- y += bandH + FRAME_GAP
76
- bandH = 0
77
- }
78
- const rect: FrameGeom = { x, y, w: f.w, h: f.h }
79
- frames.push({ name: f.name, rect })
80
- // Pass 3 — seat the sole cards on the new rect's grid (the gh#48 rule,
81
- // driven by the NEW frame geometry).
82
- const startX = rect.x + RELAYOUT_PAD + NODE_W / 2
83
- let cy = rect.y + RELAYOUT_PAD + NODE_H / 2
84
- f.soleIds.forEach((id, i) => {
85
- const col = i % f.perRow
86
- if (i > 0 && col === 0) cy += NODE_H + RELAYOUT_GAP_Y
87
- seats.push({ id, x: startX + col * (NODE_W + RELAYOUT_GAP_X), y: cy })
88
- })
89
- x += f.w + FRAME_GAP
90
- bandH = Math.max(bandH, f.h)
91
- }
92
- return { frames, seats }
93
- }
94
-
95
- /**
96
- * The model adapter (supervisor order v2): collect the LOCAL frames and
97
- * their sole members (remote org frames and multi-team members are out of
98
- * scope by spec), run the pure planner, apply the rects and seat moves
99
- * through the model's own mutation seam. Returns false when nothing is in
100
- * scope (the caller decides whether to dirty the layout).
101
- */
102
- export function applyBoardRelayout(model: WorldModel, remoteNames: ReadonlySet<string>): boolean {
103
- const frames: Array<{ name: string; rect: FrameGeom; soleIds: string[] }> = []
104
- for (const [name, rect] of model.allFrames()) {
105
- if (remoteNames.has(name)) continue // remote org frames: untouched
106
- const soleIds = model.teamMemberIds(name)
107
- .map(id => model.getNode(id))
108
- .filter((n): n is NonNullable<typeof n> => n !== undefined)
109
- .filter(n => n.memberships.length === 1)
110
- .map(n => n.id)
111
- frames.push({ name, rect, soleIds })
112
- }
113
- if (frames.length === 0) return false
114
- const out = planBoardRelayout({ frames })
115
- for (const f of out.frames) model.setFrame(f.name, f.rect)
116
- for (const seat of out.seats) {
117
- const n = model.getNode(seat.id)
118
- if (n !== undefined) model.dragNodes([seat.id], seat.x - n.x, seat.y - n.y)
119
- }
120
- return true
121
- }
1
+ /**
2
+ * 「一键重排」board relayout (supervisor order v2, upgrade of gh#48): a
3
+ * deterministic whole-board re-seat — every LOCAL canvas team frame is
4
+ * resized from its content (sole-member count drives cols/rows; width AND
5
+ * height both derive from the grid), then the frames tile in a row flow
6
+ * (constructively non-overlapping, no collision pass). Multi-team members
7
+ * never enter the size derivation and never move (their dashed edges
8
+ * express membership); remote org frames and frameless nodes are out of
9
+ * scope. Pure geometry: same input, same output — pressing twice is
10
+ * stable by construction.
11
+ * @module nexus-stage/planning-relayout
12
+ */
13
+ import { NODE_W, NODE_H, type FrameGeom, type WorldModel } from './world'
14
+
15
+ /** Inner padding between frame edge and the card grid (gh#48 family). */
16
+ export const RELAYOUT_PAD = 28
17
+ /** Card gaps inside a frame (same family as the tidy grid). */
18
+ export const RELAYOUT_GAP_X = 16
19
+ export const RELAYOUT_GAP_Y = 14
20
+ /** Gap between neighbouring frames in the row-flow tiling. */
21
+ export const FRAME_GAP = 48
22
+ /** Column cap: perRow = min(4, ceil(sqrt(n))). */
23
+ export const PER_ROW_CAP = 4
24
+ /** Floor widths/heights: keeps the floating title band legible. */
25
+ export const FRAME_MIN_W = 180
26
+ export const FRAME_MIN_H = 90
27
+ /** Default world-space wrap width of one tiling row. */
28
+ export const RELAYOUT_WRAP = 1600
29
+
30
+ /** One local frame to re-seat: its current rect and its SOLE member ids. */
31
+ export interface RelayoutFrameInput {
32
+ readonly name: string
33
+ readonly rect: FrameGeom
34
+ readonly soleIds: readonly string[]
35
+ }
36
+
37
+ /** The deterministic outcome: new frame rects and absolute card centers. */
38
+ export interface RelayoutOutput {
39
+ readonly frames: ReadonlyArray<{ name: string; rect: FrameGeom }>
40
+ readonly seats: ReadonlyArray<{ id: string; x: number; y: number }>
41
+ }
42
+
43
+ /**
44
+ * Derive the whole-board layout. Frame order in = tiling order out (the
45
+ * caller passes a stable order, e.g. the model's insertion order, which
46
+ * keeps the result deterministic across presses).
47
+ */
48
+ export function planBoardRelayout(input: {
49
+ frames: ReadonlyArray<RelayoutFrameInput>
50
+ wrapWidth?: number
51
+ }): RelayoutOutput {
52
+ const wrap = input.wrapWidth ?? RELAYOUT_WRAP
53
+ // Pass 1 — content-derived size per frame (multi-team members excluded
54
+ // upstream: soleIds carries exactly the re-seatable cards).
55
+ const sized = input.frames.map(f => {
56
+ const n = Math.max(f.soleIds.length, 1)
57
+ const perRow = Math.min(PER_ROW_CAP, Math.max(1, Math.ceil(Math.sqrt(n))))
58
+ const rows = Math.ceil(n / perRow)
59
+ const w = Math.max(FRAME_MIN_W, RELAYOUT_PAD * 2 + perRow * NODE_W + (perRow - 1) * RELAYOUT_GAP_X)
60
+ const h = Math.max(FRAME_MIN_H, RELAYOUT_PAD * 2 + rows * NODE_H + (rows - 1) * RELAYOUT_GAP_Y)
61
+ return { name: f.name, w, h, rows, perRow, soleIds: f.soleIds }
62
+ })
63
+ // Pass 2 — row-flow tiling: left→right until the wrap width is full,
64
+ // then advance one row by the tallest frame of the band. Constructive
65
+ // disjointness: boxes on one band share the y band and never overlap in
66
+ // x; bands stack with FRAME_GAP.
67
+ const frames: Array<{ name: string; rect: FrameGeom }> = []
68
+ const seats: Array<{ id: string; x: number; y: number }> = []
69
+ let x = 0
70
+ let y = 0
71
+ let bandH = 0
72
+ for (const f of sized) {
73
+ if (x > 0 && x + f.w > wrap) {
74
+ x = 0
75
+ y += bandH + FRAME_GAP
76
+ bandH = 0
77
+ }
78
+ const rect: FrameGeom = { x, y, w: f.w, h: f.h }
79
+ frames.push({ name: f.name, rect })
80
+ // Pass 3 — seat the sole cards on the new rect's grid (the gh#48 rule,
81
+ // driven by the NEW frame geometry).
82
+ const startX = rect.x + RELAYOUT_PAD + NODE_W / 2
83
+ let cy = rect.y + RELAYOUT_PAD + NODE_H / 2
84
+ f.soleIds.forEach((id, i) => {
85
+ const col = i % f.perRow
86
+ if (i > 0 && col === 0) cy += NODE_H + RELAYOUT_GAP_Y
87
+ seats.push({ id, x: startX + col * (NODE_W + RELAYOUT_GAP_X), y: cy })
88
+ })
89
+ x += f.w + FRAME_GAP
90
+ bandH = Math.max(bandH, f.h)
91
+ }
92
+ return { frames, seats }
93
+ }
94
+
95
+ /**
96
+ * The model adapter (supervisor order v2): collect the LOCAL frames and
97
+ * their sole members (remote org frames and multi-team members are out of
98
+ * scope by spec), run the pure planner, apply the rects and seat moves
99
+ * through the model's own mutation seam. Returns false when nothing is in
100
+ * scope (the caller decides whether to dirty the layout).
101
+ */
102
+ export function applyBoardRelayout(model: WorldModel, remoteNames: ReadonlySet<string>): boolean {
103
+ const frames: Array<{ name: string; rect: FrameGeom; soleIds: string[] }> = []
104
+ for (const [name, rect] of model.allFrames()) {
105
+ if (remoteNames.has(name)) continue // remote org frames: untouched
106
+ const soleIds = model.teamMemberIds(name)
107
+ .map(id => model.getNode(id))
108
+ .filter((n): n is NonNullable<typeof n> => n !== undefined)
109
+ .filter(n => n.memberships.length === 1)
110
+ .map(n => n.id)
111
+ frames.push({ name, rect, soleIds })
112
+ }
113
+ if (frames.length === 0) return false
114
+ const out = planBoardRelayout({ frames })
115
+ for (const f of out.frames) model.setFrame(f.name, f.rect)
116
+ for (const seat of out.seats) {
117
+ const n = model.getNode(seat.id)
118
+ if (n !== undefined) model.dragNodes([seat.id], seat.x - n.x, seat.y - n.y)
119
+ }
120
+ return true
121
+ }