@adea-ai/ui 0.64.0 → 0.65.1

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 (43) hide show
  1. package/README.md +50 -0
  2. package/dist/NOTICE +59 -1
  3. package/dist/components/composites/update-dialog/update-dialog.js +2 -2
  4. package/dist/components/composites/update-dialog/update-dialog.js.map +1 -1
  5. package/dist/components/layout/split-layout/drop.d.ts +13 -0
  6. package/dist/components/layout/split-layout/drop.d.ts.map +1 -0
  7. package/dist/components/layout/split-layout/drop.js +50 -0
  8. package/dist/components/layout/split-layout/drop.js.map +1 -0
  9. package/dist/components/layout/split-layout/geometry.d.ts +14 -0
  10. package/dist/components/layout/split-layout/geometry.d.ts.map +1 -0
  11. package/dist/components/layout/split-layout/geometry.js +44 -0
  12. package/dist/components/layout/split-layout/geometry.js.map +1 -0
  13. package/dist/components/layout/split-layout/index.d.ts +5 -0
  14. package/dist/components/layout/split-layout/index.d.ts.map +1 -0
  15. package/dist/components/layout/split-layout/index.js +4 -0
  16. package/dist/components/layout/split-layout/model.d.ts +51 -0
  17. package/dist/components/layout/split-layout/model.d.ts.map +1 -0
  18. package/dist/components/layout/split-layout/model.js +255 -0
  19. package/dist/components/layout/split-layout/model.js.map +1 -0
  20. package/dist/components/layout/split-layout/split-layout.d.ts +30 -0
  21. package/dist/components/layout/split-layout/split-layout.d.ts.map +1 -0
  22. package/dist/components/layout/split-layout/split-layout.js +312 -0
  23. package/dist/components/layout/split-layout/split-layout.js.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -1
  27. package/dist/r/registry.json +41 -0
  28. package/dist/r/split-layout.json +41 -0
  29. package/dist/r/src/components/composites/update-dialog/update-dialog.tsx +4 -4
  30. package/dist/r/src/components/layout/split-layout/drop.ts +29 -0
  31. package/dist/r/src/components/layout/split-layout/geometry.ts +40 -0
  32. package/dist/r/src/components/layout/split-layout/index.ts +4 -0
  33. package/dist/r/src/components/layout/split-layout/model.ts +336 -0
  34. package/dist/r/src/components/layout/split-layout/split-layout.tsx +346 -0
  35. package/package.json +10 -2
  36. package/registry.json +41 -0
  37. package/src/components/composites/update-dialog/update-dialog.tsx +4 -4
  38. package/src/components/layout/split-layout/drop.ts +29 -0
  39. package/src/components/layout/split-layout/geometry.ts +40 -0
  40. package/src/components/layout/split-layout/index.ts +4 -0
  41. package/src/components/layout/split-layout/model.ts +336 -0
  42. package/src/components/layout/split-layout/split-layout.tsx +346 -0
  43. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.64.0",
3
+ "version": "0.65.1",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
@@ -64,7 +64,13 @@
64
64
  "./base.css": "./src/styles/base.css",
65
65
  "./fonts.css": "./src/styles/fonts.css",
66
66
  "./registry.json": "./registry.json",
67
- "./package.json": "./package.json"
67
+ "./package.json": "./package.json",
68
+ "./components/layout/split-layout/model": {
69
+ "types": "./dist/components/layout/split-layout/model.d.ts",
70
+ "solid": "./src/components/layout/split-layout/model.ts",
71
+ "development": "./src/components/layout/split-layout/model.ts",
72
+ "import": "./dist/components/layout/split-layout/model.js"
73
+ }
68
74
  },
69
75
  "scripts": {
70
76
  "build": "vite build && tsc -p tsconfig.json --emitDeclarationOnly",
@@ -78,7 +84,9 @@
78
84
  "check:tree-shaking": "bun scripts/check-tree-shaking.ts",
79
85
  "theme:build": "bun scripts/build-default-theme.ts",
80
86
  "theme:check": "bun scripts/build-default-theme.ts --check",
87
+ "check:packed-layout": "node scripts/check-packed-layout.mjs",
81
88
  "check:packed-consumer": "bun scripts/check-packed-consumer.ts",
89
+ "check:packed-layout-renderer": "bun scripts/check-packed-layout-renderer.ts",
82
90
  "check:packed-appearance": "bun scripts/check-packed-appearance.ts"
83
91
  },
84
92
  "dependencies": {
package/registry.json CHANGED
@@ -1529,6 +1529,47 @@
1529
1529
  }
1530
1530
  ]
1531
1531
  },
1532
+ {
1533
+ "name": "split-layout",
1534
+ "type": "registry:ui",
1535
+ "title": "Split Layout",
1536
+ "description": "Issue-backed binary layout with stable opaque leaf owners; scoped persistence belongs to the host.",
1537
+ "dependencies": [
1538
+ "@corvu/resizable@^0.2.5",
1539
+ "lucide-solid@^1.48.0"
1540
+ ],
1541
+ "registryDependencies": [
1542
+ "@adea-ai/ui/button",
1543
+ "lib"
1544
+ ],
1545
+ "files": [
1546
+ {
1547
+ "path": "src/components/layout/split-layout/drop.ts",
1548
+ "type": "registry:ui",
1549
+ "target": "components/layout/split-layout/drop.ts"
1550
+ },
1551
+ {
1552
+ "path": "src/components/layout/split-layout/geometry.ts",
1553
+ "type": "registry:ui",
1554
+ "target": "components/layout/split-layout/geometry.ts"
1555
+ },
1556
+ {
1557
+ "path": "src/components/layout/split-layout/index.ts",
1558
+ "type": "registry:ui",
1559
+ "target": "components/layout/split-layout/index.ts"
1560
+ },
1561
+ {
1562
+ "path": "src/components/layout/split-layout/model.ts",
1563
+ "type": "registry:ui",
1564
+ "target": "components/layout/split-layout/model.ts"
1565
+ },
1566
+ {
1567
+ "path": "src/components/layout/split-layout/split-layout.tsx",
1568
+ "type": "registry:ui",
1569
+ "target": "components/layout/split-layout/split-layout.tsx"
1570
+ }
1571
+ ]
1572
+ },
1532
1573
  {
1533
1574
  "name": "status-bar",
1534
1575
  "type": "registry:ui",
@@ -457,7 +457,7 @@ export function UpdateDialog(props: UpdateDialogProps) {
457
457
  </Show>
458
458
 
459
459
  <Show when={notes()}>
460
- <section class="flex flex-col gap-2" aria-labelledby="update-release-notes">
460
+ <div class="flex flex-col gap-2">
461
461
  <div class="flex items-center gap-2">
462
462
  <h2 id="update-release-notes" class="text-sm font-semibold">
463
463
  What changed in this release
@@ -477,11 +477,11 @@ export function UpdateDialog(props: UpdateDialogProps) {
477
477
  >
478
478
  {notes()}
479
479
  </div>
480
- </section>
480
+ </div>
481
481
  </Show>
482
482
 
483
483
  <Show when={changelog()}>
484
- <section class="flex flex-col gap-2" aria-labelledby="update-changelog">
484
+ <div class="flex flex-col gap-2">
485
485
  <h2 id="update-changelog" class="text-sm font-semibold">
486
486
  Installed changelog
487
487
  </h2>
@@ -499,7 +499,7 @@ export function UpdateDialog(props: UpdateDialogProps) {
499
499
  >
500
500
  {changelog()}
501
501
  </div>
502
- </section>
502
+ </div>
503
503
  </Show>
504
504
  </div>
505
505
 
@@ -0,0 +1,29 @@
1
+ import type { SplitLayoutBranch } from './model'
2
+ export type PaneDropIntent = {
3
+ direction: SplitLayoutBranch['direction']
4
+ placement: 'before' | 'after'
5
+ }
6
+ /** Accepted Adea closest-edge intent; invalid/outside geometry cannot authorize a move. */
7
+ export function paneDropIntent(
8
+ clientX: number,
9
+ clientY: number,
10
+ rect: { left: number; top: number; width: number; height: number }
11
+ ): PaneDropIntent | undefined {
12
+ const { left, top, width, height } = rect
13
+ if (
14
+ ![clientX, clientY, left, top, width, height].every(Number.isFinite) ||
15
+ width <= 0 ||
16
+ height <= 0
17
+ )
18
+ return
19
+ const x = clientX - left
20
+ const y = clientY - top
21
+ if (x < 0 || y < 0 || x > width || y > height) return
22
+ const candidates: { distance: number; intent: PaneDropIntent }[] = [
23
+ { distance: x, intent: { direction: 'row', placement: 'before' } },
24
+ { distance: width - x, intent: { direction: 'row', placement: 'after' } },
25
+ { distance: y, intent: { direction: 'column', placement: 'before' } },
26
+ { distance: height - y, intent: { direction: 'column', placement: 'after' } },
27
+ ]
28
+ return candidates.reduce((best, next) => (next.distance < best.distance ? next : best)).intent
29
+ }
@@ -0,0 +1,40 @@
1
+ /*
2
+ * Translated from Muxy/Models/Workspace/SplitNode.swift areaFrames at
3
+ * 5c5be8697c57a2fe70cda97fdbaf7c912e2e31b6.
4
+ * Copyright (c) 2026 Muxy. SPDX-License-Identifier: MIT. See NOTICE.
5
+ */
6
+ import {
7
+ MAX_SPLIT_RATIO,
8
+ MIN_SPLIT_RATIO,
9
+ type SplitLayoutLeaf,
10
+ type SplitLayoutNode,
11
+ } from './model'
12
+ export type LayoutRect = Readonly<{ x: number; y: number; width: number; height: number }>
13
+ export type LayoutFrame<L extends SplitLayoutLeaf = SplitLayoutLeaf> = Readonly<{
14
+ node: SplitLayoutNode<L>
15
+ rect: LayoutRect
16
+ }>
17
+ /** Stable leaf DOM can use these fractions without being reparented into a changing nested tree. */
18
+ export function computeLayoutFrames<L extends SplitLayoutLeaf>(
19
+ node: SplitLayoutNode<L>
20
+ ): ReadonlyMap<string, LayoutFrame<L>> {
21
+ const frames = new Map<string, LayoutFrame<L>>()
22
+ const visit = (current: SplitLayoutNode<L>, rect: LayoutRect) => {
23
+ frames.set(current.id, { node: current, rect })
24
+ if (current.kind === 'leaf') return
25
+ const ratio = Number.isFinite(current.ratio)
26
+ ? Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, current.ratio))
27
+ : 0.5
28
+ const row = current.direction === 'row'
29
+ const first = row
30
+ ? { ...rect, width: rect.width * ratio }
31
+ : { ...rect, height: rect.height * ratio }
32
+ const second = row
33
+ ? { ...rect, x: rect.x + first.width, width: rect.width - first.width }
34
+ : { ...rect, y: rect.y + first.height, height: rect.height - first.height }
35
+ visit(current.children[0], first)
36
+ visit(current.children[1], second)
37
+ }
38
+ visit(node, { x: 0, y: 0, width: 1, height: 1 })
39
+ return frames
40
+ }
@@ -0,0 +1,4 @@
1
+ /** Issue-backed binary layout with stable opaque leaf owners; scoped persistence belongs to the host. */
2
+ export * from './model'
3
+ export * from './geometry'
4
+ export * from './split-layout'
@@ -0,0 +1,336 @@
1
+ /*
2
+ * Copyright (c) 2026 Michael Yong
3
+ * Copyright (c) 2026 Muxy
4
+ * SPDX-License-Identifier: MIT
5
+ *
6
+ * Portions of the binary split behavior are substantially translated from:
7
+ * - get-bb/bb apps/app/src/lib/split-layout/ops.ts (MIT), revision
8
+ * 52a9256373d4d36f9b60e9e2a7f333464091a2ac.
9
+ * - muxy-app/muxy Muxy/Models/Workspace/SplitNode.swift (MIT), revision
10
+ * 5c5be8697c57a2fe70cda97fdbaf7c912e2e31b6.
11
+ * Adapted first in Adea packages/dev-view/src/layout/operations.ts at
12
+ * 0322ab09ff5e9775bfddc0bf2d81e1df436dbef7. Extracted here with opaque
13
+ * host-owned leaves and an injected final-pane placeholder; immutable strict
14
+ * binary operations, accepted limits and undoable close are preserved.
15
+ * See NOTICE and docs/research/dev-view-donor-audit.md.
16
+ */
17
+
18
+ /** A visual identity only. Extend this with host-owned payload; UI never interprets it. */
19
+ export type SplitLayoutLeaf = Readonly<{ kind: 'leaf'; id: string }>
20
+ export type SplitLayoutBranch<L extends SplitLayoutLeaf = SplitLayoutLeaf> = Readonly<{
21
+ kind: 'split'
22
+ id: string
23
+ direction: 'row' | 'column'
24
+ ratio: number
25
+ children: readonly [SplitLayoutNode<L>, SplitLayoutNode<L>]
26
+ }>
27
+ export type SplitLayoutNode<L extends SplitLayoutLeaf = SplitLayoutLeaf> = L | SplitLayoutBranch<L>
28
+
29
+ export const MAX_LAYOUT_LEAVES = 8
30
+ export const MAX_LAYOUT_DEPTH = 8
31
+ export const MIN_SPLIT_RATIO = 0.1
32
+ export const MAX_SPLIT_RATIO = 0.9
33
+
34
+ export type SplitLayoutState<L extends SplitLayoutLeaf = SplitLayoutLeaf> = Readonly<{
35
+ center: SplitLayoutNode<L>
36
+ focusedLeafId: string
37
+ closed: readonly Readonly<{ center: SplitLayoutNode<L>; leafId: string }>[]
38
+ }>
39
+
40
+ export type SplitPaneInput<L extends SplitLayoutLeaf = SplitLayoutLeaf> = Readonly<{
41
+ direction: SplitLayoutBranch<L>['direction']
42
+ placement: 'before' | 'after'
43
+ leaf: L
44
+ splitId: string
45
+ }>
46
+
47
+ export function listLeaves<L extends SplitLayoutLeaf>(node: SplitLayoutNode<L>): readonly L[] {
48
+ return node.kind === 'leaf'
49
+ ? [node]
50
+ : [...listLeaves(node.children[0]), ...listLeaves(node.children[1])]
51
+ }
52
+
53
+ export function countLeaves<L extends SplitLayoutLeaf>(node: SplitLayoutNode<L>): number {
54
+ return node.kind === 'leaf' ? 1 : countLeaves(node.children[0]) + countLeaves(node.children[1])
55
+ }
56
+
57
+ export function layoutDepth<L extends SplitLayoutLeaf>(node: SplitLayoutNode<L>): number {
58
+ return node.kind === 'leaf'
59
+ ? 1
60
+ : 1 + Math.max(layoutDepth(node.children[0]), layoutDepth(node.children[1]))
61
+ }
62
+
63
+ function replaceLeaf<L extends SplitLayoutLeaf>(
64
+ node: SplitLayoutNode<L>,
65
+ leafId: string,
66
+ replacement: SplitLayoutNode<L>
67
+ ): SplitLayoutNode<L> {
68
+ if (node.kind === 'leaf') return node.id === leafId ? replacement : node
69
+ const first = replaceLeaf(node.children[0], leafId, replacement)
70
+ const second = replaceLeaf(node.children[1], leafId, replacement)
71
+ return first === node.children[0] && second === node.children[1]
72
+ ? node
73
+ : { ...node, children: [first, second] }
74
+ }
75
+
76
+ function removeLeaf<L extends SplitLayoutLeaf>(
77
+ node: SplitLayoutNode<L>,
78
+ leafId: string
79
+ ): SplitLayoutNode<L> | null {
80
+ if (node.kind === 'leaf') return node.id === leafId ? null : node
81
+ const first = removeLeaf(node.children[0], leafId)
82
+ const second = removeLeaf(node.children[1], leafId)
83
+ if (!first) return second
84
+ if (!second) return first
85
+ return first === node.children[0] && second === node.children[1]
86
+ ? node
87
+ : { ...node, children: [first, second] }
88
+ }
89
+
90
+ function containsLeaf<L extends SplitLayoutLeaf>(
91
+ node: SplitLayoutNode<L>,
92
+ leafId: string
93
+ ): boolean {
94
+ return node.kind === 'leaf'
95
+ ? node.id === leafId
96
+ : containsLeaf(node.children[0], leafId) || containsLeaf(node.children[1], leafId)
97
+ }
98
+
99
+ function assertUniqueInput<L extends SplitLayoutLeaf>(
100
+ state: SplitLayoutState<L>,
101
+ leaf: L,
102
+ splitId: string
103
+ ) {
104
+ assertLayoutTree(leaf)
105
+ if (typeof splitId !== 'string' || splitId.length === 0)
106
+ throw new Error('invalid_state: split identity must be nonempty')
107
+ const ids = new Set<string>()
108
+ const visit = (node: SplitLayoutNode<L>) => {
109
+ if (ids.has(node.id)) throw new Error('invalid_state: duplicate pane id')
110
+ ids.add(node.id)
111
+ if (node.kind === 'split') node.children.forEach(visit)
112
+ }
113
+ visit(state.center)
114
+ if (ids.has(leaf.id) || ids.has(splitId) || leaf.id === splitId)
115
+ throw new Error('invalid_state: duplicate pane id')
116
+ }
117
+
118
+ /** Visual-tree validation only; applications still decode and scope persisted preferences. */
119
+ function assertLayoutTree<L extends SplitLayoutLeaf>(center: SplitLayoutNode<L>): void {
120
+ const ids = new Set<string>()
121
+ const seen = new WeakSet<object>()
122
+ let leaves = 0
123
+ const visit = (node: SplitLayoutNode<L>, depth: number) => {
124
+ if (depth > MAX_LAYOUT_DEPTH) throw new Error('limit_exceeded: layout depth exceeds eight')
125
+ if (!node || typeof node !== 'object') throw new Error('invalid_state: missing layout node')
126
+ if (seen.has(node)) throw new Error('invalid_state: cyclic or reused layout node')
127
+ seen.add(node)
128
+ if (typeof node.id !== 'string' || node.id.length === 0)
129
+ throw new Error('invalid_state: layout identity must be nonempty')
130
+ if (ids.has(node.id)) throw new Error('invalid_state: duplicate pane id')
131
+ ids.add(node.id)
132
+ if (node.kind === 'leaf') {
133
+ leaves += 1
134
+ if (leaves > MAX_LAYOUT_LEAVES)
135
+ throw new Error('limit_exceeded: layout has more than eight leaves')
136
+ return
137
+ }
138
+ if (
139
+ node.kind !== 'split' ||
140
+ !Array.isArray(node.children) ||
141
+ node.children.length !== 2 ||
142
+ (node.direction !== 'row' && node.direction !== 'column')
143
+ )
144
+ throw new Error('invalid_state: layout must be a strict binary row or column tree')
145
+ visit(node.children[0], depth + 1)
146
+ visit(node.children[1], depth + 1)
147
+ }
148
+ visit(center, 1)
149
+ }
150
+
151
+ export function createLayoutState<L extends SplitLayoutLeaf>(
152
+ center: SplitLayoutNode<L>
153
+ ): SplitLayoutState<L> {
154
+ assertLayoutTree(center)
155
+ const first = listLeaves(center)[0]
156
+ if (!first) throw new Error('invalid_state: layout requires a leaf')
157
+ return { center, focusedLeafId: first.id, closed: [] }
158
+ }
159
+
160
+ export function splitPane<L extends SplitLayoutLeaf>(
161
+ state: SplitLayoutState<L>,
162
+ targetLeafId: string,
163
+ input: SplitPaneInput<L>
164
+ ): SplitLayoutState<L> {
165
+ if (!containsLeaf(state.center, targetLeafId)) return state
166
+ if (countLeaves(state.center) >= MAX_LAYOUT_LEAVES)
167
+ throw new Error('limit_exceeded: center layout has eight leaves')
168
+ assertUniqueInput(state, input.leaf, input.splitId)
169
+ const target = listLeaves(state.center).find((leaf) => leaf.id === targetLeafId)
170
+ if (!target) return state
171
+ const children =
172
+ input.placement === 'before' ? ([input.leaf, target] as const) : ([target, input.leaf] as const)
173
+ const center = replaceLeaf(state.center, targetLeafId, {
174
+ kind: 'split',
175
+ id: input.splitId,
176
+ direction: input.direction,
177
+ ratio: 0.5,
178
+ children,
179
+ })
180
+ if (layoutDepth(center) > MAX_LAYOUT_DEPTH)
181
+ throw new Error('limit_exceeded: center layout depth exceeds eight')
182
+ return { ...state, center, focusedLeafId: input.leaf.id, closed: [] }
183
+ }
184
+
185
+ export function closePane<L extends SplitLayoutLeaf>(
186
+ state: SplitLayoutState<L>,
187
+ leafId: string,
188
+ createPlaceholderLeaf: () => L
189
+ ): SplitLayoutState<L> {
190
+ if (!containsLeaf(state.center, leafId)) return state
191
+ const before = state.center
192
+ const readingOrder = listLeaves(before)
193
+ const closedIndex = readingOrder.findIndex((leaf) => leaf.id === leafId)
194
+ const removed = removeLeaf(before, leafId)
195
+ const center: SplitLayoutNode<L> = removed ?? createPlaceholderLeaf()
196
+ if (!removed) {
197
+ if (center.kind !== 'leaf') throw new Error('invalid_state: placeholder must be a leaf')
198
+ assertLayoutTree(center)
199
+ }
200
+ const leaves = listLeaves(center)
201
+ const fallback = leaves[Math.min(closedIndex, leaves.length - 1)] ?? leaves[0]
202
+ if (!fallback) throw new Error('invalid_state: layout requires a leaf')
203
+ return {
204
+ center,
205
+ focusedLeafId: state.focusedLeafId === leafId ? fallback.id : state.focusedLeafId,
206
+ closed: [...state.closed, { center: before, leafId }],
207
+ }
208
+ }
209
+
210
+ export function undoClosePane<L extends SplitLayoutLeaf>(
211
+ state: SplitLayoutState<L>
212
+ ): SplitLayoutState<L> {
213
+ const previous = state.closed.at(-1)
214
+ if (!previous) return state
215
+ return {
216
+ center: previous.center,
217
+ focusedLeafId: previous.leafId,
218
+ closed: state.closed.slice(0, -1),
219
+ }
220
+ }
221
+
222
+ export function focusPane<L extends SplitLayoutLeaf>(
223
+ state: SplitLayoutState<L>,
224
+ leafId: string
225
+ ): SplitLayoutState<L> {
226
+ return containsLeaf(state.center, leafId) && state.focusedLeafId !== leafId
227
+ ? { ...state, focusedLeafId: leafId }
228
+ : state
229
+ }
230
+
231
+ export function swapPanes<L extends SplitLayoutLeaf>(
232
+ state: SplitLayoutState<L>,
233
+ firstLeafId: string,
234
+ secondLeafId: string
235
+ ): SplitLayoutState<L> {
236
+ if (firstLeafId === secondLeafId) return state
237
+ const leaves = listLeaves(state.center)
238
+ const first = leaves.find((leaf) => leaf.id === firstLeafId)
239
+ const second = leaves.find((leaf) => leaf.id === secondLeafId)
240
+ if (!first || !second) return state
241
+ const swap = (node: SplitLayoutNode<L>): SplitLayoutNode<L> => {
242
+ if (node.kind === 'leaf') {
243
+ if (node.id === firstLeafId) return second
244
+ if (node.id === secondLeafId) return first
245
+ return node
246
+ }
247
+ const left = swap(node.children[0])
248
+ const right = swap(node.children[1])
249
+ return left === node.children[0] && right === node.children[1]
250
+ ? node
251
+ : { ...node, children: [left, right] }
252
+ }
253
+ return { ...state, center: swap(state.center), closed: [] }
254
+ }
255
+
256
+ export function movePane<L extends SplitLayoutLeaf>(
257
+ state: SplitLayoutState<L>,
258
+ leafId: string,
259
+ targetLeafId: string,
260
+ placement: 'before' | 'after',
261
+ direction: SplitLayoutBranch<L>['direction'],
262
+ splitId: string
263
+ ): SplitLayoutState<L> {
264
+ if (leafId === targetLeafId) return state
265
+ const moving = listLeaves(state.center).find((leaf) => leaf.id === leafId)
266
+ if (!moving || !containsLeaf(state.center, targetLeafId)) return state
267
+ const detached = removeLeaf(state.center, leafId)
268
+ if (!detached) return state
269
+ const moved = splitPane(
270
+ { center: detached, focusedLeafId: state.focusedLeafId, closed: state.closed },
271
+ targetLeafId,
272
+ { direction, placement, leaf: moving, splitId }
273
+ )
274
+ return { ...moved, focusedLeafId: leafId }
275
+ }
276
+
277
+ export function resizeSplit<L extends SplitLayoutLeaf>(
278
+ state: SplitLayoutState<L>,
279
+ splitId: string,
280
+ ratio: number
281
+ ): SplitLayoutState<L> {
282
+ if (!Number.isFinite(ratio)) return state
283
+ const nextRatio = Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, ratio))
284
+ let found = false
285
+ const visit = (node: SplitLayoutNode<L>): SplitLayoutNode<L> => {
286
+ if (node.kind === 'leaf') return node
287
+ if (node.id === splitId) {
288
+ found = true
289
+ return node.ratio === nextRatio ? node : { ...node, ratio: nextRatio }
290
+ }
291
+ const first = visit(node.children[0])
292
+ const second = visit(node.children[1])
293
+ return first === node.children[0] && second === node.children[1]
294
+ ? node
295
+ : { ...node, children: [first, second] }
296
+ }
297
+ const center = visit(state.center)
298
+ return found && center !== state.center ? { ...state, center, closed: [] } : state
299
+ }
300
+
301
+ /**
302
+ * Pure repair walk ported from bb's size normalization: every split ratio is
303
+ * clamped into the normative range and a non-finite ratio falls back to an
304
+ * even split. Structure, IDs, and focus are untouched.
305
+ */
306
+ export function normalizeLayout<L extends SplitLayoutLeaf>(
307
+ state: SplitLayoutState<L>
308
+ ): SplitLayoutState<L> {
309
+ const visit = (node: SplitLayoutNode<L>): { node: SplitLayoutNode<L>; changed: boolean } => {
310
+ if (node.kind === 'leaf') return { node, changed: false }
311
+ const first = visit(node.children[0])
312
+ const second = visit(node.children[1])
313
+ const ratio = Number.isFinite(node.ratio)
314
+ ? Math.min(MAX_SPLIT_RATIO, Math.max(MIN_SPLIT_RATIO, node.ratio))
315
+ : (MIN_SPLIT_RATIO + MAX_SPLIT_RATIO) / 2
316
+ const changed = first.changed || second.changed || ratio !== node.ratio
317
+ return {
318
+ node: changed ? { ...node, ratio, children: [first.node, second.node] } : node,
319
+ changed,
320
+ }
321
+ }
322
+ const result = visit(state.center)
323
+ return result.changed ? { ...state, center: result.node } : state
324
+ }
325
+
326
+ /** The leaf immediately after (`1`) or before (`-1`) the given leaf in reading order. */
327
+ export function neighborLeaf<L extends SplitLayoutLeaf>(
328
+ state: SplitLayoutState<L>,
329
+ leafId: string,
330
+ step: 1 | -1
331
+ ): L | undefined {
332
+ const leaves = listLeaves(state.center)
333
+ const index = leaves.findIndex((leaf) => leaf.id === leafId)
334
+ if (index < 0) return undefined
335
+ return leaves[index + step]
336
+ }