@softize/opus 17.2.0 → 18.0.1

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 (46) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0010-page-header-owns-page-chrome.md +2 -2
  4. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
  5. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  6. package/docs/adr/0015-action-size-follows-interaction-density.md +1 -1
  7. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  8. package/package.json +1 -1
  9. package/registry/skills/build-opus-ui/SKILL.md +30 -20
  10. package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
  11. package/registry/skills/build-opus-ui/references/ui-patterns.md +30 -18
  12. package/src/core/presentation.ts +223 -24
  13. package/src/core/runtime.ts +3 -0
  14. package/src/core/types.ts +2 -0
  15. package/src/mcp/index.ts +1 -0
  16. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  17. package/src/ui/components/patterns/confirm.tsx +194 -157
  18. package/src/ui/components/patterns/content-header.tsx +17 -2
  19. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  20. package/src/ui/components/patterns/form.tsx +340 -222
  21. package/src/ui/components/patterns/list.tsx +43 -44
  22. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  23. package/src/ui/components/patterns/page-state.tsx +2 -0
  24. package/src/ui/components/patterns/page.tsx +165 -51
  25. package/src/ui/components/patterns/presentation.tsx +140 -84
  26. package/src/ui/components/patterns/surface-header.tsx +5 -6
  27. package/src/ui/components/patterns/trigger.tsx +113 -83
  28. package/src/ui/components/primitives/button.tsx +2 -2
  29. package/src/ui/components/primitives/chat.tsx +19 -5
  30. package/src/ui/components/primitives/control.ts +9 -3
  31. package/src/ui/components/primitives/dialog.tsx +16 -9
  32. package/src/ui/components/primitives/drawer.tsx +9 -6
  33. package/src/ui/docs/content/action-form-card.md +9 -8
  34. package/src/ui/docs/content/action-form-dialog.md +11 -12
  35. package/src/ui/docs/content/action-form.md +25 -25
  36. package/src/ui/docs/content/action-list.md +101 -70
  37. package/src/ui/docs/content/action-trigger.md +2 -2
  38. package/src/ui/docs/content/button.md +1 -1
  39. package/src/ui/docs/content/chat.md +4 -4
  40. package/src/ui/docs/content/content.md +29 -13
  41. package/src/ui/docs/content/dialog.md +27 -21
  42. package/src/ui/docs/content/drawer.md +8 -6
  43. package/src/ui/docs/content/page.md +43 -50
  44. package/src/ui/docs/content/presentation.md +39 -28
  45. package/src/ui/docs/doc-client.tsx +1 -1
  46. package/src/ui/meta.ts +4 -4
package/CHANGELOG.md CHANGED
@@ -7,6 +7,68 @@ 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
+ ## 18.0.1 — 2026-09-13
11
+
12
+ Controles somente com ícone que pertencem ao chrome da superfície, como voltar e fechar, usam o
13
+ tamanho normal `icon`. O retorno cresce de 1.75rem para a área clicável padrão de 2.25rem; o close
14
+ permanece nessa medida e ambos mantêm o glifo compacto. `icon-sm` fica reservado às composições
15
+ densas, como paginação e toolbars.
16
+
17
+ ## 18.0.0 — 2026-09-13
18
+
19
+ ### Breaking
20
+
21
+ - Em composição explícita, `Page` exige exatamente um heading principal: use `PageTitle` em
22
+ `PageHeader` ou `PageIntro`, `Content level={1}` ou um `PageState` ativo dentro de `PageBody`.
23
+ - `DialogFooter` e `DrawerFooter` deixam de distribuir botões diretamente. Envolva decisões em
24
+ `ButtonGroup`; use `distribution="equal"` para o footer modal 50/50.
25
+ - A criação de uma coleção deixa o chrome global da página e passa ao `ContentHeader`, junto ao
26
+ título da lista. Use um botão textual `default`, sem ícone, com `Criar recurso`.
27
+
28
+ Páginas centradas em uma coleção passam a compor `Content + ActionList`: o cabeçalho do Content
29
+ reúne o título e a criação, enquanto busca, filtros, atualização, estados e paginação permanecem no
30
+ ActionList. `toolbarActions` fica reservado a operações que dependem do recorte atual.
31
+
32
+ `Presentation.route` passa a declarar caminho, surface e origem opcional do registro. Os helpers
33
+ `matchPresentationRoute` e `pathForPresentationInvocation` permitem ligar o artefato ao router sem
34
+ repetir a navegação no componente da página.
35
+
36
+ `PageHeader` pode declarar somente navegação e ações globais e coexistir com `PageIntro`; uma Page
37
+ cujo conteúdo fornece o heading principal pode conter apenas `PageBody`. Em uma Presentation de
38
+ listagem exibida como Page, título e comandos de header são materializados no Content que envolve a
39
+ lista. Dialog e Drawer continuam usando o header da superfície.
40
+
41
+ Uma ação de coleção é apresentada no extremo oposto ao título como botão textual `default`, sem
42
+ ícone, com o rótulo `Criar recurso`. `Content variant="page"` mantém essa geometria, enquanto
43
+ `PageHeader` continua reservado à navegação e às ações globais. O diálogo de criação usa
44
+ `Criar recurso` e o de edição, `Editar recurso`.
45
+
46
+ Em formulários modais, o footer 50/50 apresenta `Cancelar` em `outline` e mantém a ação principal
47
+ em `solid`. `ButtonGroup` passa a responder pelo agrupamento e pela distribuição das decisões;
48
+ `DialogFooter` e `DrawerFooter` cuidam somente da faixa. `ActionForm` cru continua usando `ghost`
49
+ por padrão e oferece `cancelVariant` para composições próprias.
50
+
51
+ Formulários comuns de criação e edição usam o rótulo padrão `Salvar`. `submitLabel` permanece para
52
+ operações cujo efeito pede outro verbo, como `Renomear`, `Criar nova versão` ou `Colocar na fila`.
53
+
54
+ Header, body e footer de `Dialog` e `Drawer` usam o mesmo padding compacto (`1rem`), preservando
55
+ alinhamento horizontal e vertical entre as três regiões. O footer mantém a cor da superfície e usa
56
+ somente a borda para separar as ações do body. O close do header usa o tamanho normal somente com
57
+ ícone; o glifo segue a escala global compacta de `0.875rem`, sem reduzir sua área clicável.
58
+ Cancelamentos em `Dialog mode="alert"`, incluindo confirmações de `ActionTrigger`, `ActionList` e
59
+ a API imperativa, usam `outline`; a decisão principal continua `solid`.
60
+
61
+ ## 17.2.1 — 2026-09-12
62
+
63
+ O `Chat` passa a renderizar `renderMessageActions` também nas mensagens enviadas. Data, cópia e
64
+ outras ações contextuais podem usar a mesma extensão discreta nos dois lados da conversa.
65
+ `activity=""` oferece o indicador compacto, com somente os pontos centralizados e “Pensando…”
66
+ preservado como nome acessível.
67
+ Tools MCP passam a projetar a `label` legível da action em `title`, mantendo `name` como
68
+ identificador técnico estável para execução e auditoria.
69
+ O transcript compacta e equilibra o respiro antes e depois da mensagem enviada, inclusive quando
70
+ a faixa de ações contextuais fica reservada para o hover.
71
+
10
72
  ## 17.2.0 — 2026-09-12
11
73
 
12
74
  Páginas hospedadas por `PageShell` passam a apresentar o título no início do conteúdo, em
@@ -95,7 +157,6 @@ mesmo motivo. Nas APIs imperativas `alert`, `confirm`, `prompt` e `choose`, e em
95
157
  `PageHeaderVariant` deixam de existir; use `PageShell` para a barra persistente e `PageIntro` para
96
158
  a introdução do conteúdo.
97
159
 
98
-
99
160
  ## 15.2.2 — 2026-09-11
100
161
 
101
162
  O acesso descritivo de Produtos de Dados usa `permissionContexts` no lugar do nome genérico
package/bin/lib/check.mjs CHANGED
@@ -170,6 +170,8 @@ const UI_STRUCTURAL_ROOTS = new Map([
170
170
  alternateHeader: "PageIntro",
171
171
  body: "PageBody",
172
172
  footer: "PageFooter",
173
+ headerRequired: false,
174
+ allowBothHeaders: true,
173
175
  shorthand: new Set(["title", "actions"]),
174
176
  // O contador saiu do título da página (ADR 0009). Tirar `count` do shorthand não proíbe
175
177
  // nada sozinho — só apaga o marcador que fazia o gate enxergar aquele Page —, então a
@@ -239,10 +241,7 @@ const UI_REMOVED_PROPS = new Map([
239
241
  ]);
240
242
 
241
243
  const UI_REMOVED_COMPONENTS = new Map([
242
- [
243
- "PageDescription",
244
- "mova contexto relevante para o início de `PageBody`",
245
- ],
244
+ ["PageDescription", "mova contexto relevante para o início de `PageBody`"],
246
245
  [
247
246
  "DialogDescription",
248
247
  "mova contexto relevante para o início de `DialogBody`",
@@ -260,13 +259,10 @@ const UI_STRUCTURAL_HEADERS = new Map([
260
259
  "PageHeader",
261
260
  {
262
261
  title: "PageTitle",
263
- optional: new Set([
264
- "PageBack",
265
- "PageNavigation",
266
- "PageActions",
267
- ]),
262
+ optional: new Set(["PageBack", "PageNavigation", "PageActions"]),
268
263
  exclusive: [new Set(["PageBack", "PageNavigation"])],
269
264
  shorthand: new Set(),
265
+ titleRequired: false,
270
266
  },
271
267
  ],
272
268
  [
@@ -365,12 +361,21 @@ export const DATA_PRODUCT_MARKERS = ["defineDataProduct", "defineEntity"];
365
361
 
366
362
  /** Extrai Produtos de Dados e entities literais para validar a linhagem sem executar o app. */
367
363
  export function parseDataProducts(fileName, sourceText) {
368
- const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
364
+ const sf = ts.createSourceFile(
365
+ fileName,
366
+ sourceText,
367
+ ts.ScriptTarget.Latest,
368
+ true,
369
+ ts.ScriptKind.TS,
370
+ );
369
371
  const products = [];
370
372
  const entities = [];
371
- const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
372
- const prop = (obj, key) => obj.properties.find((item) =>
373
- ts.isPropertyAssignment(item) && item.name?.getText(sf) === key)?.initializer;
373
+ const lineOf = (node) =>
374
+ sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
375
+ const prop = (obj, key) =>
376
+ obj.properties.find(
377
+ (item) => ts.isPropertyAssignment(item) && item.name?.getText(sf) === key,
378
+ )?.initializer;
374
379
  const stringProp = (obj, key) => {
375
380
  const value = prop(obj, key);
376
381
  return value && ts.isStringLiteral(value) ? value.text : null;
@@ -383,17 +388,33 @@ export function parseDataProducts(fileName, sourceText) {
383
388
  };
384
389
  const isExported = (call) => {
385
390
  const declaration = call.parent;
386
- const statement = ts.isVariableDeclaration(declaration) ? declaration.parent?.parent : undefined;
387
- return statement !== undefined && ts.isVariableStatement(statement) && (statement.modifiers ?? []).some((item) => item.kind === ts.SyntaxKind.ExportKeyword);
391
+ const statement = ts.isVariableDeclaration(declaration)
392
+ ? declaration.parent?.parent
393
+ : undefined;
394
+ return (
395
+ statement !== undefined &&
396
+ ts.isVariableStatement(statement) &&
397
+ (statement.modifiers ?? []).some(
398
+ (item) => item.kind === ts.SyntaxKind.ExportKeyword,
399
+ )
400
+ );
388
401
  };
389
402
  const visit = (node) => {
390
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0])) {
403
+ if (
404
+ ts.isCallExpression(node) &&
405
+ ts.isIdentifier(node.expression) &&
406
+ node.arguments[0] &&
407
+ ts.isObjectLiteralExpression(node.arguments[0])
408
+ ) {
391
409
  const obj = node.arguments[0];
392
410
  if (node.expression.text === "defineDataProduct") {
393
411
  const versionNode = prop(obj, "version");
394
412
  products.push({
395
413
  id: stringProp(obj, "id"),
396
- version: versionNode && ts.isNumericLiteral(versionNode) ? Number(versionNode.text) : null,
414
+ version:
415
+ versionNode && ts.isNumericLiteral(versionNode)
416
+ ? Number(versionNode.text)
417
+ : null,
397
418
  interfaces: stringList(obj, "interfaces"),
398
419
  entities: stringList(obj, "entities"),
399
420
  exported: isExported(node),
@@ -609,7 +630,8 @@ export function checkProject(sources) {
609
630
  for (const { file, text } of sources) {
610
631
  const declarations = parseDataProducts(file, text);
611
632
  for (const entity of declarations.entities) entityNames.add(entity);
612
- for (const product of declarations.products) productDeclarations.push({ product, file });
633
+ for (const product of declarations.products)
634
+ productDeclarations.push({ product, file });
613
635
  for (const item of parseActions(file, text)) {
614
636
  if (item.form === "bindAction") {
615
637
  actions += 1;
@@ -633,33 +655,69 @@ export function checkProject(sources) {
633
655
 
634
656
  const productIds = new Set();
635
657
  for (const { product, file } of productDeclarations) {
636
- const finding = (rule, message) => findings.push({
637
- rule, level: "error", action: product.id ?? "(Produto de Dados sem id)",
638
- line: product.line, file, message,
639
- });
640
- if (!product.exported) finding("data-product-export", "defineDataProduct precisa ser `export const` para compor o domínio e o catálogo.");
658
+ const finding = (rule, message) =>
659
+ findings.push({
660
+ rule,
661
+ level: "error",
662
+ action: product.id ?? "(Produto de Dados sem id)",
663
+ line: product.line,
664
+ file,
665
+ message,
666
+ });
667
+ if (!product.exported)
668
+ finding(
669
+ "data-product-export",
670
+ "defineDataProduct precisa ser `export const` para compor o domínio e o catálogo.",
671
+ );
641
672
  if (product.id === null || !ACTION_NAME_RE.test(product.id)) {
642
- finding("data-product-id", "Produto de Dados precisa de `id` namespaced literal, como `sales.leads`.");
673
+ finding(
674
+ "data-product-id",
675
+ "Produto de Dados precisa de `id` namespaced literal, como `sales.leads`.",
676
+ );
643
677
  } else if (productIds.has(product.id)) {
644
- finding("data-product-duplicate", `Produto de Dados "${product.id}" está declarado mais de uma vez.`);
678
+ finding(
679
+ "data-product-duplicate",
680
+ `Produto de Dados "${product.id}" está declarado mais de uma vez.`,
681
+ );
645
682
  } else {
646
683
  productIds.add(product.id);
647
684
  }
648
- if (product.version === null || !Number.isInteger(product.version) || product.version < 1) {
649
- finding("data-product-version", "Produto de Dados precisa de `version` literal inteira e positiva.");
685
+ if (
686
+ product.version === null ||
687
+ !Number.isInteger(product.version) ||
688
+ product.version < 1
689
+ ) {
690
+ finding(
691
+ "data-product-version",
692
+ "Produto de Dados precisa de `version` literal inteira e positiva.",
693
+ );
650
694
  }
651
695
  if (product.interfaces === null || product.interfaces.length === 0) {
652
- finding("data-product-interfaces", "Produto de Dados precisa declarar ao menos uma Action literal em `interfaces`.");
696
+ finding(
697
+ "data-product-interfaces",
698
+ "Produto de Dados precisa declarar ao menos uma Action literal em `interfaces`.",
699
+ );
653
700
  } else {
654
701
  for (const action of product.interfaces) {
655
- if (!actionNames.has(action)) finding("data-product-interface", `A interface "${action}" não corresponde a uma Action ou contrato declarado no projeto.`);
702
+ if (!actionNames.has(action))
703
+ finding(
704
+ "data-product-interface",
705
+ `A interface "${action}" não corresponde a uma Action ou contrato declarado no projeto.`,
706
+ );
656
707
  }
657
708
  }
658
709
  if (product.entities === null) {
659
- finding("data-product-entities", "Produto de Dados precisa declarar `entities` como lista literal.");
710
+ finding(
711
+ "data-product-entities",
712
+ "Produto de Dados precisa declarar `entities` como lista literal.",
713
+ );
660
714
  } else {
661
715
  for (const entity of product.entities) {
662
- if (!entityNames.has(entity)) finding("data-product-entity", `A Entity "${entity}" não foi declarada no projeto.`);
716
+ if (!entityNames.has(entity))
717
+ finding(
718
+ "data-product-entity",
719
+ `A Entity "${entity}" não foi declarada no projeto.`,
720
+ );
663
721
  }
664
722
  }
665
723
  }
@@ -682,7 +740,12 @@ export function checkProject(sources) {
682
740
  }
683
741
  }
684
742
 
685
- return { actions, contracts, dataProducts: productDeclarations.length, findings };
743
+ return {
744
+ actions,
745
+ contracts,
746
+ dataProducts: productDeclarations.length,
747
+ findings,
748
+ };
686
749
  }
687
750
 
688
751
  /** Migrações de UI que falhariam apenas visualmente. Linhas que são só comentário não
@@ -977,6 +1040,84 @@ function directStructuralChildren(node, imports) {
977
1040
  return { names, hasOpaque };
978
1041
  }
979
1042
 
1043
+ function directImportedJsxChildren(node, imports) {
1044
+ if (!ts.isJsxElement(node)) return [];
1045
+ return node.children.flatMap((child) => {
1046
+ if (!ts.isJsxElement(child) && !ts.isJsxSelfClosingElement(child)) return [];
1047
+ const local = jsxLocalName(child);
1048
+ const imported = local === null ? undefined : imports.get(local);
1049
+ return imported === undefined ? [] : [{ node: child, component: imported }];
1050
+ });
1051
+ }
1052
+
1053
+ function jsxNumericAttribute(node, name) {
1054
+ const attribute = jsxAttribute(jsxOpening(node), name);
1055
+ if (attribute === undefined || attribute.initializer === undefined) return null;
1056
+ if (
1057
+ ts.isJsxExpression(attribute.initializer) &&
1058
+ attribute.initializer.expression !== undefined &&
1059
+ ts.isNumericLiteral(attribute.initializer.expression)
1060
+ ) {
1061
+ return Number(attribute.initializer.expression.text);
1062
+ }
1063
+ if (ts.isStringLiteral(attribute.initializer)) {
1064
+ const value = Number(attribute.initializer.text);
1065
+ return Number.isFinite(value) ? value : null;
1066
+ }
1067
+ return null;
1068
+ }
1069
+
1070
+ function countPageHeadingSources(page, imports) {
1071
+ let count = 0;
1072
+ let states = 0;
1073
+ for (const child of directImportedJsxChildren(page, imports)) {
1074
+ if (child.component === "PageHeader" || child.component === "PageIntro") {
1075
+ count += directImportedJsxChildren(child.node, imports).filter(
1076
+ (entry) => entry.component === "PageTitle",
1077
+ ).length;
1078
+ }
1079
+ if (child.component !== "PageBody") continue;
1080
+ const visit = (node) => {
1081
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
1082
+ const local = jsxLocalName(node);
1083
+ if (
1084
+ local !== null &&
1085
+ ((imports.get(local) === "Content" &&
1086
+ jsxNumericAttribute(node, "level") === 1) ||
1087
+ imports.get(local) === "PageState")
1088
+ ) {
1089
+ if (imports.get(local) === "PageState") states += 1;
1090
+ else count += 1;
1091
+ }
1092
+ }
1093
+ ts.forEachChild(node, visit);
1094
+ };
1095
+ visit(child.node);
1096
+ }
1097
+ return states > 0 ? states : count;
1098
+ }
1099
+
1100
+ function pageBodyHasOpaqueDescendant(page, imports) {
1101
+ for (const child of directImportedJsxChildren(page, imports)) {
1102
+ if (child.component !== "PageBody" || !ts.isJsxElement(child.node)) continue;
1103
+ let opaque = false;
1104
+ const visit = (node) => {
1105
+ if (opaque) return;
1106
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
1107
+ const local = jsxLocalName(node);
1108
+ if (local !== null && imports.get(local) === undefined) {
1109
+ opaque = true;
1110
+ return;
1111
+ }
1112
+ }
1113
+ ts.forEachChild(node, visit);
1114
+ };
1115
+ for (const bodyChild of child.node.children) visit(bodyChild);
1116
+ if (opaque) return true;
1117
+ }
1118
+ return false;
1119
+ }
1120
+
980
1121
  /** Verifica a anatomia JSX pública definida na ADR 0005. */
981
1122
  export function checkUiStructure(file, text) {
982
1123
  if (!/\.[cm]?[jt]sx$/.test(file)) return [];
@@ -1152,13 +1293,9 @@ export function checkUiStructure(file, text) {
1152
1293
  attributes.has(attribute),
1153
1294
  );
1154
1295
  const children = directStructuralChildren(node, imports);
1155
- const insidePageShell =
1156
- component === "Page" && hasJsxAncestor(node, imports, "PageShell");
1157
- const acceptedHeaders = (
1158
- insidePageShell
1159
- ? [root.alternateHeader]
1160
- : [root.header, root.alternateHeader]
1161
- ).filter(Boolean);
1296
+ const acceptedHeaders = [root.header, root.alternateHeader].filter(
1297
+ Boolean,
1298
+ );
1162
1299
  const structural =
1163
1300
  acceptedHeaders.some((header) => children.names.includes(header)) ||
1164
1301
  children.names.includes(root.body) ||
@@ -1182,6 +1319,10 @@ export function checkUiStructure(file, text) {
1182
1319
  const headers = children.names.filter((name) =>
1183
1320
  acceptedHeaders.includes(name),
1184
1321
  ).length;
1322
+ const duplicatedHeader = acceptedHeaders.some(
1323
+ (header) =>
1324
+ children.names.filter((name) => name === header).length > 1,
1325
+ );
1185
1326
  const bodies = children.names.filter(
1186
1327
  (name) => name === root.body,
1187
1328
  ).length;
@@ -1194,18 +1335,39 @@ export function checkUiStructure(file, text) {
1194
1335
  name !== root.body &&
1195
1336
  name !== root.footer,
1196
1337
  );
1197
- if (
1198
- headers !== 1 ||
1338
+ const invalidComposition =
1339
+ (root.headerRequired === false
1340
+ ? headers > (root.allowBothHeaders ? 2 : 1)
1341
+ : headers !== 1) ||
1342
+ duplicatedHeader ||
1199
1343
  bodies !== 1 ||
1200
1344
  footers > 1 ||
1201
1345
  unexpected ||
1202
- children.hasOpaque
1203
- ) {
1346
+ children.hasOpaque;
1347
+ if (invalidComposition) {
1204
1348
  add(
1205
1349
  node,
1206
1350
  component,
1207
- `${component} explícito exige exatamente um ${acceptedHeaders.join(" ou ")}, um ${root.body} e no máximo um ${root.footer ?? "footer"} como filhos diretos.`,
1351
+ root.headerRequired === false
1352
+ ? `${component} explícito exige exatamente um ${root.body} e aceita no máximo um de cada: ${acceptedHeaders.join(", ")} e ${root.footer ?? "footer"}.`
1353
+ : `${component} explícito exige exatamente um ${acceptedHeaders.join(" ou ")}, um ${root.body} e no máximo um ${root.footer ?? "footer"} como filhos diretos.`,
1208
1354
  );
1355
+ } else if (component === "Page") {
1356
+ const headingSources = countPageHeadingSources(node, imports);
1357
+ if (
1358
+ headingSources === 1 ||
1359
+ (headingSources === 0 &&
1360
+ pageBodyHasOpaqueDescendant(node, imports))
1361
+ ) {
1362
+ // Um componente intermediário pode materializar PageState. O runtime valida essa
1363
+ // composição depois que os layout effects dos filhos registram a presença.
1364
+ } else {
1365
+ add(
1366
+ node,
1367
+ component,
1368
+ "Page explícito exige exatamente um heading principal em PageHeader, PageIntro, Content level={1} ou PageState dentro de PageBody.",
1369
+ );
1370
+ }
1209
1371
  }
1210
1372
  }
1211
1373
  }
@@ -1576,7 +1738,12 @@ export async function scanDir(rootDir) {
1576
1738
  const sources = [];
1577
1739
  for (const file of files) {
1578
1740
  const text = readProjectFile(rootDir, path.relative(rootDir, file)).content;
1579
- if (![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS].some((m) => text.includes(m))) continue;
1741
+ if (
1742
+ ![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS].some((m) =>
1743
+ text.includes(m),
1744
+ )
1745
+ )
1746
+ continue;
1580
1747
  sources.push({ file: path.relative(rootDir, file), text });
1581
1748
  }
1582
1749
  const checked = checkProject(sources);
@@ -36,8 +36,8 @@ A variante altera somente a apresentação. Título, descrição, retorno e aç
36
36
  semanticamente à mesma página. Um header sem conteúdo útil não é materializado para reservar
37
37
  altura, e o header é ocultado durante os estados integrais do `PageState` — inversão da decisão
38
38
  original da ADR 0004, argumentada no adendo dela. Para acompanhar o ritmo
39
- compacto da barra, ações com texto usam `Button size="sm"` e ações somente com ícone usam
40
- `Button size="icon-sm"`.
39
+ da barra, ações com texto usam `Button size="default"` e ações somente com ícone usam
40
+ `Button size="icon"`.
41
41
 
42
42
  ## Consequências
43
43
 
@@ -4,6 +4,10 @@
4
4
  - **Data:** 2026-09-10.
5
5
  - **Revisa:** ADR 0010.
6
6
 
7
+ > **Atualização (2026-09-13).** A ADR 0016 separa o chrome persistente da introdução e do
8
+ > cabeçalho de uma coleção. `PageHeader` passa a declarar navegação e ações globais para a barra;
9
+ > `PageIntro` permanece opcional no conteúdo e `Content` nomeia coleções.
10
+
7
11
  ## Contexto
8
12
 
9
13
  A ADR 0010 reuniu título, navegação e ações em `PageHeader`, mas partiu do pressuposto de que uma
@@ -3,6 +3,14 @@
3
3
  - **Status:** aceita.
4
4
  - **Data:** 2026-09-11.
5
5
 
6
+ > **Atualização (2026-09-13).** Conforme a ADR 0016, uma Presentation de listagem em Page
7
+ > materializa título e comandos de header no `Content` que envolve o `ActionList`. Dialog e Drawer
8
+ > continuam materializando esses elementos no header da superfície.
9
+ >
10
+ > Uma Presentation pode declarar uma `route` absoluta com parâmetros. Nessa forma, URL, surface e
11
+ > origem do registro passam a integrar o artefato; o adaptador da aplicação apenas conecta a spec ao
12
+ > router hospedeiro.
13
+
6
14
  ## Contexto
7
15
 
8
16
  Page, Dialog e Drawer compartilham uma anatomia estrutural, mas consumidores ainda repetem a
@@ -49,12 +57,16 @@ estática, publicável no manifest e inspecionável pela Lens. A invocação é
49
57
  uma execução: identifica a Presentation, escolhe a surface, carrega somente input JSON e mantém a
50
58
  pilha de frames necessária para voltar. Ela não é publicada no manifest.
51
59
 
60
+ Quando refresh, deep link ou histórico fazem parte do recurso, `route` declara o caminho, a surface
61
+ e, quando necessário, a lista que fornece o registro identificado por um parâmetro. Rotas continuam
62
+ opcionais: a mesma Presentation pode ser usada como superfície efêmera sem assumir um router.
63
+
52
64
  `back` restaura o último frame da pilha. Em Dialog ou Drawer, ele só é apresentado quando o frame
53
65
  anterior também é modal; a Page mantida ao fundo não cria uma etapa intermediária. `close` encerra
54
66
  apenas dialog ou drawer e é inválido para page. Formulários modais oferecem `Cancelar` como ação de
55
67
  abandono no footer, ao lado da conclusão. `navigate` abre outra invocação por push ou substitui a
56
- atual; sincronizar esse estado com URL é responsabilidade do adaptador da aplicação quando refresh,
57
- deep link ou histórico forem necessários. O inspetor de desenvolvimento reúne definição,
68
+ atual. Sem `route`, sincronizar esse estado com URL é responsabilidade do adaptador da aplicação.
69
+ Com `route`, o adaptador deriva o caminho e a invocação do próprio artefato. O inspetor de desenvolvimento reúne definição,
58
70
  invocação, bindings resolvidos e diagnósticos, mascarando chaves sensíveis antes de expor o JSON. O
59
71
  inventário estático e seu JSON ficam disponíveis na Lens; a aplicação não adiciona um launcher
60
72
  flutuante ao shell.
@@ -24,7 +24,7 @@ risco.
24
24
  - ações operacionais em toolbar, header de seção ou coleção densa usam `sm`;
25
25
  - ações internas de linha, célula ou campo usam `xs` ou `icon-xs`;
26
26
  - ações somente com ícone que pertencem ao chrome da superfície, como voltar e fechar, usam
27
- `icon-sm`;
27
+ `icon`;
28
28
  - `lg` fica reservado a chamadas que deliberadamente precisam de uma área de toque maior, não a
29
29
  uma ação primária comum.
30
30
 
@@ -0,0 +1,80 @@
1
+ # ADR 0016 — O cabeçalho da coleção pertence ao Content
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-09-13.
5
+ - **Revisa:** ADRs 0011 e 0013.
6
+
7
+ ## Contexto
8
+
9
+ Páginas centradas em uma coleção colocavam o título no `PageIntro`, a criação na barra do
10
+ `PageShell` e busca, filtros e atualização no `ActionList`. Essa divisão afastava a ação primária
11
+ do recurso que ela altera e fazia o mesmo conjunto mudar de hierarquia ao ser apresentado em Page,
12
+ Dialog ou Drawer.
13
+
14
+ `Content` já oferece a estrutura pública para nomear uma região e reunir suas ações. Criar
15
+ `ListHeader`, `ListTitle` ou equivalentes repetiria essa anatomia e sugeriria um componente genérico
16
+ `List` que o Opus não possui. `ActionList` é o pattern orientado pela `ListAction`; busca não é a
17
+ casca estrutural da coleção.
18
+
19
+ ## Decisão
20
+
21
+ Uma coleção nomeada em uma página compõe `Content + ActionList`. `ContentHeader` recebe o título e
22
+ as ações da coleção, como criar um item. `ActionList` continua responsável por busca, filtros,
23
+ atualização, visualização, resultados, estados e paginação. `toolbarActions` fica reservado a
24
+ operações ligadas ao recorte corrente da lista, não ao comando primário de criar.
25
+
26
+ `PageHeader` representa o chrome da página: navegação e ações globais. Dentro de `PageShell`,
27
+ esses slots são projetados na barra; `PageIntro` permanece opcional para uma introdução própria da
28
+ página. Uma `Page` explícita mantém exatamente um heading principal: `PageTitle` em header/intro,
29
+ `Content level={1}` ou um `PageState` ativo no body. Quando `Content` fornece esse heading, a Page
30
+ pode conter somente `PageBody`.
31
+
32
+ Na `Presentation`, uma action `list` exibida como Page materializa o título e os comandos de header
33
+ no `Content` que envolve o `ActionList`. Em Dialog e Drawer, o título e esses comandos continuam no
34
+ header da superfície, pois ele já está imediatamente ligado ao corpo. A definição serializável não
35
+ muda conforme a superfície.
36
+
37
+ ## Consequências
38
+
39
+ - criação e outras ações da coleção permanecem próximas da listagem;
40
+ - `Content` continua sendo a única anatomia pública de região nomeada;
41
+ - `ActionList` não ganha slots estruturais duplicados nem um componente `List` implícito;
42
+ - o mesmo artefato de Presentation preserva sua hierarquia ao alternar entre Page, Dialog e Drawer;
43
+ - ações da coleção ficam no extremo oposto ao título, preservando a associação sem disputar a
44
+ leitura do heading;
45
+ - a criação no header de uma coleção usa botão textual `default`, sem ícone, com o rótulo
46
+ `Criar recurso`; comandos globais da superfície também mantêm a escala normal;
47
+ - o diálogo de criação usa `Criar recurso` e o de edição usa `Editar recurso`, mantendo os dois
48
+ estados operacionais simétricos.
49
+
50
+ Os títulos nomeiam a tarefa (`Criar recurso` e `Editar recurso`), enquanto a conclusão de
51
+ formulários comuns usa `Salvar` nos dois casos. Esta é uma decisão deliberada do Opus: mantém a
52
+ ação curta e estável quando o contexto do modal já torna o objeto e a intenção inequívocos. Ela
53
+ especializa a recomendação genérica de repetir o resultado no botão; operações com efeito próprio
54
+ continuam declarando outro verbo por `submitLabel`.
55
+
56
+ ## Alternativas consideradas
57
+
58
+ ### Criar uma família ActionListHeader
59
+
60
+ Deixaria o vínculo nominal explícito, mas duplicaria `ContentHeader`, `ContentTitle` e
61
+ `ContentActions` sem introduzir outra semântica. Foi descartada em favor da composição existente.
62
+
63
+ ### Manter a criação em toolbarActions
64
+
65
+ Manteria a API atual, mas misturaria o comando primário da coleção com controles operacionais do
66
+ recorte e reduziria sua hierarquia visual. Foi descartada.
67
+
68
+ ### Manter todas as ações na barra da Page
69
+
70
+ Produziria uma posição constante, mas afastaria a criação da lista e faria a barra acumular
71
+ responsabilidades locais. Foi descartada para coleções; ações realmente globais continuam nela.
72
+
73
+ ## Verificação
74
+
75
+ - testes de `Page` cobrem `PageHeader + PageIntro + PageBody` e `PageBody` sem header;
76
+ - `opus check` aceita essa gramática e continua rejeitando slots duplicados ou fora da família;
77
+ - testes de `Presentation` confirmam que Page de listagem usa `Content` e mantém a barra sem a ação
78
+ da coleção;
79
+ - a documentação de `ActionList` recomenda `Content` e reserva `toolbarActions` ao recorte atual;
80
+ - consumidores validam visualmente o título e a criação junto da listagem.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "17.2.0",
3
+ "version": "18.0.1",
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",