@softize/opus 22.2.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/bin/cli.mjs +3 -9
  3. package/bin/lib/check.mjs +242 -49
  4. package/bin/lib/copy.mjs +17 -35
  5. package/bin/lib/gen-runner.mjs +2 -0
  6. package/bin/lib/gen-stubs.mjs +3 -1
  7. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +1 -1
  8. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
  9. package/docs/adr/0032-tab-baseline-respects-the-container-inset.md +16 -12
  10. package/docs/adr/0034-tabs-can-own-a-frame-without-owning-a-surface.md +8 -6
  11. package/docs/adr/0036-tabs-declares-trigger-distribution.md +11 -8
  12. package/docs/adr/0037-design-data-lives-outside-the-contract.md +1 -1
  13. package/docs/adr/0038-content-level-defines-primary-presentation.md +62 -0
  14. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +62 -0
  15. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +59 -0
  16. package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
  17. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
  18. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
  19. package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
  20. package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
  21. package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
  22. package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
  23. package/docs/code-style.md +1 -1
  24. package/package.json +5 -1
  25. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  26. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  27. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  28. package/registry/skills/create-opus-action/references/contract-and-binding.md +3 -3
  29. package/registry/templates/app/src/App.tsx +24 -12
  30. package/src/core/contracts.ts +1 -1
  31. package/src/core/runtime.ts +4 -37
  32. package/src/core/types.ts +5 -23
  33. package/src/design/index.ts +14 -3
  34. package/src/testing/index.ts +6 -26
  35. package/src/ui/appearance.ts +149 -0
  36. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  37. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  38. package/src/ui/components/patterns/confirm.tsx +3 -1
  39. package/src/ui/components/patterns/fill-region.ts +8 -7
  40. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  41. package/src/ui/components/patterns/form.tsx +8 -7
  42. package/src/ui/components/patterns/list.tsx +64 -14
  43. package/src/ui/components/patterns/page.tsx +15 -11
  44. package/src/ui/components/patterns/pane.tsx +9 -240
  45. package/src/ui/components/patterns/presentation.tsx +14 -14
  46. package/src/ui/components/patterns/sidebar.tsx +7 -17
  47. package/src/ui/components/patterns/split.tsx +111 -59
  48. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
  49. package/src/ui/components/patterns/structural-media.tsx +26 -0
  50. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  51. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  52. package/src/ui/components/patterns/surface.tsx +620 -0
  53. package/src/ui/components/patterns/trigger.tsx +11 -9
  54. package/src/ui/components/primitives/ask.tsx +155 -83
  55. package/src/ui/components/primitives/button.tsx +11 -9
  56. package/src/ui/components/primitives/checkbox.tsx +8 -8
  57. package/src/ui/components/primitives/command.tsx +1 -1
  58. package/src/ui/components/primitives/composer.tsx +47 -45
  59. package/src/ui/components/primitives/detail.tsx +9 -2
  60. package/src/ui/components/primitives/dialog.tsx +60 -26
  61. package/src/ui/components/primitives/drawer.tsx +58 -12
  62. package/src/ui/components/primitives/icon-picker.tsx +93 -70
  63. package/src/ui/components/primitives/input-group.tsx +35 -28
  64. package/src/ui/components/primitives/input-otp.tsx +16 -16
  65. package/src/ui/components/primitives/input.tsx +20 -10
  66. package/src/ui/components/primitives/item.tsx +9 -12
  67. package/src/ui/components/primitives/menu.tsx +44 -65
  68. package/src/ui/components/primitives/metric-card.tsx +75 -59
  69. package/src/ui/components/primitives/radio-group.tsx +9 -9
  70. package/src/ui/components/primitives/select.tsx +344 -209
  71. package/src/ui/components/primitives/switch.tsx +10 -10
  72. package/src/ui/components/primitives/table.tsx +29 -24
  73. package/src/ui/components/primitives/tabs.tsx +58 -88
  74. package/src/ui/components/primitives/textarea.tsx +6 -6
  75. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  76. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  77. package/src/ui/components/primitives/toggle.tsx +82 -16
  78. package/src/ui/docs/DocBrowser.tsx +77 -69
  79. package/src/ui/docs/content/action-form-card.md +9 -9
  80. package/src/ui/docs/content/action-list.md +85 -70
  81. package/src/ui/docs/content/action-view.md +33 -18
  82. package/src/ui/docs/content/actions.md +6 -7
  83. package/src/ui/docs/content/button.md +1 -1
  84. package/src/ui/docs/content/communication.md +1 -1
  85. package/src/ui/docs/content/customization.md +64 -11
  86. package/src/ui/docs/content/detail.md +8 -6
  87. package/src/ui/docs/content/dialog.md +100 -58
  88. package/src/ui/docs/content/drawer.md +63 -19
  89. package/src/ui/docs/content/field.md +14 -15
  90. package/src/ui/docs/content/icon-tile.md +14 -13
  91. package/src/ui/docs/content/page.md +34 -26
  92. package/src/ui/docs/content/pane.md +41 -290
  93. package/src/ui/docs/content/presentation.md +3 -3
  94. package/src/ui/docs/content/semantic-context.md +0 -1
  95. package/src/ui/docs/content/separator.md +6 -6
  96. package/src/ui/docs/content/sidebar.md +68 -266
  97. package/src/ui/docs/content/skeleton.md +20 -18
  98. package/src/ui/docs/content/split.md +14 -14
  99. package/src/ui/docs/content/surface.md +513 -0
  100. package/src/ui/docs/content/tabs.md +82 -83
  101. package/src/ui/docs/content/testing.md +2 -3
  102. package/src/ui/docs/content/toggle.md +120 -63
  103. package/src/ui/docs/content/tokens.md +124 -40
  104. package/src/ui/docs/registry.tsx +4 -6
  105. package/src/ui/drivers/react.tsx +4 -3
  106. package/src/ui/index.ts +30 -27
  107. package/src/ui/meta.ts +15 -27
  108. package/src/ui/react.tsx +42 -46
  109. package/src/ui/theme.css +311 -64
  110. package/src/vite/design.ts +23 -21
  111. package/src/ui/components/patterns/content-header.tsx +0 -532
  112. package/src/ui/components/patterns/surface-media.tsx +0 -69
  113. package/src/ui/components/primitives/card.tsx +0 -191
  114. package/src/ui/docs/content/card.md +0 -292
  115. package/src/ui/docs/content/content.md +0 -276
@@ -6,8 +6,8 @@
6
6
  - Execução estrutural: montar uma página de relatório isolada com `Page > PageHeader + PageBody`,
7
7
  outra em `PageShell` com navegação contextual acima de `Page > PageHeader + PageBody`, garantindo
8
8
  exatamente um `PageTitle` no header, e uma coleção nomeada com
9
- `Content > ContentHeader + ContentBody > ActionList`. Provar que criação fica em
10
- `ContentActions`, no extremo oposto ao título, como botão textual `default` sem ícone e com
9
+ `Surface > SurfaceHeader + SurfaceBody > ActionList`. Provar que criação fica em
10
+ `SurfaceActions`, 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
12
12
  `ActionFilterBar` separado do renderer, `ItemGroup` para
13
13
  uma coleção secundária e um `ActionFormDialog`; provar teto padrão de `80rem`, hierarquia por
@@ -18,13 +18,14 @@
18
18
  - Execução roteável: declarar Page, criação e edição com `Presentation.route`; provar deep link,
19
19
  hidratação do registro, fechamento para a lista e projeção integral no manifest.
20
20
  - Execução abreviada: montar outra página com `<Page title actions>` e uma seção com
21
- `<Content title description actions>`, provando que ambas produzem a mesma anatomia e que o lint
21
+ `<Surface title description actions>`, provando que ambas produzem a mesma anatomia e que o lint
22
22
  rejeita a mistura entre props abreviadas e headers explícitos.
23
- - Execução de forma: compor uma tabela dentro de Card sem moldura duplicada, manter a moldura
23
+ - Execução de forma: compor uma tabela dentro de `Surface` 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
+ - Execução de Surface seccionada: compor `Surface framed layout="sectioned"` com mídia e
26
+ `DetailGroup` com divisores; provar header com divisor, `SurfaceBody flush` sem recuo e ausência
27
+ de classes no consumidor que reconstruam essa anatomia. Provar `as="fieldset"` com `legend`
28
+ nativa sem `frameVariant`.
28
29
  - Reprova: criar uma casca `bg-card` que herda o texto global ou introduzir `rounded-widget`
29
30
  quando a escala `rounded-*` atende ao papel.
30
31
  - Reprova: trocar automaticamente o radius de um controle quando apenas seu tamanho muda ou
@@ -32,8 +33,8 @@
32
33
  - Reprova: reconstruir manualmente o container de página, usar `Empty` tracejado como vazio de
33
34
  tabela, alinhar toda coluna numérica à direita por inferência ou destacar “Cancelar” como ação
34
35
  primária em modal.
35
- - Reprova: deixar `ContentHeader` fora de `Content`, omitir `PageBody`/`ContentBody` na composição
36
- explícita, usar `CardContent`/`PaneContent` em código novo ou tratar `DialogContent` como Body.
36
+ - Reprova: deixar `SurfaceHeader` fora de `Surface`, omitir `PageBody`/`SurfaceBody` na composição
37
+ explícita, usar `Card*` ou `Content*` em código novo ou tratar `DialogContent` como Body.
37
38
  - Execução de dicionários: montar a lista de clientes com “Pessoa física/Empresa” e
38
39
  “Prospect/Cliente”; provar que o tipo ocupa a coluna “Tipo” como classificação (badge `outline`
39
40
  com os ícones declarados `user` e `building`), que o estágio ocupa a coluna “Estágio” como badge
@@ -9,7 +9,7 @@
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
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
12
+ Declare esse heading com `PageTitle` no header, `Surface level={1}` ou um `PageState` ativo no
13
13
  body. `PageHeader` é a única introdução estrutural e reúne navegação, título e ações. `title` e `actions` no
14
14
  próprio `Page` são a abreviação para o caso direto.
15
15
  Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
@@ -26,8 +26,8 @@
26
26
  ações somente com ícone de chrome usam `icon`. O tamanho `sm` fica para ações operacionais em
27
27
  toolbar, seção ou coleção densa, e `xs`/`icon-xs` para ações internas de linha ou célula. Contexto
28
28
  e variante resolvem a hierarquia visual sem alterar essa medida. Em uma coleção, a ação de
29
- criar usa botão textual `default`, sem ícone, no extremo oposto do `ContentHeader`. Não monte
30
- `PaneHeader`, portal ou seletor global para reconstruir essa composição.
29
+ criar usa botão textual `default`, sem ícone, no extremo oposto do `SurfaceHeader`. Não monte
30
+ cabeçalho paralelo, portal ou seletor global para reconstruir essa composição.
31
31
  - `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
32
32
  forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
33
33
  explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho da Page
@@ -37,22 +37,23 @@
37
37
  `role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
38
38
  recuperação como botão `outline` textual. Estados de seção ou coleção continuam em `DataState`,
39
39
  `ActionView`, `ActionList` ou `Alert`; não elevar uma falha parcial a estado da página.
40
- - `Content` delimita uma seção e segue a mesma anatomia: `Content > ContentHeader (ContentTitle,
41
- ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader` nunca fica
42
- solto. `title`, `description`, `count` e `actions` no `Content` são a abreviação para o caso
43
- direto e não podem ser misturados ao header explícito. Na variante `page`, ações da coleção ficam
44
- no extremo oposto ao título; a criação usa botão textual `default`, sem ícone, com `Criar recurso`.
40
+ - `Surface` delimita uma região e segue a anatomia `Surface > SurfaceHeader (SurfaceMedia,
41
+ SurfaceTitle, SurfaceDescription, SurfaceMeta, SurfaceActions) + SurfaceBody + SurfaceFooter`.
42
+ Os slots nunca ficam soltos. `title`, `description`, `count`, `media` e `actions` são a abreviação
43
+ para o caso direto e não podem ser misturados ao header explícito. Em `level={1}`, ações da
44
+ coleção ficam no extremo oposto ao título; a criação usa botão textual `default`, sem ícone, com
45
+ `Criar recurso`.
45
46
  O diálogo de criação usa `Criar recurso` e o de edição, `Editar recurso`. `level` preserva a
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.
50
- - Uma página centrada em coleção compõe `Content + ActionList`. O `ContentHeader` recebe título e
47
+ hierarquia semântica do heading. `Surface framed` aceita `layout="sectioned"`; declare
48
+ `SurfaceBody flush` quando o filho direto já possui recuo interno, como `DetailGroup` com
49
+ divisores, lista ou tabela. `as="fieldset"` cria a `legend` nativa sem variante de moldura.
50
+ - Uma página centrada em coleção compõe `Surface + ActionList`. O `SurfaceHeader` recebe título e
51
51
  criação; o `ActionList` mantém busca, filtros, atualização, resultados, estados e paginação.
52
52
  `toolbarActions` fica reservado a operações que dependem do recorte atual.
53
- - Card, Drawer e Pane nomeiam a região principal como `CardBody`, `DrawerBody` e `PaneBody`.
54
- `*Content` permanece reservado a raízes técnicas ou painéis cujo papel não é o corpo de uma
55
- estrutura, como `DialogContent`, `PopoverContent` e `TabsContent`.
53
+ - `Surface` e Drawer nomeiam a região principal como `SurfaceBody` e `DrawerBody`. `Pane` é apenas
54
+ uma unidade dimensionável de `Split` e não possui anatomia interna. `*Content` permanece
55
+ reservado a raízes técnicas ou painéis cujo papel não é o corpo de uma estrutura, como
56
+ `DialogContent`, `PopoverContent` e `TabsPanel`.
56
57
  - Componentes compartilhados não impõem margem externa; páginas e shells compõem layout.
57
58
  - A UI assume layout desktop fixo e largura mínima suportada de `64rem`. Não introduzir
58
59
  breakpoints de viewport ou container queries isoladas; uma futura responsividade precisa começar
@@ -1,9 +1,9 @@
1
1
  # Contrato e binding
2
2
 
3
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.
4
+ declarativa compartilhável. Ele não declara dados de design: no modo design, a action responde
5
+ com dado gerado a partir do `output`, e um cenário específico, quando necessário, fica fora do
6
+ contrato em `defineDesignScenario` (ADR 0037).
7
7
 
8
8
  O binding contém `handler`, `loads`, autorização dependente de dados carregados, execução
9
9
  em background, eventos e idempotência. `bindAction(contract, binding)` produz o `ActionDef`
@@ -1,8 +1,13 @@
1
- import type { z } from 'zod'
2
- import { Badge, Card, DataState, useListAction } from '@softize/opus/ui/react'
3
- import { Task, taskList } from './domains/tasks/actions/list.ts'
1
+ import type { z } from "zod";
2
+ import {
3
+ Badge,
4
+ DataState,
5
+ Surface,
6
+ useListAction,
7
+ } from "@softize/opus/ui/react";
8
+ import { Task, taskList } from "./domains/tasks/actions/list.ts";
4
9
 
5
- type TaskItem = z.infer<typeof Task>
10
+ type TaskItem = z.infer<typeof Task>;
6
11
 
7
12
  /** Página inicial do esqueleto: prova a fundação (tema + componentes do Opus) e o
8
13
  * domínio-exemplo. Troque tudo — este arquivo é seu. */
@@ -10,14 +15,16 @@ export function App(): React.ReactElement {
10
15
  // O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
11
16
  // serve o backend no próprio dev server: dev normal executa o handler real;
12
17
  // `pnpm dev:design` responde com dado gerado a partir do contrato, isolado de qualquer backend.
13
- const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
18
+ const { items, isLoading, error, refetch } =
19
+ useListAction<TaskItem>(taskList);
14
20
 
15
21
  return (
16
22
  <main className="flex min-h-full items-center justify-center bg-background p-6">
17
- <Card className="w-full max-w-md p-6">
18
- <h1 className="mb-1 text-lg font-semibold">{'{{APP_NAME}}'}</h1>
23
+ <Surface framed elevation="raised" className="w-full max-w-md p-6">
24
+ <h1 className="mb-1 text-lg font-semibold">{"{{APP_NAME}}"}</h1>
19
25
  <p className="mb-4 text-sm text-muted-foreground">
20
- Esqueleto criado pelo opus create. A spec vive nas declarações do domínio.
26
+ Esqueleto criado pelo opus create. A spec vive nas declarações do
27
+ domínio.
21
28
  </p>
22
29
  {/* Carregando, erro e vazio saem do DataState; a lista só cuida dos itens. */}
23
30
  <DataState
@@ -29,14 +36,19 @@ export function App(): React.ReactElement {
29
36
  >
30
37
  <ul className="space-y-2">
31
38
  {items.map((t) => (
32
- <li key={t.id} className="flex items-center justify-between gap-2 text-sm">
39
+ <li
40
+ key={t.id}
41
+ className="flex items-center justify-between gap-2 text-sm"
42
+ >
33
43
  <span>{t.title}</span>
34
- <Badge context={t.done ? 'success' : 'neutral'}>{t.done ? 'Feita' : 'Aberta'}</Badge>
44
+ <Badge context={t.done ? "success" : "neutral"}>
45
+ {t.done ? "Feita" : "Aberta"}
46
+ </Badge>
35
47
  </li>
36
48
  ))}
37
49
  </ul>
38
50
  </DataState>
39
- </Card>
51
+ </Surface>
40
52
  </main>
41
- )
53
+ );
42
54
  }
@@ -42,7 +42,7 @@ type BindingKeys = 'handler' | 'loads' | 'background' | 'emits' | 'idempotency'
42
42
  * `ParsedIn` (o lado PARSEADO do schema de input — defaults aplicados) defaulta
43
43
  * a `any` nos contratos, de propósito: quem anota contrato à mão é o CLIENTE
44
44
  * (`FormContract<MeuInput, MeuOutput>` em hooks/props), que só conhece o fio —
45
- * e `authorize`/`mockHandler` são contravariantes em `ParsedIn`, então um
45
+ * e `authorize` é contravariante em `ParsedIn`, então um
46
46
  * default estreito rejeitaria contrato real cujo schema tem `.default()`.
47
47
  * Quem precisa do parseado com precisão (`bindAction`) o infere do schema.
48
48
  */
@@ -833,16 +833,7 @@ export class Runtime {
833
833
  }
834
834
 
835
835
  // — 5. Handler ————————————————————————————————————————————————————————
836
- // Modo design (`OPUS_MODE=design`): tenta mockHandler primeiro, cai
837
- // no handler real se não houver. Em qualquer outro modo, handler real
838
- // sempre manda. Leitura de env mora aqui, no boundary do execute — o
839
- // resto do core continua sem tocar `process.env`.
840
- const isDesignMode = isOpusDesignMode()
841
- const handlerToRun =
842
- isDesignMode && action.mockHandler !== undefined
843
- ? action.mockHandler
844
- : action.handler
845
- const output = await handlerToRun(
836
+ const output = await action.handler(
846
837
  ctx,
847
838
  validatedInput,
848
839
  loaded,
@@ -850,8 +841,6 @@ export class Runtime {
850
841
  ? progress
851
842
  : undefined,
852
843
  )
853
- const usedMock = handlerToRun === action.mockHandler
854
-
855
844
  // — 6. Validate output (dev only) —————————————————————————————————————
856
845
  // Search retorna Paginated<Item> mas `output` schema descreve só Item;
857
846
  // skipamos pra evitar falso negativo. v1 pode validar items[].
@@ -874,7 +863,6 @@ export class Runtime {
874
863
  ctx,
875
864
  input: validatedInput,
876
865
  output: validatedOutput,
877
- mode: usedMock ? 'design' : undefined,
878
866
  }),
879
867
  action.audit,
880
868
  )
@@ -1271,8 +1259,6 @@ export class Runtime {
1271
1259
  input: unknown
1272
1260
  output?: unknown
1273
1261
  error?: ReturnType<typeof error>
1274
- /** Quando `'design'`, sinaliza que o mockHandler foi executado. */
1275
- mode?: 'design' | undefined
1276
1262
  }): AuditRecord {
1277
1263
  const record: AuditRecord = {
1278
1264
  id: args.actionId,
@@ -1293,13 +1279,12 @@ export class Runtime {
1293
1279
  if (args.nature !== undefined) {
1294
1280
  const effect = resolveActionEffect(args.nature)
1295
1281
  if (effect !== undefined) record.effect = effect
1296
- // A exposição só existe quando a action devolveu algo: erro não expõe o dado declarado, e a
1297
- // resposta simulada do modo design (mock, cenário ou gerada) não é de ninguém.
1298
- if (args.nature.personalData === true && args.outcome === 'success' && args.mode !== 'design') {
1282
+ // A exposição só existe quando a action devolveu algo. O plugin de design remove o marcador
1283
+ // das respostas simuladas antes de registrar a action no runtime.
1284
+ if (args.nature.personalData === true && args.outcome === 'success') {
1299
1285
  record.personalData = { records: countReturnedRecords(args.nature.kind, args.output) }
1300
1286
  }
1301
1287
  }
1302
- if (args.mode !== undefined) record.meta = { mode: args.mode }
1303
1288
  return record
1304
1289
  }
1305
1290
 
@@ -2095,21 +2080,3 @@ function generateId(): string {
2095
2080
  // fallback determinístico-suficiente quando crypto não disponível
2096
2081
  return `id_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
2097
2082
  }
2098
-
2099
- /**
2100
- * Único ponto do core que lê `process.env`. Usado pelo `execute()` pra
2101
- * decidir entre `handler` real e `mockHandler`. Isolado em função pra
2102
- * testabilidade — testes podem `vi.stubEnv('OPUS_MODE', ...)`.
2103
- *
2104
- * Mantém a invariante "core nunca lê env" intacta no resto do código:
2105
- * adapters/config seguem recebendo valores literais.
2106
- *
2107
- * Exportado (fora do index público) pro harness de `testing` aplicar a MESMA
2108
- * régua no `runAction` — a semântica de design mora num lugar só.
2109
- */
2110
- export function isOpusDesignMode(): boolean {
2111
- // Acesso via globalThis: defensivo pra runtimes sem `process` (browser, edge) E
2112
- // não exige @types/node no consumidor que compila este source (apps web).
2113
- const g = globalThis as { process?: { env?: Record<string, string | undefined> } }
2114
- return g.process?.env?.['OPUS_MODE'] === 'design'
2115
- }
package/src/core/types.ts CHANGED
@@ -638,6 +638,9 @@ export interface FilterSpec {
638
638
 
639
639
  type: 'text' | 'select' | 'lookup' | 'date' | 'number'
640
640
  multiple?: boolean
641
+ /** Controle usado para um `select` de escolha única. `select` é o padrão; `toggle`
642
+ * mantém todas as opções curtas visíveis em um grupo conectado. */
643
+ presentation?: 'select' | 'toggle'
641
644
  /** Habilita busca local nas opções de um `select`; `lookup` já é pesquisável por natureza. */
642
645
  searchable?: boolean
643
646
  /** Quando `false`, a interface omite a opção vazia. Limpar restaura o default do filtro. */
@@ -799,7 +802,7 @@ export type LoadersSpec<In> = Record<string, LoaderFn<In>>
799
802
  * aqui.
800
803
  * - **`ParsedIn`** — o lado PARSEADO (o output do schema de input, defaults
801
804
  * aplicados). O runtime SEMPRE valida antes de executar, então `handler`,
802
- * `mockHandler`, `authorize` e `loads` recebem `ParsedIn`.
805
+ * `handler`, `authorize` e `loads` recebem `ParsedIn`.
803
806
  *
804
807
  * O default `ParsedIn = In` mantém retrocompat com anotações à mão de dois
805
808
  * genéricos; `defineAction`/`defineContract` inferem os dois lados do schema.
@@ -848,16 +851,6 @@ export interface ActionBase<In, Out, ParsedIn = In> {
848
851
  // — Execução —
849
852
  handler: HandlerFn<ParsedIn, Out>
850
853
 
851
- /**
852
- * Handler alternativo executado quando `process.env.OPUS_MODE === 'design'`.
853
- *
854
- * @deprecated Desde a 22.1.0 (ADR 0037); sai na próxima major. No modo design, a action sem
855
- * mock já responde com dado gerado a partir do `output`; quando o design precisar de uma resposta
856
- * específica, declare um cenário fora do contrato com `defineDesignScenario`, de
857
- * `@softize/opus/design`, e aponte a opção `scenarios` do `opusDesign()` para ele.
858
- */
859
- mockHandler?: HandlerFn<ParsedIn, Out>
860
-
861
854
  // — Comportamento —
862
855
  audit?: boolean | AuditConfig
863
856
  confirm?: ConfirmSpec
@@ -922,24 +915,13 @@ export interface SearchFilters {
922
915
  * envelope — handler é fonte de verdade.
923
916
  */
924
917
  export interface ListAction<In, Out, ParsedIn = In>
925
- extends Omit<ActionBase<In, Out, ParsedIn>, 'handler' | 'mockHandler'> {
918
+ extends Omit<ActionBase<In, Out, ParsedIn>, 'handler'> {
926
919
  kind: 'list'
927
920
  handler: (
928
921
  ctx: ActionContext,
929
922
  input: ParsedIn,
930
923
  loaded?: Record<string, unknown>,
931
924
  ) => Paginated<Out> | Promise<Paginated<Out>>
932
- /**
933
- * Mesma semântica de `ActionBase.mockHandler`, mas devolve `Paginated<Out>`
934
- * (search retorna lista paginada, não item solto).
935
- *
936
- * @deprecated Desde a 22.1.0 (ADR 0037); ver `ActionBase.mockHandler`.
937
- */
938
- mockHandler?: (
939
- ctx: ActionContext,
940
- input: ParsedIn,
941
- loaded?: Record<string, unknown>,
942
- ) => Paginated<Out> | Promise<Paginated<Out>>
943
925
  /** Colunas declarativas da listagem (a UI deriva a tabela; ver ListColumnSpec). */
944
926
  columns?: ListColumnSpec[]
945
927
  filters?: Record<string, FilterSpec>
@@ -9,7 +9,7 @@
9
9
  * respostas ao runtime de design.
10
10
  */
11
11
 
12
- import { z } from 'zod'
12
+ import type { ZodTypeAny } from 'zod'
13
13
  import type { ActionContext, InferOutput, Paginated } from '../core/types.ts'
14
14
  import { fake, fakeMany } from '../testing/fake.ts'
15
15
 
@@ -102,11 +102,14 @@ interface DesignActionShape {
102
102
  * - `list` devolve três páginas do tamanho pedido em `limit` ({@link DESIGN_PAGE_SIZE} quando a
103
103
  * entrada não pede) e respeita a paginação declarada; busca e filtros não filtram o dado gerado.
104
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`.
105
+ * - Saída que não é schema Zod não tem gerador: a resposta é `null`.
106
106
  */
107
107
  export function designResponse(action: DesignActionShape, input: unknown): unknown {
108
108
  const output = action.output
109
- if (!(output instanceof z.ZodType)) return null
109
+ // O contrato vem do grafo do consumidor e pode carregar outra instância física do Zod.
110
+ // `instanceof` confundiria identidade de módulo com contrato; o gerador usa somente a
111
+ // estrutura pública/estável que já recebe (`safeParse`) e a definição introspectável (`_def`).
112
+ if (!isZodSchema(output)) return null
110
113
  const seed = `${action.name}:${canonicalJson(withoutPagination(input))}`
111
114
 
112
115
  if (action.kind === 'list') {
@@ -177,3 +180,11 @@ function canonicalJson(value: unknown): string {
177
180
  function isRecord(value: unknown): value is Record<string, unknown> {
178
181
  return typeof value === 'object' && value !== null && !Array.isArray(value)
179
182
  }
183
+
184
+ function isZodSchema(value: unknown): value is ZodTypeAny {
185
+ return (
186
+ isRecord(value) &&
187
+ isRecord(value._def) &&
188
+ typeof value.safeParse === 'function'
189
+ )
190
+ }
@@ -14,8 +14,6 @@
14
14
  * valida output (pulado em `list`, como no runtime). Falha estoura o MESMO
15
15
  * `ActionError` tipado do runtime (`validation.invalid_input`, `auth.unauthenticated`,
16
16
  * `auth.system_forbidden`, `auth.forbidden`…). A opção `schedule` executa como o sistema.
17
- * Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
18
- * (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
19
17
  * • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
20
18
  * arquivo, com a mesma régua de key dos drivers reais.
21
19
  *
@@ -30,9 +28,7 @@ import { evalExpression } from '../dsl/eval.ts'
30
28
  import { parseExpression } from '../dsl/parser.ts'
31
29
  import { normalizeKey } from '../storage/index.ts'
32
30
  import { error } from '../core/errors.ts'
33
- // Import direto do runtime (fora do index público): a régua handler×mockHandler
34
- // tem UMA fonte — o harness nunca pode divergir do execute().
35
- import { assertAuthenticated, isOpusDesignMode, markSystemActor } from '../core/runtime.ts'
31
+ import { assertAuthenticated, markSystemActor } from '../core/runtime.ts'
36
32
  import type {
37
33
  ActionContext,
38
34
  ActionKind,
@@ -206,14 +202,8 @@ export interface TestableAction<In, Out, ParsedIn = In> {
206
202
  authorize?: AuthorizeSpec<ParsedIn>
207
203
  input: Schema<In, ParsedIn>
208
204
  output: Schema<Out> | Schema<unknown, Out>
209
- /** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
205
+ /** Opcional para produzir um erro útil quando um contrato puro chega ao harness. */
210
206
  handler?: HandlerFn<ParsedIn, Out>
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
- */
216
- mockHandler?: HandlerFn<ParsedIn, Out>
217
207
  }
218
208
 
219
209
  /** Variante list: o schema descreve o ITEM, o handler devolve `Paginated<Item>`. */
@@ -230,11 +220,6 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
230
220
  input: ParsedIn,
231
221
  loaded?: Record<string, unknown>,
232
222
  ) => Paginated<Item> | Promise<Paginated<Item>>
233
- mockHandler?: (
234
- ctx: ActionContext,
235
- input: ParsedIn,
236
- loaded?: Record<string, unknown>,
237
- ) => Paginated<Item> | Promise<Paginated<Item>>
238
223
  }
239
224
 
240
225
  export interface RunActionOptions extends TestContextOptions {
@@ -278,9 +263,8 @@ async function validate<T>(
278
263
  * validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
279
264
  * `ActionError` do runtime — afirme com `expect(...).rejects`.
280
265
  *
281
- * A escolha do executável segue a régua do `execute()`: em `OPUS_MODE=design`,
282
- * `mockHandler` presente vence; senão, `handler` real. Contrato puro (sem
283
- * handler) só roda em design — fora dele, `testing.no_handler`.
266
+ * Contrato puro sem handler termina em `testing.no_handler`: dados de design
267
+ * pertencem ao plugin `opusDesign()` e não são comportamento da action.
284
268
  */
285
269
  export async function runAction<In, Item, ParsedIn = In>(
286
270
  action: TestableListAction<In, Item, ParsedIn>,
@@ -325,18 +309,14 @@ export async function runAction<In, Out, ParsedIn = In>(
325
309
  if (decision !== true) throw decision
326
310
  }
327
311
 
328
- const handlerToRun =
329
- isOpusDesignMode() && action.mockHandler !== undefined
330
- ? action.mockHandler
331
- : action.handler
332
- if (handlerToRun === undefined) {
312
+ if (action.handler === undefined) {
333
313
  throw error({
334
314
  code: 'testing.no_handler',
335
315
  category: 'internal',
336
316
  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.`,
337
317
  })
338
318
  }
339
- const raw = await handlerToRun(ctx, parsed, loaded)
319
+ const raw = await action.handler(ctx, parsed, loaded)
340
320
 
341
321
  // Mesmo skip do runtime: list devolve Paginated<Item>, o schema descreve Item.
342
322
  const output = action.kind === 'list' ? (raw as Out) : await validate<Out>(action.output, raw, 'output')
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @softize/opus/ui/appearance — personalização de aparência sem React (ADR 0044).
3
+ *
4
+ * O tema deriva a escala neutra de quatro eixos inteiros (0 a 100) declarados por modo em
5
+ * `theme.css`: `--appearance-light-*` e `--appearance-dark-*`. Este módulo publica os tipos, os
6
+ * presets oficiais e o helper que converte overrides parciais nas custom properties que o
7
+ * consumidor aplica onde quiser (em geral, inline em `<html>`).
8
+ *
9
+ * Reset não pertence ao Opus UI: um eixo sem override vira `null`, e o consumidor remove a
10
+ * propriedade para voltar ao cascade — ou seja, ao preset do tema ou ao CSS do próprio app.
11
+ * Persistência e interface de ajuste também ficam com o consumidor.
12
+ */
13
+
14
+ export type AppearanceMode = "light" | "dark";
15
+
16
+ export const appearanceModes = [
17
+ "light",
18
+ "dark",
19
+ ] as const satisfies readonly AppearanceMode[];
20
+
21
+ export type AppearanceAxis =
22
+ "colorIntensity" | "contrast" | "surfaceBrightness" | "layerContrast";
23
+
24
+ /** Ordem canônica dos eixos: a mesma usada em documentação, presets e custom properties. */
25
+ export const appearanceAxes = [
26
+ "colorIntensity",
27
+ "contrast",
28
+ "surfaceBrightness",
29
+ "layerContrast",
30
+ ] as const satisfies readonly AppearanceAxis[];
31
+
32
+ /** Valor completo de um modo: todo eixo é um inteiro entre `APPEARANCE_MIN` e `APPEARANCE_MAX`. */
33
+ export type AppearanceSettings = Readonly<Record<AppearanceAxis, number>>;
34
+
35
+ /** Override parcial: eixo ausente (ou não finito) segue o cascade em vez de ser reescrito. */
36
+ export type AppearanceOverrides = Partial<
37
+ Record<AppearanceAxis, number | null | undefined>
38
+ >;
39
+
40
+ export const APPEARANCE_MIN = 0;
41
+ export const APPEARANCE_MAX = 100;
42
+
43
+ /**
44
+ * Presets oficiais por modo — reproduzem exatamente os valores do tema sem personalização.
45
+ * Intensidade, contraste e contraste entre camadas partem de zero: o preset é o piso, e o ajuste
46
+ * só acrescenta cor ou separação. No claro, a superfície elevada já é branca; o brilho parte do
47
+ * máximo e só pode diminuir. No escuro, o brilho fica no meio para permitir um escuro mais
48
+ * profundo ou mais suave.
49
+ */
50
+ export const appearancePresets: Readonly<
51
+ Record<AppearanceMode, AppearanceSettings>
52
+ > = {
53
+ light: {
54
+ colorIntensity: 0,
55
+ contrast: 0,
56
+ surfaceBrightness: 100,
57
+ layerContrast: 0,
58
+ },
59
+ dark: {
60
+ colorIntensity: 0,
61
+ contrast: 0,
62
+ surfaceBrightness: 50,
63
+ layerContrast: 0,
64
+ },
65
+ };
66
+
67
+ const AXIS_PROPERTY: Record<AppearanceAxis, string> = {
68
+ colorIntensity: "color-intensity",
69
+ contrast: "contrast",
70
+ surfaceBrightness: "surface-brightness",
71
+ layerContrast: "layer-contrast",
72
+ };
73
+
74
+ export type AppearanceCustomProperty =
75
+ `--appearance-${AppearanceMode}-${string}`;
76
+
77
+ /** Nome da custom property que `theme.css` lê para um eixo de um modo. */
78
+ export function appearanceCustomProperty(
79
+ mode: AppearanceMode,
80
+ axis: AppearanceAxis,
81
+ ): AppearanceCustomProperty {
82
+ return `--appearance-${mode}-${AXIS_PROPERTY[axis]}`;
83
+ }
84
+
85
+ /**
86
+ * Normaliza um valor de eixo: arredonda para inteiro e limita ao intervalo. Retorna `undefined`
87
+ * para o que não é um número finito — o consumidor pode ter lido um valor corrompido de
88
+ * persistência, e a resposta correta é seguir o cascade, não travar a interface.
89
+ */
90
+ export function normalizeAppearanceValue(value: unknown): number | undefined {
91
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
92
+ return Math.min(APPEARANCE_MAX, Math.max(APPEARANCE_MIN, Math.round(value)));
93
+ }
94
+
95
+ /** Preset do modo com os overrides válidos aplicados por cima. */
96
+ export function resolveAppearance(
97
+ mode: AppearanceMode,
98
+ overrides: AppearanceOverrides = {},
99
+ ): AppearanceSettings {
100
+ const preset = appearancePresets[mode];
101
+ const resolved = { ...preset } as Record<AppearanceAxis, number>;
102
+ for (const axis of appearanceAxes) {
103
+ const value = normalizeAppearanceValue(overrides[axis]);
104
+ if (value !== undefined) resolved[axis] = value;
105
+ }
106
+ return resolved;
107
+ }
108
+
109
+ /**
110
+ * Converte overrides parciais nas custom properties do modo. Todo eixo aparece no resultado:
111
+ * com valor normalizado quando há override e `null` quando não há, para o consumidor remover a
112
+ * propriedade e deixar o cascade valer. Assim, o mesmo mapa serve para aplicar e para desfazer.
113
+ */
114
+ export function appearanceCustomProperties(
115
+ mode: AppearanceMode,
116
+ overrides: AppearanceOverrides = {},
117
+ ): Record<AppearanceCustomProperty, string | null> {
118
+ const properties = {} as Record<AppearanceCustomProperty, string | null>;
119
+ for (const axis of appearanceAxes) {
120
+ const value = normalizeAppearanceValue(overrides[axis]);
121
+ properties[appearanceCustomProperty(mode, axis)] =
122
+ value === undefined ? null : String(value);
123
+ }
124
+ return properties;
125
+ }
126
+
127
+ /** Alvo mínimo para aplicar propriedades: `CSSStyleDeclaration` satisfaz, e um stub de teste também. */
128
+ export interface AppearanceStyleTarget {
129
+ setProperty(name: string, value: string): void;
130
+ removeProperty(name: string): string | void;
131
+ }
132
+
133
+ /**
134
+ * Aplica os overrides de um modo num alvo de estilo. Eixos sem override são removidos, então
135
+ * chamar sem overrides devolve o elemento ao cascade — é o reset, feito pelo consumidor, sem
136
+ * estado no Opus.
137
+ */
138
+ export function applyAppearanceCustomProperties(
139
+ target: AppearanceStyleTarget,
140
+ mode: AppearanceMode,
141
+ overrides: AppearanceOverrides = {},
142
+ ): void {
143
+ for (const [name, value] of Object.entries(
144
+ appearanceCustomProperties(mode, overrides),
145
+ )) {
146
+ if (value === null) target.removeProperty(name);
147
+ else target.setProperty(name, value);
148
+ }
149
+ }