@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,31 +1,31 @@
1
- /** Human owner form only: labels never grant membership or become routing ids. */
2
- export function createSeatNameEditor(sessions: readonly { id: string; name?: string; label?: string }[], names: Record<string, string>, save: (names: Record<string, string>) => Promise<{ ok: boolean; error?: string }>): HTMLElement {
3
- const box = document.createElement('section')
4
- const heading = document.createElement('h4'); heading.textContent = '会话席位名(仅人工设定)'; box.appendChild(heading)
5
- const note = document.createElement('p'); note.className = 'wb-hint'; note.textContent = '稳定展示名,不改变会话标题、路由句柄或准入。留空并保存可清除。'; box.appendChild(note)
6
- const select = document.createElement('select'); select.setAttribute('aria-label', '选择命名的本机会话')
7
- for (const id of new Set([...sessions.map(row => row.id), ...Object.keys(names)])) {
8
- const row = sessions.find(row => row.id === id), option = document.createElement('option')
9
- option.value = id; option.textContent = (names[id] ?? row?.name ?? row?.label ?? id) + ' · ' + id; select.appendChild(option)
10
- }
11
- const input = document.createElement('input'); input.type = 'text'; input.maxLength = 64; input.setAttribute('aria-label', '人工席位名'); input.placeholder = '例如:开发席'
12
- const button = document.createElement('button'); button.type = 'submit'; button.textContent = '保存席位名'; button.className = 'wb-member-submit'
13
- const form = document.createElement('form'); form.className = 'wb-member-add'; form.append(select, input, button)
14
- const status = document.createElement('p'); status.className = 'wb-hint'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite')
15
- let pending = false
16
- const load = () => { input.value = names[select.value] ?? ''; button.disabled = pending || select.value === '' }
17
- select.addEventListener('change', load); load()
18
- form.addEventListener('submit', async event => {
19
- event.preventDefault(); if (pending || select.value === '') return
20
- const id = select.value, value = input.value.trim(), next = { ...names }
21
- if (value === '') delete next[id]; else next[id] = value
22
- pending = true; button.disabled = true; select.disabled = true; input.disabled = true
23
- try {
24
- const result = await save(next)
25
- if (result.ok) { delete names[id]; if (value !== '') names[id] = value; status.textContent = '席位名已保存;目录将随状态刷新。' }
26
- else status.textContent = result.error ?? '保存失败'
27
- } catch (error) { status.textContent = '保存失败:' + String(error) }
28
- finally { pending = false; select.disabled = false; input.disabled = false; load() }
29
- })
30
- box.append(form, status); return box
31
- }
1
+ /** Human owner form only: labels never grant membership or become routing ids. */
2
+ export function createSeatNameEditor(sessions: readonly { id: string; name?: string; label?: string }[], names: Record<string, string>, save: (names: Record<string, string>) => Promise<{ ok: boolean; error?: string }>): HTMLElement {
3
+ const box = document.createElement('section')
4
+ const heading = document.createElement('h4'); heading.textContent = '会话席位名(仅人工设定)'; box.appendChild(heading)
5
+ const note = document.createElement('p'); note.className = 'wb-hint'; note.textContent = '稳定展示名,不改变会话标题、路由句柄或准入。留空并保存可清除。'; box.appendChild(note)
6
+ const select = document.createElement('select'); select.setAttribute('aria-label', '选择命名的本机会话')
7
+ for (const id of new Set([...sessions.map(row => row.id), ...Object.keys(names)])) {
8
+ const row = sessions.find(row => row.id === id), option = document.createElement('option')
9
+ option.value = id; option.textContent = (names[id] ?? row?.name ?? row?.label ?? id) + ' · ' + id; select.appendChild(option)
10
+ }
11
+ const input = document.createElement('input'); input.type = 'text'; input.maxLength = 64; input.setAttribute('aria-label', '人工席位名'); input.placeholder = '例如:开发席'
12
+ const button = document.createElement('button'); button.type = 'submit'; button.textContent = '保存席位名'; button.className = 'wb-member-submit'
13
+ const form = document.createElement('form'); form.className = 'wb-member-add'; form.append(select, input, button)
14
+ const status = document.createElement('p'); status.className = 'wb-hint'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite')
15
+ let pending = false
16
+ const load = () => { input.value = names[select.value] ?? ''; button.disabled = pending || select.value === '' }
17
+ select.addEventListener('change', load); load()
18
+ form.addEventListener('submit', async event => {
19
+ event.preventDefault(); if (pending || select.value === '') return
20
+ const id = select.value, value = input.value.trim(), next = { ...names }
21
+ if (value === '') delete next[id]; else next[id] = value
22
+ pending = true; button.disabled = true; select.disabled = true; input.disabled = true
23
+ try {
24
+ const result = await save(next)
25
+ if (result.ok) { delete names[id]; if (value !== '') names[id] = value; status.textContent = '席位名已保存;目录将随状态刷新。' }
26
+ else status.textContent = result.error ?? '保存失败'
27
+ } catch (error) { status.textContent = '保存失败:' + String(error) }
28
+ finally { pending = false; select.disabled = false; input.disabled = false; load() }
29
+ })
30
+ box.append(form, status); return box
31
+ }
@@ -1,64 +1,64 @@
1
- /**
2
- * Drag a local session from the workbench rail (目录 · 本机会话) straight into
3
- * a canvas team frame on the board (owner request 2026-11-04). The rail row
4
- * carries the session id in a private drag type; the board accepts it only
5
- * over a frame and turns the drop into the same add-member action a card drop
6
- * makes, so roster frames, mirror frames (fleet relay) and the host's own
7
- * refusals all behave exactly as for an on-board drag.
8
- * @module nexus-stage/session-drag
9
- */
10
-
11
- /** Private drag type: an arbitrary page or file dropped on the board is ignored. */
12
- export const SESSION_DRAG_TYPE = 'application/x-dsh-session'
13
-
14
- /** Make one rail row draggable as a local session. */
15
- export function makeSessionDraggable(item: HTMLElement, sessionId: string): void {
16
- item.draggable = true
17
- item.classList.add('is-draggable')
18
- item.title = `${item.title === '' ? sessionId : item.title} · 可拖入画布队`
19
- item.addEventListener('dragstart', ev => {
20
- if (ev.dataTransfer === null) return
21
- ev.dataTransfer.setData(SESSION_DRAG_TYPE, sessionId)
22
- ev.dataTransfer.effectAllowed = 'copy'
23
- })
24
- }
25
-
26
- export interface SessionDropDeps {
27
- /** The innermost team frame under a client point, if any. */
28
- frameAt(clientX: number, clientY: number): string | undefined
29
- /** Frame elements by team name, for the drop-target highlight. */
30
- frames(): ReadonlyMap<string, HTMLElement>
31
- /** A session dropped on a frame. */
32
- drop(team: string, sessionId: string): void
33
- readonly signal: AddEventListenerOptions
34
- }
35
-
36
- /** Accept rail session drags on the board root. */
37
- export function attachSessionDrop(root: HTMLElement, d: SessionDropDeps): void {
38
- const carries = (ev: DragEvent): boolean => ev.dataTransfer !== null && [...ev.dataTransfer.types].includes(SESSION_DRAG_TYPE)
39
- const highlight = (team: string | undefined): void => {
40
- for (const [name, el] of d.frames()) el.classList.toggle('drop-target', name === team)
41
- }
42
- root.addEventListener('dragover', ev => {
43
- if (!carries(ev)) return
44
- const team = d.frameAt(ev.clientX, ev.clientY)
45
- highlight(team)
46
- if (team === undefined) return
47
- ev.preventDefault() // only a frame is a drop target
48
- ev.dataTransfer!.dropEffect = 'copy'
49
- }, d.signal)
50
- root.addEventListener('dragleave', ev => {
51
- if (carries(ev) && (ev.relatedTarget === null || !root.contains(ev.relatedTarget as Node))) highlight(undefined)
52
- }, d.signal)
53
- // A drag cancelled with Esc over a frame fires no dragleave on the board: the source's dragend clears it.
54
- root.ownerDocument.addEventListener('dragend', () => highlight(undefined), d.signal)
55
- root.addEventListener('drop', ev => {
56
- if (!carries(ev)) return
57
- highlight(undefined)
58
- const team = d.frameAt(ev.clientX, ev.clientY)
59
- const id = ev.dataTransfer!.getData(SESSION_DRAG_TYPE)
60
- if (team === undefined || id === '') return
61
- ev.preventDefault()
62
- d.drop(team, id)
63
- }, d.signal)
64
- }
1
+ /**
2
+ * Drag a local session from the workbench rail (目录 · 本机会话) straight into
3
+ * a canvas team frame on the board (owner request 2026-11-04). The rail row
4
+ * carries the session id in a private drag type; the board accepts it only
5
+ * over a frame and turns the drop into the same add-member action a card drop
6
+ * makes, so roster frames, mirror frames (fleet relay) and the host's own
7
+ * refusals all behave exactly as for an on-board drag.
8
+ * @module nexus-stage/session-drag
9
+ */
10
+
11
+ /** Private drag type: an arbitrary page or file dropped on the board is ignored. */
12
+ export const SESSION_DRAG_TYPE = 'application/x-dsh-session'
13
+
14
+ /** Make one rail row draggable as a local session. */
15
+ export function makeSessionDraggable(item: HTMLElement, sessionId: string): void {
16
+ item.draggable = true
17
+ item.classList.add('is-draggable')
18
+ item.title = `${item.title === '' ? sessionId : item.title} · 可拖入画布队`
19
+ item.addEventListener('dragstart', ev => {
20
+ if (ev.dataTransfer === null) return
21
+ ev.dataTransfer.setData(SESSION_DRAG_TYPE, sessionId)
22
+ ev.dataTransfer.effectAllowed = 'copy'
23
+ })
24
+ }
25
+
26
+ export interface SessionDropDeps {
27
+ /** The innermost team frame under a client point, if any. */
28
+ frameAt(clientX: number, clientY: number): string | undefined
29
+ /** Frame elements by team name, for the drop-target highlight. */
30
+ frames(): ReadonlyMap<string, HTMLElement>
31
+ /** A session dropped on a frame. */
32
+ drop(team: string, sessionId: string): void
33
+ readonly signal: AddEventListenerOptions
34
+ }
35
+
36
+ /** Accept rail session drags on the board root. */
37
+ export function attachSessionDrop(root: HTMLElement, d: SessionDropDeps): void {
38
+ const carries = (ev: DragEvent): boolean => ev.dataTransfer !== null && [...ev.dataTransfer.types].includes(SESSION_DRAG_TYPE)
39
+ const highlight = (team: string | undefined): void => {
40
+ for (const [name, el] of d.frames()) el.classList.toggle('drop-target', name === team)
41
+ }
42
+ root.addEventListener('dragover', ev => {
43
+ if (!carries(ev)) return
44
+ const team = d.frameAt(ev.clientX, ev.clientY)
45
+ highlight(team)
46
+ if (team === undefined) return
47
+ ev.preventDefault() // only a frame is a drop target
48
+ ev.dataTransfer!.dropEffect = 'copy'
49
+ }, d.signal)
50
+ root.addEventListener('dragleave', ev => {
51
+ if (carries(ev) && (ev.relatedTarget === null || !root.contains(ev.relatedTarget as Node))) highlight(undefined)
52
+ }, d.signal)
53
+ // A drag cancelled with Esc over a frame fires no dragleave on the board: the source's dragend clears it.
54
+ root.ownerDocument.addEventListener('dragend', () => highlight(undefined), d.signal)
55
+ root.addEventListener('drop', ev => {
56
+ if (!carries(ev)) return
57
+ highlight(undefined)
58
+ const team = d.frameAt(ev.clientX, ev.clientY)
59
+ const id = ev.dataTransfer!.getData(SESSION_DRAG_TYPE)
60
+ if (team === undefined || id === '') return
61
+ ev.preventDefault()
62
+ d.drop(team, id)
63
+ }, d.signal)
64
+ }
@@ -1,106 +1,106 @@
1
- /**
2
- * Viewport math for the 2D planning canvas: pan/zoom state with the same
3
- * clamp contract as the persisted layout (scale 0.25-3, coordinates
4
- * bounded), pointer-anchored zoom, and the CSS transform that carries the
5
- * world container. Pure functions throughout - the DOM layer applies the
6
- * returned transform, it never computes one.
7
- *
8
- * Convention (shared with the host LayoutStore): (x, y) is the world
9
- * point displayed at the viewport's top-left corner; scale multiplies
10
- * world units into screen pixels.
11
- * @module nexus-stage/viewport
12
- */
13
-
14
- import { SCALE_MAX, SCALE_MIN, type LayoutViewport } from './layout-doc'
15
-
16
- /** Re-export for callers that only want the viewport vocabulary. */
17
- export type { LayoutViewport, LayoutViewport as ViewportState } from './layout-doc'
18
-
19
- /** The zero viewport: origin, unzoomed - what a fresh canvas shows. */
20
- export const IDENTITY_VIEWPORT: LayoutViewport = { x: 0, y: 0, scale: 1 }
21
-
22
- function clampScale(scale: number): number {
23
- return Math.max(SCALE_MIN, Math.min(SCALE_MAX, scale))
24
- }
25
-
26
- /** Clamp an untrusted viewport into the persisted-layout envelope. */
27
- export function clampViewport(vp: LayoutViewport): LayoutViewport {
28
- const safe = Number.isFinite(vp.scale) ? vp.scale : 1
29
- const x = Number.isFinite(vp.x) ? vp.x : 0
30
- const y = Number.isFinite(vp.y) ? vp.y : 0
31
- return { x, y, scale: clampScale(safe) }
32
- }
33
-
34
- /** Screen (client-relative px) -> world point. */
35
- export function screenToWorld(vp: LayoutViewport, sx: number, sy: number): { x: number; y: number } {
36
- return { x: vp.x + sx / vp.scale, y: vp.y + sy / vp.scale }
37
- }
38
-
39
- /** World point -> screen (client-relative px). */
40
- export function worldToScreen(vp: LayoutViewport, wx: number, wy: number): { x: number; y: number } {
41
- return { x: (wx - vp.x) * vp.scale, y: (wy - vp.y) * vp.scale }
42
- }
43
-
44
- /** Pan by screen-space pixels; dividing by scale keeps 1px = 1px on screen. */
45
- export function panBy(vp: LayoutViewport, dx: number, dy: number): LayoutViewport {
46
- return { x: vp.x + dx / vp.scale, y: vp.y + dy / vp.scale, scale: vp.scale }
47
- }
48
-
49
- /**
50
- * Zoom by a multiplicative factor keeping the world point under the
51
- * anchor screen position (ax, ay) fixed: the card under the cursor stays
52
- * under the cursor. Invariant: vp' = vp + a/s - a/s'.
53
- */
54
- export function zoomAt(vp: LayoutViewport, factor: number, ax: number, ay: number): LayoutViewport {
55
- const scale = clampScale(vp.scale * factor)
56
- if (scale === vp.scale) return vp
57
- return { x: vp.x + ax / vp.scale - ax / scale, y: vp.y + ay / vp.scale - ay / scale, scale }
58
- }
59
-
60
- /** Content bounds in world space (null = nothing to fit). */
61
- export interface WorldBounds { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
62
-
63
- /** A fit never shrinks past a readable floor: below {@link NARROW_FIT_WIDTH}
64
- * (the board's phone breakpoint) {@link NARROW_FIT_FLOOR}, at any wider
65
- * viewport {@link WIDE_FIT_FLOOR}. Fitting a whole board drew 13px card
66
- * names at ~7px on a phone, and a real 66-card board at 30% on a desktop
67
- * (review 2026-10-27, real-host check). Past the floor the fit anchors the
68
- * content's top-left corner instead of its centre; the rest is a pan away. */
69
- export const NARROW_FIT_WIDTH = 680
70
- export const NARROW_FIT_FLOOR = 0.8
71
- export const WIDE_FIT_FLOOR = 0.6
72
- const FIT_MARGIN = 16
73
-
74
- /**
75
- * Fit the content bounds into a viewW x viewH viewport at 90% fill,
76
- * clamped to the persisted scale envelope; empty bounds reset to identity.
77
- * The scale is floored (see {@link WIDE_FIT_FLOOR}); an explicit maxScale
78
- * below the floor still wins.
79
- */
80
- export function fitView(bounds: WorldBounds | null, viewW: number, viewH: number, maxScale = Number.POSITIVE_INFINITY): LayoutViewport {
81
- if (bounds === null || viewW <= 0 || viewH <= 0) return IDENTITY_VIEWPORT
82
- const bw = bounds.maxX - bounds.minX
83
- const bh = bounds.maxY - bounds.minY
84
- if (!(bw > 0) || !(bh > 0)) return IDENTITY_VIEWPORT
85
- // The floor is about content that does not FIT at a readable scale, so it
86
- // is judged on the natural fit — a caller's maxScale cap below the floor
87
- // must not turn a board that fits into a top-left-anchored one (PR #79 review).
88
- const natural = 0.9 * Math.min(viewW / bw, viewH / bh)
89
- const fitted = Math.min(maxScale, natural)
90
- const floor = viewW < NARROW_FIT_WIDTH ? NARROW_FIT_FLOOR : WIDE_FIT_FLOOR
91
- if (natural < floor) {
92
- const scale = clampScale(Math.min(maxScale, floor))
93
- return { x: bounds.minX - FIT_MARGIN / scale, y: bounds.minY - FIT_MARGIN / scale, scale }
94
- }
95
- const scale = clampScale(fitted)
96
- return {
97
- x: (bounds.minX + bounds.maxX) / 2 - viewW / (2 * scale),
98
- y: (bounds.minY + bounds.maxY) / 2 - viewH / (2 * scale),
99
- scale,
100
- }
101
- }
102
-
103
- /** The CSS transform for the world container: translate then scale. */
104
- export function worldTransformCss(vp: LayoutViewport): string {
105
- return `translate(${-vp.x * vp.scale}px, ${-vp.y * vp.scale}px) scale(${vp.scale})`
106
- }
1
+ /**
2
+ * Viewport math for the 2D planning canvas: pan/zoom state with the same
3
+ * clamp contract as the persisted layout (scale 0.25-3, coordinates
4
+ * bounded), pointer-anchored zoom, and the CSS transform that carries the
5
+ * world container. Pure functions throughout - the DOM layer applies the
6
+ * returned transform, it never computes one.
7
+ *
8
+ * Convention (shared with the host LayoutStore): (x, y) is the world
9
+ * point displayed at the viewport's top-left corner; scale multiplies
10
+ * world units into screen pixels.
11
+ * @module nexus-stage/viewport
12
+ */
13
+
14
+ import { SCALE_MAX, SCALE_MIN, type LayoutViewport } from './layout-doc'
15
+
16
+ /** Re-export for callers that only want the viewport vocabulary. */
17
+ export type { LayoutViewport, LayoutViewport as ViewportState } from './layout-doc'
18
+
19
+ /** The zero viewport: origin, unzoomed - what a fresh canvas shows. */
20
+ export const IDENTITY_VIEWPORT: LayoutViewport = { x: 0, y: 0, scale: 1 }
21
+
22
+ function clampScale(scale: number): number {
23
+ return Math.max(SCALE_MIN, Math.min(SCALE_MAX, scale))
24
+ }
25
+
26
+ /** Clamp an untrusted viewport into the persisted-layout envelope. */
27
+ export function clampViewport(vp: LayoutViewport): LayoutViewport {
28
+ const safe = Number.isFinite(vp.scale) ? vp.scale : 1
29
+ const x = Number.isFinite(vp.x) ? vp.x : 0
30
+ const y = Number.isFinite(vp.y) ? vp.y : 0
31
+ return { x, y, scale: clampScale(safe) }
32
+ }
33
+
34
+ /** Screen (client-relative px) -> world point. */
35
+ export function screenToWorld(vp: LayoutViewport, sx: number, sy: number): { x: number; y: number } {
36
+ return { x: vp.x + sx / vp.scale, y: vp.y + sy / vp.scale }
37
+ }
38
+
39
+ /** World point -> screen (client-relative px). */
40
+ export function worldToScreen(vp: LayoutViewport, wx: number, wy: number): { x: number; y: number } {
41
+ return { x: (wx - vp.x) * vp.scale, y: (wy - vp.y) * vp.scale }
42
+ }
43
+
44
+ /** Pan by screen-space pixels; dividing by scale keeps 1px = 1px on screen. */
45
+ export function panBy(vp: LayoutViewport, dx: number, dy: number): LayoutViewport {
46
+ return { x: vp.x + dx / vp.scale, y: vp.y + dy / vp.scale, scale: vp.scale }
47
+ }
48
+
49
+ /**
50
+ * Zoom by a multiplicative factor keeping the world point under the
51
+ * anchor screen position (ax, ay) fixed: the card under the cursor stays
52
+ * under the cursor. Invariant: vp' = vp + a/s - a/s'.
53
+ */
54
+ export function zoomAt(vp: LayoutViewport, factor: number, ax: number, ay: number): LayoutViewport {
55
+ const scale = clampScale(vp.scale * factor)
56
+ if (scale === vp.scale) return vp
57
+ return { x: vp.x + ax / vp.scale - ax / scale, y: vp.y + ay / vp.scale - ay / scale, scale }
58
+ }
59
+
60
+ /** Content bounds in world space (null = nothing to fit). */
61
+ export interface WorldBounds { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }
62
+
63
+ /** A fit never shrinks past a readable floor: below {@link NARROW_FIT_WIDTH}
64
+ * (the board's phone breakpoint) {@link NARROW_FIT_FLOOR}, at any wider
65
+ * viewport {@link WIDE_FIT_FLOOR}. Fitting a whole board drew 13px card
66
+ * names at ~7px on a phone, and a real 66-card board at 30% on a desktop
67
+ * (review 2026-10-27, real-host check). Past the floor the fit anchors the
68
+ * content's top-left corner instead of its centre; the rest is a pan away. */
69
+ export const NARROW_FIT_WIDTH = 680
70
+ export const NARROW_FIT_FLOOR = 0.8
71
+ export const WIDE_FIT_FLOOR = 0.6
72
+ const FIT_MARGIN = 16
73
+
74
+ /**
75
+ * Fit the content bounds into a viewW x viewH viewport at 90% fill,
76
+ * clamped to the persisted scale envelope; empty bounds reset to identity.
77
+ * The scale is floored (see {@link WIDE_FIT_FLOOR}); an explicit maxScale
78
+ * below the floor still wins.
79
+ */
80
+ export function fitView(bounds: WorldBounds | null, viewW: number, viewH: number, maxScale = Number.POSITIVE_INFINITY): LayoutViewport {
81
+ if (bounds === null || viewW <= 0 || viewH <= 0) return IDENTITY_VIEWPORT
82
+ const bw = bounds.maxX - bounds.minX
83
+ const bh = bounds.maxY - bounds.minY
84
+ if (!(bw > 0) || !(bh > 0)) return IDENTITY_VIEWPORT
85
+ // The floor is about content that does not FIT at a readable scale, so it
86
+ // is judged on the natural fit — a caller's maxScale cap below the floor
87
+ // must not turn a board that fits into a top-left-anchored one (PR #79 review).
88
+ const natural = 0.9 * Math.min(viewW / bw, viewH / bh)
89
+ const fitted = Math.min(maxScale, natural)
90
+ const floor = viewW < NARROW_FIT_WIDTH ? NARROW_FIT_FLOOR : WIDE_FIT_FLOOR
91
+ if (natural < floor) {
92
+ const scale = clampScale(Math.min(maxScale, floor))
93
+ return { x: bounds.minX - FIT_MARGIN / scale, y: bounds.minY - FIT_MARGIN / scale, scale }
94
+ }
95
+ const scale = clampScale(fitted)
96
+ return {
97
+ x: (bounds.minX + bounds.maxX) / 2 - viewW / (2 * scale),
98
+ y: (bounds.minY + bounds.maxY) / 2 - viewH / (2 * scale),
99
+ scale,
100
+ }
101
+ }
102
+
103
+ /** The CSS transform for the world container: translate then scale. */
104
+ export function worldTransformCss(vp: LayoutViewport): string {
105
+ return `translate(${-vp.x * vp.scale}px, ${-vp.y * vp.scale}px) scale(${vp.scale})`
106
+ }