@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/bin/cli.mjs +9 -1
  3. package/bin/lib/check.mjs +37 -29
  4. package/docs/adr/0004-page-content-state-is-composed.md +3 -2
  5. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +3 -0
  6. package/docs/adr/0006-semantic-context-precedes-visual-variant.md +23 -14
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -0
  8. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
  9. package/docs/adr/0016-list-collection-header-belongs-to-content.md +13 -8
  10. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +27 -26
  11. package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +1 -0
  12. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +71 -0
  13. package/docs/adr/0030-framed-content-can-use-sectioned-anatomy.md +89 -0
  14. package/docs/adr/0031-detail-dividers-own-the-tabular-label-treatment.md +61 -0
  15. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +59 -0
  16. package/docs/adr/0033-drawer-uses-an-inset-structural-frame.md +72 -0
  17. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +60 -0
  18. package/docs/adr/0035-button-group-separates-frame-relationship-and-distribution.md +63 -0
  19. package/docs/adr/0036-tabs-declares-trigger-distribution.md +58 -0
  20. package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
  21. package/docs/radius-scale.md +5 -2
  22. package/package.json +5 -1
  23. package/registry/skills/build-opus-ui/SKILL.md +9 -6
  24. package/registry/skills/build-opus-ui/references/evaluations.md +5 -2
  25. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -16
  26. package/registry/skills/create-opus-action/references/contract-and-binding.md +5 -3
  27. package/registry/skills/create-opus-action/scripts/scaffold.mjs +0 -4
  28. package/registry/skills/test-opus-action/SKILL.md +2 -2
  29. package/registry/skills/test-opus-action/references/harness.md +2 -2
  30. package/registry/templates/app/src/App.tsx +1 -1
  31. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
  32. package/registry/templates/app/src/domains/tasks/actions/list.ts +3 -5
  33. package/registry/templates/app/vite.config.ts +3 -2
  34. package/src/core/domain.ts +2 -2
  35. package/src/core/types.ts +6 -20
  36. package/src/design/index.ts +179 -0
  37. package/src/testing/fake.ts +2 -2
  38. package/src/testing/index.ts +7 -3
  39. package/src/ui/components/patterns/content-header.tsx +51 -13
  40. package/src/ui/components/patterns/list.tsx +51 -33
  41. package/src/ui/components/patterns/page.tsx +139 -251
  42. package/src/ui/components/patterns/presentation.tsx +2 -6
  43. package/src/ui/components/primitives/button-group.tsx +80 -8
  44. package/src/ui/components/primitives/button.tsx +85 -60
  45. package/src/ui/components/primitives/detail.tsx +63 -57
  46. package/src/ui/components/primitives/dialog.tsx +8 -11
  47. package/src/ui/components/primitives/drawer.tsx +16 -12
  48. package/src/ui/components/primitives/tabs.tsx +123 -54
  49. package/src/ui/docs/content/actions.md +49 -43
  50. package/src/ui/docs/content/button.md +55 -29
  51. package/src/ui/docs/content/content.md +61 -36
  52. package/src/ui/docs/content/detail.md +28 -19
  53. package/src/ui/docs/content/drawer.md +53 -47
  54. package/src/ui/docs/content/input.md +51 -35
  55. package/src/ui/docs/content/page.md +84 -95
  56. package/src/ui/docs/content/pane.md +83 -63
  57. package/src/ui/docs/content/presentation.md +20 -20
  58. package/src/ui/docs/content/tabs.md +107 -36
  59. package/src/ui/docs/content/testing.md +9 -25
  60. package/src/ui/meta.ts +5 -5
  61. package/src/ui/react.tsx +0 -1
  62. package/src/vite/design.ts +133 -23
@@ -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 mudam neste entregável. Arredondar somente os
46
- cantos expostos exige validar clipping, scroll e conteúdo full-bleed em uma mudança própria.
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": "21.0.1",
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` declara o chrome,
47
- `PageIntro` a introdução opcional e `PageBody` o conteúdo. Na composição explícita, declare
48
- exatamente um heading principal com `PageTitle` no header/intro, `Content level={1}` ou um
49
- `PageState` ativo no body;
50
- dentro de `PageShell`, os slots de
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? + PageIntro? + PageBody`, garantindo exatamente um
8
- `PageTitle` entre header/intro, e uma coleção nomeada com
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`, `PageIntro` e
12
- `PageFooter`. Declare esse heading com `PageTitle` em header/intro, `Content level={1}` ou um
13
- `PageState` ativo no body. `PageHeader`
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
- - `PageIntro` é a região estrutural de uma introdução no conteúdo. Pode coexistir com
19
- `PageHeader`, porque as duas regiões têm papéis diferentes.
20
- - `PageHeader` declara navegação e ações globais; fora do shell também pode carregar o título. Em
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 é o único responsável pela barra e projeta os slots de `PageHeader`; `PageIntro` permanece
28
- no conteúdo.
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 isolada
38
- some inteiro — incluindo o `PageBack`; dentro de `PageShell`, `PageIntro` some e a barra
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, autorização
4
- declarativa compartilhável e `mockHandler` de design quando aplicá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/mock → comportamento. Campos específicos do kind permanecem junto do grupo ao qual
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; para contrato puro, testar `mockHandler`
16
- apenas no modo design.
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
- Prefira o binding completo em testes de execução. Um contrato sem handler só executa via
10
- `mockHandler` quando `OPUS_MODE=design`.
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 o mockHandler, isolado de qualquer backend.
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 { afterEach, describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
2
2
  import { runAction } from '@softize/opus/testing'
3
- import { Task, taskList, taskListImpl } from './list.ts'
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, mock e testes. */
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 mockHandler responde (dado fake do próprio schema);
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 os mockHandler dos contratos respondem o `/api` — preview autossuficiente,
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
- * mocks. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
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
@@ -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
- * `mockHandler` responde. Fora de design, contrato sem handler falha no
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 `mockHandler` responde; fora de design, a ausência de
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
- * Em modo design: runtime tenta `mockHandler` primeiro; se ausente, cai
827
- * no `handler` normal (permitindo testar pontualmente real ao lado de
828
- * mocks).
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
+ }
@@ -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: um `mockHandler` devolve `fake(entity)`/`fakeMany(entity, n)` e o preview mostra
4
- * a UI com dado realista, sem backend nem banco (o design-mode do runtime nem toca `ctx.db`).
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
@@ -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 via `mockHandler` em modo design. */
209
+ /** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
210
210
  handler?: HandlerFn<ParsedIn, Out>
211
- /** Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime. */
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 — contrato puro roda em modo design (OPUS_MODE=design) com mockHandler; fora dele, teste o ActionDef do bindAction`,
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)