@softize/opus 18.5.0 → 20.0.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 (151) hide show
  1. package/CHANGELOG.md +275 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/check.mjs +198 -10
  4. package/bin/lib/copy.mjs +29 -0
  5. package/bin/lib/mcp.mjs +1 -1
  6. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +298 -0
  7. package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
  8. package/docs/data-products.md +15 -9
  9. package/docs/protocol.md +3 -4
  10. package/docs/releasing.md +2 -2
  11. package/package.json +2 -2
  12. package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
  13. package/registry/templates/app/package.json +1 -1
  14. package/src/core/runtime.ts +96 -8
  15. package/src/core/types.ts +5 -2
  16. package/src/ui/components/patterns/action-form-card.tsx +11 -1
  17. package/src/ui/components/patterns/content-header.tsx +16 -12
  18. package/src/ui/components/patterns/fill-region.ts +150 -0
  19. package/src/ui/components/patterns/heading-level.ts +16 -0
  20. package/src/ui/components/patterns/list.tsx +103 -73
  21. package/src/ui/components/patterns/page.tsx +20 -4
  22. package/src/ui/components/patterns/pane.tsx +294 -0
  23. package/src/ui/components/patterns/presentation.tsx +81 -54
  24. package/src/ui/components/patterns/sidebar.tsx +13 -72
  25. package/src/ui/components/patterns/split.tsx +51 -38
  26. package/src/ui/components/patterns/surface-anatomy.ts +246 -0
  27. package/src/ui/components/patterns/surface-header.tsx +51 -6
  28. package/src/ui/components/patterns/surface-media.tsx +69 -0
  29. package/src/ui/components/patterns/trigger.tsx +12 -2
  30. package/src/ui/components/primitives/alert.tsx +9 -1
  31. package/src/ui/components/primitives/avatar.tsx +7 -3
  32. package/src/ui/components/primitives/card.tsx +91 -29
  33. package/src/ui/components/primitives/carousel.tsx +34 -5
  34. package/src/ui/components/primitives/checkbox.tsx +11 -3
  35. package/src/ui/components/primitives/command.tsx +14 -5
  36. package/src/ui/components/primitives/copyable.tsx +120 -10
  37. package/src/ui/components/primitives/detail.tsx +10 -13
  38. package/src/ui/components/primitives/dialog.tsx +179 -45
  39. package/src/ui/components/primitives/drawer.tsx +131 -24
  40. package/src/ui/components/primitives/empty.tsx +1 -1
  41. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  42. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  43. package/src/ui/components/primitives/input-group.tsx +9 -2
  44. package/src/ui/components/primitives/input-otp.tsx +1 -1
  45. package/src/ui/components/primitives/kbd.tsx +1 -1
  46. package/src/ui/components/primitives/label.tsx +3 -0
  47. package/src/ui/components/primitives/menu.tsx +5 -0
  48. package/src/ui/components/primitives/metric-card.tsx +98 -39
  49. package/src/ui/components/primitives/popover.tsx +1 -1
  50. package/src/ui/components/primitives/progress.tsx +9 -1
  51. package/src/ui/components/primitives/slider.tsx +62 -11
  52. package/src/ui/components/primitives/truncate.tsx +10 -1
  53. package/src/ui/docs/content/accordion.md +74 -27
  54. package/src/ui/docs/content/action-form-card.md +24 -15
  55. package/src/ui/docs/content/action-form-dialog.md +17 -7
  56. package/src/ui/docs/content/action-form.md +92 -77
  57. package/src/ui/docs/content/action-list-dialog.md +75 -38
  58. package/src/ui/docs/content/action-list.md +134 -52
  59. package/src/ui/docs/content/action-trigger.md +27 -10
  60. package/src/ui/docs/content/action-view.md +14 -14
  61. package/src/ui/docs/content/actions.md +161 -66
  62. package/src/ui/docs/content/ai.md +43 -44
  63. package/src/ui/docs/content/alert.md +83 -29
  64. package/src/ui/docs/content/ask.md +51 -26
  65. package/src/ui/docs/content/aspect-ratio.md +22 -24
  66. package/src/ui/docs/content/audit.md +23 -19
  67. package/src/ui/docs/content/auth.md +38 -19
  68. package/src/ui/docs/content/avatar.md +54 -5
  69. package/src/ui/docs/content/badge.md +29 -9
  70. package/src/ui/docs/content/breadcrumb.md +21 -8
  71. package/src/ui/docs/content/button.md +73 -43
  72. package/src/ui/docs/content/calendar.md +134 -26
  73. package/src/ui/docs/content/card.md +252 -35
  74. package/src/ui/docs/content/carousel.md +37 -6
  75. package/src/ui/docs/content/chat.md +118 -37
  76. package/src/ui/docs/content/checkbox.md +73 -7
  77. package/src/ui/docs/content/cli.md +94 -55
  78. package/src/ui/docs/content/collapsible.md +43 -19
  79. package/src/ui/docs/content/command.md +30 -8
  80. package/src/ui/docs/content/composer.md +23 -8
  81. package/src/ui/docs/content/content.md +37 -7
  82. package/src/ui/docs/content/copyable.md +39 -8
  83. package/src/ui/docs/content/customization.md +55 -46
  84. package/src/ui/docs/content/cycle.md +33 -18
  85. package/src/ui/docs/content/data-products.md +115 -0
  86. package/src/ui/docs/content/data-state.md +20 -6
  87. package/src/ui/docs/content/data.md +20 -19
  88. package/src/ui/docs/content/detail.md +12 -5
  89. package/src/ui/docs/content/dialog.md +219 -28
  90. package/src/ui/docs/content/dictionary-value.md +4 -4
  91. package/src/ui/docs/content/dock.md +54 -19
  92. package/src/ui/docs/content/dot.md +35 -5
  93. package/src/ui/docs/content/drawer.md +193 -22
  94. package/src/ui/docs/content/empty-value.md +20 -6
  95. package/src/ui/docs/content/empty.md +24 -3
  96. package/src/ui/docs/content/events.md +76 -16
  97. package/src/ui/docs/content/executions.md +9 -9
  98. package/src/ui/docs/content/field.md +21 -6
  99. package/src/ui/docs/content/getting-started.md +44 -34
  100. package/src/ui/docs/content/icon-picker.md +19 -13
  101. package/src/ui/docs/content/icon-tile.md +28 -5
  102. package/src/ui/docs/content/input-otp.md +58 -7
  103. package/src/ui/docs/content/input.md +22 -3
  104. package/src/ui/docs/content/item.md +53 -6
  105. package/src/ui/docs/content/kbd.md +5 -1
  106. package/src/ui/docs/content/label.md +13 -4
  107. package/src/ui/docs/content/log.md +5 -5
  108. package/src/ui/docs/content/markdown.md +5 -4
  109. package/src/ui/docs/content/mcp.md +49 -23
  110. package/src/ui/docs/content/menu.md +95 -10
  111. package/src/ui/docs/content/metric-card.md +68 -21
  112. package/src/ui/docs/content/observability.md +10 -9
  113. package/src/ui/docs/content/page.md +125 -11
  114. package/src/ui/docs/content/pagination.md +13 -5
  115. package/src/ui/docs/content/pane.md +300 -0
  116. package/src/ui/docs/content/popover.md +42 -5
  117. package/src/ui/docs/content/presentation.md +91 -47
  118. package/src/ui/docs/content/progress.md +27 -14
  119. package/src/ui/docs/content/queue.md +39 -21
  120. package/src/ui/docs/content/radio-group.md +26 -4
  121. package/src/ui/docs/content/router.md +29 -10
  122. package/src/ui/docs/content/runtime.md +90 -54
  123. package/src/ui/docs/content/scheduler.md +53 -21
  124. package/src/ui/docs/content/scroll-area.md +42 -35
  125. package/src/ui/docs/content/select.md +78 -33
  126. package/src/ui/docs/content/semantic-context.md +21 -2
  127. package/src/ui/docs/content/separator.md +15 -9
  128. package/src/ui/docs/content/sidebar.md +25 -45
  129. package/src/ui/docs/content/skeleton.md +13 -4
  130. package/src/ui/docs/content/slider.md +37 -9
  131. package/src/ui/docs/content/spinner.md +4 -3
  132. package/src/ui/docs/content/split.md +34 -20
  133. package/src/ui/docs/content/storage.md +8 -9
  134. package/src/ui/docs/content/switch.md +10 -7
  135. package/src/ui/docs/content/table.md +46 -8
  136. package/src/ui/docs/content/tabs.md +14 -4
  137. package/src/ui/docs/content/testing.md +76 -31
  138. package/src/ui/docs/content/textarea.md +5 -4
  139. package/src/ui/docs/content/toast.md +48 -7
  140. package/src/ui/docs/content/toggle.md +3 -3
  141. package/src/ui/docs/content/tokens.md +15 -26
  142. package/src/ui/docs/content/tooltip.md +18 -2
  143. package/src/ui/docs/content/truncate.md +36 -6
  144. package/src/ui/docs/content/ui.md +74 -22
  145. package/src/ui/docs/content/upgrading.md +45 -36
  146. package/src/ui/docs/plugin.ts +36 -2
  147. package/src/ui/docs/registry.tsx +14 -2
  148. package/src/ui/drivers/react.tsx +16 -8
  149. package/src/ui/meta.ts +38 -32
  150. package/src/ui/react.tsx +14 -0
  151. package/src/vite/design.ts +16 -4
@@ -42,18 +42,22 @@ import { Copyable } from "../primitives/copyable.tsx";
42
42
  import { DetailField, DetailGroup } from "../primitives/detail.tsx";
43
43
  import {
44
44
  Dialog,
45
+ DialogActions,
45
46
  DialogBody,
46
47
  DialogContent,
47
48
  DialogFooter,
48
49
  DialogHeader,
50
+ DialogNavigation,
49
51
  DialogTitle,
50
52
  } from "../primitives/dialog.tsx";
51
53
  import {
52
54
  Drawer,
55
+ DrawerActions,
53
56
  DrawerBody,
54
57
  DrawerContent,
55
58
  DrawerFooter,
56
59
  DrawerHeader,
60
+ DrawerNavigation,
57
61
  DrawerTitle,
58
62
  } from "../primitives/drawer.tsx";
59
63
  import {
@@ -79,6 +83,7 @@ import {
79
83
  ContentTitle,
80
84
  } from "./content-header.tsx";
81
85
  import { SurfaceAssistant } from "./surface-assistant.tsx";
86
+ import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
82
87
 
83
88
  interface PresentationFrameProps {
84
89
  surface: PresentationSurface;
@@ -105,6 +110,35 @@ interface PresentationFrameProps {
105
110
  const bodyClassName =
106
111
  "h-full min-h-full [&>[data-slot=data-state]]:flex [&>[data-slot=data-state]]:min-h-full [&>[data-slot=data-state]]:items-center [&>[data-slot=data-state]]:justify-center";
107
112
 
113
+ /**
114
+ * Corpo da Presentation. Em página `fill`, é o último elo da cadeia de altura (ADR 0024):
115
+ * a lista do body é filha direta dele e assume `fill` sem classe no consumidor.
116
+ */
117
+ function PresentationBody({
118
+ layout,
119
+ className,
120
+ children,
121
+ }: {
122
+ layout: "flow" | "fill";
123
+ className?: string;
124
+ children: ReactNode;
125
+ }): ReactElement {
126
+ const fill = useFillRegion();
127
+ return (
128
+ <div
129
+ data-slot="presentation-body"
130
+ {...fill.attributes}
131
+ className={cn(
132
+ bodyClassName,
133
+ (layout === "fill" || fill.filling) && fillRegionClassName,
134
+ className,
135
+ )}
136
+ >
137
+ {fill.provide(children)}
138
+ </div>
139
+ );
140
+ }
141
+
108
142
  function PresentationActions({
109
143
  children,
110
144
  equal = false,
@@ -162,17 +196,14 @@ function PresentationFrame({
162
196
  </PresentationActions>
163
197
  );
164
198
  const body = (
165
- <div
166
- data-slot="presentation-body"
167
- className={cn(
168
- bodyClassName,
169
- layout === "fill" && "flex min-h-0 flex-1 flex-col",
170
- bodyClassNameProp,
171
- )}
172
- >
199
+ <PresentationBody layout={layout} className={bodyClassNameProp}>
173
200
  {typeof children === "function" ? children(footerTarget) : children}
174
- </div>
201
+ </PresentationBody>
175
202
  );
203
+ // Viewport entre o PageBody e o conteúdo. Em `fill`, é coluna flexível para a altura
204
+ // chegar ao conteúdo; a rolagem continua disponível como recurso de última instância.
205
+ const viewportFillClassName =
206
+ layout === "fill" ? fillRegionClassName : undefined;
176
207
  const withAssistant = (content: ReactElement): ReactElement =>
177
208
  assistantLabel === undefined || assistantPanel === undefined ? (
178
209
  content
@@ -220,7 +251,12 @@ function PresentationFrame({
220
251
  )}
221
252
  <PageBody className="min-h-0 flex-1 overflow-hidden">
222
253
  {withAssistant(
223
- <div className="h-full overflow-x-hidden overflow-y-auto">
254
+ <div
255
+ className={cn(
256
+ "h-full overflow-x-hidden overflow-y-auto",
257
+ viewportFillClassName,
258
+ )}
259
+ >
224
260
  {content}
225
261
  </div>,
226
262
  )}
@@ -253,19 +289,20 @@ function PresentationFrame({
253
289
  )}
254
290
  </PageHeader>
255
291
  )}
292
+ {contentOwnsHeading ? null : (
293
+ <PageIntro>
294
+ <PageTitle>{title}</PageTitle>
295
+ </PageIntro>
296
+ )}
256
297
  <PageBody className="min-h-0 flex-1 overflow-hidden">
257
298
  {withAssistant(
258
299
  <div
259
300
  className={cn(
260
301
  "flex h-full min-h-0 flex-col overflow-x-hidden",
261
302
  layout === "fill" ? "overflow-y-hidden" : "overflow-y-auto",
303
+ viewportFillClassName,
262
304
  )}
263
305
  >
264
- {contentOwnsHeading ? null : (
265
- <PageIntro>
266
- <PageTitle>{title}</PageTitle>
267
- </PageIntro>
268
- )}
269
306
  {body}
270
307
  </div>,
271
308
  )}
@@ -299,7 +336,12 @@ function PresentationFrame({
299
336
  </PageHeader>
300
337
  <PageBody className="min-h-0 flex-1 overflow-hidden">
301
338
  {withAssistant(
302
- <div className="h-full overflow-x-hidden overflow-y-auto px-8 py-8">
339
+ <div
340
+ className={cn(
341
+ "h-full overflow-x-hidden overflow-y-auto px-8 py-8",
342
+ viewportFillClassName,
343
+ )}
344
+ >
303
345
  {body}
304
346
  </div>,
305
347
  )}
@@ -311,37 +353,30 @@ function PresentationFrame({
311
353
 
312
354
  if (surface === "dialog") {
313
355
  return (
314
- <Dialog
315
- open={open}
316
- onOpenChange={(nextOpen) => {
317
- if (!nextOpen && blocked) return;
318
- onOpenChange?.(nextOpen);
319
- }}
320
- >
356
+ <Dialog open={open} onOpenChange={onOpenChange}>
321
357
  <DialogContent
322
358
  className={cn(
323
359
  assistantOpen ? "h-[min(48rem,85vh)] max-w-5xl" : undefined,
324
360
  className,
325
361
  )}
326
362
  aria-describedby={undefined}
363
+ // `closeDisabled` bloqueia o botão de fechar, o Esc e o clique fora da superfície.
327
364
  closeDisabled={blocked}
328
- onEscapeKeyDown={(event) => {
329
- if (blocked) event.preventDefault();
330
- }}
331
- onPointerDownOutside={(event) => {
332
- if (blocked) event.preventDefault();
333
- }}
334
365
  >
335
366
  <div className="flex h-full min-h-0 flex-col">
336
367
  <DialogHeader>
337
- {navigation}
338
- <DialogTitle className="truncate">{title}</DialogTitle>
368
+ {navigation === undefined ? null : (
369
+ <DialogNavigation>{navigation}</DialogNavigation>
370
+ )}
371
+ <DialogTitle>{title}</DialogTitle>
339
372
  {headerActions === undefined &&
340
373
  assistantAction === undefined ? null : (
341
- <PresentationActions>
342
- {headerActions}
343
- {assistantAction}
344
- </PresentationActions>
374
+ <DialogActions>
375
+ <PresentationActions>
376
+ {headerActions}
377
+ {assistantAction}
378
+ </PresentationActions>
379
+ </DialogActions>
345
380
  )}
346
381
  </DialogHeader>
347
382
  {withAssistant(<DialogBody>{body}</DialogBody>)}
@@ -355,13 +390,7 @@ function PresentationFrame({
355
390
  }
356
391
 
357
392
  return (
358
- <Drawer
359
- open={open}
360
- onOpenChange={(nextOpen) => {
361
- if (!nextOpen && blocked) return;
362
- onOpenChange?.(nextOpen);
363
- }}
364
- >
393
+ <Drawer open={open} onOpenChange={onOpenChange}>
365
394
  <DrawerContent
366
395
  className={cn(
367
396
  assistantOpen
@@ -371,23 +400,21 @@ function PresentationFrame({
371
400
  )}
372
401
  aria-describedby={undefined}
373
402
  closeDisabled={blocked}
374
- onEscapeKeyDown={(event) => {
375
- if (blocked) event.preventDefault();
376
- }}
377
- onPointerDownOutside={(event) => {
378
- if (blocked) event.preventDefault();
379
- }}
380
403
  >
381
404
  <div className="flex h-full min-h-0 flex-col">
382
405
  <DrawerHeader>
383
- {navigation}
384
- <DrawerTitle className="truncate">{title}</DrawerTitle>
406
+ {navigation === undefined ? null : (
407
+ <DrawerNavigation>{navigation}</DrawerNavigation>
408
+ )}
409
+ <DrawerTitle>{title}</DrawerTitle>
385
410
  {headerActions === undefined &&
386
411
  assistantAction === undefined ? null : (
387
- <PresentationActions>
388
- {headerActions}
389
- {assistantAction}
390
- </PresentationActions>
412
+ <DrawerActions>
413
+ <PresentationActions>
414
+ {headerActions}
415
+ {assistantAction}
416
+ </PresentationActions>
417
+ </DrawerActions>
391
418
  )}
392
419
  </DrawerHeader>
393
420
  {withAssistant(<DrawerBody>{body}</DrawerBody>)}
@@ -7,6 +7,19 @@ import {
7
7
  import { ChevronRight } from "lucide-react";
8
8
  import { useOverflowing } from "../../lib/overflow.ts";
9
9
  import { cn } from "../../lib/cn.ts";
10
+
11
+ // Os slots de Pane moram em `pane.tsx` desde a ADR 0023. A Sidebar continua sendo uma
12
+ // das superfícies que os compõe, então reexporta o que antes definia.
13
+ export {
14
+ PaneActions,
15
+ PaneBody,
16
+ PaneContent,
17
+ PaneDescription,
18
+ PaneFooter,
19
+ PaneHeader,
20
+ PaneMedia,
21
+ PaneTitle,
22
+ } from "./pane.tsx";
10
23
  import { focusRing } from "../primitives/control.ts";
11
24
  import {
12
25
  Tooltip,
@@ -58,78 +71,6 @@ export function Sidebar({
58
71
  );
59
72
  }
60
73
 
61
- /** Faixa fixa no topo de um pane — marca, contexto ou ações locais. */
62
- export function PaneHeader({
63
- className,
64
- children,
65
- }: {
66
- className?: string;
67
- children: ReactNode;
68
- }): React.ReactElement {
69
- return (
70
- <div
71
- data-slot="pane-header"
72
- className={cn("shrink-0 border-b border-border", className)}
73
- >
74
- {children}
75
- </div>
76
- );
77
- }
78
-
79
- /** Região rolável que ocupa o espaço restante de um pane. */
80
- export function PaneBody({
81
- className,
82
- children,
83
- }: {
84
- className?: string;
85
- children: ReactNode;
86
- }): React.ReactElement {
87
- return (
88
- <div
89
- data-slot="pane-body"
90
- className={cn("min-h-0 flex-1 overflow-y-auto", className)}
91
- >
92
- {children}
93
- </div>
94
- );
95
- }
96
-
97
- /** @deprecated Use PaneBody. Alias mantido para migração; sai numa próxima versão major. */
98
- export function PaneContent({
99
- className,
100
- children,
101
- }: {
102
- className?: string;
103
- children: ReactNode;
104
- }): React.ReactElement {
105
- return (
106
- <div
107
- data-slot="pane-content"
108
- className={cn("min-h-0 flex-1 overflow-y-auto", className)}
109
- >
110
- {children}
111
- </div>
112
- );
113
- }
114
-
115
- /** Faixa fixa no rodapé de um pane para ações persistentes. */
116
- export function PaneFooter({
117
- className,
118
- children,
119
- }: {
120
- className?: string;
121
- children: ReactNode;
122
- }): React.ReactElement {
123
- return (
124
- <div
125
- data-slot="pane-footer"
126
- className={cn("shrink-0 border-t border-border p-2", className)}
127
- >
128
- {children}
129
- </div>
130
- );
131
- }
132
-
133
74
  export interface SidebarItemProps {
134
75
  label: string;
135
76
  icon?: ReactNode;
@@ -1,41 +1,18 @@
1
- import { Children, isValidElement, type CSSProperties, type ReactElement, type ReactNode } from 'react'
1
+ import { Children, cloneElement, Fragment, isValidElement, type ReactElement, type ReactNode } from 'react'
2
2
  import { cn } from '../../lib/cn.ts'
3
3
  import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
4
+ import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from './pane.tsx'
5
+
6
+ // A família Pane mora em `pane.tsx` desde a ADR 0023; o Split continua sendo sua casa
7
+ // pública, então reexporta o que já era importado daqui.
8
+ export { Pane, PaneActions, PaneBody, PaneContent, PaneDescription, PaneFooter, PaneHeader, PaneMedia, PaneTitle } from './pane.tsx'
9
+ export type { PaneProps, PaneSize }
4
10
 
5
- export type PaneSize = number | `${number}%` | `${number}rem` | `${number}em` | `${number}vh` | `${number}vw` | `${number}px`
6
11
  export type SplitLayout = Record<string, number>
7
12
  export interface SplitLayoutChange {
8
13
  isUserInteraction: boolean
9
14
  }
10
15
 
11
- export interface PaneProps {
12
- /** Identidade estável usada pelo layout redimensionável e por layouts persistidos. */
13
- id?: string
14
- /** Tamanho inicial. Números representam porcentagens; strings aceitam %, rem, em, vh, vw e px. */
15
- initialSize?: PaneSize
16
- /** Tamanho mínimo quando o split é redimensionável. */
17
- minSize?: PaneSize
18
- /** Tamanho máximo quando o split é redimensionável. */
19
- maxSize?: PaneSize
20
- /** Ocupa o espaço remanescente no layout simples. */
21
- grow?: boolean
22
- /** Respiro interno padrão. Use `none` para chrome, navegação ou conteúdo com inset próprio. */
23
- inset?: 'none' | 'sm' | 'md' | 'lg'
24
- className?: string
25
- children: ReactNode
26
- }
27
-
28
- /** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de <Split>. */
29
- export function Pane({ id, initialSize, grow = false, inset = 'md', className, children }: PaneProps): React.ReactElement {
30
- const style: CSSProperties | undefined = initialSize === undefined ? undefined : { flexBasis: sizeToCss(initialSize) }
31
- const insets = { none: '', sm: 'p-2', md: 'p-4', lg: 'p-6' } as const
32
- return (
33
- <div id={id === undefined ? undefined : String(id)} data-slot="pane" data-inset={inset} className={cn('min-h-0 min-w-0 shrink-0', grow && 'flex-1', insets[inset], className)} style={style}>
34
- {children}
35
- </div>
36
- )
37
- }
38
-
39
16
  export interface SplitProps {
40
17
  /** Identidade estável do grupo redimensionável. */
41
18
  id?: string
@@ -53,20 +30,56 @@ export interface SplitProps {
53
30
  children: ReactNode
54
31
  }
55
32
 
33
+ /**
34
+ * Expande fragments em qualquer profundidade, como quem escreve `<>{a}{b}</>` espera.
35
+ * `Children.toArray` numera as chaves por nível, então o filho de um fragment herda a
36
+ * chave do fragment como prefixo — sem isso, `.0` de dentro colidiria com `.0` de fora.
37
+ */
38
+ function flattenFragments(children: ReactNode, prefix = ''): ReactNode[] {
39
+ return Children.toArray(children).flatMap((node): ReactNode[] => {
40
+ if (isValidElement(node) && node.type === Fragment)
41
+ return flattenFragments((node.props as { children?: ReactNode }).children, `${prefix}${String(node.key)}/`)
42
+ if (prefix !== '' && isValidElement(node)) return [cloneElement(node, { key: `${prefix}${String(node.key)}` })]
43
+ return [node]
44
+ })
45
+ }
46
+
47
+ /** Nome legível do filho recusado, para a mensagem apontar o nó certo. */
48
+ function describeChild(node: ReactNode): string {
49
+ if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') {
50
+ const text = String(node).trim()
51
+ return `o texto "${text.length > 40 ? `${text.slice(0, 40)}…` : text}"`
52
+ }
53
+ if (isValidElement(node)) {
54
+ const type = node.type as string | { displayName?: string; name?: string }
55
+ const name = typeof type === 'string' ? type : (type.displayName ?? type.name ?? '')
56
+ return name === '' ? 'um componente sem nome' : `<${name}>`
57
+ }
58
+ return 'um nó que não é elemento React'
59
+ }
60
+
61
+ /**
62
+ * O Split lê `initialSize`, `minSize`, `grow` e `id` de cada Pane ANTES de renderizá-lo,
63
+ * então só enxerga um `<Pane>` escrito diretamente (ou dentro de fragment). Qualquer outro
64
+ * filho seria descartado sem aviso — a tela perderia uma região e ninguém saberia por quê.
65
+ */
56
66
  function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
57
- return Children.toArray(children).filter(
58
- (child): child is ReactElement<PaneProps> => isValidElement<PaneProps>(child) && child.type === Pane,
59
- )
67
+ return flattenFragments(children).flatMap((child) => {
68
+ if (isValidElement<PaneProps>(child) && child.type === Pane) return [child]
69
+ // Espaço em branco (um `{' '}` da formatação) não é conteúdo: nunca ocupou lugar.
70
+ if (typeof child === 'string' && child.trim() === '') return []
71
+ throw new Error(
72
+ `Split recebeu ${describeChild(child)} como filho, mas só distribui <Pane>: esse conteúdo ficaria fora do layout. ` +
73
+ 'Envolva-o em um <Pane>. Se ele vem de um componente que devolve um <Pane>, escreva o <Pane> dentro do Split e ' +
74
+ 'passe o componente como filho dele: o Split lê o tamanho de cada pane antes de renderizá-lo.',
75
+ )
76
+ })
60
77
  }
61
78
 
62
79
  // Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
63
80
  // a unidade explícita para layouts que precisam de um limite físico, como `28rem`.
64
- function sizeToCss(value: PaneSize): string {
65
- return typeof value === 'number' ? `${value}%` : value
66
- }
67
-
68
81
  function resizableSize(value: PaneSize | undefined): string | undefined {
69
- return value === undefined ? undefined : sizeToCss(value)
82
+ return value === undefined ? undefined : paneSizeToCss(value)
70
83
  }
71
84
 
72
85
  function percentage(value: PaneSize | undefined): number | undefined {
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Anatomia de superfície (interno): as medidas que Card, Dialog, Drawer e Pane
3
+ * compartilham — ADR 0023.
4
+ *
5
+ * Aqui mora o que antes era escrito seis vezes: recuo de cada região, ritmo vertical,
6
+ * escala do título, posição das ações e a regra dos divisores. As famílias públicas
7
+ * consomem estas tabelas; nenhuma reescreve as classes.
8
+ *
9
+ * Dois layouts, e cada um é um pacote indivisível:
10
+ *
11
+ * - `continuous` — sem divisores. O ritmo separa: o header se aproxima do corpo e a
12
+ * última região — seja ela o header, o body ou o footer — fecha o espaço de baixo com
13
+ * o mesmo respiro do topo. É o card de leitura.
14
+ * - `sectioned` — regiões simétricas, com divisor entre as que forem adjacentes. É o
15
+ * modal, o drawer e o painel.
16
+ *
17
+ * O recuo lateral é o mesmo nas três regiões da mesma superfície; é isso que a suíte
18
+ * transversal verifica. Entre famílias diferentes ele pode divergir: o card de leitura
19
+ * é mais folgado que um painel de operação.
20
+ */
21
+
22
+ import {
23
+ createContext,
24
+ createElement,
25
+ useContext,
26
+ type ReactElement,
27
+ type ReactNode,
28
+ } from "react";
29
+ import { FillBoundary } from "./fill-region.ts";
30
+
31
+ export type SurfaceFamily = "card" | "dialog" | "drawer" | "pane";
32
+ export type SurfaceLayout = "continuous" | "sectioned";
33
+ export type SurfaceRegion = "header" | "body" | "footer";
34
+
35
+ /**
36
+ * Divisores: a região desenha a própria linha quando existe uma vizinha do outro lado.
37
+ * Um header sozinho não fecha nada; um footer que é a única região também não.
38
+ *
39
+ * O header suprime a própria linha quando o vizinho imediato é um footer — os dois
40
+ * juntos desenhariam duas bordas encostadas, uma sobre a outra.
41
+ */
42
+ const headerDivider = [
43
+ "[&:not(:last-child)]:border-b",
44
+ "[&:not(:last-child)]:border-border",
45
+ // A supressão precisa VENCER a regra acima na cascata, e não só vir depois dela: as
46
+ // duas nascem com a mesma especificidade e a ordem na folha é do Tailwind, não nossa.
47
+ // Repetir o `:not(:last-child)` aqui acrescenta uma pseudoclasse e desempata.
48
+ "[&:not(:last-child):has(+[data-slot$=-footer])]:border-b-0",
49
+ ].join(" ");
50
+ const footerDivider =
51
+ "[&:not(:first-child)]:border-t [&:not(:first-child)]:border-border";
52
+
53
+ interface RegionAnatomy {
54
+ header: string;
55
+ body: string;
56
+ footer: string;
57
+ }
58
+
59
+ interface FamilyAnatomy {
60
+ /** Recuo e ritmo por layout. O recuo lateral é comum às três regiões. */
61
+ regions: Record<SurfaceLayout, RegionAnatomy>;
62
+ /** Escala visual do título. O nível semântico do heading é escolhido à parte. */
63
+ title: Record<SurfaceLayout, string>;
64
+ /** Descrição no header, quando a família a oferece (ADRs 0014 e 0018 excluem modais). */
65
+ description: string;
66
+ /** Espaço entre mídia, título e ações na linha do header. */
67
+ headerGap: string;
68
+ /** Faixa do rodapé no layout seccionado, quando a família declara uma. */
69
+ footerBand: string;
70
+ }
71
+
72
+ /**
73
+ * O recuo seccionado de Card, Dialog e Drawer: 1rem, a densidade das superfícies de
74
+ * operação, e o que permite um card seccionado conviver com um drawer sem degrau
75
+ * visível. O pane tem a sua (`px-3`, a do chrome), declarada na tabela abaixo.
76
+ */
77
+ const sectionedRegions: RegionAnatomy = {
78
+ header: "p-4",
79
+ body: "p-4",
80
+ footer: "p-4",
81
+ };
82
+
83
+ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
84
+ card: {
85
+ regions: {
86
+ // O `pb-3` do header e do body encurtam a distância até a região seguinte; quando
87
+ // a região é a última, ela reassume o respiro de baixo (`[&:last-child]:pb-5`) —
88
+ // inclusive o header sozinho, que sem isso fecharia o card mais apertado embaixo
89
+ // do que em cima.
90
+ continuous: {
91
+ header: "px-5 pt-5 pb-3 [&:last-child]:pb-5",
92
+ body: "px-5 py-3 [&:last-child]:pb-5 [&:first-child]:pt-5",
93
+ footer: "px-5 pt-3 pb-5",
94
+ },
95
+ sectioned: sectionedRegions,
96
+ },
97
+ title: {
98
+ continuous: "text-base font-semibold leading-none",
99
+ sectioned: "text-sm font-semibold leading-none",
100
+ },
101
+ description: "text-sm text-muted-foreground",
102
+ headerGap: "gap-3",
103
+ footerBand: "",
104
+ },
105
+ dialog: {
106
+ regions: {
107
+ continuous: {
108
+ header: "p-4 pb-2 [&:last-child]:pb-4",
109
+ body: "p-4 py-2 [&:last-child]:pb-4 [&:first-child]:pt-4",
110
+ footer: "p-4 pt-2",
111
+ },
112
+ sectioned: sectionedRegions,
113
+ },
114
+ title: {
115
+ continuous: "text-lg font-semibold leading-none",
116
+ sectioned: "text-lg font-semibold leading-none",
117
+ },
118
+ description: "text-sm text-muted-foreground",
119
+ headerGap: "gap-4",
120
+ footerBand: "bg-muted/20 dark:bg-input/20",
121
+ },
122
+ drawer: {
123
+ regions: {
124
+ continuous: {
125
+ header: "p-4 pb-2 [&:last-child]:pb-4",
126
+ body: "p-4 py-2 [&:last-child]:pb-4 [&:first-child]:pt-4",
127
+ footer: "p-4 pt-2",
128
+ },
129
+ sectioned: sectionedRegions,
130
+ },
131
+ title: {
132
+ continuous: "text-lg font-semibold leading-none",
133
+ sectioned: "text-lg font-semibold leading-none",
134
+ },
135
+ description: "text-sm text-muted-foreground",
136
+ headerGap: "gap-4",
137
+ footerBand: "bg-muted/20 dark:bg-input/20",
138
+ },
139
+ pane: {
140
+ regions: {
141
+ continuous: {
142
+ header: "flex shrink-0 items-center px-3 pt-3 pb-2 [&:last-child]:pb-3",
143
+ body: "p-3 [&:first-child]:pt-3",
144
+ footer: "px-3 pt-2 pb-3",
145
+ },
146
+ sectioned: {
147
+ // A faixa tem a altura da barra do shell; `flex items-center` centraliza a linha
148
+ // dentro dela, e `shrink-0` impede que um corpo longo comprima a faixa.
149
+ header: "flex h-[3.75rem] shrink-0 items-center px-3",
150
+ body: "p-3",
151
+ footer: "px-3 py-2",
152
+ },
153
+ },
154
+ title: {
155
+ continuous: "text-sm font-semibold leading-none",
156
+ sectioned: "text-sm font-semibold leading-none",
157
+ },
158
+ description: "text-xs text-muted-foreground",
159
+ headerGap: "gap-2",
160
+ footerBand: "",
161
+ },
162
+ };
163
+
164
+ /** Classes da região, já com o divisor que o layout deriva da vizinhança. */
165
+ export function surfaceRegionClasses(
166
+ family: SurfaceFamily,
167
+ layout: SurfaceLayout,
168
+ region: SurfaceRegion,
169
+ ): string {
170
+ const anatomy = surfaceAnatomy[family];
171
+ const base = anatomy.regions[layout][region];
172
+ if (layout !== "sectioned") return base;
173
+ if (region === "header") return `${base} ${headerDivider}`;
174
+ if (region === "footer")
175
+ return [base, footerDivider, anatomy.footerBand].filter(Boolean).join(" ");
176
+ return base;
177
+ }
178
+
179
+ /** Escala visual do título — o nível do heading vem da composição, nunca daqui. */
180
+ export function surfaceTitleClasses(
181
+ family: SurfaceFamily,
182
+ layout: SurfaceLayout,
183
+ ): string {
184
+ return surfaceAnatomy[family].title[layout];
185
+ }
186
+
187
+ export function surfaceDescriptionClasses(family: SurfaceFamily): string {
188
+ return surfaceAnatomy[family].description;
189
+ }
190
+
191
+ /**
192
+ * A linha do header: mídia, heading que absorve a folga, ações no extremo oposto.
193
+ *
194
+ * `flex-1` não é decoração: quando a própria região é flex — o pane, que tem altura de
195
+ * barra —, a linha vira um item que se mede pelo conteúdo e as ações param no meio.
196
+ */
197
+ export function surfaceHeaderRowClasses(family: SurfaceFamily): string {
198
+ return `flex min-w-0 flex-1 items-center ${surfaceAnatomy[family].headerGap}`;
199
+ }
200
+
201
+ /** Ações do header e do footer: fora do fluxo de compressão, na escala densa. */
202
+ export const surfaceActionsClasses = "flex shrink-0 items-center gap-2";
203
+
204
+ /**
205
+ * O layout é do root e chega aos slots por contexto — ADR 0023. Slot não recebe
206
+ * `layout`: o divisor depende de quem está do outro lado, então a decisão precisa valer
207
+ * para a superfície inteira.
208
+ *
209
+ * O default por família preserva o que ela já entregava antes desta ADR.
210
+ */
211
+ const SurfaceLayoutContext = createContext<SurfaceLayout | null>(null);
212
+
213
+ /**
214
+ * Publica o layout da superfície para os slots. A superfície também interrompe a
215
+ * propagação de `layout="fill"` da página (ADR 0024): a altura do seu corpo é decisão dela,
216
+ * inclusive quando um portal a renderiza dentro de uma página `fill`.
217
+ */
218
+ export function SurfaceLayoutProvider({
219
+ value,
220
+ children,
221
+ }: {
222
+ value: SurfaceLayout;
223
+ children?: ReactNode;
224
+ }): ReactElement {
225
+ return createElement(
226
+ SurfaceLayoutContext.Provider,
227
+ { value },
228
+ createElement(FillBoundary, null, children),
229
+ );
230
+ }
231
+
232
+ const familyDefaultLayout: Record<SurfaceFamily, SurfaceLayout> = {
233
+ card: "continuous",
234
+ dialog: "sectioned",
235
+ drawer: "sectioned",
236
+ pane: "sectioned",
237
+ };
238
+
239
+ /** O layout vigente: o declarado pelo root da superfície, ou o default da família. */
240
+ export function useSurfaceLayout(family: SurfaceFamily): SurfaceLayout {
241
+ return useContext(SurfaceLayoutContext) ?? familyDefaultLayout[family];
242
+ }
243
+
244
+ export function defaultSurfaceLayout(family: SurfaceFamily): SurfaceLayout {
245
+ return familyDefaultLayout[family];
246
+ }