@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43

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 (72) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  6. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  7. package/assets/stageDist/index.html +12 -12
  8. package/cli/a2a.mjs +24 -4
  9. package/lib/canvas-store.js +249 -17
  10. package/lib/client.js +62 -72
  11. package/lib/client.js.map +1 -1
  12. package/lib/config.js +1 -0
  13. package/lib/faces/admin.js +103 -63
  14. package/lib/faces/wire.js +3 -1
  15. package/lib/index.js +116 -22
  16. package/lib/origin-claim.js +4 -0
  17. package/lib/skill/SKILL.md +117 -117
  18. package/lib/skill/references/collaboration.md +428 -428
  19. package/lib/skill/references/discovery.md +78 -78
  20. package/lib/skill/references/intake.md +107 -107
  21. package/lib/skill/references/membership.md +73 -73
  22. package/lib/skill/references/pitfalls.md +51 -51
  23. package/lib/skill/references/router.md +96 -96
  24. package/lib/skill/references/routing.md +65 -65
  25. package/lib/types/canvas-store.d.ts +48 -1
  26. package/lib/types/config.d.ts +2 -0
  27. package/lib/types/faces/admin.d.ts +2 -0
  28. package/lib/types/origin-claim.d.ts +2 -0
  29. package/nexus-stage/package.json +21 -21
  30. package/nexus-stage/scripts/mockhost.py +261 -261
  31. package/nexus-stage/src/canvas-ops.ts +191 -191
  32. package/nexus-stage/src/canvas-wire.ts +164 -164
  33. package/nexus-stage/src/cli-home-picker.ts +42 -42
  34. package/nexus-stage/src/fault.ts +56 -56
  35. package/nexus-stage/src/federation-overlay.ts +103 -103
  36. package/nexus-stage/src/federation.ts +241 -241
  37. package/nexus-stage/src/layout-doc.ts +145 -145
  38. package/nexus-stage/src/main.ts +520 -513
  39. package/nexus-stage/src/planning-card-text.ts +73 -73
  40. package/nexus-stage/src/planning-card.ts +63 -63
  41. package/nexus-stage/src/planning-edges.ts +26 -26
  42. package/nexus-stage/src/planning-fit.ts +45 -45
  43. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  44. package/nexus-stage/src/planning-legend.ts +39 -39
  45. package/nexus-stage/src/planning-member-handles.ts +61 -61
  46. package/nexus-stage/src/planning-menus.ts +422 -422
  47. package/nexus-stage/src/planning-mirror.ts +75 -75
  48. package/nexus-stage/src/planning-nodelist.ts +294 -294
  49. package/nexus-stage/src/planning-overlap.ts +159 -159
  50. package/nexus-stage/src/planning-reachability.ts +28 -28
  51. package/nexus-stage/src/planning-relayout.ts +121 -121
  52. package/nexus-stage/src/planning-roster.ts +15 -15
  53. package/nexus-stage/src/planning-search.ts +168 -168
  54. package/nexus-stage/src/planning-types.ts +115 -115
  55. package/nexus-stage/src/planning-undo.ts +68 -68
  56. package/nexus-stage/src/planning-view.ts +1499 -1499
  57. package/nexus-stage/src/planning.css +340 -340
  58. package/nexus-stage/src/seat-name-editor.ts +31 -31
  59. package/nexus-stage/src/session-drag.ts +64 -64
  60. package/nexus-stage/src/viewport.ts +106 -106
  61. package/nexus-stage/src/wb-chat.ts +684 -684
  62. package/nexus-stage/src/wb-directory.ts +237 -237
  63. package/nexus-stage/src/wb-ledger.ts +279 -279
  64. package/nexus-stage/src/wb-names.ts +43 -43
  65. package/nexus-stage/src/wb-rail.ts +147 -147
  66. package/nexus-stage/src/workbench.css +388 -388
  67. package/nexus-stage/src/workbench.ts +1294 -1272
  68. package/nexus-stage/src/world.ts +509 -509
  69. package/nexus-stage/tsconfig.json +24 -24
  70. package/nexus-stage/vite.config.ts +15 -15
  71. package/package.json +1 -1
  72. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
@@ -1,73 +1,73 @@
1
- /**
2
- * Board card text (review 2026-10-27): what a card's title, second line and
3
- * tooltip say. Extracted from planning-view so the card rules read in one
4
- * place (and the view stays under its M1 module-size cap).
5
- *
6
- * - Session cards used to read `dsh/11111111 · scout-01` on the second line:
7
- * the title repeated, the handle truncated on every card. The line now
8
- * carries what the session is FOR (its description) and falls back to the
9
- * handle; the handle always rides the tooltip.
10
- * - Peer cards titled `192.168.3.157:13080`, clipped by the peer badge with no
11
- * break point. When the host publishes exactly one named team (its host
12
- * row), that name is the title and the address moves to the second line.
13
- * @module nexus-stage/planning-card-text
14
- */
15
- import { displayName } from './stage-name'
16
- import type { RemoteTeamRow } from './federation'
17
- import type { WorldNode } from './world'
18
-
19
- export interface CardText { readonly title: string; readonly sub: string; readonly tip: string }
20
-
21
- /**
22
- * A description as one plain line. /state `description` is the session's
23
- * recent-activity excerpt — the head of its last reply, newlines folded,
24
- * capped at 160 chars — so on a real host it reads `# ✅ 已纠正——…`,
25
- * `**全绿**`, `` `direct-…` `` and folded tables (`| 环节 | 结果 | |---|`).
26
- * The card keeps the prose: it stops at the first table or code fence and
27
- * drops heading, emphasis, code and link markup.
28
- */
29
- export function plainLine(text: string): string {
30
- let s = text
31
- const stop = s.search(/\|| ```|^```/)
32
- if (stop >= 0) s = s.slice(0, stop)
33
- return s
34
- .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [text](url) → text
35
- .replace(/(^|\s)#{1,6}\s+/g, '$1') // headings
36
- .replace(/\*\*|__|`/g, '') // bold, code
37
- .replace(/\s+/g, ' ')
38
- .trim()
39
- }
40
-
41
- /** The remote-team summary a peer card carries: score + count + first names. */
42
- function remoteSummary(rows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): string {
43
- const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
44
- const head = names.slice(0, 2).join('、')
45
- const more = names.length > 2 ? ` +${names.length - 2}` : ''
46
- return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
47
- }
48
-
49
- export function cardText(n: WorldNode, remoteRows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): CardText {
50
- // gh#38: member cards show their display NAME as the title (the whole
51
- // point is picking a person).
52
- if (n.id.startsWith('member-')) {
53
- const title = n.name !== undefined && n.name !== '' ? n.name : n.team
54
- return { title, sub: n.team, tip: n.team }
55
- }
56
- if (n.remote === true) {
57
- const host = n.id.slice('peer-'.length)
58
- const named = remoteRows.length === 1 && remoteRows[0]!.name !== undefined && remoteRows[0]!.name !== '' ? remoteRows[0]!.name : undefined
59
- const summary = remoteSummary(remoteRows, score)
60
- return named !== undefined
61
- ? { title: named, sub: `${host} · ${summary}`, tip: `${named}\n${host}\n${summary}` }
62
- : { title: host, sub: summary, tip: `${host}\n${summary}` }
63
- }
64
- const title = displayName(n)
65
- const handle = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
66
- const desc = plainLine(n.description ?? '')
67
- // When the title fell back to the team label the handle would repeat it
68
- // verbatim; then only the suffixes remain.
69
- const base = desc !== '' ? desc : handle === title ? '' : n.team
70
- const suffix = (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
71
- + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
72
- return { title, sub: base + suffix, tip: desc !== '' ? `${handle}\n${desc}` : handle }
73
- }
1
+ /**
2
+ * Board card text (review 2026-10-27): what a card's title, second line and
3
+ * tooltip say. Extracted from planning-view so the card rules read in one
4
+ * place (and the view stays under its M1 module-size cap).
5
+ *
6
+ * - Session cards used to read `dsh/11111111 · scout-01` on the second line:
7
+ * the title repeated, the handle truncated on every card. The line now
8
+ * carries what the session is FOR (its description) and falls back to the
9
+ * handle; the handle always rides the tooltip.
10
+ * - Peer cards titled `192.168.3.157:13080`, clipped by the peer badge with no
11
+ * break point. When the host publishes exactly one named team (its host
12
+ * row), that name is the title and the address moves to the second line.
13
+ * @module nexus-stage/planning-card-text
14
+ */
15
+ import { displayName } from './stage-name'
16
+ import type { RemoteTeamRow } from './federation'
17
+ import type { WorldNode } from './world'
18
+
19
+ export interface CardText { readonly title: string; readonly sub: string; readonly tip: string }
20
+
21
+ /**
22
+ * A description as one plain line. /state `description` is the session's
23
+ * recent-activity excerpt — the head of its last reply, newlines folded,
24
+ * capped at 160 chars — so on a real host it reads `# ✅ 已纠正——…`,
25
+ * `**全绿**`, `` `direct-…` `` and folded tables (`| 环节 | 结果 | |---|`).
26
+ * The card keeps the prose: it stops at the first table or code fence and
27
+ * drops heading, emphasis, code and link markup.
28
+ */
29
+ export function plainLine(text: string): string {
30
+ let s = text
31
+ const stop = s.search(/\|| ```|^```/)
32
+ if (stop >= 0) s = s.slice(0, stop)
33
+ return s
34
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [text](url) → text
35
+ .replace(/(^|\s)#{1,6}\s+/g, '$1') // headings
36
+ .replace(/\*\*|__|`/g, '') // bold, code
37
+ .replace(/\s+/g, ' ')
38
+ .trim()
39
+ }
40
+
41
+ /** The remote-team summary a peer card carries: score + count + first names. */
42
+ function remoteSummary(rows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): string {
43
+ const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
44
+ const head = names.slice(0, 2).join('、')
45
+ const more = names.length > 2 ? ` +${names.length - 2}` : ''
46
+ return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
47
+ }
48
+
49
+ export function cardText(n: WorldNode, remoteRows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): CardText {
50
+ // gh#38: member cards show their display NAME as the title (the whole
51
+ // point is picking a person).
52
+ if (n.id.startsWith('member-')) {
53
+ const title = n.name !== undefined && n.name !== '' ? n.name : n.team
54
+ return { title, sub: n.team, tip: n.team }
55
+ }
56
+ if (n.remote === true) {
57
+ const host = n.id.slice('peer-'.length)
58
+ const named = remoteRows.length === 1 && remoteRows[0]!.name !== undefined && remoteRows[0]!.name !== '' ? remoteRows[0]!.name : undefined
59
+ const summary = remoteSummary(remoteRows, score)
60
+ return named !== undefined
61
+ ? { title: named, sub: `${host} · ${summary}`, tip: `${named}\n${host}\n${summary}` }
62
+ : { title: host, sub: summary, tip: `${host}\n${summary}` }
63
+ }
64
+ const title = displayName(n)
65
+ const handle = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
66
+ const desc = plainLine(n.description ?? '')
67
+ // When the title fell back to the team label the handle would repeat it
68
+ // verbatim; then only the suffixes remain.
69
+ const base = desc !== '' ? desc : handle === title ? '' : n.team
70
+ const suffix = (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
71
+ + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
72
+ return { title, sub: base + suffix, tip: desc !== '' ? `${handle}\n${desc}` : handle }
73
+ }
@@ -1,63 +1,63 @@
1
- /**
2
- * Board node card: the DOM skeleton and the right-hand chip. Extracted from planning-view so the card's static
3
- * structure reads in one place and the view stays under its M1 module-size cap (1507 lines; it reached 1529 with
4
- * the 工作中/空闲 chip). Pure DOM helpers — no view state; renderNodes still decides what each card shows.
5
- * @module nexus-stage/planning-card
6
- */
7
- import type { PlanningSession } from './planning-types'
8
- import type { WorldNode } from './world'
9
-
10
- /** The card's 工作中/空闲 chip text; empty when the host reported no status (older host, cold or remote node). */
11
- export function workLabel(work: Pick<PlanningSession, 'status' | 'queued'> | undefined): string {
12
- if (work?.status === undefined) return ''
13
- const base = work.status === 'running' ? '工作中' : '空闲'
14
- return typeof work.queued === 'number' && work.queued > 0 ? `${base} · 排队 ${String(work.queued)}` : base
15
- }
16
-
17
- /** A fresh `.p-node` card: title row (dot + text), second row (description + 工作中/空闲 chip), and the right-hand chip. */
18
- export function createCardElement(id: string): HTMLDivElement {
19
- const el = document.createElement('div')
20
- el.className = 'p-node'
21
- el.dataset.id = id
22
- el.tabIndex = 0
23
- const nm = document.createElement('div')
24
- nm.className = 'nm'
25
- const dot = document.createElement('span')
26
- dot.className = 'dot'
27
- const name = document.createElement('span')
28
- name.className = 'nm-text'
29
- nm.append(dot, name)
30
- const sub = document.createElement('div')
31
- sub.className = 'sub mono'
32
- const prio = document.createElement('span')
33
- prio.className = 'prio mono'
34
- // Second line: the description, with the 工作中/空闲 chip at its right end — the title keeps its full width.
35
- const work = document.createElement('span')
36
- work.className = 'work'
37
- const subRow = document.createElement('div')
38
- subRow.className = 'sub-row'
39
- subRow.append(sub, work)
40
- el.append(nm, subRow, prio)
41
- return el
42
- }
43
-
44
- /**
45
- * The right-hand chip. Peer cards show the link badge (gh#20 W-A: linked green / stranger gray, then the
46
- * verified tick/cross; an absent link field from an older host renders the neutral 'peer'). A remote MEMBER card
47
- * (gh#38) is one session on that host, not the host: it reads 成员, colored and ticked by its host's link
48
- * (it used to fall through to 'peer' and looked like a host card). Session cards show their first membership
49
- * priority `P<n>`. Writes only on change.
50
- */
51
- export function paintPrio(prioEl: HTMLElement, n: WorldNode, info: { link?: 'linked' | 'stranger'; verified?: boolean } | undefined): void {
52
- if (n.remote === true) {
53
- const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
54
- if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
55
- const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
56
- const badge = (n.id.startsWith('member-') ? '成员' : info?.link ?? 'peer') + mark
57
- if (prioEl.textContent !== badge) prioEl.textContent = badge
58
- } else {
59
- if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
60
- const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
61
- if (prioEl.textContent !== prioText) prioEl.textContent = prioText
62
- }
63
- }
1
+ /**
2
+ * Board node card: the DOM skeleton and the right-hand chip. Extracted from planning-view so the card's static
3
+ * structure reads in one place and the view stays under its M1 module-size cap (1507 lines; it reached 1529 with
4
+ * the 工作中/空闲 chip). Pure DOM helpers — no view state; renderNodes still decides what each card shows.
5
+ * @module nexus-stage/planning-card
6
+ */
7
+ import type { PlanningSession } from './planning-types'
8
+ import type { WorldNode } from './world'
9
+
10
+ /** The card's 工作中/空闲 chip text; empty when the host reported no status (older host, cold or remote node). */
11
+ export function workLabel(work: Pick<PlanningSession, 'status' | 'queued'> | undefined): string {
12
+ if (work?.status === undefined) return ''
13
+ const base = work.status === 'running' ? '工作中' : '空闲'
14
+ return typeof work.queued === 'number' && work.queued > 0 ? `${base} · 排队 ${String(work.queued)}` : base
15
+ }
16
+
17
+ /** A fresh `.p-node` card: title row (dot + text), second row (description + 工作中/空闲 chip), and the right-hand chip. */
18
+ export function createCardElement(id: string): HTMLDivElement {
19
+ const el = document.createElement('div')
20
+ el.className = 'p-node'
21
+ el.dataset.id = id
22
+ el.tabIndex = 0
23
+ const nm = document.createElement('div')
24
+ nm.className = 'nm'
25
+ const dot = document.createElement('span')
26
+ dot.className = 'dot'
27
+ const name = document.createElement('span')
28
+ name.className = 'nm-text'
29
+ nm.append(dot, name)
30
+ const sub = document.createElement('div')
31
+ sub.className = 'sub mono'
32
+ const prio = document.createElement('span')
33
+ prio.className = 'prio mono'
34
+ // Second line: the description, with the 工作中/空闲 chip at its right end — the title keeps its full width.
35
+ const work = document.createElement('span')
36
+ work.className = 'work'
37
+ const subRow = document.createElement('div')
38
+ subRow.className = 'sub-row'
39
+ subRow.append(sub, work)
40
+ el.append(nm, subRow, prio)
41
+ return el
42
+ }
43
+
44
+ /**
45
+ * The right-hand chip. Peer cards show the link badge (gh#20 W-A: linked green / stranger gray, then the
46
+ * verified tick/cross; an absent link field from an older host renders the neutral 'peer'). A remote MEMBER card
47
+ * (gh#38) is one session on that host, not the host: it reads 成员, colored and ticked by its host's link
48
+ * (it used to fall through to 'peer' and looked like a host card). Session cards show their first membership
49
+ * priority `P<n>`. Writes only on change.
50
+ */
51
+ export function paintPrio(prioEl: HTMLElement, n: WorldNode, info: { link?: 'linked' | 'stranger'; verified?: boolean } | undefined): void {
52
+ if (n.remote === true) {
53
+ const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
54
+ if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
55
+ const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
56
+ const badge = (n.id.startsWith('member-') ? '成员' : info?.link ?? 'peer') + mark
57
+ if (prioEl.textContent !== badge) prioEl.textContent = badge
58
+ } else {
59
+ if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
60
+ const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
61
+ if (prioEl.textContent !== prioText) prioEl.textContent = prioText
62
+ }
63
+ }
@@ -1,26 +1,26 @@
1
- /**
2
- * The membership spokes' SVG layer (edges.ts stays pure geometry). Moved
3
- * out of planning-view (module-size cap) when pointercancel landed.
4
- * @module nexus-stage/planning-edges
5
- */
6
- import { starEdges } from './edges'
7
- import type { WorldModel } from './world'
8
-
9
- /**
10
- * Stateless, rebuilt wholesale. Derived from the MODEL (not the host's
11
- * teams): optimistic membership changes must repaint their edges
12
- * immediately, before the host confirms.
13
- */
14
- export function drawMemberEdges(svg: SVGSVGElement, model: WorldModel): void {
15
- while (svg.firstChild !== null) svg.firstChild.remove()
16
- const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
17
- for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
18
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
19
- line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
20
- line.setAttribute('x1', String(e.x1))
21
- line.setAttribute('y1', String(e.y1))
22
- line.setAttribute('x2', String(e.x2))
23
- line.setAttribute('y2', String(e.y2))
24
- svg.appendChild(line)
25
- }
26
- }
1
+ /**
2
+ * The membership spokes' SVG layer (edges.ts stays pure geometry). Moved
3
+ * out of planning-view (module-size cap) when pointercancel landed.
4
+ * @module nexus-stage/planning-edges
5
+ */
6
+ import { starEdges } from './edges'
7
+ import type { WorldModel } from './world'
8
+
9
+ /**
10
+ * Stateless, rebuilt wholesale. Derived from the MODEL (not the host's
11
+ * teams): optimistic membership changes must repaint their edges
12
+ * immediately, before the host confirms.
13
+ */
14
+ export function drawMemberEdges(svg: SVGSVGElement, model: WorldModel): void {
15
+ while (svg.firstChild !== null) svg.firstChild.remove()
16
+ const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
17
+ for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
18
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
19
+ line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
20
+ line.setAttribute('x1', String(e.x1))
21
+ line.setAttribute('y1', String(e.y1))
22
+ line.setAttribute('x2', String(e.x2))
23
+ line.setAttribute('y2', String(e.y2))
24
+ svg.appendChild(line)
25
+ }
26
+ }
@@ -1,45 +1,45 @@
1
- /**
2
- * The board's 适配 (fit) framing (review 2026-10-27).
3
- *
4
- * 适配 used to fit the local content into the whole board at any scale: a
5
- * small board zoomed to 167%, the top frame's title slid under the toolbar,
6
- * and the peer cards on the right were cut off. 适配 now frames what the
7
- * first-run fit frames:
8
- * - local content plus the peer cards;
9
- * - the frame titles that sit above their frames;
10
- * - never magnified past 100%;
11
- * - the area below the HUD (toolbar / status chip), not under it.
12
- * @module nexus-stage/planning-fit
13
- */
14
- import { FRAME_HEAD_TOP } from './edges'
15
- import { fitView, type LayoutViewport, type WorldBounds } from './viewport'
16
- import { nodeRect, type WorldNode } from './world'
17
-
18
- /** Local content bounds ∪ peer cards, lifted to include the frame titles
19
- * (a title straddles its frame's top border, FRAME_HEAD_TOP above it). */
20
- export function boardBounds(content: WorldBounds | null, nodes: Iterable<WorldNode>): WorldBounds | null {
21
- let b = content === null ? null : { ...content, minY: content.minY + FRAME_HEAD_TOP }
22
- for (const n of nodes) {
23
- if (n.remote !== true) continue
24
- const r = nodeRect(n)
25
- b = b === null
26
- ? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
27
- : { minX: Math.min(b.minX, r.x), minY: Math.min(b.minY, r.y), maxX: Math.max(b.maxX, r.x + r.w), maxY: Math.max(b.maxY, r.y + r.h) }
28
- }
29
- return b
30
- }
31
-
32
- /** Fit `bounds` at ≤100% into the board area below the HUD's bottom edge
33
- * (`hudBottom` px from the board's top; 0 when the HUD is not laid out). */
34
- export function fitBelowHud(bounds: WorldBounds | null, viewW: number, viewH: number, hudBottom: number): LayoutViewport {
35
- const inset = hudBottom > 0 && hudBottom < viewH / 2 ? hudBottom : 0
36
- const vp = fitView(bounds, viewW, viewH - inset, 1)
37
- return inset === 0 || bounds === null ? vp : { ...vp, y: vp.y - inset / vp.scale }
38
- }
39
-
40
- /** The HUD's bottom edge inside the board root: the lowest of the toolbar
41
- * and status chip, plus a small gap; 0 before layout (jsdom, hidden). */
42
- export function hudBottomOf(...els: ReadonlyArray<HTMLElement>): number {
43
- const bottom = Math.max(0, ...els.map(e => e.offsetHeight > 0 ? e.offsetTop + e.offsetHeight : 0))
44
- return bottom > 0 ? bottom + 8 : 0
45
- }
1
+ /**
2
+ * The board's 适配 (fit) framing (review 2026-10-27).
3
+ *
4
+ * 适配 used to fit the local content into the whole board at any scale: a
5
+ * small board zoomed to 167%, the top frame's title slid under the toolbar,
6
+ * and the peer cards on the right were cut off. 适配 now frames what the
7
+ * first-run fit frames:
8
+ * - local content plus the peer cards;
9
+ * - the frame titles that sit above their frames;
10
+ * - never magnified past 100%;
11
+ * - the area below the HUD (toolbar / status chip), not under it.
12
+ * @module nexus-stage/planning-fit
13
+ */
14
+ import { FRAME_HEAD_TOP } from './edges'
15
+ import { fitView, type LayoutViewport, type WorldBounds } from './viewport'
16
+ import { nodeRect, type WorldNode } from './world'
17
+
18
+ /** Local content bounds ∪ peer cards, lifted to include the frame titles
19
+ * (a title straddles its frame's top border, FRAME_HEAD_TOP above it). */
20
+ export function boardBounds(content: WorldBounds | null, nodes: Iterable<WorldNode>): WorldBounds | null {
21
+ let b = content === null ? null : { ...content, minY: content.minY + FRAME_HEAD_TOP }
22
+ for (const n of nodes) {
23
+ if (n.remote !== true) continue
24
+ const r = nodeRect(n)
25
+ b = b === null
26
+ ? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
27
+ : { minX: Math.min(b.minX, r.x), minY: Math.min(b.minY, r.y), maxX: Math.max(b.maxX, r.x + r.w), maxY: Math.max(b.maxY, r.y + r.h) }
28
+ }
29
+ return b
30
+ }
31
+
32
+ /** Fit `bounds` at ≤100% into the board area below the HUD's bottom edge
33
+ * (`hudBottom` px from the board's top; 0 when the HUD is not laid out). */
34
+ export function fitBelowHud(bounds: WorldBounds | null, viewW: number, viewH: number, hudBottom: number): LayoutViewport {
35
+ const inset = hudBottom > 0 && hudBottom < viewH / 2 ? hudBottom : 0
36
+ const vp = fitView(bounds, viewW, viewH - inset, 1)
37
+ return inset === 0 || bounds === null ? vp : { ...vp, y: vp.y - inset / vp.scale }
38
+ }
39
+
40
+ /** The HUD's bottom edge inside the board root: the lowest of the toolbar
41
+ * and status chip, plus a small gap; 0 before layout (jsdom, hidden). */
42
+ export function hudBottomOf(...els: ReadonlyArray<HTMLElement>): number {
43
+ const bottom = Math.max(0, ...els.map(e => e.offsetHeight > 0 ? e.offsetTop + e.offsetHeight : 0))
44
+ return bottom > 0 ? bottom + 8 : 0
45
+ }
@@ -1,13 +1,13 @@
1
- /** Stable team hue shared by canvas and roster frames. */
2
- import { FRAME_HUES } from './tokens'
3
-
4
- export function frameHue(name: string): { line: string; bg: string } {
5
- let h = 0
6
- for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
7
- const rgb = FRAME_HUES[h % FRAME_HUES.length]!
8
- const line = "#" + rgb.toString(16).padStart(6, "0")
9
- const r = (rgb >> 16) & 0xff
10
- const g = (rgb >> 8) & 0xff
11
- const b = rgb & 0xff
12
- return { line, bg: `rgba(${r},${g},${b},0.05)` }
13
- }
1
+ /** Stable team hue shared by canvas and roster frames. */
2
+ import { FRAME_HUES } from './tokens'
3
+
4
+ export function frameHue(name: string): { line: string; bg: string } {
5
+ let h = 0
6
+ for (let i = 0; i < name.length; i++) h = ((h * 31 + name.charCodeAt(i)) & 0xfffffff) >>> 0
7
+ const rgb = FRAME_HUES[h % FRAME_HUES.length]!
8
+ const line = "#" + rgb.toString(16).padStart(6, "0")
9
+ const r = (rgb >> 16) & 0xff
10
+ const g = (rgb >> 8) & 0xff
11
+ const b = rgb & 0xff
12
+ return { line, bg: `rgba(${r},${g},${b},0.05)` }
13
+ }
@@ -1,39 +1,39 @@
1
- /**
2
- * The board legend (review 2026-10-27, real-host check): a fixed panel at
3
- * the bottom-left sat over the lowest frames of a real 66-card board. It is
4
- * now a native <details> fold — closed by default, a small 图例 chip that
5
- * covers nothing; open, it shows the edge key. The open state is a
6
- * per-viewer convenience in browser storage (absent or throwing → closed).
7
- * @module nexus-stage/planning-legend
8
- */
9
- const KEY = 'dsh-a2a.board.legend'
10
-
11
- /** Build the legend fold. */
12
- export function createLegend(): HTMLDetailsElement {
13
- const legend = document.createElement('details')
14
- legend.className = 'p-legend'
15
- const summary = document.createElement('summary')
16
- summary.textContent = '图例'
17
- summary.title = '边的含义(点击展开 / 收起)'
18
- const rows = document.createElement('div')
19
- rows.className = 'p-legend-rows'
20
- const row = (dashed: boolean, text: string, color?: string): void => {
21
- const line = document.createElement('span')
22
- const bar = document.createElement('i')
23
- if (dashed) bar.className = 'dashed'
24
- if (color !== undefined) bar.style.borderTopColor = color
25
- line.append(bar, document.createTextNode(text))
26
- rows.appendChild(line)
27
- }
28
- row(false, '成员边(星形,框→成员)')
29
- row(true, '跨队多属(虚线)')
30
- row(true, 'roster 网络队框(双线边框)')
31
- row(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
32
- row(true, '联邦线(→peer)', 'var(--federal)')
33
- legend.append(summary, rows)
34
- try { legend.open = window.localStorage.getItem(KEY) === 'open' } catch { /* closed */ }
35
- legend.addEventListener('toggle', () => {
36
- try { window.localStorage.setItem(KEY, legend.open ? 'open' : 'closed') } catch { /* convenience only */ }
37
- })
38
- return legend
39
- }
1
+ /**
2
+ * The board legend (review 2026-10-27, real-host check): a fixed panel at
3
+ * the bottom-left sat over the lowest frames of a real 66-card board. It is
4
+ * now a native <details> fold — closed by default, a small 图例 chip that
5
+ * covers nothing; open, it shows the edge key. The open state is a
6
+ * per-viewer convenience in browser storage (absent or throwing → closed).
7
+ * @module nexus-stage/planning-legend
8
+ */
9
+ const KEY = 'dsh-a2a.board.legend'
10
+
11
+ /** Build the legend fold. */
12
+ export function createLegend(): HTMLDetailsElement {
13
+ const legend = document.createElement('details')
14
+ legend.className = 'p-legend'
15
+ const summary = document.createElement('summary')
16
+ summary.textContent = '图例'
17
+ summary.title = '边的含义(点击展开 / 收起)'
18
+ const rows = document.createElement('div')
19
+ rows.className = 'p-legend-rows'
20
+ const row = (dashed: boolean, text: string, color?: string): void => {
21
+ const line = document.createElement('span')
22
+ const bar = document.createElement('i')
23
+ if (dashed) bar.className = 'dashed'
24
+ if (color !== undefined) bar.style.borderTopColor = color
25
+ line.append(bar, document.createTextNode(text))
26
+ rows.appendChild(line)
27
+ }
28
+ row(false, '成员边(星形,框→成员)')
29
+ row(true, '跨队多属(虚线)')
30
+ row(true, 'roster 网络队框(双线边框)')
31
+ row(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
32
+ row(true, '联邦线(→peer)', 'var(--federal)')
33
+ legend.append(summary, rows)
34
+ try { legend.open = window.localStorage.getItem(KEY) === 'open' } catch { /* closed */ }
35
+ legend.addEventListener('toggle', () => {
36
+ try { window.localStorage.setItem(KEY, legend.open ? 'open' : 'closed') } catch { /* convenience only */ }
37
+ })
38
+ return legend
39
+ }