@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,125 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 24.0.0 — 2026-10-01
11
+
12
+ `ToggleGroup` passa a compartilhar a gramática estrutural de `ButtonGroup`: `mode` aceita
13
+ `connected`, `spaced`, `divided` e o novo `segmented`; `distribution` aceita `content` e `equal`;
14
+ `orientation`, `shape` e `framed` permanecem eixos independentes. A aparência segmentada mantém
15
+ escolhas curtas numa superfície compartilhada e destaca o controle ativo dentro dela. Filtros
16
+ `select` com `presentation: "toggle"` passam a usar esse modo; estado, URL, defaults e input da
17
+ action não mudam.
18
+
19
+ O tema passa a distinguir canvas, superfícies elevadas, controles e estados neutros com tokens
20
+ opacos por papel. No tema claro, o canvas quase branco sustenta cards e popovers brancos; inputs,
21
+ hover, seleção e pressão deixam de depender de alpha sobre a superfície ancestral. Os componentes
22
+ usam os mesmos papéis nos dois temas, enquanto cada tema calibra somente os valores.
23
+
24
+ A escala neutra passa a ser derivada de quatro eixos de aparência por modo — intensidade de cor,
25
+ contraste, brilho das superfícies e contraste entre camadas —, cada um de 0 a 100, lidos de
26
+ `--appearance-light-*` e `--appearance-dark-*` (ADR 0044). Com os eixos no preset, o tema é
27
+ exatamente o calibrado; quem não personaliza não precisa mudar nada. Os limites impedem que um
28
+ ajuste reduza a separação entre os papéis ou o contraste do texto sobre as superfícies em repouso
29
+ abaixo do preset, e a intensidade tinge somente os neutros, no matiz de `--neutral-hue`. O novo
30
+ módulo sem React `@softize/opus/ui/appearance`, também reexportado por `@softize/opus/ui` e
31
+ `@softize/opus/ui/react`, publica tipos, presets e o helper que converte overrides parciais em
32
+ custom properties. Reset é a remoção dos overrides pela aplicação; o Opus não publica componente
33
+ de ajuste nem guarda a preferência. Um app que redeclara diretamente um token neutro continua
34
+ funcionando, mas esse token deixa de acompanhar os eixos.
35
+
36
+ No preset, seleção persistente também se afasta mais do hover transitório nos dois temas. Navegação
37
+ ativa permanece reconhecível mesmo sem depender de texto em negrito, enquanto pressão conserva um
38
+ degrau próprio além da seleção.
39
+
40
+ `ToggleGroup mode="segmented"` usa `bg-card` no item ativo, e `Tabs framed` aproxima a linha dos
41
+ triggers com inset próprio. `PageHeader`, `DialogHeader` e `DrawerHeader` aceitam navegação e ações
42
+ sem título; `SurfaceHeader` aceita ações sem título. O título pode permanecer no conteúdo de modais
43
+ por meio das novas `DialogIntro` e `DrawerIntro`. `DetailGroup` ganha `elevation` e passa a usar
44
+ `raised` por padrão, inclusive em `plain`; declare `elevation="none"` quando o agrupamento tiver de
45
+ permanecer no mesmo plano do pai.
46
+
47
+ ### Breaking
48
+
49
+ `ToggleGroupItem` foi removido. Use `Toggle` como filho de `ToggleGroup` e informe `value`; fora do
50
+ grupo, `Toggle` continua usando `pressed`, `defaultPressed` e `onPressedChange`. `spacing` também foi
51
+ removido: use `mode="connected"` no lugar de zero e `mode="spaced"` no lugar de valores positivos.
52
+ Não há aliases de compatibilidade porque o único consumidor é migrado atomicamente.
53
+
54
+ `Content*` e `Card*` foram removidos da API pública. Use `Surface` e seus slots
55
+ `SurfaceHeader`, `SurfaceMedia`, `SurfaceTitle`, `SurfaceDescription`, `SurfaceMeta`,
56
+ `SurfaceActions`, `SurfaceBody` e `SurfaceFooter`. A raiz agora é `div` por padrão; declare
57
+ `as="section"`, `as="article"` ou `as="fieldset"` quando o documento exigir essa semântica.
58
+ `framed` acrescenta borda, `elevation="raised"` acrescenta o fundo elevado e
59
+ `layout="sectioned"` cria as faixas com divisores. `frameVariant` não existe mais.
60
+
61
+ `SurfaceMedia` reserva posição e não cria `IconTile`: componha o tile explicitamente quando o
62
+ ícone precisar de moldura. O recuo estrutural de superfícies limitadas passa de `1.25rem` para
63
+ `1rem`, títulos internos usam `text-sm` e conteúdos que já controlam o próprio recuo declaram
64
+ `flush` em `SurfaceBody`. Essas mudanças são intencionais e não têm aliases de compatibilidade.
65
+
66
+ `DialogMedia` e `DrawerMedia` seguem o mesmo contrato sem decoração de
67
+ `SurfaceMedia`. As propriedades `size`, `context`, `variant` e `asChild` foram removidas desses
68
+ slots: passe o glifo, a imagem ou a marca diretamente; quando precisar de moldura, componha
69
+ `IconTile` com essas propriedades. `Dialog mode="alert"` também deixa de aumentar ou emoldurar a
70
+ mídia automaticamente.
71
+
72
+ Bodies estruturais passam a governar o ritmo entre seus filhos diretos com intervalo de `1.5rem`.
73
+ O layout do pai decide o inset; no seccionado, Surface, Dialog e Drawer usam `1rem` lateral e
74
+ `1.5rem` vertical. `DialogBody` e `DrawerBody` ganham `flush`, como `SurfaceBody`.
75
+ Migração: remova `space-*`, `gap-*` e padding de `className` no body; use um wrapper para relações
76
+ internas e `flush` somente quando o filho controlar o próprio inset. `opus check` cobra a regra.
77
+
78
+ `Pane` passa a ser somente a unidade de dimensionamento de `Split`. As propriedades `inset` e
79
+ `layout`, além de `PaneHeader`, `PaneBody`, `PaneContent`, `PaneFooter`, `PaneMedia`, `PaneTitle`,
80
+ `PaneDescription` e `PaneActions`, foram removidas sem aliases. Migração: componha uma `Surface`
81
+ dentro do pane quando precisar de anatomia visual; para navegação ou conteúdo especializado, deixe
82
+ o próprio filho declarar organização e rolagem.
83
+
84
+ ### Novidades
85
+
86
+ `DialogIntro` e `DrawerIntro` formalizam o título do conteúdo quando o header do modal é reservado
87
+ a navegação, ações globais e fechamento. Cada intro exige o título da família e aceita
88
+ `DialogIntroActions` ou `DrawerIntroActions`; no formato compacto, título e ações continuam no
89
+ header. O body permanece dono do ritmo e da rolagem (ADR 0048).
90
+
91
+ ## 23.0.0 — 2026-09-29
92
+
93
+ Esta major conclui a separação entre contrato e dados de design, simplifica a hierarquia de
94
+ `Content` e dá a Tabs uma anatomia pública única para navegação. A seção **Breaking** reúne as
95
+ migrações obrigatórias.
96
+
97
+ ### Breaking
98
+
99
+ `mockHandler` foi removido de `ActionBase`, contratos, actions de lista, runtime e `runAction`,
100
+ concluindo a ADR 0037. O core não lê mais `OPUS_MODE`: o plugin `opusDesign()` liga cenários ou
101
+ respostas geradas como handlers antes de registrar as actions no runtime. Migração: remova cada
102
+ `mockHandler`; quando a resposta específica importar para o design, transforme-a em cenário com
103
+ `defineDesignScenario`. Testes devem passar a action produzida por `bindAction` ao `runAction`.
104
+
105
+ Filtros `select` de escolha única aceitam `presentation: "toggle"` para manter opções curtas e
106
+ mutuamente exclusivas visíveis em um grupo conectado. A apresentação padrão continua sendo
107
+ `Select`; estado, URL, defaults e input da action não mudam. Opções vindas de dicionário também
108
+ apresentam o ícone declarado na entrada, sempre acompanhado pelo rótulo.
109
+
110
+ `TabsList` passa a usar somente a linha de navegação e deixa de aceitar `variant`. Migração: remova
111
+ `variant="line"`; usos da antiga superfície preenchida devem representar escolha de valor com
112
+ `ToggleGroup`, usando `variant="outline"` quando precisarem de borda.
113
+
114
+ A anatomia pública de Tabs passa a nomear estrutura, não o mecanismo da primitiva:
115
+ `TabsTrigger` vira `TabsListItem` e `TabsContent` vira `TabsPanel`. A mudança não mantém aliases;
116
+ consumidores devem atualizar imports e JSX no mesmo upgrade.
117
+
118
+ `Content` deixa de aceitar `variant="page" | "section"`. `level={1}` identifica o conteúdo
119
+ principal, aplica automaticamente a escala de `PageHeader` e usa o mesmo intervalo de `1.5rem`
120
+ entre header e body; níveis de 2 a 6 preservam a apresentação compacta de seção. Migração: remova
121
+ `variant="page"` e mantenha `level={1}`. Não existe substituição para `variant="section"`, que era
122
+ o comportamento dos níveis internos.
123
+
124
+ ### Filtros e controles
125
+
126
+ `Toggle` e `ToggleGroupItem` em `variant="outline"` preservam a borda no estado ativo e usam um
127
+ fundo sutil distinto dela, inclusive no tema escuro.
128
+
10
129
  ## 22.2.0 — 2026-09-29
11
130
 
12
131
  As actions passam a declarar o que fazem com os dados (ADR 0041). Dois campos novos no contrato,
@@ -381,7 +500,7 @@ página (ADR 0024). A `Presentation` de lista em `layout="fill"` passa a entrega
381
500
  lista ocupa a altura da página e só os resultados rolam.
382
501
 
383
502
  Quem encadeava `flex min-h-0 flex-1 flex-col` à mão pode remover as classes; mantidas, continuam
384
- funcionando. Com um contêiner próprio entre `ContentBody` e a lista, como um `TabsContent`, a lista
503
+ funcionando. Com um contêiner próprio entre `ContentBody` e a lista, como um `TabsPanel`, a lista
385
504
  continua em `flow`: declare `layout="fill"` nela e dê ao contêiner `flex min-h-0 flex-1 flex-col`.
386
505
 
387
506
  ### Superfícies e patterns
package/bin/cli.mjs CHANGED
@@ -40,16 +40,8 @@ async function cmdCheck(dir) {
40
40
  for (const error of materialization.errors) log('error', `✗ opus check: ${error}`)
41
41
  process.exit(1)
42
42
  }
43
- const { files, actions, contracts, deprecatedMockHandlers, missingEffect, missingPersonalData, findings, hasMarker } =
43
+ const { files, actions, contracts, missingEffect, missingPersonalData, findings, hasMarker } =
44
44
  await scanDir(root)
45
- // Aviso de migração, não violação: uma linha só, para não poluir o gate a cada execução.
46
- if (deprecatedMockHandlers > 0) {
47
- log(
48
- 'warn',
49
- `⚠ opus check: ${deprecatedMockHandlers} action(s) ainda declaram \`mockHandler\`, que sai na próxima major. ` +
50
- 'No modo design, a action sem mock já responde com dado gerado; use `defineDesignScenario` quando o design precisar de um cenário (ADR 0037).',
51
- )
52
- }
53
45
 
54
46
  if (missingEffect > 0 || missingPersonalData > 0) {
55
47
  log(
@@ -441,6 +433,8 @@ Regras da UI (@softize/opus/ui):
441
433
  ui-structure-split — filho direto de Split que não é Pane (elemento HTML, texto,
442
434
  outro componente Opus ou componente do próprio arquivo); o
443
435
  Split lança erro ao renderizá-lo
436
+ ui-structure-body-rhythm — padding, gap ou space aplicado diretamente a um Body;
437
+ use o ritmo nativo, flush ou um wrapper filho
444
438
  ui-semantic-api — API semântica: \`context\` + \`variant\`; reprova aliases de
445
439
  migração e \`tone\` em novos dicionários
446
440
  removed-ui-token — token público removido (ring-edge, shadow-card…) com a
package/bin/lib/check.mjs CHANGED
@@ -58,7 +58,6 @@ const FIELD_GROUP = {
58
58
  authorize: 3,
59
59
  loads: 3,
60
60
  handler: 4,
61
- mockHandler: 4,
62
61
  audit: 5,
63
62
  confirm: 5,
64
63
  invalidates: 5,
@@ -129,40 +128,34 @@ const UI_STRUCTURAL_PARENTS = new Map([
129
128
  ["PageBack", new Set(["PageHeader"])],
130
129
  ["PageNavigation", new Set(["PageHeader"])],
131
130
  ["PageActions", new Set(["PageHeader"])],
132
- ["ContentHeader", new Set(["Content"])],
133
- ["ContentBody", new Set(["Content"])],
134
- ["ContentTitle", new Set(["ContentHeader"])],
135
- ["ContentDescription", new Set(["ContentHeader"])],
136
- ["ContentMeta", new Set(["ContentHeader"])],
137
- ["ContentActions", new Set(["ContentHeader"])],
138
- ["CardHeader", new Set(["Card"])],
139
- ["CardBody", new Set(["Card"])],
140
- ["CardFooter", new Set(["Card"])],
141
- ["CardMedia", new Set(["CardHeader"])],
142
- ["CardTitle", new Set(["CardHeader"])],
143
- ["CardDescription", new Set(["CardHeader"])],
144
- ["CardAction", new Set(["CardHeader"])],
131
+ // As regiões de Surface não são estritas: com `asChild`, elas ficam dentro do elemento que
132
+ // recebe a superfície (ADR 0045). Os slots do header são filhos diretos dele.
133
+ ["SurfaceHeader", new Set(["Surface"])],
134
+ ["SurfaceBody", new Set(["Surface"])],
135
+ ["SurfaceFooter", new Set(["Surface"])],
136
+ ["SurfaceMedia", new Set(["SurfaceHeader"])],
137
+ ["SurfaceTitle", new Set(["SurfaceHeader"])],
138
+ ["SurfaceDescription", new Set(["SurfaceHeader"])],
139
+ ["SurfaceMeta", new Set(["SurfaceHeader"])],
140
+ ["SurfaceActions", new Set(["SurfaceHeader"])],
145
141
  ["DialogHeader", new Set(["DialogContent"])],
146
142
  ["DialogBody", new Set(["DialogContent"])],
147
143
  ["DialogFooter", new Set(["DialogContent"])],
144
+ ["DialogIntro", new Set(["DialogBody"])],
145
+ ["DialogIntroActions", new Set(["DialogIntro"])],
148
146
  ["DialogNavigation", new Set(["DialogHeader"])],
149
147
  ["DialogMedia", new Set(["DialogHeader"])],
150
- ["DialogTitle", new Set(["DialogHeader"])],
148
+ ["DialogTitle", new Set(["DialogHeader", "DialogIntro"])],
151
149
  ["DialogActions", new Set(["DialogHeader"])],
152
150
  ["DrawerHeader", new Set(["DrawerContent"])],
153
151
  ["DrawerBody", new Set(["DrawerContent"])],
154
152
  ["DrawerFooter", new Set(["DrawerContent"])],
153
+ ["DrawerIntro", new Set(["DrawerBody"])],
154
+ ["DrawerIntroActions", new Set(["DrawerIntro"])],
155
155
  ["DrawerNavigation", new Set(["DrawerHeader"])],
156
156
  ["DrawerMedia", new Set(["DrawerHeader"])],
157
- ["DrawerTitle", new Set(["DrawerHeader"])],
157
+ ["DrawerTitle", new Set(["DrawerHeader", "DrawerIntro"])],
158
158
  ["DrawerActions", new Set(["DrawerHeader"])],
159
- ["PaneHeader", new Set(["Pane", "Sidebar"])],
160
- ["PaneBody", new Set(["Pane", "Sidebar"])],
161
- ["PaneFooter", new Set(["Pane", "Sidebar"])],
162
- ["PaneMedia", new Set(["PaneHeader"])],
163
- ["PaneTitle", new Set(["PaneHeader"])],
164
- ["PaneDescription", new Set(["PaneHeader"])],
165
- ["PaneActions", new Set(["PaneHeader"])],
166
159
  ["AlertMedia", new Set(["Alert"])],
167
160
  ["AlertHeader", new Set(["Alert"])],
168
161
  ["AlertActions", new Set(["Alert"])],
@@ -206,12 +199,15 @@ const UI_STRUCTURAL_ROOTS = new Map([
206
199
  },
207
200
  ],
208
201
  [
209
- "Content",
202
+ "Surface",
210
203
  {
211
- header: "ContentHeader",
212
- body: "ContentBody",
213
- shorthand: new Set(["title", "description", "count", "actions"]),
214
- removed: new Map([["meta", "use `count`"]]),
204
+ header: "SurfaceHeader",
205
+ body: "SurfaceBody",
206
+ footer: "SurfaceFooter",
207
+ // Cada região é opcional, a forma curta convive com SurfaceBody e SurfaceFooter, e sem
208
+ // região nenhuma a Surface é uma caixa de filhos livres (ADR 0045).
209
+ flexible: true,
210
+ shorthand: new Set(["title", "media", "description", "count", "actions"]),
215
211
  },
216
212
  ],
217
213
  ]);
@@ -224,18 +220,61 @@ const UI_STRICT_DIRECT_COMPONENTS = new Set([
224
220
  "PageBack",
225
221
  "PageNavigation",
226
222
  "PageActions",
227
- "ContentHeader",
228
- "ContentBody",
229
- "ContentTitle",
230
- "ContentDescription",
231
- "ContentMeta",
232
- "ContentActions",
223
+ "SurfaceMedia",
224
+ "SurfaceTitle",
225
+ "SurfaceDescription",
226
+ "SurfaceMeta",
227
+ "SurfaceActions",
233
228
  "AlertMedia",
234
229
  "AlertHeader",
235
230
  "AlertActions",
236
231
  ]);
237
232
 
233
+ // O body público é a única fonte de inset e ritmo estrutural (ADR 0046). Classes de
234
+ // ocupação, overflow, alinhamento e tipografia continuam livres; relações locais usam
235
+ // um wrapper filho e full bleed usa a prop `flush` das famílias que a oferecem.
236
+ const UI_STRUCTURAL_BODIES = new Set([
237
+ "SurfaceBody",
238
+ "DialogBody",
239
+ "DrawerBody",
240
+ "PageBody",
241
+ ]);
242
+
243
+ const UI_BODY_RHYTHM_CLASS =
244
+ /(?:^|:)(?:!?-?)(?:space-[xy]|gap(?:-[xy])?|p|px|py|pt|pr|pb|pl|ps|pe)-/;
245
+
246
+ const UI_FLUSH_BODIES = new Set(["SurfaceBody", "DialogBody", "DrawerBody"]);
247
+
238
248
  const UI_REMOVED_PROPS = new Map([
249
+ [
250
+ "Pane",
251
+ new Map([
252
+ ["inset", "mova o recuo para o conteúdo ou para uma `Surface` filha"],
253
+ ["layout", "use `Surface` para anatomia visual contínua ou seccionada"],
254
+ ]),
255
+ ],
256
+ [
257
+ "Surface",
258
+ new Map([["meta", "use `count` para o total ao lado do título"]]),
259
+ ],
260
+ [
261
+ "DialogMedia",
262
+ new Map([
263
+ ["size", "dimensione o nó filho"],
264
+ ["context", "declare o contexto no `IconTile` filho"],
265
+ ["variant", "declare a variante no `IconTile` filho"],
266
+ ["asChild", "passe o nó diretamente como filho"],
267
+ ]),
268
+ ],
269
+ [
270
+ "DrawerMedia",
271
+ new Map([
272
+ ["size", "dimensione o nó filho"],
273
+ ["context", "declare o contexto no `IconTile` filho"],
274
+ ["variant", "declare a variante no `IconTile` filho"],
275
+ ["asChild", "passe o nó diretamente como filho"],
276
+ ]),
277
+ ],
239
278
  [
240
279
  "PageHeader",
241
280
  new Map([
@@ -263,6 +302,14 @@ const UI_REMOVED_PROPS = new Map([
263
302
  ]);
264
303
 
265
304
  const UI_REMOVED_COMPONENTS = new Map([
305
+ ["PaneHeader", "use `SurfaceHeader` dentro de uma `Surface`"],
306
+ ["PaneBody", "use `SurfaceBody` ou um contêiner próprio do conteúdo"],
307
+ ["PaneContent", "use `SurfaceBody` ou um contêiner próprio do conteúdo"],
308
+ ["PaneFooter", "use `SurfaceFooter` dentro de uma `Surface`"],
309
+ ["PaneMedia", "use `SurfaceMedia` dentro de `SurfaceHeader`"],
310
+ ["PaneTitle", "use `SurfaceTitle` dentro de `SurfaceHeader`"],
311
+ ["PaneDescription", "use `SurfaceDescription` dentro de `SurfaceHeader`"],
312
+ ["PaneActions", "use `SurfaceActions` dentro de `SurfaceHeader`"],
266
313
  ["PageIntro", "use `PageHeader` como a única introdução estrutural"],
267
314
  ["PageDescription", "mova contexto relevante para o início de `PageBody`"],
268
315
  [
@@ -273,11 +320,54 @@ const UI_REMOVED_COMPONENTS = new Map([
273
320
  "DrawerDescription",
274
321
  "mova contexto relevante para o início de `DrawerBody`",
275
322
  ],
323
+ // Content e Card deram lugar a Surface (ADR 0045).
324
+ [
325
+ "Content",
326
+ 'use `Surface as="section"`, ou `as="fieldset"` para um grupo de campos',
327
+ ],
328
+ ["ContentHeader", "use `SurfaceHeader`"],
329
+ [
330
+ "ContentMedia",
331
+ "use `SurfaceMedia` e componha `IconTile` quando quiser a moldura",
332
+ ],
333
+ ["ContentTitle", "use `SurfaceTitle`"],
334
+ ["ContentDescription", "use `SurfaceDescription`"],
335
+ ["ContentMeta", "use `SurfaceMeta`"],
336
+ ["ContentActions", "use `SurfaceActions`"],
337
+ ["ContentBody", "use `SurfaceBody`"],
338
+ ["Card", 'use `Surface framed elevation="raised"`'],
339
+ ["CardHeader", "use `SurfaceHeader`"],
340
+ [
341
+ "CardMedia",
342
+ "use `SurfaceMedia` e componha `IconTile` quando quiser a moldura",
343
+ ],
344
+ ["CardTitle", "use `SurfaceTitle` e declare `level` em `Surface`"],
345
+ ["CardDescription", "use `SurfaceDescription`"],
346
+ ["CardAction", "use `SurfaceActions`"],
347
+ ["CardBody", "use `SurfaceBody`"],
348
+ ["CardContent", "use `SurfaceBody`"],
349
+ ["CardFooter", "use `SurfaceFooter`"],
276
350
  ]);
277
351
 
278
352
  const UI_STRUCTURAL_CONTAINERS = new Set(["PageShell"]);
279
353
 
280
354
  const UI_STRUCTURAL_HEADERS = new Map([
355
+ [
356
+ "DialogIntro",
357
+ {
358
+ title: "DialogTitle",
359
+ optional: new Set(["DialogIntroActions"]),
360
+ shorthand: new Set(),
361
+ },
362
+ ],
363
+ [
364
+ "DrawerIntro",
365
+ {
366
+ title: "DrawerTitle",
367
+ optional: new Set(["DrawerIntroActions"]),
368
+ shorthand: new Set(),
369
+ },
370
+ ],
281
371
  [
282
372
  "PageHeader",
283
373
  {
@@ -289,15 +379,19 @@ const UI_STRUCTURAL_HEADERS = new Map([
289
379
  },
290
380
  ],
291
381
  [
292
- "ContentHeader",
382
+ "SurfaceHeader",
293
383
  {
294
- title: "ContentTitle",
384
+ title: "SurfaceTitle",
295
385
  optional: new Set([
296
- "ContentDescription",
297
- "ContentMeta",
298
- "ContentActions",
386
+ "SurfaceMedia",
387
+ "SurfaceDescription",
388
+ "SurfaceMeta",
389
+ "SurfaceActions",
299
390
  ]),
300
- shorthand: new Set(["title", "description", "meta", "actions"]),
391
+ shorthand: new Set(),
392
+ // Slots condicionais (`{cond && <SurfaceActions />}`) são composição legítima; o runtime
393
+ // valida o que de fato foi renderizado.
394
+ allowOpaque: true,
301
395
  },
302
396
  ],
303
397
  [
@@ -651,8 +745,6 @@ export function checkProject(sources) {
651
745
  const productDeclarations = [];
652
746
  const entityNames = new Set();
653
747
  const actionNames = new Set();
654
- // `mockHandler` obsoleto (ADR 0037): contado, não reprovado — a minor ainda o aceita.
655
- let deprecatedMockHandlers = 0;
656
748
  // Natureza da action (ADR 0041): declarações ausentes são contadas, não reprovadas, na minor.
657
749
  let missingEffect = 0;
658
750
  let missingPersonalData = 0;
@@ -680,7 +772,6 @@ export function checkProject(sources) {
680
772
  if (item.name !== null) actionNames.add(item.name);
681
773
  }
682
774
  for (const f of lintAction(item)) findings.push({ ...f, file });
683
- if (item.keys.includes("mockHandler")) deprecatedMockHandlers += 1;
684
775
  if (item.form !== "bindAction") {
685
776
  const writesByGet = item.kind === "list" || item.kind === "view";
686
777
  if (!writesByGet && !item.keys.includes("effect")) missingEffect += 1;
@@ -791,7 +882,6 @@ export function checkProject(sources) {
791
882
  actions,
792
883
  contracts,
793
884
  dataProducts: productDeclarations.length,
794
- deprecatedMockHandlers,
795
885
  missingEffect,
796
886
  missingPersonalData,
797
887
  findings,
@@ -1090,6 +1180,57 @@ function directStructuralChildren(node, imports) {
1090
1180
  return { names, hasOpaque };
1091
1181
  }
1092
1182
 
1183
+ function countImportedJsxDescendants(node, imports, component) {
1184
+ const boundary =
1185
+ component === "DialogTitle"
1186
+ ? "DialogContent"
1187
+ : component === "DrawerTitle"
1188
+ ? "DrawerContent"
1189
+ : undefined;
1190
+ const count = (child, root = false) => {
1191
+ if (
1192
+ !root &&
1193
+ (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child))
1194
+ ) {
1195
+ const local = jsxLocalName(child);
1196
+ const imported = local === null ? undefined : imports.get(local);
1197
+ if (imported === boundary) return 0;
1198
+ if (imported === component) return 1;
1199
+ }
1200
+ if (ts.isConditionalExpression(child)) {
1201
+ return (
1202
+ count(child.condition) +
1203
+ Math.max(count(child.whenTrue), count(child.whenFalse))
1204
+ );
1205
+ }
1206
+ let total = 0;
1207
+ ts.forEachChild(child, (descendant) => {
1208
+ total += count(descendant);
1209
+ });
1210
+ return total;
1211
+ };
1212
+ return count(node, true);
1213
+ }
1214
+
1215
+ /**
1216
+ * Diz se o JSX literal prova um filho livre ao lado das regiões: texto, elemento HTML ou outro
1217
+ * componente do Opus. Expressões e componentes de outros módulos ficam de fora, porque só o
1218
+ * runtime sabe o que eles renderizam.
1219
+ */
1220
+ function hasLiteralFreeChild(node, imports, regionNames) {
1221
+ if (!ts.isJsxElement(node)) return false;
1222
+ return node.children.some((child) => {
1223
+ if (ts.isJsxText(child)) return child.text.trim() !== "";
1224
+ if (!ts.isJsxElement(child) && !ts.isJsxSelfClosingElement(child))
1225
+ return false;
1226
+ const local = jsxLocalName(child);
1227
+ if (local === null) return false;
1228
+ const imported = imports.get(local);
1229
+ if (imported !== undefined) return !regionNames.includes(imported);
1230
+ return /^[a-z]/.test(local);
1231
+ });
1232
+ }
1233
+
1093
1234
  /**
1094
1235
  * Componentes declarados no próprio arquivo (função, classe ou arrow/function atribuída).
1095
1236
  * Um deles nunca é o `Pane` do Opus, então o `Split` recusaria o elemento mesmo que o
@@ -1275,7 +1416,7 @@ function countPageHeadingSources(page, imports) {
1275
1416
  const local = jsxLocalName(node);
1276
1417
  if (
1277
1418
  local !== null &&
1278
- ((imports.get(local) === "Content" &&
1419
+ ((imports.get(local) === "Surface" &&
1279
1420
  jsxNumericAttribute(node, "level") === 1) ||
1280
1421
  imports.get(local) === "PageState")
1281
1422
  ) {
@@ -1415,6 +1556,36 @@ export function checkUiStructure(file, text) {
1415
1556
  }
1416
1557
  }
1417
1558
 
1559
+ if (component !== null && UI_STRUCTURAL_BODIES.has(component)) {
1560
+ const overridden = jsxStringAttributeValues(
1561
+ jsxOpening(node),
1562
+ "className",
1563
+ )
1564
+ .flatMap((value) => value.trim().split(/\s+/))
1565
+ .filter((token) => UI_BODY_RHYTHM_CLASS.test(token));
1566
+ if (overridden.length > 0) {
1567
+ add(
1568
+ node,
1569
+ component,
1570
+ `${component} já governa inset e ritmo: remova ${[...new Set(overridden)].map((token) => `\`${token}\``).join(", ")}; ${UI_FLUSH_BODIES.has(component) ? "use `flush` para full bleed ou " : "use "}um wrapper filho para relações internas.`,
1571
+ "ui-structure-body-rhythm",
1572
+ );
1573
+ }
1574
+ }
1575
+
1576
+ if (component === "DialogContent" || component === "DrawerContent") {
1577
+ const title =
1578
+ component === "DialogContent" ? "DialogTitle" : "DrawerTitle";
1579
+ if (countImportedJsxDescendants(node, imports, title) > 1) {
1580
+ add(
1581
+ node,
1582
+ component,
1583
+ `${component} aceita um único ${title}: mantenha o título no header ou na intro, nunca nos dois.`,
1584
+ "ui-structure-duplicate-title",
1585
+ );
1586
+ }
1587
+ }
1588
+
1418
1589
  const header =
1419
1590
  component === null ? undefined : UI_STRUCTURAL_HEADERS.get(component);
1420
1591
  if (header !== undefined) {
@@ -1519,7 +1690,30 @@ export function checkUiStructure(file, text) {
1519
1690
  // some aqui.
1520
1691
  // Com filhos estruturais, o que as regras de forma acham é defeito independente: misturar
1521
1692
  // shorthand com slots, ou um filho inesperado, não some quando a prop sai.
1522
- if (removedProp && !structural) {
1693
+ if (root.flexible) {
1694
+ const regionNames = [root.header, root.body, root.footer];
1695
+ const regions = children.names.filter((name) =>
1696
+ regionNames.includes(name),
1697
+ );
1698
+ if (shorthand && regions.includes(root.header)) {
1699
+ add(
1700
+ node,
1701
+ component,
1702
+ `${component} não permite misturar a forma curta com ${root.header} explícito.`,
1703
+ "ui-structure-mode",
1704
+ );
1705
+ } else if (
1706
+ regions.length > 0 &&
1707
+ (new Set(regions).size !== regions.length ||
1708
+ hasLiteralFreeChild(node, imports, regionNames))
1709
+ ) {
1710
+ add(
1711
+ node,
1712
+ component,
1713
+ `${component} com slots estruturais aceita no máximo um ${root.header}, um ${root.body} e um ${root.footer} como filhos diretos e não os mistura com filhos livres: envolva o conteúdo em ${root.body}.`,
1714
+ );
1715
+ }
1716
+ } else if (removedProp && !structural) {
1523
1717
  // Nada a acrescentar; os filhos seguem sendo visitados no fim de `visit`.
1524
1718
  } else if (shorthand && structural) {
1525
1719
  add(
@@ -1578,7 +1772,7 @@ export function checkUiStructure(file, text) {
1578
1772
  add(
1579
1773
  node,
1580
1774
  component,
1581
- "Page explícito exige exatamente um heading principal em PageHeader, Content level={1} ou PageState dentro de PageBody.",
1775
+ "Page explícito exige exatamente um heading principal em PageHeader, Surface level={1} ou PageState dentro de PageBody.",
1582
1776
  );
1583
1777
  }
1584
1778
  }
@@ -1944,7 +2138,7 @@ async function walkUiFiles(dir, acc = []) {
1944
2138
  return acc;
1945
2139
  }
1946
2140
 
1947
- /** Escaneia um diretório → { files, actions, contracts, deprecatedMockHandlers, findings, hasMarker }.
2141
+ /** Escaneia um diretório → { files, actions, contracts, findings, hasMarker }.
1948
2142
  * `hasMarker` = o diretório é um projeto opus (tem opus.json na raiz). */
1949
2143
  export async function scanDir(rootDir) {
1950
2144
  rootDir = canonicalProjectDirectory(rootDir);
@@ -1974,7 +2168,6 @@ export async function scanDir(rootDir) {
1974
2168
  actions: checked.actions,
1975
2169
  contracts: checked.contracts,
1976
2170
  dataProducts: checked.dataProducts,
1977
- deprecatedMockHandlers: checked.deprecatedMockHandlers,
1978
2171
  missingEffect: checked.missingEffect,
1979
2172
  missingPersonalData: checked.missingPersonalData,
1980
2173
  findings: [...checked.findings, ...uiFindings],