@softize/opus 13.1.0 → 15.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 (72) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +33 -5
  4. package/bin/lib/cli-shared.mjs +30 -1
  5. package/bin/lib/copy.mjs +279 -6
  6. package/bin/lib/db.mjs +2 -0
  7. package/docs/adr/0004-page-content-state-is-composed.md +39 -5
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
  9. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
  10. package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
  11. package/docs/code-style.md +4 -1
  12. package/docs/data-layer.md +9 -0
  13. package/package.json +1 -1
  14. package/registry/instructions/opus.md +3 -3
  15. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  16. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
  17. package/registry/templates/app/src/App.tsx +11 -6
  18. package/src/core/types.ts +3 -4
  19. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  20. package/src/ui/components/patterns/confirm.tsx +2 -31
  21. package/src/ui/components/patterns/content-header.tsx +42 -141
  22. package/src/ui/components/patterns/data-state.tsx +42 -68
  23. package/src/ui/components/patterns/form.tsx +15 -15
  24. package/src/ui/components/patterns/list.tsx +55 -34
  25. package/src/ui/components/patterns/page-state.tsx +81 -51
  26. package/src/ui/components/patterns/page.tsx +228 -97
  27. package/src/ui/components/patterns/state-surface.tsx +262 -0
  28. package/src/ui/components/patterns/surface-header.tsx +204 -0
  29. package/src/ui/components/patterns/trigger.tsx +9 -10
  30. package/src/ui/components/patterns/view.tsx +14 -16
  31. package/src/ui/components/primitives/alert.tsx +1 -33
  32. package/src/ui/components/primitives/avatar.tsx +15 -5
  33. package/src/ui/components/primitives/badge.tsx +2 -43
  34. package/src/ui/components/primitives/button-group.tsx +34 -8
  35. package/src/ui/components/primitives/button.tsx +31 -35
  36. package/src/ui/components/primitives/control.ts +69 -0
  37. package/src/ui/components/primitives/dot.tsx +1 -30
  38. package/src/ui/components/primitives/input-group.tsx +11 -8
  39. package/src/ui/components/primitives/item.tsx +3 -1
  40. package/src/ui/components/primitives/menu.tsx +1 -7
  41. package/src/ui/components/primitives/pagination.tsx +16 -8
  42. package/src/ui/components/primitives/select.tsx +2 -2
  43. package/src/ui/components/primitives/spinner.tsx +13 -16
  44. package/src/ui/components/primitives/switch.tsx +4 -1
  45. package/src/ui/components/primitives/tabs.tsx +5 -3
  46. package/src/ui/components/primitives/toggle.tsx +9 -4
  47. package/src/ui/docs/content/action-form.md +26 -0
  48. package/src/ui/docs/content/action-list-dialog.md +2 -2
  49. package/src/ui/docs/content/action-list.md +35 -2
  50. package/src/ui/docs/content/action-trigger.md +5 -4
  51. package/src/ui/docs/content/action-view.md +3 -2
  52. package/src/ui/docs/content/alert.md +16 -4
  53. package/src/ui/docs/content/avatar.md +7 -3
  54. package/src/ui/docs/content/button.md +48 -17
  55. package/src/ui/docs/content/communication.md +36 -0
  56. package/src/ui/docs/content/content.md +5 -4
  57. package/src/ui/docs/content/data-state.md +17 -13
  58. package/src/ui/docs/content/dialog.md +1 -4
  59. package/src/ui/docs/content/input.md +1 -1
  60. package/src/ui/docs/content/item.md +1 -1
  61. package/src/ui/docs/content/page.md +160 -37
  62. package/src/ui/docs/content/pagination.md +11 -9
  63. package/src/ui/docs/content/semantic-context.md +3 -2
  64. package/src/ui/docs/content/sidebar.md +2 -42
  65. package/src/ui/docs/content/spinner.md +9 -6
  66. package/src/ui/docs/content/switch.md +1 -1
  67. package/src/ui/docs/content/tabs.md +1 -1
  68. package/src/ui/docs/content/toggle.md +1 -1
  69. package/src/ui/drivers/react.tsx +1 -6
  70. package/src/ui/meta.ts +8 -8
  71. package/src/ui/react.tsx +16 -18
  72. package/src/ui/components/patterns/shell-nav.tsx +0 -154
package/CHANGELOG.md CHANGED
@@ -7,6 +7,103 @@ 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
+ ## 15.0.0 — 2026-09-09
11
+
12
+ `PageHeader` passa a oferecer `variant="bar"`, uma apresentação compacta da mesma região de título,
13
+ descrição e ações. O novo `PageBack` recebe o destino pai explícito: no header padrão fica acima do
14
+ título com rótulo; na barra vira icon-only com tooltip e nome acessível. Páginas comuns deixam de
15
+ precisar de um chrome paralelo, enquanto `PageActionsTarget` permanece para workspaces imersivos que
16
+ já tenham uma faixa própria. Para hierarquias com mais de um ancestral, `PageNavigation` recebe o
17
+ `Breadcrumb` na mesma posição introdutória e não pode ser combinado com `PageBack`. Ações em barra
18
+ usam a escala compacta: `sm` com texto e `icon-sm` quando exibem somente um ícone.
19
+
20
+ `PageState` passa a centralizar carregamento, falha e vazio sem moldura e a ocupar a altura
21
+ disponível. `Page` reconhece o estado mesmo quando um componente intermediário o renderiza, oculta
22
+ o cabeçalho inteiro nos três estados e o restaura em `ready`. A falha mantém `role="alert"`, deixa
23
+ de usar o visual de `Alert` e apresenta a recuperação como botão `outline` textual. A moldura tracejada continua disponível no `Empty` usado diretamente para
24
+ representar uma região disponível à criação ou vínculo.
25
+
26
+ O CLI carrega o `.env` ao lado do `opus.config.ts` antes de importar o config, em `db`, `seed` e
27
+ `gen`. O config do consumer costuma abrir o banco no topo do módulo, lendo `process.env` na hora
28
+ do import: sem isso, um projeto que guarda a URL no `.env` migrava o banco DEFAULT do config
29
+ enquanto o runtime usava outro, e o schema aplicado sumia do banco que a aplicação abre. Variável
30
+ já definida no ambiente vence o arquivo (a semântica do `--env-file` do Node), então
31
+ `DATABASE_URL=... opus db migrate` e a injeção do container seguem mandando; ausência de `.env`
32
+ é normal e não é erro. Um projeto que já carregava o `.env` por conta própria no `opus.config.ts`
33
+ continua correto: as duas cargas são idempotentes e a primeira vence.
34
+
35
+ **Breaking — API removida.** `Page` deixa de aceitar `count` e o barrel deixa de exportar
36
+ `PageMeta` (ADR 0009). O cabeçalho da página reconhece somente `PageTitle`, `PageDescription`,
37
+ `PageActions` e a região introdutória; um total vai para o conteúdo que o explica — a listagem, a
38
+ métrica ou uma seção com `Content`, onde `ContentMeta` e `Content.count` continuam disponíveis.
39
+ `PageBack` passa a exigir `href`: sem destino, o elemento não era tabulável nem tinha nome
40
+ acessível. `PageNavigation` e `PageBack` não podem ser combinados na mesma página: escolha o
41
+ retorno de um ancestral só ou a trilha completa.
42
+
43
+ **Breaking — comportamento e DOM.** `Page` oculta o `PageHeader` inteiro enquanto o `PageState`
44
+ estiver em carregamento, erro ou vazio, sem opt-out. Teste que procura o título da página durante a
45
+ carga passa a falhar; o nível semântico segue disponível no `role="heading"` da própria superfície
46
+ de estado. Some junto a região introdutória, então uma subpágina em erro fica sem o `PageBack` para
47
+ o pai; quem depende desse retorno oferece a saída pelo `action` do próprio `PageState`. O motivo, a
48
+ alternativa descartada e esse custo estão no adendo da ADR 0004. O erro e o vazio do `PageState`
49
+ trocam de anatomia: sai `[data-slot="alert"]` e `[data-slot="alert-actions"]`, entram `error-state`
50
+ e `state-actions`, e o vazio passa de `data-frame="region"` para `bare`. As ações de linha do
51
+ `ActionList` passam a vir dentro de um `ButtonGroup`, o que acrescenta um `role="group"` por linha;
52
+ as ações da barra (Filtros, Recarregar, Exibição) passaram de `outline` para `ghost`, e o que o
53
+ consumidor entrega em `ActionFilterBar.actions` agora fica depois do grupo de controles auxiliares,
54
+ no fim da barra. A recuperação do aviso inline de erro virou ação só de ícone, com o rótulo no nome
55
+ acessível e no tooltip em vez do texto na tela. Quem afirma texto, variante ou slot nesses pontos
56
+ precisa revisar as asserções.
57
+
58
+ **Breaking — ícones voltam à escala.** `controlGlyph` passa a devolver seletores literais. Montada
59
+ em runtime, a classe saía certa no DOM, mas o scanner do Tailwind não a via e o CSS nunca era
60
+ gerado: na prática, todo ícone de `Button` sem `size-*` próprio vinha no tamanho padrão do lucide.
61
+ Agora todos passam a medir o degrau da escala, então ícones encolhem visivelmente no consumidor. O
62
+ `ActionTrigger` só de ícone usa `icon-xs` (1.5rem) por padrão, o degrau da ação que mora dentro de
63
+ uma linha densa, e passa a respeitar um `size` declarado quando a composição pede mais presença —
64
+ `icon-sm` na barra, por exemplo. Atenção ao inverso: um `size` que antes era ignorado no modo
65
+ ícone passa a valer, então `size="lg"` esquecido ao lado de `icon` agora produz um botão de altura
66
+ de texto com um ícone só.
67
+
68
+ Do lado aditivo, `ButtonGroup` ganha `mode`: `connected`, o comportamento de sempre com as bordas
69
+ coladas, e `spaced`, que só agrupa e espaça — é o que as ações de linha e os controles auxiliares
70
+ da barra do `ActionList` passam a usar. `ActionList` ganha `toolbarActions` para o que o consumidor
71
+ acrescenta no fim da barra de filtros.
72
+
73
+ ## 14.0.0 — 2026-09-09
74
+
75
+ Estados vazio, carregando e erro passam a uma composição só. `DataState` compõe `Empty` (moldura
76
+ sólida, `bare` dentro de tabela) e o mesmo `Alert` de contexto `danger` com botão de recuperação
77
+ que `PageState` já usava; o `Spinner` vira decorativo e o contêiner carrega o único `role="status"`.
78
+ As props de estado têm um nome só em `DataState`, `PageState`, `ActionList` e `ActionListDialog`:
79
+ `emptyMessage`, `errorMessage`, `retryLabel`; `PageState` ganha `onRetry` e `ActionView` ganha
80
+ `emptyMessage` como atalho. `ContentHeader` e `PageHeader` renderizam a mesma anatomia
81
+ (`surface-header.tsx`), e `Content` passa a usar `count`, como `Page`.
82
+
83
+ A escala de `size` é uma só (`primitives/control.ts`): `xs`, `sm`, `default`, `lg` (1.5, 2, 2.25
84
+ e 2.5rem) e `icon-xs`, `icon-sm`, `icon`, `icon-lg` (1.5, 1.75, 2.25 e 2.5rem), consumida por
85
+ Button, InputGroupButton, Toggle, Select, Tabs, Switch, Item, Avatar, Spinner, Pagination e
86
+ ActionTrigger. O degrau de 1.75rem que os consumidores forçavam por classe agora é `icon-sm`.
87
+ `Button.icon` recebe um nó (`icon={<Plus />}`), como todos os outros componentes. `LabelHelp` é
88
+ exportado pelo barrel e documentado em `form.md`.
89
+
90
+ O extrator de copy alcança o que antes escapava do inventário: chamadas imperativas de diálogo
91
+ (`dialog.confirm`, `alert`, `prompt`, `choose` — título, descrição e rótulos das ações),
92
+ `TooltipContent`, rótulos em arrays literais locais percorridos por `map`, o `help` de
93
+ `LabelHelp` e elementos JSX com genérico (`<ActionList<A, B>>`). Os campos de estado seguem os
94
+ nomes novos (`emptyMessage`, `errorMessage`, `retryLabel`).
95
+
96
+ **Breaking:** renomeie `emptyText`, `errorText` e `retryText` para `emptyMessage`,
97
+ `errorMessage` e `retryLabel` em `DataState`, `PageState` e `ActionListDialog`
98
+ (`Select.emptyText` não muda). Troque `Content.meta` por `count`; `ContentHeader` só existe
99
+ dentro de `Content`. `icon-sm` passa a medir 1.75rem e `Avatar` segue a escala (`sm` antigo é
100
+ `xs`; `default` cresce para 2.25rem); classes `size-7` forçadas viram `size="icon-sm"`.
101
+ `Button.icon` deixa de aceitar componente: `icon={Trash}` vira `icon={<Trash />}`. Removidos:
102
+ `TbdlibProvider` (use `OpusProvider`), `ShellNav*` (use `SidebarNav`), `confirm()` e `ConfirmHost`
103
+ (use `dialog.confirm` e `DialogHost`), `FieldSpec.hint` (use `help`) e as variantes semânticas
104
+ legadas de Button, Badge, Alert, Dot e MenuItem — declare `context` e `variant`. Testes que
105
+ liam o markup do vazio de `DataState` passam a olhar `[data-slot=empty]`.
106
+
10
107
  ## 13.1.0 — 2026-09-08
11
108
 
12
109
  Erros deixam de falar inglês e de vazar detalhe interno. `normalizeError` devolve a microcopy fixa
package/bin/cli.mjs CHANGED
@@ -328,6 +328,8 @@ Flags
328
328
  --json (introspect, seed) Saída estruturada em JSON.
329
329
  --monorepo (create) Cria a raiz do workspace em vez de um app.
330
330
  --config <path> (gen, db, seed) Caminho do opus.config.ts. Default: ./opus.config.ts.
331
+ O .env ao lado desse arquivo é carregado antes do config; variável
332
+ já definida no ambiente vence o arquivo.
331
333
  --output <path> (gen) Pasta de saída. Default: a do config, ou ./.gen.
332
334
  --profile <nome> (seed) Perfil do dataset. Default: o defaultProfile do seed.
333
335
  --scope <nome> (seed) Escopo explícito dos dados. Alternativa: OPUS_SEED_SCOPE.
package/bin/lib/check.mjs CHANGED
@@ -107,8 +107,9 @@ const UI_STRUCTURAL_PARENTS = new Map([
107
107
  ["PageHeader", new Set(["Page"])],
108
108
  ["PageBody", new Set(["Page"])],
109
109
  ["PageTitle", new Set(["PageHeader"])],
110
+ ["PageBack", new Set(["PageHeader"])],
111
+ ["PageNavigation", new Set(["PageHeader"])],
110
112
  ["PageDescription", new Set(["PageHeader"])],
111
- ["PageMeta", new Set(["PageHeader"])],
112
113
  ["PageActions", new Set(["PageHeader"])],
113
114
  ["ContentHeader", new Set(["Content"])],
114
115
  ["ContentBody", new Set(["Content"])],
@@ -166,7 +167,11 @@ const UI_STRUCTURAL_ROOTS = new Map([
166
167
  {
167
168
  header: "PageHeader",
168
169
  body: "PageBody",
169
- shorthand: new Set(["title", "description", "count", "actions"]),
170
+ shorthand: new Set(["title", "description", "actions"]),
171
+ // O contador saiu do título da página (ADR 0009). Tirar `count` do shorthand não proíbe
172
+ // nada sozinho — só apaga o marcador que fazia o gate enxergar aquele Page —, então a
173
+ // prop removida é cobrada aqui, nas duas formas de composição.
174
+ removed: new Map([["count", "o total pertence ao conteúdo que o explica"]]),
170
175
  },
171
176
  ],
172
177
  [
@@ -174,7 +179,8 @@ const UI_STRUCTURAL_ROOTS = new Map([
174
179
  {
175
180
  header: "ContentHeader",
176
181
  body: "ContentBody",
177
- shorthand: new Set(["title", "description", "meta", "actions"]),
182
+ shorthand: new Set(["title", "description", "count", "actions"]),
183
+ removed: new Map([["meta", "use `count`"]]),
178
184
  },
179
185
  ],
180
186
  ]);
@@ -183,8 +189,9 @@ const UI_STRICT_DIRECT_COMPONENTS = new Set([
183
189
  "PageHeader",
184
190
  "PageBody",
185
191
  "PageTitle",
192
+ "PageBack",
193
+ "PageNavigation",
186
194
  "PageDescription",
187
- "PageMeta",
188
195
  "PageActions",
189
196
  "ContentHeader",
190
197
  "ContentBody",
@@ -202,7 +209,13 @@ const UI_STRUCTURAL_HEADERS = new Map([
202
209
  "PageHeader",
203
210
  {
204
211
  title: "PageTitle",
205
- optional: new Set(["PageDescription", "PageMeta", "PageActions"]),
212
+ optional: new Set([
213
+ "PageBack",
214
+ "PageNavigation",
215
+ "PageDescription",
216
+ "PageActions",
217
+ ]),
218
+ exclusive: [new Set(["PageBack", "PageNavigation"])],
206
219
  shorthand: new Set(),
207
220
  },
208
221
  ],
@@ -898,6 +911,10 @@ export function checkUiStructure(file, text) {
898
911
  (name) =>
899
912
  children.names.filter((child) => child === name).length > 1,
900
913
  );
914
+ const combinedExclusive = (header.exclusive ?? []).some(
915
+ (group) =>
916
+ children.names.filter((name) => group.has(name)).length > 1,
917
+ );
901
918
  const titleValid =
902
919
  header.titleRequired === false ? titles <= 1 : titles === 1;
903
920
  const recognizedContent =
@@ -910,6 +927,7 @@ export function checkUiStructure(file, text) {
910
927
  !(header.allowOpaque && children.hasOpaque)) ||
911
928
  unexpected ||
912
929
  duplicatedOptional ||
930
+ combinedExclusive ||
913
931
  (children.hasOpaque && !header.allowOpaque)
914
932
  ) {
915
933
  add(
@@ -932,6 +950,16 @@ export function checkUiStructure(file, text) {
932
950
  : [],
933
951
  ),
934
952
  );
953
+ for (const [attribute, hint] of root.removed ?? []) {
954
+ if (attributes.has(attribute)) {
955
+ add(
956
+ node,
957
+ component,
958
+ `${component} não aceita mais a propriedade \`${attribute}\`: ${hint}.`,
959
+ "ui-structure-removed-prop",
960
+ );
961
+ }
962
+ }
935
963
  const shorthand = [...root.shorthand].some((attribute) =>
936
964
  attributes.has(attribute),
937
965
  );
@@ -55,10 +55,39 @@ export async function fileExists(file) {
55
55
  // Config do consumer
56
56
  // =============================================================================
57
57
 
58
+ /**
59
+ * Carrega o `.env` ao lado do `opus.config.ts` no processo do CLI, para que ele chegue
60
+ * aos runners (que herdam o ambiente) ANTES de o config do consumer ser importado.
61
+ *
62
+ * O config costuma abrir a conexão do banco no topo do módulo, lendo `process.env` na
63
+ * hora do import: sem isto, `db` e `seed` caem na conexão default e operam um banco
64
+ * diferente do que o runtime do projeto usa. Ausência do arquivo é normal (container e
65
+ * CI injetam por ambiente) e não é erro; qualquer outra falha de leitura sobe.
66
+ *
67
+ * Variável já definida no ambiente VENCE o arquivo (semântica do `--env-file` do Node),
68
+ * então `DATABASE_URL=... opus db migrate` e a injeção do container continuam mandando.
69
+ *
70
+ * Devolve o caminho carregado, ou `null` quando não havia `.env`.
71
+ */
72
+ export function loadProjectEnv(configPath) {
73
+ const envFile = path.join(path.dirname(configPath), '.env')
74
+ try {
75
+ process.loadEnvFile(envFile)
76
+ return envFile
77
+ } catch (cause) {
78
+ if (cause.code === 'ENOENT') return null
79
+ throw cause
80
+ }
81
+ }
82
+
58
83
  /**
59
84
  * Resolve o `opus.config.ts` a partir de `flags.config` (default: `./opus.config.ts`),
60
85
  * contido no projeto. Lança `Error` com uma mensagem única quando o arquivo não existe;
61
86
  * cada comando decide o canal (texto ou JSON) e o código de saída.
87
+ *
88
+ * Resolver o config é também o momento em que o projeto passa a ser conhecido, então o
89
+ * `.env` dele é carregado aqui (ver `loadProjectEnv`) — em um lugar só, para que nenhum
90
+ * comando que importe o config do consumer possa esquecer.
62
91
  */
63
92
  export function resolveConfig(flags, cwd = process.cwd()) {
64
93
  const root = canonicalProjectDirectory(cwd)
@@ -69,7 +98,7 @@ export function resolveConfig(flags, cwd = process.cwd()) {
69
98
  'Passe o caminho com --config <path> ou crie o arquivo na raiz do projeto.',
70
99
  )
71
100
  }
72
- return { cwd: root, configPath: config.path }
101
+ return { cwd: root, configPath: config.path, envFile: loadProjectEnv(config.path) }
73
102
  }
74
103
 
75
104
  // =============================================================================
package/bin/lib/copy.mjs CHANGED
@@ -103,12 +103,23 @@ const JSX_CHILD_ROLES = new Map([
103
103
  ['PopoverDescription', 'description'],
104
104
  ['PopoverTitle', 'title'],
105
105
  ['PageDescription', 'description'],
106
+ // O texto do PageBack é o NOME do destino ('Clientes'), como um item de trilha — não um
107
+ // comando. Classificado como `button`, a política universal cobraria verbo de ação.
108
+ ['PageBack', 'breadcrumb'],
106
109
  ['PageTitle', 'title'],
107
110
  ['TableCaption', 'description'],
108
111
  ['TableHead', 'heading'],
109
112
  ['TabsTrigger', 'tab'],
113
+ // A Base não tem papel `tooltip`. O tooltip nomeia um controle icon-only, logo é um
114
+ // fragmento (sem ponto final), não uma frase; `label` é o papel que reflete isso.
115
+ ['TooltipContent', 'label'],
110
116
  ])
111
117
 
118
+ // Componentes cuja copy entra pelas próprias props e que, como filhos, não acrescentam
119
+ // texto ao pai: `<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>` inventaria `Nome`
120
+ // como label e a ajuda como helper-text, sem tornar o filho opaco.
121
+ const SELF_CONTAINED_CHILD_COMPONENTS = new Set(['LabelHelp'])
122
+
112
123
  const OPTIONAL_CHILD_COMPONENTS = new Set(['ActionForm', 'ActionFormCard', 'ActionFormDialog'])
113
124
  const JSX_ACTION_CONSUMERS = new Set([
114
125
  'ActionForm', 'ActionFormCard', 'ActionFormDialog', 'ActionList', 'ActionListDialog', 'ActionTrigger', 'ActionView',
@@ -125,6 +136,14 @@ const JSX_PROP_ROLES = new Map([
125
136
  ['ActionForm', new Map([['submitLabel', 'button'], ['cancelLabel', 'button']])],
126
137
  ['ActionFormCard', new Map([['title', 'title'], ['description', 'description'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
127
138
  ['ActionFormDialog', new Map([['title', 'title'], ['description', 'dialog-body'], ['submitLabel', 'button'], ['cancelLabel', 'button']])],
139
+ // Estados unificados (14.0): a mesma tríade de mensagens em ActionList, ActionListDialog,
140
+ // DataState e PageState; ActionView só tem o vazio.
141
+ ['ActionList', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
142
+ ['ActionListDialog', new Map([
143
+ ['title', 'title'], ['description', 'dialog-body'],
144
+ ['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
145
+ ])],
146
+ ['ActionView', new Map([['emptyMessage', 'empty-state']])],
128
147
  // `itemLabel` identifica o dado alvo (por exemplo, `customer.name`); não é
129
148
  // microcopy estável e, por isso, não pertence ao inventário editorial.
130
149
  ['ActionTrigger', new Map([['label', 'button']])],
@@ -132,7 +151,7 @@ const JSX_PROP_ROLES = new Map([
132
151
  ['CommandInput', new Map([['placeholder', 'placeholder']])],
133
152
  ['Content', new Map([['title', 'title'], ['description', 'description']])],
134
153
  ['ContentHeader', new Map([['title', 'title'], ['description', 'description']])],
135
- ['DataState', new Map([['emptyText', 'empty-state'], ['errorText', 'error']])],
154
+ ['DataState', new Map([['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button']])],
136
155
  // A Dock nomeia a barra e cada ação por prop. Sem estas linhas, a copy sairia do inventário
137
156
  // exatamente quando uma superfície migra de <Button aria-label> para <DockAction label>.
138
157
  ['Dock', new Map([['label', 'label']])],
@@ -140,8 +159,14 @@ const JSX_PROP_ROLES = new Map([
140
159
  ['Input', new Map([['placeholder', 'placeholder']])],
141
160
  ['InputGroupInput', new Map([['placeholder', 'placeholder']])],
142
161
  ['InputGroupTextarea', new Map([['placeholder', 'placeholder']])],
162
+ // A ajuda vive num tooltip (portal): não herda transformação nem classe do label.
163
+ ['LabelHelp', new Map([['help', 'helper-text']])],
143
164
  ['MetricCard', new Map([['label', 'label'], ['description', 'description']])],
144
165
  ['Page', new Map([['title', 'title'], ['description', 'description']])],
166
+ ['PageState', new Map([
167
+ ['title', 'title'], ['description', 'description'],
168
+ ['emptyMessage', 'empty-state'], ['errorMessage', 'error'], ['retryLabel', 'button'],
169
+ ])],
145
170
  ['Select', new Map([
146
171
  ['placeholder', 'placeholder'],
147
172
  ['searchPlaceholder', 'placeholder'],
@@ -215,6 +240,36 @@ const JSX_PORTAL_BOUNDARIES = new Set([
215
240
  'PopoverContent', 'TooltipContent',
216
241
  ])
217
242
 
243
+ // Respostas imperativas `dialog.*` da UI Opus. O DialogHost renderiza em portal com classes
244
+ // próprias; nenhuma transformação do chamador alcança o texto.
245
+ const DIALOG_METHOD_ROLES = new Map([
246
+ ['alert', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'], ['action', 'button']])],
247
+ ['confirm', new Map([
248
+ ['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
249
+ ['action', 'button'], ['cancel', 'button'],
250
+ ])],
251
+ ['prompt', new Map([
252
+ ['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body'],
253
+ ['action', 'button'], ['cancel', 'button'], ['placeholder', 'placeholder'],
254
+ ])],
255
+ ['choose', new Map([['title', 'title'], ['description', 'dialog-body'], ['body', 'dialog-body']])],
256
+ ])
257
+
258
+ // Métodos de array que não mutam o receptor. Os que devolvem os próprios elementos (num
259
+ // array novo ou avulsos) exigem que o resultado também seja usado de forma estável; os
260
+ // demais só produzem primitivos, novos objetos ou nada.
261
+ const ARRAY_RETURNING_ARRAY_METHODS = new Set(['concat', 'filter', 'flat', 'slice', 'toReversed', 'toSorted'])
262
+ const ELEMENT_PICKING_ARRAY_METHODS = new Set(['at', 'find', 'findLast'])
263
+ const ELEMENT_ITERATING_ARRAY_METHODS = new Set([
264
+ 'every', 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'flatMap', 'forEach', 'map', 'some',
265
+ ])
266
+ const READ_ONLY_ARRAY_METHODS = new Set([
267
+ ...ARRAY_RETURNING_ARRAY_METHODS, ...ELEMENT_PICKING_ARRAY_METHODS, ...ELEMENT_ITERATING_ARRAY_METHODS,
268
+ 'includes', 'indexOf', 'join', 'lastIndexOf',
269
+ ])
270
+ // Cadeia que preserva os elementos entre o array literal e a iteração que os lê.
271
+ const ELEMENT_PRESERVING_ARRAY_METHODS = new Set(['filter', 'slice', 'toReversed', 'toSorted'])
272
+
218
273
  const digest = (content) => createHash('sha256').update(content).digest('hex')
219
274
  const portable = (value) => value.split(path.sep).join('/')
220
275
 
@@ -421,6 +476,17 @@ function knownBindAction(call, checker) {
421
476
  return importedMemberName(call.expression, checker, (module) => CONTRACT_MODULES.has(module)) === 'bindAction'
422
477
  }
423
478
 
479
+ /** Reconhece `dialog.confirm({...})` e `Opus.dialog.alert({...})` importados da UI Opus. */
480
+ function knownDialogCall(call, checker) {
481
+ const callee = unwrap(call.expression)
482
+ if (
483
+ ts.isPropertyAccessExpression(callee) &&
484
+ DIALOG_METHOD_ROLES.has(callee.name.text) &&
485
+ importedMemberName(callee.expression, checker, uiModule) === 'dialog'
486
+ ) return { method: callee.name.text, field: `dialog.${callee.name.text}()` }
487
+ return null
488
+ }
489
+
424
490
  function safeBindActionBinding(call, checker) {
425
491
  if (call.arguments.length < 2) return false
426
492
  const binding = unwrap(call.arguments[1])
@@ -521,6 +587,16 @@ function stableExpressionUse(node, checker, mode, stack) {
521
587
  const parent = carrier.parent
522
588
 
523
589
  if (assignmentTarget(carrier)) return false
590
+ if (consumerMode === 'aggregate' && ts.isCallExpression(parent) && parent.expression === carrier) {
591
+ const access = unwrap(carrier)
592
+ return (
593
+ ts.isPropertyAccessExpression(access) && arrayValued(access.expression, checker) &&
594
+ readOnlyArrayMethodUse(access.name.text, parent, checker, stack)
595
+ )
596
+ }
597
+ if (ts.isForOfStatement(parent) && parent.expression === carrier) {
598
+ return consumerMode === 'aggregate' && arrayValued(carrier, checker) && readOnlyForOfBinding(parent, checker)
599
+ }
524
600
  if (ts.isVariableDeclaration(parent) && parent.initializer === carrier) {
525
601
  if (!ts.isVariableDeclarationList(parent.parent) || (parent.parent.flags & ts.NodeFlags.Const) === 0) return false
526
602
  const aliases = bindingNames(parent.name, checker)
@@ -534,6 +610,8 @@ function stableExpressionUse(node, checker, mode, stack) {
534
610
  if (ts.isCallExpression(parent) && parent.arguments.some((argument) => argument === carrier)) {
535
611
  if (consumerMode === 'aggregate') {
536
612
  if (knownDictionaryFactory(parent, checker)) return true
613
+ // O DialogHost copia as opções (`{ ...options }`) e só as lê; nada muta o literal.
614
+ if (parent.arguments[0] === carrier && knownDialogCall(parent, checker) !== null) return true
537
615
  return knownContractFactory(parent, checker) && stableFactoryResult(parent, checker, stack)
538
616
  }
539
617
  if (
@@ -573,6 +651,166 @@ function stableReferences(binding, checker, mode, stack) {
573
651
  return stable
574
652
  }
575
653
 
654
+ /**
655
+ * Um parâmetro de callback (ou variável de `for…of`) é somente leitura quando cada uso é
656
+ * `elemento.chave` fora de posição de atribuição. Strings são imutáveis, então uma leitura de
657
+ * propriedade nunca altera o rótulo literal; qualquer outro uso (spread, argumento, retorno,
658
+ * atribuição) poderia mutar ou fazer o objeto escapar e é rejeitado. Destructuring copia os
659
+ * valores de primeiro nível e, por isso, também não alcança o literal.
660
+ */
661
+ function readOnlyElementBinding(name, checker) {
662
+ if (ts.isObjectBindingPattern(name)) return true
663
+ if (!ts.isIdentifier(name)) return false
664
+ const symbol = bindingSymbol(name, checker)
665
+ if (symbol === undefined) return false
666
+ let readOnly = true
667
+ const visit = (node) => {
668
+ if (!readOnly) return
669
+ if (ts.isIdentifier(node) && sameBinding(node, symbol, checker) && !declarationIdentifier(node, symbol)) {
670
+ const carrier = transparentCarrier(node)
671
+ const access = carrier.parent
672
+ readOnly =
673
+ (ts.isPropertyAccessExpression(access) || ts.isElementAccessExpression(access)) &&
674
+ access.expression === carrier && !assignmentTarget(access)
675
+ }
676
+ ts.forEachChild(node, visit)
677
+ }
678
+ visit(name.getSourceFile())
679
+ return readOnly
680
+ }
681
+
682
+ function readOnlyCallback(callback, checker) {
683
+ const value = callback === undefined ? null : unwrap(callback)
684
+ if (value === null || (!ts.isArrowFunction(value) && !ts.isFunctionExpression(value))) return false
685
+ // O terceiro parâmetro expõe o próprio array ao callback; índice e `thisArg` não.
686
+ if (value.parameters.length > 2) return false
687
+ const element = value.parameters[0]
688
+ if (element === undefined) return true
689
+ if (element.dotDotDotToken !== undefined || element.initializer !== undefined) return false
690
+ return readOnlyElementBinding(element.name, checker)
691
+ }
692
+
693
+ function readOnlyArrayMethodUse(method, call, checker, stack) {
694
+ if (!READ_ONLY_ARRAY_METHODS.has(method)) return false
695
+ if (ELEMENT_ITERATING_ARRAY_METHODS.has(method) && !readOnlyCallback(call.arguments[0], checker)) return false
696
+ if (ARRAY_RETURNING_ARRAY_METHODS.has(method) || ELEMENT_PICKING_ARRAY_METHODS.has(method)) {
697
+ return stableExpressionUse(call, checker, 'aggregate', stack)
698
+ }
699
+ return true
700
+ }
701
+
702
+ /**
703
+ * A semântica somente leitura de `map`, `filter`… vale para arrays. Um objeto literal pode
704
+ * declarar um método com o mesmo nome e mutar o que quiser; por isso o receptor precisa ser
705
+ * um `const` com array literal ou o resultado de um método que devolve array sobre ele.
706
+ */
707
+ function arrayValued(node, checker) {
708
+ const value = unwrap(node)
709
+ if (ts.isIdentifier(value)) {
710
+ const binding = constInitializer(value, checker)
711
+ return binding !== null && ts.isArrayLiteralExpression(unwrap(binding.initializer))
712
+ }
713
+ if (ts.isCallExpression(value)) {
714
+ const callee = unwrap(value.expression)
715
+ return ts.isPropertyAccessExpression(callee) && ARRAY_RETURNING_ARRAY_METHODS.has(callee.name.text) &&
716
+ arrayValued(callee.expression, checker)
717
+ }
718
+ return ts.isArrayLiteralExpression(value)
719
+ }
720
+
721
+ function readOnlyForOfBinding(statement, checker) {
722
+ const initializer = statement.initializer
723
+ if (!ts.isVariableDeclarationList(initializer) || initializer.declarations.length !== 1) return false
724
+ return readOnlyElementBinding(initializer.declarations[0].name, checker)
725
+ }
726
+
727
+ /**
728
+ * Localiza o array literal do qual um identificador é elemento: parâmetro de callback de
729
+ * `map`/`forEach`/`filter`… ou variável de `for…of`, possivelmente atrás de `filter`,
730
+ * `slice`, `toSorted` e `toReversed`, que preservam os elementos. Só arrays literais no
731
+ * mesmo arquivo entram; qualquer outra origem devolve null.
732
+ */
733
+ function elementSource(identifier, checker) {
734
+ const symbol = bindingSymbol(identifier, checker)
735
+ const declaration = symbol?.declarations?.find(
736
+ (item) => ts.isParameter(item) || ts.isBindingElement(item) || ts.isVariableDeclaration(item),
737
+ )
738
+ if (declaration === undefined) return null
739
+
740
+ let key = null
741
+ let binding = declaration
742
+ if (ts.isBindingElement(declaration)) {
743
+ if (declaration.dotDotDotToken !== undefined || declaration.initializer !== undefined) return null
744
+ const pattern = declaration.parent
745
+ if (!ts.isObjectBindingPattern(pattern) || (!ts.isParameter(pattern.parent) && !ts.isVariableDeclaration(pattern.parent))) return null
746
+ const keyNode = declaration.propertyName ?? declaration.name
747
+ if (!ts.isIdentifier(keyNode) && !ts.isStringLiteral(keyNode)) return null
748
+ key = keyNode.text
749
+ binding = pattern.parent
750
+ }
751
+
752
+ let receiver
753
+ if (ts.isParameter(binding)) {
754
+ if (binding.dotDotDotToken !== undefined || binding.initializer !== undefined) return null
755
+ const callback = binding.parent
756
+ if ((!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) || callback.parameters[0] !== binding) return null
757
+ const call = transparentCarrier(callback).parent
758
+ if (!ts.isCallExpression(call) || call.arguments[0] !== transparentCarrier(callback)) return null
759
+ const callee = unwrap(call.expression)
760
+ if (!ts.isPropertyAccessExpression(callee) || !ELEMENT_ITERATING_ARRAY_METHODS.has(callee.name.text)) return null
761
+ receiver = callee.expression
762
+ } else if (ts.isVariableDeclaration(binding)) {
763
+ const list = binding.parent
764
+ if (!ts.isVariableDeclarationList(list) || !ts.isForOfStatement(list.parent) || list.parent.initializer !== list) return null
765
+ receiver = list.parent.expression
766
+ } else return null
767
+
768
+ let source = unwrap(receiver)
769
+ while (
770
+ ts.isCallExpression(source) && ts.isPropertyAccessExpression(unwrap(source.expression)) &&
771
+ ELEMENT_PRESERVING_ARRAY_METHODS.has(unwrap(source.expression).name.text)
772
+ ) source = unwrap(unwrap(source.expression).expression)
773
+ const array = resolveBinding(source, checker)
774
+ return ts.isArrayLiteralExpression(array) ? { array, key } : null
775
+ }
776
+
777
+ /**
778
+ * Resolve `elemento.chave` (ou a chave destruturada) para o texto literal de cada elemento
779
+ * do array de origem. Um único elemento opaco ou sem texto estático torna o conjunto todo
780
+ * inextraível: o inventário não pode listar só parte do que a superfície renderiza.
781
+ */
782
+ function elementTexts(node, checker) {
783
+ const value = unwrap(node)
784
+ let identifier
785
+ let key = null
786
+ if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
787
+ identifier = unwrap(value.expression)
788
+ key = value.name.text
789
+ } else if (ts.isElementAccessExpression(value) && ts.isIdentifier(unwrap(value.expression))) {
790
+ identifier = unwrap(value.expression)
791
+ key = value.argumentExpression === undefined ? null : staticText(value.argumentExpression, checker)?.text ?? null
792
+ } else if (ts.isIdentifier(value)) {
793
+ identifier = value
794
+ } else return null
795
+ const source = elementSource(identifier, checker)
796
+ if (source === null) return null
797
+ if (ts.isIdentifier(value)) key = source.key
798
+ else if (source.key !== null) return null
799
+ if (key === null) return null
800
+
801
+ const listed = objects(source.array, checker)
802
+ if (listed.opaque.length > 0 || listed.values.length === 0) return null
803
+ const texts = []
804
+ for (const element of listed.values) {
805
+ const resolved = property(element, key, element.getSourceFile(), checker)
806
+ if (resolved.opaque !== undefined || resolved.candidate === undefined) return null
807
+ const text = staticText(propertyValue(resolved.candidate), checker)
808
+ if (text === null) return null
809
+ texts.push(text)
810
+ }
811
+ return texts
812
+ }
813
+
576
814
  /**
577
815
  * Objetos e arrays `const` continuam mutáveis. A extração só faz fold quando cada uso
578
816
  * está numa allowlist observável: composição `const` igualmente estável, factory Opus
@@ -982,7 +1220,6 @@ export function extractCopyFromSource(file, sourceText) {
982
1220
  const field = (object, prefix) => {
983
1221
  addProperty(object, 'label', 'label', prefix)
984
1222
  addProperty(object, 'placeholder', 'placeholder', prefix)
985
- addProperty(object, 'hint', 'helper-text', prefix)
986
1223
  addProperty(object, 'help', 'helper-text', prefix)
987
1224
  nestedObject(object, 'options', optionItems, prefix)
988
1225
  }
@@ -1384,6 +1621,11 @@ export function extractCopyFromSource(file, sourceText) {
1384
1621
  ? { kind: 'decorative' }
1385
1622
  : { kind: 'text', ...text, uppercase: inheritedUppercase }
1386
1623
  }
1624
+ const listed = elementTexts(value, checker)
1625
+ if (listed !== null) {
1626
+ const values = listed.map((item) => ({ kind: 'text', ...item, uppercase: inheritedUppercase }))
1627
+ return values.length === 1 ? values[0] : { kind: 'alternatives', values, node: values[0].node }
1628
+ }
1387
1629
  if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) {
1388
1630
  return childNode(value, inheritedUppercase, hiddenAncestor)
1389
1631
  }
@@ -1439,6 +1681,9 @@ export function extractCopyFromSource(file, sourceText) {
1439
1681
  if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
1440
1682
  const opening = ts.isJsxElement(child) ? child.openingElement : child
1441
1683
  const component = opusComponent(opening.tagName)
1684
+ if (component !== null && SELF_CONTAINED_CHILD_COMPONENTS.has(component) && ts.isJsxSelfClosingElement(child)) {
1685
+ return { kind: 'decorative' }
1686
+ }
1442
1687
  const provenElement = transformableElement(opening, component)
1443
1688
  const hidden = attribute(opening.attributes, 'aria-hidden')
1444
1689
  if (hidden.opaque !== undefined) return { kind: 'opaque', node: hidden.opaque }
@@ -1593,7 +1838,10 @@ export function extractCopyFromSource(file, sourceText) {
1593
1838
  if (component === null) return
1594
1839
  let inherited
1595
1840
  const inheritedFor = (className, styleName, portal = false) => {
1596
- if (portal || localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
1841
+ // Um componente que renderiza em portal (TooltipContent) não é descendente visual
1842
+ // dos ancestrais no JSX; a transformação deles não alcança o texto.
1843
+ if (portal || JSX_PORTAL_BOUNDARIES.has(component)) return false
1844
+ if (localTransformOverridesInheritance(opening.attributes, className, styleName)) return false
1597
1845
  inherited ??= ancestorTransform(element)
1598
1846
  return inherited.opaque ? false : inherited.uppercase
1599
1847
  }
@@ -1676,7 +1924,11 @@ export function extractCopyFromSource(file, sourceText) {
1676
1924
  }
1677
1925
  } else {
1678
1926
  const value = raw === null ? null : attributeText(item)
1679
- if (value === null) {
1927
+ // `title={group.label}` dentro de `GROUPS.map(...)`: cada literal do array local
1928
+ // vira um texto na própria linha, em vez de um diagnóstico sobre a prop.
1929
+ const listed = value === null && raw !== null ? elementTexts(raw, checker) : null
1930
+ const texts = value !== null ? [value] : listed ?? []
1931
+ if (texts.length === 0) {
1680
1932
  diagnostics.push(diagnostic(
1681
1933
  file,
1682
1934
  sourceFile,
@@ -1685,8 +1937,8 @@ export function extractCopyFromSource(file, sourceText) {
1685
1937
  raw === null ? 'missing-content' : 'content',
1686
1938
  ))
1687
1939
  }
1688
- else {
1689
- const entry = { source: file, line: lineOf(sourceFile, value.node), role, text: value.text }
1940
+ for (const text of texts) {
1941
+ const entry = { source: file, line: lineOf(sourceFile, text.node), role, text: text.text }
1690
1942
  if (transformed.uppercase) entry.transform = 'uppercase'
1691
1943
  entries.push(entry)
1692
1944
  }
@@ -1838,7 +2090,28 @@ export function extractCopyFromSource(file, sourceText) {
1838
2090
  }
1839
2091
  }
1840
2092
 
2093
+ /**
2094
+ * `dialog.confirm({ title, description, action })` é copy tão editorial quanto a de um
2095
+ * `<ActionFormDialog>`; a chamada só é imperativa. Um template com interpolação
2096
+ * (`description: describe(name)`) cai no diagnóstico `content` normal, declarável em
2097
+ * `copy.dynamic` como `message-template`.
2098
+ */
2099
+ function extractDialog(call, { method, field }) {
2100
+ const raw = call.arguments[0]
2101
+ const options = raw === undefined ? null : resolveBinding(raw, checker)
2102
+ if (options === null || !ts.isObjectLiteralExpression(options)) {
2103
+ diagnostics.push(diagnostic(file, sourceFile, raw ?? call, `${field} options`, 'structure'))
2104
+ return
2105
+ }
2106
+ for (const [name, role] of DIALOG_METHOD_ROLES.get(method)) addVisualObjectProperty(options, name, role, `${field}.`)
2107
+ if (method === 'choose') {
2108
+ nestedObject(options, 'actions', (action, prefix) => addVisualObjectProperty(action, 'label', 'button', prefix), `${field}.`)
2109
+ }
2110
+ }
2111
+
1841
2112
  function visit(node) {
2113
+ const dialogCall = ts.isCallExpression(node) ? knownDialogCall(node, checker) : null
2114
+ if (dialogCall !== null) extractDialog(node, dialogCall)
1842
2115
  const factory = ts.isCallExpression(node) ? contractFactory(node.expression) : null
1843
2116
  if (ts.isCallExpression(node) && factory !== null && node.arguments.length > 0) {
1844
2117
  hasContract = true