@adea-ai/ui 0.63.4 → 0.65.0

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 (59) hide show
  1. package/README.md +74 -0
  2. package/dist/NOTICE +81 -0
  3. package/dist/components/conversation/busy-send-button.d.ts +28 -0
  4. package/dist/components/conversation/busy-send-button.d.ts.map +1 -0
  5. package/dist/components/conversation/busy-send-button.js +184 -0
  6. package/dist/components/conversation/busy-send-button.js.map +1 -0
  7. package/dist/components/conversation/index.d.ts +1 -0
  8. package/dist/components/conversation/index.d.ts.map +1 -1
  9. package/dist/components/conversation/index.js +2 -1
  10. package/dist/components/layout/split-layout/drop.d.ts +13 -0
  11. package/dist/components/layout/split-layout/drop.d.ts.map +1 -0
  12. package/dist/components/layout/split-layout/drop.js +50 -0
  13. package/dist/components/layout/split-layout/drop.js.map +1 -0
  14. package/dist/components/layout/split-layout/geometry.d.ts +14 -0
  15. package/dist/components/layout/split-layout/geometry.d.ts.map +1 -0
  16. package/dist/components/layout/split-layout/geometry.js +44 -0
  17. package/dist/components/layout/split-layout/geometry.js.map +1 -0
  18. package/dist/components/layout/split-layout/index.d.ts +5 -0
  19. package/dist/components/layout/split-layout/index.d.ts.map +1 -0
  20. package/dist/components/layout/split-layout/index.js +4 -0
  21. package/dist/components/layout/split-layout/model.d.ts +51 -0
  22. package/dist/components/layout/split-layout/model.d.ts.map +1 -0
  23. package/dist/components/layout/split-layout/model.js +255 -0
  24. package/dist/components/layout/split-layout/model.js.map +1 -0
  25. package/dist/components/layout/split-layout/split-layout.d.ts +30 -0
  26. package/dist/components/layout/split-layout/split-layout.d.ts.map +1 -0
  27. package/dist/components/layout/split-layout/split-layout.js +312 -0
  28. package/dist/components/layout/split-layout/split-layout.js.map +1 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -1
  32. package/dist/lib/tokens.d.ts +3 -0
  33. package/dist/lib/tokens.d.ts.map +1 -1
  34. package/dist/lib/tokens.js +3 -0
  35. package/dist/lib/tokens.js.map +1 -1
  36. package/dist/r/conversation.json +7 -0
  37. package/dist/r/registry.json +48 -0
  38. package/dist/r/split-layout.json +41 -0
  39. package/dist/r/src/components/conversation/busy-send-button.tsx +161 -0
  40. package/dist/r/src/components/conversation/index.ts +1 -0
  41. package/dist/r/src/components/layout/split-layout/drop.ts +29 -0
  42. package/dist/r/src/components/layout/split-layout/geometry.ts +40 -0
  43. package/dist/r/src/components/layout/split-layout/index.ts +4 -0
  44. package/dist/r/src/components/layout/split-layout/model.ts +336 -0
  45. package/dist/r/src/components/layout/split-layout/split-layout.tsx +346 -0
  46. package/dist/r/src/lib/tokens.ts +3 -0
  47. package/dist/r/src/styles/theme.css +9 -0
  48. package/package.json +10 -2
  49. package/registry.json +48 -0
  50. package/src/components/conversation/busy-send-button.tsx +161 -0
  51. package/src/components/conversation/index.ts +1 -0
  52. package/src/components/layout/split-layout/drop.ts +29 -0
  53. package/src/components/layout/split-layout/geometry.ts +40 -0
  54. package/src/components/layout/split-layout/index.ts +4 -0
  55. package/src/components/layout/split-layout/model.ts +336 -0
  56. package/src/components/layout/split-layout/split-layout.tsx +346 -0
  57. package/src/index.ts +1 -0
  58. package/src/lib/tokens.ts +3 -0
  59. package/src/styles/theme.css +9 -0
@@ -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
+ }
@@ -0,0 +1,346 @@
1
+ import Resizable from '@corvu/resizable'
2
+ import {
3
+ For,
4
+ createMemo,
5
+ createComputed,
6
+ createUniqueId,
7
+ createSignal,
8
+ onCleanup,
9
+ on,
10
+ type Accessor,
11
+ type JSX,
12
+ } from 'solid-js'
13
+ import { X, GripVertical } from 'lucide-solid'
14
+ import { Button } from '../../ui/button'
15
+ import { cn } from '../../../lib/utils'
16
+ import { computeLayoutFrames, type LayoutRect } from './geometry'
17
+ import { paneDropIntent, type PaneDropIntent } from './drop'
18
+ export type { PaneDropIntent } from './drop'
19
+ const PANE_DRAG_TYPE = 'application/x-adea-pane-move'
20
+ import {
21
+ listLeaves,
22
+ MIN_SPLIT_RATIO,
23
+ MAX_SPLIT_RATIO,
24
+ type SplitLayoutBranch,
25
+ type SplitLayoutLeaf,
26
+ type SplitLayoutState,
27
+ } from './model'
28
+
29
+ export type SplitLayoutProps<L extends SplitLayoutLeaf> = {
30
+ state: SplitLayoutState<L>
31
+ label: string
32
+ /** Accessible name for a pane region and its close button. */
33
+ labelForLeaf: (leaf: L) => string
34
+ /** Called once per stable leaf owner; the accessor tracks opaque payload replacement. */
35
+ renderLeaf: (leaf: Accessor<L>) => JSX.Element
36
+ /** Inline visible header composition, separate from the region and close-button name. */
37
+ renderPaneLabel?: (leaf: Accessor<L>) => JSX.Element
38
+ /** Whether pane regions participate in sequential keyboard navigation; defaults to programmatic focus only. */
39
+ paneTabIndex?: 0 | -1
40
+ /** Accessible splitter name; defaults to the current orientation-specific label. */
41
+ labelForSeparator?: (branch: SplitLayoutBranch<L>) => string
42
+ onResize: (splitId: string, ratio: number) => void
43
+ onFocus?: (leafId: string) => void
44
+ /** Host performs the model transition and returns the surviving focus destination. */
45
+ onClose?: (leafId: string) => string | undefined
46
+ /** Pointer placement only; the host supplies keyboard commands and performs the model transition. */
47
+ onMove?: (leafId: string, targetId: string, intent: PaneDropIntent) => void
48
+ /** Stable header composition for host-owned keyboard actions/capability feedback. */
49
+ renderPaneActions?: (leaf: Accessor<L>) => JSX.Element
50
+ class?: string
51
+ }
52
+ function rectStyle(rect: LayoutRect): JSX.CSSProperties {
53
+ return {
54
+ left: `${rect.x * 100}%`,
55
+ top: `${rect.y * 100}%`,
56
+ width: `${rect.width * 100}%`,
57
+ height: `${rect.height * 100}%`,
58
+ }
59
+ }
60
+ /** Muxy frame geometry keeps leaf owners stable; Corvu owns constrained separator interactions. */
61
+ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L>) {
62
+ let root: HTMLDivElement | undefined
63
+ let pendingFrame: number | undefined
64
+ const refs = new Map<string, HTMLElement>()
65
+ const domIds = new Map<string, string>()
66
+ const prefix = createUniqueId()
67
+ let nextDomId = 0
68
+ let nextDragId = 0
69
+ const [drag, setDrag] = createSignal<{ id: string; token: string }>()
70
+ const [drop, setDrop] = createSignal<{ id: string; intent: PaneDropIntent }>()
71
+ const clearDrag = () => {
72
+ setDrag(undefined)
73
+ setDrop(undefined)
74
+ }
75
+ const domId = (id: string) => {
76
+ const existing = domIds.get(id)
77
+ if (existing) return existing
78
+ const created = `${prefix}-pane-${nextDomId++}`
79
+ domIds.set(id, created)
80
+ return created
81
+ }
82
+ const leaves = createMemo(() => listLeaves(props.state.center))
83
+ const leafMap = createMemo(() => new Map(leaves().map((leaf) => [leaf.id, leaf])))
84
+ const frames = createMemo(() => computeLayoutFrames(props.state.center))
85
+ const branches = createMemo(() =>
86
+ Array.from(frames().values()).flatMap((frame) =>
87
+ frame.node.kind === 'split' ? [frame.node] : []
88
+ )
89
+ )
90
+ createComputed(() => {
91
+ const current = drag()
92
+ if (current && (!props.onMove || !leafMap().has(current.id))) clearDrag()
93
+ const target = drop()
94
+ if (target && !leafMap().has(target.id)) setDrop(undefined)
95
+ })
96
+ const allowedTarget = (id: string, transfer: DataTransfer | null) => {
97
+ const current = drag()
98
+ return props.onMove &&
99
+ current &&
100
+ current.id !== id &&
101
+ leafMap().has(current.id) &&
102
+ leafMap().has(id) &&
103
+ transfer?.types.includes(PANE_DRAG_TYPE)
104
+ ? current
105
+ : undefined
106
+ }
107
+ const dropLabel = createMemo(() => {
108
+ const target = drop()
109
+ const leaf = target ? leafMap().get(target.id) : undefined
110
+ return target && leaf
111
+ ? `Drop to place pane ${target.intent.placement} ${props.labelForLeaf(leaf)}`
112
+ : ''
113
+ })
114
+ const branchMap = createMemo(() => new Map(branches().map((branch) => [branch.id, branch])))
115
+ const schedule = (action: () => void) => {
116
+ if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame)
117
+ pendingFrame = requestAnimationFrame(() => {
118
+ pendingFrame = undefined
119
+ action()
120
+ })
121
+ }
122
+ // Register before keyed children update: capture a focused editor before an
123
+ // ordered DOM move can blur it. Never override a newer focus outside this root.
124
+ createComputed(
125
+ on(
126
+ () => props.state.center,
127
+ () => {
128
+ const active = root?.ownerDocument.activeElement
129
+ if (!root || !(active instanceof HTMLElement) || !root.contains(active)) return
130
+ const field =
131
+ active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement
132
+ ? active
133
+ : undefined
134
+ const selection = field
135
+ ? {
136
+ start: field.selectionStart,
137
+ end: field.selectionEnd,
138
+ direction: field.selectionDirection,
139
+ }
140
+ : undefined
141
+ schedule(() => {
142
+ if (!active.isConnected || !root?.contains(active)) return
143
+ const current = active.ownerDocument.activeElement
144
+ if (current !== active && current !== active.ownerDocument.body) return
145
+ active.focus({ preventScroll: true })
146
+ if (field && selection?.start !== null && selection?.end !== null && selection)
147
+ field.setSelectionRange(
148
+ selection.start,
149
+ selection.end,
150
+ selection.direction ?? undefined
151
+ )
152
+ })
153
+ }
154
+ )
155
+ )
156
+ onCleanup(() => {
157
+ if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame)
158
+ clearDrag()
159
+ refs.clear()
160
+ domIds.clear()
161
+ })
162
+ const close = (id: string) => {
163
+ const next = props.onClose?.(id)
164
+ if (next) schedule(() => refs.get(next)?.focus({ preventScroll: true }))
165
+ }
166
+ return (
167
+ <div
168
+ ref={(el) => {
169
+ root = el
170
+ }}
171
+ role="group"
172
+ aria-label={props.label}
173
+ data-slot="split-layout"
174
+ class={cn('relative size-full min-h-0 min-w-0', props.class)}
175
+ >
176
+ <For each={leaves().map((leaf) => leaf.id)}>
177
+ {(id) => {
178
+ const initial = leafMap().get(id)!
179
+ const leaf = () => leafMap().get(id) ?? initial
180
+ const content = props.renderLeaf(leaf)
181
+ const paneLabel = props.renderPaneLabel?.(leaf)
182
+ const actions = props.renderPaneActions?.(leaf)
183
+ const intent = () => (drop()?.id === id ? drop()?.intent : undefined)
184
+ onCleanup(() => {
185
+ refs.delete(id)
186
+ domIds.delete(id)
187
+ })
188
+ return (
189
+ <section
190
+ ref={(el) => refs.set(id, el)}
191
+ id={domId(id)}
192
+ role="region"
193
+ aria-label={props.labelForLeaf(leaf())}
194
+ tabIndex={props.paneTabIndex ?? -1}
195
+ data-pane-id={id}
196
+ data-focused={props.state.focusedLeafId === id ? '' : undefined}
197
+ data-drop-direction={intent()?.direction}
198
+ data-drop-placement={intent()?.placement}
199
+ onDragOver={(event) => {
200
+ if (!allowedTarget(id, event.dataTransfer)) return
201
+ const next = paneDropIntent(
202
+ event.clientX,
203
+ event.clientY,
204
+ event.currentTarget.getBoundingClientRect()
205
+ )
206
+ if (!next) {
207
+ setDrop(undefined)
208
+ return
209
+ }
210
+ event.preventDefault()
211
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
212
+ setDrop({ id, intent: next })
213
+ }}
214
+ onDragLeave={(event) => {
215
+ if (
216
+ event.relatedTarget instanceof Node &&
217
+ event.currentTarget.contains(event.relatedTarget)
218
+ )
219
+ return
220
+ if (drop()?.id === id) setDrop(undefined)
221
+ }}
222
+ onDrop={(event) => {
223
+ const current = allowedTarget(id, event.dataTransfer)
224
+ const next = paneDropIntent(
225
+ event.clientX,
226
+ event.clientY,
227
+ event.currentTarget.getBoundingClientRect()
228
+ )
229
+ const accepted =
230
+ current && next && event.dataTransfer?.getData(PANE_DRAG_TYPE) === current.token
231
+ clearDrag()
232
+ if (!accepted) return
233
+ event.preventDefault()
234
+ props.onMove?.(current.id, id, next)
235
+ }}
236
+ style={rectStyle(frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 })}
237
+ class="absolute flex min-h-0 min-w-0 flex-col overflow-hidden border border-border data-[focused]:border-primary bg-background text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
238
+ onFocusIn={() => props.onFocus?.(id)}
239
+ >
240
+ <header class="flex min-w-0 shrink-0 items-center gap-2 bg-surface px-2">
241
+ <span
242
+ data-pane-drag-handle=""
243
+ draggable={Boolean(props.onMove)}
244
+ class="flex min-w-0 flex-1 items-center gap-2 truncate text-sm"
245
+ title={props.onMove ? `Drag ${props.labelForLeaf(leaf())} to move` : undefined}
246
+ onDragStart={(event) => {
247
+ if (!props.onMove || !event.dataTransfer) {
248
+ event.preventDefault()
249
+ return
250
+ }
251
+ const token = `${prefix}-${++nextDragId}`
252
+ setDrag({ id, token })
253
+ event.dataTransfer.setData(PANE_DRAG_TYPE, token)
254
+ event.dataTransfer.effectAllowed = 'move'
255
+ }}
256
+ onDragEnd={clearDrag}
257
+ >
258
+ {props.onMove ? (
259
+ <GripVertical class="size-3 shrink-0" aria-hidden="true" />
260
+ ) : null}
261
+ <span class="min-w-0 flex-1 truncate">
262
+ {paneLabel ?? props.labelForLeaf(leaf())}
263
+ </span>
264
+ </span>
265
+ {actions}
266
+ {props.onClose ? (
267
+ <Button
268
+ variant="ghost"
269
+ size="icon-xs"
270
+ aria-label={`Close ${props.labelForLeaf(leaf())}`}
271
+ onClick={() => close(id)}
272
+ >
273
+ <X />
274
+ </Button>
275
+ ) : null}
276
+ </header>
277
+ <div class="min-h-0 min-w-0 flex-1 overflow-auto">{content}</div>
278
+ {intent() ? (
279
+ <div
280
+ aria-hidden="true"
281
+ class={cn('pointer-events-none absolute border-2 border-primary bg-primary/10', {
282
+ 'inset-y-0 left-0 w-1/2':
283
+ intent()?.direction === 'row' && intent()?.placement === 'before',
284
+ 'inset-y-0 right-0 w-1/2':
285
+ intent()?.direction === 'row' && intent()?.placement === 'after',
286
+ 'inset-x-0 top-0 h-1/2':
287
+ intent()?.direction === 'column' && intent()?.placement === 'before',
288
+ 'inset-x-0 bottom-0 h-1/2':
289
+ intent()?.direction === 'column' && intent()?.placement === 'after',
290
+ })}
291
+ />
292
+ ) : null}
293
+ </section>
294
+ )
295
+ }}
296
+ </For>
297
+ <span class="sr-only" role="status">
298
+ {dropLabel()}
299
+ </span>
300
+ <For each={branches().map((branch) => branch.id)}>
301
+ {(id) => {
302
+ const initial = branchMap().get(id)!
303
+ const branch: Accessor<SplitLayoutBranch<L>> = () => branchMap().get(id) ?? initial
304
+ return (
305
+ <Resizable
306
+ orientation={branch().direction === 'row' ? 'horizontal' : 'vertical'}
307
+ sizes={[branch().ratio, 1 - branch().ratio]}
308
+ keyboardDelta={0.05}
309
+ onSizesChange={(sizes) => {
310
+ if (sizes[0] !== undefined && Math.abs(sizes[0] - branch().ratio) > 0.000001)
311
+ props.onResize(id, sizes[0])
312
+ }}
313
+ style={rectStyle(frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 })}
314
+ class="pointer-events-none absolute flex min-h-0 min-w-0 data-[orientation=vertical]:flex-col"
315
+ >
316
+ <Resizable.Panel
317
+ minSize={MIN_SPLIT_RATIO}
318
+ maxSize={MAX_SPLIT_RATIO}
319
+ aria-hidden="true"
320
+ />
321
+ <Resizable.Handle
322
+ aria-label={
323
+ props.labelForSeparator?.(branch()) ??
324
+ (branch().direction === 'row' ? 'Resize pane columns' : 'Resize pane rows')
325
+ }
326
+ aria-orientation={branch().direction === 'row' ? 'vertical' : 'horizontal'}
327
+ aria-controls={listLeaves(branch())
328
+ .map((leaf) => domId(leaf.id))
329
+ .join(' ')}
330
+ aria-valuemin={10}
331
+ aria-valuemax={90}
332
+ aria-valuenow={Math.round(branch().ratio * 100)}
333
+ class="pointer-events-auto relative w-px shrink-0 bg-border after:absolute after:inset-y-0 after:-inset-x-1 after:w-3 focus-visible:bg-primary focus-visible:outline-none hover:bg-primary data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:inset-x-0 data-[orientation=vertical]:after:-inset-y-1 data-[orientation=vertical]:after:h-3 data-[orientation=vertical]:after:w-full"
334
+ />
335
+ <Resizable.Panel
336
+ minSize={MIN_SPLIT_RATIO}
337
+ maxSize={MAX_SPLIT_RATIO}
338
+ aria-hidden="true"
339
+ />
340
+ </Resizable>
341
+ )
342
+ }}
343
+ </For>
344
+ </div>
345
+ )
346
+ }
@@ -434,6 +434,9 @@ export const typographyTokens: TokenDefinition[] = [
434
434
  },
435
435
  ]
436
436
 
437
+ /** Height tokens also size both axes of icon controls through Tailwind's --size
438
+ * namespace. Padding has its own --spacing mapping. Nominal pixel descriptions
439
+ * use a 16px rem; rendered dimensions follow the consumer's root font size. */
437
440
  export const densityTokens: TokenDefinition[] = [
438
441
  { name: 'control-height-2xs', kind: 'dimension', description: '20px — an inline chip control.' },
439
442
  { name: 'control-height-xs', kind: 'dimension', description: '24px — a dense table row action.' },