@softize/opus 19.0.0 → 20.1.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 (148) hide show
  1. package/CHANGELOG.md +239 -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/0020-icon-tiles-are-explicitly-composed.md +8 -0
  7. package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +19 -1
  9. package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
  10. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
  11. package/docs/data-products.md +15 -9
  12. package/docs/protocol.md +3 -4
  13. package/docs/releasing.md +2 -2
  14. package/package.json +2 -2
  15. package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
  16. package/registry/templates/app/package.json +1 -1
  17. package/src/core/runtime.ts +96 -8
  18. package/src/core/types.ts +5 -2
  19. package/src/ui/components/patterns/action-form-card.tsx +11 -1
  20. package/src/ui/components/patterns/content-header.tsx +109 -18
  21. package/src/ui/components/patterns/fill-region.ts +150 -0
  22. package/src/ui/components/patterns/list.tsx +91 -92
  23. package/src/ui/components/patterns/page.tsx +20 -4
  24. package/src/ui/components/patterns/presentation.tsx +52 -37
  25. package/src/ui/components/patterns/split.tsx +44 -4
  26. package/src/ui/components/patterns/surface-anatomy.ts +35 -8
  27. package/src/ui/components/patterns/surface-header.tsx +58 -25
  28. package/src/ui/components/patterns/trigger.tsx +12 -2
  29. package/src/ui/components/primitives/alert.tsx +9 -1
  30. package/src/ui/components/primitives/avatar.tsx +7 -3
  31. package/src/ui/components/primitives/card.tsx +1 -1
  32. package/src/ui/components/primitives/carousel.tsx +34 -5
  33. package/src/ui/components/primitives/checkbox.tsx +11 -3
  34. package/src/ui/components/primitives/command.tsx +2 -1
  35. package/src/ui/components/primitives/copyable.tsx +120 -10
  36. package/src/ui/components/primitives/dialog.tsx +42 -11
  37. package/src/ui/components/primitives/drawer.tsx +10 -2
  38. package/src/ui/components/primitives/empty.tsx +1 -1
  39. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  40. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  41. package/src/ui/components/primitives/input-group.tsx +9 -2
  42. package/src/ui/components/primitives/input-otp.tsx +1 -1
  43. package/src/ui/components/primitives/kbd.tsx +1 -1
  44. package/src/ui/components/primitives/label.tsx +3 -0
  45. package/src/ui/components/primitives/menu.tsx +5 -0
  46. package/src/ui/components/primitives/popover.tsx +1 -1
  47. package/src/ui/components/primitives/progress.tsx +9 -1
  48. package/src/ui/components/primitives/slider.tsx +62 -11
  49. package/src/ui/components/primitives/truncate.tsx +10 -1
  50. package/src/ui/docs/content/accordion.md +74 -27
  51. package/src/ui/docs/content/action-form-card.md +24 -15
  52. package/src/ui/docs/content/action-form-dialog.md +17 -7
  53. package/src/ui/docs/content/action-form.md +92 -77
  54. package/src/ui/docs/content/action-list-dialog.md +75 -38
  55. package/src/ui/docs/content/action-list.md +141 -58
  56. package/src/ui/docs/content/action-trigger.md +27 -10
  57. package/src/ui/docs/content/action-view.md +14 -14
  58. package/src/ui/docs/content/actions.md +161 -66
  59. package/src/ui/docs/content/ai.md +43 -44
  60. package/src/ui/docs/content/alert.md +83 -29
  61. package/src/ui/docs/content/ask.md +51 -26
  62. package/src/ui/docs/content/aspect-ratio.md +22 -24
  63. package/src/ui/docs/content/audit.md +23 -19
  64. package/src/ui/docs/content/auth.md +38 -19
  65. package/src/ui/docs/content/avatar.md +54 -5
  66. package/src/ui/docs/content/badge.md +29 -9
  67. package/src/ui/docs/content/breadcrumb.md +21 -8
  68. package/src/ui/docs/content/button.md +73 -43
  69. package/src/ui/docs/content/calendar.md +134 -26
  70. package/src/ui/docs/content/card.md +41 -15
  71. package/src/ui/docs/content/carousel.md +37 -6
  72. package/src/ui/docs/content/chat.md +118 -37
  73. package/src/ui/docs/content/checkbox.md +73 -7
  74. package/src/ui/docs/content/cli.md +94 -55
  75. package/src/ui/docs/content/collapsible.md +43 -19
  76. package/src/ui/docs/content/command.md +30 -8
  77. package/src/ui/docs/content/composer.md +23 -8
  78. package/src/ui/docs/content/content.md +119 -27
  79. package/src/ui/docs/content/copyable.md +39 -8
  80. package/src/ui/docs/content/customization.md +55 -46
  81. package/src/ui/docs/content/cycle.md +33 -18
  82. package/src/ui/docs/content/data-products.md +115 -0
  83. package/src/ui/docs/content/data-state.md +20 -6
  84. package/src/ui/docs/content/data.md +20 -19
  85. package/src/ui/docs/content/detail.md +12 -5
  86. package/src/ui/docs/content/dialog.md +107 -24
  87. package/src/ui/docs/content/dictionary-value.md +4 -4
  88. package/src/ui/docs/content/dock.md +54 -19
  89. package/src/ui/docs/content/dot.md +35 -5
  90. package/src/ui/docs/content/drawer.md +63 -19
  91. package/src/ui/docs/content/empty-value.md +20 -6
  92. package/src/ui/docs/content/empty.md +24 -3
  93. package/src/ui/docs/content/events.md +76 -16
  94. package/src/ui/docs/content/executions.md +9 -9
  95. package/src/ui/docs/content/field.md +21 -6
  96. package/src/ui/docs/content/getting-started.md +44 -34
  97. package/src/ui/docs/content/icon-picker.md +19 -13
  98. package/src/ui/docs/content/icon-tile.md +38 -15
  99. package/src/ui/docs/content/input-otp.md +58 -7
  100. package/src/ui/docs/content/input.md +22 -3
  101. package/src/ui/docs/content/item.md +53 -6
  102. package/src/ui/docs/content/kbd.md +5 -1
  103. package/src/ui/docs/content/label.md +13 -4
  104. package/src/ui/docs/content/log.md +5 -5
  105. package/src/ui/docs/content/markdown.md +5 -4
  106. package/src/ui/docs/content/mcp.md +49 -23
  107. package/src/ui/docs/content/menu.md +95 -10
  108. package/src/ui/docs/content/metric-card.md +12 -4
  109. package/src/ui/docs/content/observability.md +10 -9
  110. package/src/ui/docs/content/page.md +118 -12
  111. package/src/ui/docs/content/pagination.md +13 -5
  112. package/src/ui/docs/content/pane.md +55 -5
  113. package/src/ui/docs/content/popover.md +42 -5
  114. package/src/ui/docs/content/presentation.md +91 -47
  115. package/src/ui/docs/content/progress.md +27 -14
  116. package/src/ui/docs/content/queue.md +39 -21
  117. package/src/ui/docs/content/radio-group.md +26 -4
  118. package/src/ui/docs/content/router.md +29 -10
  119. package/src/ui/docs/content/runtime.md +90 -54
  120. package/src/ui/docs/content/scheduler.md +53 -21
  121. package/src/ui/docs/content/scroll-area.md +42 -35
  122. package/src/ui/docs/content/select.md +78 -33
  123. package/src/ui/docs/content/semantic-context.md +21 -2
  124. package/src/ui/docs/content/separator.md +15 -9
  125. package/src/ui/docs/content/sidebar.md +3 -2
  126. package/src/ui/docs/content/skeleton.md +6 -1
  127. package/src/ui/docs/content/slider.md +37 -9
  128. package/src/ui/docs/content/spinner.md +4 -3
  129. package/src/ui/docs/content/split.md +19 -0
  130. package/src/ui/docs/content/storage.md +8 -9
  131. package/src/ui/docs/content/switch.md +10 -7
  132. package/src/ui/docs/content/table.md +46 -8
  133. package/src/ui/docs/content/tabs.md +14 -4
  134. package/src/ui/docs/content/testing.md +76 -31
  135. package/src/ui/docs/content/textarea.md +5 -4
  136. package/src/ui/docs/content/toast.md +48 -7
  137. package/src/ui/docs/content/toggle.md +3 -3
  138. package/src/ui/docs/content/tokens.md +15 -26
  139. package/src/ui/docs/content/tooltip.md +18 -2
  140. package/src/ui/docs/content/truncate.md +36 -6
  141. package/src/ui/docs/content/ui.md +74 -22
  142. package/src/ui/docs/content/upgrading.md +45 -36
  143. package/src/ui/docs/plugin.ts +36 -2
  144. package/src/ui/docs/registry.tsx +6 -0
  145. package/src/ui/drivers/react.tsx +16 -8
  146. package/src/ui/meta.ts +32 -32
  147. package/src/ui/react.tsx +1 -0
  148. package/src/vite/design.ts +16 -4
@@ -66,6 +66,7 @@ import {
66
66
  type DictLike,
67
67
  } from "../../drivers/react.tsx";
68
68
  import { cn } from "../../lib/cn.ts";
69
+ import { FillBoundary, useFillLeaf } from "./fill-region.ts";
69
70
  import { objectSchemaShape } from "../../lib/object-schema.ts";
70
71
  import { Badge } from "../primitives/badge.tsx";
71
72
  import { DictionaryValue } from "../primitives/dictionary-value.tsx";
@@ -343,7 +344,12 @@ export interface ActionListProps<TInput, TItem> {
343
344
  action: ListActionLike;
344
345
  /** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
345
346
  input: TInput;
346
- /** `fill` ocupa a altura disponível e mantém a rolagem dentro da região de resultados. */
347
+ /**
348
+ * `fill` ocupa a altura disponível e mantém a rolagem dentro da região de resultados.
349
+ * Sem valor, vale `fill` quando o elemento pai repassa a altura de uma `Page` ou
350
+ * `Presentation` em `fill` — `PageBody`, `ContentBody` ou o corpo da Presentation — e
351
+ * `flow` nos demais casos (ADR 0024).
352
+ */
347
353
  layout?: "flow" | "fill";
348
354
  /** Ações do consumidor no fim da barra de filtros, depois dos controles de exibição. */
349
355
  toolbarActions?: ReactNode;
@@ -1212,20 +1218,8 @@ function AdvancedFiltersDialog({
1212
1218
  frameVariant={
1213
1219
  sectionVariant === "framed" ? "legend" : "default"
1214
1220
  }
1215
- title={
1216
- <span className="flex items-center gap-2">
1217
- {sectionIcons?.[section] !== undefined && (
1218
- <span
1219
- data-slot="content-title-icon"
1220
- aria-hidden="true"
1221
- className="inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
1222
- >
1223
- {sectionIcons[section]}
1224
- </span>
1225
- )}
1226
- {section}
1227
- </span>
1228
- }
1221
+ title={section}
1222
+ media={sectionIcons?.[section]}
1229
1223
  >
1230
1224
  <FieldGroup className={cn("grid gap-4", gridColumns)}>
1231
1225
  {entries.map(field)}
@@ -1666,7 +1660,7 @@ function RowActions({ children }: { children: ReactNode }) {
1666
1660
  export function ActionList<TInput, TItem>({
1667
1661
  action,
1668
1662
  input,
1669
- layout = "flow",
1663
+ layout: layoutProp,
1670
1664
  toolbarActions,
1671
1665
  columns,
1672
1666
  cells,
@@ -1693,6 +1687,7 @@ export function ActionList<TInput, TItem>({
1693
1687
  onStateChange,
1694
1688
  }: ActionListProps<TInput, TItem>) {
1695
1689
  const confirmBodyId = useId();
1690
+ const { layout, ref: rootRef } = useFillLeaf<HTMLDivElement>(layoutProp);
1696
1691
  // Query-backed (useListAction): busca declarativa por [name, input] E refetch
1697
1692
  // automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
1698
1693
  // O input efetivo é computado abaixo; o hook re-busca quando ele muda.
@@ -2367,6 +2362,7 @@ export function ActionList<TInput, TItem>({
2367
2362
  return (
2368
2363
  <TooltipProvider>
2369
2364
  <div
2365
+ ref={rootRef}
2370
2366
  data-action={action.name}
2371
2367
  data-layout={layout}
2372
2368
  className={cn(
@@ -2375,84 +2371,87 @@ export function ActionList<TInput, TItem>({
2375
2371
  : "space-y-3",
2376
2372
  )}
2377
2373
  >
2378
- {toolbar}
2379
- {beforeContent}
2380
- {selectionBar}
2381
- {/* Tabela e rodapé formam um bloco só: o `gap` da pilha separa a barra de
2382
- ferramentas do conteúdo, nunca o conteúdo do seu próprio rodapé. Duas
2383
- linhas: a dos resultados absorve a altura que sobra, a do rodapé se mede
2384
- pelo conteúdo. Em `flow` a primeira linha também é `1fr`, mas o bloco não
2385
- tem altura própria e ela vira a altura do conteúdo. */}
2386
- <div className={cn("grid min-h-0 grid-rows-[1fr_auto]", layout === "fill" && "flex-1")}>
2387
- {/* A rolagem mora aqui, nos dois eixos: é este elemento que o `sticky` do
2388
- cabeçalho enxerga. Um contêiner de rolagem entre ele e a tabela roubaria
2389
- essa referência e o cabeçalho voltaria a subir junto com as linhas.
2390
- Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
2391
- <div
2392
- aria-busy={busy}
2393
- className={cn(
2394
- "min-h-0",
2395
- layout === "fill" && "overflow-auto",
2396
- busy && items.length > 0 && "pointer-events-none opacity-60",
2397
- )}
2398
- >
2399
- {body}
2374
+ {/* A lista é a folha da cadeia de altura: o que ela hospeda decide a própria. */}
2375
+ <FillBoundary>
2376
+ {toolbar}
2377
+ {beforeContent}
2378
+ {selectionBar}
2379
+ {/* Tabela e rodapé formam um bloco só: o `gap` da pilha separa a barra de
2380
+ ferramentas do conteúdo, nunca o conteúdo do seu próprio rodapé. Duas
2381
+ linhas: a dos resultados absorve a altura que sobra, a do rodapé se mede
2382
+ pelo conteúdo. Em `flow` a primeira linha também é `1fr`, mas o bloco não
2383
+ tem altura própria e ela vira a altura do conteúdo. */}
2384
+ <div className={cn("grid min-h-0 grid-rows-[1fr_auto]", layout === "fill" && "flex-1")}>
2385
+ {/* A rolagem mora aqui, nos dois eixos: é este elemento que o `sticky` do
2386
+ cabeçalho enxerga. Um contêiner de rolagem entre ele e a tabela roubaria
2387
+ essa referência e o cabeçalho voltaria a subir junto com as linhas.
2388
+ Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
2389
+ <div
2390
+ aria-busy={busy}
2391
+ className={cn(
2392
+ "min-h-0",
2393
+ layout === "fill" && "overflow-auto",
2394
+ busy && items.length > 0 && "pointer-events-none opacity-60",
2395
+ )}
2396
+ >
2397
+ {body}
2398
+ </div>
2399
+ {footer}
2400
2400
  </div>
2401
- {footer}
2402
- </div>
2403
- {confirming !== null && (
2404
- <Dialog
2405
- mode="alert"
2406
- open
2407
- onOpenChange={(o) => {
2408
- if (!o) setConfirming(null);
2409
- }}
2410
- >
2411
- <DialogContent
2412
- aria-describedby={
2413
- confirming.confirm?.message === undefined
2414
- ? undefined
2415
- : confirmBodyId
2416
- }
2401
+ {confirming !== null && (
2402
+ <Dialog
2403
+ mode="alert"
2404
+ open
2405
+ onOpenChange={(o) => {
2406
+ if (!o) setConfirming(null);
2407
+ }}
2417
2408
  >
2418
- <DialogHeader>
2419
- <DialogTitle>
2420
- {confirming.confirm?.title ?? confirming.label}
2421
- </DialogTitle>
2422
- </DialogHeader>
2423
- {confirming.confirm?.message !== undefined && (
2424
- <DialogBody
2425
- id={confirmBodyId}
2426
- className="text-sm text-muted-foreground"
2427
- >
2428
- {confirming.confirm.message}
2429
- </DialogBody>
2430
- )}
2431
- <DialogFooter>
2432
- <ButtonGroup mode="spaced" distribution="equal">
2433
- <DialogClose initialFocus asChild>
2434
- <Button variant="outline">Cancelar</Button>
2435
- </DialogClose>
2436
- <DialogClose asChild>
2437
- <Button
2438
- context={
2439
- confirming.destructive === true ? "danger" : "primary"
2440
- }
2441
- variant="solid"
2442
- onClick={() => {
2443
- const a = confirming;
2444
- setConfirming(null);
2445
- void runBatch(a);
2446
- }}
2447
- >
2448
- {confirming.label}
2449
- </Button>
2450
- </DialogClose>
2451
- </ButtonGroup>
2452
- </DialogFooter>
2453
- </DialogContent>
2454
- </Dialog>
2455
- )}
2409
+ <DialogContent
2410
+ aria-describedby={
2411
+ confirming.confirm?.message === undefined
2412
+ ? undefined
2413
+ : confirmBodyId
2414
+ }
2415
+ >
2416
+ <DialogHeader>
2417
+ <DialogTitle>
2418
+ {confirming.confirm?.title ?? confirming.label}
2419
+ </DialogTitle>
2420
+ </DialogHeader>
2421
+ {confirming.confirm?.message !== undefined && (
2422
+ <DialogBody
2423
+ id={confirmBodyId}
2424
+ className="text-sm text-muted-foreground"
2425
+ >
2426
+ {confirming.confirm.message}
2427
+ </DialogBody>
2428
+ )}
2429
+ <DialogFooter>
2430
+ <ButtonGroup mode="spaced" distribution="equal">
2431
+ <DialogClose initialFocus asChild>
2432
+ <Button variant="outline">Cancelar</Button>
2433
+ </DialogClose>
2434
+ <DialogClose asChild>
2435
+ <Button
2436
+ context={
2437
+ confirming.destructive === true ? "danger" : "primary"
2438
+ }
2439
+ variant="solid"
2440
+ onClick={() => {
2441
+ const a = confirming;
2442
+ setConfirming(null);
2443
+ void runBatch(a);
2444
+ }}
2445
+ >
2446
+ {confirming.label}
2447
+ </Button>
2448
+ </DialogClose>
2449
+ </ButtonGroup>
2450
+ </DialogFooter>
2451
+ </DialogContent>
2452
+ </Dialog>
2453
+ )}
2454
+ </FillBoundary>
2456
2455
  </div>
2457
2456
  </TooltipProvider>
2458
2457
  );
@@ -30,6 +30,11 @@ import {
30
30
  } from "./page-heading-context.tsx";
31
31
  import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
32
32
  import { PageState, PageStatePresenceProvider } from "./page-state.tsx";
33
+ import {
34
+ FillRoot,
35
+ fillRegionClassName,
36
+ useFillRegion,
37
+ } from "./fill-region.ts";
33
38
 
34
39
  interface PageContextValue {}
35
40
 
@@ -205,7 +210,10 @@ interface PageBaseProps extends Omit<
205
210
  > {
206
211
  /** Classes do container. Default: centralizado com `max-w-7xl` (80rem). */
207
212
  className?: string;
208
- /** `fill` limita a página à altura do shell para que regiões internas assumam a rolagem. */
213
+ /**
214
+ * `fill` limita a página à altura do shell para que regiões internas assumam a rolagem.
215
+ * A altura chega sozinha a uma `ActionList` composta por `PageBody` e `Content` (ADR 0024).
216
+ */
209
217
  layout?: "flow" | "fill";
210
218
  }
211
219
 
@@ -455,7 +463,7 @@ export function Page({
455
463
  className,
456
464
  )}
457
465
  >
458
- {content}
466
+ <FillRoot active={layout === "fill"}>{content}</FillRoot>
459
467
  </div>
460
468
  </main>
461
469
  </PageHeadingPresenceProvider>
@@ -671,17 +679,25 @@ export function PageBack({
671
679
 
672
680
  export function PageBody({
673
681
  className,
682
+ children,
674
683
  ...props
675
684
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
676
685
  const page = useContext(PageContext);
677
686
  const integralState = useContext(PageIntegralStateContext);
687
+ const fill = useFillRegion();
678
688
  requireParent(page !== null, "PageBody", "Page");
679
689
  return (
680
690
  <div
681
691
  data-slot="page-body"
682
- className={cn(integralState && "flex min-h-0 flex-1 flex-col", className)}
692
+ {...fill.attributes}
693
+ className={cn(
694
+ (integralState || fill.filling) && fillRegionClassName,
695
+ className,
696
+ )}
683
697
  {...props}
684
- />
698
+ >
699
+ {fill.provide(children)}
700
+ </div>
685
701
  );
686
702
  }
687
703
 
@@ -83,6 +83,7 @@ import {
83
83
  ContentTitle,
84
84
  } from "./content-header.tsx";
85
85
  import { SurfaceAssistant } from "./surface-assistant.tsx";
86
+ import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
86
87
 
87
88
  interface PresentationFrameProps {
88
89
  surface: PresentationSurface;
@@ -109,6 +110,35 @@ interface PresentationFrameProps {
109
110
  const bodyClassName =
110
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";
111
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
+
112
142
  function PresentationActions({
113
143
  children,
114
144
  equal = false,
@@ -166,17 +196,14 @@ function PresentationFrame({
166
196
  </PresentationActions>
167
197
  );
168
198
  const body = (
169
- <div
170
- data-slot="presentation-body"
171
- className={cn(
172
- bodyClassName,
173
- layout === "fill" && "flex min-h-0 flex-1 flex-col",
174
- bodyClassNameProp,
175
- )}
176
- >
199
+ <PresentationBody layout={layout} className={bodyClassNameProp}>
177
200
  {typeof children === "function" ? children(footerTarget) : children}
178
- </div>
201
+ </PresentationBody>
179
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;
180
207
  const withAssistant = (content: ReactElement): ReactElement =>
181
208
  assistantLabel === undefined || assistantPanel === undefined ? (
182
209
  content
@@ -224,7 +251,12 @@ function PresentationFrame({
224
251
  )}
225
252
  <PageBody className="min-h-0 flex-1 overflow-hidden">
226
253
  {withAssistant(
227
- <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
+ >
228
260
  {content}
229
261
  </div>,
230
262
  )}
@@ -268,6 +300,7 @@ function PresentationFrame({
268
300
  className={cn(
269
301
  "flex h-full min-h-0 flex-col overflow-x-hidden",
270
302
  layout === "fill" ? "overflow-y-hidden" : "overflow-y-auto",
303
+ viewportFillClassName,
271
304
  )}
272
305
  >
273
306
  {body}
@@ -303,7 +336,12 @@ function PresentationFrame({
303
336
  </PageHeader>
304
337
  <PageBody className="min-h-0 flex-1 overflow-hidden">
305
338
  {withAssistant(
306
- <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
+ >
307
345
  {body}
308
346
  </div>,
309
347
  )}
@@ -315,26 +353,15 @@ function PresentationFrame({
315
353
 
316
354
  if (surface === "dialog") {
317
355
  return (
318
- <Dialog
319
- open={open}
320
- onOpenChange={(nextOpen) => {
321
- if (!nextOpen && blocked) return;
322
- onOpenChange?.(nextOpen);
323
- }}
324
- >
356
+ <Dialog open={open} onOpenChange={onOpenChange}>
325
357
  <DialogContent
326
358
  className={cn(
327
359
  assistantOpen ? "h-[min(48rem,85vh)] max-w-5xl" : undefined,
328
360
  className,
329
361
  )}
330
362
  aria-describedby={undefined}
363
+ // `closeDisabled` bloqueia o botão de fechar, o Esc e o clique fora da superfície.
331
364
  closeDisabled={blocked}
332
- onEscapeKeyDown={(event) => {
333
- if (blocked) event.preventDefault();
334
- }}
335
- onPointerDownOutside={(event) => {
336
- if (blocked) event.preventDefault();
337
- }}
338
365
  >
339
366
  <div className="flex h-full min-h-0 flex-col">
340
367
  <DialogHeader>
@@ -363,13 +390,7 @@ function PresentationFrame({
363
390
  }
364
391
 
365
392
  return (
366
- <Drawer
367
- open={open}
368
- onOpenChange={(nextOpen) => {
369
- if (!nextOpen && blocked) return;
370
- onOpenChange?.(nextOpen);
371
- }}
372
- >
393
+ <Drawer open={open} onOpenChange={onOpenChange}>
373
394
  <DrawerContent
374
395
  className={cn(
375
396
  assistantOpen
@@ -379,12 +400,6 @@ function PresentationFrame({
379
400
  )}
380
401
  aria-describedby={undefined}
381
402
  closeDisabled={blocked}
382
- onEscapeKeyDown={(event) => {
383
- if (blocked) event.preventDefault();
384
- }}
385
- onPointerDownOutside={(event) => {
386
- if (blocked) event.preventDefault();
387
- }}
388
403
  >
389
404
  <div className="flex h-full min-h-0 flex-col">
390
405
  <DrawerHeader>
@@ -1,4 +1,4 @@
1
- import { Children, isValidElement, 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
4
  import { Pane, paneSizeToCss, type PaneProps, type PaneSize } from './pane.tsx'
@@ -30,10 +30,50 @@ export interface SplitProps {
30
30
  children: ReactNode
31
31
  }
32
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
+ */
33
66
  function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
34
- return Children.toArray(children).filter(
35
- (child): child is ReactElement<PaneProps> => isValidElement<PaneProps>(child) && child.type === Pane,
36
- )
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
+ })
37
77
  }
38
78
 
39
79
  // Números continuam percentuais por compatibilidade com Split/Pane. Strings preservam
@@ -9,7 +9,8 @@
9
9
  * Dois layouts, e cada um é um pacote indivisível:
10
10
  *
11
11
  * - `continuous` — sem divisores. O ritmo separa: o header se aproxima do corpo e a
12
- * última região fecha o espaço de baixo. É o card de leitura.
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.
13
14
  * - `sectioned` — regiões simétricas, com divisor entre as que forem adjacentes. É o
14
15
  * modal, o drawer e o painel.
15
16
  *
@@ -18,7 +19,14 @@
18
19
  * é mais folgado que um painel de operação.
19
20
  */
20
21
 
21
- import { createContext, useContext } from "react";
22
+ import {
23
+ createContext,
24
+ createElement,
25
+ useContext,
26
+ type ReactElement,
27
+ type ReactNode,
28
+ } from "react";
29
+ import { FillBoundary } from "./fill-region.ts";
22
30
 
23
31
  export type SurfaceFamily = "card" | "dialog" | "drawer" | "pane";
24
32
  export type SurfaceLayout = "continuous" | "sectioned";
@@ -76,9 +84,11 @@ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
76
84
  card: {
77
85
  regions: {
78
86
  // O `pb-3` do header e do body encurtam a distância até a região seguinte; quando
79
- // o body é a última região, ele reassume o respiro de baixo (`[&:last-child]:pb-5`).
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.
80
90
  continuous: {
81
- header: "px-5 pt-5 pb-3",
91
+ header: "px-5 pt-5 pb-3 [&:last-child]:pb-5",
82
92
  body: "px-5 py-3 [&:last-child]:pb-5 [&:first-child]:pt-5",
83
93
  footer: "px-5 pt-3 pb-5",
84
94
  },
@@ -95,7 +105,7 @@ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
95
105
  dialog: {
96
106
  regions: {
97
107
  continuous: {
98
- header: "p-4 pb-2",
108
+ header: "p-4 pb-2 [&:last-child]:pb-4",
99
109
  body: "p-4 py-2 [&:last-child]:pb-4 [&:first-child]:pt-4",
100
110
  footer: "p-4 pt-2",
101
111
  },
@@ -112,7 +122,7 @@ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
112
122
  drawer: {
113
123
  regions: {
114
124
  continuous: {
115
- header: "p-4 pb-2",
125
+ header: "p-4 pb-2 [&:last-child]:pb-4",
116
126
  body: "p-4 py-2 [&:last-child]:pb-4 [&:first-child]:pt-4",
117
127
  footer: "p-4 pt-2",
118
128
  },
@@ -129,7 +139,7 @@ export const surfaceAnatomy: Record<SurfaceFamily, FamilyAnatomy> = {
129
139
  pane: {
130
140
  regions: {
131
141
  continuous: {
132
- header: "flex shrink-0 items-center px-3 pt-3 pb-2",
142
+ header: "flex shrink-0 items-center px-3 pt-3 pb-2 [&:last-child]:pb-3",
133
143
  body: "p-3 [&:first-child]:pt-3",
134
144
  footer: "px-3 pt-2 pb-3",
135
145
  },
@@ -200,7 +210,24 @@ export const surfaceActionsClasses = "flex shrink-0 items-center gap-2";
200
210
  */
201
211
  const SurfaceLayoutContext = createContext<SurfaceLayout | null>(null);
202
212
 
203
- export const SurfaceLayoutProvider = SurfaceLayoutContext.Provider;
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
+ }
204
231
 
205
232
  const familyDefaultLayout: Record<SurfaceFamily, SurfaceLayout> = {
206
233
  card: "continuous",