@softize/opus 25.7.0 → 26.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 (41) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/bin/lib/check.mjs +9 -32
  3. package/bin/lib/copy.mjs +7 -4
  4. package/docs/adr/0014-structural-headers-do-not-carry-description.md +1 -1
  5. package/docs/adr/0018-modal-header-only-names-the-surface.md +1 -1
  6. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +4 -0
  7. package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
  8. package/docs/adr/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
  9. package/docs/adr/README.md +2 -0
  10. package/package.json +1 -1
  11. package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
  13. package/src/ai/drivers/anthropic.ts +49 -9
  14. package/src/ai/index.ts +5 -2
  15. package/src/core/ai-message.ts +20 -0
  16. package/src/core/index.ts +5 -0
  17. package/src/core/types.ts +28 -4
  18. package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
  19. package/src/ui/components/patterns/form-dialog.tsx +18 -7
  20. package/src/ui/components/patterns/form.tsx +36 -8
  21. package/src/ui/components/patterns/page.tsx +22 -0
  22. package/src/ui/components/patterns/surface-anatomy.ts +1 -1
  23. package/src/ui/components/primitives/chat.tsx +179 -22
  24. package/src/ui/components/primitives/composer.tsx +397 -7
  25. package/src/ui/components/primitives/dialog.tsx +28 -1
  26. package/src/ui/components/primitives/drawer.tsx +30 -2
  27. package/src/ui/docs/content/action-form-card.md +1 -1
  28. package/src/ui/docs/content/action-form-dialog.md +8 -0
  29. package/src/ui/docs/content/action-form.md +18 -5
  30. package/src/ui/docs/content/action-list-dialog.md +8 -0
  31. package/src/ui/docs/content/ai.md +46 -0
  32. package/src/ui/docs/content/chat.md +97 -2
  33. package/src/ui/docs/content/composer.md +144 -1
  34. package/src/ui/docs/content/dialog.md +24 -2
  35. package/src/ui/docs/content/drawer.md +24 -2
  36. package/src/ui/docs/content/page.md +23 -1
  37. package/src/ui/docs/content/surface.md +8 -2
  38. package/src/ui/lib/modal-layers.ts +27 -3
  39. package/src/ui/lib/prepare-image.ts +386 -0
  40. package/src/ui/meta.ts +2 -2
  41. package/src/ui/react.tsx +21 -1
@@ -17,6 +17,7 @@ import { declaredJsonSchema } from '../../core/index.ts'
17
17
  import type {
18
18
  AiAdapter,
19
19
  AiCompleteOptions,
20
+ AiContentPart,
20
21
  AiMessage,
21
22
  AiRunOptions,
22
23
  AiRunResult,
@@ -101,6 +102,50 @@ function defaultToJsonSchema(schema: unknown): unknown {
101
102
  return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], { $refStrategy: 'none' })
102
103
  }
103
104
 
105
+ const IMAGE_MEDIA_TYPES: ReadonlySet<string> = new Set(['image/png', 'image/jpeg', 'image/webp'])
106
+
107
+ /** Converte o conteúdo neutro da mensagem (ADR 0058) para os blocos da API da Anthropic. Uma
108
+ * parte desconhecida, uma parte de texto vazia, uma lista vazia, uma imagem fora dos formatos
109
+ * aceitos ou uma imagem na fala do assistente lançam erro: nada é descartado em silêncio, e nada
110
+ * chega a ser enviado. */
111
+ function toAnthropicContent(message: AiMessage, index: number): unknown {
112
+ const { content } = message
113
+ if (typeof content === 'string') return content
114
+ // A API recusa lista vazia e bloco de texto sem texto; o erro aqui diz qual mensagem é.
115
+ if (content.length === 0) {
116
+ throw new Error(`ai/anthropic: mensagem ${index + 1} sem conteúdo (lista de partes vazia).`)
117
+ }
118
+ return content.map((part: AiContentPart, partIndex) => {
119
+ const where = `mensagem ${index + 1}, parte ${partIndex + 1}`
120
+ if (part.type === 'text') {
121
+ if (part.text.trim() === '') {
122
+ throw new Error(`ai/anthropic: parte de texto vazia (${where}).`)
123
+ }
124
+ return { type: 'text', text: part.text }
125
+ }
126
+ if (part.type === 'image') {
127
+ if (message.role !== 'user') {
128
+ throw new Error(`ai/anthropic: imagem só pode ir numa mensagem 'user' (${where}).`)
129
+ }
130
+ if (!IMAGE_MEDIA_TYPES.has(part.mediaType)) {
131
+ throw new Error(
132
+ `ai/anthropic: formato de imagem não aceito: "${String(part.mediaType)}" (${where}). ` +
133
+ 'Use image/png, image/jpeg ou image/webp.',
134
+ )
135
+ }
136
+ return { type: 'image', source: { type: 'base64', media_type: part.mediaType, data: part.data } }
137
+ }
138
+ const unknownType = (part as { type?: unknown }).type
139
+ throw new Error(`ai/anthropic: parte de conteúdo desconhecida: "${String(unknownType)}" (${where}).`)
140
+ })
141
+ }
142
+
143
+ /** O histórico de entrada no formato da API, convertido antes da primeira chamada. */
144
+ function toAnthropicMessages(input: string | AiMessage[]): Array<{ role: string; content: unknown }> {
145
+ if (typeof input === 'string') return [{ role: 'user', content: input }]
146
+ return input.map((m, i) => ({ role: m.role, content: toAnthropicContent(m, i) }))
147
+ }
148
+
104
149
  interface ContentBlock {
105
150
  type: string
106
151
  text?: string
@@ -183,6 +228,9 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
183
228
  },
184
229
 
185
230
  async run(input: string | AiMessage[], opts: AiRunOptions): Promise<AiRunResult> {
231
+ // Mensagens no formato Anthropic — convertidas antes de tudo (uma parte inválida falha sem
232
+ // chamar a API) e crescem com tool_use/tool_result a cada passo.
233
+ const messages = toAnthropicMessages(input)
186
234
  const c = await client()
187
235
  const tools = withAskTool(
188
236
  opts.tools.map((t) => ({
@@ -192,11 +240,6 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
192
240
  })),
193
241
  opts,
194
242
  )
195
- // Mensagens no formato Anthropic — crescem com tool_use/tool_result a cada passo.
196
- const messages: Array<{ role: string; content: unknown }> =
197
- typeof input === 'string'
198
- ? [{ role: 'user', content: input }]
199
- : input.map((m) => ({ role: m.role, content: m.content }))
200
243
 
201
244
  const maxSteps = opts.maxSteps ?? 8
202
245
  const calls: Array<{ name: string; input: unknown }> = []
@@ -242,6 +285,7 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
242
285
  },
243
286
 
244
287
  async *runStream(input: string | AiMessage[], opts: AiRunOptions): AsyncIterable<ChatEvent> {
288
+ const messages = toAnthropicMessages(input)
245
289
  const c = await client()
246
290
  const tools = withAskTool(
247
291
  opts.tools.map((t) => ({
@@ -251,10 +295,6 @@ export function anthropicAi(options: AnthropicAiOptions = {}): AiAdapter {
251
295
  })),
252
296
  opts,
253
297
  )
254
- const messages: Array<{ role: string; content: unknown }> =
255
- typeof input === 'string'
256
- ? [{ role: 'user', content: input }]
257
- : input.map((m) => ({ role: m.role, content: m.content }))
258
298
 
259
299
  const maxSteps = opts.maxSteps ?? 8
260
300
 
package/src/ai/index.ts CHANGED
@@ -5,11 +5,14 @@
5
5
  * (texto → objeto VALIDADO por um `Schema` do protocolo — o mesmo tipo de schema
6
6
  * das actions vira o contrato da resposta do modelo). Driver em `ai/anthropic`.
7
7
  * A superfície é a mínima de backoffice e cresce por reincidência — não por
8
- * especulação (streaming, chat multi-turn e tools livres entram quando um caso
9
- * real cobrar).
8
+ * especulação. Streaming, chat multi-turn e mensagem com imagem (ADR 0058) já
9
+ * entraram assim; o resto espera um caso real.
10
10
  */
11
11
 
12
12
  export type { AiAdapter, AiCompleteOptions } from '../core/index.ts'
13
+ // Mensagem com texto e imagem (ADR 0058): o tipo neutro de conteúdo e a leitura só do texto.
14
+ export type { AiContentPart, AiImageMediaType, AiMessage } from '../core/index.ts'
15
+ export { aiMessageText } from '../core/index.ts'
13
16
  // Elicitação — perguntar à pessoa no meio do turno (round-trip via `AiRunOptions.onAsk`).
14
17
  // O contrato (tipos) mora no core; aqui saem o nome reservado, o schema/descrição da tool e
15
18
  // o formatador. É o ponto de consistência pra qualquer consumidor (chat, copilot, MCP externo).
@@ -0,0 +1,20 @@
1
+ import type { AiMessage } from './types.ts'
2
+
3
+ /** Separa as partes de texto de uma mensagem na leitura: cada parte continua um parágrafo. */
4
+ const TEXT_PART_SEPARATOR = '\n\n'
5
+
6
+ /**
7
+ * O texto de uma mensagem da IA (ADR 0058): o próprio `content` quando ele é texto, ou as partes
8
+ * de texto unidas por uma linha em branco. As imagens não entram.
9
+ *
10
+ * Use onde só o texto importa — título de conversa, contagem de caracteres, bloco de contexto
11
+ * montado por interpolação. Interpolar `content` direto produz "[object Object]" quando a
12
+ * mensagem tem partes, e o compilador não aponta esse uso.
13
+ */
14
+ export function aiMessageText(message: Pick<AiMessage, 'content'>): string {
15
+ const { content } = message
16
+ if (typeof content === 'string') return content
17
+ return content
18
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
19
+ .join(TEXT_PART_SEPARATOR)
20
+ }
package/src/core/index.ts CHANGED
@@ -113,6 +113,8 @@ export type {
113
113
  AiCompleteOptions,
114
114
  AiTool,
115
115
  AiMessage,
116
+ AiContentPart,
117
+ AiImageMediaType,
116
118
  AiRunOptions,
117
119
  AiRunResult,
118
120
  BoundAi,
@@ -243,6 +245,9 @@ export type {
243
245
  } from './runtime.ts'
244
246
  export { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
245
247
 
248
+ // — Mensagem da IA (ADR 0058) ———————————————————————————————————————————————————
249
+ export { aiMessageText } from './ai-message.ts'
250
+
246
251
  // — Assistente do recurso (ADR 0053) ————————————————————————————————————————————
247
252
  export {
248
253
  ASSISTANT_CONTEXT_DEFAULT_CHARS,
package/src/core/types.ts CHANGED
@@ -618,8 +618,9 @@ export type FieldWidget = BuiltInFieldWidget | (string & { readonly __opusCustom
618
618
  export interface FieldSpec {
619
619
  label: I18nRef
620
620
  placeholder?: I18nRef
621
- /** Ajuda junto à label (ícone ⓘ + tooltip): critério, efeito ou limitação que a label não
622
- * diz. Ajuda que só repete a label deve ser omitida. */
621
+ /** Orientação necessária para preencher ou prever o efeito da escolha, sempre visível. */
622
+ hint?: I18nRef
623
+ /** Explicação complementar sob demanda junto à label; não recebe regras indispensáveis. */
623
624
  help?: I18nRef
624
625
 
625
626
  default?: unknown
@@ -1383,10 +1384,33 @@ export interface AiTool {
1383
1384
  }
1384
1385
  }
1385
1386
 
1386
- /** Turno de conversa (histórico multi-turn do chat). */
1387
+ /** Formatos de imagem que a mensagem aceita: os três que os provedores conhecidos leem (ADR 0058). */
1388
+ export type AiImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp'
1389
+
1390
+ /**
1391
+ * Parte de conteúdo de uma mensagem, sem a forma de nenhum provedor (ADR 0058). Cada driver
1392
+ * converte as partes para o formato da sua API e recusa com erro o que não conhece.
1393
+ *
1394
+ * A imagem não leva nome: o rótulo que a pessoa e o modelo usam ("Imagem 1: print.png") é uma
1395
+ * parte de texto que o consumidor monta antes dela.
1396
+ */
1397
+ export type AiContentPart =
1398
+ | { type: 'text'; text: string }
1399
+ | {
1400
+ type: 'image'
1401
+ mediaType: AiImageMediaType
1402
+ /** Bytes da imagem em base64, sem o prefixo `data:`. */
1403
+ data: string
1404
+ }
1405
+
1406
+ /**
1407
+ * Turno de conversa (histórico multi-turn do chat). `content` é texto ou uma lista de partes
1408
+ * com texto e imagem (ADR 0058). Para ler só o texto, use `aiMessageText(message)`: interpolar
1409
+ * `content` direto num template produz "[object Object]" quando a mensagem tem partes.
1410
+ */
1387
1411
  export interface AiMessage {
1388
1412
  role: 'user' | 'assistant'
1389
- content: string
1413
+ content: string | AiContentPart[]
1390
1414
  }
1391
1415
 
1392
1416
  /** Uma opção de resposta de uma pergunta de esclarecimento (`ask_user`). */
@@ -19,6 +19,7 @@ import {
19
19
  DialogContent,
20
20
  DialogHeader,
21
21
  DialogTitle,
22
+ DialogDescription,
22
23
  } from '../primitives/dialog.tsx'
23
24
  import { ActionList } from './list.tsx'
24
25
  import type { ListAction } from '../../../core/index.ts'
@@ -34,6 +35,8 @@ export interface ActionListDialogProps<
34
35
  open: boolean
35
36
  onOpenChange: (open: boolean) => void
36
37
  title: string
38
+ /** Orientação opcional sobre a finalidade; regras e consequências pertencem a intro ou aos campos. */
39
+ description?: ReactNode
37
40
  /** Conteúdo relevante apresentado antes da lista, como texto ou Alert. */
38
41
  intro?: ReactNode
39
42
  /** Nota à esquerda da linha de ações (contexto curto). O total já mora no rodapé. */
@@ -63,6 +66,7 @@ export function ActionListDialog<
63
66
  open,
64
67
  onOpenChange,
65
68
  title,
69
+ description,
66
70
  intro,
67
71
  note,
68
72
  actions,
@@ -75,19 +79,22 @@ export function ActionListDialog<
75
79
  children,
76
80
  }: ActionListDialogProps<TItem, TInput>): React.ReactElement {
77
81
  const introId = useId()
82
+ const descriptionId = useId()
83
+ const describedBy =
84
+ [description == null ? null : descriptionId, intro == null ? null : introId]
85
+ .filter(Boolean)
86
+ .join(' ') || undefined
78
87
  return (
79
88
  <Dialog open={open} onOpenChange={onOpenChange}>
80
- <DialogContent
81
- className={cn('max-w-3xl', className)}
82
- aria-describedby={intro === undefined ? undefined : introId}
83
- >
89
+ <DialogContent className={cn('max-w-3xl', className)} aria-describedby={describedBy}>
84
90
  <DialogHeader>
85
91
  <DialogTitle>{title}</DialogTitle>
92
+ {description != null && (
93
+ <DialogDescription id={descriptionId}>{description}</DialogDescription>
94
+ )}
86
95
  </DialogHeader>
87
96
  <DialogBody>
88
- {intro !== undefined && (
89
- <div id={introId}>{intro}</div>
90
- )}
97
+ {intro != null && <div id={introId}>{intro}</div>}
91
98
  {(note !== undefined || actions !== undefined) && (
92
99
  <div
93
100
  data-slot="action-list-dialog-toolbar"
@@ -16,6 +16,7 @@ import {
16
16
  DialogFooter,
17
17
  DialogHeader,
18
18
  DialogTitle,
19
+ DialogDescription,
19
20
  } from "../primitives/dialog.tsx";
20
21
  import { ActionForm, type ActionFormProps } from "./form.tsx";
21
22
  import { ButtonGroup } from "../primitives/button-group.tsx";
@@ -27,6 +28,8 @@ export interface ActionFormDialogProps<
27
28
  open: boolean;
28
29
  onOpenChange: (open: boolean) => void;
29
30
  title: string;
31
+ /** Orientação opcional sobre a finalidade; regras e consequências pertencem a intro ou aos campos. */
32
+ description?: ReactNode;
30
33
  /** Conteúdo relevante apresentado antes dos campos, como texto ou Alert. */
31
34
  intro?: ReactNode;
32
35
  /** Distribui as ações pela largura disponível somente quando as decisões pedem peso equivalente. */
@@ -40,6 +43,7 @@ export function ActionFormDialog<
40
43
  open,
41
44
  onOpenChange,
42
45
  title,
46
+ description,
43
47
  intro,
44
48
  onSuccess,
45
49
  onCancel,
@@ -48,15 +52,23 @@ export function ActionFormDialog<
48
52
  ...rest
49
53
  }: ActionFormDialogProps<TInput, TData>) {
50
54
  const introId = useId();
55
+ const descriptionId = useId();
56
+ const describedBy =
57
+ [description == null ? null : descriptionId, intro == null ? null : introId]
58
+ .filter(Boolean)
59
+ .join(" ") || undefined;
51
60
  return (
52
61
  <Dialog open={open} onOpenChange={onOpenChange}>
53
62
  {/* Header e rodapé FIXOS, só o CORPO (campos) scrolla — o "X" não rola junto e
54
63
  forms longos não estouram a viewport. O content já é coluna flex com teto. */}
55
- <DialogContent
56
- aria-describedby={intro === undefined ? undefined : introId}
57
- >
64
+ <DialogContent aria-describedby={describedBy}>
58
65
  <DialogHeader>
59
66
  <DialogTitle>{title}</DialogTitle>
67
+ {description != null && (
68
+ <DialogDescription id={descriptionId}>
69
+ {description}
70
+ </DialogDescription>
71
+ )}
60
72
  </DialogHeader>
61
73
  <ActionForm
62
74
  {...rest}
@@ -65,9 +77,7 @@ export function ActionFormDialog<
65
77
  // faixa da casa — fonte única). Os botões seguem dentro do <form> do ActionForm.
66
78
  body={(fields) => (
67
79
  <DialogBody>
68
- {intro !== undefined && (
69
- <div id={introId}>{intro}</div>
70
- )}
80
+ {intro != null && <div id={introId}>{intro}</div>}
71
81
  {fields}
72
82
  </DialogBody>
73
83
  )}
@@ -84,7 +94,8 @@ export function ActionFormDialog<
84
94
  }}
85
95
  onCancel={onCancel ?? (() => onOpenChange(false))}
86
96
  cancelVariant={
87
- cancelVariant ?? (footerDistribution === "equal" ? "outline" : "ghost")
97
+ cancelVariant ??
98
+ (footerDistribution === "equal" ? "outline" : "ghost")
88
99
  }
89
100
  />
90
101
  </DialogContent>
@@ -4,7 +4,7 @@
4
4
  * Dois modos, um motor:
5
5
  * - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo.
6
6
  * - COMPOSIÇÃO (com children): você diagrama; <ActionFormField name /> coloca cada campo
7
- * (label/help/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
7
+ * (label/hint/help/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
8
8
  * JSX ({cond && <ActionFormField/>}); opções de runtime entram por prop no campo. O
9
9
  * FieldGroup canônico permanece nos dois modos; grids e seções entram como filhos explícitos.
10
10
  *
@@ -54,6 +54,7 @@ import { ToggleGroup } from "../primitives/toggle-group.tsx";
54
54
  import {
55
55
  Field,
56
56
  FieldError,
57
+ FieldDescription,
57
58
  FieldGroup,
58
59
  FieldLabel,
59
60
  } from "../primitives/field.tsx";
@@ -137,7 +138,9 @@ function inferFieldKind(schema: ZodTypeAny): FieldKind {
137
138
  interface FieldSpec {
138
139
  label?: string;
139
140
  placeholder?: string;
140
- /** Ajuda na label: ícone ⓘ + tooltip no hover. */
141
+ /** Orientação necessária sempre visível abaixo do controle. */
142
+ hint?: string;
143
+ /** Detalhe complementar no ícone junto à label, disponível por hover ou foco. */
141
144
  help?: string;
142
145
  /** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
143
146
  * Honra 'textarea' (string sem max), 'code' (textarea monoespaçada, ex.: SKILL.md),
@@ -206,9 +209,8 @@ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
206
209
  * lugar (`<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>`) e o `opus copy` inventarie
207
210
  * o texto de `help` como helper-text.
208
211
  *
209
- * `tabIndex={-1}`: fica FORA do autofocus do diálogo (senão o modal foca no ícone ao abrir e
210
- * o tooltip vem aberto); abre só no hover. preventDefault: clicar o ícone não dispara o label
211
- * (não foca o input / não toggla).
212
+ * O botão participa do Tab e abre por foco ou hover. Dialog e Drawer preservam o foco
213
+ * inicial no controle, sem remover a ajuda da navegação por teclado. Clicar não ativa a label.
212
214
  */
213
215
  export function LabelHelp({
214
216
  help,
@@ -222,7 +224,8 @@ export function LabelHelp({
222
224
  <TooltipTrigger asChild>
223
225
  <button
224
226
  type="button"
225
- tabIndex={-1}
227
+ data-slot="label-help"
228
+ tabIndex={0}
226
229
  aria-label="Ajuda"
227
230
  onClick={(e) => {
228
231
  // Clicar o ícone não ativa o label (não foca/toggla o campo).
@@ -344,7 +347,8 @@ export function ActionFormField({
344
347
  const errorMessage =
345
348
  typeof fieldError?.message === "string" ? fieldError.message : undefined;
346
349
  const errorId = errorMessage !== undefined ? `${name}-error` : undefined;
347
- const describedBy = errorId;
350
+ const hintId = spec.hint?.trim() ? `${name}-hint` : undefined;
351
+ const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
348
352
  useEffect(() => {
349
353
  if (errorMessage === undefined) return;
350
354
  const control = document.getElementById(name);
@@ -430,6 +434,9 @@ export function ActionFormField({
430
434
  </span>
431
435
  <LabelHelp help={spec.help} />
432
436
  </FieldLabel>
437
+ {hintId !== undefined && (
438
+ <FieldDescription id={hintId}>{spec.hint}</FieldDescription>
439
+ )}
433
440
  {errorMessage !== undefined && (
434
441
  <FieldError id={errorId}>{errorMessage}</FieldError>
435
442
  )}
@@ -683,6 +690,9 @@ export function ActionFormField({
683
690
  />
684
691
  )}
685
692
 
693
+ {hintId !== undefined && (
694
+ <FieldDescription id={hintId}>{spec.hint}</FieldDescription>
695
+ )}
686
696
  {errorMessage !== undefined && (
687
697
  <FieldError id={errorId}>{errorMessage}</FieldError>
688
698
  )}
@@ -774,7 +784,25 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
774
784
  string,
775
785
  ZodTypeAny | undefined
776
786
  >;
777
- const fields = action.fields as Record<string, FieldSpec>;
787
+ // O contexto entrega texto resolvido também aos renderizadores de campo do consumidor.
788
+ const fields: Record<string, FieldSpec> = Object.fromEntries(
789
+ Object.entries(action.fields ?? {}).map(([name, declared]) => {
790
+ const spec = declared as FieldSpec;
791
+ return [
792
+ name,
793
+ {
794
+ ...spec,
795
+ label: msgText(spec.label, name),
796
+ placeholder:
797
+ spec.placeholder === undefined
798
+ ? undefined
799
+ : msgText(spec.placeholder, ""),
800
+ hint: spec.hint === undefined ? undefined : msgText(spec.hint, ""),
801
+ help: spec.help === undefined ? undefined : msgText(spec.help, ""),
802
+ },
803
+ ];
804
+ }),
805
+ );
778
806
 
779
807
  // Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta
780
808
  // DialogBody/DialogFooter (fonte única do scroll e da faixa). Os botões ficam DENTRO do
@@ -147,6 +147,8 @@ interface PageBaseProps extends Omit<
147
147
 
148
148
  interface PageShorthandProps extends PageBaseProps {
149
149
  title: ReactNode;
150
+ /** Orientação opcional sobre a finalidade da página; regras pertencem ao corpo. */
151
+ description?: ReactNode;
150
152
  actions?: ReactNode;
151
153
  children: ReactNode;
152
154
  }
@@ -224,6 +226,7 @@ function hasOpaquePageBodyContent(node: ReactNode): boolean {
224
226
 
225
227
  export function Page({
226
228
  title,
229
+ description,
227
230
  actions,
228
231
  layout = "flow",
229
232
  className,
@@ -343,6 +346,9 @@ export function Page({
343
346
  <>
344
347
  <PageHeader>
345
348
  <PageTitle>{title}</PageTitle>
349
+ {description !== undefined && (
350
+ <PageDescription>{description}</PageDescription>
351
+ )}
346
352
  {actions !== undefined && <PageActions>{actions}</PageActions>}
347
353
  </PageHeader>
348
354
  <PageBody>{children}</PageBody>
@@ -461,6 +467,7 @@ export function PageHeader({
461
467
  slots={{
462
468
  leading: [PageBack, PageNavigation],
463
469
  title: PageTitle,
470
+ description: PageDescription,
464
471
  actions: PageActions,
465
472
  }}
466
473
  leadingPlacement="above"
@@ -496,6 +503,21 @@ export function PageTitle({
496
503
  );
497
504
  }
498
505
 
506
+ /** Orientação breve e dispensável para a decisão; não recebe regras de negócio. */
507
+ export function PageDescription({
508
+ className,
509
+ ...props
510
+ }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
511
+ requireParent(useContext(PageHeaderContext), "PageDescription", "PageHeader");
512
+ return (
513
+ <p
514
+ data-slot="page-description"
515
+ className={cn(structuralHeaderClasses.page.description, className)}
516
+ {...props}
517
+ />
518
+ );
519
+ }
520
+
499
521
  export function PageActions({
500
522
  className,
501
523
  ...props
@@ -60,7 +60,7 @@ interface FamilyAnatomy {
60
60
  regions: Record<SurfaceLayout, RegionAnatomy>;
61
61
  /** Escala visual do título. O nível semântico do heading é escolhido à parte. */
62
62
  title: Record<SurfaceLayout, string>;
63
- /** Descrição no header, quando a família a oferece (ADRs 0014 e 0018 excluem modais). */
63
+ /** Descrição no header, quando a família a oferece (ADR 0059 inclui subheadings opcionais em modais). */
64
64
  description: string;
65
65
  /** Espaço entre mídia, título e ações na linha do header. */
66
66
  headerGap: string;