@lovett/ui 0.0.11 → 0.2.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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,1110 @@
1
+ /**
2
+ * DetailSurface — one item, three modes, one DOM.
3
+ *
4
+ * `docs/design-briefs/tasks-item-detail-full.md` §1:
5
+ *
6
+ * full default; near-full-screen with a margin, so the board stays
7
+ * faintly visible behind it. NOT a takeover.
8
+ * modal a smaller centred window.
9
+ * panel a right side panel.
10
+ *
11
+ * ## Why this is not `Modal` for two of them and `FloatingDrawer` for the third
12
+ *
13
+ * That was the first shape tried, and the brief kills it in one line:
14
+ * "Switching modes must not lose the open item, the active tab, scroll
15
+ * position, or an in-progress edit." Swapping which PRIMITIVE holds the
16
+ * content unmounts the content — React reconciles by position under a parent,
17
+ * and `<Modal>` and `<FloatingDrawer>` are different parents. Everything
18
+ * living below the swap dies with it: the active tab, both scrollers, and
19
+ * every half-typed field editor. The predecessor
20
+ * (`lenses/tasks/components/detail/item-detail-surface.tsx`) paid for this by
21
+ * hoisting the title and description drafts into the frame chooser and
22
+ * re-applying `scrollTop` in a layout effect after every reframe — and that
23
+ * still only covered the two edits it knew the names of. With five field
24
+ * editors, an activity tab and two scrollers, that approach does not scale;
25
+ * it just moves the loss somewhere less obvious.
26
+ *
27
+ * So the frame is ONE element tree in every mode, and the mode changes only
28
+ * positioning, size and flex direction. Nothing unmounts, so nothing is
29
+ * hoisted, so nothing is lost — the requirement is satisfied structurally
30
+ * rather than by a list of state to remember.
31
+ *
32
+ * That does mean this file owns dialog mechanics `Modal` already owns, so it
33
+ * borrows the same pieces rather than reimplementing them: `useLayer` for
34
+ * topmost-wins Escape, `tabbablesWithin` for the trap, `.ds-card-surface` for
35
+ * the chrome. What is genuinely new here is the framing, and the framing is
36
+ * the part neither primitive could express.
37
+ *
38
+ * ## Orientation follows the mode
39
+ *
40
+ * `detailPaneSide()` — activity at the inline end in `full` and `modal`, below
41
+ * in `panel`, with the divider turning with it. There is no second setting to
42
+ * disagree with the mode.
43
+ *
44
+ * ## Modality
45
+ *
46
+ * `full` and `modal` are modal: a backdrop, a focus trap. `panel` is not —
47
+ * the board stays legible and operable beside it, which is the whole point of
48
+ * the shape — so it has no backdrop and no trap, and answers Escape through
49
+ * the shared layer stack so a select opened inside it closes first.
50
+ *
51
+ * The dialog is ALWAYS MOUNTED and driven by `open` (§8). Closed it is
52
+ * `inert`, so an off-screen panel is neither a stray dialog in the
53
+ * accessibility tree nor a set of stray tab stops.
54
+ *
55
+ * ## The split's bounds are the CONTAINER's, not a constant
56
+ *
57
+ * The first version expressed the constraint on one pane only: the activity
58
+ * pane had a `min` and a `max` in absolute px, and the fields pane was
59
+ * `flex-1 min-w-0` — which is to say it had no minimum at all and would shrink
60
+ * to nothing. In `modal` mode (840px at most) an activity pane at its legal
61
+ * 720 left the fields ~116px, and the label column alone is 132px; the value
62
+ * column went negative and every value wrapped to about four characters
63
+ * ("Aug 22, 202", "Set star date"). An absolute maximum cannot know that.
64
+ *
65
+ * So the bounds are RECOMPUTED from the measured split — the aside may grow to
66
+ * `container − FIELDS_MIN − the divider`, and no further, in every mode and at
67
+ * every viewport. A `ResizeObserver` is what makes that hold: `full` / `modal`
68
+ * / `panel` and the viewport all change the container, and none of them is a
69
+ * React render this component can hook. The divider is handed the DERIVED
70
+ * bounds, so it cannot be dragged into a state the layout will not honour —
71
+ * the constraint is enforced where the gesture happens, not repaired after it.
72
+ *
73
+ * The fields pane still degrades below that floor rather than colliding: the
74
+ * field row stacks its label above its value under a container query, and
75
+ * every value truncates. See `field-row.tsx` and the `ds-field-list` block in
76
+ * styles.css.
77
+ *
78
+ * ## The PANEL has an outer edge, and it obeys the same rule
79
+ *
80
+ * `panel` mode's width was a hardcoded `max-inline-size: 560px`. That is fine
81
+ * at 1512 and wrong on a laptop — 560 of side panel plus a 308px rail leaves a
82
+ * 1024px screen 144px of board, which is half a lane. So the panel's leading
83
+ * edge is a `ResizableHandle` too, and its ceiling is derived the same way the
84
+ * split's is: MEASURED wrapper less a reserve the host declares, recomputed by
85
+ * a `ResizeObserver` on the wrapper, so the board keeps a whole lane beside it
86
+ * at every viewport. Its floor is the panel's own content minimum (`PANEL_MIN`
87
+ * — the header's non-shrinking cluster), never an arbitrary number.
88
+ *
89
+ * The two resizes are INDEPENDENT and both live: the outer one moves the
90
+ * panel's leading edge, the inner one moves the seam between the fields and
91
+ * the activity pane. Only `panel` mode draws the outer one — `full` and
92
+ * `modal` are centred overlays already sized against the viewport.
93
+ *
94
+ * Note which axis each governs, because it is the thing that looks like a bug
95
+ * and is not: in `panel` mode the split is a COLUMN, so the inner divider's
96
+ * bounds are derived from the container's HEIGHT and the outer drag does not
97
+ * move them. What the outer drag does move is the fields pane's INLINE size,
98
+ * and the collision that lives there — the value column falling under its
99
+ * 256px floor — is held off by `PANEL_MIN`, not by the split's clamp. The
100
+ * split's observer still watches the same box and re-derives from whatever it
101
+ * reports, so neither clamp is ever computed once and left stale.
102
+ *
103
+ * ## Collapsing a pane must not unmount it
104
+ *
105
+ * The whole reason this is one element tree is that state survives. A collapse
106
+ * that swapped a pane for `null` would throw away exactly what a mode switch
107
+ * is careful to keep — the scroll, the drafts, the active tab — so it is a
108
+ * SIZE change, not a conditional render.
109
+ *
110
+ * It is two boxes per pane, and that is load-bearing rather than decorative.
111
+ * The OUTER box is what collapses, to zero, with `overflow: hidden`. The INNER
112
+ * box keeps an explicit pixel size, so the collapsed pane's content is laid out
113
+ * at exactly the width it had a moment ago — which is what preserves
114
+ * `scrollTop`. Collapse the pane by shrinking the scroller itself and its
115
+ * content reflows to zero width, its `scrollHeight` collapses with it, and the
116
+ * browser clamps the scroll position to nothing; restoring then lands the
117
+ * operator at the top of a conversation they had scrolled halfway down.
118
+ */
119
+
120
+ import {
121
+ useCallback,
122
+ useEffect,
123
+ useLayoutEffect,
124
+ useMemo,
125
+ useRef,
126
+ useState,
127
+ type CSSProperties,
128
+ type ReactNode,
129
+ type RefObject,
130
+ } from 'react'
131
+ import { ChevronsDown, ChevronsLeft, ChevronsRight, ChevronsUp } from 'lucide-react'
132
+ import { cn } from '../lib/utils'
133
+ import { tabbablesWithin } from '../lib/focus'
134
+ import { useLayer } from '../lib/layer-stack'
135
+ import { ResizableHandle } from '../resizable'
136
+ import { DetailDivider } from './detail-divider'
137
+ import {
138
+ DETAIL_PANEL_DEFAULT_SIZE,
139
+ detailPaneSide,
140
+ type DetailCollapse,
141
+ type DetailMode,
142
+ type DetailPaneSide,
143
+ } from './types'
144
+
145
+ /** Per-orientation defaults for the activity pane, in px. */
146
+ const DEFAULT_ASIDE_SIZE: Readonly<Record<DetailPaneSide, number>> = {
147
+ side: 400,
148
+ bottom: 260,
149
+ }
150
+
151
+ const DEFAULT_ASIDE_MIN: Readonly<Record<DetailPaneSide, number>> = {
152
+ side: 280,
153
+ bottom: 160,
154
+ }
155
+
156
+ /**
157
+ * The ceiling the OPERATOR is offered, before the container has its say. The
158
+ * effective maximum is the smaller of this and what the fields pane can spare;
159
+ * see `boundsFor`.
160
+ */
161
+ const DEFAULT_ASIDE_MAX: Readonly<Record<DetailPaneSide, number>> = {
162
+ side: 720,
163
+ bottom: 560,
164
+ }
165
+
166
+ /**
167
+ * What the FIELDS pane may never go below, per orientation. The number the
168
+ * whole clamp is built on, so it is derived from the row rather than picked:
169
+ *
170
+ * side 288px. The field list pads `--space-4` each side (32), leaving
171
+ * 256px of content. Below 400px the row STACKS — label above value,
172
+ * full width — so 256px is the value column outright, which is about
173
+ * 30 characters at the row's 13px. Side by side above 400px the same
174
+ * 256px is what is left after the tightened 104px label and the 8px
175
+ * gap, so the value column lands on 256 from both directions and
176
+ * never goes under it at any divider position. That is the fix: not
177
+ * "a bit more room", a floor the value column cannot fall through.
178
+ * bottom 200px. A height, and a different question: the list's toolbar is
179
+ * ~48px, so this is the toolbar plus roughly four rows — enough for
180
+ * the pane to read as a list rather than as a sliver.
181
+ */
182
+ const FIELDS_MIN: Readonly<Record<DetailPaneSide, number>> = {
183
+ side: 288,
184
+ bottom: 200,
185
+ }
186
+
187
+ /**
188
+ * The divider strip, px — `w-1` / `h-1` in `detail-divider.tsx`. It sits
189
+ * BETWEEN the panes and therefore comes out of the container before either of
190
+ * them is measured.
191
+ */
192
+ const DIVIDER_SIZE = 4
193
+
194
+ /**
195
+ * The rail a collapsed pane leaves behind, px — `--space-6`, and the same 24
196
+ * the thread's collapse rail uses for the same reason: it is the interface
197
+ * standards' minimum target, and a rail thinner than that is a hairline
198
+ * pretending to be a control.
199
+ *
200
+ * It is IN FLOW rather than floating. The first pass pinned a round pill to
201
+ * the split's top corner and it landed on top of the field list's search and
202
+ * filter row — chrome overlapping chrome, which is the layout-stability rule
203
+ * from the other side: reserve the space, do not park something over content.
204
+ */
205
+ const RESTORE_RAIL_SIZE = 24
206
+
207
+ function clamp(value: number, low: number, high: number): number {
208
+ return Math.max(low, Math.min(high, value))
209
+ }
210
+
211
+ /**
212
+ * The bounds the divider actually gets.
213
+ *
214
+ * `container` is null until the observer has measured — before first layout,
215
+ * and in any environment without a `ResizeObserver`. There the absolute bounds
216
+ * stand, which is the behaviour this component already had.
217
+ *
218
+ * The order matters when the container is too small to satisfy both panes: the
219
+ * FIELDS floor wins and the aside takes what is left, down to nothing, rather
220
+ * than the two of them overflowing a panel that clips. `min` is pulled down to
221
+ * `max` in that case, because a separator whose minimum exceeds its maximum
222
+ * describes a range that does not exist.
223
+ */
224
+ function boundsFor(
225
+ container: number | null,
226
+ side: DetailPaneSide,
227
+ min: number,
228
+ max: number,
229
+ ): { readonly min: number; readonly max: number } {
230
+ if (container === null) return { min, max }
231
+ // Floored: `contentRect` is fractional on a scaled display, and a separator
232
+ // reporting `aria-valuemax="252.90625"` is both unreadable to a screen
233
+ // reader and a subpixel over the floor it is supposed to protect.
234
+ const room = Math.floor(container - FIELDS_MIN[side] - DIVIDER_SIZE)
235
+ const effectiveMax = Math.max(Math.min(max, room), 0)
236
+ return { min: Math.min(min, effectiveMax), max: effectiveMax }
237
+ }
238
+
239
+ /**
240
+ * What the PANEL may never be dragged below in `panel` mode, px — its own
241
+ * content minimum, derived from the widest thing it holds rather than picked.
242
+ *
243
+ * That thing is the HEADER, not the field list. Measured on the running lens,
244
+ * its non-shrinking cluster is 365px: mark-complete (110) + "Updated ‹when›"
245
+ * (75) + prev / next (68) + star, ellipsis and close (32 each), six
246
+ * `--space-2` gaps (48), and `--space-4` of inline padding either side (32).
247
+ * The title is the only flexible child, so under 365 the header stops fitting
248
+ * and the panel's `overflow: hidden` eats controls from the end — the close
249
+ * button first, which is a trap rather than a tight layout.
250
+ *
251
+ * 400 is that floor plus enough for the title to still read as a field, and it
252
+ * is also the width at which `FieldRow` stacks its label above its value: at
253
+ * and below it the value column is the full content width (~368px), well over
254
+ * the 256px floor `FIELDS_MIN` protects on the other axis. So neither
255
+ * collision is reachable by dragging the edge.
256
+ */
257
+ const PANEL_MIN = 400
258
+
259
+ /**
260
+ * How much inline space must stay clear beside the panel when the host does
261
+ * not say, px. One board lane at its floor (`grid-auto-columns: minmax(288px,
262
+ * 1fr)`) plus a `--space-4` gutter either side.
263
+ *
264
+ * It is a RESERVE rather than a constant maximum because 560px is fine at
265
+ * 1512 and wrong on a laptop: the ceiling has to be the viewport's answer, not
266
+ * a number. A host whose content sits behind a fixed rail passes its own
267
+ * `panelReserve` — this component measures the viewport, not what is under it,
268
+ * and 320px of viewport is not 320px of board when a 308px rail holds the left
269
+ * edge.
270
+ */
271
+ const DEFAULT_PANEL_RESERVE = 320
272
+
273
+ /**
274
+ * `ResizableHandle`'s own cross-axis hit padding, px — it widens the 4px line
275
+ * to an 8px target and pulls itself back by the same amount so the line stays
276
+ * centred on the edge it decorates.
277
+ *
278
+ * The panel's leading edge is the one place that pull-back would be thrown
279
+ * away: half the target would sit outside `overflow: hidden` and be clipped,
280
+ * leaving 6px to grab. So the strip is offset by it, and the whole 8px lands
281
+ * inside the panel. Mirrored rather than imported because it is the handle's
282
+ * internal geometry, not a shared measure.
283
+ */
284
+ const HANDLE_HIT_PAD = 2
285
+
286
+ /**
287
+ * The bounds the panel's leading edge actually gets.
288
+ *
289
+ * `available` is null until the wrapper has been measured — before first
290
+ * layout, and in any environment without a `ResizeObserver`. There the panel
291
+ * keeps the fixed geometry it has always had.
292
+ *
293
+ * The order matches `boundsFor`, and for the same reason: when the viewport
294
+ * cannot afford both the panel's floor and the reserve, the reserve wins and
295
+ * the minimum is pulled down to the maximum, because a separator whose minimum
296
+ * exceeds its maximum describes a range that does not exist.
297
+ */
298
+ function panelBoundsFor(
299
+ available: number | null,
300
+ reserve: number,
301
+ ): { readonly min: number; readonly max: number } {
302
+ if (available === null) {
303
+ return { min: PANEL_MIN, max: Math.max(PANEL_MIN, DETAIL_PANEL_DEFAULT_SIZE) }
304
+ }
305
+ // Floored for the same reason `boundsFor` floors: `contentRect` is
306
+ // fractional on a scaled display, and `aria-valuemax="879.328125"` is
307
+ // unreadable to a screen reader.
308
+ const max = Math.max(Math.floor(available - reserve), 0)
309
+ return { min: Math.min(PANEL_MIN, max), max }
310
+ }
311
+
312
+ /**
313
+ * The margin that keeps `full` from being a takeover, and the geometry of the
314
+ * other two. Sizes are `min()`-clamped against the viewport so the panel can
315
+ * never exceed the space its margin leaves.
316
+ */
317
+ const PANEL_GEOMETRY: Readonly<Record<DetailMode, CSSProperties>> = {
318
+ full: {
319
+ inlineSize: '100%',
320
+ maxInlineSize: '1280px',
321
+ blockSize: '100%',
322
+ },
323
+ modal: {
324
+ inlineSize: '100%',
325
+ maxInlineSize: '840px',
326
+ blockSize: 'min(720px, 100%)',
327
+ },
328
+ // `inlineSize` is REPLACED by the measured width below; what is here is the
329
+ // pre-measurement fallback, and it is the size the mode had before its
330
+ // leading edge became draggable. `maxInlineSize` stays as the last-resort
331
+ // guard: a stored width from a wider monitor can never overflow the wrapper
332
+ // even for the frame before the observer reports.
333
+ panel: {
334
+ inlineSize: `${DETAIL_PANEL_DEFAULT_SIZE}px`,
335
+ maxInlineSize: '100%',
336
+ blockSize: '100%',
337
+ },
338
+ }
339
+
340
+ const WRAPPER_PADDING: Readonly<Record<DetailMode, string>> = {
341
+ full: 'var(--space-6)',
342
+ modal: 'var(--space-6)',
343
+ panel: 'var(--space-3)',
344
+ }
345
+
346
+ export interface DetailSurfaceProps {
347
+ /** Drive the dialog with this. NEVER conditionally render the surface (§8). */
348
+ open: boolean
349
+ mode: DetailMode
350
+ onClose: () => void
351
+ /** Names the dialog. There is no `<h2>` of our own to point at. */
352
+ ariaLabel: string
353
+ /** The header row — a `<DetailHeader>`. Spans the full width above the split. */
354
+ header?: ReactNode | undefined
355
+ /** The fields pane. */
356
+ children: ReactNode
357
+ /** The activity pane. Omitted, the surface renders no divider and no pane. */
358
+ aside?: ReactNode | undefined
359
+ /**
360
+ * Size of the activity pane along the split axis, px. Controlled when
361
+ * supplied — and it is per-ORIENTATION, which is why the change handler is
362
+ * told which side it is reporting: 400px of width is not 400px of height.
363
+ */
364
+ asideSize?: number | undefined
365
+ onAsideSizeChange?: ((next: number, side: DetailPaneSide) => void) | undefined
366
+ /** Fires once on release / keypress. Persist from here, not the change. */
367
+ onAsideSizeCommit?: ((next: number, side: DetailPaneSide) => void) | undefined
368
+ /**
369
+ * The bounds the operator is OFFERED. Both are narrowed further against the
370
+ * measured container so the fields pane keeps its floor — passing a large
371
+ * `asideMax` cannot squeeze the fields to nothing.
372
+ */
373
+ asideMin?: number | undefined
374
+ asideMax?: number | undefined
375
+ /**
376
+ * Which pane is collapsed. Controlled when supplied, and per-ORIENTATION for
377
+ * the same reason the size is — the handler is told which side it reports.
378
+ */
379
+ collapsed?: DetailCollapse | undefined
380
+ onCollapsedChange?: ((next: DetailCollapse, side: DetailPaneSide) => void) | undefined
381
+ /** Names what the divider resizes. Default "Resize the activity pane". */
382
+ dividerLabel?: string | undefined
383
+ /**
384
+ * `panel` MODE ONLY: the panel's own width along the inline axis, px.
385
+ * Controlled when supplied. `full` and `modal` ignore every `panel*` prop —
386
+ * they are centred overlays whose size is already viewport-relative, and
387
+ * they render no edge handle at all.
388
+ */
389
+ panelSize?: number | undefined
390
+ onPanelSizeChange?: ((next: number) => void) | undefined
391
+ /** Fires once on release / keypress. Persist from here, not the change. */
392
+ onPanelSizeCommit?: ((next: number) => void) | undefined
393
+ /**
394
+ * How much inline space must stay clear beside the panel, px — the panel's
395
+ * effective maximum is the measured wrapper less this. Default
396
+ * `DEFAULT_PANEL_RESERVE`; a host whose content sits behind a fixed rail
397
+ * should pass the rail's width plus whatever its own content needs.
398
+ *
399
+ * Measured INSIDE this component's padding box, so a reserve derived from a
400
+ * viewport-relative offset is conservative by one gutter — which errs toward
401
+ * leaving the host MORE room, never less.
402
+ */
403
+ panelReserve?: number | undefined
404
+ /** Names the panel's own edge handle. Default "Resize the panel". */
405
+ panelResizeLabel?: string | undefined
406
+ /** On the dialog panel. */
407
+ className?: string | undefined
408
+ }
409
+
410
+ export function DetailSurface({
411
+ open,
412
+ mode,
413
+ onClose,
414
+ ariaLabel,
415
+ header,
416
+ children,
417
+ aside,
418
+ asideSize,
419
+ onAsideSizeChange,
420
+ onAsideSizeCommit,
421
+ asideMin,
422
+ asideMax,
423
+ collapsed,
424
+ onCollapsedChange,
425
+ dividerLabel = 'Resize the activity pane',
426
+ panelSize,
427
+ onPanelSizeChange,
428
+ onPanelSizeCommit,
429
+ panelReserve,
430
+ panelResizeLabel = 'Resize the panel',
431
+ className,
432
+ }: DetailSurfaceProps) {
433
+ const wrapperRef = useRef<HTMLDivElement | null>(null)
434
+ const panelRef = useRef<HTMLDivElement | null>(null)
435
+ const splitRef = useRef<HTMLDivElement | null>(null)
436
+ const side = detailPaneSide(mode)
437
+ const isModal = mode !== 'panel'
438
+ const isPanel = !isModal
439
+ const vertical = side === 'side'
440
+
441
+ /* ---- the activity pane's size, per orientation ------------------ */
442
+
443
+ // Uncontrolled state is a RECORD, not a number: the two orientations are
444
+ // different measurements and carrying one value across the switch would
445
+ // hand a 400px-wide pane's width to a pane that is now a height.
446
+ const [internalSize, setInternalSize] =
447
+ useState<Record<DetailPaneSide, number>>(DEFAULT_ASIDE_SIZE)
448
+ const controlled = asideSize !== undefined
449
+ const requested = controlled ? asideSize : internalSize[side]
450
+ const min = asideMin ?? DEFAULT_ASIDE_MIN[side]
451
+ const max = asideMax ?? DEFAULT_ASIDE_MAX[side]
452
+
453
+ /* ---- what the container will actually allow ---------------------- */
454
+
455
+ // Measured rather than derived: `full` / `modal` / `panel` and the viewport
456
+ // all change this box, and none of them is a render this component sees.
457
+ // A zero is ignored — that is a box with no layout yet, not a real
458
+ // measurement, and treating it as one would clamp the split to nothing on
459
+ // the frame before first paint.
460
+ const [containerSize, setContainerSize] = useState<number | null>(null)
461
+ useEffect(() => {
462
+ const node = splitRef.current
463
+ if (node === null) return
464
+ if (typeof ResizeObserver === 'undefined') return
465
+ // The reading in hand measured the OTHER axis if the orientation just
466
+ // changed. Dropped here rather than in an effect of its own, because two
467
+ // effects run in declaration order and the second would undo the first's
468
+ // fresh measurement on every re-run.
469
+ setContainerSize(null)
470
+ const observer = new ResizeObserver((entries) => {
471
+ const entry = entries[0]
472
+ if (entry === undefined) return
473
+ const measured = vertical ? entry.contentRect.width : entry.contentRect.height
474
+ if (measured > 0) setContainerSize(measured)
475
+ })
476
+ observer.observe(node)
477
+ return () => observer.disconnect()
478
+ }, [vertical])
479
+
480
+ const bounds = useMemo(
481
+ () => boundsFor(containerSize, side, min, max),
482
+ [containerSize, side, min, max],
483
+ )
484
+ const size = clamp(requested, bounds.min, bounds.max)
485
+
486
+ const setSize = useCallback(
487
+ (next: number) => {
488
+ if (!controlled) setInternalSize((current) => ({ ...current, [side]: next }))
489
+ onAsideSizeChange?.(next, side)
490
+ },
491
+ [controlled, onAsideSizeChange, side],
492
+ )
493
+
494
+ const commitSize = useCallback(
495
+ (next: number) => {
496
+ onAsideSizeCommit?.(next, side)
497
+ },
498
+ [onAsideSizeCommit, side],
499
+ )
500
+
501
+ /* ---- the panel's own width, `panel` mode only -------------------- */
502
+
503
+ // Uncontrolled state is a single number rather than a record: unlike the
504
+ // split, there is only one panel geometry — `full` and `modal` are centred
505
+ // overlays and never read it.
506
+ const [internalPanelSize, setInternalPanelSize] = useState(DETAIL_PANEL_DEFAULT_SIZE)
507
+ const panelControlled = panelSize !== undefined
508
+ const requestedPanel = panelControlled ? panelSize : internalPanelSize
509
+
510
+ // The wrapper is `inset: 0`, so its content box IS the viewport less this
511
+ // mode's padding. MEASURED, not read off `window`: the same observer then
512
+ // answers a viewport resize, a mode change and a zoom step, and there is no
513
+ // second copy of "how wide is it" for the two to disagree about.
514
+ //
515
+ // Cleared when the mode leaves `panel` so the reading in hand can never be
516
+ // the wrong layout's — the same discipline the split's observer applies on
517
+ // an orientation change.
518
+ const [availableSize, setAvailableSize] = useState<number | null>(null)
519
+ useEffect(() => {
520
+ if (!isPanel) {
521
+ setAvailableSize(null)
522
+ return
523
+ }
524
+ const node = wrapperRef.current
525
+ if (node === null) return
526
+ if (typeof ResizeObserver === 'undefined') return
527
+ const observer = new ResizeObserver((entries) => {
528
+ const entry = entries[0]
529
+ if (entry === undefined) return
530
+ const measured = entry.contentRect.width
531
+ // A zero is a box with no layout yet, not a measurement.
532
+ if (measured > 0) setAvailableSize(measured)
533
+ })
534
+ observer.observe(node)
535
+ return () => observer.disconnect()
536
+ }, [isPanel])
537
+
538
+ const panelBounds = useMemo(
539
+ () => panelBoundsFor(availableSize, panelReserve ?? DEFAULT_PANEL_RESERVE),
540
+ [availableSize, panelReserve],
541
+ )
542
+ // CLAMPED at render rather than written back to the host. A bound that moves
543
+ // with the viewport is not a reason to throw away a stored preference: shrink
544
+ // the window and the panel comes down to the ceiling, widen it again and the
545
+ // operator's own width returns.
546
+ const panelWidth = clamp(requestedPanel, panelBounds.min, panelBounds.max)
547
+
548
+ const setPanelWidth = useCallback(
549
+ (next: number) => {
550
+ if (!panelControlled) setInternalPanelSize(next)
551
+ onPanelSizeChange?.(next)
552
+ },
553
+ [panelControlled, onPanelSizeChange],
554
+ )
555
+
556
+ const commitPanelWidth = useCallback(
557
+ (next: number) => {
558
+ onPanelSizeCommit?.(next)
559
+ },
560
+ [onPanelSizeCommit],
561
+ )
562
+
563
+ /* ---- which pane is collapsed, per orientation -------------------- */
564
+
565
+ const [internalCollapsed, setInternalCollapsed] = useState<
566
+ Record<DetailPaneSide, DetailCollapse>
567
+ >({ side: 'none', bottom: 'none' })
568
+ const collapseControlled = collapsed !== undefined
569
+ const collapse: DetailCollapse = collapseControlled ? collapsed : internalCollapsed[side]
570
+
571
+ const setCollapse = useCallback(
572
+ (next: DetailCollapse) => {
573
+ if (!collapseControlled) {
574
+ setInternalCollapsed((current) => ({ ...current, [side]: next }))
575
+ }
576
+ onCollapsedChange?.(next, side)
577
+ },
578
+ [collapseControlled, onCollapsedChange, side],
579
+ )
580
+
581
+ /* ---- collapsing moves focus to the rail it leaves behind ---------
582
+ *
583
+ * The collapse capsule lives INSIDE the divider, and the divider is
584
+ * `hidden` the moment a pane collapses — so the button the operator just
585
+ * pressed unmounts under their finger and focus falls to `<body>`. A
586
+ * keyboard user then has to Tab from the top of the document to get back
587
+ * to anything, and the thing they were doing (put a pane away, bring it
588
+ * back) is exactly the thing they can no longer reach.
589
+ *
590
+ * The `RestoreRail` mounts in the SAME commit the capsule unmounts, so the
591
+ * move cannot happen in the click handler — the rail's DOM node does not
592
+ * exist yet. It is a layout effect keyed on a bumped counter rather than on
593
+ * `collapse` itself, so a re-render for any other reason (a resize, a prop
594
+ * change) never steals focus, and collapsing twice to the same value still
595
+ * fires.
596
+ *
597
+ * `useLayoutEffect` and not `useEffect`: it runs before paint, so focus
598
+ * never visibly sits on `<body>` in between.
599
+ */
600
+ const restoreRailRef = useRef<HTMLButtonElement | null>(null)
601
+ const [restoreFocusTick, setRestoreFocusTick] = useState(0)
602
+ const restoreFocusPending = useRef<'rail' | 'fields-capsule' | 'aside-capsule' | null>(
603
+ null,
604
+ )
605
+
606
+ const collapseTo = useCallback(
607
+ (next: DetailCollapse) => {
608
+ // Both directions unmount the control that was pressed. Collapsing
609
+ // replaces the capsule with the rail; EXPANDING unmounts the rail and
610
+ // brings the capsules back — measured in the browser landing on
611
+ // <body>, exactly the same defect mirrored.
612
+ restoreFocusPending.current =
613
+ next !== 'none'
614
+ ? 'rail'
615
+ : collapse === 'aside'
616
+ ? 'aside-capsule'
617
+ : 'fields-capsule'
618
+ setCollapse(next)
619
+ setRestoreFocusTick((tick) => tick + 1)
620
+ },
621
+ [collapse, setCollapse],
622
+ )
623
+
624
+ useLayoutEffect(() => {
625
+ const target = restoreFocusPending.current
626
+ if (target === null) return
627
+ restoreFocusPending.current = null
628
+ if (target === 'rail') {
629
+ restoreRailRef.current?.focus()
630
+ return
631
+ }
632
+ // The capsules belong to `DetailDivider`, which mounts them in the same
633
+ // commit — scoped to THIS surface's split, in DOM order (fields, then
634
+ // activity), so a second detail surface on the page cannot be hit.
635
+ const capsules = splitRef.current?.querySelectorAll<HTMLButtonElement>(
636
+ '[data-slot="detail-collapse-button"]',
637
+ )
638
+ const capsule = capsules?.[target === 'aside-capsule' ? 1 : 0]
639
+ capsule?.focus()
640
+ }, [restoreFocusTick])
641
+
642
+ // A deliberate size gesture — drag, arrow, Home — is also a statement that
643
+ // the operator wants both panes. Matching `ResizableSplit`'s `setMode('split')`.
644
+ const resizeTo = useCallback(
645
+ (next: number) => {
646
+ if (collapse !== 'none') setCollapse('none')
647
+ setSize(next)
648
+ },
649
+ [collapse, setCollapse, setSize],
650
+ )
651
+
652
+ /* ---- focus: in on open, back to the opener on close ------------- */
653
+
654
+ useEffect(() => {
655
+ if (!open) return
656
+ const panel = panelRef.current
657
+ if (!panel) return
658
+ const active = document.activeElement
659
+ const previous =
660
+ active instanceof HTMLElement && active !== document.body ? active : null
661
+ const initial = panel.querySelector<HTMLElement>('[data-autofocus]')
662
+ ;(initial ?? panel).focus()
663
+ return () => {
664
+ // The half that gets forgotten. Without it, closing drops the caret to
665
+ // <body> and the next Tab restarts from the top of the page.
666
+ if (previous && previous.isConnected) previous.focus()
667
+ }
668
+ }, [open])
669
+
670
+ /* ---- Escape, through the shared stack --------------------------- */
671
+
672
+ // `modal` for the two centred modes so the Tab trap's topmost-MODAL guard
673
+ // can find it; `popover` for the side panel, which is non-modal and must
674
+ // let a select opened inside it answer Escape first.
675
+ const layer = useLayer({
676
+ enabled: open,
677
+ kind: isModal ? 'modal' : 'popover',
678
+ elementRef: panelRef,
679
+ onEscape: () => onClose(),
680
+ })
681
+
682
+ /* ---- Tab trap, modal modes only --------------------------------- */
683
+
684
+ useEffect(() => {
685
+ if (!open || !isModal) return
686
+ const panel = panelRef.current
687
+ if (!panel) return
688
+
689
+ const onKeyDown = (event: KeyboardEvent) => {
690
+ if (event.key !== 'Tab') return
691
+ if (!layer.isTopOfKind()) return
692
+ const active = document.activeElement
693
+ // Focus outside the panel is almost always a body-level portal (a
694
+ // menu, a popover) opened FROM it. Wrapping that back in would break
695
+ // the portal's own keyboard handling.
696
+ if (!(active instanceof HTMLElement) || !panel.contains(active)) return
697
+
698
+ const tabbables = tabbablesWithin(panel)
699
+ const first = tabbables[0]
700
+ const last = tabbables[tabbables.length - 1]
701
+ if (!first || !last) {
702
+ event.preventDefault()
703
+ panel.focus()
704
+ return
705
+ }
706
+ if (active === panel) {
707
+ if (event.shiftKey) {
708
+ event.preventDefault()
709
+ last.focus()
710
+ }
711
+ return
712
+ }
713
+ if (event.shiftKey && active === first) {
714
+ event.preventDefault()
715
+ last.focus()
716
+ } else if (!event.shiftKey && active === last) {
717
+ event.preventDefault()
718
+ first.focus()
719
+ }
720
+ }
721
+
722
+ document.addEventListener('keydown', onKeyDown)
723
+ return () => document.removeEventListener('keydown', onKeyDown)
724
+ }, [open, isModal, layer])
725
+
726
+ const backdropLit = open && isModal
727
+
728
+ /* ---- the split's geometry ---------------------------------------- */
729
+
730
+ const hasAside = aside !== undefined
731
+ const fieldsCollapsed = hasAside && collapse === 'fields'
732
+ const asideCollapsed = !hasAside || collapse === 'aside'
733
+ const floor = FIELDS_MIN[side]
734
+
735
+ // The size the fields pane is holding right now. When it collapses, its
736
+ // INNER box is pinned here so its content does not reflow to zero width and
737
+ // take the scroll position with it.
738
+ const railShowing = hasAside && collapse !== 'none'
739
+ const fieldsFrozen = Math.max(
740
+ floor,
741
+ containerSize === null
742
+ ? floor
743
+ : containerSize - DIVIDER_SIZE - size - (railShowing ? RESTORE_RAIL_SIZE : 0),
744
+ )
745
+
746
+ /** Along the split axis; the cross axis is always released to 0. */
747
+ const axis = (along: CSSProperties['inlineSize']): CSSProperties =>
748
+ vertical
749
+ ? { inlineSize: along, minInlineSize: along, minBlockSize: 0 }
750
+ : { blockSize: along, minBlockSize: along, minInlineSize: 0 }
751
+
752
+ const fieldsClipStyle: CSSProperties = fieldsCollapsed
753
+ ? { flex: '0 0 0px', ...axis(0), overflow: 'hidden' }
754
+ : {
755
+ flex: '1 1 0%',
756
+ ...(vertical
757
+ ? { minInlineSize: `${floor}px`, minBlockSize: 0 }
758
+ : { minBlockSize: `${floor}px`, minInlineSize: 0 }),
759
+ overflow: 'hidden',
760
+ }
761
+
762
+ const fieldsInnerStyle: CSSProperties | undefined = fieldsCollapsed
763
+ ? {
764
+ flex: '0 0 auto',
765
+ ...(vertical
766
+ ? { inlineSize: `${fieldsFrozen}px`, blockSize: '100%' }
767
+ : { blockSize: `${fieldsFrozen}px`, inlineSize: '100%' }),
768
+ }
769
+ : undefined
770
+
771
+ /** Fill what is left, along either axis. */
772
+ const fill: CSSProperties = {
773
+ flex: '1 1 0%',
774
+ minInlineSize: 0,
775
+ minBlockSize: 0,
776
+ }
777
+
778
+ const asideClipStyle: CSSProperties = asideCollapsed
779
+ ? { flex: '0 0 0px', ...axis(0), overflow: 'hidden' }
780
+ : fieldsCollapsed
781
+ ? // The other pane is away, so this one takes the room. Without this the
782
+ // aside sat at its stored 400px with the rest of the panel blank.
783
+ { ...fill, overflow: 'hidden' }
784
+ : { flex: '0 0 auto', ...axis(`${size}px`), overflow: 'hidden' }
785
+
786
+ // Explicit while the pane is the one that may be COLLAPSED — that is what
787
+ // keeps its content laid out at the width it had while its clip is at zero,
788
+ // and therefore what keeps its scroll position. When the OTHER pane is the
789
+ // collapsed one this pane is on screen and simply fills.
790
+ const asideInnerStyle: CSSProperties = fieldsCollapsed
791
+ ? fill
792
+ : {
793
+ flex: '0 0 auto',
794
+ ...(vertical
795
+ ? { inlineSize: `${size}px`, blockSize: '100%' }
796
+ : { blockSize: `${size}px`, inlineSize: '100%' }),
797
+ }
798
+
799
+ // `panel` is the only mode whose inline size is the operator's to set, so it
800
+ // is the only one whose geometry is not the table's verbatim.
801
+ const geometry: CSSProperties = isPanel
802
+ ? { ...PANEL_GEOMETRY.panel, inlineSize: `${panelWidth}px` }
803
+ : PANEL_GEOMETRY[mode]
804
+
805
+ // The chevron points the way the pane will come BACK — outward from the
806
+ // rail, which is the edge it went behind.
807
+ const RestoreFieldsIcon = vertical ? ChevronsRight : ChevronsDown
808
+ const RestoreAsideIcon = vertical ? ChevronsLeft : ChevronsUp
809
+
810
+ return (
811
+ <div
812
+ ref={wrapperRef}
813
+ data-slot="detail-surface"
814
+ data-mode={mode}
815
+ data-state={open ? 'open' : 'closed'}
816
+ className={cn(
817
+ 'fixed inset-0 z-50 flex',
818
+ mode === 'panel' ? 'items-stretch justify-end' : 'items-center justify-center',
819
+ )}
820
+ // The wrapper covers the viewport in every mode so the panel can be
821
+ // positioned against it, but only the backdrop and the panel take
822
+ // pointer events — otherwise `panel` mode would swallow every click on
823
+ // the board it is meant to leave usable.
824
+ style={{ padding: WRAPPER_PADDING[mode], pointerEvents: 'none' }}
825
+ >
826
+ {/* Backdrop. Present in every mode and driven by opacity, so switching
827
+ from `panel` to `full` fades one in rather than popping it. It is
828
+ translucent by token (`--modal-overlay`), which is what leaves the
829
+ board faintly visible behind `full`. */}
830
+ <div
831
+ aria-hidden="true"
832
+ data-slot="detail-backdrop"
833
+ onClick={onClose}
834
+ className="absolute inset-0 transition-opacity duration-[var(--dur-base)] ease-[var(--ease-out)] motion-reduce:transition-none"
835
+ style={{
836
+ background: 'rgb(var(--modal-overlay))',
837
+ opacity: backdropLit ? 1 : 0,
838
+ pointerEvents: backdropLit ? 'auto' : 'none',
839
+ }}
840
+ />
841
+
842
+ <div
843
+ ref={panelRef}
844
+ role="dialog"
845
+ aria-label={ariaLabel}
846
+ {...(isModal ? { 'aria-modal': true } : {})}
847
+ tabIndex={-1}
848
+ inert={!open}
849
+ // `inert` already takes the closed panel out of the accessibility
850
+ // tree and the tab ring in a browser. `aria-hidden` says the same
851
+ // thing to everything that does NOT implement `inert` — older AT, and
852
+ // every testing-library query, which is not a footnote: an
853
+ // always-mounted dialog that stays visible to `getByRole('dialog')`
854
+ // makes the NEXT dialog on the page ambiguous, and the host's own
855
+ // tests start failing on a component they never touched. Safe
856
+ // together here because nothing inside can hold focus while closed:
857
+ // the same `open` that sets this drives the focus effect.
858
+ aria-hidden={open ? undefined : true}
859
+ data-slot="detail-panel"
860
+ data-mode={mode}
861
+ className={cn(
862
+ 'ds-card-surface relative flex min-h-0 flex-col overflow-hidden',
863
+ 'transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-out)]',
864
+ 'motion-reduce:transition-none',
865
+ // The panel takes focus on open (`tabIndex={-1}` + the focus
866
+ // effect below), so `focus:outline-none` with nothing in its
867
+ // place left the first keyboard landing spot in a dialog with no
868
+ // visible cue at all. Same treatment as every other control in
869
+ // the package: kill the UA outline on the `focus-visible` state
870
+ // only, and replace it with the ring. Forced-colors restores a
871
+ // real outline (styles.css).
872
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
873
+ open ? 'opacity-100' : 'opacity-0',
874
+ !open && (mode === 'panel' ? 'translate-x-2' : 'translate-y-2'),
875
+ className,
876
+ )}
877
+ // `pointerEvents` is INLINE, not a utility class. The wrapper sets
878
+ // `none` so `panel` mode leaves the board clickable, and the panel has
879
+ // to opt back in — through the one channel that cannot be lost to a
880
+ // missing Tailwind `@source` (CLAUDE.md §2: build-clean but visually
881
+ // broken). Whether a dialog can be clicked is behaviour, not styling.
882
+ style={{
883
+ ...geometry,
884
+ borderRadius: 'var(--radius-xl)',
885
+ pointerEvents: open ? 'auto' : 'none',
886
+ }}
887
+ >
888
+ {/* The panel's OWN edge, and only in `panel` mode. `full` and `modal`
889
+ are centred overlays already sized against the viewport, so an edge
890
+ handle on them would be a control with nothing to say.
891
+
892
+ It is `ResizableHandle` itself, not a third implementation of a
893
+ drag: the WAI-ARIA splitter role, `aria-valuenow/min/max`, the
894
+ arrows, `Home` and the double-click reset all come from there, the
895
+ same as the inner divider's. What differs is that this one needs no
896
+ strip of its own — the panel's border already draws the seam — so
897
+ it is absolute on the leading edge, offset by `HANDLE_HIT_PAD` so
898
+ the whole 8px target lands inside the panel's `overflow: hidden`
899
+ rather than half outside it. */}
900
+ {isPanel ? (
901
+ <div
902
+ data-slot="detail-panel-resize"
903
+ className="absolute z-[2] w-1"
904
+ style={{ insetBlock: 0, insetInlineStart: `${HANDLE_HIT_PAD}px` }}
905
+ >
906
+ <ResizableHandle
907
+ side="left"
908
+ value={panelWidth}
909
+ onChange={setPanelWidth}
910
+ onCommit={commitPanelWidth}
911
+ min={panelBounds.min}
912
+ max={panelBounds.max}
913
+ defaultValue={clamp(
914
+ DETAIL_PANEL_DEFAULT_SIZE,
915
+ panelBounds.min,
916
+ panelBounds.max,
917
+ )}
918
+ aria-label={panelResizeLabel}
919
+ // A RING, not the handle's own fill: this edge sits over the
920
+ // panel's border rather than between two panes, and a 4px tint
921
+ // there is not a focus indicator anyone can find.
922
+ className="focus-visible:[box-shadow:var(--ring-focus)]"
923
+ />
924
+ </div>
925
+ ) : null}
926
+
927
+ {header === undefined ? null : header}
928
+
929
+ <div
930
+ ref={splitRef}
931
+ data-slot="detail-split"
932
+ data-orientation={vertical ? 'side' : 'bottom'}
933
+ data-collapsed={collapse}
934
+ className={cn(
935
+ 'relative flex min-h-0 flex-1',
936
+ vertical ? 'flex-row' : 'flex-col',
937
+ )}
938
+ >
939
+ {/* The way back, and it is IN FLOW on the edge the pane went behind
940
+ — a pane the operator cannot restore is a trap, and a floating
941
+ pill over the field list's own toolbar is a different bug. */}
942
+ {fieldsCollapsed ? (
943
+ <RestoreRail
944
+ buttonRef={restoreRailRef}
945
+ vertical={vertical}
946
+ edge="start"
947
+ label="Show the fields pane"
948
+ onClick={() => collapseTo('none')}
949
+ >
950
+ <RestoreFieldsIcon size={14} strokeWidth={1.5} aria-hidden="true" />
951
+ </RestoreRail>
952
+ ) : null}
953
+
954
+ {/* Outer box collapses; inner box keeps its size. See the header. */}
955
+ <div
956
+ data-slot="detail-fields-clip"
957
+ data-detail-clip=""
958
+ className="flex"
959
+ style={fieldsClipStyle}
960
+ inert={fieldsCollapsed}
961
+ // Same policy as the closed dialog above, and for the same reason:
962
+ // `inert` is honoured by browsers, `aria-hidden` is what every
963
+ // non-`inert`-aware AT and every testing-library query reads. A
964
+ // collapsed pane is clipped to zero, so leaving it in the
965
+ // accessibility tree means a screen reader still walks fields the
966
+ // operator has put away. Safe together because nothing inside can
967
+ // hold focus while collapsed — collapsing moves focus to the
968
+ // restore rail (see `collapseTo`).
969
+ aria-hidden={fieldsCollapsed ? true : undefined}
970
+ >
971
+ <div
972
+ data-slot="detail-fields"
973
+ className="flex min-h-0 min-w-0 flex-1 flex-col"
974
+ {...(fieldsInnerStyle === undefined ? {} : { style: fieldsInnerStyle })}
975
+ >
976
+ {children}
977
+ </div>
978
+ </div>
979
+
980
+ {aside === undefined ? null : (
981
+ <>
982
+ <DetailDivider
983
+ orientation={side}
984
+ value={size}
985
+ onChange={resizeTo}
986
+ onCommit={commitSize}
987
+ min={bounds.min}
988
+ max={bounds.max}
989
+ defaultValue={DEFAULT_ASIDE_SIZE[side]}
990
+ aria-label={dividerLabel}
991
+ hidden={collapse !== 'none'}
992
+ onCollapseFields={() => collapseTo('fields')}
993
+ onCollapseAside={() => collapseTo('aside')}
994
+ onToggleCollapse={() =>
995
+ collapseTo(collapse === 'none' ? 'aside' : 'none')
996
+ }
997
+ />
998
+ <div
999
+ data-slot="detail-activity-clip"
1000
+ data-detail-clip=""
1001
+ className="flex"
1002
+ style={asideClipStyle}
1003
+ inert={asideCollapsed}
1004
+ // See the fields pane above — same policy, same reason.
1005
+ aria-hidden={asideCollapsed ? true : undefined}
1006
+ >
1007
+ <div
1008
+ data-slot="detail-activity"
1009
+ className="flex min-h-0 min-w-0 flex-col"
1010
+ style={asideInnerStyle}
1011
+ >
1012
+ {aside}
1013
+ </div>
1014
+ </div>
1015
+
1016
+ {collapse === 'aside' ? (
1017
+ <RestoreRail
1018
+ buttonRef={restoreRailRef}
1019
+ vertical={vertical}
1020
+ edge="end"
1021
+ label="Show the activity pane"
1022
+ onClick={() => collapseTo('none')}
1023
+ >
1024
+ <RestoreAsideIcon size={14} strokeWidth={1.5} aria-hidden="true" />
1025
+ </RestoreRail>
1026
+ ) : null}
1027
+ </>
1028
+ )}
1029
+ </div>
1030
+ </div>
1031
+ </div>
1032
+ )
1033
+ }
1034
+
1035
+ /**
1036
+ * The rail a collapsed pane leaves behind.
1037
+ *
1038
+ * The whole rail IS the button — the same trick the thread's collapse rail
1039
+ * uses: a 1px hairline is the right VISUAL weight for a seam and a hopeless
1040
+ * target, so the target is 24px wide and the hairline is pinned inside it,
1041
+ * leaving 23px of it transparent.
1042
+ */
1043
+ function RestoreRail({
1044
+ buttonRef,
1045
+ vertical,
1046
+ edge,
1047
+ label,
1048
+ onClick,
1049
+ children,
1050
+ }: {
1051
+ /** The surface parks focus here after a collapse — see `collapseTo`. Only
1052
+ * one rail is ever mounted (`collapse` is one of three exclusive values),
1053
+ * so both call sites can share a single ref. */
1054
+ buttonRef?: RefObject<HTMLButtonElement | null> | undefined
1055
+ vertical: boolean
1056
+ edge: 'start' | 'end'
1057
+ label: string
1058
+ onClick: () => void
1059
+ children: ReactNode
1060
+ }) {
1061
+ return (
1062
+ <button
1063
+ type="button"
1064
+ ref={buttonRef}
1065
+ data-slot="detail-restore"
1066
+ data-edge={edge}
1067
+ onClick={onClick}
1068
+ aria-label={label}
1069
+ title={label}
1070
+ className={cn(
1071
+ 'relative flex shrink-0 items-center justify-center',
1072
+ 'transition-colors duration-[var(--dur-fast)] ease-[var(--ease-out)]',
1073
+ 'motion-reduce:transition-none',
1074
+ 'hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]',
1075
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
1076
+ )}
1077
+ style={{
1078
+ ...(vertical
1079
+ ? { inlineSize: `${RESTORE_RAIL_SIZE}px`, alignSelf: 'stretch' }
1080
+ : { blockSize: `${RESTORE_RAIL_SIZE}px`, inlineSize: '100%' }),
1081
+ color: 'rgb(var(--text-tertiary))',
1082
+ }}
1083
+ >
1084
+ {/* The seam keeps reading as a seam while the pane is away. */}
1085
+ <span
1086
+ aria-hidden="true"
1087
+ className="pointer-events-none absolute"
1088
+ style={{
1089
+ background: 'rgb(var(--border))',
1090
+ ...(vertical
1091
+ ? {
1092
+ insetBlock: 0,
1093
+ inlineSize: '1px',
1094
+ ...(edge === 'end'
1095
+ ? { insetInlineStart: 0 }
1096
+ : { insetInlineEnd: 0 }),
1097
+ }
1098
+ : {
1099
+ insetInline: 0,
1100
+ blockSize: '1px',
1101
+ ...(edge === 'end'
1102
+ ? { insetBlockStart: 0 }
1103
+ : { insetBlockEnd: 0 }),
1104
+ }),
1105
+ }}
1106
+ />
1107
+ {children}
1108
+ </button>
1109
+ )
1110
+ }