@softize/opus 19.0.0 → 20.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 (142) hide show
  1. package/CHANGELOG.md +193 -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/0023-surfaces-share-one-anatomy-and-two-layouts.md +5 -0
  7. package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
  8. package/docs/data-products.md +15 -9
  9. package/docs/protocol.md +3 -4
  10. package/docs/releasing.md +2 -2
  11. package/package.json +2 -2
  12. package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
  13. package/registry/templates/app/package.json +1 -1
  14. package/src/core/runtime.ts +96 -8
  15. package/src/core/types.ts +5 -2
  16. package/src/ui/components/patterns/action-form-card.tsx +11 -1
  17. package/src/ui/components/patterns/content-header.tsx +15 -3
  18. package/src/ui/components/patterns/fill-region.ts +150 -0
  19. package/src/ui/components/patterns/list.tsx +89 -78
  20. package/src/ui/components/patterns/page.tsx +20 -4
  21. package/src/ui/components/patterns/presentation.tsx +52 -37
  22. package/src/ui/components/patterns/split.tsx +44 -4
  23. package/src/ui/components/patterns/surface-anatomy.ts +35 -8
  24. package/src/ui/components/patterns/trigger.tsx +12 -2
  25. package/src/ui/components/primitives/alert.tsx +9 -1
  26. package/src/ui/components/primitives/avatar.tsx +7 -3
  27. package/src/ui/components/primitives/carousel.tsx +34 -5
  28. package/src/ui/components/primitives/checkbox.tsx +11 -3
  29. package/src/ui/components/primitives/command.tsx +2 -1
  30. package/src/ui/components/primitives/copyable.tsx +120 -10
  31. package/src/ui/components/primitives/dialog.tsx +41 -10
  32. package/src/ui/components/primitives/drawer.tsx +9 -1
  33. package/src/ui/components/primitives/empty.tsx +1 -1
  34. package/src/ui/components/primitives/icon-picker.tsx +6 -2
  35. package/src/ui/components/primitives/icon-tile.tsx +7 -0
  36. package/src/ui/components/primitives/input-group.tsx +9 -2
  37. package/src/ui/components/primitives/input-otp.tsx +1 -1
  38. package/src/ui/components/primitives/kbd.tsx +1 -1
  39. package/src/ui/components/primitives/label.tsx +3 -0
  40. package/src/ui/components/primitives/menu.tsx +5 -0
  41. package/src/ui/components/primitives/popover.tsx +1 -1
  42. package/src/ui/components/primitives/progress.tsx +9 -1
  43. package/src/ui/components/primitives/slider.tsx +62 -11
  44. package/src/ui/components/primitives/truncate.tsx +10 -1
  45. package/src/ui/docs/content/accordion.md +74 -27
  46. package/src/ui/docs/content/action-form-card.md +24 -15
  47. package/src/ui/docs/content/action-form-dialog.md +17 -7
  48. package/src/ui/docs/content/action-form.md +92 -77
  49. package/src/ui/docs/content/action-list-dialog.md +75 -38
  50. package/src/ui/docs/content/action-list.md +134 -52
  51. package/src/ui/docs/content/action-trigger.md +27 -10
  52. package/src/ui/docs/content/action-view.md +14 -14
  53. package/src/ui/docs/content/actions.md +161 -66
  54. package/src/ui/docs/content/ai.md +43 -44
  55. package/src/ui/docs/content/alert.md +83 -29
  56. package/src/ui/docs/content/ask.md +51 -26
  57. package/src/ui/docs/content/aspect-ratio.md +22 -24
  58. package/src/ui/docs/content/audit.md +23 -19
  59. package/src/ui/docs/content/auth.md +38 -19
  60. package/src/ui/docs/content/avatar.md +54 -5
  61. package/src/ui/docs/content/badge.md +29 -9
  62. package/src/ui/docs/content/breadcrumb.md +21 -8
  63. package/src/ui/docs/content/button.md +73 -43
  64. package/src/ui/docs/content/calendar.md +134 -26
  65. package/src/ui/docs/content/card.md +41 -15
  66. package/src/ui/docs/content/carousel.md +37 -6
  67. package/src/ui/docs/content/chat.md +118 -37
  68. package/src/ui/docs/content/checkbox.md +73 -7
  69. package/src/ui/docs/content/cli.md +94 -55
  70. package/src/ui/docs/content/collapsible.md +43 -19
  71. package/src/ui/docs/content/command.md +30 -8
  72. package/src/ui/docs/content/composer.md +23 -8
  73. package/src/ui/docs/content/content.md +31 -7
  74. package/src/ui/docs/content/copyable.md +39 -8
  75. package/src/ui/docs/content/customization.md +55 -46
  76. package/src/ui/docs/content/cycle.md +33 -18
  77. package/src/ui/docs/content/data-products.md +115 -0
  78. package/src/ui/docs/content/data-state.md +20 -6
  79. package/src/ui/docs/content/data.md +20 -19
  80. package/src/ui/docs/content/detail.md +12 -5
  81. package/src/ui/docs/content/dialog.md +107 -24
  82. package/src/ui/docs/content/dictionary-value.md +4 -4
  83. package/src/ui/docs/content/dock.md +54 -19
  84. package/src/ui/docs/content/dot.md +35 -5
  85. package/src/ui/docs/content/drawer.md +63 -19
  86. package/src/ui/docs/content/empty-value.md +20 -6
  87. package/src/ui/docs/content/empty.md +24 -3
  88. package/src/ui/docs/content/events.md +76 -16
  89. package/src/ui/docs/content/executions.md +9 -9
  90. package/src/ui/docs/content/field.md +21 -6
  91. package/src/ui/docs/content/getting-started.md +44 -34
  92. package/src/ui/docs/content/icon-picker.md +19 -13
  93. package/src/ui/docs/content/icon-tile.md +28 -5
  94. package/src/ui/docs/content/input-otp.md +58 -7
  95. package/src/ui/docs/content/input.md +22 -3
  96. package/src/ui/docs/content/item.md +53 -6
  97. package/src/ui/docs/content/kbd.md +5 -1
  98. package/src/ui/docs/content/label.md +13 -4
  99. package/src/ui/docs/content/log.md +5 -5
  100. package/src/ui/docs/content/markdown.md +5 -4
  101. package/src/ui/docs/content/mcp.md +49 -23
  102. package/src/ui/docs/content/menu.md +95 -10
  103. package/src/ui/docs/content/metric-card.md +12 -4
  104. package/src/ui/docs/content/observability.md +10 -9
  105. package/src/ui/docs/content/page.md +118 -12
  106. package/src/ui/docs/content/pagination.md +13 -5
  107. package/src/ui/docs/content/pane.md +55 -5
  108. package/src/ui/docs/content/popover.md +42 -5
  109. package/src/ui/docs/content/presentation.md +91 -47
  110. package/src/ui/docs/content/progress.md +27 -14
  111. package/src/ui/docs/content/queue.md +39 -21
  112. package/src/ui/docs/content/radio-group.md +26 -4
  113. package/src/ui/docs/content/router.md +29 -10
  114. package/src/ui/docs/content/runtime.md +90 -54
  115. package/src/ui/docs/content/scheduler.md +53 -21
  116. package/src/ui/docs/content/scroll-area.md +42 -35
  117. package/src/ui/docs/content/select.md +78 -33
  118. package/src/ui/docs/content/semantic-context.md +21 -2
  119. package/src/ui/docs/content/separator.md +15 -9
  120. package/src/ui/docs/content/sidebar.md +3 -2
  121. package/src/ui/docs/content/skeleton.md +6 -1
  122. package/src/ui/docs/content/slider.md +37 -9
  123. package/src/ui/docs/content/spinner.md +4 -3
  124. package/src/ui/docs/content/split.md +19 -0
  125. package/src/ui/docs/content/storage.md +8 -9
  126. package/src/ui/docs/content/switch.md +10 -7
  127. package/src/ui/docs/content/table.md +46 -8
  128. package/src/ui/docs/content/tabs.md +14 -4
  129. package/src/ui/docs/content/testing.md +76 -31
  130. package/src/ui/docs/content/textarea.md +5 -4
  131. package/src/ui/docs/content/toast.md +48 -7
  132. package/src/ui/docs/content/toggle.md +3 -3
  133. package/src/ui/docs/content/tokens.md +15 -26
  134. package/src/ui/docs/content/tooltip.md +18 -2
  135. package/src/ui/docs/content/truncate.md +36 -6
  136. package/src/ui/docs/content/ui.md +74 -22
  137. package/src/ui/docs/content/upgrading.md +45 -36
  138. package/src/ui/docs/plugin.ts +36 -2
  139. package/src/ui/docs/registry.tsx +6 -0
  140. package/src/ui/drivers/react.tsx +16 -8
  141. package/src/ui/meta.ts +31 -31
  142. package/src/vite/design.ts +16 -4
@@ -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.
@@ -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.0.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
@@ -13,6 +13,7 @@ import {
13
13
  CardTitle,
14
14
  } from "../primitives/card.tsx";
15
15
  import { ActionForm, type ActionFormProps } from "./form.tsx";
16
+ import type { HeadingLevel } from "./heading-level.ts";
16
17
  import { ButtonGroup } from "../primitives/button-group.tsx";
17
18
 
18
19
  export interface ActionFormCardProps<
@@ -21,6 +22,12 @@ export interface ActionFormCardProps<
21
22
  > extends Omit<ActionFormProps<TInput, TData>, "body" | "footer"> {
22
23
  /** Título do header do card. Sem ele, o card começa direto no conteúdo. */
23
24
  title?: string;
25
+ /**
26
+ * Nível semântico do título (`h1`–`h6`), repassado ao `CardTitle` como `level`. Sem
27
+ * ele vale o `h3` do Card. Muda só a hierarquia do documento; a escala visual continua
28
+ * a do Card.
29
+ */
30
+ titleLevel?: HeadingLevel;
24
31
  /** Subtítulo opcional, abaixo do título. */
25
32
  description?: string;
26
33
  /** Classes do Card (a superfície). `className` vai pro <form>. */
@@ -29,6 +36,7 @@ export interface ActionFormCardProps<
29
36
 
30
37
  export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
31
38
  title,
39
+ titleLevel,
32
40
  description,
33
41
  cardClassName,
34
42
  ...rest
@@ -37,7 +45,9 @@ export function ActionFormCard<TInput extends Record<string, unknown>, TData>({
37
45
  <Card className={cardClassName}>
38
46
  {(title !== undefined || description !== undefined) && (
39
47
  <CardHeader>
40
- {title !== undefined && <CardTitle>{title}</CardTitle>}
48
+ {title !== undefined && (
49
+ <CardTitle level={titleLevel}>{title}</CardTitle>
50
+ )}
41
51
  {description !== undefined && (
42
52
  <CardDescription>{description}</CardDescription>
43
53
  )}
@@ -10,6 +10,7 @@ import {
10
10
  type ReactNode,
11
11
  } from "react";
12
12
  import { cn } from "../../lib/cn.ts";
13
+ import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
13
14
  import { headingTags } from "./heading-level.ts";
14
15
  import { usePageHeadingPresence } from "./page-heading-context.tsx";
15
16
  import {
@@ -122,6 +123,7 @@ export function Content({
122
123
  ...props
123
124
  }: ContentProps): ReactElement {
124
125
  const titleId = useId();
126
+ const fill = useFillRegion();
125
127
  const context = {
126
128
  element: as,
127
129
  frameVariant,
@@ -185,6 +187,7 @@ export function Content({
185
187
  data-as="fieldset"
186
188
  data-framed={framed}
187
189
  data-frame-variant={frameVariant}
190
+ {...fill.attributes}
188
191
  aria-labelledby={ariaLabelledBy}
189
192
  className={cn(
190
193
  !framed && "space-y-3",
@@ -194,11 +197,12 @@ export function Content({
194
197
  framed &&
195
198
  frameVariant === "legend" &&
196
199
  "space-y-3 rounded-lg border border-border px-4 pb-4",
200
+ fill.filling && fillRegionClassName,
197
201
  className,
198
202
  )}
199
203
  {...(props as ComponentProps<"fieldset">)}
200
204
  >
201
- {anatomy}
205
+ {fill.provide(anatomy)}
202
206
  </fieldset>
203
207
  ) : (
204
208
  <section
@@ -206,15 +210,17 @@ export function Content({
206
210
  data-as="section"
207
211
  data-framed={framed}
208
212
  data-frame-variant={frameVariant}
213
+ {...fill.attributes}
209
214
  aria-labelledby={ariaLabelledBy}
210
215
  className={cn(
211
216
  "space-y-3",
212
217
  framed && "rounded-lg border border-border p-4",
218
+ fill.filling && fillRegionClassName,
213
219
  className,
214
220
  )}
215
221
  {...(props as ComponentProps<"section">)}
216
222
  >
217
- {anatomy}
223
+ {fill.provide(anatomy)}
218
224
  </section>
219
225
  )}
220
226
  </ContentContext.Provider>
@@ -379,6 +385,7 @@ export function ContentActions({
379
385
 
380
386
  export function ContentBody({
381
387
  className,
388
+ children,
382
389
  ...props
383
390
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
384
391
  const content = requiredContext(
@@ -386,18 +393,23 @@ export function ContentBody({
386
393
  "ContentBody",
387
394
  "Content",
388
395
  );
396
+ const fill = useFillRegion();
389
397
  return (
390
398
  <div
391
399
  data-slot="content-body"
400
+ {...fill.attributes}
392
401
  className={cn(
393
402
  content.element === "fieldset" && "space-y-3",
394
403
  content.element === "fieldset" &&
395
404
  content.framed &&
396
405
  content.frameVariant === "default" &&
397
406
  "clear-left pt-3",
407
+ fill.filling && fillRegionClassName,
398
408
  className,
399
409
  )}
400
410
  {...props}
401
- />
411
+ >
412
+ {fill.provide(children)}
413
+ </div>
402
414
  );
403
415
  }