@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.
- package/CHANGELOG.md +55 -1
- package/bin/lib/check.mjs +212 -45
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
- package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +30 -20
- package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
- package/registry/skills/build-opus-ui/references/ui-patterns.md +29 -17
- package/src/core/presentation.ts +223 -24
- package/src/core/runtime.ts +3 -0
- package/src/core/types.ts +2 -0
- package/src/mcp/index.ts +1 -0
- package/src/ui/components/patterns/action-form-card.tsx +8 -1
- package/src/ui/components/patterns/confirm.tsx +194 -157
- package/src/ui/components/patterns/content-header.tsx +17 -2
- package/src/ui/components/patterns/form-dialog.tsx +28 -14
- package/src/ui/components/patterns/form.tsx +340 -222
- package/src/ui/components/patterns/list.tsx +43 -44
- package/src/ui/components/patterns/page-heading-context.tsx +34 -0
- package/src/ui/components/patterns/page-state.tsx +2 -0
- package/src/ui/components/patterns/page.tsx +164 -50
- package/src/ui/components/patterns/presentation.tsx +140 -84
- package/src/ui/components/patterns/surface-header.tsx +5 -6
- package/src/ui/components/patterns/trigger.tsx +112 -82
- package/src/ui/components/primitives/button.tsx +2 -2
- package/src/ui/components/primitives/chat.tsx +19 -5
- package/src/ui/components/primitives/control.ts +9 -3
- package/src/ui/components/primitives/dialog.tsx +16 -9
- package/src/ui/components/primitives/drawer.tsx +9 -6
- package/src/ui/docs/content/action-form-card.md +9 -8
- package/src/ui/docs/content/action-form-dialog.md +11 -12
- package/src/ui/docs/content/action-form.md +25 -25
- package/src/ui/docs/content/action-list.md +101 -70
- package/src/ui/docs/content/chat.md +4 -4
- package/src/ui/docs/content/content.md +29 -13
- package/src/ui/docs/content/dialog.md +27 -21
- package/src/ui/docs/content/drawer.md +8 -6
- package/src/ui/docs/content/page.md +43 -50
- package/src/ui/docs/content/presentation.md +39 -28
- package/src/ui/docs/doc-client.tsx +1 -1
- 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(
|
|
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) =>
|
|
372
|
-
|
|
373
|
-
|
|
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)
|
|
387
|
-
|
|
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 (
|
|
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:
|
|
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)
|
|
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) =>
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
649
|
-
|
|
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(
|
|
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))
|
|
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(
|
|
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))
|
|
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 {
|
|
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
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
57
|
-
|
|
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
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
`
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
11.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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` já 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
|
-
|
|
81
|
+
16. Testar estados de loading, vazio, erro, sucesso, permissão e interação relevante.
|
|
72
82
|
|
|
73
83
|
## Verificação
|
|
74
84
|
|