@softize/opus 17.2.0 → 18.0.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 (42) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
  4. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  5. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  6. package/package.json +1 -1
  7. package/registry/skills/build-opus-ui/SKILL.md +30 -20
  8. package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
  9. package/registry/skills/build-opus-ui/references/ui-patterns.md +29 -17
  10. package/src/core/presentation.ts +223 -24
  11. package/src/core/runtime.ts +3 -0
  12. package/src/core/types.ts +2 -0
  13. package/src/mcp/index.ts +1 -0
  14. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  15. package/src/ui/components/patterns/confirm.tsx +194 -157
  16. package/src/ui/components/patterns/content-header.tsx +17 -2
  17. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  18. package/src/ui/components/patterns/form.tsx +340 -222
  19. package/src/ui/components/patterns/list.tsx +43 -44
  20. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  21. package/src/ui/components/patterns/page-state.tsx +2 -0
  22. package/src/ui/components/patterns/page.tsx +164 -50
  23. package/src/ui/components/patterns/presentation.tsx +140 -84
  24. package/src/ui/components/patterns/surface-header.tsx +5 -6
  25. package/src/ui/components/patterns/trigger.tsx +112 -82
  26. package/src/ui/components/primitives/button.tsx +2 -2
  27. package/src/ui/components/primitives/chat.tsx +19 -5
  28. package/src/ui/components/primitives/control.ts +9 -3
  29. package/src/ui/components/primitives/dialog.tsx +16 -9
  30. package/src/ui/components/primitives/drawer.tsx +9 -6
  31. package/src/ui/docs/content/action-form-card.md +9 -8
  32. package/src/ui/docs/content/action-form-dialog.md +11 -12
  33. package/src/ui/docs/content/action-form.md +25 -25
  34. package/src/ui/docs/content/action-list.md +101 -70
  35. package/src/ui/docs/content/chat.md +4 -4
  36. package/src/ui/docs/content/content.md +29 -13
  37. package/src/ui/docs/content/dialog.md +27 -21
  38. package/src/ui/docs/content/drawer.md +8 -6
  39. package/src/ui/docs/content/page.md +43 -50
  40. package/src/ui/docs/content/presentation.md +39 -28
  41. package/src/ui/docs/doc-client.tsx +1 -1
  42. package/src/ui/meta.ts +4 -4
package/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@ 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.0 — 2026-09-13
11
+
12
+ ### Breaking
13
+
14
+ - Em composição explícita, `Page` exige exatamente um heading principal: use `PageTitle` em
15
+ `PageHeader` ou `PageIntro`, `Content level={1}` ou um `PageState` ativo dentro de `PageBody`.
16
+ - `DialogFooter` e `DrawerFooter` deixam de distribuir botões diretamente. Envolva decisões em
17
+ `ButtonGroup`; use `distribution="equal"` para o footer modal 50/50.
18
+ - A criação de uma coleção deixa o chrome global da página e passa ao `ContentHeader`, junto ao
19
+ título da lista. Use um botão textual `default`, sem ícone, com `Criar recurso`.
20
+
21
+ Páginas centradas em uma coleção passam a compor `Content + ActionList`: o cabeçalho do Content
22
+ reúne o título e a criação, enquanto busca, filtros, atualização, estados e paginação permanecem no
23
+ ActionList. `toolbarActions` fica reservado a operações que dependem do recorte atual.
24
+
25
+ `Presentation.route` passa a declarar caminho, surface e origem opcional do registro. Os helpers
26
+ `matchPresentationRoute` e `pathForPresentationInvocation` permitem ligar o artefato ao router sem
27
+ repetir a navegação no componente da página.
28
+
29
+ `PageHeader` pode declarar somente navegação e ações globais e coexistir com `PageIntro`; uma Page
30
+ cujo conteúdo fornece o heading principal pode conter apenas `PageBody`. Em uma Presentation de
31
+ listagem exibida como Page, título e comandos de header são materializados no Content que envolve a
32
+ lista. Dialog e Drawer continuam usando o header da superfície.
33
+
34
+ Uma ação de coleção é apresentada no extremo oposto ao título como botão textual `default`, sem
35
+ ícone, com o rótulo `Criar recurso`. `Content variant="page"` mantém essa geometria, enquanto
36
+ `PageHeader` continua reservado à navegação e às ações globais. O diálogo de criação usa
37
+ `Criar recurso` e o de edição, `Editar recurso`.
38
+
39
+ Em formulários modais, o footer 50/50 apresenta `Cancelar` em `outline` e mantém a ação principal
40
+ em `solid`. `ButtonGroup` passa a responder pelo agrupamento e pela distribuição das decisões;
41
+ `DialogFooter` e `DrawerFooter` cuidam somente da faixa. `ActionForm` cru continua usando `ghost`
42
+ por padrão e oferece `cancelVariant` para composições próprias.
43
+
44
+ Formulários comuns de criação e edição usam o rótulo padrão `Salvar`. `submitLabel` permanece para
45
+ operações cujo efeito pede outro verbo, como `Renomear`, `Criar nova versão` ou `Colocar na fila`.
46
+
47
+ Header, body e footer de `Dialog` e `Drawer` usam o mesmo padding compacto (`1rem`), preservando
48
+ alinhamento horizontal e vertical entre as três regiões. O footer mantém a cor da superfície e usa
49
+ somente a borda para separar as ações do body. O close do header usa o tamanho normal somente com
50
+ ícone; o glifo segue a escala global compacta de `0.875rem`, sem reduzir sua área clicável.
51
+ Cancelamentos em `Dialog mode="alert"`, incluindo confirmações de `ActionTrigger`, `ActionList` e
52
+ a API imperativa, usam `outline`; a decisão principal continua `solid`.
53
+
54
+ ## 17.2.1 — 2026-09-12
55
+
56
+ O `Chat` passa a renderizar `renderMessageActions` também nas mensagens enviadas. Data, cópia e
57
+ outras ações contextuais podem usar a mesma extensão discreta nos dois lados da conversa.
58
+ `activity=""` oferece o indicador compacto, com somente os pontos centralizados e “Pensando…”
59
+ preservado como nome acessível.
60
+ Tools MCP passam a projetar a `label` legível da action em `title`, mantendo `name` como
61
+ identificador técnico estável para execução e auditoria.
62
+ O transcript compacta e equilibra o respiro antes e depois da mensagem enviada, inclusive quando
63
+ a faixa de ações contextuais fica reservada para o hover.
64
+
10
65
  ## 17.2.0 — 2026-09-12
11
66
 
12
67
  Páginas hospedadas por `PageShell` passam a apresentar o título no início do conteúdo, em
@@ -95,7 +150,6 @@ mesmo motivo. Nas APIs imperativas `alert`, `confirm`, `prompt` e `choose`, e em
95
150
  `PageHeaderVariant` deixam de existir; use `PageShell` para a barra persistente e `PageIntro` para
96
151
  a introdução do conteúdo.
97
152
 
98
-
99
153
  ## 15.2.2 — 2026-09-11
100
154
 
101
155
  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);
@@ -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.
@@ -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.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",
@@ -2,6 +2,7 @@
2
2
  name: build-opus-ui
3
3
  description: Constrói interface contract-driven com hooks, forms, listas, views e componentes de @softize/opus/ui. Use ao implementar ou alterar telas que consomem actions Opus.
4
4
  ---
5
+
5
6
  <!-- softize-skill-route: $model-opus-dictionary -->
6
7
 
7
8
  # Construir UI Opus
@@ -39,10 +40,15 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
39
40
  paginam pela primitiva `Pagination`; selects inline de filtro têm largura fixa. Não reescrever
40
41
  esses defaults na tela.
41
42
  7. Tratar página, filtro, seleção e modal importante como estado navegável por URL quando
42
- o produto precisa de deep link, back/forward ou refresh.
43
- 8. Compor superfícies pela gramática estrutural do catálogo: fora de `PageShell`, `Page` contém
44
- `PageHeader` e `PageBody`; dentro dele, contém `PageIntro` e `PageBody`; `Content` contém
45
- `ContentHeader` e `ContentBody`; Card, Drawer e Pane usam seus
43
+ o produto precisa de deep link, back/forward ou refresh. Em `Presentation`, declarar `route`
44
+ no artefato e deixar o adaptador apenas conectar a spec ao router da aplicação.
45
+ 8. Compor superfícies pela gramática estrutural do catálogo: `PageHeader` declara o chrome,
46
+ `PageIntro` a introdução opcional e `PageBody` o conteúdo. Na composição explícita, declare
47
+ exatamente um heading principal com `PageTitle` no header/intro, `Content level={1}` ou um
48
+ `PageState` ativo no body;
49
+ dentro de `PageShell`, os slots de
50
+ `PageHeader` são projetados na barra. `Content` contém `ContentHeader` e `ContentBody`; Card,
51
+ Drawer e Pane usam seus
46
52
  respectivos `*Body`. Para o caso direto, usar a sintaxe abreviada de `Page` (`title`,
47
53
  `actions`) ou de `Content` (`title`, `description`, `actions` e `count`); não misturá-la com o
48
54
  header explícito. O título da página não carrega contador. Ajustar o
@@ -51,24 +57,28 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
51
57
  9. Manter margem e posicionamento no consumidor; componente reutilizável controla apenas
52
58
  seu interior.
53
59
  10. Não definir a fonte raiz em uma biblioteca ou componente. O navegador e a aplicação são
54
- responsáveis por `font-size` em `html`; medidas escaláveis da UI usam `rem` ou a escala
55
- relativa do Tailwind. Reservar `px` a hairlines e compensações presas à geometria da borda,
56
- com justificativa e cobertura explícitas.
57
- 11. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
58
- 12. Tratar tokens de superfície como pares indivisíveis no mesmo fragmento de classes:
59
- `bg-card text-card-foreground` e `bg-popover text-popover-foreground`. Não depender da
60
- igualdade atual com `--foreground`, porque o app pode sobrescrever cada par.
61
- 13. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
62
- `rounded-xs` a `rounded-2xl` expressam a forma. Escolher o degrau pela escala visual:
63
- detalhes e itens internos tendem a `xs`/`sm`, controles e flutuantes compactos a `md`,
64
- molduras estruturais a `lg`, e superfícies principais ou modais a `xl`. Essa associação
65
- orienta o default, não cria uma restrição semântica. Em aninhamento, evitar moldura dupla e
66
- reduzir o raio interno; em grupos conectados, remover os raios das arestas internas. Tamanho
67
- e forma permanecem eixos separados; usar `shape="pill"` quando a pílula for intencional.
68
- 14. Distinguir uma região disponível para criação ou vínculo, representada por `Empty` com
60
+ responsáveis por `font-size` em `html`; medidas escaláveis da UI usam `rem` ou a escala
61
+ relativa do Tailwind. Reservar `px` a hairlines e compensações presas à geometria da borda,
62
+ com justificativa e cobertura explícitas.
63
+ 11. Em uma página centrada em coleção, compor `Content + ActionList`: título e criação ficam em
64
+ extremos opostos do `ContentHeader`; a criação usa botão textual `default`, sem ícone, com
65
+ `Criar recurso`. Busca, filtros, atualização, estados e paginação permanecem no `ActionList`.
66
+ Reservar `toolbarActions` a operações ligadas ao recorte atual.
67
+ 12. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
68
+ 13. Tratar tokens de superfície como pares indivisíveis no mesmo fragmento de classes:
69
+ `bg-card text-card-foreground` e `bg-popover text-popover-foreground`. Não depender da
70
+ igualdade atual com `--foreground`, porque o app pode sobrescrever cada par.
71
+ 14. Usar diretamente a escala `rounded-*`; não criar radius por nome de componente quando
72
+ `rounded-xs` a `rounded-2xl` expressam a forma. Escolher o degrau pela escala visual:
73
+ detalhes e itens internos tendem a `xs`/`sm`, controles e flutuantes compactos a `md`,
74
+ molduras estruturais a `lg`, e superfícies principais ou modais a `xl`. Essa associação
75
+ orienta o default, não cria uma restrição semântica. Em aninhamento, evitar moldura dupla e
76
+ reduzir o raio interno; em grupos conectados, remover os raios das arestas internas. Tamanho
77
+ e forma permanecem eixos separados; usar `shape="pill"` quando a pílula for intencional.
78
+ 15. Distinguir uma região disponível para criação ou vínculo, representada por `Empty` com
69
79
  moldura tracejada, de um resultado vazio dentro de uma estrutura existente, que preserva
70
80
  a moldura sólida dessa estrutura.
71
- 15. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
81
+ 16. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
72
82
 
73
83
  ## Verificação
74
84