@softize/opus 19.0.0 → 20.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/check.mjs +198 -10
  4. package/bin/lib/copy.mjs +29 -0
  5. package/bin/lib/mcp.mjs +1 -1
  6. package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
  7. package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +19 -1
  9. package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
  10. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
  11. package/docs/data-products.md +15 -9
  12. package/docs/protocol.md +3 -4
  13. package/docs/releasing.md +2 -2
  14. package/package.json +2 -2
  15. package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
  16. package/registry/templates/app/package.json +1 -1
  17. package/src/core/runtime.ts +96 -8
  18. package/src/core/types.ts +5 -2
  19. package/src/ui/components/patterns/action-form-card.tsx +11 -1
  20. package/src/ui/components/patterns/content-header.tsx +109 -18
  21. package/src/ui/components/patterns/fill-region.ts +150 -0
  22. package/src/ui/components/patterns/list.tsx +91 -92
  23. package/src/ui/components/patterns/page.tsx +20 -4
  24. package/src/ui/components/patterns/presentation.tsx +52 -37
  25. package/src/ui/components/patterns/split.tsx +44 -4
  26. package/src/ui/components/patterns/surface-anatomy.ts +35 -8
  27. package/src/ui/components/patterns/surface-header.tsx +58 -25
  28. package/src/ui/components/patterns/trigger.tsx +12 -2
  29. package/src/ui/components/primitives/alert.tsx +9 -1
  30. package/src/ui/components/primitives/avatar.tsx +7 -3
  31. package/src/ui/components/primitives/card.tsx +1 -1
  32. package/src/ui/components/primitives/carousel.tsx +34 -5
  33. package/src/ui/components/primitives/checkbox.tsx +11 -3
  34. package/src/ui/components/primitives/command.tsx +2 -1
  35. package/src/ui/components/primitives/copyable.tsx +120 -10
  36. package/src/ui/components/primitives/dialog.tsx +42 -11
  37. package/src/ui/components/primitives/drawer.tsx +10 -2
  38. package/src/ui/components/primitives/empty.tsx +1 -1
  39. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  40. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  41. package/src/ui/components/primitives/input-group.tsx +9 -2
  42. package/src/ui/components/primitives/input-otp.tsx +1 -1
  43. package/src/ui/components/primitives/kbd.tsx +1 -1
  44. package/src/ui/components/primitives/label.tsx +3 -0
  45. package/src/ui/components/primitives/menu.tsx +5 -0
  46. package/src/ui/components/primitives/popover.tsx +1 -1
  47. package/src/ui/components/primitives/progress.tsx +9 -1
  48. package/src/ui/components/primitives/slider.tsx +62 -11
  49. package/src/ui/components/primitives/truncate.tsx +10 -1
  50. package/src/ui/docs/content/accordion.md +74 -27
  51. package/src/ui/docs/content/action-form-card.md +24 -15
  52. package/src/ui/docs/content/action-form-dialog.md +17 -7
  53. package/src/ui/docs/content/action-form.md +92 -77
  54. package/src/ui/docs/content/action-list-dialog.md +75 -38
  55. package/src/ui/docs/content/action-list.md +141 -58
  56. package/src/ui/docs/content/action-trigger.md +27 -10
  57. package/src/ui/docs/content/action-view.md +14 -14
  58. package/src/ui/docs/content/actions.md +161 -66
  59. package/src/ui/docs/content/ai.md +43 -44
  60. package/src/ui/docs/content/alert.md +83 -29
  61. package/src/ui/docs/content/ask.md +51 -26
  62. package/src/ui/docs/content/aspect-ratio.md +22 -24
  63. package/src/ui/docs/content/audit.md +23 -19
  64. package/src/ui/docs/content/auth.md +38 -19
  65. package/src/ui/docs/content/avatar.md +54 -5
  66. package/src/ui/docs/content/badge.md +29 -9
  67. package/src/ui/docs/content/breadcrumb.md +21 -8
  68. package/src/ui/docs/content/button.md +73 -43
  69. package/src/ui/docs/content/calendar.md +134 -26
  70. package/src/ui/docs/content/card.md +41 -15
  71. package/src/ui/docs/content/carousel.md +37 -6
  72. package/src/ui/docs/content/chat.md +118 -37
  73. package/src/ui/docs/content/checkbox.md +73 -7
  74. package/src/ui/docs/content/cli.md +94 -55
  75. package/src/ui/docs/content/collapsible.md +43 -19
  76. package/src/ui/docs/content/command.md +30 -8
  77. package/src/ui/docs/content/composer.md +23 -8
  78. package/src/ui/docs/content/content.md +119 -27
  79. package/src/ui/docs/content/copyable.md +39 -8
  80. package/src/ui/docs/content/customization.md +55 -46
  81. package/src/ui/docs/content/cycle.md +33 -18
  82. package/src/ui/docs/content/data-products.md +115 -0
  83. package/src/ui/docs/content/data-state.md +20 -6
  84. package/src/ui/docs/content/data.md +20 -19
  85. package/src/ui/docs/content/detail.md +12 -5
  86. package/src/ui/docs/content/dialog.md +107 -24
  87. package/src/ui/docs/content/dictionary-value.md +4 -4
  88. package/src/ui/docs/content/dock.md +54 -19
  89. package/src/ui/docs/content/dot.md +35 -5
  90. package/src/ui/docs/content/drawer.md +63 -19
  91. package/src/ui/docs/content/empty-value.md +20 -6
  92. package/src/ui/docs/content/empty.md +24 -3
  93. package/src/ui/docs/content/events.md +76 -16
  94. package/src/ui/docs/content/executions.md +9 -9
  95. package/src/ui/docs/content/field.md +21 -6
  96. package/src/ui/docs/content/getting-started.md +44 -34
  97. package/src/ui/docs/content/icon-picker.md +19 -13
  98. package/src/ui/docs/content/icon-tile.md +38 -15
  99. package/src/ui/docs/content/input-otp.md +58 -7
  100. package/src/ui/docs/content/input.md +22 -3
  101. package/src/ui/docs/content/item.md +53 -6
  102. package/src/ui/docs/content/kbd.md +5 -1
  103. package/src/ui/docs/content/label.md +13 -4
  104. package/src/ui/docs/content/log.md +5 -5
  105. package/src/ui/docs/content/markdown.md +5 -4
  106. package/src/ui/docs/content/mcp.md +49 -23
  107. package/src/ui/docs/content/menu.md +95 -10
  108. package/src/ui/docs/content/metric-card.md +12 -4
  109. package/src/ui/docs/content/observability.md +10 -9
  110. package/src/ui/docs/content/page.md +118 -12
  111. package/src/ui/docs/content/pagination.md +13 -5
  112. package/src/ui/docs/content/pane.md +55 -5
  113. package/src/ui/docs/content/popover.md +42 -5
  114. package/src/ui/docs/content/presentation.md +91 -47
  115. package/src/ui/docs/content/progress.md +27 -14
  116. package/src/ui/docs/content/queue.md +39 -21
  117. package/src/ui/docs/content/radio-group.md +26 -4
  118. package/src/ui/docs/content/router.md +29 -10
  119. package/src/ui/docs/content/runtime.md +90 -54
  120. package/src/ui/docs/content/scheduler.md +53 -21
  121. package/src/ui/docs/content/scroll-area.md +42 -35
  122. package/src/ui/docs/content/select.md +78 -33
  123. package/src/ui/docs/content/semantic-context.md +21 -2
  124. package/src/ui/docs/content/separator.md +15 -9
  125. package/src/ui/docs/content/sidebar.md +3 -2
  126. package/src/ui/docs/content/skeleton.md +6 -1
  127. package/src/ui/docs/content/slider.md +37 -9
  128. package/src/ui/docs/content/spinner.md +4 -3
  129. package/src/ui/docs/content/split.md +19 -0
  130. package/src/ui/docs/content/storage.md +8 -9
  131. package/src/ui/docs/content/switch.md +10 -7
  132. package/src/ui/docs/content/table.md +46 -8
  133. package/src/ui/docs/content/tabs.md +14 -4
  134. package/src/ui/docs/content/testing.md +76 -31
  135. package/src/ui/docs/content/textarea.md +5 -4
  136. package/src/ui/docs/content/toast.md +48 -7
  137. package/src/ui/docs/content/toggle.md +3 -3
  138. package/src/ui/docs/content/tokens.md +15 -26
  139. package/src/ui/docs/content/tooltip.md +18 -2
  140. package/src/ui/docs/content/truncate.md +36 -6
  141. package/src/ui/docs/content/ui.md +74 -22
  142. package/src/ui/docs/content/upgrading.md +45 -36
  143. package/src/ui/docs/plugin.ts +36 -2
  144. package/src/ui/docs/registry.tsx +6 -0
  145. package/src/ui/drivers/react.tsx +16 -8
  146. package/src/ui/meta.ts +32 -32
  147. package/src/ui/react.tsx +1 -0
  148. package/src/vite/design.ts +16 -4
@@ -30,6 +30,13 @@ uma `section`. A escolha de `fieldset` não define o acabamento visual: quando `
30
30
  presente, a moldura é contínua por padrão. Os filtros avançados preservam a legenda integrada à
31
31
  borda declarando `frameVariant="legend"`.
32
32
 
33
+ ### Emenda de 2026-09-26 — o acabamento `legend` também vale para `section`
34
+
35
+ A ADR 0025 estende `frameVariant="legend"` a `Content as="section"`, para que regiões de leitura
36
+ integrem o header à moldura sem declarar semântica de formulário, e permite `ContentMedia` na
37
+ legenda de um `fieldset`. A regra desta ADR se mantém: `as` escolhe o elemento e a semântica, e
38
+ `frameVariant` escolhe só o acabamento. Na legenda, contador, descrição e ações continuam inválidos.
39
+
33
40
  ## Consequências
34
41
 
35
42
  - A mesma API nomeia e delimita regiões de leitura e grupos de formulário.
@@ -143,7 +143,7 @@ anteriores já os delimitaram:
143
143
 
144
144
  | Slot | Card | Content | Page | Dialog | Drawer | Pane |
145
145
  | --- | --- | --- | --- | --- | --- | --- |
146
- | Media | sim | não | não | sim (`DialogMedia`) | sim | sim |
146
+ | Media | sim | sim (`ContentMedia`) | não | sim (`DialogMedia`) | sim | sim |
147
147
  | Title | sim | sim | sim | sim | sim | sim |
148
148
  | Description | sim | sim | não (ADR 0014) | não (ADR 0018) | não (ADR 0018) | não |
149
149
  | Meta | não | sim (contador) | não (ADR 0009) | não | não | não |
@@ -163,6 +163,19 @@ de um cabeçalho remontam a linha com `span`, incluindo o ícone dentro do texto
163
163
  `Content` permanece sem footer: é uma região nomeada dentro de outra superfície, e um rodapé
164
164
  pertence à superfície que a hospeda.
165
165
 
166
+ #### Emenda de 2026-09-25 — Content também expõe mídia
167
+
168
+ A aplicação posterior da anatomia à tela do Evento mostrou que regiões de leitura podem precisar
169
+ da mesma identificação visual que um Card sem deixarem de ser `Content`. Sem um slot próprio, a
170
+ documentação e os consumidores voltavam a inserir o ícone dentro de `ContentTitle`, repetindo o
171
+ desvio que motivou esta ADR.
172
+
173
+ `ContentHeader` passa a aceitar `ContentMedia`, e o shorthand expõe `media`. A mídia usa a mesma
174
+ moldura de `IconTile` das demais famílias e integra o grupo visual do título. Esse contrato vale
175
+ tanto para `section` quanto para `fieldset`: `as` troca o elemento e a semântica, não a apresentação
176
+ de mídia e título. Na `legend`, contador, descrição e ações continuam indisponíveis porque não
177
+ nomeiam o grupo de campos.
178
+
166
179
  ### 7. Título: hierarquia e tamanho são independentes
167
180
 
168
181
  O nível semântico vem de `level` (`1`–`6`) ou de `asChild`, e nunca do tamanho visual. O tamanho vem
@@ -231,6 +244,11 @@ seccionado com a primeira linha apagada.
231
244
  Pela mesma razão, a regra que suprime o divisor entre um header e um rodapé adjacentes precisa
232
245
  vencer a que o cria — não basta vir depois dela na folha, porque a ordem das variantes não é nossa.
233
246
 
247
+ **O recuo inferior da última região no layout contínuo segue a mesma lógica** (20.0.0). Ele vem de
248
+ `[&:last-child]:pb-*`, e um `pb-0` do consumidor não o desliga quando a região é a última da
249
+ superfície: para mudar esse respiro, a superfície troca de layout. Quem precisar mesmo sobrescrever
250
+ escreve a mesma variante (`[&:last-child]:pb-*`), que o merge reconhece como substituta.
251
+
234
252
  ## Consequências
235
253
 
236
254
  - Uma mudança de espaçamento, divisor ou escala passa a ter um lugar só; hoje tem seis.
@@ -0,0 +1,118 @@
1
+ # ADR 0024 — A ocupação de altura se propaga por contexto
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-09-25.
5
+ - **Complementa:** ADRs 0017 e 0023.
6
+
7
+ ## Contexto
8
+
9
+ `Page layout="fill"` limita a página à altura do shell, e `ActionList layout="fill"` ocupa a altura
10
+ disponível e rola somente os resultados. As duas metades já existiam desde a 18.3.0, mas não se
11
+ encontravam sozinhas: a altura só chega à lista se cada elemento entre a página e ela for uma coluna
12
+ flexível com `min-h-0`. `PageBody`, `Content` e `ContentBody` não faziam isso.
13
+
14
+ O efeito era mensurável no código e na documentação:
15
+
16
+ - a página da `ActionList` ensinava a escrever `flex min-h-0 flex-1 flex-col` em `PageBody`,
17
+ `Content` e `ContentBody` para o `fill` funcionar;
18
+ - a `Presentation` de lista em `layout="fill"` envolvia o corpo em `Content` e `ContentBody` sem essa
19
+ cadeia e não repassava `layout` à lista — o `fill` declarado nunca chegava a ela;
20
+ - quando uma classe faltava, a lista crescia com o conteúdo e o `fill` se comportava como `flow`,
21
+ sem erro nem aviso.
22
+
23
+ As forças em tensão:
24
+
25
+ - **Automação × previsibilidade.** A cadeia é mecânica e o consumidor erra ao montá-la à mão; mas
26
+ uma página `fill` também hospeda seções irmãs, cards e diálogos que não devem virar colunas
27
+ flexíveis por acidente.
28
+ - **Contexto React × DOM.** O contexto atravessa qualquer componente, inclusive portais e
29
+ contêineres que não repassam altura; a altura só atravessa o DOM elemento a elemento.
30
+ - **Default × explícito.** Uma lista dentro da cadeia quase sempre quer `fill`; uma lista em uma
31
+ seção secundária da mesma página quer `flow`.
32
+
33
+ ## Decisão
34
+
35
+ ### 1. A raiz publica, os elos republicam, a folha se registra
36
+
37
+ - **Raiz:** `Page layout="fill"` publica a cadeia. `Page layout="flow"` a interrompe. A
38
+ `Presentation` em página herda o comportamento da `Page` que monta.
39
+ - **Elos canônicos:** `PageBody`, `Content`, `ContentBody` e o corpo da `Presentation` leem a
40
+ cadeia e a republicam para os filhos. Na cadeia, carregam `data-fill="false"`.
41
+ - **Folha:** a `ActionList`. Sem `layout`, ela assume `fill` quando o elemento pai no DOM é um elo
42
+ (`data-fill`) e se registra na cadeia. Com `layout="fill"` explícito, registra-se mesmo sob um
43
+ contêiner próprio; com `layout="flow"` explícito, nunca se registra.
44
+
45
+ ### 2. Só o caminho até a folha vira coluna flexível
46
+
47
+ Um elo aplica `flex min-h-0 flex-1 flex-col` e passa a `data-fill="true"` somente quando uma folha
48
+ se registrou abaixo dele. Seções irmãs sem lista mantêm a altura do próprio conteúdo, e uma página
49
+ `fill` sem lista renderiza exatamente como antes. O registro acontece em `useLayoutEffect`, antes
50
+ da primeira pintura.
51
+
52
+ ### 3. Superfícies com anatomia própria interrompem a cadeia
53
+
54
+ `Card`, `Dialog`, `Drawer` e `Pane` interrompem a propagação no provider de layout que já
55
+ compartilham (ADR 0023). A altura do corpo delas é decisão delas, inclusive quando um portal as
56
+ renderiza dentro de uma página `fill`. A própria `ActionList` também interrompe a cadeia para o que
57
+ ela hospeda. Em `Presentation`, o `fill` continua valendo apenas para a superfície `page`.
58
+
59
+ ### 4. O mecanismo é interno
60
+
61
+ O contexto e os hooks vivem em um módulo interno, fora da API pública. Um contêiner próprio entre o
62
+ elo e a lista não é detectado como elo: quem o escreve declara `layout="fill"` na lista e dá ao
63
+ contêiner a coluna flexível. Novos elos ou folhas entram no SDK, com teste, e não no consumidor.
64
+
65
+ ## Consequências
66
+
67
+ - `Page layout="fill"` › `PageBody` › `Content` › `ContentBody` › `ActionList` funciona sem classes
68
+ no consumidor, e a `Presentation` de lista em `fill` passa a entregar a rolagem interna prometida.
69
+ - Classes que o consumidor já escrevia para montar a cadeia continuam funcionando e podem ser
70
+ removidas: o `className` é mesclado por último e repete as mesmas classes.
71
+ - Uma `ActionList` sem `layout`, filha direta de um elo em uma página `fill`, passa a ocupar a altura.
72
+ Quem queria a lista crescendo com a página declara `layout="flow"`.
73
+ - A primeira renderização de uma cadeia com folha faz uma passada extra de layout effect. No
74
+ servidor, a cadeia só se forma na hidratação.
75
+ - `Tabs` e outros contêineres intermediários não são elos, em qualquer ponto do caminho. A lista só
76
+ confere o pai imediato: com um contêiner próprio entre dois elos (por exemplo, um `div` entre
77
+ `PageBody` e `Content`), ela ainda se registra e os elos viram coluna flexível, mas a altura não
78
+ atravessa o contêiner e a lista se comporta como `flow`. Quem compõe assim dá ao contêiner a
79
+ coluna flexível, como antes.
80
+ - O elo ativo vira coluna flexível com `align-items: stretch`, que a lista precisa. Os demais filhos
81
+ de cada elo ativo também são afetados: um botão inline se estica à largura do elo e margens
82
+ verticais deixam de colapsar. Para um irmão manter a largura do conteúdo, dê a ele `self-start`
83
+ ou envolva-o em um contêiner próprio.
84
+ - `Dialog` em `mode="alert"` também interrompe a cadeia, como os demais modos.
85
+
86
+ ## Alternativas descartadas
87
+
88
+ ### Manter a cadeia no consumidor
89
+
90
+ Não custa nada ao SDK. Foi descartada porque é o estado que produziu a documentação ensinando
91
+ classes de correção e a `Presentation` com `fill` que não chegava à lista.
92
+
93
+ ### Aplicar a coluna flexível em todo elo dentro de `fill`
94
+
95
+ Mais simples: bastaria ler o contexto. Foi descartada porque duas seções irmãs com `flex-1` dividem
96
+ a altura ao meio e o conteúdo de uma transborda sobre a outra, e porque `flex-col` estica filhos
97
+ inline, como botões, que antes tinham a largura do conteúdo.
98
+
99
+ ### Detectar a folha só por CSS (`:has`)
100
+
101
+ Evitaria o registro em React. Foi descartada porque `:has` enxerga qualquer descendente no DOM,
102
+ inclusive listas dentro de cards e contêineres que não repassam a altura, e porque a decisão da
103
+ lista — `fill` ou `flow` — ainda precisaria do contexto.
104
+
105
+ ### Expor o contexto na API pública
106
+
107
+ Deixaria o consumidor criar elos próprios. Foi adiada: sem um caso real além de `ActionList`, o
108
+ contrato público seria especulativo.
109
+
110
+ ## Verificação
111
+
112
+ - `tests/ui/fill-region.test.tsx` renderiza a cadeia `Page fill` › `PageBody` › `Content` ›
113
+ `ContentBody` › `ActionList` e confere atributo e classes de cada nível; cobre seções irmãs,
114
+ página `fill` sem lista, página `flow`, `layout="flow"` explícito, contêiner próprio com e sem
115
+ `fill` explícito, a interrupção por `Card` e a interrupção pela própria lista.
116
+ - `tests/ui/presentation.test.tsx` confere a `Presentation` de lista em `fill`, dentro e fora de
117
+ `PageShell`, e a lista em `flow` quando a `Presentation` é `flow`.
118
+ - A rolagem real é conferida em navegador: a happy-dom não calcula layout.
@@ -0,0 +1,134 @@
1
+ # ADR 0025 — O acabamento da moldura de Content não altera sua semântica
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-09-25.
5
+ - **Complementa:** ADRs 0021 e 0023.
6
+
7
+ ## Contexto
8
+
9
+ `Content` separa o elemento semântico (`section` ou `fieldset`) da presença da moldura. Porém, o
10
+ acabamento `frameVariant="legend"`, que integra visualmente o título à borda, só está disponível
11
+ quando a raiz é um `fieldset`.
12
+
13
+ Essa restrição leva regiões somente de leitura a escolherem `fieldset` apenas para obter o
14
+ acabamento visual. É o caso de grupos de detalhes como Cliente e Veículo: eles formam seções
15
+ nomeadas com `DetailGroup`, não conjuntos de controles de formulário. A aparência desejada se
16
+ parece com uma legenda, mas não transforma o título em uma `legend` semântica.
17
+
18
+ As forças em tensão são:
19
+
20
+ - preservar `fieldset` e `legend` exclusivamente para grupos de campos relacionados;
21
+ - oferecer a mesma linguagem visual a regiões de leitura;
22
+ - manter título, mídia, descrição, contador e ações compatíveis com a anatomia normal de `Content`;
23
+ - não criar outra família pública apenas para desenhar uma borda interrompida.
24
+
25
+ ## Decisão
26
+
27
+ `frameVariant="legend"` passa a ser aceito tanto por `Content as="section"` quanto por
28
+ `Content as="fieldset"`. A propriedade escolhe somente o acabamento da moldura; `as` continua
29
+ sendo a única propriedade que determina o elemento e sua semântica.
30
+
31
+ Em uma `section`, o título permanece um heading no nível declarado, o header continua aceitando
32
+ `ContentMedia`, `ContentMeta`, `ContentDescription` e `ContentActions`, e a borda é visualmente
33
+ interrompida ao redor das regiões do header que a ocupam. Nenhum elemento `legend` é emitido.
34
+
35
+ Em um `fieldset`, o título continua sendo a `legend` nativa e pode ser precedido pelo mesmo
36
+ `ContentMedia` usado em uma `section`. A mídia é decorativa e acompanha o nome do grupo; contador,
37
+ descrição e ações continuam fora da legenda. `as` altera a semântica, não a apresentação do par
38
+ mídia + título nem o comportamento nativo de formulários.
39
+
40
+ O nome `legend` descreve o acabamento visual existente e não cria um terceiro valor incompatível
41
+ para o mesmo desenho. A documentação deve apresentar primeiro a escolha semântica e depois o
42
+ acabamento, deixando explícito que uma região de leitura permanece `section`.
43
+
44
+ Para interromper a linha sem depender da transparência dos wrappers intermediários, Card, Dialog e
45
+ Drawer declaram `--surface-background` com o token que já usam (`--background` ou `--card`). As
46
+ regiões do header que cruzam a borda usam essa cor; quando não houver uma superfície declarada,
47
+ `Content` usa `--background` como fallback. O nome segue o papel da variável, como os demais
48
+ contratos CSS locais, sem prefixo de pacote. Uma superfície com fundo próprio que não seja uma
49
+ dessas três, como um `Card` com `bg-*`, um `Popover` ou um contêiner da aplicação, declara a mesma
50
+ variável com o fundo que pinta.
51
+
52
+ ### Relação com a ADR 0020
53
+
54
+ A ADR 0020 mantém genéricos os slots de ícone, como `FieldLegend.icon`: o consumidor passa o glifo
55
+ quando quer só o ícone e compõe `IconTile` quando quer a moldura. `ContentMedia` não é um slot
56
+ genérico; é o slot de mídia da anatomia de superfícies (ADR 0023), que já promete a moldura, como
57
+ `CardMedia` e `ItemMedia variant="icon"`. Por isso a mídia de `Content` sempre desenha a moldura, e
58
+ `asChild` continua sendo a saída para uma marca com visual próprio.
59
+
60
+ Consumidores que já compunham `IconTile` antes deste slot existir não devem ganhar moldura dentro de
61
+ moldura. `ContentMedia` adota um `IconTile` recebido como filho: o slot assume o lugar dele e herda
62
+ `size`, `context`, `variant` e as demais props declaradas nele. `media` com `null` ou `false` é
63
+ tratado como ausência, e nenhuma moldura vazia é desenhada. Os ícones de
64
+ `advancedFilters.sectionIcons` da `ActionList` passam pelo mesmo slot, sem normalização própria.
65
+
66
+ ## Consequências
67
+
68
+ - Regiões de leitura podem integrar o header à moldura sem declarar semântica de formulário.
69
+ - A composição `Content + DetailGroup` preserva `section`, heading, mídia, descrição, contador e
70
+ ações.
71
+ - Grupos de campos continuam usando `fieldset` e `legend` quando essa relação existe no conteúdo.
72
+ - Filtros e regiões de leitura usam o mesmo slot, escala e alinhamento para mídia e título.
73
+ - O default permanece `frameVariant="default"`; quem não usa as novas props não muda visualmente.
74
+ - `advancedFilters.sectionIcons` muda: um glifo solto, antes exibido sem moldura, passa a ocupar o
75
+ `ContentMedia` e ganha a moldura padrão. Um `IconTile` continua com a aparência que declara.
76
+ - A `legend` de todo `Content as="fieldset"` passa a envolver título e mídia nos wrappers do
77
+ `SurfaceHeader` (`legend > span[data-slot=content-heading] > …`). Seletores que dependiam do
78
+ título como filho direto da `legend` precisam ser ajustados.
79
+ - O acabamento precisa funcionar sobre as superfícies canônicas sem o consumidor reconstruir a
80
+ borda ou posicionar o header por classes próprias.
81
+ - Wrappers transparentes entre a superfície e `Content` não deixam a borda aparecer atrás do grupo
82
+ de heading ou das ações, porque a cor vem da variável declarada pela superfície e não do pai.
83
+ - Superfícies com fundo próprio fora de Card, Dialog e Drawer ficam responsáveis por declarar
84
+ `--surface-background`; sem isso, a faixa atrás do header usa `--background`.
85
+
86
+ ## Alternativas consideradas
87
+
88
+ ### Usar `fieldset` também para detalhes somente de leitura
89
+
90
+ Reaproveitaria a implementação atual, mas atribuiria semântica de grupo de formulário a uma lista
91
+ de definições. Foi descartada porque a escolha do elemento passaria a ser governada pela aparência.
92
+
93
+ ### Criar outro componente de painel
94
+
95
+ Separaria esse desenho de `Content`, mas duplicaria a anatomia de região nomeada e voltaria a exigir
96
+ uma decisão entre estruturas equivalentes. Foi descartada porque `Content` já é responsável pelo
97
+ header, corpo e moldura da região.
98
+
99
+ ### Criar um novo valor visual com o mesmo desenho
100
+
101
+ Um nome como `notched` evitaria a associação com a `legend`, mas criaria dois valores para o mesmo
102
+ acabamento e uma migração sem ganho observável. Foi descartado porque o contrato já afirma que
103
+ `frameVariant` não altera a semântica.
104
+
105
+ ### Herdar o background do pai imediato
106
+
107
+ Manteria a implementação restrita a `Content`, mas `background-color: inherit` recebe
108
+ `transparent` quando `DrawerBody`, `CardBody` ou outro wrapper não pinta o próprio fundo. Foi
109
+ descartado porque o acabamento depende da superfície que efetivamente pinta, não do pai estrutural
110
+ mais próximo.
111
+
112
+ ### Redesenhar a borda em segmentos
113
+
114
+ Evitaria cobrir a linha com uma cor, mas exigiria derivar segmentos antes, entre e depois de título,
115
+ descrição e ações com larguras dinâmicas. Foi descartado porque duplicaria no CSS a anatomia que o
116
+ header já organiza.
117
+
118
+ ## Verificação
119
+
120
+ - Testes de UI confirmam que `Content as="section"` com `frameVariant="legend"` mantém `section`,
121
+ heading e todos os slots do header.
122
+ - Testes confirmam que o mesmo acabamento em `fieldset` continua emitindo `legend` e restringindo
123
+ seus slots.
124
+ - Testes confirmam que o default continua usando a moldura contínua nos dois elementos.
125
+ - Testes confirmam que Card, Dialog e Drawer declaram `--surface-background` e que o grupo de
126
+ heading e as ações do header usam essa variável, com `--background` como fallback. A herança da
127
+ variável através de wrappers transparentes é comportamento do CSS, não é provada pela happy-dom e
128
+ depende de conferência visual no catálogo, onde a página de `Content` a demonstra sobre um `Card`.
129
+ - Testes confirmam que um `IconTile` passado como mídia é adotado pelo slot, que `null` e `false`
130
+ não desenham mídia e que um glifo solto em `sectionIcons` ganha a moldura.
131
+ - Um teste confirma que `Page layout="fill"` leva a altura até a `ActionList` através de um
132
+ `Content` com `frameVariant="legend"` (ADR 0024).
133
+ - A documentação demonstra `Content + DetailGroup` no acabamento `legend` sem usar `fieldset` e
134
+ mantém `Content as="fieldset" + FieldGroup` para grupos de controles.
@@ -10,7 +10,10 @@ Ele não é uma query, tabela ou view: essas são implementações possíveis. A
10
10
  recorte e autorização continua sendo cada Action.
11
11
 
12
12
  ```ts
13
- import { defineDataProduct, defineDomain } from '@softize/opus'
13
+ import { defineDataProduct, defineDomain } from '@softize/opus/core'
14
+ import { LeadEntity } from './entity.ts' // defineEntity({ name: 'lead', … })
15
+ import { leadList } from './actions/list.ts' // action 'lead.list'
16
+ import { leadFunnel } from './actions/funnel.ts' // action 'lead.funnel'
14
17
 
15
18
  export const salesLeads = defineDataProduct({
16
19
  id: 'sales.leads',
@@ -22,24 +25,27 @@ export const salesLeads = defineDataProduct({
22
25
  classification: 'internal',
23
26
  nature: 'real',
24
27
  sources: [{ id: 'followize', label: 'Followize' }],
25
- entities: ['Lead'],
28
+ entities: ['lead'],
26
29
  access: {
27
30
  permissionContexts: ['sales'],
28
31
  organizationalScopes: ['unit', 'team'],
29
32
  },
30
- interfaces: ['sale.list', 'sales.performance'],
33
+ interfaces: ['lead.list', 'lead.funnel'],
31
34
  })
32
35
 
33
- export const salesDomain = defineDomain({
34
- name: 'sales',
35
- entities: { Lead },
36
- actions: { saleList, salesPerformance },
36
+ export const leadDomain = defineDomain({
37
+ name: 'lead',
38
+ entities: { LeadEntity },
39
+ actions: { leadList, leadFunnel },
37
40
  dataProducts: { salesLeads },
38
41
  })
39
42
  ```
40
43
 
41
- `interfaces` e `entities` usam os nomes declarados, não nomes de arquivo ou tabela. O registro do
42
- domínio e `opus check` detectam relações inexistentes. `sources` identifica sistemas ou conjuntos
44
+ `interfaces` e `entities` usam os nomes declarados — o `name` da action e o `name` de
45
+ `defineEntity` —, não a chave do objeto, o nome do arquivo ou da tabela. O registro do domínio e
46
+ `opus check` detectam relações inexistentes: aqui, `entities: ['LeadEntity']` ou `['Lead']` seriam
47
+ recusados, porque a entidade se declara como `lead`. As actions seguem o prefixo do domínio
48
+ (`lead.*`), como qualquer action registrada nele. `sources` identifica sistemas ou conjuntos
43
49
  upstream; uma Fonte não precisa ser uma Entity Opus.
44
50
 
45
51
  ## Acesso e segurança
package/docs/protocol.md CHANGED
@@ -1606,7 +1606,6 @@ defineReaction({
1606
1606
 
1607
1607
  handler: async (ctx, event) => {
1608
1608
  await syncToSalesforce(event.data.leadId)
1609
- await ctx.emit('hub.lead.synced', { leadId: event.data.leadId })
1610
1609
  },
1611
1610
  })
1612
1611
 
@@ -1629,7 +1628,7 @@ type ReactionContext = {
1629
1628
  user: User | null // ator que disparou o evento (pode ser system)
1630
1629
  tenantId: string | null
1631
1630
  db: unknown // mesmo data adapter
1632
- emit: EmitFn // reaction pode emitir novos eventos (chain)
1631
+ emit: EmitFn // descarta o evento sem publicar (sem encadeamento)
1633
1632
  log: LoggerAdapter // logger pre-bound com contexto da reaction
1634
1633
  storage: StorageAdapter | null // storage de arquivos (experimental)
1635
1634
  provenance: Provenance // { kind: 'reaction', … }
@@ -1638,7 +1637,7 @@ type ReactionContext = {
1638
1637
  }
1639
1638
  ```
1640
1639
 
1641
- Reaction pode emitir eventos novos via `ctx.emit` — habilita workflows declarativos em cadeia (`lead.created` → reaction → `lead.onboarded` → outra reaction → ...).
1640
+ Uma reaction não encadeia outra por evento: dentro dela, `ctx.emit` descarta o evento sem publicá-lo. Com `config.dev` ativo, cada descarte registra o aviso `runtime.reaction_emit_ignored` no log; em produção, o descarte é silencioso. Quando outro consumidor precisa desse fato, emita o evento no handler da action que o produz e declare-o em `emits`.
1642
1641
 
1643
1642
  ### Setup unificado
1644
1643
 
@@ -1690,7 +1689,7 @@ para cada reaction:
1690
1689
  ↓ (a) dedup check (se declarado)
1691
1690
  ↓ (b) authorize (se declarado)
1692
1691
  ↓ (c) handler executa (com timeout)
1693
- ↓ (d) handler emite eventos (opcional, via ctx.emit)
1692
+ ↓ (d) ctx.emit dentro da reaction não publica (descartado, com aviso em dev)
1694
1693
  ↓ (e) audit (reaction completed/failed)
1695
1694
  ↓ (f) retry se falhou e config permite
1696
1695
  ```
package/docs/releasing.md CHANGED
@@ -60,7 +60,7 @@ pra testar publicação seria a guarda atrapalhando quem está experimentando.
60
60
 
61
61
  **Gate de qualidade**: depois do bump e da materialização, mas antes de publicar, roda
62
62
  `pnpm typecheck` + `pnpm test` + `pnpm copy:check` + `base copy check` e **aborta a release
63
- se qualquer um falhar**. O Opus declara `@softize/base ^2.3.0` em `dependencies`, pois usa
63
+ se qualquer um falhar**. O Opus declara `@softize/base ^2.4.0` em `dependencies`, pois usa
64
64
  suas APIs públicas de filesystem em runtime, e materializa os artefatos Base no próprio repo;
65
65
  a release não baixa uma política ad hoc. Como o
66
66
  Opus **ship source** (`.ts`, sem build), essa é a última barreira antes do tarball — sem ela,
@@ -74,7 +74,7 @@ não é bloqueada por cobertura abaixo de 100%; a cobertura é acompanhada com `
74
74
  **Smoke do esqueleto**: depois do bump e antes do publish, o `release.sh` gera um app com
75
75
  `opus create`, instala o **tarball exato** que vai ser publicado e roda os gates dele
76
76
  (typecheck · test · `opus check` · `opus copy --check` · `base copy check` · manifest ·
77
- build). O template exige `@softize/base ^2.3.0`; publique a Base compatível antes do Opus.
77
+ build). O template exige `@softize/base ^2.4.0`; publique a Base compatível antes do Opus.
78
78
  O `minimumReleaseAgeExclude` do template inclui os dois pacotes, e o smoke executa os
79
79
  fragmentos de pre-push para provar que o layout pnpm instalado resolve ambos os CLIs.
80
80
  É o que pega o que typecheck+test não
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "19.0.0",
3
+ "version": "20.1.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",
@@ -247,7 +247,7 @@
247
247
  "@radix-ui/react-slot": "^1.1.1",
248
248
  "@radix-ui/react-tabs": "^1.1.2",
249
249
  "@radix-ui/react-tooltip": "^1.1.6",
250
- "@softize/base": "^2.3.0",
250
+ "@softize/base": "^2.4.0",
251
251
  "@tailwindcss/typography": "^0.5.20",
252
252
  "@types/markdown-it": "^14.1.2",
253
253
  "class-variance-authority": "^0.7.1",
@@ -2,7 +2,8 @@
2
2
 
3
3
  const KINDS = new Set(['simple', 'form', 'list', 'view'])
4
4
  const cap = (value) => value.charAt(0).toUpperCase() + value.slice(1)
5
- const constant = (resource, verb) => resource + verb.split('-').map(cap).join('')
5
+ const camel = (value) => value.split('-').map((part, index) => (index === 0 ? part : cap(part))).join('')
6
+ const constant = (resource, verb) => camel(`${resource}-${verb}`)
6
7
 
7
8
  function contractBody({ resource, verb, kind }) {
8
9
  const name = `${resource}.${verb}`
@@ -30,7 +30,7 @@
30
30
  "zod": "^3.24.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@softize/base": "^2.3.0",
33
+ "@softize/base": "^2.4.0",
34
34
  "@tailwindcss/vite": "^4.1.0",
35
35
  "@types/node": "^22.0.0",
36
36
  "@types/react": "^19.0.0",
@@ -413,9 +413,35 @@ export class Runtime {
413
413
  })
414
414
  }
415
415
  this.actions.set(action.name, action)
416
+ if (this.config.dev) this.warnIgnoredExecutionFields(action)
416
417
  }
417
418
  }
418
419
 
420
+ /**
421
+ * `emits` e `background` pertencem ao binding de qualquer kind pelo tipo, mas o runtime e o
422
+ * manifest só os aplicam em `simple` e `form`. Restringir o tipo quebraria bindings
423
+ * existentes; o aviso em desenvolvimento evita que a declaração pareça ter efeito.
424
+ */
425
+ private warnIgnoredExecutionFields(action: ActionDef): void {
426
+ if (action.kind !== 'list' && action.kind !== 'view') return
427
+ const declared = action as { emits?: unknown; background?: unknown }
428
+ const fields = (['emits', 'background'] as const).filter((field) => declared[field] !== undefined)
429
+ if (fields.length === 0) return
430
+ const list = fields.map((field) => `\`${field}\``).join(' e ')
431
+ const effects = fields.map((field) =>
432
+ field === 'background'
433
+ ? 'Sem fila, a action executa na própria requisição.'
434
+ : 'Os eventos declarados não entram no manifest nem na conferência do que o handler emite.',
435
+ )
436
+ this.log.warn(
437
+ `A action "${action.name}" é do kind "${action.kind}" e declara ${list}, que o runtime só ` +
438
+ `aplica em actions simple e form. ${effects.join(' ')} Remova ${list} da declaração da ` +
439
+ 'action, no `bindAction` ou no `defineAction`; se a operação precisa de fila ou de ' +
440
+ 'eventos declarados, use uma action simple ou form.',
441
+ { code: 'runtime.action_field_ignored', action: action.name, kind: action.kind, fields },
442
+ )
443
+ }
444
+
419
445
  private registerReactions(reactions: Array<ReactionDef<any>>): void {
420
446
  for (const reaction of reactions) {
421
447
  if (this.reactions.has(reaction.name)) {
@@ -455,6 +481,12 @@ export class Runtime {
455
481
  })
456
482
  }
457
483
 
484
+ // Antes de inicializar adapters: é uma checagem do registro, e falhar aqui não deixa
485
+ // conexão aberta para trás. Sem scheduler, a checagem não roda porque nenhum schedule
486
+ // executaria: os adapters inicializam e o start falha depois, com
487
+ // `runtime.eventbus_required` se também faltar event bus, senão `runtime.scheduler_required`.
488
+ if (this.scheduler !== undefined) this.assertScheduledActionsExist()
489
+
458
490
  for (const adapter of this.allAdapters()) {
459
491
  if (adapter.init !== undefined) await adapter.init(this)
460
492
  if (adapter.dispose !== undefined) {
@@ -1203,6 +1235,35 @@ export class Runtime {
1203
1235
  }
1204
1236
  }
1205
1237
 
1238
+ /**
1239
+ * Um schedule com nome de action errado dispararia em silêncio para sempre. Schedules com
1240
+ * `enabled: false` ficam de fora: não são registrados e podem apontar para uma action que
1241
+ * ainda não existe neste ambiente.
1242
+ */
1243
+ private assertScheduledActionsExist(): void {
1244
+ const missing = [...this.schedules.values()].filter(
1245
+ (schedule) => schedule.enabled !== false && !this.actions.has(schedule.action),
1246
+ )
1247
+ if (missing.length === 0) return
1248
+ const list = missing
1249
+ .map((schedule) => `"${schedule.name}" → "${schedule.action}"`)
1250
+ .join(', ')
1251
+ throw error({
1252
+ code: 'runtime.schedule_action_not_found',
1253
+ category: 'internal',
1254
+ message:
1255
+ `O runtime não iniciou porque ${
1256
+ missing.length === 1
1257
+ ? 'um schedule aponta para uma action não registrada'
1258
+ : `${missing.length} schedules apontam para actions não registradas`
1259
+ }: ${list}. Corrija o nome em \`action\` ou registre a action no mesmo runtime; para ` +
1260
+ 'manter o schedule sem executar, declare `enabled: false`.',
1261
+ meta: {
1262
+ schedules: missing.map((schedule) => ({ schedule: schedule.name, action: schedule.action })),
1263
+ },
1264
+ })
1265
+ }
1266
+
1206
1267
  private registerSchedule(schedule: ScheduleDef): void {
1207
1268
  if (schedule.enabled === false) {
1208
1269
  this.log.info('schedule disabled', { schedule: schedule.name })
@@ -1221,12 +1282,45 @@ export class Runtime {
1221
1282
  typeof schedule.input === 'function'
1222
1283
  ? await (schedule.input as () => unknown | Promise<unknown>)()
1223
1284
  : (schedule.input ?? {})
1224
- await this.execute(schedule.action, input, {
1285
+ const result = await this.execute(schedule.action, input, {
1225
1286
  user: null,
1226
1287
  tenantId: null,
1227
1288
  can: () => false,
1228
1289
  provenance: { kind: 'schedule', scheduleName: schedule.name },
1229
1290
  })
1291
+ if (result.ok) return
1292
+ // Ninguém espera o resultado de um disparo: sem este registro, a falha só existiria no
1293
+ // audit da action. Action inexistente não chega aqui, porque o start() recusa o schedule.
1294
+ this.log.error(
1295
+ `O schedule "${schedule.name}" não concluiu a action "${schedule.action}" (${result.error.code}). ` +
1296
+ 'O agendamento continua ativo e o próximo disparo executa a action de novo. Confira a ' +
1297
+ 'mensagem e o audit da action; `auth.unauthenticated` indica que a action agendada precisa ' +
1298
+ 'declarar `public: true`.',
1299
+ {
1300
+ schedule: schedule.name,
1301
+ action: schedule.action,
1302
+ code: result.error.code,
1303
+ message: result.error.message,
1304
+ actionId: result.meta.actionId,
1305
+ ...(result.error.cause !== undefined ? { cause: result.error.cause } : {}),
1306
+ },
1307
+ )
1308
+ }
1309
+
1310
+ /**
1311
+ * `ctx.emit` de reaction não publica: encadear reactions por evento ainda não é suportado.
1312
+ * Fora de produção, o descarte vira aviso para que o evento não pareça ter sido entregue.
1313
+ */
1314
+ private buildReactionEmit(reaction: ReactionDef<any>, log: Logger): ReactionContext['emit'] {
1315
+ return async (event) => {
1316
+ if (!this.config.dev) return
1317
+ log.warn(
1318
+ `A reaction "${reaction.name}" chamou ctx.emit('${event}'), mas reactions não publicam ` +
1319
+ 'eventos: o evento foi descartado e nenhuma reaction ou integração o recebe. Para ' +
1320
+ 'anunciar esse fato, emita-o no handler da action que o produz e declare-o em `emits`.',
1321
+ { code: 'runtime.reaction_emit_ignored', reaction: reaction.name, event },
1322
+ )
1323
+ }
1230
1324
  }
1231
1325
 
1232
1326
  private async runReaction(
@@ -1260,7 +1354,7 @@ export class Runtime {
1260
1354
  tenantId: event.tenant ?? null,
1261
1355
  db: this.data?.contextExtension().db,
1262
1356
  log: reactionLog,
1263
- emit: noopEmit,
1357
+ emit: this.buildReactionEmit(reaction, reactionLog),
1264
1358
  storage: this.storage ?? null,
1265
1359
  cache: this.cache ?? null,
1266
1360
  // Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
@@ -1411,12 +1505,6 @@ function severityForError(err: any): 'info' | 'warning' | 'error' {
1411
1505
  return 'error'
1412
1506
  }
1413
1507
 
1414
- /**
1415
- * Placeholder pra `ctx.emit` em reactions. Reaction chain (reaction emite
1416
- * eventos novos que disparam outras reactions) entra em v1+.
1417
- */
1418
- const noopEmit: ActionContext['emit'] = async () => undefined
1419
-
1420
1508
  function generateId(): string {
1421
1509
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1422
1510
  return crypto.randomUUID()
package/src/core/types.ts CHANGED
@@ -255,8 +255,10 @@ export interface User {
255
255
  export type CanFn = (permission: string, resource?: unknown) => boolean | Promise<boolean>
256
256
 
257
257
  /**
258
- * Função que emite um `DomainEvent`. Disponível em `ActionContext.emit` e
259
- * `ReactionContext.emit`. Plugada pelo `EventBusAdapter`.
258
+ * Função que emite um `DomainEvent`. Em `ActionContext.emit`, publica o evento pelo
259
+ * `EventBusAdapter`. Em `ReactionContext.emit`, descarta o evento sem publicá-lo, porque
260
+ * reactions ainda não encadeiam outras por evento; com `config.dev` ativo, cada descarte
261
+ * registra o aviso `runtime.reaction_emit_ignored`.
260
262
  */
261
263
  export type EmitFn = <T = unknown>(
262
264
  event: string,
@@ -293,6 +295,7 @@ export interface ReactionContext {
293
295
  tenantId: string | null
294
296
  db: unknown
295
297
  log: Logger
298
+ /** Descarta o evento sem publicá-lo: reactions ainda não encadeiam outras. Ver `EmitFn`. */
296
299
  emit: EmitFn
297
300
  storage: StorageAdapter | null
298
301
  cache: CacheAdapter | null