@softize/opus 21.0.1 → 22.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.
- package/CHANGELOG.md +71 -0
- package/bin/cli.mjs +9 -1
- package/bin/lib/check.mjs +37 -29
- package/docs/adr/0004-page-content-state-is-composed.md +3 -2
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +3 -0
- package/docs/adr/0006-semantic-context-precedes-visual-variant.md +23 -14
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
- package/docs/adr/0016-list-collection-header-belongs-to-content.md +13 -8
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +27 -26
- package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +1 -0
- package/docs/adr/0029-page-header-is-the-single-page-introduction.md +71 -0
- package/docs/adr/0030-framed-content-can-use-sectioned-anatomy.md +89 -0
- package/docs/adr/0031-detail-dividers-own-the-tabular-label-treatment.md +61 -0
- package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +59 -0
- package/docs/adr/0033-drawer-uses-an-inset-structural-frame.md +72 -0
- package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +60 -0
- package/docs/adr/0035-button-group-separates-frame-relationship-and-distribution.md +63 -0
- package/docs/adr/0036-tabs-declares-trigger-distribution.md +58 -0
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
- package/docs/radius-scale.md +5 -2
- package/package.json +5 -1
- package/registry/skills/build-opus-ui/SKILL.md +9 -6
- package/registry/skills/build-opus-ui/references/evaluations.md +5 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -16
- package/registry/skills/create-opus-action/references/contract-and-binding.md +5 -3
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +0 -4
- package/registry/skills/test-opus-action/SKILL.md +2 -2
- package/registry/skills/test-opus-action/references/harness.md +2 -2
- package/registry/templates/app/src/App.tsx +1 -1
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
- package/registry/templates/app/src/domains/tasks/actions/list.ts +3 -5
- package/registry/templates/app/vite.config.ts +3 -2
- package/src/core/domain.ts +2 -2
- package/src/core/types.ts +6 -20
- package/src/design/index.ts +179 -0
- package/src/testing/fake.ts +2 -2
- package/src/testing/index.ts +7 -3
- package/src/ui/components/patterns/content-header.tsx +51 -13
- package/src/ui/components/patterns/list.tsx +51 -33
- package/src/ui/components/patterns/page.tsx +139 -251
- package/src/ui/components/patterns/presentation.tsx +2 -6
- package/src/ui/components/primitives/button-group.tsx +80 -8
- package/src/ui/components/primitives/button.tsx +85 -60
- package/src/ui/components/primitives/detail.tsx +63 -57
- package/src/ui/components/primitives/dialog.tsx +8 -11
- package/src/ui/components/primitives/drawer.tsx +16 -12
- package/src/ui/components/primitives/tabs.tsx +123 -54
- package/src/ui/docs/content/actions.md +49 -43
- package/src/ui/docs/content/button.md +55 -29
- package/src/ui/docs/content/content.md +61 -36
- package/src/ui/docs/content/detail.md +28 -19
- package/src/ui/docs/content/drawer.md +53 -47
- package/src/ui/docs/content/input.md +51 -35
- package/src/ui/docs/content/page.md +84 -95
- package/src/ui/docs/content/pane.md +83 -63
- package/src/ui/docs/content/presentation.md +20 -20
- package/src/ui/docs/content/tabs.md +107 -36
- package/src/ui/docs/content/testing.md +9 -25
- package/src/ui/meta.ts +5 -5
- package/src/ui/react.tsx +0 -1
- package/src/vite/design.ts +133 -23
package/docs/radius-scale.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> **Status: decidido (ago/2026).** A forma usa uma escala contínua do Tailwind; o nome do
|
|
4
4
|
> componente não cria um segundo conjunto de tokens.
|
|
5
|
+
>
|
|
6
|
+
> **Atualização (2026-09-28).** A ADR 0033 conclui a validação que este documento deixou pendente e
|
|
7
|
+
> aplica `rounded-xl` ao Drawer junto da sua nova moldura recuada.
|
|
5
8
|
|
|
6
9
|
## Contexto
|
|
7
10
|
|
|
@@ -42,8 +45,8 @@ voltaria a acoplar forma e tipo de componente, problema removido na versão 10.0
|
|
|
42
45
|
`--radius` alcançar também os extremos usados pelo Opus. `rounded-3xl` continua nativo do
|
|
43
46
|
Tailwind; aplicações que usam esse degrau precisam revisar a fronteira ao alterar a base.
|
|
44
47
|
- Novos componentes podem escolher uma utility da escala sem inventar vocabulário próprio.
|
|
45
|
-
- Drawer e outras superfícies presas à viewport não
|
|
46
|
-
|
|
48
|
+
- Naquele entregável, Drawer e outras superfícies presas à viewport não mudaram. A ADR 0033 fez a
|
|
49
|
+
validação própria de clipping, scroll e conteúdo full-bleed e passou o Drawer para `rounded-xl`.
|
|
47
50
|
|
|
48
51
|
## Alternativas descartadas
|
|
49
52
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.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",
|
|
@@ -173,6 +173,10 @@
|
|
|
173
173
|
"types": "./src/seed/index.ts",
|
|
174
174
|
"default": "./src/seed/index.ts"
|
|
175
175
|
},
|
|
176
|
+
"./design": {
|
|
177
|
+
"types": "./src/design/index.ts",
|
|
178
|
+
"default": "./src/design/index.ts"
|
|
179
|
+
},
|
|
176
180
|
"./presentation": {
|
|
177
181
|
"types": "./src/presentation/index.ts",
|
|
178
182
|
"default": "./src/presentation/index.ts"
|
|
@@ -43,18 +43,21 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
|
|
|
43
43
|
7. Tratar página, filtro, seleção e modal importante como estado navegável por URL quando
|
|
44
44
|
o produto precisa de deep link, back/forward ou refresh. Em `Presentation`, declarar `route`
|
|
45
45
|
no artefato e deixar o adaptador apenas conectar a spec ao router da aplicação.
|
|
46
|
-
8. Compor superfícies pela gramática estrutural do catálogo: `PageHeader`
|
|
47
|
-
|
|
48
|
-
exatamente um heading principal com `PageTitle` no header
|
|
49
|
-
`PageState` ativo no body
|
|
50
|
-
|
|
51
|
-
`PageHeader` são projetados na barra. `Content` contém `ContentHeader` e `ContentBody`; Card,
|
|
46
|
+
8. Compor superfícies pela gramática estrutural do catálogo: `PageHeader` é a única introdução
|
|
47
|
+
estrutural e reúne navegação contextual opcional, título e ações; `PageBody` contém o conteúdo.
|
|
48
|
+
Na composição explícita, declare exatamente um heading principal com `PageTitle` no header,
|
|
49
|
+
`Content level={1}` ou um `PageState` ativo no body. `PageShell` pode fornecer navegação e ações
|
|
50
|
+
conhecidas pela aplicação, mas não cria uma barra visual. `Content` contém `ContentHeader` e `ContentBody`; Card,
|
|
52
51
|
Drawer e Pane usam seus
|
|
53
52
|
respectivos `*Body`. Para o caso direto, usar a sintaxe abreviada de `Page` (`title`,
|
|
54
53
|
`actions`) ou de `Content` (`title`, `description`, `actions` e `count`); não misturá-la com o
|
|
55
54
|
header explícito. O título da página não carrega contador. Ajustar o
|
|
56
55
|
nível do heading pela hierarquia semântica, não pelo destaque visual. O `Page` mantém seu teto
|
|
57
56
|
centralizado padrão de `80rem`.
|
|
57
|
+
Em `Content framed`, usar `layout="sectioned"` quando o filho direto já controlar o recuo do
|
|
58
|
+
body, como `DetailGroup` com divisores, lista ou tabela; não reconstruir divisor, padding ou
|
|
59
|
+
recorte por classes no consumidor. `frameVariant="legend"` continua sendo um acabamento da
|
|
60
|
+
borda contínua e não combina com o layout seccionado.
|
|
58
61
|
9. Manter margem e posicionamento no consumidor; componente reutilizável controla apenas
|
|
59
62
|
seu interior.
|
|
60
63
|
10. Não definir a fonte raiz em uma biblioteca ou componente. O navegador e a aplicação são
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
- Não dispara: “Ajuste o CSS de um e-mail estático.”
|
|
5
5
|
- Execução: implementar uma lista com modal roteável e provar loading, erro, vazio e back.
|
|
6
6
|
- Execução estrutural: montar uma página de relatório isolada com `Page > PageHeader + PageBody`,
|
|
7
|
-
outra em `PageShell` com `Page > PageHeader
|
|
8
|
-
`PageTitle`
|
|
7
|
+
outra em `PageShell` com navegação contextual acima de `Page > PageHeader + PageBody`, garantindo
|
|
8
|
+
exatamente um `PageTitle` no header, e uma coleção nomeada com
|
|
9
9
|
`Content > ContentHeader + ContentBody > ActionList`. Provar que criação fica em
|
|
10
10
|
`ContentActions`, no extremo oposto ao título, como botão textual `default` sem ícone e com
|
|
11
11
|
`Criar recurso`, enquanto uma operação dependente do recorte usa `toolbarActions`; incluir
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
rejeita a mistura entre props abreviadas e headers explícitos.
|
|
23
23
|
- Execução de forma: compor uma tabela dentro de Card sem moldura duplicada, manter a moldura
|
|
24
24
|
estrutural standalone em `rounded-lg` e os controles internos em `rounded-md`.
|
|
25
|
+
- Execução de Content seccionado: compor `Content framed layout="sectioned"` com mídia e
|
|
26
|
+
`DetailGroup` com divisores; provar header com divisor, body sem recuo e ausência de classes no
|
|
27
|
+
consumidor que reconstruam essa anatomia. Reprovar a combinação com `frameVariant="legend"`.
|
|
25
28
|
- Reprova: criar uma casca `bg-card` que herda o texto global ou introduzir `rounded-widget`
|
|
26
29
|
quando a escala `rounded-*` atende ao papel.
|
|
27
30
|
- Reprova: trocar automaticamente o radius de um controle quando apenas seu tamanho muda ou
|
|
@@ -8,25 +8,21 @@
|
|
|
8
8
|
parâmetro podem declarar `route.record` para hidratar o item a partir de uma Presentation de
|
|
9
9
|
lista; não reconstruir essa decisão com condicionais no componente da página.
|
|
10
10
|
- `Page` fornece o `<main>` e o container centralizado com teto padrão de `80rem`. Sua forma
|
|
11
|
-
explícita exige `PageBody`, exatamente um heading principal e aceita `PageHeader
|
|
12
|
-
|
|
13
|
-
`
|
|
14
|
-
declara chrome; `PageIntro` declara uma introdução opcional no conteúdo. `title` e `actions` no
|
|
11
|
+
explícita exige `PageBody`, exatamente um heading principal e aceita `PageHeader` e `PageFooter`.
|
|
12
|
+
Declare esse heading com `PageTitle` no header, `Content level={1}` ou um `PageState` ativo no
|
|
13
|
+
body. `PageHeader` é a única introdução estrutural e reúne navegação, título e ações. `title` e `actions` no
|
|
15
14
|
próprio `Page` são a abreviação para o caso direto.
|
|
16
15
|
Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
|
|
17
16
|
real de largura; não reconstruir esse container em cada rota.
|
|
18
|
-
- `
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
uma subpágina simples, `PageBack` recebe o destino pai explícito e aparece
|
|
22
|
-
antes do título como controle somente com ícone. Para mais de um ancestral relevante, use
|
|
17
|
+
- `PageHeader` declara navegação contextual, título e ações. Em uma subpágina simples, `PageBack`
|
|
18
|
+
recebe o destino pai explícito e aparece acima da linha do título como controle somente com
|
|
19
|
+
ícone. Para mais de um ancestral relevante, use
|
|
23
20
|
`Breadcrumb` dentro de `PageNavigation`. Não combine retorno e breadcrumb nem crie um chrome
|
|
24
21
|
paralelo para uma `Page`. `PageActionsTarget` fica reservado a workspaces imersivos que já
|
|
25
22
|
possuam chrome próprio.
|
|
26
23
|
- Quando shell e rota conhecem partes diferentes da mesma página, use `PageShell` ao redor da rota.
|
|
27
|
-
O shell
|
|
28
|
-
no
|
|
29
|
-
Ações com texto na barra usam o tamanho `default`, como as ações de footer de Dialog e Drawer;
|
|
24
|
+
O shell entrega navegação e ações ao `PageHeader` descendente sem criar uma barra visual.
|
|
25
|
+
Ações com texto no cabeçalho usam o tamanho `default`, como as ações de footer de Dialog e Drawer;
|
|
30
26
|
ações somente com ícone de chrome usam `icon`. O tamanho `sm` fica para ações operacionais em
|
|
31
27
|
toolbar, seção ou coleção densa, e `xs`/`icon-xs` para ações internas de linha ou célula. Contexto
|
|
32
28
|
e variante resolvem a hierarquia visual sem alterar essa medida. Em uma coleção, a ação de
|
|
@@ -34,9 +30,8 @@
|
|
|
34
30
|
`PaneHeader`, portal ou seletor global para reconstruir essa composição.
|
|
35
31
|
- `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
|
|
36
32
|
forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
|
|
37
|
-
explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho da Page
|
|
38
|
-
some inteiro — incluindo
|
|
39
|
-
permanece. O estado ocupa a área disponível e seu título assume o heading
|
|
33
|
+
explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho da Page
|
|
34
|
+
some inteiro — incluindo navegação e ações recebidas de `PageShell`. O estado ocupa a área disponível e seu título assume o heading
|
|
40
35
|
principal, inclusive quando um componente intermediário renderiza o estado. Uma subpágina que
|
|
41
36
|
dependa do retorno ao pai oferece essa saída pelo `action` do próprio `PageState`. O erro mantém
|
|
42
37
|
`role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
|
|
@@ -48,7 +43,10 @@ ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader`
|
|
|
48
43
|
direto e não podem ser misturados ao header explícito. Na variante `page`, ações da coleção ficam
|
|
49
44
|
no extremo oposto ao título; a criação usa botão textual `default`, sem ícone, com `Criar recurso`.
|
|
50
45
|
O diálogo de criação usa `Criar recurso` e o de edição, `Editar recurso`. `level` preserva a
|
|
51
|
-
hierarquia semântica do heading.
|
|
46
|
+
hierarquia semântica do heading. `Content framed` aceita `layout="sectioned"` quando o filho
|
|
47
|
+
direto já possui recuo interno, como `DetailGroup` com divisores, lista ou tabela: o header recebe
|
|
48
|
+
divisor e o body fica sem padding. `frameVariant="legend"` trata a borda contínua e não combina
|
|
49
|
+
com esse layout.
|
|
52
50
|
- Uma página centrada em coleção compõe `Content + ActionList`. O `ContentHeader` recebe título e
|
|
53
51
|
criação; o `ActionList` mantém busca, filtros, atualização, resultados, estados e paginação.
|
|
54
52
|
`toolbarActions` fica reservado a operações que dependem do recorte atual.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Contrato e binding
|
|
2
2
|
|
|
3
|
-
O contrato contém `name`, `kind`, documentação, schemas, metadata de UI
|
|
4
|
-
declarativa compartilhável
|
|
3
|
+
O contrato contém `name`, `kind`, documentação, schemas, metadata de UI e autorização
|
|
4
|
+
declarativa compartilhável. Ele não declara dados de design: no modo design, a action sem mock já
|
|
5
|
+
responde com dado gerado a partir do `output`, e um cenário específico, quando necessário, fica
|
|
6
|
+
fora do contrato em `defineDesignScenario` (ADR 0037). `mockHandler` está obsoleto.
|
|
5
7
|
|
|
6
8
|
O binding contém `handler`, `loads`, autorização dependente de dados carregados, execução
|
|
7
9
|
em background, eventos e idempotência. `bindAction(contract, binding)` produz o `ActionDef`
|
|
8
10
|
registrado no runtime.
|
|
9
11
|
|
|
10
12
|
Ordem cobrada pelo check: identidade → documentação → input/output → autorização →
|
|
11
|
-
handler
|
|
13
|
+
handler → comportamento. Campos específicos do kind permanecem junto do grupo ao qual
|
|
12
14
|
servem e o scaffold acompanha a forma atual do SDK.
|
|
@@ -18,7 +18,6 @@ ${common}
|
|
|
18
18
|
input: z.object({}),
|
|
19
19
|
output: z.object({ id: z.string() }),
|
|
20
20
|
paginate: 'cursor',
|
|
21
|
-
mockHandler: async () => ({ items: [], cursor: { next: null }, total: 0 }),
|
|
22
21
|
})`
|
|
23
22
|
}
|
|
24
23
|
if (kind === 'form') {
|
|
@@ -30,7 +29,6 @@ ${common}
|
|
|
30
29
|
input: z.object({ id: z.string() }),
|
|
31
30
|
output: z.object({ id: z.string() }),
|
|
32
31
|
fields: { id: { label: 'Identificador' } },
|
|
33
|
-
mockHandler: async (_ctx, input) => ({ id: input.id }),
|
|
34
32
|
})`
|
|
35
33
|
}
|
|
36
34
|
if (kind === 'view') {
|
|
@@ -40,7 +38,6 @@ ${common}
|
|
|
40
38
|
description: 'Descreva o comportamento de negócio e seus limites.',
|
|
41
39
|
input: z.object({ id: z.string() }),
|
|
42
40
|
output: z.object({ id: z.string() }),
|
|
43
|
-
mockHandler: async (_ctx, input) => ({ id: input.id }),
|
|
44
41
|
})`
|
|
45
42
|
}
|
|
46
43
|
return `export const ${identifier} = defineContract({
|
|
@@ -50,7 +47,6 @@ ${common}
|
|
|
50
47
|
messages: { success: 'Operação concluída.', error: 'Não foi possível concluir.' },
|
|
51
48
|
input: z.object({ id: z.string() }),
|
|
52
49
|
output: z.object({ ok: z.boolean() }),
|
|
53
|
-
mockHandler: async () => ({ ok: true }),
|
|
54
50
|
})`
|
|
55
51
|
}
|
|
56
52
|
|
|
@@ -12,8 +12,8 @@ infraestrutura real quando a unidade é o contrato.
|
|
|
12
12
|
|
|
13
13
|
## Procedimento
|
|
14
14
|
|
|
15
|
-
1. Importar o `ActionDef` produzido pelo binding
|
|
16
|
-
|
|
15
|
+
1. Importar o `ActionDef` produzido pelo binding. Dados do modo design não são comportamento da
|
|
16
|
+
action e não entram nestes testes.
|
|
17
17
|
2. Executar com `runAction` e fornecer `user`, `can`, `loaded`, storage ou outros adapters
|
|
18
18
|
pelas opções do harness.
|
|
19
19
|
3. Cobrir sucesso, input inválido, autorização negada e erros de negócio materiais.
|
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
- `memStorage()`: adapter de storage em memória com inspeção de chaves.
|
|
7
7
|
- `fake` e `fakeMany`: dados determinísticos derivados de schema.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
`
|
|
9
|
+
Teste o binding completo. Um contrato sem handler falha com `testing.no_handler`: os dados do modo
|
|
10
|
+
design são gerados pelo plugin `opusDesign()` e não são comportamento da action.
|
|
@@ -9,7 +9,7 @@ type TaskItem = z.infer<typeof Task>
|
|
|
9
9
|
export function App(): React.ReactElement {
|
|
10
10
|
// O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
|
|
11
11
|
// serve o backend no próprio dev server: dev normal executa o handler real;
|
|
12
|
-
// `pnpm dev:design` responde com
|
|
12
|
+
// `pnpm dev:design` responde com dado gerado a partir do contrato, isolado de qualquer backend.
|
|
13
13
|
const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
|
|
14
14
|
|
|
15
15
|
return (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { runAction } from '@softize/opus/testing'
|
|
3
|
-
import {
|
|
3
|
+
import { taskListImpl } from './list.ts'
|
|
4
4
|
|
|
5
5
|
// O harness roda o MESMO pipeline do runtime (valida input, cobra auth, valida
|
|
6
6
|
// output) — teste pelo contrato, não chamando o handler à mão.
|
|
@@ -14,21 +14,4 @@ describe('task.list', () => {
|
|
|
14
14
|
it('sem usuário, barra: a action não é pública', async () => {
|
|
15
15
|
await expect(runAction(taskListImpl, {})).rejects.toMatchObject({ code: 'auth.unauthenticated' })
|
|
16
16
|
})
|
|
17
|
-
|
|
18
|
-
describe('modo design (o que o `pnpm dev:design` serve)', () => {
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
// OPUS_MODE é global do processo — limpa pra não vazar entre testes.
|
|
21
|
-
delete process.env['OPUS_MODE']
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('o contrato puro responde pelo mockHandler, com dado válido pro schema', async () => {
|
|
25
|
-
process.env['OPUS_MODE'] = 'design'
|
|
26
|
-
const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
|
|
27
|
-
expect(output.items.length).toBeGreaterThan(0)
|
|
28
|
-
// `list` pula a validação de output no pipeline — afirme item a item.
|
|
29
|
-
for (const item of output.items) {
|
|
30
|
-
expect(Task.safeParse(item).success).toBe(true)
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
})
|
|
34
17
|
})
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { bindAction, defineContract } from '@softize/opus/core'
|
|
3
|
-
import { fakeMany } from '@softize/opus/testing'
|
|
4
3
|
|
|
5
|
-
/** Item do domínio-exemplo — o schema alimenta contrato,
|
|
4
|
+
/** Item do domínio-exemplo — o schema alimenta contrato, dados de design e testes. */
|
|
6
5
|
export const Task = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
|
|
7
6
|
|
|
8
7
|
// O split canônico contrato↔handler: o CONTRATO é declarativo e roda nos dois
|
|
9
8
|
// lados (a SPA consome via useListAction); o BINDING é server-only. Em
|
|
10
|
-
// `pnpm dev:design` o
|
|
11
|
-
// no dev normal e em prod, o handler real do binding assume.
|
|
9
|
+
// `pnpm dev:design` o plugin responde com dado gerado a partir do `output`
|
|
10
|
+
// (ADR 0037); no dev normal e em prod, o handler real do binding assume.
|
|
12
11
|
export const taskList = defineContract({
|
|
13
12
|
name: 'task.list',
|
|
14
13
|
kind: 'list',
|
|
@@ -19,7 +18,6 @@ export const taskList = defineContract({
|
|
|
19
18
|
input: z.object({}),
|
|
20
19
|
output: Task,
|
|
21
20
|
paginate: 'cursor',
|
|
22
|
-
mockHandler: async () => ({ items: fakeMany(Task, 5), cursor: { next: null }, total: 5 }),
|
|
23
21
|
})
|
|
24
22
|
|
|
25
23
|
// Fixture em memória — o domínio-exemplo não tem banco; troque pelo seu repositório.
|
|
@@ -5,11 +5,12 @@ import { fileURLToPath } from 'node:url'
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Modo design (`vite --mode design`, script dev:design): o runtime Opus sobe DENTRO do
|
|
8
|
-
* dev server e
|
|
8
|
+
* dev server e as actions respondem o `/api` com dado gerado a partir do `output` do contrato
|
|
9
|
+
* (ADR 0037; cenários específicos entram pela opção `scenarios`) — preview autossuficiente,
|
|
9
10
|
* isolado de prod (o plugin desliga o `server.proxy` sozinho; prefixo ex-proxy sem
|
|
10
11
|
* cobertura responde 503, nunca vaza pra porta real). Quando o app ganhar um backend real,
|
|
11
12
|
* o `dev` normal proxeia pra ele (adicione `server.proxy`); o preview de design segue nos
|
|
12
|
-
*
|
|
13
|
+
* dados de design. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
|
|
13
14
|
*
|
|
14
15
|
* Import DINÂMICO NO TOP-LEVEL, de propósito, e em duas condições que andam juntas: (1) o
|
|
15
16
|
* @softize/opus embarca TS source e o config loader padrão (esbuild bundle — o que build e
|
package/src/core/domain.ts
CHANGED
|
@@ -36,7 +36,7 @@ import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
|
|
|
36
36
|
* Um registrável de action num domínio: BOUND (`ActionDef`, com handler) ou
|
|
37
37
|
* contrato PURO (`ActionContract`, sem handler). Um domínio de design — a
|
|
38
38
|
* fatia isomorfa (ADR 0004) — registra só contratos, e em modo design o
|
|
39
|
-
* `
|
|
39
|
+
* plugin `opusDesign()` responde por eles (ADR 0037). Fora de design, contrato sem handler falha no
|
|
40
40
|
* `execute` (comportamento INTENCIONAL); o tipo só deixa de exigir o cast.
|
|
41
41
|
*/
|
|
42
42
|
export type DomainAction = ActionDef | ActionContract
|
|
@@ -205,7 +205,7 @@ function* iterateActions(
|
|
|
205
205
|
): IterableIterator<ActionDef> {
|
|
206
206
|
if (actions === undefined) return
|
|
207
207
|
// Um contrato puro (`ActionContract`, sem handler) entra como `ActionDef` no
|
|
208
|
-
// runtime — em design o `
|
|
208
|
+
// runtime — em design o plugin `opusDesign()` responde; fora de design, a ausência de
|
|
209
209
|
// handler falha no `execute` (intencional). O cast reconcilia o tipo neste
|
|
210
210
|
// boundary de normalização; ver `DomainAction`.
|
|
211
211
|
if (Array.isArray(actions)) {
|
package/src/core/types.ts
CHANGED
|
@@ -821,27 +821,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
|
|
|
821
821
|
|
|
822
822
|
/**
|
|
823
823
|
* Handler alternativo executado quando `process.env.OPUS_MODE === 'design'`.
|
|
824
|
-
* Use pra prototipar UI com dados realistas sem precisar do backend pronto.
|
|
825
824
|
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
* Em modo default (sem env): `mockHandler` é ignorado. Útil pra apagar
|
|
831
|
-
* em revisão ou manter pro Designer reusar.
|
|
832
|
-
*
|
|
833
|
-
* Pattern recomendado pra ações que só têm mock durante dev:
|
|
834
|
-
*
|
|
835
|
-
* ```ts
|
|
836
|
-
* defineAction({
|
|
837
|
-
* name: 'ticket.list',
|
|
838
|
-
* // ...
|
|
839
|
-
* mockHandler: async () => MOCK_TICKETS,
|
|
840
|
-
* handler: () => {
|
|
841
|
-
* throw new Error('handler real ainda não implementado — use OPUS_MODE=design')
|
|
842
|
-
* },
|
|
843
|
-
* })
|
|
844
|
-
* ```
|
|
825
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); sai na próxima major. No modo design, a action sem
|
|
826
|
+
* mock já responde com dado gerado a partir do `output`; quando o design precisar de uma resposta
|
|
827
|
+
* específica, declare um cenário fora do contrato com `defineDesignScenario`, de
|
|
828
|
+
* `@softize/opus/design`, e aponte a opção `scenarios` do `opusDesign()` para ele.
|
|
845
829
|
*/
|
|
846
830
|
mockHandler?: HandlerFn<ParsedIn, Out>
|
|
847
831
|
|
|
@@ -919,6 +903,8 @@ export interface ListAction<In, Out, ParsedIn = In>
|
|
|
919
903
|
/**
|
|
920
904
|
* Mesma semântica de `ActionBase.mockHandler`, mas devolve `Paginated<Out>`
|
|
921
905
|
* (search retorna lista paginada, não item solto).
|
|
906
|
+
*
|
|
907
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); ver `ActionBase.mockHandler`.
|
|
922
908
|
*/
|
|
923
909
|
mockHandler?: (
|
|
924
910
|
ctx: ActionContext,
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dados do modo design fora do contrato (ADR 0037).
|
|
3
|
+
*
|
|
4
|
+
* No modo design, toda action responde: sem cenário, a resposta é gerada pelo `fake` a partir do
|
|
5
|
+
* `output` do contrato. Quando o design precisa de coerência (um item em cada situação, algo criado
|
|
6
|
+
* que aparece na lista), um cenário registrado pelo nome da action vence a resposta padrão.
|
|
7
|
+
*
|
|
8
|
+
* O módulo é isomórfico e não conhece Vite: o plugin `opusDesign()` carrega os cenários e liga as
|
|
9
|
+
* respostas ao runtime de design.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import type { ActionContext, InferOutput, Paginated } from '../core/types.ts'
|
|
14
|
+
import { fake, fakeMany } from '../testing/fake.ts'
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Cenários
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
// `Symbol.for`: o plugin roda no processo do Vite e carrega os cenários pelo pipeline SSR, então
|
|
21
|
+
// a marca precisa sobreviver a duas instâncias do módulo.
|
|
22
|
+
const SCENARIO = Symbol.for('@softize/opus/design-scenario')
|
|
23
|
+
|
|
24
|
+
type ContractKind = 'simple' | 'form' | 'list' | 'view'
|
|
25
|
+
|
|
26
|
+
interface ScenarioContract {
|
|
27
|
+
name: string
|
|
28
|
+
kind: ContractKind
|
|
29
|
+
input: unknown
|
|
30
|
+
output: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ScenarioInput<C> = C extends { input: infer S } ? InferOutput<S> : never
|
|
34
|
+
|
|
35
|
+
type ScenarioOutput<C> = C extends { output: infer S }
|
|
36
|
+
? C extends { kind: 'list' }
|
|
37
|
+
? Paginated<InferOutput<S>>
|
|
38
|
+
: InferOutput<S>
|
|
39
|
+
: never
|
|
40
|
+
|
|
41
|
+
/** Resposta de um cenário: o valor devolvido, ou uma função da entrada já validada. */
|
|
42
|
+
export type DesignScenarioResponse<C> =
|
|
43
|
+
| ScenarioOutput<C>
|
|
44
|
+
| ((input: ScenarioInput<C>, ctx: ActionContext) => ScenarioOutput<C> | Promise<ScenarioOutput<C>>)
|
|
45
|
+
|
|
46
|
+
/** Cenário de design ligado a uma action pelo nome. */
|
|
47
|
+
export interface DesignScenario {
|
|
48
|
+
readonly [SCENARIO]: true
|
|
49
|
+
readonly action: string
|
|
50
|
+
respond(input: unknown, ctx: ActionContext): Promise<unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Declara a resposta de uma action no modo design, fora do contrato. A resposta é tipada pela
|
|
55
|
+
* entrada e pela saída do contrato; em `list`, é a página completa (`items`, `cursor`, `total`).
|
|
56
|
+
*/
|
|
57
|
+
export function defineDesignScenario<C extends ScenarioContract>(
|
|
58
|
+
contract: C,
|
|
59
|
+
// NoInfer: o contrato é inferido só do primeiro argumento. Sem isso, a resposta entra na
|
|
60
|
+
// inferência e os literais do retorno de uma função alargam (`'closed'` vira `string`).
|
|
61
|
+
response: NoInfer<DesignScenarioResponse<C>>,
|
|
62
|
+
): DesignScenario {
|
|
63
|
+
const respond =
|
|
64
|
+
typeof response === 'function'
|
|
65
|
+
? async (input: unknown, ctx: ActionContext): Promise<unknown> =>
|
|
66
|
+
(response as (input: unknown, ctx: ActionContext) => unknown)(input, ctx)
|
|
67
|
+
: async (): Promise<unknown> => response
|
|
68
|
+
return { [SCENARIO]: true, action: contract.name, respond }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `true` quando o valor foi criado por `defineDesignScenario`. */
|
|
72
|
+
export function isDesignScenario(value: unknown): value is DesignScenario {
|
|
73
|
+
return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[SCENARIO] === true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// Resposta padrão
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
/** Itens por página da listagem padrão quando a entrada não pede `limit`. */
|
|
81
|
+
export const DESIGN_PAGE_SIZE = 20
|
|
82
|
+
|
|
83
|
+
// Teto do `limit` atendido: o maior tamanho de página que o `ActionList` oferece.
|
|
84
|
+
const DESIGN_MAX_PAGE_SIZE = 100
|
|
85
|
+
|
|
86
|
+
// Três páginas: o bastante para a paginação do preview ter para onde ir.
|
|
87
|
+
const DESIGN_PAGES = 3
|
|
88
|
+
|
|
89
|
+
const PAGINATION_KEYS = new Set(['page', 'cursor', 'offset', 'limit'])
|
|
90
|
+
|
|
91
|
+
interface DesignActionShape {
|
|
92
|
+
name: string
|
|
93
|
+
kind: ContractKind
|
|
94
|
+
output?: unknown
|
|
95
|
+
paginate?: 'cursor' | 'offset' | false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resposta padrão do modo design: dado gerado pelo `fake` a partir do `output`, com semente
|
|
100
|
+
* derivada do nome da action e da entrada. A mesma requisição recebe a mesma resposta.
|
|
101
|
+
*
|
|
102
|
+
* - `list` devolve três páginas do tamanho pedido em `limit` ({@link DESIGN_PAGE_SIZE} quando a
|
|
103
|
+
* entrada não pede) e respeita a paginação declarada; busca e filtros não filtram o dado gerado.
|
|
104
|
+
* - Nas demais, a saída ecoa o `id` da entrada quando o schema da saída aceita esse valor.
|
|
105
|
+
* - Saída que não é schema zod não tem gerador: a resposta é `null`.
|
|
106
|
+
*/
|
|
107
|
+
export function designResponse(action: DesignActionShape, input: unknown): unknown {
|
|
108
|
+
const output = action.output
|
|
109
|
+
if (!(output instanceof z.ZodType)) return null
|
|
110
|
+
const seed = `${action.name}:${canonicalJson(withoutPagination(input))}`
|
|
111
|
+
|
|
112
|
+
if (action.kind === 'list') {
|
|
113
|
+
const paginate = action.paginate ?? 'cursor'
|
|
114
|
+
const size = pageSize(input)
|
|
115
|
+
if (paginate === false) {
|
|
116
|
+
return { items: fakeMany(output, size, { seed }), cursor: { next: null }, total: size }
|
|
117
|
+
}
|
|
118
|
+
// O `limit` fica fora da semente: mudar o tamanho da página redistribui os mesmos itens.
|
|
119
|
+
const all = fakeMany(output, size * DESIGN_PAGES, { seed })
|
|
120
|
+
const page = requestedPage(input, size)
|
|
121
|
+
const start = (page - 1) * size
|
|
122
|
+
const next = paginate === 'cursor' && page < DESIGN_PAGES ? `design:${page + 1}` : null
|
|
123
|
+
const prev = paginate === 'cursor' && page > 1 ? `design:${page - 1}` : null
|
|
124
|
+
return {
|
|
125
|
+
items: all.slice(start, start + size),
|
|
126
|
+
cursor: { next, prev },
|
|
127
|
+
total: all.length,
|
|
128
|
+
} satisfies Paginated<unknown>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const value = fake(output, { seed })
|
|
132
|
+
const inputId = isRecord(input) ? input.id : undefined
|
|
133
|
+
if (inputId !== undefined && isRecord(value) && 'id' in value) {
|
|
134
|
+
// Só ecoa quando a saída continua válida: `id` numérico ou uuid na saída não aceita qualquer
|
|
135
|
+
// valor da entrada, e a validação de output do runtime recusaria a resposta.
|
|
136
|
+
const echoed = { ...value, id: inputId }
|
|
137
|
+
if (output.safeParse(echoed).success) return echoed
|
|
138
|
+
}
|
|
139
|
+
return value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pageSize(input: unknown): number {
|
|
143
|
+
const limit = isRecord(input) && typeof input.limit === 'number' ? Math.trunc(input.limit) : DESIGN_PAGE_SIZE
|
|
144
|
+
return Math.min(Math.max(1, limit), DESIGN_MAX_PAGE_SIZE)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Página pedida, de 1 em diante; além da última página, a lista vem vazia. */
|
|
148
|
+
function requestedPage(input: unknown, size: number): number {
|
|
149
|
+
if (!isRecord(input)) return 1
|
|
150
|
+
const cursor = typeof input.cursor === 'string' ? /^design:(\d+)$/.exec(input.cursor) : null
|
|
151
|
+
const fromCursor = cursor === null ? undefined : Number(cursor[1])
|
|
152
|
+
const fromOffset = typeof input.offset === 'number' ? Math.floor(input.offset / size) + 1 : undefined
|
|
153
|
+
const fromPage = typeof input.page === 'number' ? input.page : undefined
|
|
154
|
+
return Math.max(1, Math.trunc(fromCursor ?? fromPage ?? fromOffset ?? 1))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function withoutPagination(input: unknown): unknown {
|
|
158
|
+
if (!isRecord(input)) return input
|
|
159
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => !PAGINATION_KEYS.has(key)))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** JSON com chaves ordenadas: a mesma entrada produz a mesma semente, qualquer que seja a ordem. */
|
|
163
|
+
function canonicalJson(value: unknown): string {
|
|
164
|
+
if (typeof value === 'bigint') return `${value}n`
|
|
165
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString())
|
|
166
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
167
|
+
if (isRecord(value)) {
|
|
168
|
+
const entries = Object.keys(value)
|
|
169
|
+
.filter((key) => value[key] !== undefined)
|
|
170
|
+
.sort()
|
|
171
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
172
|
+
return `{${entries.join(',')}}`
|
|
173
|
+
}
|
|
174
|
+
return JSON.stringify(value) ?? 'null'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
179
|
+
}
|
package/src/testing/fake.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `fake` — dado sintético DETERMINÍSTICO a partir de um schema zod. É a matéria-prima do
|
|
3
|
-
* modo design:
|
|
4
|
-
* a UI com dado realista, sem backend nem banco
|
|
3
|
+
* modo design: a resposta gerada pelo plugin `opusDesign()` (ADR 0037) sai de `fake`/`fakeMany`
|
|
4
|
+
* sobre o `output` do contrato, e o preview mostra a UI com dado realista, sem backend nem banco.
|
|
5
5
|
*
|
|
6
6
|
* DETERMINISMO: PRNG próprio semeado (mulberry32) — mesma `seed` → mesma saída, sempre. Nunca
|
|
7
7
|
* `Math.random` (o preview tem que ser reproduzível entre execuções). O default é semeado por
|
package/src/testing/index.ts
CHANGED
|
@@ -206,9 +206,13 @@ export interface TestableAction<In, Out, ParsedIn = In> {
|
|
|
206
206
|
authorize?: AuthorizeSpec<ParsedIn>
|
|
207
207
|
input: Schema<In, ParsedIn>
|
|
208
208
|
output: Schema<Out> | Schema<unknown, Out>
|
|
209
|
-
/** Opcional: contrato puro (sem handler) executa
|
|
209
|
+
/** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
|
|
210
210
|
handler?: HandlerFn<ParsedIn, Out>
|
|
211
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime.
|
|
213
|
+
*
|
|
214
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); teste a action ligada ao handler.
|
|
215
|
+
*/
|
|
212
216
|
mockHandler?: HandlerFn<ParsedIn, Out>
|
|
213
217
|
}
|
|
214
218
|
|
|
@@ -329,7 +333,7 @@ export async function runAction<In, Out, ParsedIn = In>(
|
|
|
329
333
|
throw error({
|
|
330
334
|
code: 'testing.no_handler',
|
|
331
335
|
category: 'internal',
|
|
332
|
-
message: `Action "${action.name}" não tem handler executável
|
|
336
|
+
message: `Action "${action.name}" não tem handler executável: teste o ActionDef produzido pelo bindAction. Os dados do modo design são gerados pelo plugin opusDesign() e não são comportamento da action.`,
|
|
333
337
|
})
|
|
334
338
|
}
|
|
335
339
|
const raw = await handlerToRun(ctx, parsed, loaded)
|