@softize/opus 26.0.0 → 26.2.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 (35) hide show
  1. package/CHANGELOG.md +39 -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/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +27 -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/core/types.ts +3 -2
  14. package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
  15. package/src/ui/components/patterns/form-dialog.tsx +18 -7
  16. package/src/ui/components/patterns/form.tsx +36 -8
  17. package/src/ui/components/patterns/page.tsx +22 -0
  18. package/src/ui/components/patterns/sidebar.tsx +17 -3
  19. package/src/ui/components/patterns/surface-anatomy.ts +1 -1
  20. package/src/ui/components/primitives/dialog.tsx +28 -1
  21. package/src/ui/components/primitives/drawer.tsx +30 -2
  22. package/src/ui/docs/DocBrowser.tsx +128 -81
  23. package/src/ui/docs/content/action-form-card.md +1 -1
  24. package/src/ui/docs/content/action-form-dialog.md +8 -0
  25. package/src/ui/docs/content/action-form.md +18 -5
  26. package/src/ui/docs/content/action-list-dialog.md +8 -0
  27. package/src/ui/docs/content/dialog.md +24 -2
  28. package/src/ui/docs/content/documentation-portal.md +45 -2
  29. package/src/ui/docs/content/drawer.md +24 -2
  30. package/src/ui/docs/content/page.md +23 -1
  31. package/src/ui/docs/content/sidebar.md +23 -0
  32. package/src/ui/docs/content/surface.md +8 -2
  33. package/src/ui/docs/types.ts +2 -0
  34. package/src/ui/lib/modal-layers.ts +27 -3
  35. package/src/ui/react.tsx +3 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ 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.2.0 — 2026-10-09
11
+
12
+ Portais podem organizar capítulos em subgrupos e abrir ferramentas relacionadas sem substituir
13
+ a página em leitura.
14
+
15
+ - `DocGroup.groups` declara subgrupos opcionais. Navegação, busca, URLs diretas e sequência
16
+ editorial percorrem todos os níveis, com as páginas próprias antes dos subgrupos. A busca
17
+ preserva os ancestrais dos resultados; no modo inicialmente recolhido, restaura o estado
18
+ manual ao limpar. No modo expandido, os ancestrais da página ativa são reabertos. Ramos
19
+ vazios não aparecem, sem sugerir outro termo quando não há consulta.
20
+ - `DocBrowser.sidebarLinks` preserva a navegação nativa para URLs HTTP(S) absolutas.
21
+ - `SidebarItem` e `DocBrowser.sidebarLinks` aceitam `newTab`: o link abre em outra aba com
22
+ `noopener noreferrer` e informa a abertura no nome acessível. No portal, também exibe
23
+ um ícone de nova aba. A opção não intercepta a navegação nem chama `onClick`.
24
+
25
+ As novas propriedades são opcionais. Consumidores sem subgrupos ou `newTab` mantêm seus
26
+ contratos; não há migração obrigatória nesta versão.
27
+
28
+ ## 26.1.0 — 2026-10-09
29
+
30
+ Subheadings descrevem a finalidade da superfície; orientações necessárias ficam visíveis no corpo
31
+ ou junto ao campo (ADR 0059).
32
+
33
+ - `Page` aceita `description`. `PageDescription`, `DialogDescription` e `DrawerDescription`
34
+ compõem o cabeçalho ou a introdução declarada da superfície.
35
+ - `ActionFormDialog` e `ActionListDialog` recebem `description` para o cabeçalho. `intro` continua
36
+ no corpo, para regras e consequências da tarefa; ambos participam da descrição acessível.
37
+ - `FieldSpec.hint` declara orientação visível abaixo do controle. `ActionFormField` associa hint
38
+ e erro por `aria-describedby`; `help` continua sendo a ajuda opcional junto à label.
39
+ - Os textos dos campos resolvem `I18nRef` pelo texto padrão também nos renderizadores próprios.
40
+ O ícone de ajuda participa do Tab e abre por foco, sem tomar o foco inicial de outro controle
41
+ em Dialog e Drawer.
42
+ - O inventário de copy, o validador estrutural, a skill de UI e os exemplos da documentação
43
+ reconhecem esses papéis sem proibir subheadings de maneira indiscriminada.
44
+
45
+ **O que fazer:** rode `opus setup` e `opus copy`; revise a posição das orientações existentes.
46
+ Renderizadores próprios de campos devem apresentar `hint` e associá-lo ao controle, preservando
47
+ os erros. Nenhum texto migra automaticamente de `help` para `hint`.
48
+
10
49
  ## 26.0.0 — 2026-10-09
11
50
 
12
51
  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
@@ -39,6 +39,19 @@ de ADRs ou versões.
39
39
 
40
40
  ### Hierarquia e leitura
41
41
 
42
+ > **Emenda — 2026-10-08, protótipo local:** `DocGroup.groups` permite subgrupos
43
+ > opcionais. Um assunto como Integrações pode pertencer a Domínios e ainda reunir
44
+ > páginas por sistema. A navegação, a busca, a seleção direta e a sequência de
45
+ > leitura percorrem os mesmos grupos aninhados. Páginas próprias aparecem antes
46
+ > dos subgrupos; ramos sem resultados somem da busca, sem perder a abertura manual.
47
+ > O contrato anterior continua válido quando `groups` é omitido. Uma árvore paralela
48
+ > no host e rótulos artificiais para simular profundidade foram descartados por
49
+ > duplicar comportamento ou perder a hierarquia. Testes de links diretos, busca,
50
+ > grupos vazios, rótulos repetidos e continuidade verificam a extensão.
51
+
52
+ > **Consolidação — 2026-10-09:** a extensão de subgrupos compõe o candidato da versão
53
+ > 26.2.0, sobre a base 26.1.0. Publicação e adoção permanecem etapas separadas.
54
+
42
55
  `sidebarSize` configura a largura inicial usando o `PaneSize` do `Split`. A omissão preserva
43
56
  `14rem`. `sidebarInitiallyCollapsed` permite começar com seções e grupos nomeados recolhidos,
44
57
  inclusive quando a pessoa abre uma página por URL direta. A busca revela os resultados
@@ -52,6 +65,16 @@ Esses destinos usam `SidebarItem.href`, não uma cópia do estilo da linha. A ex
52
65
  botão quando `href` é omitido, o foco nos dois modos e a abertura nativa em outra aba. Quando há
53
66
  `onClick`, somente a navegação simples é interceptada; o host mantém a decisão de roteamento.
54
67
 
68
+ > **Emenda — 2026-10-09:** destinos HTTP(S) absolutos em `sidebarLinks` usam a navegação
69
+ > nativa, pois podem apontar para outra aplicação. `newTab` é opcional em `SidebarItem`
70
+ > e `sidebarLinks`: com `href`, declara `target="_blank"`, `rel="noopener noreferrer"`
71
+ > e a abertura no nome acessível, sem chamar `onClick`. Sem `href`, não muda o botão.
72
+ > O portal acrescenta o ícone pelo slot `badge` do item canônico. Interceptar esses
73
+ > destinos no roteador ou copiar a linha no host foi descartado: o primeiro impede
74
+ > a navegação entre aplicações e o segundo duplica foco, acessibilidade e composição.
75
+ > Testes verificam links internos e absolutos, modificadores, nome acessível, ícone
76
+ > nos dois modos iniciais do portal e comportamento do item aberto ou recolhido.
77
+
55
78
  O modo opcional `editorial` acrescenta contexto, sumário dos headings realmente renderizados e
56
79
  links de continuidade segundo a ordem declarada. `additionalPages` preserva URLs de páginas fora
57
80
  da árvore e da sequência. O renderer de exemplos aceita `codeCollapsible={false}` quando ocultar
@@ -143,6 +166,10 @@ e separa a consulta documental da consulta local.
143
166
 
144
167
  - Testes de `DocBrowser`: busca local e externa, vazio, largura configurável, lateral opcional,
145
168
  persistência de grupos, expansão temporária, conteúdo do host, redirects e leitura editorial.
169
+ - Regressões da extensão: espaçamento dos grupos sem rótulo em diferentes níveis, acervo vazio
170
+ sem consulta, modificadores em links absolutos e ícone de nova aba nos dois modos iniciais.
171
+ - Testes de `SidebarItem`: nova aba com modificadores, botão habilitado sem `href`, estado
172
+ desabilitado e preservação do badge textual no nome acessível do item aberto.
146
173
  - Testes de `DocHeader`: slots, atributos nativos e composição com ou sem busca e navegação.
147
174
  - Testes de `DocCatalog`: busca local controlada, independência do topo, ausência de controles de
148
175
  coleção, texto integral das versões, incompatibilidades e abertura nativa em outra aba.
@@ -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.2.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