@softize/opus 26.0.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 (29) hide show
  1. package/CHANGELOG.md +21 -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/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
  8. package/docs/adr/README.md +2 -0
  9. package/package.json +1 -1
  10. package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
  11. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
  12. package/src/core/types.ts +3 -2
  13. package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
  14. package/src/ui/components/patterns/form-dialog.tsx +18 -7
  15. package/src/ui/components/patterns/form.tsx +36 -8
  16. package/src/ui/components/patterns/page.tsx +22 -0
  17. package/src/ui/components/patterns/surface-anatomy.ts +1 -1
  18. package/src/ui/components/primitives/dialog.tsx +28 -1
  19. package/src/ui/components/primitives/drawer.tsx +30 -2
  20. package/src/ui/docs/content/action-form-card.md +1 -1
  21. package/src/ui/docs/content/action-form-dialog.md +8 -0
  22. package/src/ui/docs/content/action-form.md +18 -5
  23. package/src/ui/docs/content/action-list-dialog.md +8 -0
  24. package/src/ui/docs/content/dialog.md +24 -2
  25. package/src/ui/docs/content/drawer.md +24 -2
  26. package/src/ui/docs/content/page.md +23 -1
  27. package/src/ui/docs/content/surface.md +8 -2
  28. package/src/ui/lib/modal-layers.ts +27 -3
  29. package/src/ui/react.tsx +3 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 26.1.0 — 2026-10-09
11
+
12
+ Subheadings descrevem a finalidade da superfície; orientações necessárias ficam visíveis no corpo
13
+ ou junto ao campo (ADR 0059).
14
+
15
+ - `Page` aceita `description`. `PageDescription`, `DialogDescription` e `DrawerDescription`
16
+ compõem o cabeçalho ou a introdução declarada da superfície.
17
+ - `ActionFormDialog` e `ActionListDialog` recebem `description` para o cabeçalho. `intro` continua
18
+ no corpo, para regras e consequências da tarefa; ambos participam da descrição acessível.
19
+ - `FieldSpec.hint` declara orientação visível abaixo do controle. `ActionFormField` associa hint
20
+ e erro por `aria-describedby`; `help` continua sendo a ajuda opcional junto à label.
21
+ - Os textos dos campos resolvem `I18nRef` pelo texto padrão também nos renderizadores próprios.
22
+ O ícone de ajuda participa do Tab e abre por foco, sem tomar o foco inicial de outro controle
23
+ em Dialog e Drawer.
24
+ - O inventário de copy, o validador estrutural, a skill de UI e os exemplos da documentação
25
+ reconhecem esses papéis sem proibir subheadings de maneira indiscriminada.
26
+
27
+ **O que fazer:** rode `opus setup` e `opus copy`; revise a posição das orientações existentes.
28
+ Renderizadores próprios de campos devem apresentar `hint` e associá-lo ao controle, preservando
29
+ os erros. Nenhum texto migra automaticamente de `help` para `hint`.
30
+
10
31
  ## 26.0.0 — 2026-10-09
11
32
 
12
33
  A conversa com a IA pode levar imagens (ADR 0058). Uma mensagem passa a aceitar texto e imagem
package/bin/lib/check.mjs CHANGED
@@ -152,6 +152,7 @@ const UI_STRUCTURAL_PARENTS = new Map([
152
152
  ["PageBody", new Set(["Page"])],
153
153
  ["PageFooter", new Set(["Page"])],
154
154
  ["PageTitle", new Set(["PageHeader"])],
155
+ ["PageDescription", new Set(["PageHeader"])],
155
156
  ["PageBack", new Set(["PageHeader"])],
156
157
  ["PageNavigation", new Set(["PageHeader"])],
157
158
  ["PageActions", new Set(["PageHeader"])],
@@ -173,6 +174,7 @@ const UI_STRUCTURAL_PARENTS = new Map([
173
174
  ["DialogNavigation", new Set(["DialogHeader"])],
174
175
  ["DialogMedia", new Set(["DialogHeader"])],
175
176
  ["DialogTitle", new Set(["DialogHeader", "DialogIntro"])],
177
+ ["DialogDescription", new Set(["DialogHeader", "DialogIntro"])],
176
178
  ["DialogActions", new Set(["DialogHeader"])],
177
179
  ["DrawerHeader", new Set(["DrawerContent"])],
178
180
  ["DrawerBody", new Set(["DrawerContent"])],
@@ -182,6 +184,7 @@ const UI_STRUCTURAL_PARENTS = new Map([
182
184
  ["DrawerNavigation", new Set(["DrawerHeader"])],
183
185
  ["DrawerMedia", new Set(["DrawerHeader"])],
184
186
  ["DrawerTitle", new Set(["DrawerHeader", "DrawerIntro"])],
187
+ ["DrawerDescription", new Set(["DrawerHeader", "DrawerIntro"])],
185
188
  ["DrawerActions", new Set(["DrawerHeader"])],
186
189
  ["AlertMedia", new Set(["Alert"])],
187
190
  ["AlertHeader", new Set(["Alert"])],
@@ -215,13 +218,12 @@ const UI_STRUCTURAL_ROOTS = new Map([
215
218
  body: "PageBody",
216
219
  footer: "PageFooter",
217
220
  headerRequired: false,
218
- shorthand: new Set(["title", "actions"]),
221
+ shorthand: new Set(["title", "description", "actions"]),
219
222
  // O contador saiu do título da página (ADR 0009). Tirar `count` do shorthand não proíbe
220
223
  // nada sozinho — só apaga o marcador que fazia o gate enxergar aquele Page —, então a
221
224
  // prop removida é cobrada aqui, nas duas formas de composição.
222
225
  removed: new Map([
223
226
  ["count", "o total pertence ao conteúdo que o explica"],
224
- ["description", "mova contexto relevante para o início do body"],
225
227
  ]),
226
228
  },
227
229
  ],
@@ -244,6 +246,7 @@ const UI_STRICT_DIRECT_COMPONENTS = new Set([
244
246
  "PageBody",
245
247
  "PageFooter",
246
248
  "PageTitle",
249
+ "PageDescription",
247
250
  "PageBack",
248
251
  "PageNavigation",
249
252
  "PageActions",
@@ -308,24 +311,7 @@ const UI_REMOVED_PROPS = new Map([
308
311
  ["variant", "o cabeçalho da página tem uma única apresentação rolável"],
309
312
  ]),
310
313
  ],
311
- [
312
- "ActionFormDialog",
313
- new Map([
314
- [
315
- "description",
316
- "use `intro` somente para contexto relevante no início do corpo",
317
- ],
318
- ]),
319
- ],
320
- [
321
- "ActionListDialog",
322
- new Map([
323
- [
324
- "description",
325
- "use `intro` somente para contexto relevante no início do corpo",
326
- ],
327
- ]),
328
- ],
314
+
329
315
  ]);
330
316
 
331
317
  const UI_REMOVED_COMPONENTS = new Map([
@@ -338,15 +324,6 @@ const UI_REMOVED_COMPONENTS = new Map([
338
324
  ["PaneDescription", "use `SurfaceDescription` dentro de `SurfaceHeader`"],
339
325
  ["PaneActions", "use `SurfaceActions` dentro de `SurfaceHeader`"],
340
326
  ["PageIntro", "use `PageHeader` como a única introdução estrutural"],
341
- ["PageDescription", "mova contexto relevante para o início de `PageBody`"],
342
- [
343
- "DialogDescription",
344
- "mova contexto relevante para o início de `DialogBody`",
345
- ],
346
- [
347
- "DrawerDescription",
348
- "mova contexto relevante para o início de `DrawerBody`",
349
- ],
350
327
  // Content e Card deram lugar a Surface (ADR 0045).
351
328
  [
352
329
  "Content",
@@ -383,7 +360,7 @@ const UI_STRUCTURAL_HEADERS = new Map([
383
360
  "DialogIntro",
384
361
  {
385
362
  title: "DialogTitle",
386
- optional: new Set(["DialogIntroActions"]),
363
+ optional: new Set(["DialogIntroActions", "DialogDescription"]),
387
364
  shorthand: new Set(),
388
365
  },
389
366
  ],
@@ -391,7 +368,7 @@ const UI_STRUCTURAL_HEADERS = new Map([
391
368
  "DrawerIntro",
392
369
  {
393
370
  title: "DrawerTitle",
394
- optional: new Set(["DrawerIntroActions"]),
371
+ optional: new Set(["DrawerIntroActions", "DrawerDescription"]),
395
372
  shorthand: new Set(),
396
373
  },
397
374
  ],
@@ -399,7 +376,7 @@ const UI_STRUCTURAL_HEADERS = new Map([
399
376
  "PageHeader",
400
377
  {
401
378
  title: "PageTitle",
402
- optional: new Set(["PageBack", "PageNavigation", "PageActions"]),
379
+ optional: new Set(["PageBack", "PageNavigation", "PageActions", "PageDescription"]),
403
380
  exclusive: [new Set(["PageBack", "PageNavigation"])],
404
381
  shorthand: new Set(),
405
382
  titleRequired: false,
package/bin/lib/copy.mjs CHANGED
@@ -99,9 +99,9 @@ const JSX_CHILD_ROLES = new Map([
99
99
  ['Button', 'button'],
100
100
  ['CommandEmpty', 'empty-state'],
101
101
  ['CommandItem', 'menu-item'],
102
- ['DialogDescription', 'dialog-body'],
102
+ ['DialogDescription', 'description'],
103
103
  ['DialogTitle', 'title'],
104
- ['DrawerDescription', 'dialog-body'],
104
+ ['DrawerDescription', 'description'],
105
105
  ['DrawerTitle', 'title'],
106
106
  ['EmptyDescription', 'empty-state'],
107
107
  ['EmptyTitle', 'title'],
@@ -123,6 +123,7 @@ const JSX_CHILD_ROLES = new Map([
123
123
  // comando. Classificado como `button`, a política universal cobraria verbo de ação.
124
124
  ['PageBack', 'breadcrumb'],
125
125
  ['PageTitle', 'title'],
126
+ ['PageDescription', 'description'],
126
127
  ['SurfaceDescription', 'description'],
127
128
  ['SurfaceTitle', 'title'],
128
129
  ['TableCaption', 'description'],
@@ -182,7 +183,7 @@ const JSX_PROP_ROLES = new Map([
182
183
  'ActionFormDialog',
183
184
  new Map([
184
185
  ['title', 'title'],
185
- ['description', 'dialog-body'],
186
+ ['description', 'description'],
186
187
  ['intro', 'dialog-body'],
187
188
  ['submitLabel', 'button'],
188
189
  ['cancelLabel', 'button'],
@@ -202,7 +203,7 @@ const JSX_PROP_ROLES = new Map([
202
203
  'ActionListDialog',
203
204
  new Map([
204
205
  ['title', 'title'],
205
- ['description', 'dialog-body'],
206
+ ['description', 'description'],
206
207
  ['intro', 'dialog-body'],
207
208
  ['emptyMessage', 'empty-state'],
208
209
  ['errorMessage', 'error'],
@@ -255,6 +256,7 @@ const JSX_PROP_ROLES = new Map([
255
256
  'Page',
256
257
  new Map([
257
258
  ['title', 'title'],
259
+ ['description', 'description'],
258
260
  ]),
259
261
  ],
260
262
  [
@@ -1503,6 +1505,7 @@ export function extractCopyFromSource(file, sourceText) {
1503
1505
  addProperty(object, 'label', 'label', prefix)
1504
1506
  addProperty(object, 'placeholder', 'placeholder', prefix)
1505
1507
  addProperty(object, 'help', 'helper-text', prefix)
1508
+ addProperty(object, 'hint', 'helper-text', prefix)
1506
1509
  nestedObject(object, 'options', optionItems, prefix)
1507
1510
  }
1508
1511
 
@@ -1,6 +1,6 @@
1
1
  # ADR 0014 — Headers estruturais não carregam descrição
2
2
 
3
- - **Status:** aceita.
3
+ - **Status:** substituída pela ADR 0059 (subheading opcional para orientação).
4
4
  - **Data:** 2026-09-11.
5
5
  - **Complementa:** ADRs 0005, 0011 e 0018.
6
6
 
@@ -3,7 +3,7 @@
3
3
  > Publicada originalmente com o número 0012, que colidia com a ADR de Produtos de Dados do mesmo
4
4
  > dia. Renumerada em 2026-09-16; o conteúdo não mudou.
5
5
 
6
- - **Status:** aceita.
6
+ - **Status:** parcialmente substituída pela ADR 0059 (subheading opcional no cabeçalho).
7
7
  - **Data:** 2026-09-11.
8
8
 
9
9
  ## Contexto
@@ -158,6 +158,10 @@ anteriores já os delimitaram:
158
158
  | Body | sim | sim | sim | sim | sim | sim |
159
159
  | Footer | sim | não | sim | sim | sim | sim |
160
160
 
161
+ > **Atualização (2026-10-09):** a ADR 0059 substitui a restrição de descrição abaixo. Page, Dialog
162
+ > e Drawer passam a aceitar subheading opcional de orientação; regras necessárias ficam no corpo
163
+ > ou junto dos controles. A tabela registra a decisão original desta ADR.
164
+
161
165
  As ADRs 0009, 0014 e 0018 não são reabertas: Page, Dialog e Drawer continuam sem descrição no
162
166
  header, e Page continua sem contador. `Dialog`, `Drawer` e `Pane` ganham o slot de ações que hoje
163
167
  não têm — a ausência obrigava a injetar nós soltos ao lado do título, e o `Studio` a manter três
@@ -0,0 +1,51 @@
1
+ # ADR 0059 — Subheadings orientam sem carregar regras de negócio
2
+
3
+ - **Status:** aceita para implementação local; publicação pendente.
4
+ - **Data:** 2026-10-09.
5
+ - **Substitui:** ADR 0014.
6
+ - **Substitui parcialmente:** ADRs 0018 e 0023, quanto à proibição de descrição no cabeçalho.
7
+
8
+ ## Contexto
9
+
10
+ A proibição de descrições nos cabeçalhos evitou texto de preenchimento, mas também deslocou
11
+ orientações úteis para o corpo. A distinção necessária é semântica: uma frase que apresenta a
12
+ finalidade da superfície pode ser ignorada sem comprometer a tarefa; uma regra ou consequência
13
+ necessária à decisão precisa estar visível no ponto em que será usada.
14
+
15
+ ## Decisão
16
+
17
+ Page, Surface (inclusive os usos antes atendidos por Content e Card), Dialog e Drawer aceitam um
18
+ subheading opcional sob o título. A API usa `description` e os slots `PageDescription`,
19
+ `SurfaceDescription`, `DialogDescription` e `DrawerDescription`. Não há texto padrão nem obrigação
20
+ de preencher esse espaço. Frases que apenas repetem o título são omitidas.
21
+
22
+ “Escolha onde a pasta ‘Produtos’ e suas subpastas aparecem.” orienta a tarefa e pode ocupar esse
23
+ slot. “O motivo define se a observação é obrigatória e qual pendência será criada.” comunica regras:
24
+ as condições precisam acompanhar os respectivos campos, ações ou conteúdo. O tamanho da frase não
25
+ altera essa classificação. A pergunta de revisão é: ao pular o subheading, a pessoa ainda consegue
26
+ decidir e concluir corretamente?
27
+
28
+ `ActionFormDialog` e `ActionListDialog` separam `description`, orientação no cabeçalho, de `intro`,
29
+ contexto necessário no corpo. Quando ambos existem, a descrição acessível relaciona ambos os IDs.
30
+ Nas composições diretas, o consumidor mantém controle explícito de `aria-describedby`.
31
+ A API imperativa de decisões permanece com `body`: confirmações devem apresentar seus efeitos no
32
+ conteúdo da decisão. Fieldsets mantêm somente nome e mídia na legenda.
33
+
34
+ ## Alternativas e consequências
35
+
36
+ Manter a proibição exigiria recriar subheadings no consumidor. Mover toda descrição para o
37
+ cabeçalho esconderia regras em uma região visual secundária. A escolha preserva os textos
38
+ necessários no corpo e permite orientação breve com a anatomia compartilhada.
39
+
40
+ As APIs são aditivas; `intro` existente não muda de lugar. A classificação semântica exige revisão
41
+ contextual de copy e não pode ser provada por uma busca de palavras ou limite de caracteres.
42
+ `FieldSpec.hint` mantém visível a orientação necessária junto ao controle; `help` oferece detalhe
43
+ complementar no ícone. Ambos podem coexistir sem repetir texto. Hint e erro participam da descrição
44
+ acessível do controle. A escolha depende da necessidade para decidir, não do tamanho da frase.
45
+ A Base mantém o princípio de comunicação; o Opus mantém componentes, documentação e extração.
46
+
47
+ ## Verificação
48
+
49
+ Testar composição, omissão, coexistência com `intro`, associação acessível e extração de copy.
50
+ O checker deve aceitar os novos slots em seus pais e continuar rejeitando anatomias inválidas.
51
+ Validar no consumidor os exemplos de organização de pastas e configuração de motivos de perda.
@@ -10,3 +10,5 @@ entender por que uma restrição existe ou como ela evoluiu.
10
10
 
11
11
  Uma ADR preserva seu contexto histórico mesmo quando é substituída. O comportamento vigente deve
12
12
  estar documentado na superfície correspondente, sem exigir a reconstrução dessa sequência.
13
+
14
+ - [ADR 0059 — Subheadings orientam sem carregar regras de negócio](0059-subheadings-orient-without-carrying-business-rules.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "26.0.0",
3
+ "version": "26.1.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -66,3 +66,11 @@
66
66
  sobreporem.
67
67
  - Reprova: dimensionar select inline de filtro pelo conteúdo ou pela label (`min-w-*` solto,
68
68
  largura calculada), em vez da largura fixa do pattern.
69
+
70
+ ## Orientação, hint e help
71
+
72
+ Construir um formulário com orientação breve no cabeçalho, regra de seleção vazia e explicação
73
+ complementar do termo. Esperado: `description` orienta a finalidade, `hint` mantém visível o efeito
74
+ de deixar vazio e `help` oferece somente o detalhe opcional. `intro` continua no corpo quando a
75
+ regra afeta vários campos. Reprovar regra obrigatória somente em subheading ou tooltip, repetição
76
+ da label, duplicação entre hint/help e perda de `aria-describedby` quando aparece um erro.
@@ -10,11 +10,11 @@
10
10
  - `Page` fornece o `<main>` e o container centralizado com teto padrão de `80rem`. Sua forma
11
11
  explícita exige `PageBody`, exatamente um heading principal e aceita `PageHeader` e `PageFooter`.
12
12
  Declare esse heading com `PageTitle` no header, `Surface level={1}` ou um `PageState` ativo no
13
- body. `PageHeader` é a única introdução estrutural e reúne navegação, título e ações. `title` e `actions` no
14
- próprio `Page` são a abreviação para o caso direto.
13
+ body. `PageHeader` é a única introdução estrutural e reúne navegação, título, `PageDescription`
14
+ opcional e ações. `title`, `description` e `actions` no próprio `Page` abreviam o caso direto.
15
15
  Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
16
16
  real de largura; não reconstruir esse container em cada rota.
17
- - `PageHeader` declara navegação contextual, título e ações. Em uma subpágina simples, `PageBack`
17
+ - `PageHeader` declara navegação contextual, título, subheading opcional e ações. Em uma subpágina simples, `PageBack`
18
18
  recebe o destino pai explícito e aparece acima da linha do título como controle somente com
19
19
  ícone. Para mais de um ancestral relevante, use
20
20
  `Breadcrumb` dentro de `PageNavigation`. Não combine retorno e breadcrumb nem crie um chrome
@@ -92,7 +92,9 @@ SurfaceTitle, SurfaceDescription, SurfaceMeta, SurfaceActions) + SurfaceBody + S
92
92
  cancelamento a `outline`.
93
93
  - Cabeçalhos de `Dialog` e `Drawer` nomeiam a superfície com o título e organizam suas ações. O close
94
94
  padrão é o `CloseButton` (`neutral`, `subtle`, pílula) no final do header, sem posição flutuante
95
- alternativa. Não preencher uma segunda linha por hábito. Consequência, restrição ou instrução que
95
+ alternativa. `PageDescription`, `SurfaceDescription`, `DialogDescription` e `DrawerDescription`
96
+ orientam brevemente sobre a finalidade; wrappers usam `description`. Omitir repetição do título.
97
+ Pular esse subheading não pode comprometer a decisão. Consequência, restrição ou instrução que
96
98
  realmente mude a tarefa entra no início de `DialogBody`/`DrawerBody`, como texto ou `Alert`;
97
99
  wrappers usam `intro` e a API imperativa usa `body`. Relacione texto conciso por
98
100
  `aria-describedby` somente quando ele também precisar descrever a superfície para tecnologias
@@ -219,3 +221,11 @@ cells={{
219
221
  // Risco comportamental usado como nome visual.
220
222
  <Button variant="destructive">Excluir</Button>
221
223
  ```
224
+
225
+ ## Orientação de campo
226
+
227
+ Usar `FieldSpec.hint` para condição, formato ou consequência necessários à escolha, sempre visível
228
+ abaixo do controle. Reservar `FieldSpec.help` ao ícone com detalhe complementar sob demanda.
229
+ Omitir repetição da label; combinar ambos somente com funções distintas. Regra de vários campos
230
+ pertence ao corpo ou ao grupo. Em composição manual, `FieldDescription` e erro entram juntos no
231
+ `aria-describedby` do controle.
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
@@ -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;
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
5
5
  import {
6
6
  SurfaceLayoutProvider,
7
7
  surfaceActionsClasses,
8
+ surfaceDescriptionClasses,
8
9
  surfaceHeaderRowClasses,
9
10
  surfaceRegionClasses,
10
11
  surfaceTitleClasses,
@@ -26,6 +27,7 @@ import {
26
27
  exemptLayerFirst,
27
28
  focusIsInLayer,
28
29
  handleModalTabKey,
30
+ skipHelpOnAutoFocus,
29
31
  isLayerInteraction,
30
32
  routeEscapeToLayer,
31
33
  useLayerPortal,
@@ -415,6 +417,7 @@ export function DialogContent({
415
417
  onEscapeKeyDown,
416
418
  onPointerDownOutside,
417
419
  onInteractOutside,
420
+ onOpenAutoFocus,
418
421
  onCloseAutoFocus,
419
422
  onKeyDown,
420
423
  ref,
@@ -481,6 +484,7 @@ export function DialogContent({
481
484
  aria-describedby={ariaDescribedBy}
482
485
  // O `AlertDialog` do Radix cancela sozinho o clique fora; só o Esc é composto.
483
486
  onEscapeKeyDown={dismiss.onEscapeKeyDown}
487
+ onOpenAutoFocus={onOpenAutoFocus}
484
488
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
485
489
  onKeyDown={onKeyDown}
486
490
  {...props}
@@ -532,6 +536,10 @@ export function DialogContent({
532
536
  onPointerDownOutside={dismiss.onPointerDownOutside}
533
537
  onInteractOutside={dismiss.onInteractOutside}
534
538
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
539
+ onOpenAutoFocus={(event) => {
540
+ onOpenAutoFocus?.(event);
541
+ skipHelpOnAutoFocus(event);
542
+ }}
535
543
  onKeyDown={handleKeyDown}
536
544
  {...props}
537
545
  >
@@ -584,6 +592,7 @@ export function DialogHeader({
584
592
  leading: DialogNavigation,
585
593
  media: DialogMedia,
586
594
  title: DialogTitle,
595
+ description: DialogDescription,
587
596
  actions: DialogActions,
588
597
  }}
589
598
  leadingPlacement="inline"
@@ -645,7 +654,11 @@ export function DialogIntro({
645
654
  <StructuralHeader
646
655
  name="DialogIntro"
647
656
  slot="dialog-intro"
648
- slots={{ title: DialogTitle, actions: DialogIntroActions }}
657
+ slots={{
658
+ title: DialogTitle,
659
+ description: DialogDescription,
660
+ actions: DialogIntroActions,
661
+ }}
649
662
  leadingPlacement="inline"
650
663
  titleRequired
651
664
  className={cn("text-left", className)}
@@ -770,3 +783,17 @@ export function DialogTitle({
770
783
  </Title>
771
784
  );
772
785
  }
786
+
787
+ /** Orientação opcional sob o título; condições e consequências ficam no corpo. */
788
+ export function DialogDescription({
789
+ className,
790
+ ...props
791
+ }: React.ComponentProps<"p">) {
792
+ return (
793
+ <p
794
+ data-slot="dialog-description"
795
+ className={cn("mt-1", surfaceDescriptionClasses("dialog"), className)}
796
+ {...props}
797
+ />
798
+ );
799
+ }
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
5
5
  import {
6
6
  SurfaceLayoutProvider,
7
7
  surfaceActionsClasses,
8
+ surfaceDescriptionClasses,
8
9
  surfaceHeaderRowClasses,
9
10
  surfaceRegionClasses,
10
11
  surfaceTitleClasses,
@@ -28,6 +29,7 @@ import {
28
29
  LAYER_MODAL_ATTRIBUTE,
29
30
  LayerPortalProvider,
30
31
  handleModalTabKey,
32
+ skipHelpOnAutoFocus,
31
33
  useLayerPortal,
32
34
  useModalLayerEntry,
33
35
  } from "../../lib/modal-layers.ts";
@@ -113,7 +115,9 @@ function DrawerOverlay({
113
115
  return (
114
116
  <DrawerPrimitive.Overlay
115
117
  data-slot="drawer-overlay"
116
- {...{ [LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined }}
118
+ {...{
119
+ [LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined,
120
+ }}
117
121
  className={cn(overlayClasses, layer.zIndexClass, className)}
118
122
  {...props}
119
123
  />
@@ -146,6 +150,7 @@ function DrawerContent({
146
150
  onEscapeKeyDown,
147
151
  onPointerDownOutside,
148
152
  onInteractOutside,
153
+ onOpenAutoFocus,
149
154
  onCloseAutoFocus,
150
155
  onKeyDown,
151
156
  ref,
@@ -218,6 +223,10 @@ function DrawerContent({
218
223
  onPointerDownOutside={dismiss.onPointerDownOutside}
219
224
  onInteractOutside={dismiss.onInteractOutside}
220
225
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
226
+ onOpenAutoFocus={(event) => {
227
+ onOpenAutoFocus?.(event);
228
+ skipHelpOnAutoFocus(event);
229
+ }}
221
230
  onKeyDown={handleKeyDown}
222
231
  {...props}
223
232
  >
@@ -251,6 +260,7 @@ function DrawerHeader({
251
260
  leading: DrawerNavigation,
252
261
  media: DrawerMedia,
253
262
  title: DrawerTitle,
263
+ description: DrawerDescription,
254
264
  actions: DrawerActions,
255
265
  }}
256
266
  leadingPlacement="inline"
@@ -314,7 +324,11 @@ function DrawerIntro({
314
324
  <StructuralHeader
315
325
  name="DrawerIntro"
316
326
  slot="drawer-intro"
317
- slots={{ title: DrawerTitle, actions: DrawerIntroActions }}
327
+ slots={{
328
+ title: DrawerTitle,
329
+ description: DrawerDescription,
330
+ actions: DrawerIntroActions,
331
+ }}
318
332
  leadingPlacement="inline"
319
333
  titleRequired
320
334
  className={cn("text-left", className)}
@@ -435,3 +449,17 @@ export {
435
449
  DrawerFooter,
436
450
  DrawerTitle,
437
451
  };
452
+
453
+ /** Orientação opcional sob o título; condições e consequências ficam no corpo. */
454
+ export function DrawerDescription({
455
+ className,
456
+ ...props
457
+ }: React.ComponentProps<"p">) {
458
+ return (
459
+ <p
460
+ data-slot="drawer-description"
461
+ className={cn("mt-1", surfaceDescriptionClasses("drawer"), className)}
462
+ {...props}
463
+ />
464
+ );
465
+ }
@@ -28,7 +28,7 @@ a mesma.
28
28
  | ------------------- | ---------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
29
  | `title` | `string` | | Título do cabeçalho. Sem ele e sem `description`, o card começa diretamente pelo corpo. |
30
30
  | `titleLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `3` | Nível do heading do título. Muda a hierarquia do documento, não a escala visual. |
31
- | `description` | `string` | | Texto de apoio abaixo do título. |
31
+ | `description` | `string` | | Orientação opcional sobre a finalidade. Regras e consequências ficam junto dos campos ou no corpo. |
32
32
  | `cardClassName` | `string` | | Classes aplicadas à superfície do card. |
33
33
  | `className` | `string` | | Classes aplicadas ao `<form>` interno. |
34
34
  | Demais propriedades | Propriedades de `ActionForm` | | `action`, `defaultValues`, `fieldOptions`, `submitLabel`, `cancelLabel`, `cancelVariant`, `onCancel`, `onSuccess`, `disabled`, `onLoadingChange` e `children` mantêm o comportamento de `ActionForm`. `body` e `footer` não são aceitas: o card fornece o corpo e o rodapé. |
@@ -35,12 +35,20 @@ salvo escolha explícita em `cancelVariant`.
35
35
 
36
36
  Para consultar uma coleção no mesmo formato de modal, use [`ActionListDialog`](/ui/action-list-dialog).
37
37
 
38
+ ## Orientação e contexto
39
+
40
+ `description` apresenta a finalidade no cabeçalho, como “Escolha onde a pasta e suas subpastas
41
+ aparecem.”. É opcional; não repita o título. `intro` apresenta no corpo informações necessárias
42
+ à decisão que não pertencem a um campo específico. Regras, condições e consequências não ficam
43
+ somente no subheading. Quando ambos são fornecidos, ambos participam de `aria-describedby`.
44
+
38
45
  ## Propriedades de ActionFormDialog
39
46
 
40
47
  | Propriedade | Tipo | Padrão | Descrição |
41
48
  | --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
49
  | `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e, quando não há `onCancel` próprio, ao cancelar. |
43
50
  | `title` | `string` | | Operação e recurso exibidos no cabeçalho, como `Criar workspace` ou `Editar workspace`. |
51
+ | `description` | `ReactNode` | | Orientação opcional sob o título; não recebe regras de negócio. |
44
52
  | `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
45
53
  | `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal. Preserve o padrão em criação e edição comuns; sobrescreva apenas quando a operação tiver outro efeito, como `Renomear` ou `Criar nova versão`. |
46
54
  | `footerDistribution` | `'content' \| 'equal'` | `'content'` | Mantém a largura das ações pelo conteúdo ou divide a faixa igualmente. |
@@ -84,12 +84,21 @@ do mesmo peso visual. Nesse caso, o cancelamento muda para `outline`, salvo esco
84
84
  `cancelVariant`. Para apresentar o formulário como uma seção delimitada da página, use
85
85
  [`ActionFormCard`](/ui/action-form-card). As propriedades de cada wrapper estão na página dele.
86
86
 
87
- ## Ajuda no rótulo
87
+ ## Hint visível ou ícone de help
88
88
 
89
- `FieldSpec.help` aparece como um ícone junto ao rótulo, com o texto em um tooltip. Escreva ali o
90
- critério, o efeito ou a limitação que o rótulo não diz; ajuda que só repete o rótulo deve ser omitida.
91
- Não existe texto auxiliar sob o campo: quem precisa de uma frase permanente abaixo do controle usa
92
- `FieldDescription` na composição própria.
89
+ Use `FieldSpec.hint` abaixo do controle quando a pessoa precisar da informação para preencher
90
+ corretamente ou prever o efeito da escolha. Exemplo: “Sem áreas selecionadas, a pasta aparece em
91
+ todas as áreas.” O texto permanece visível e participa do `aria-describedby` do controle, junto
92
+ do erro quando houver. Em uma composição própria, use `FieldDescription` com a mesma associação.
93
+
94
+ Use `FieldSpec.help` no ícone junto à label para detalhes complementares, definições e exemplos
95
+ opcionais. A pergunta é: sem abrir essa ajuda, a pessoa ainda consegue escolher corretamente?
96
+ Se não, o trecho necessário deve ser hint. Não use o comprimento da frase como critério nem
97
+ esconda uma condição obrigatória no tooltip. Omita texto que apenas repete a label.
98
+
99
+ Hint e help podem coexistir se tiverem funções diferentes, sem duplicar a mesma frase. Um erro
100
+ explica uma entrada inválida e sua correção; não substitui uma orientação necessária antes de
101
+ preencher. Uma regra que afeta vários campos pertence ao corpo ou ao grupo correspondente.
93
102
 
94
103
  `LabelHelp` é esse mesmo ícone, exportado para um formulário sem contrato mostrar a ajuda no mesmo
95
104
  lugar:
@@ -185,3 +194,7 @@ ciclo de vida que o pattern não cobre; validação e execução continuam iguai
185
194
  | Propriedade | Tipo | Descrição |
186
195
  | ----------- | -------- | ------------------------------------------------------------ |
187
196
  | `help` | `string` | O texto do tooltip. Vazio ou ausente, o ícone não renderiza. |
197
+
198
+ O `hint` aceita texto ou `I18nRef` e permanece visível; o contexto do formulário entrega o texto
199
+ resolvido aos campos personalizados. O botão de `help` participa do Tab e abre por foco ou hover.
200
+ Dialog e Drawer evitam que a ajuda seja o primeiro foco automático quando há outro controle.
@@ -96,6 +96,13 @@ const roleOptions = roles.items.map((role) => ({ value: role.id, label: role.nam
96
96
  </ActionListDialog>;
97
97
  ```
98
98
 
99
+ ## Orientação e contexto
100
+
101
+ `description` apresenta a finalidade no cabeçalho, como “Escolha onde a pasta e suas subpastas
102
+ aparecem.”. É opcional; não repita o título. `intro` apresenta no corpo informações necessárias
103
+ à decisão que não pertencem a um campo específico. Regras, condições e consequências não ficam
104
+ somente no subheading. Quando ambos são fornecidos, ambos participam de `aria-describedby`.
105
+
99
106
  ## Propriedades de ActionListDialog
100
107
 
101
108
  | Propriedade | Tipo | Padrão | Descrição |
@@ -103,6 +110,7 @@ const roleOptions = roles.items.map((role) => ({ value: role.id, label: role.nam
103
110
  | `action / input` | `ListAction / TInput` | `input`: `{}` | O contrato e o escopo base da consulta. Uma mudança em `input` refaz a consulta; as invalidações declaradas por forms e triggers também. |
104
111
  | `open / onOpenChange` | `boolean / (open) => void` | | Estado controlado do modal. |
105
112
  | `title` | `string` | | Nome da lista exibido no cabeçalho fixo. |
113
+ | `description` | `ReactNode` | | Orientação opcional sob o título; não recebe regras de negócio. |
106
114
  | `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
107
115
  | `children` | `(items, refetch) => ReactNode` | | A composição dos itens. Carregamento, erro e vazio já são tratados pelo `ActionList`. |
108
116
  | `note` | `ReactNode` | | Contexto curto à esquerda da linha de ações. O total de itens já aparece no rodapé do `ActionList`. |
@@ -150,8 +150,21 @@ Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem)
150
150
  [`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
151
151
  quadrado `icon-sm` (1.75rem) por padrão.
152
152
 
153
- O cabeçalho não recebe descrição: um modal já é a superfície de uma tarefa, e o contexto que ajuda a
154
- decidir aparece no começo do corpo, como texto ou `Alert`.
153
+ O cabeçalho aceita `DialogDescription` como subheading opcional para orientar sobre a finalidade.
154
+ Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
155
+ ficam junto dos controles ou em `DialogBody`. Uma frase curta também pode ser regra: o critério é se
156
+ pular o texto compromete a decisão, não sua extensão.
157
+
158
+ ```tsx
159
+ <DialogHeader>
160
+ <DialogTitle>Organizar pasta</DialogTitle>
161
+ <DialogDescription>Escolha onde a pasta e suas subpastas aparecem.</DialogDescription>
162
+ </DialogHeader>
163
+ ```
164
+
165
+ Na composição direta, associe o `id` do texto por `aria-describedby` em `DialogContent` quando ele
166
+ descrever a superfície para tecnologias assistivas. A regra sobre seleção vazia pertence ao
167
+ campo de áreas e permanece fora do subheading.
155
168
 
156
169
  ## Identificar a tarefa com um ícone
157
170
 
@@ -634,3 +647,12 @@ conteúdo no primeiro bloco de `DialogBody`. `DialogIntro` exige um `DialogTitle
634
647
 
635
648
  No formato compacto, mantenha `DialogTitle` e `DialogActions` no próprio `DialogHeader`. Para
636
649
  comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions).
650
+
651
+ ## Propriedades de DialogDescription
652
+
653
+ | Propriedade | Tipo | Padrão | Descrição |
654
+ | --- | --- | --- | --- |
655
+ | `children` | `ReactNode` | | Orientação opcional sob o título em `DialogHeader` ou `DialogIntro`. |
656
+ | `id` | `string` | | Identificador para associação acessível explícita. |
657
+ | `className` | `string` | | Ajusta a apresentação do parágrafo. |
658
+ | `…props` | `ComponentProps<"p">` | | Atributos nativos. |
@@ -148,8 +148,21 @@ Voltar e ações do cabeçalho usam `Button` `ghost` no tamanho `icon` (2.25rem)
148
148
  [`Presentation`](/ui/presentation) usa no voltar. O botão de fechar é menor: um `CloseButton` no
149
149
  quadrado `icon-sm` (1.75rem) por padrão.
150
150
 
151
- O cabeçalho não recebe descrição: o painel já nomeia o recurso, e o contexto que ajuda a decidir
152
- aparece no começo do corpo, como texto ou `Alert`.
151
+ O cabeçalho aceita `DrawerDescription` como subheading opcional para orientar sobre a finalidade.
152
+ Omita quando repetir o título. Condições, regras de negócio e consequências necessárias à decisão
153
+ ficam junto dos controles ou em `DrawerBody`. Uma frase curta também pode ser regra: o critério é se
154
+ pular o texto compromete a decisão, não sua extensão.
155
+
156
+ ```tsx
157
+ <DrawerHeader>
158
+ <DrawerTitle>Organizar pasta</DrawerTitle>
159
+ <DrawerDescription>Escolha onde a pasta e suas subpastas aparecem.</DrawerDescription>
160
+ </DrawerHeader>
161
+ ```
162
+
163
+ Na composição direta, associe o `id` do texto por `aria-describedby` em `DrawerContent` quando ele
164
+ descrever a superfície para tecnologias assistivas. A regra sobre seleção vazia pertence ao
165
+ campo de áreas e permanece fora do subheading.
153
166
 
154
167
  ## Impedir o fechamento durante uma operação
155
168
 
@@ -301,3 +314,12 @@ Assim o título acompanha o conteúdo rolável enquanto o chrome permanece está
301
314
 
302
315
  No formato compacto, mantenha `DrawerTitle` e `DrawerActions` no próprio `DrawerHeader`. Para
303
316
  comparar as duas formas em cada `layout`, abra [Composições](/ui/compositions).
317
+
318
+ ## Propriedades de DrawerDescription
319
+
320
+ | Propriedade | Tipo | Padrão | Descrição |
321
+ | --- | --- | --- | --- |
322
+ | `children` | `ReactNode` | | Orientação opcional sob o título em `DrawerHeader` ou `DrawerIntro`. |
323
+ | `id` | `string` | | Identificador para associação acessível explícita. |
324
+ | `className` | `string` | | Ajusta a apresentação do parágrafo. |
325
+ | `…props` | `ComponentProps<"p">` | | Atributos nativos. |
@@ -1,7 +1,7 @@
1
1
  ## Esqueleto de página
2
2
 
3
3
  Use `Page` para manter cabeçalho, estado e conteúdo na mesma anatomia. `PageHeader` é a única
4
- introdução estrutural: reúne navegação contextual opcional, título e ações. O container é
4
+ introdução estrutural: reúne navegação contextual opcional, título, orientação opcional e ações. O container é
5
5
  centralizado e ocupa a largura disponível até `80rem` (`max-w-7xl`). Use `className` somente quando
6
6
  a composição pedir outro teto ou largura total.
7
7
 
@@ -41,6 +41,19 @@ render(
41
41
  );
42
42
  ```
43
43
 
44
+ ## Orientar sobre a finalidade
45
+
46
+ Use `description` na forma curta ou `PageDescription` em `PageHeader` quando uma frase breve
47
+ ajudar a reconhecer a finalidade da página. Omita texto que só repita o título. Regras de negócio,
48
+ limites e consequências ficam nos campos, ações ou corpo em que são necessários. Pular o
49
+ subheading não deve comprometer a decisão nem a conclusão correta da tarefa.
50
+
51
+ ```tsx
52
+ <Page title="Conhecimento" description="Consulte os materiais de apoio da sua área.">
53
+ <KnowledgeList />
54
+ </Page>
55
+ ```
56
+
44
57
  ## Retorno para a página pai
45
58
 
46
59
  Use `PageBack` em uma subpágina simples. O destino é explícito para continuar correto após refresh ou
@@ -311,6 +324,7 @@ estado na própria seção com [`DataState`](/ui/data-state), [`ActionView`](/ui
311
324
 
312
325
  | Propriedade | Tipo | Padrão | Descrição |
313
326
  | ----------- | -------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
327
+ | `description` | `ReactNode` | | Orientação opcional sob o título na forma curta. |
314
328
  | `title` | `ReactNode` | | O h1 da página. |
315
329
  | `actions` | `ReactNode` | | Ações contextuais no extremo oposto do cabeçalho. |
316
330
  | `layout` | `'flow' \| 'fill'` | `'flow'` | Ocupação de altura, não anatomia: `fill` limita a página ao shell para que uma região interna role e leva essa altura até uma `ActionList` composta em `PageBody`. |
@@ -414,3 +428,11 @@ ocupa a altura.
414
428
  | `retryLabel` | `string` | `'Tentar de novo'` | Texto do botão de recuperação. |
415
429
  | `children` | `ReactNode` | | Conteúdo renderizado somente em `ready`. |
416
430
  | `className` | `string` | | Classes da superfície de estado. |
431
+
432
+ ## Propriedades de PageDescription
433
+
434
+ | Propriedade | Tipo | Padrão | Descrição |
435
+ | --- | --- | --- | --- |
436
+ | `children` | `ReactNode` | | Orientação breve e opcional; filho direto de `PageHeader`. |
437
+ | `className` | `string` | | Ajusta a apresentação do parágrafo. |
438
+ | `…props` | `HTMLAttributes<HTMLParagraphElement>` | | Atributos nativos. |
@@ -131,12 +131,11 @@ render(
131
131
  as="section"
132
132
  framed
133
133
  title="Plano atual"
134
- description="Renova em 12 de maio."
135
134
  level={3}
136
135
  >
137
136
  <SurfaceBody>
138
137
  <p className="text-sm text-muted-foreground">
139
- Até 10 workspaces e 50 agentes.
138
+ Renova em 12 de maio. Até 10 workspaces e 50 agentes.
140
139
  </p>
141
140
  </SurfaceBody>
142
141
  <SurfaceFooter>
@@ -511,3 +510,10 @@ um wrapper filho declara relações locais mais compactas.
511
510
  | ----------- | ----------- | ------ | ---------------------------------------------------- |
512
511
  | `children` | `ReactNode` | | Ações persistentes, alinhadas no fim da linha. |
513
512
  | `className` | `string` | | Ajusta o rodapé, inclusive a distribuição das ações. |
513
+
514
+ ## Subheading e regras de negócio
515
+
516
+ `description` e `SurfaceDescription` apresentam brevemente a finalidade da região. A orientação
517
+ é opcional e não repete o título. A mesma regra vale para uma página (`level={1}`), uma seção ou
518
+ um card: condições, limites e consequências necessários à decisão ficam no corpo ou junto do
519
+ controle correspondente. Pular o subheading não deve comprometer a tarefa.
@@ -143,6 +143,21 @@ function syncHosts(): void {
143
143
  }
144
144
  }
145
145
 
146
+ /** A ajuda permanece no Tab, mas não toma o foco inicial do campo ao abrir um modal. */
147
+ export function skipHelpOnAutoFocus(event: Event): void {
148
+ if (event.defaultPrevented || !(event.target instanceof HTMLElement)) return;
149
+ const candidates = tabbables(event.target).filter(
150
+ (element) => element.tagName !== "A",
151
+ );
152
+ if (candidates[0]?.dataset.slot !== "label-help") return;
153
+ const control = candidates.find(
154
+ (element) => element.dataset.slot !== "label-help",
155
+ );
156
+ if (control === undefined) return;
157
+ control.focus();
158
+ if (document.activeElement === control) event.preventDefault();
159
+ }
160
+
146
161
  /** Elementos alcançáveis pelo Tab dentro de `root`, na ordem do documento. */
147
162
  function tabbables(root: HTMLElement): HTMLElement[] {
148
163
  const nodes: HTMLElement[] = [];
@@ -178,7 +193,10 @@ function tabbables(root: HTMLElement): HTMLElement[] {
178
193
  function focusFromKeyboard(element: HTMLElement): void {
179
194
  element.focus();
180
195
  // O Tab do navegador seleciona o texto do campo que recebe o foco; a passagem manual imita.
181
- if (element instanceof HTMLInputElement && typeof element.select === "function") {
196
+ if (
197
+ element instanceof HTMLInputElement &&
198
+ typeof element.select === "function"
199
+ ) {
182
200
  element.select();
183
201
  }
184
202
  }
@@ -342,7 +360,10 @@ export function registerFloatingLayer(
342
360
  // podem tratar o Tab e marcá-lo com `preventDefault`.
343
361
  const onKeyDown = (event: KeyboardEvent): void => {
344
362
  if (!isTabKey(event) || event.defaultPrevented) return;
345
- if (!layerExemptionsActive() || layerOf(document.activeElement) !== record) {
363
+ if (
364
+ !layerExemptionsActive() ||
365
+ layerOf(document.activeElement) !== record
366
+ ) {
346
367
  return;
347
368
  }
348
369
  const candidates = tabbables(host);
@@ -361,7 +382,10 @@ export function registerFloatingLayer(
361
382
  if (layerWherePersonIs() === record) escape()?.(event);
362
383
  };
363
384
  const observer = new MutationObserver(() => {
364
- if (pointerAnchor !== null && pointerAnchor.parentNode !== pointerLayer?.host) {
385
+ if (
386
+ pointerAnchor !== null &&
387
+ pointerAnchor.parentNode !== pointerLayer?.host
388
+ ) {
365
389
  // O painel apertado saiu da camada, como quando é fechado por código e vira o FAB.
366
390
  pointerAnchor = null;
367
391
  pointerLayer = null;
package/src/ui/react.tsx CHANGED
@@ -173,6 +173,7 @@ export {
173
173
  DialogBody,
174
174
  DialogFooter,
175
175
  DialogTitle,
176
+ DialogDescription,
176
177
  } from "./components/primitives/dialog.tsx";
177
178
  export type {
178
179
  DialogProps,
@@ -326,6 +327,7 @@ export {
326
327
  DrawerNavigation,
327
328
  DrawerBody,
328
329
  DrawerTitle,
330
+ DrawerDescription,
329
331
  DrawerTrigger,
330
332
  } from "./components/primitives/drawer.tsx";
331
333
  export {
@@ -509,6 +511,7 @@ export {
509
511
  PageNavigation,
510
512
  PageBack,
511
513
  PageTitle,
514
+ PageDescription,
512
515
  PageActions,
513
516
  PageActionsTarget,
514
517
  PageBody,