@softize/opus 19.0.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 (142) hide show
  1. package/CHANGELOG.md +193 -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 +5 -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 +15 -3
  18. package/src/ui/components/patterns/fill-region.ts +150 -0
  19. package/src/ui/components/patterns/list.tsx +89 -78
  20. package/src/ui/components/patterns/page.tsx +20 -4
  21. package/src/ui/components/patterns/presentation.tsx +52 -37
  22. package/src/ui/components/patterns/split.tsx +44 -4
  23. package/src/ui/components/patterns/surface-anatomy.ts +35 -8
  24. package/src/ui/components/patterns/trigger.tsx +12 -2
  25. package/src/ui/components/primitives/alert.tsx +9 -1
  26. package/src/ui/components/primitives/avatar.tsx +7 -3
  27. package/src/ui/components/primitives/carousel.tsx +34 -5
  28. package/src/ui/components/primitives/checkbox.tsx +11 -3
  29. package/src/ui/components/primitives/command.tsx +2 -1
  30. package/src/ui/components/primitives/copyable.tsx +120 -10
  31. package/src/ui/components/primitives/dialog.tsx +41 -10
  32. package/src/ui/components/primitives/drawer.tsx +9 -1
  33. package/src/ui/components/primitives/empty.tsx +1 -1
  34. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  35. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  36. package/src/ui/components/primitives/input-group.tsx +9 -2
  37. package/src/ui/components/primitives/input-otp.tsx +1 -1
  38. package/src/ui/components/primitives/kbd.tsx +1 -1
  39. package/src/ui/components/primitives/label.tsx +3 -0
  40. package/src/ui/components/primitives/menu.tsx +5 -0
  41. package/src/ui/components/primitives/popover.tsx +1 -1
  42. package/src/ui/components/primitives/progress.tsx +9 -1
  43. package/src/ui/components/primitives/slider.tsx +62 -11
  44. package/src/ui/components/primitives/truncate.tsx +10 -1
  45. package/src/ui/docs/content/accordion.md +74 -27
  46. package/src/ui/docs/content/action-form-card.md +24 -15
  47. package/src/ui/docs/content/action-form-dialog.md +17 -7
  48. package/src/ui/docs/content/action-form.md +92 -77
  49. package/src/ui/docs/content/action-list-dialog.md +75 -38
  50. package/src/ui/docs/content/action-list.md +134 -52
  51. package/src/ui/docs/content/action-trigger.md +27 -10
  52. package/src/ui/docs/content/action-view.md +14 -14
  53. package/src/ui/docs/content/actions.md +161 -66
  54. package/src/ui/docs/content/ai.md +43 -44
  55. package/src/ui/docs/content/alert.md +83 -29
  56. package/src/ui/docs/content/ask.md +51 -26
  57. package/src/ui/docs/content/aspect-ratio.md +22 -24
  58. package/src/ui/docs/content/audit.md +23 -19
  59. package/src/ui/docs/content/auth.md +38 -19
  60. package/src/ui/docs/content/avatar.md +54 -5
  61. package/src/ui/docs/content/badge.md +29 -9
  62. package/src/ui/docs/content/breadcrumb.md +21 -8
  63. package/src/ui/docs/content/button.md +73 -43
  64. package/src/ui/docs/content/calendar.md +134 -26
  65. package/src/ui/docs/content/card.md +41 -15
  66. package/src/ui/docs/content/carousel.md +37 -6
  67. package/src/ui/docs/content/chat.md +118 -37
  68. package/src/ui/docs/content/checkbox.md +73 -7
  69. package/src/ui/docs/content/cli.md +94 -55
  70. package/src/ui/docs/content/collapsible.md +43 -19
  71. package/src/ui/docs/content/command.md +30 -8
  72. package/src/ui/docs/content/composer.md +23 -8
  73. package/src/ui/docs/content/content.md +31 -7
  74. package/src/ui/docs/content/copyable.md +39 -8
  75. package/src/ui/docs/content/customization.md +55 -46
  76. package/src/ui/docs/content/cycle.md +33 -18
  77. package/src/ui/docs/content/data-products.md +115 -0
  78. package/src/ui/docs/content/data-state.md +20 -6
  79. package/src/ui/docs/content/data.md +20 -19
  80. package/src/ui/docs/content/detail.md +12 -5
  81. package/src/ui/docs/content/dialog.md +107 -24
  82. package/src/ui/docs/content/dictionary-value.md +4 -4
  83. package/src/ui/docs/content/dock.md +54 -19
  84. package/src/ui/docs/content/dot.md +35 -5
  85. package/src/ui/docs/content/drawer.md +63 -19
  86. package/src/ui/docs/content/empty-value.md +20 -6
  87. package/src/ui/docs/content/empty.md +24 -3
  88. package/src/ui/docs/content/events.md +76 -16
  89. package/src/ui/docs/content/executions.md +9 -9
  90. package/src/ui/docs/content/field.md +21 -6
  91. package/src/ui/docs/content/getting-started.md +44 -34
  92. package/src/ui/docs/content/icon-picker.md +19 -13
  93. package/src/ui/docs/content/icon-tile.md +28 -5
  94. package/src/ui/docs/content/input-otp.md +58 -7
  95. package/src/ui/docs/content/input.md +22 -3
  96. package/src/ui/docs/content/item.md +53 -6
  97. package/src/ui/docs/content/kbd.md +5 -1
  98. package/src/ui/docs/content/label.md +13 -4
  99. package/src/ui/docs/content/log.md +5 -5
  100. package/src/ui/docs/content/markdown.md +5 -4
  101. package/src/ui/docs/content/mcp.md +49 -23
  102. package/src/ui/docs/content/menu.md +95 -10
  103. package/src/ui/docs/content/metric-card.md +12 -4
  104. package/src/ui/docs/content/observability.md +10 -9
  105. package/src/ui/docs/content/page.md +118 -12
  106. package/src/ui/docs/content/pagination.md +13 -5
  107. package/src/ui/docs/content/pane.md +55 -5
  108. package/src/ui/docs/content/popover.md +42 -5
  109. package/src/ui/docs/content/presentation.md +91 -47
  110. package/src/ui/docs/content/progress.md +27 -14
  111. package/src/ui/docs/content/queue.md +39 -21
  112. package/src/ui/docs/content/radio-group.md +26 -4
  113. package/src/ui/docs/content/router.md +29 -10
  114. package/src/ui/docs/content/runtime.md +90 -54
  115. package/src/ui/docs/content/scheduler.md +53 -21
  116. package/src/ui/docs/content/scroll-area.md +42 -35
  117. package/src/ui/docs/content/select.md +78 -33
  118. package/src/ui/docs/content/semantic-context.md +21 -2
  119. package/src/ui/docs/content/separator.md +15 -9
  120. package/src/ui/docs/content/sidebar.md +3 -2
  121. package/src/ui/docs/content/skeleton.md +6 -1
  122. package/src/ui/docs/content/slider.md +37 -9
  123. package/src/ui/docs/content/spinner.md +4 -3
  124. package/src/ui/docs/content/split.md +19 -0
  125. package/src/ui/docs/content/storage.md +8 -9
  126. package/src/ui/docs/content/switch.md +10 -7
  127. package/src/ui/docs/content/table.md +46 -8
  128. package/src/ui/docs/content/tabs.md +14 -4
  129. package/src/ui/docs/content/testing.md +76 -31
  130. package/src/ui/docs/content/textarea.md +5 -4
  131. package/src/ui/docs/content/toast.md +48 -7
  132. package/src/ui/docs/content/toggle.md +3 -3
  133. package/src/ui/docs/content/tokens.md +15 -26
  134. package/src/ui/docs/content/tooltip.md +18 -2
  135. package/src/ui/docs/content/truncate.md +36 -6
  136. package/src/ui/docs/content/ui.md +74 -22
  137. package/src/ui/docs/content/upgrading.md +45 -36
  138. package/src/ui/docs/plugin.ts +36 -2
  139. package/src/ui/docs/registry.tsx +6 -0
  140. package/src/ui/drivers/react.tsx +16 -8
  141. package/src/ui/meta.ts +31 -31
  142. package/src/vite/design.ts +16 -4
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Propagação da ocupação de altura (interno) — ADR 0024.
3
+ *
4
+ * `Page layout="fill"` limita a página à altura do shell; a `Presentation` em página herda
5
+ * esse limite pela `Page` que ela monta. Para uma região interna ocupar a folga e rolar
6
+ * sozinha, cada elemento entre a raiz limitada e essa região precisa repassar a altura como
7
+ * coluna flexível. Este módulo faz essa cadeia se formar sem classes no consumidor:
8
+ *
9
+ * - a raiz (`FillRoot`) publica o contexto;
10
+ * - os elos canônicos (`PageBody`, `Content`, `ContentBody`, o corpo da `Presentation`)
11
+ * leem o contexto com `useFillRegion` e o republicam para os filhos;
12
+ * - a folha (`ActionList`) usa `useFillLeaf`: assume `fill` por padrão quando o elemento
13
+ * pai é um elo e se registra na cadeia;
14
+ * - um elo só vira coluna flexível quando uma folha se registrou abaixo dele. Irmãos sem
15
+ * folha mantêm a altura do próprio conteúdo, e uma página `fill` sem lista não muda;
16
+ * - superfícies com anatomia própria (Card, Dialog, Drawer, Pane) cortam a cadeia com
17
+ * `FillBoundary`: a altura delas é decisão delas, inclusive quando um portal as renderiza
18
+ * dentro de uma página `fill`.
19
+ *
20
+ * O registro acontece em `useLayoutEffect`, então as classes chegam antes da primeira pintura.
21
+ */
22
+
23
+ import {
24
+ createContext,
25
+ createElement,
26
+ useContext,
27
+ useLayoutEffect,
28
+ useMemo,
29
+ useRef,
30
+ useState,
31
+ type ReactElement,
32
+ type ReactNode,
33
+ type RefObject,
34
+ } from "react";
35
+
36
+ export type FillLayout = "flow" | "fill";
37
+
38
+ interface FillChain {
39
+ /** Anuncia uma folha abaixo deste elo; devolve a retirada do anúncio. */
40
+ register(): () => void;
41
+ }
42
+
43
+ const FillChainContext = createContext<FillChain | null>(null);
44
+
45
+ /** A raiz já tem altura limitada: não há o que propagar acima dela. */
46
+ const rootChain: FillChain = { register: () => () => {} };
47
+
48
+ /** Classes de um elo que repassa a altura à folha registrada abaixo dele. */
49
+ export const fillRegionClassName = "flex min-h-0 flex-1 flex-col";
50
+
51
+ /**
52
+ * Atributo que marca um elo da cadeia no DOM: `"true"` quando repassa a altura, `"false"`
53
+ * quando está na cadeia sem folha abaixo. A folha só assume `fill` implícito quando o
54
+ * elemento pai o carrega — um contêiner próprio no meio não repassa a altura.
55
+ */
56
+ export const fillRegionAttribute = "data-fill";
57
+
58
+ /** Publica a cadeia quando `active`; caso contrário a interrompe. */
59
+ export function FillRoot({
60
+ active,
61
+ children,
62
+ }: {
63
+ active: boolean;
64
+ children?: ReactNode;
65
+ }): ReactElement {
66
+ return createElement(
67
+ FillChainContext.Provider,
68
+ { value: active ? rootChain : null },
69
+ children,
70
+ );
71
+ }
72
+
73
+ /** Interrompe a cadeia: o conteúdo abaixo decide a própria altura. */
74
+ export function FillBoundary({
75
+ children,
76
+ }: {
77
+ children?: ReactNode;
78
+ }): ReactElement {
79
+ return createElement(FillChainContext.Provider, { value: null }, children);
80
+ }
81
+
82
+ export interface FillRegion {
83
+ /** O elo está em uma cadeia `fill`, com ou sem folha abaixo. */
84
+ inChain: boolean;
85
+ /** Uma folha se registrou abaixo: o elo deve repassar a altura. */
86
+ filling: boolean;
87
+ /** Atributos do elemento do elo. */
88
+ attributes: { "data-fill"?: "true" | "false" };
89
+ /** Republica a cadeia para os filhos do elo. */
90
+ provide(children: ReactNode): ReactElement;
91
+ }
92
+
93
+ /** Elo da cadeia: lê o contexto, conta as folhas abaixo e o republica. */
94
+ export function useFillRegion(): FillRegion {
95
+ const parent = useContext(FillChainContext);
96
+ const [leaves, setLeaves] = useState(0);
97
+ const chain = useMemo<FillChain | null>(
98
+ () =>
99
+ parent === null
100
+ ? null
101
+ : {
102
+ register() {
103
+ setLeaves((count) => count + 1);
104
+ const releaseParent = parent.register();
105
+ let registered = true;
106
+ return () => {
107
+ if (!registered) return;
108
+ registered = false;
109
+ setLeaves((count) => Math.max(0, count - 1));
110
+ releaseParent();
111
+ };
112
+ },
113
+ },
114
+ [parent],
115
+ );
116
+ const inChain = parent !== null;
117
+ const filling = inChain && leaves > 0;
118
+ return {
119
+ inChain,
120
+ filling,
121
+ attributes: inChain ? { "data-fill": filling ? "true" : "false" } : {},
122
+ provide: (children) =>
123
+ createElement(FillChainContext.Provider, { value: chain }, children),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Folha da cadeia. Sem `layout` explícito, assume `fill` quando o elemento pai é um elo;
129
+ * com `layout="fill"` explícito, registra-se mesmo sob um contêiner próprio, que passa a
130
+ * ser responsabilidade de quem o escreveu. `layout="flow"` nunca se registra.
131
+ */
132
+ export function useFillLeaf<TElement extends HTMLElement>(
133
+ layout: FillLayout | undefined,
134
+ ): { layout: FillLayout; ref: RefObject<TElement | null> } {
135
+ const chain = useContext(FillChainContext);
136
+ const ref = useRef<TElement | null>(null);
137
+ const [linked, setLinked] = useState(false);
138
+ useLayoutEffect(() => {
139
+ setLinked(
140
+ chain !== null &&
141
+ ref.current?.parentElement?.hasAttribute(fillRegionAttribute) === true,
142
+ );
143
+ }, [chain]);
144
+ const resolved = layout ?? (linked ? "fill" : "flow");
145
+ useLayoutEffect(() => {
146
+ if (chain === null || resolved !== "fill") return undefined;
147
+ return chain.register();
148
+ }, [chain, resolved]);
149
+ return { layout: resolved, ref };
150
+ }
@@ -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;
@@ -1666,7 +1672,7 @@ function RowActions({ children }: { children: ReactNode }) {
1666
1672
  export function ActionList<TInput, TItem>({
1667
1673
  action,
1668
1674
  input,
1669
- layout = "flow",
1675
+ layout: layoutProp,
1670
1676
  toolbarActions,
1671
1677
  columns,
1672
1678
  cells,
@@ -1693,6 +1699,7 @@ export function ActionList<TInput, TItem>({
1693
1699
  onStateChange,
1694
1700
  }: ActionListProps<TInput, TItem>) {
1695
1701
  const confirmBodyId = useId();
1702
+ const { layout, ref: rootRef } = useFillLeaf<HTMLDivElement>(layoutProp);
1696
1703
  // Query-backed (useListAction): busca declarativa por [name, input] E refetch
1697
1704
  // automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
1698
1705
  // O input efetivo é computado abaixo; o hook re-busca quando ele muda.
@@ -2367,6 +2374,7 @@ export function ActionList<TInput, TItem>({
2367
2374
  return (
2368
2375
  <TooltipProvider>
2369
2376
  <div
2377
+ ref={rootRef}
2370
2378
  data-action={action.name}
2371
2379
  data-layout={layout}
2372
2380
  className={cn(
@@ -2375,84 +2383,87 @@ export function ActionList<TInput, TItem>({
2375
2383
  : "space-y-3",
2376
2384
  )}
2377
2385
  >
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}
2386
+ {/* A lista é a folha da cadeia de altura: o que ela hospeda decide a própria. */}
2387
+ <FillBoundary>
2388
+ {toolbar}
2389
+ {beforeContent}
2390
+ {selectionBar}
2391
+ {/* Tabela e rodapé formam um bloco só: o `gap` da pilha separa a barra de
2392
+ ferramentas do conteúdo, nunca o conteúdo do seu próprio rodapé. Duas
2393
+ linhas: a dos resultados absorve a altura que sobra, a do rodapé se mede
2394
+ pelo conteúdo. Em `flow` a primeira linha também é `1fr`, mas o bloco não
2395
+ tem altura própria e ela vira a altura do conteúdo. */}
2396
+ <div className={cn("grid min-h-0 grid-rows-[1fr_auto]", layout === "fill" && "flex-1")}>
2397
+ {/* A rolagem mora aqui, nos dois eixos: é este elemento que o `sticky` do
2398
+ cabeçalho enxerga. Um contêiner de rolagem entre ele e a tabela roubaria
2399
+ essa referência e o cabeçalho voltaria a subir junto com as linhas.
2400
+ Refetch com a lista já na tela: esmaece e trava interação até chegar. */}
2401
+ <div
2402
+ aria-busy={busy}
2403
+ className={cn(
2404
+ "min-h-0",
2405
+ layout === "fill" && "overflow-auto",
2406
+ busy && items.length > 0 && "pointer-events-none opacity-60",
2407
+ )}
2408
+ >
2409
+ {body}
2410
+ </div>
2411
+ {footer}
2400
2412
  </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
- }
2413
+ {confirming !== null && (
2414
+ <Dialog
2415
+ mode="alert"
2416
+ open
2417
+ onOpenChange={(o) => {
2418
+ if (!o) setConfirming(null);
2419
+ }}
2417
2420
  >
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
- )}
2421
+ <DialogContent
2422
+ aria-describedby={
2423
+ confirming.confirm?.message === undefined
2424
+ ? undefined
2425
+ : confirmBodyId
2426
+ }
2427
+ >
2428
+ <DialogHeader>
2429
+ <DialogTitle>
2430
+ {confirming.confirm?.title ?? confirming.label}
2431
+ </DialogTitle>
2432
+ </DialogHeader>
2433
+ {confirming.confirm?.message !== undefined && (
2434
+ <DialogBody
2435
+ id={confirmBodyId}
2436
+ className="text-sm text-muted-foreground"
2437
+ >
2438
+ {confirming.confirm.message}
2439
+ </DialogBody>
2440
+ )}
2441
+ <DialogFooter>
2442
+ <ButtonGroup mode="spaced" distribution="equal">
2443
+ <DialogClose initialFocus asChild>
2444
+ <Button variant="outline">Cancelar</Button>
2445
+ </DialogClose>
2446
+ <DialogClose asChild>
2447
+ <Button
2448
+ context={
2449
+ confirming.destructive === true ? "danger" : "primary"
2450
+ }
2451
+ variant="solid"
2452
+ onClick={() => {
2453
+ const a = confirming;
2454
+ setConfirming(null);
2455
+ void runBatch(a);
2456
+ }}
2457
+ >
2458
+ {confirming.label}
2459
+ </Button>
2460
+ </DialogClose>
2461
+ </ButtonGroup>
2462
+ </DialogFooter>
2463
+ </DialogContent>
2464
+ </Dialog>
2465
+ )}
2466
+ </FillBoundary>
2456
2467
  </div>
2457
2468
  </TooltipProvider>
2458
2469
  );
@@ -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