@softize/opus 15.2.2 → 16.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 (37) hide show
  1. package/CHANGELOG.md +60 -18
  2. package/bin/lib/check.mjs +98 -15
  3. package/bin/lib/copy.mjs +811 -314
  4. package/bin/lib/gen-manifest.mjs +24 -23
  5. package/bin/lib/gen-runner.mjs +188 -148
  6. package/docs/adr/0010-page-header-owns-page-chrome.md +3 -4
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -3
  8. package/docs/adr/0012-modal-header-only-names-the-surface.md +45 -0
  9. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +92 -0
  10. package/docs/code-style.md +24 -19
  11. package/package.json +5 -1
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +43 -26
  13. package/src/core/presentation.ts +512 -0
  14. package/src/presentation/index.ts +1 -0
  15. package/src/ui/components/patterns/action-list-dialog.tsx +26 -9
  16. package/src/ui/components/patterns/confirm.tsx +34 -29
  17. package/src/ui/components/patterns/form-dialog.tsx +20 -8
  18. package/src/ui/components/patterns/list.tsx +26 -9
  19. package/src/ui/components/patterns/page.tsx +99 -139
  20. package/src/ui/components/patterns/presentation.tsx +539 -0
  21. package/src/ui/components/patterns/sidebar.tsx +3 -3
  22. package/src/ui/components/patterns/trigger.tsx +38 -17
  23. package/src/ui/components/primitives/button-group.tsx +53 -43
  24. package/src/ui/components/primitives/command.tsx +30 -72
  25. package/src/ui/components/primitives/dialog.tsx +23 -89
  26. package/src/ui/components/primitives/drawer.tsx +8 -34
  27. package/src/ui/docs/content/action-form-dialog.md +12 -10
  28. package/src/ui/docs/content/action-list-dialog.md +22 -17
  29. package/src/ui/docs/content/button.md +52 -35
  30. package/src/ui/docs/content/communication.md +26 -26
  31. package/src/ui/docs/content/dialog.md +173 -154
  32. package/src/ui/docs/content/drawer.md +12 -11
  33. package/src/ui/docs/content/page.md +72 -91
  34. package/src/ui/docs/content/presentation.md +188 -0
  35. package/src/ui/docs/registry.tsx +6 -0
  36. package/src/ui/meta.ts +8 -2
  37. package/src/ui/react.tsx +16 -3
package/CHANGELOG.md CHANGED
@@ -7,15 +7,54 @@ 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
+ ## 16.1.0 — 2026-09-11
11
+
12
+ `PresentationDevtoolsProvider`, `usePresentationRegistration` e `PresentationDevtools` permitem
13
+ que o shell reúna as Presentations ativas em um único launcher de desenvolvimento. Cada recurso
14
+ registra sua definição e o estado vivo da invocação; a pessoa escolhe qual snapshot inspecionar no
15
+ menu. O JSON continua mascarando dados sensíveis e só é materializado depois da escolha, evitando
16
+ serialização desnecessária durante a renderização. O consumidor deve montar o provider, os registros
17
+ e o launcher apenas em ambiente de desenvolvimento.
18
+
19
+ ## 16.0.0 — 2026-09-11
20
+
21
+ Opus 16 introduz `Presentation` como artefato público declarativo, disponível também pelo subpath
22
+ `@softize/opus/presentation`, e sua composição React portátil entre Page, Dialog e Drawer. A
23
+ `PresentationInvocation` guarda o identificador do recurso, a surface, input estritamente JSON e a
24
+ pilha de frames usada por `back`; `openPresentation`, `backPresentation`, `closePresentation` e
25
+ `applyPresentationEffects` oferecem transições puras para o renderer. O efeito `navigate` declara
26
+ input por bindings e escolhe entre `push` e `replace`.
27
+
28
+ `PresentationInspector` oferece, sob demanda, um snapshot único da definição, invocação, bindings
29
+ resolvidos e diagnósticos. Chaves que indicam senha, segredo, token, cookie, autorização ou chave de
30
+ API são mascaradas antes de o JSON aparecer ou ser copiado. A Lens continua expondo somente a
31
+ definição estática projetada no manifest.
32
+
33
+ `PageFooter` entra na composição explícita de Page. `PageHeader` organiza retorno ou breadcrumb,
34
+ título e ações na mesma linha, acompanhando a anatomia usada pela Presentation; `PageIntro` também
35
+ aceita a navegação própria do recurso quando existe um `PageShell`. `PageBack` vira um controle
36
+ somente com ícone; o nome do destino continua no nome acessível e no tooltip. `ButtonGroup` ganha a
37
+ distribuição `equal` para ações compactas de mesma largura. A Sidebar recolhida passa a `4rem`, com
38
+ itens de `2.5rem` e respiro de `0.75rem`.
39
+
40
+ **Breaking:** cabeçalhos de Dialog e Drawer passam a nomear apenas a superfície. Remova
41
+ `DialogDescription` e `DrawerDescription` e leve explicações para o body ou para o `help` do campo
42
+ correspondente. Nos wrappers `ActionFormDialog` e `ActionListDialog`, remova `description` pelo
43
+ mesmo motivo. Nas APIs imperativas `alert`, `confirm`, `prompt` e `choose`, e em
44
+ `ActionTrigger.confirm`, renomeie `description` para `body`. `PageHeader.variant` e o tipo
45
+ `PageHeaderVariant` deixam de existir; use `PageShell` para a barra persistente e `PageIntro` para
46
+ a introdução do conteúdo.
47
+
48
+
10
49
  ## 15.2.2 — 2026-09-11
11
50
 
12
51
  O acesso descritivo de Produtos de Dados usa `permissionContexts` no lugar do nome genérico
13
- `contexts`. A qualificação evita confundir contextos do framework, contexto de tela, domínio de
52
+ `contexts`. A qualificação evita confundir contexto do framework, contexto de tela, domínio de
14
53
  dados e vocabulário de autorização nas projeções do manifest e da Lens.
15
54
 
16
55
  **Migração recomendada:** em declarações `defineDataProduct`, renomeie `access.contexts` para
17
56
  `access.permissionContexts` e regenere o manifest. O nome anterior continua aceito e projetado em
18
- toda a série 15.x como alias deprecated; consumidores novos devem ler somente o nome qualificado.
57
+ toda a série 15.x como alias obsoleto; consumidores novos devem ler somente o nome qualificado.
19
58
 
20
59
  ## 15.2.1 — 2026-09-11
21
60
 
@@ -214,7 +253,7 @@ resizable e slider passam a ejetados no `registry.lock.json`.
214
253
  Descontinuados, com aviso no editor e sem mudança de comportamento: as variantes legadas
215
254
  (`default`, `secondary`, `destructive`, `success`, `warning`, `info`), `confirm()`, `ConfirmHost`,
216
255
  `FieldSpec.hint` (use `help`), `ShellNav*` (use `SidebarNav`) e `TbdlibProvider`, renomeado para
217
- `OpusProvider`. `'danger'` deixa de ser aceito como *variante* legada de Badge e Alert no tipo (era
256
+ `OpusProvider`. `'danger'` deixa de ser aceito como _variante_ legada de Badge e Alert no tipo (era
218
257
  um contexto disfarçado): declare `context="danger"`. O botão de confirmar do `ActionTrigger` passa a
219
258
  ser sempre `solid` — `danger` quando a action é `destructive`, senão o `context` do gatilho — e não
220
259
  herda mais o `variant` do gatilho; a permissão negada (`authorization`/`authentication`) entra na
@@ -454,6 +493,7 @@ Os patterns existentes passam a reutilizar os primitives canônicos: `ActionForm
454
493
  `FieldGroup`, descrições e erros associados ao controle; `ActionList` usa a moldura de `Table` e
455
494
  os estados seguros de `DataState`, preservando a tentativa de recuperação; e a confirmação de uma
456
495
  `ActionTrigger` destrutiva mantém o tratamento destrutivo mesmo quando o gatilho visual é discreto.
496
+
457
497
  ## 12.5.5 — 2026-08-25
458
498
 
459
499
  Só documentação: leva ao pacote a correção de nota que a 12.5.4 recebeu no repositório depois de
@@ -800,6 +840,7 @@ todas as mensagens que apenas têm comportamento `sticky` disponível.
800
840
  fronteira memoizada separada do estado do composer, e cada bloco Markdown reaproveita o
801
841
  resultado enquanto seu conteúdo não muda. Conversas longas mantêm o custo de digitação
802
842
  constante; durante streaming, somente a mensagem alterada volta a passar pelo parser.
843
+
803
844
  ## 9.0.3 — 2026-08-12
804
845
 
805
846
  **O pre-push ignora worktrees internos do Maestro.** Projetos sob `.maestro/` pertencem
@@ -1033,11 +1074,11 @@ de backend de pé — nem, pior, falar com prod por um proxy esquecido no vite.c
1033
1074
  `vite --mode design`, o vite.config traz `opusDesign({ devBackend: true })` (dev normal
1034
1075
  serve o handler real via /api; design roteia pro mock) e o domínio-exemplo mostra o
1035
1076
  split canônico: `defineContract` com `mockHandler` alimentado pelo schema (`fakeMany`)
1036
- + `bindAction` com o handler in-memory — o App consome o CONTRATO via `useListAction`
1037
- (fim do "chama o handler direto, sem servidor ainda"), e o teste cobre o bound E o
1038
- contrato puro em design. O plugin também marca `@softize/opus` como `ssr.noExternal`:
1039
- o pacote embarca TS source e, externalizado, o node recusa TS sob node_modules no
1040
- load SSR do entry.
1077
+ - `bindAction` com o handler in-memory — o App consome o CONTRATO via `useListAction`
1078
+ (fim do "chama o handler direto, sem servidor ainda"), e o teste cobre o bound E o
1079
+ contrato puro em design. O plugin também marca `@softize/opus` como `ssr.noExternal`:
1080
+ o pacote embarca TS source e, externalizado, o node recusa TS sob node_modules no
1081
+ load SSR do entry.
1041
1082
  - **`options: { kind: 'dictionary', ref }` agora resolve.** O `TbdlibProvider` ganha
1042
1083
  `dicts` (`Record<ref, DictLike>` — o `DictType` do `t.dict` encaixa direto) e a UI
1043
1084
  resolve a origem declarada no contrato: no `ActionList`, filtro e chips mostram os
@@ -1292,7 +1333,7 @@ todos. Elemento novo (table, figure, dl) entra sozinho.
1292
1333
 
1293
1334
  **O respiro fantasma nas mensagens.** As bolhas do `Chat` tentavam zerar a margem do
1294
1335
  primeiro e do último bloco com `[&>*:first-child]:mt-0`, mas o primeiro filho da bolha é o
1295
- *wrapper* do `prose` — que não tem margem; quem tem é o `<p>` um nível abaixo. A
1336
+ _wrapper_ do `prose` — que não tem margem; quem tem é o `<p>` um nível abaixo. A
1296
1337
  compensação nunca surtiu efeito, e cada mensagem carregava ~16px invisíveis em cima e
1297
1338
  embaixo. Agora mora dentro do `Markdown`, onde alcança o bloco certo.
1298
1339
 
@@ -1644,7 +1685,7 @@ ver a nota de migração do t.json()).
1644
1685
  - **`readOnlyContextPool` (`@softize/opus/data/readonly-pool`).** SQL escrito por LLM
1645
1686
  com as QUATRO defesas dos ADRs 0003/0007 — todas no BANCO, nenhuma em regex: pool com
1646
1687
  teto (`max` do pool e/ou `maxConcurrent` com fila própria), `BEGIN TRANSACTION READ
1647
- ONLY`, `SET LOCAL ROLE` por transação (alcance do contexto; roles/grants
1688
+ ONLY`, `SET LOCAL ROLE` por transação (alcance do contexto; roles/grants
1648
1689
  default-fechado são migração sua) e protocolo estendido (o SQL roda sempre com array
1649
1690
  de valores — multi-sentença é recusada pelo protocolo). Saindo, `DISCARD ALL` devolve
1650
1691
  a conexão limpa; limpeza que falha DESTRÓI a conexão (nunca volta suja ao pool).
@@ -1676,7 +1717,7 @@ novos, todos aditivos).
1676
1717
  mas roda qualquer um (o hook usa name/kind e lê `invalidates`) — disparar um
1677
1718
  FormContract fora de form (modal composto que submete `role.update`) exigia
1678
1719
  `as unknown as SimpleContract<...>`. Agora o parâmetro é `ActionContract<TInput,
1679
- TData>`; o cast morre. Nenhuma chamada existente muda.
1720
+ TData>`; o cast morre. Nenhuma chamada existente muda.
1680
1721
  - **`useActionFormContext()` (novo export, `ui/react`).** A válvula de escape pra
1681
1722
  CONTROLE CUSTOM no modo composição do `<ActionForm>`: campo com UI própria (a grade
1682
1723
  de permissões que forçou abandonar o form inteiro) participa do form do contrato —
@@ -1765,9 +1806,9 @@ o guard agora moram na base, então qualquer consumidor de IA do Opus nasce prot
1765
1806
  breaking** (tipos/campos/exports novos, aditivos).
1766
1807
 
1767
1808
  - **Contrato de pergunta (novos tipos, `@softize/opus`).** `AskQuestion` (enunciado + `header`
1768
- + `options` + `multiSelect`), `AskOption` e `AskAnswer` (rótulos escolhidos + texto livre) —
1769
- a forma única que todo mundo fala. Espelha o AskUserQuestion do Claude Code, mas servida por
1770
- um round-trip REAL.
1809
+ - `options` + `multiSelect`), `AskOption` e `AskAnswer` (rótulos escolhidos + texto livre) —
1810
+ a forma única que todo mundo fala. Espelha o AskUserQuestion do Claude Code, mas servida por
1811
+ um round-trip REAL.
1771
1812
  - **`AiRunOptions.onAsk` / `BoundAiRunOptions.onAsk` (novo hook).** O round-trip de "perguntar
1772
1813
  à pessoa": com ele, o driver INTERCEPTA a tool reservada `ask_user` (injetada automaticamente
1773
1814
  no catálogo) e chama o handler, devolvendo as respostas ao modelo. Passa direto por `ctx.ai`
@@ -1859,8 +1900,8 @@ Correções da 2.35.0 (que saiu antes de passar pelo review). **Prefira esta à
1859
1900
 
1860
1901
  ## 2.35.0 — 2026-07-21
1861
1902
 
1862
- Sidebar recolhível no `AppShell` e o **rem base passa a 15px**. *(Publicada com os defeitos
1863
- que a 2.35.1 conserta — use a 2.35.1.)*
1903
+ Sidebar recolhível no `AppShell` e o **rem base passa a 15px**. _(Publicada com os defeitos
1904
+ que a 2.35.1 conserta — use a 2.35.1.)_
1864
1905
 
1865
1906
  - **Base do rem em 15px. Visual: GLOBAL — e o delta depende de onde você está.** Todo `rem`
1866
1907
  deriva daqui, então tipografia, espaçamento e tamanho de controle mudam em toda tela no
@@ -1875,9 +1916,10 @@ que a 2.35.1 conserta — use a 2.35.1.)*
1875
1916
  reincidência). Quem quiser a escala densa fixa `html { font-size: 14px }` no próprio entry.
1876
1917
  **Consumidor que já sobrescrevia pra 15px pode remover o override** — vira redundante, não
1877
1918
  conflitante (mesmo valor), então dá pra limpar sem pressa.
1878
- <br>*Correção de registro:* a entrada da 2.31.5 saiu com dois erros (chamava 15px de "a
1919
+ <br>_Correção de registro:_ a entrada da 2.31.5 saiu com dois erros (chamava 15px de "a
1879
1920
  escala anterior" e dizia "encolhe ~6,7%"), ambos por supor uma baseline de 15px que nunca
1880
1921
  existiu. Corrigidos lá, com nota.
1922
+
1881
1923
  - **`AppShell` ganha `collapsible` (opt-in).** Sem a prop, nada muda. Com ela, o shell
1882
1924
  controla a **largura** e publica o estado; **o que some é decisão do conteúdo**, porque o
1883
1925
  `sidebarNav` é do consumidor. Em vez de exigir estado no app, o aside expõe
@@ -1946,7 +1988,7 @@ para cá.**
1946
1988
  empilhava uma entrada. Era o caso pt-BR inteiro.
1947
1989
  - Some a última cópia à mão do mecanismo, que tinha sobrado dentro do próprio
1948
1990
  `registry.tsx` (o redirect de `/docs/ui` pra `/ui` — agora `navigate('/ui', { replace:
1949
- true })`, porque redirect não merece entrada no histórico).
1991
+ true })`, porque redirect não merece entrada no histórico).
1950
1992
 
1951
1993
  ## 2.34.0 — 2026-07-21
1952
1994
 
package/bin/lib/check.mjs CHANGED
@@ -107,9 +107,10 @@ const UI_STRUCTURAL_PARENTS = new Map([
107
107
  ["PageHeader", new Set(["Page"])],
108
108
  ["PageIntro", new Set(["Page"])],
109
109
  ["PageBody", new Set(["Page"])],
110
+ ["PageFooter", new Set(["Page"])],
110
111
  ["PageTitle", new Set(["PageHeader", "PageIntro"])],
111
- ["PageBack", new Set(["PageHeader"])],
112
- ["PageNavigation", new Set(["PageHeader"])],
112
+ ["PageBack", new Set(["PageHeader", "PageIntro"])],
113
+ ["PageNavigation", new Set(["PageHeader", "PageIntro"])],
113
114
  ["PageDescription", new Set(["PageHeader", "PageIntro"])],
114
115
  ["PageActions", new Set(["PageHeader", "PageIntro"])],
115
116
  ["ContentHeader", new Set(["Content"])],
@@ -169,11 +170,14 @@ const UI_STRUCTURAL_ROOTS = new Map([
169
170
  header: "PageHeader",
170
171
  alternateHeader: "PageIntro",
171
172
  body: "PageBody",
173
+ footer: "PageFooter",
172
174
  shorthand: new Set(["title", "description", "actions"]),
173
175
  // O contador saiu do título da página (ADR 0009). Tirar `count` do shorthand não proíbe
174
176
  // nada sozinho — só apaga o marcador que fazia o gate enxergar aquele Page —, então a
175
177
  // prop removida é cobrada aqui, nas duas formas de composição.
176
- removed: new Map([["count", "o total pertence ao conteúdo que o explica"]]),
178
+ removed: new Map([
179
+ ["count", "o total pertence ao conteúdo que o explica"],
180
+ ]),
177
181
  },
178
182
  ],
179
183
  [
@@ -191,6 +195,7 @@ const UI_STRICT_DIRECT_COMPONENTS = new Set([
191
195
  "PageHeader",
192
196
  "PageIntro",
193
197
  "PageBody",
198
+ "PageFooter",
194
199
  "PageTitle",
195
200
  "PageBack",
196
201
  "PageNavigation",
@@ -207,6 +212,44 @@ const UI_STRICT_DIRECT_COMPONENTS = new Set([
207
212
  "AlertActions",
208
213
  ]);
209
214
 
215
+ const UI_REMOVED_PROPS = new Map([
216
+ [
217
+ "PageHeader",
218
+ new Map([
219
+ ["variant", "use `PageShell` para a barra persistente da página"],
220
+ ]),
221
+ ],
222
+ [
223
+ "ActionFormDialog",
224
+ new Map([
225
+ [
226
+ "description",
227
+ "use `intro` somente para contexto relevante no início do corpo",
228
+ ],
229
+ ]),
230
+ ],
231
+ [
232
+ "ActionListDialog",
233
+ new Map([
234
+ [
235
+ "description",
236
+ "use `intro` somente para contexto relevante no início do corpo",
237
+ ],
238
+ ]),
239
+ ],
240
+ ]);
241
+
242
+ const UI_REMOVED_COMPONENTS = new Map([
243
+ [
244
+ "DialogDescription",
245
+ "mova contexto relevante para o início de `DialogBody`",
246
+ ],
247
+ [
248
+ "DrawerDescription",
249
+ "mova contexto relevante para o início de `DrawerBody`",
250
+ ],
251
+ ]);
252
+
210
253
  const UI_STRUCTURAL_CONTAINERS = new Set(["PageShell"]);
211
254
 
212
255
  const UI_STRUCTURAL_HEADERS = new Map([
@@ -228,7 +271,13 @@ const UI_STRUCTURAL_HEADERS = new Map([
228
271
  "PageIntro",
229
272
  {
230
273
  title: "PageTitle",
231
- optional: new Set(["PageDescription", "PageActions"]),
274
+ optional: new Set([
275
+ "PageBack",
276
+ "PageNavigation",
277
+ "PageDescription",
278
+ "PageActions",
279
+ ]),
280
+ exclusive: [new Set(["PageBack", "PageNavigation"])],
232
281
  shorthand: new Set(),
233
282
  },
234
283
  ],
@@ -295,14 +344,7 @@ const UI_LEGACY_VARIANTS = new Map([
295
344
  ],
296
345
  [
297
346
  "Alert",
298
- new Set([
299
- "default",
300
- "destructive",
301
- "info",
302
- "success",
303
- "warning",
304
- "danger",
305
- ]),
347
+ new Set(["default", "destructive", "info", "success", "warning", "danger"]),
306
348
  ],
307
349
  [
308
350
  "Dot",
@@ -969,6 +1011,16 @@ export function checkUiStructure(file, text) {
969
1011
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
970
1012
  const local = jsxLocalName(node);
971
1013
  const component = local === null ? null : imports.get(local);
1014
+ const removedComponent =
1015
+ component === null ? undefined : UI_REMOVED_COMPONENTS.get(component);
1016
+ if (component !== null && removedComponent !== undefined) {
1017
+ add(
1018
+ node,
1019
+ component,
1020
+ `${component} não faz mais parte da API pública: ${removedComponent}.`,
1021
+ "ui-structure-removed-component",
1022
+ );
1023
+ }
972
1024
  const parents =
973
1025
  component === null ? undefined : UI_STRUCTURAL_PARENTS.get(component);
974
1026
  if (parents !== undefined) {
@@ -990,6 +1042,29 @@ export function checkUiStructure(file, text) {
990
1042
  }
991
1043
  }
992
1044
 
1045
+ const removedProps =
1046
+ component === null ? undefined : UI_REMOVED_PROPS.get(component);
1047
+ if (removedProps !== undefined) {
1048
+ const opening = jsxOpening(node);
1049
+ const attributes = new Set(
1050
+ opening.attributes.properties.flatMap((attribute) =>
1051
+ ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name)
1052
+ ? [attribute.name.text]
1053
+ : [],
1054
+ ),
1055
+ );
1056
+ for (const [attribute, hint] of removedProps) {
1057
+ if (attributes.has(attribute)) {
1058
+ add(
1059
+ node,
1060
+ component,
1061
+ `${component} não aceita mais a propriedade \`${attribute}\`: ${hint}.`,
1062
+ "ui-structure-removed-prop",
1063
+ );
1064
+ }
1065
+ }
1066
+ }
1067
+
993
1068
  const header =
994
1069
  component === null ? undefined : UI_STRUCTURAL_HEADERS.get(component);
995
1070
  if (header !== undefined) {
@@ -1090,7 +1165,8 @@ export function checkUiStructure(file, text) {
1090
1165
  ).filter(Boolean);
1091
1166
  const structural =
1092
1167
  acceptedHeaders.some((header) => children.names.includes(header)) ||
1093
- children.names.includes(root.body);
1168
+ children.names.includes(root.body) ||
1169
+ (root.footer !== undefined && children.names.includes(root.footer));
1094
1170
  // A prop removida saiu do `shorthand`, então sozinha ela não marca o nó como forma curta:
1095
1171
  // ele cai no ramo explícito, que cobra header e body — uma segunda mensagem inteiramente
1096
1172
  // induzida pela prop. Sem filhos estruturais essa cobrança dispara de qualquer jeito, e
@@ -1113,19 +1189,26 @@ export function checkUiStructure(file, text) {
1113
1189
  const bodies = children.names.filter(
1114
1190
  (name) => name === root.body,
1115
1191
  ).length;
1192
+ const footers = children.names.filter(
1193
+ (name) => name === root.footer,
1194
+ ).length;
1116
1195
  const unexpected = children.names.some(
1117
- (name) => !acceptedHeaders.includes(name) && name !== root.body,
1196
+ (name) =>
1197
+ !acceptedHeaders.includes(name) &&
1198
+ name !== root.body &&
1199
+ name !== root.footer,
1118
1200
  );
1119
1201
  if (
1120
1202
  headers !== 1 ||
1121
1203
  bodies !== 1 ||
1204
+ footers > 1 ||
1122
1205
  unexpected ||
1123
1206
  children.hasOpaque
1124
1207
  ) {
1125
1208
  add(
1126
1209
  node,
1127
1210
  component,
1128
- `${component} explícito exige exatamente um ${acceptedHeaders.join(" ou ")} e um ${root.body} como filhos diretos.`,
1211
+ `${component} explícito exige exatamente um ${acceptedHeaders.join(" ou ")}, um ${root.body} e no máximo um ${root.footer ?? "footer"} como filhos diretos.`,
1129
1212
  );
1130
1213
  }
1131
1214
  }