@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.
- package/CHANGELOG.md +193 -0
- package/bin/cli.mjs +5 -0
- package/bin/lib/check.mjs +198 -10
- package/bin/lib/copy.mjs +29 -0
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +5 -0
- package/docs/adr/0024-fill-height-propagates-by-context.md +118 -0
- package/docs/data-products.md +15 -9
- package/docs/protocol.md +3 -4
- package/docs/releasing.md +2 -2
- package/package.json +2 -2
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +2 -1
- package/registry/templates/app/package.json +1 -1
- package/src/core/runtime.ts +96 -8
- package/src/core/types.ts +5 -2
- package/src/ui/components/patterns/action-form-card.tsx +11 -1
- package/src/ui/components/patterns/content-header.tsx +15 -3
- package/src/ui/components/patterns/fill-region.ts +150 -0
- package/src/ui/components/patterns/list.tsx +89 -78
- package/src/ui/components/patterns/page.tsx +20 -4
- package/src/ui/components/patterns/presentation.tsx +52 -37
- package/src/ui/components/patterns/split.tsx +44 -4
- package/src/ui/components/patterns/surface-anatomy.ts +35 -8
- package/src/ui/components/patterns/trigger.tsx +12 -2
- package/src/ui/components/primitives/alert.tsx +9 -1
- package/src/ui/components/primitives/avatar.tsx +7 -3
- package/src/ui/components/primitives/carousel.tsx +34 -5
- package/src/ui/components/primitives/checkbox.tsx +11 -3
- package/src/ui/components/primitives/command.tsx +2 -1
- package/src/ui/components/primitives/copyable.tsx +120 -10
- package/src/ui/components/primitives/dialog.tsx +41 -10
- package/src/ui/components/primitives/drawer.tsx +9 -1
- package/src/ui/components/primitives/empty.tsx +1 -1
- package/src/ui/components/primitives/icon-picker.tsx +6 -2
- package/src/ui/components/primitives/icon-tile.tsx +7 -0
- package/src/ui/components/primitives/input-group.tsx +9 -2
- package/src/ui/components/primitives/input-otp.tsx +1 -1
- package/src/ui/components/primitives/kbd.tsx +1 -1
- package/src/ui/components/primitives/label.tsx +3 -0
- package/src/ui/components/primitives/menu.tsx +5 -0
- package/src/ui/components/primitives/popover.tsx +1 -1
- package/src/ui/components/primitives/progress.tsx +9 -1
- package/src/ui/components/primitives/slider.tsx +62 -11
- package/src/ui/components/primitives/truncate.tsx +10 -1
- package/src/ui/docs/content/accordion.md +74 -27
- package/src/ui/docs/content/action-form-card.md +24 -15
- package/src/ui/docs/content/action-form-dialog.md +17 -7
- package/src/ui/docs/content/action-form.md +92 -77
- package/src/ui/docs/content/action-list-dialog.md +75 -38
- package/src/ui/docs/content/action-list.md +134 -52
- package/src/ui/docs/content/action-trigger.md +27 -10
- package/src/ui/docs/content/action-view.md +14 -14
- package/src/ui/docs/content/actions.md +161 -66
- package/src/ui/docs/content/ai.md +43 -44
- package/src/ui/docs/content/alert.md +83 -29
- package/src/ui/docs/content/ask.md +51 -26
- package/src/ui/docs/content/aspect-ratio.md +22 -24
- package/src/ui/docs/content/audit.md +23 -19
- package/src/ui/docs/content/auth.md +38 -19
- package/src/ui/docs/content/avatar.md +54 -5
- package/src/ui/docs/content/badge.md +29 -9
- package/src/ui/docs/content/breadcrumb.md +21 -8
- package/src/ui/docs/content/button.md +73 -43
- package/src/ui/docs/content/calendar.md +134 -26
- package/src/ui/docs/content/card.md +41 -15
- package/src/ui/docs/content/carousel.md +37 -6
- package/src/ui/docs/content/chat.md +118 -37
- package/src/ui/docs/content/checkbox.md +73 -7
- package/src/ui/docs/content/cli.md +94 -55
- package/src/ui/docs/content/collapsible.md +43 -19
- package/src/ui/docs/content/command.md +30 -8
- package/src/ui/docs/content/composer.md +23 -8
- package/src/ui/docs/content/content.md +31 -7
- package/src/ui/docs/content/copyable.md +39 -8
- package/src/ui/docs/content/customization.md +55 -46
- package/src/ui/docs/content/cycle.md +33 -18
- package/src/ui/docs/content/data-products.md +115 -0
- package/src/ui/docs/content/data-state.md +20 -6
- package/src/ui/docs/content/data.md +20 -19
- package/src/ui/docs/content/detail.md +12 -5
- package/src/ui/docs/content/dialog.md +107 -24
- package/src/ui/docs/content/dictionary-value.md +4 -4
- package/src/ui/docs/content/dock.md +54 -19
- package/src/ui/docs/content/dot.md +35 -5
- package/src/ui/docs/content/drawer.md +63 -19
- package/src/ui/docs/content/empty-value.md +20 -6
- package/src/ui/docs/content/empty.md +24 -3
- package/src/ui/docs/content/events.md +76 -16
- package/src/ui/docs/content/executions.md +9 -9
- package/src/ui/docs/content/field.md +21 -6
- package/src/ui/docs/content/getting-started.md +44 -34
- package/src/ui/docs/content/icon-picker.md +19 -13
- package/src/ui/docs/content/icon-tile.md +28 -5
- package/src/ui/docs/content/input-otp.md +58 -7
- package/src/ui/docs/content/input.md +22 -3
- package/src/ui/docs/content/item.md +53 -6
- package/src/ui/docs/content/kbd.md +5 -1
- package/src/ui/docs/content/label.md +13 -4
- package/src/ui/docs/content/log.md +5 -5
- package/src/ui/docs/content/markdown.md +5 -4
- package/src/ui/docs/content/mcp.md +49 -23
- package/src/ui/docs/content/menu.md +95 -10
- package/src/ui/docs/content/metric-card.md +12 -4
- package/src/ui/docs/content/observability.md +10 -9
- package/src/ui/docs/content/page.md +118 -12
- package/src/ui/docs/content/pagination.md +13 -5
- package/src/ui/docs/content/pane.md +55 -5
- package/src/ui/docs/content/popover.md +42 -5
- package/src/ui/docs/content/presentation.md +91 -47
- package/src/ui/docs/content/progress.md +27 -14
- package/src/ui/docs/content/queue.md +39 -21
- package/src/ui/docs/content/radio-group.md +26 -4
- package/src/ui/docs/content/router.md +29 -10
- package/src/ui/docs/content/runtime.md +90 -54
- package/src/ui/docs/content/scheduler.md +53 -21
- package/src/ui/docs/content/scroll-area.md +42 -35
- package/src/ui/docs/content/select.md +78 -33
- package/src/ui/docs/content/semantic-context.md +21 -2
- package/src/ui/docs/content/separator.md +15 -9
- package/src/ui/docs/content/sidebar.md +3 -2
- package/src/ui/docs/content/skeleton.md +6 -1
- package/src/ui/docs/content/slider.md +37 -9
- package/src/ui/docs/content/spinner.md +4 -3
- package/src/ui/docs/content/split.md +19 -0
- package/src/ui/docs/content/storage.md +8 -9
- package/src/ui/docs/content/switch.md +10 -7
- package/src/ui/docs/content/table.md +46 -8
- package/src/ui/docs/content/tabs.md +14 -4
- package/src/ui/docs/content/testing.md +76 -31
- package/src/ui/docs/content/textarea.md +5 -4
- package/src/ui/docs/content/toast.md +48 -7
- package/src/ui/docs/content/toggle.md +3 -3
- package/src/ui/docs/content/tokens.md +15 -26
- package/src/ui/docs/content/tooltip.md +18 -2
- package/src/ui/docs/content/truncate.md +36 -6
- package/src/ui/docs/content/ui.md +74 -22
- package/src/ui/docs/content/upgrading.md +45 -36
- package/src/ui/docs/plugin.ts +36 -2
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/drivers/react.tsx +16 -8
- package/src/ui/meta.ts +31 -31
- 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.
|
package/docs/data-products.md
CHANGED
|
@@ -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: ['
|
|
28
|
+
entities: ['lead'],
|
|
26
29
|
access: {
|
|
27
30
|
permissionContexts: ['sales'],
|
|
28
31
|
organizationalScopes: ['unit', 'team'],
|
|
29
32
|
},
|
|
30
|
-
interfaces: ['
|
|
33
|
+
interfaces: ['lead.list', 'lead.funnel'],
|
|
31
34
|
})
|
|
32
35
|
|
|
33
|
-
export const
|
|
34
|
-
name: '
|
|
35
|
-
entities: {
|
|
36
|
-
actions: {
|
|
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
|
|
42
|
-
|
|
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 //
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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.
|
|
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": "
|
|
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.
|
|
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
|
|
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}`
|
package/src/core/runtime.ts
CHANGED
|
@@ -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:
|
|
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`.
|
|
259
|
-
* `ReactionContext.emit
|
|
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 &&
|
|
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
|
}
|