@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
@@ -2,6 +2,76 @@ import * as React from 'react'
2
2
  import { CheckIcon, CopyIcon } from 'lucide-react'
3
3
  import { cn } from '../../lib/cn.ts'
4
4
 
5
+ /**
6
+ * A região viva que anuncia "Copiado", uma por documento.
7
+ *
8
+ * Uma tela com dezenas de Copyable (uma tabela de IDs, por exemplo) montaria dezenas de regiões
9
+ * `role="status"` vazias no fim do `body`. Todas as instâncias compartilham a mesma: a primeira a
10
+ * montar cria o nó, a última a desmontar o remove. Quem escreve a confirmação vira dono do texto;
11
+ * só o dono o apaga, para que a expiração de uma cópia antiga não silencie a mais recente.
12
+ * Se algo fora do React tirar o nó do documento (um `body.innerHTML` de script legado, por
13
+ * exemplo), a próxima montagem ou cópia o recoloca, para que o anúncio não suma em silêncio.
14
+ */
15
+ interface SharedStatus {
16
+ node: HTMLSpanElement
17
+ users: number
18
+ owner: symbol | undefined
19
+ }
20
+
21
+ const statusByDocument = new WeakMap<Document, SharedStatus>()
22
+
23
+ function acquireStatus(doc: Document): SharedStatus {
24
+ let shared = statusByDocument.get(doc)
25
+ if (shared === undefined) {
26
+ const node = doc.createElement('span')
27
+ node.setAttribute('role', 'status')
28
+ node.setAttribute('aria-live', 'polite')
29
+ node.setAttribute('data-slot', 'copyable-status')
30
+ node.className = 'sr-only'
31
+ doc.body.appendChild(node)
32
+ shared = { node, users: 0, owner: undefined }
33
+ statusByDocument.set(doc, shared)
34
+ }
35
+ ensureConnected(shared.node, doc)
36
+ shared.users += 1
37
+ return shared
38
+ }
39
+
40
+ // Devolve `true` quando precisou recolocar a região no `body`.
41
+ function ensureConnected(node: HTMLSpanElement, doc: Document): boolean {
42
+ if (node.isConnected) return false
43
+ doc.body.appendChild(node)
44
+ return true
45
+ }
46
+
47
+ function releaseStatus(doc: Document, shared: SharedStatus, id: symbol): void {
48
+ if (shared.owner === id) clearStatus(shared, id)
49
+ shared.users -= 1
50
+ if (shared.users > 0) return
51
+ shared.node.remove()
52
+ if (statusByDocument.get(doc) === shared) statusByDocument.delete(doc)
53
+ }
54
+
55
+ function announceCopied(shared: SharedStatus, id: symbol): void {
56
+ shared.owner = id
57
+ const doc = shared.node.ownerDocument
58
+ // Um nó de texto novo, em vez de manter o anterior: uma segunda cópia enquanto "Copiado" ainda
59
+ // está na região (da mesma instância ou de outra) continua sendo uma mudança a anunciar.
60
+ const write = (): void => {
61
+ if (shared.owner === id) shared.node.replaceChildren(doc.createTextNode('Copiado'))
62
+ }
63
+ // Uma região recém-inserida já com texto costuma não ser lida: depois de recolocá-la, o texto
64
+ // entra no tick seguinte, quando o leitor de tela já a conhece.
65
+ if (ensureConnected(shared.node, doc)) setTimeout(write, 0)
66
+ else write()
67
+ }
68
+
69
+ function clearStatus(shared: SharedStatus, id: symbol): void {
70
+ if (shared.owner !== id) return
71
+ shared.owner = undefined
72
+ shared.node.replaceChildren()
73
+ }
74
+
5
75
  export interface CopyableProps extends Omit<React.ComponentProps<'button'>, 'value'> {
6
76
  /** O texto que vai pro clipboard no clique. */
7
77
  value: string
@@ -13,7 +83,16 @@ export interface CopyableProps extends Omit<React.ComponentProps<'button'>, 'val
13
83
  * Clicar-pra-copiar com feedback: copia `value` pro clipboard e troca o ícone por um
14
84
  * check por ~1.5s. Sem filhos é um botão-ícone (bom pra toolbar/célula); com filhos, o
15
85
  * rótulo visível + o ícone à direita. Pra IDs, tokens, slugs, URLs. No-op silencioso se
16
- * o clipboard não estiver disponível (contexto inseguro/SSR).
86
+ * o clipboard não estiver disponível (contexto inseguro/SSR) ou recusar a escrita (permissão
87
+ * negada, documento sem foco): o botão só confirma "copiado" depois que a escrita conclui.
88
+ *
89
+ * Nome acessível: com filhos, é o próprio texto visível (quem fala o que vê aciona o botão);
90
+ * sem filhos, "Copiar" e, na confirmação, "Copiado". Um `aria-label` do consumidor vence os
91
+ * dois. A confirmação é anunciada por uma região viva no fim de `document.body`, compartilhada
92
+ * por todas as instâncias do documento: dentro do botão, o texto entraria no nome calculado pelo
93
+ * conteúdo (e o botão não expõe descendentes como regiões); como irmã, mudaria a estrutura do
94
+ * contêiner e seletores como `:first-child` de um ButtonGroup. A região só é criada no cliente,
95
+ * depois da hidratação, e existe antes da primeira cópia para que a mudança seja anunciada.
17
96
  */
18
97
  export function Copyable({
19
98
  value,
@@ -25,26 +104,57 @@ export function Copyable({
25
104
  }: CopyableProps): React.ReactElement {
26
105
  const [copied, setCopied] = React.useState(false)
27
106
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
28
- React.useEffect(() => () => clearTimeout(timer.current), [])
107
+ const [id] = React.useState(() => Symbol('copyable'))
108
+ const status = React.useRef<SharedStatus | undefined>(undefined)
109
+ React.useEffect(() => {
110
+ const doc = document
111
+ const shared = acquireStatus(doc)
112
+ status.current = shared
113
+ return () => {
114
+ clearTimeout(timer.current)
115
+ status.current = undefined
116
+ releaseStatus(doc, shared, id)
117
+ }
118
+ }, [id])
119
+ const iconOnly = children === undefined || children === null || children === false || children === ''
29
120
  return (
30
121
  <button
31
122
  type="button"
32
123
  data-slot="copyable"
33
124
  data-copied={copied || undefined}
34
- aria-label={copied ? 'Copiado' : 'Copiar'}
35
- onClick={(e) => {
125
+ aria-label={iconOnly ? (copied ? 'Copiado' : 'Copiar') : undefined}
126
+ onClick={async (e) => {
36
127
  onClick?.(e)
37
- void navigator.clipboard?.writeText(value).then(() => {
38
- setCopied(true)
39
- clearTimeout(timer.current)
40
- timer.current = setTimeout(() => setCopied(false), feedbackMs)
41
- })
128
+ const clipboard = globalThis.navigator?.clipboard
129
+ if (clipboard === undefined) return
130
+ clearTimeout(timer.current)
131
+ try {
132
+ await clipboard.writeText(value)
133
+ } catch {
134
+ // Recusado: some com um "copiado" anterior para não afirmar o que não aconteceu.
135
+ setCopied(false)
136
+ if (status.current !== undefined) clearStatus(status.current, id)
137
+ return
138
+ }
139
+ const shared = status.current
140
+ // Desmontou enquanto a escrita corria: não há botão nem região a atualizar.
141
+ if (shared === undefined) return
142
+ setCopied(true)
143
+ announceCopied(shared, id)
144
+ timer.current = setTimeout(() => {
145
+ setCopied(false)
146
+ clearStatus(shared, id)
147
+ }, feedbackMs)
42
148
  }}
43
149
  className={cn('inline-flex items-center gap-1.5', className)}
44
150
  {...props}
45
151
  >
46
152
  {children}
47
- {copied ? <CheckIcon className="size-3.5 text-context-success-emphasis" /> : <CopyIcon className="size-3.5" />}
153
+ {copied ? (
154
+ <CheckIcon aria-hidden="true" className="size-3.5 text-context-success-emphasis" />
155
+ ) : (
156
+ <CopyIcon aria-hidden="true" className="size-3.5" />
157
+ )}
48
158
  </button>
49
159
  )
50
160
  }
@@ -54,9 +54,7 @@ function DetailGroup({
54
54
  columnClasses[columns],
55
55
  variant === 'plain' && !dividers && 'gap-x-6 gap-y-4',
56
56
  dividers && 'gap-px overflow-hidden',
57
- (variant === 'framed' || dividers) &&
58
- !splitHorizontalField &&
59
- '[&>[data-slot=detail-field]]:p-3',
57
+ (variant === 'framed' || dividers) && !splitHorizontalField && '[&>[data-slot=detail-field]]:p-3',
60
58
  variant === 'framed' &&
61
59
  'overflow-hidden rounded-lg border border-border bg-transparent text-foreground',
62
60
  // Default da coluna de rótulos como CLASSE, antes do `className`: assim uma classe do
@@ -74,10 +72,7 @@ function DetailGroup({
74
72
  )
75
73
  }
76
74
 
77
- export interface DetailFieldProps extends Omit<
78
- React.ComponentProps<'div'>,
79
- 'children'
80
- > {
75
+ export interface DetailFieldProps extends Omit<React.ComponentProps<'div'>, 'children'> {
81
76
  label: React.ReactNode
82
77
  /** `null`, `undefined`, string vazia ou só com espaços renderizam a ausência
83
78
  * como travessão; “Não informado” ou `empty` preservam o significado para
@@ -99,11 +94,13 @@ function DetailField({
99
94
  ...props
100
95
  }: DetailFieldProps): React.ReactElement {
101
96
  const { splitHorizontalField } = React.useContext(DetailGroupLayoutContext)
102
- const content = !isAbsentValue(value)
103
- ? value
104
- : empty === undefined || typeof empty === 'string'
105
- ? <EmptyValue compact {...(empty !== undefined ? { label: empty } : {})} />
106
- : empty
97
+ const content = !isAbsentValue(value) ? (
98
+ value
99
+ ) : empty === undefined || typeof empty === 'string' ? (
100
+ <EmptyValue compact {...(empty !== undefined ? { label: empty } : {})} />
101
+ ) : (
102
+ empty
103
+ )
107
104
  return (
108
105
  <div
109
106
  data-slot="detail-field"
@@ -131,7 +128,7 @@ function DetailField({
131
128
  <dt
132
129
  data-slot="detail-field-label"
133
130
  className={cn(
134
- 'flex items-center gap-2.5 text-sm leading-snug font-medium text-muted-foreground',
131
+ 'flex items-center gap-1.5 text-sm leading-snug font-medium text-muted-foreground',
135
132
  splitHorizontalField && 'bg-muted/30 p-3 dark:bg-input/30',
136
133
  )}
137
134
  >
@@ -2,6 +2,22 @@ import * as React from "react";
2
2
  import * as DialogPrimitive from "@radix-ui/react-dialog";
3
3
  import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
4
4
  import { cn } from "../../lib/cn.ts";
5
+ import {
6
+ SurfaceLayoutProvider,
7
+ surfaceActionsClasses,
8
+ surfaceHeaderRowClasses,
9
+ surfaceRegionClasses,
10
+ surfaceTitleClasses,
11
+ useSurfaceLayout,
12
+ type SurfaceLayout,
13
+ } from "../patterns/surface-anatomy.ts";
14
+ import { FillBoundary } from "../patterns/fill-region.ts";
15
+ import { headingTags, type HeadingLevel } from "../patterns/heading-level.ts";
16
+ import { SurfaceHeader } from "../patterns/surface-header.tsx";
17
+ import {
18
+ SurfaceMedia,
19
+ type SurfaceMediaProps,
20
+ } from "../patterns/surface-media.tsx";
5
21
  import {
6
22
  CloseButton,
7
23
  type CloseButtonSize,
@@ -30,6 +46,24 @@ const DialogContentContext = React.createContext<DialogContentContextValue>({
30
46
  closeSize: "sm",
31
47
  });
32
48
 
49
+ /**
50
+ * Compõe o handler de dispensa do consumidor com o bloqueio de `closeDisabled`: o handler dele
51
+ * roda primeiro e, com a superfície bloqueada, o evento é cancelado para que Esc, clique fora ou
52
+ * foco fora não a fechem. Sem bloqueio, devolve o handler original.
53
+ */
54
+ export function preventDismissWhen<
55
+ E extends { defaultPrevented: boolean; preventDefault(): void },
56
+ >(
57
+ blocked: boolean,
58
+ handler: ((event: E) => void) | undefined,
59
+ ): ((event: E) => void) | undefined {
60
+ if (!blocked) return handler;
61
+ return (event) => {
62
+ handler?.(event);
63
+ if (!event.defaultPrevented) event.preventDefault();
64
+ };
65
+ }
66
+
33
67
  function useDialogContext(): DialogContextValue {
34
68
  return React.useContext(DialogContext);
35
69
  }
@@ -175,15 +209,23 @@ export function DialogContent({
175
209
  showCloseButton,
176
210
  closeDisabled = false,
177
211
  closeSize = "sm",
212
+ layout = "sectioned",
178
213
  "aria-describedby": ariaDescribedBy,
214
+ onEscapeKeyDown,
215
+ onInteractOutside,
179
216
  ...props
180
217
  }: React.ComponentProps<typeof DialogPrimitive.Content> & {
181
218
  /** Mostra a ação “Fechar” no modo padrão. O modo `alert` exige uma resposta identificável. */
182
219
  showCloseButton?: boolean;
183
- /** Mantém a ação visível, mas impede fechar enquanto a superfície está bloqueada. */
220
+ /**
221
+ * Bloqueia o fechamento enquanto a superfície está ocupada: desabilita a ação “Fechar”, que
222
+ * continua visível, e cancela Esc e clique ou foco fora depois dos handlers do consumidor.
223
+ */
184
224
  closeDisabled?: boolean;
185
225
  /** Tamanho da ação circular de fechar no cabeçalho. */
186
226
  closeSize?: CloseButtonSize;
227
+ /** Anatomia da superfície: `sectioned` (default) ou `continuous` — ADR 0023. */
228
+ layout?: SurfaceLayout;
187
229
  }) {
188
230
  const { mode } = useDialogContext();
189
231
  const showClose = showCloseButton ?? mode !== "alert";
@@ -199,17 +241,22 @@ export function DialogContent({
199
241
  data-mode="alert"
200
242
  className={cn(contentClasses, "grid max-w-xs gap-4 p-6", className)}
201
243
  aria-describedby={ariaDescribedBy}
244
+ onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
202
245
  {...props}
203
246
  >
204
- <DialogContentContext.Provider
205
- value={{
206
- showCloseButton: false,
207
- closeDisabled: true,
208
- closeSize,
209
- }}
210
- >
211
- {children}
212
- </DialogContentContext.Provider>
247
+ {/* Sem anatomia seccionada (exceção da ADR 0023), mas a altura continua sendo
248
+ decisão do diálogo: a cadeia `fill` da página não atravessa o portal. */}
249
+ <FillBoundary>
250
+ <DialogContentContext.Provider
251
+ value={{
252
+ showCloseButton: false,
253
+ closeDisabled: true,
254
+ closeSize,
255
+ }}
256
+ >
257
+ {children}
258
+ </DialogContentContext.Provider>
259
+ </FillBoundary>
213
260
  </AlertDialogPrimitive.Content>
214
261
  </DialogPortal>
215
262
  );
@@ -221,19 +268,24 @@ export function DialogContent({
221
268
  <DialogPrimitive.Content
222
269
  data-slot="dialog-content"
223
270
  data-mode="default"
271
+ data-layout={layout}
224
272
  className={cn(
225
273
  contentClasses,
226
274
  "flex max-h-[85vh] max-w-lg flex-col",
227
275
  className,
228
276
  )}
229
277
  aria-describedby={ariaDescribedBy}
278
+ onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
279
+ onInteractOutside={preventDismissWhen(closeDisabled, onInteractOutside)}
230
280
  {...props}
231
281
  >
232
- <DialogContentContext.Provider
233
- value={{ showCloseButton: showClose, closeDisabled, closeSize }}
234
- >
235
- {children}
236
- </DialogContentContext.Provider>
282
+ <SurfaceLayoutProvider value={layout}>
283
+ <DialogContentContext.Provider
284
+ value={{ showCloseButton: showClose, closeDisabled, closeSize }}
285
+ >
286
+ {children}
287
+ </DialogContentContext.Provider>
288
+ </SurfaceLayoutProvider>
237
289
  </DialogPrimitive.Content>
238
290
  </DialogPortal>
239
291
  );
@@ -245,40 +297,99 @@ export function DialogHeader({
245
297
  ...props
246
298
  }: React.ComponentProps<"div">) {
247
299
  const { mode } = useDialogContext();
300
+ const layout = useSurfaceLayout("dialog");
248
301
  const { showCloseButton, closeDisabled, closeSize } =
249
302
  React.useContext(DialogContentContext);
303
+
304
+ // O modo de alerta é a exceção declarada da ADR 0023: a resposta obrigatória empilha
305
+ // mídia, título e corpo centralizados, e o padding mora na raiz do conteúdo.
306
+ if (mode === "alert") {
307
+ return (
308
+ <div
309
+ data-slot="dialog-header"
310
+ className={cn(
311
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=dialog-media]:grid-rows-[auto_auto_1fr]",
312
+ className,
313
+ )}
314
+ {...props}
315
+ >
316
+ {children}
317
+ </div>
318
+ );
319
+ }
320
+
250
321
  return (
251
- <div
252
- data-slot="dialog-header"
322
+ <SurfaceHeader
323
+ name="DialogHeader"
324
+ slot="dialog"
325
+ slots={{
326
+ leading: DialogNavigation,
327
+ media: DialogMedia,
328
+ title: DialogTitle,
329
+ actions: DialogActions,
330
+ }}
331
+ leadingPlacement="inline"
332
+ titleRequired={false}
333
+ rowClassName={surfaceHeaderRowClasses("dialog")}
253
334
  className={cn(
254
- mode === "alert"
255
- ? "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=dialog-media]:grid-rows-[auto_auto_1fr]"
256
- : "flex shrink-0 items-center gap-4 border-b p-4 text-left [&:has(+[data-slot=dialog-footer])]:border-b-0",
335
+ "text-left",
336
+ surfaceRegionClasses("dialog", layout, "header"),
257
337
  className,
258
338
  )}
339
+ trailing={
340
+ showCloseButton ? (
341
+ <DialogCloseButton disabled={closeDisabled} size={closeSize} />
342
+ ) : undefined
343
+ }
259
344
  {...props}
260
345
  >
261
346
  {children}
262
- {mode !== "alert" && showCloseButton && (
263
- <DialogCloseButton disabled={closeDisabled} size={closeSize} />
264
- )}
265
- </div>
347
+ </SurfaceHeader>
266
348
  );
267
349
  }
268
350
 
269
- export function DialogMedia({
351
+ /** Região introdutória do header — o voltar de um fluxo modal encadeado. */
352
+ export function DialogNavigation({ className, ...props }: React.ComponentProps<"div">) {
353
+ return (
354
+ <div
355
+ data-slot="dialog-navigation-content"
356
+ className={cn("flex min-w-0 shrink-0 items-center", className)}
357
+ {...props}
358
+ />
359
+ );
360
+ }
361
+
362
+ /** Ações da tela no header, antes do botão de fechar. */
363
+ export function DialogActions({
270
364
  className,
271
365
  ...props
272
366
  }: React.ComponentProps<"div">) {
273
367
  return (
274
368
  <div
275
- data-slot="dialog-media"
369
+ data-slot="dialog-actions"
370
+ className={cn(surfaceActionsClasses, className)}
371
+ {...props}
372
+ />
373
+ );
374
+ }
375
+
376
+ export function DialogMedia({ className, ...props }: SurfaceMediaProps) {
377
+ const { mode } = useDialogContext();
378
+ // No alerta a mídia é o elemento de reconhecimento da resposta e ocupa a moldura
379
+ // maior — exceção declarada da ADR 0023, coberta por teste. Fora dele, a mesma
380
+ // moldura das demais famílias.
381
+ return mode === "alert" ? (
382
+ <SurfaceMedia
383
+ slot="dialog-media"
384
+ size="lg"
276
385
  className={cn(
277
- 'mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted *:[svg:not([class*="size-"])]:size-8',
386
+ 'mb-2 size-16 *:[svg:not([class*="size-"])]:size-8',
278
387
  className,
279
388
  )}
280
389
  {...props}
281
390
  />
391
+ ) : (
392
+ <SurfaceMedia slot="dialog-media" {...props} className={className} />
282
393
  );
283
394
  }
284
395
 
@@ -288,11 +399,17 @@ export function DialogBody({
288
399
  ...props
289
400
  }: React.ComponentProps<"div">) {
290
401
  const { mode } = useDialogContext();
402
+ const layout = useSurfaceLayout("dialog");
291
403
  return (
292
404
  <div
293
405
  data-slot="dialog-body"
294
406
  className={cn(
295
- mode === "alert" ? "min-w-0" : "min-h-0 flex-1 overflow-y-auto p-4",
407
+ mode === "alert"
408
+ ? "min-w-0"
409
+ : cn(
410
+ "min-h-0 flex-1 overflow-y-auto",
411
+ surfaceRegionClasses("dialog", layout, "body"),
412
+ ),
296
413
  className,
297
414
  )}
298
415
  style={
@@ -310,13 +427,17 @@ export function DialogFooter({
310
427
  ...props
311
428
  }: React.ComponentProps<"div">) {
312
429
  const { mode } = useDialogContext();
430
+ const layout = useSurfaceLayout("dialog");
313
431
  return (
314
432
  <div
315
433
  data-slot="dialog-footer"
316
434
  className={cn(
317
435
  mode === "alert"
318
436
  ? "w-full"
319
- : "flex shrink-0 flex-row justify-end gap-2 border-t bg-muted/20 p-4 dark:bg-input/20",
437
+ : cn(
438
+ "flex shrink-0 flex-row justify-end gap-2",
439
+ surfaceRegionClasses("dialog", layout, "footer"),
440
+ ),
320
441
  className,
321
442
  )}
322
443
  {...props}
@@ -326,23 +447,36 @@ export function DialogFooter({
326
447
 
327
448
  export function DialogTitle({
328
449
  className,
450
+ level,
451
+ children,
329
452
  ...props
330
- }: React.ComponentProps<typeof DialogPrimitive.Title>) {
453
+ }: React.ComponentProps<typeof DialogPrimitive.Title> & {
454
+ /** Hierarquia do documento. Sem ele vale o `h2` que a primitiva emite. */
455
+ level?: HeadingLevel;
456
+ }) {
331
457
  const { mode } = useDialogContext();
332
- return mode === "alert" ? (
333
- <AlertDialogPrimitive.Title
334
- data-slot="dialog-title"
335
- className={cn("text-lg font-semibold", className)}
336
- {...props}
337
- />
338
- ) : (
339
- <DialogPrimitive.Title
340
- data-slot="dialog-title"
341
- className={cn(
342
- "min-w-0 flex-1 text-lg font-semibold leading-none",
343
- className,
344
- )}
345
- {...props}
346
- />
458
+ const layout = useSurfaceLayout("dialog");
459
+ const Title = mode === "alert" ? AlertDialogPrimitive.Title : DialogPrimitive.Title;
460
+ const classes = cn(
461
+ mode === "alert" ? undefined : "min-w-0 truncate",
462
+ surfaceTitleClasses("dialog", layout),
463
+ className,
464
+ );
465
+ // O nível troca a TAG do mesmo nó, via `asChild` — nunca aninha um heading dentro
466
+ // de outro, e a escala continua vindo do layout (ADR 0023).
467
+ if (level === undefined) {
468
+ return (
469
+ <Title data-slot="dialog-title" className={classes} {...props}>
470
+ {children}
471
+ </Title>
472
+ );
473
+ }
474
+ const Heading = headingTags[level];
475
+ return (
476
+ <Title asChild {...props}>
477
+ <Heading data-slot="dialog-title" className={classes}>
478
+ {children}
479
+ </Heading>
480
+ </Title>
347
481
  );
348
482
  }