@softize/opus 23.0.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 (95) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +241 -42
  4. package/bin/lib/copy.mjs +16 -34
  5. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
  6. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +3 -3
  7. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +5 -6
  8. package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
  9. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
  10. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
  11. package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
  12. package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
  13. package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
  14. package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
  15. package/package.json +5 -1
  16. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  17. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  18. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  19. package/registry/templates/app/src/App.tsx +24 -12
  20. package/src/ui/appearance.ts +149 -0
  21. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  22. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  23. package/src/ui/components/patterns/confirm.tsx +3 -1
  24. package/src/ui/components/patterns/fill-region.ts +8 -7
  25. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  26. package/src/ui/components/patterns/form.tsx +8 -7
  27. package/src/ui/components/patterns/list.tsx +15 -19
  28. package/src/ui/components/patterns/page.tsx +15 -11
  29. package/src/ui/components/patterns/pane.tsx +9 -240
  30. package/src/ui/components/patterns/presentation.tsx +14 -14
  31. package/src/ui/components/patterns/sidebar.tsx +7 -17
  32. package/src/ui/components/patterns/split.tsx +111 -59
  33. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
  34. package/src/ui/components/patterns/structural-media.tsx +26 -0
  35. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  36. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  37. package/src/ui/components/patterns/surface.tsx +620 -0
  38. package/src/ui/components/patterns/trigger.tsx +11 -9
  39. package/src/ui/components/primitives/ask.tsx +155 -83
  40. package/src/ui/components/primitives/button.tsx +11 -9
  41. package/src/ui/components/primitives/checkbox.tsx +8 -8
  42. package/src/ui/components/primitives/command.tsx +1 -1
  43. package/src/ui/components/primitives/composer.tsx +47 -45
  44. package/src/ui/components/primitives/detail.tsx +9 -2
  45. package/src/ui/components/primitives/dialog.tsx +60 -26
  46. package/src/ui/components/primitives/drawer.tsx +58 -12
  47. package/src/ui/components/primitives/icon-picker.tsx +90 -73
  48. package/src/ui/components/primitives/input-group.tsx +35 -28
  49. package/src/ui/components/primitives/input-otp.tsx +16 -16
  50. package/src/ui/components/primitives/input.tsx +20 -10
  51. package/src/ui/components/primitives/item.tsx +9 -12
  52. package/src/ui/components/primitives/menu.tsx +44 -65
  53. package/src/ui/components/primitives/metric-card.tsx +75 -59
  54. package/src/ui/components/primitives/radio-group.tsx +9 -9
  55. package/src/ui/components/primitives/select.tsx +344 -209
  56. package/src/ui/components/primitives/switch.tsx +10 -10
  57. package/src/ui/components/primitives/table.tsx +29 -24
  58. package/src/ui/components/primitives/tabs.tsx +4 -2
  59. package/src/ui/components/primitives/textarea.tsx +6 -6
  60. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  61. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  62. package/src/ui/components/primitives/toggle.tsx +82 -16
  63. package/src/ui/docs/DocBrowser.tsx +77 -69
  64. package/src/ui/docs/content/action-form-card.md +9 -9
  65. package/src/ui/docs/content/action-list.md +76 -70
  66. package/src/ui/docs/content/action-view.md +33 -18
  67. package/src/ui/docs/content/button.md +1 -1
  68. package/src/ui/docs/content/communication.md +1 -1
  69. package/src/ui/docs/content/customization.md +64 -11
  70. package/src/ui/docs/content/detail.md +8 -6
  71. package/src/ui/docs/content/dialog.md +100 -58
  72. package/src/ui/docs/content/drawer.md +63 -19
  73. package/src/ui/docs/content/field.md +14 -15
  74. package/src/ui/docs/content/icon-tile.md +14 -13
  75. package/src/ui/docs/content/page.md +34 -26
  76. package/src/ui/docs/content/pane.md +41 -290
  77. package/src/ui/docs/content/presentation.md +3 -3
  78. package/src/ui/docs/content/separator.md +6 -6
  79. package/src/ui/docs/content/sidebar.md +68 -266
  80. package/src/ui/docs/content/skeleton.md +20 -18
  81. package/src/ui/docs/content/split.md +14 -14
  82. package/src/ui/docs/content/surface.md +513 -0
  83. package/src/ui/docs/content/tabs.md +20 -20
  84. package/src/ui/docs/content/toggle.md +117 -65
  85. package/src/ui/docs/content/tokens.md +124 -40
  86. package/src/ui/docs/registry.tsx +4 -6
  87. package/src/ui/index.ts +30 -27
  88. package/src/ui/meta.ts +15 -27
  89. package/src/ui/react.tsx +40 -43
  90. package/src/ui/theme.css +311 -64
  91. package/src/ui/components/patterns/content-header.tsx +0 -528
  92. package/src/ui/components/patterns/surface-media.tsx +0 -69
  93. package/src/ui/components/primitives/card.tsx +0 -191
  94. package/src/ui/docs/content/card.md +0 -292
  95. package/src/ui/docs/content/content.md +0 -275
package/CHANGELOG.md CHANGED
@@ -7,6 +7,87 @@ 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
+
10
91
  ## 23.0.0 — 2026-09-29
11
92
 
12
93
  Esta major conclui a separação entre contrato e dados de design, simplifica a hierarquia de
package/bin/cli.mjs CHANGED
@@ -433,6 +433,8 @@ Regras da UI (@softize/opus/ui):
433
433
  ui-structure-split — filho direto de Split que não é Pane (elemento HTML, texto,
434
434
  outro componente Opus ou componente do próprio arquivo); o
435
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
436
438
  ui-semantic-api — API semântica: \`context\` + \`variant\`; reprova aliases de
437
439
  migração e \`tone\` em novos dicionários
438
440
  removed-ui-token — token público removido (ring-edge, shadow-card…) com a
package/bin/lib/check.mjs CHANGED
@@ -128,40 +128,34 @@ const UI_STRUCTURAL_PARENTS = new Map([
128
128
  ["PageBack", new Set(["PageHeader"])],
129
129
  ["PageNavigation", new Set(["PageHeader"])],
130
130
  ["PageActions", new Set(["PageHeader"])],
131
- ["ContentHeader", new Set(["Content"])],
132
- ["ContentBody", new Set(["Content"])],
133
- ["ContentTitle", new Set(["ContentHeader"])],
134
- ["ContentDescription", new Set(["ContentHeader"])],
135
- ["ContentMeta", new Set(["ContentHeader"])],
136
- ["ContentActions", new Set(["ContentHeader"])],
137
- ["CardHeader", new Set(["Card"])],
138
- ["CardBody", new Set(["Card"])],
139
- ["CardFooter", new Set(["Card"])],
140
- ["CardMedia", new Set(["CardHeader"])],
141
- ["CardTitle", new Set(["CardHeader"])],
142
- ["CardDescription", new Set(["CardHeader"])],
143
- ["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"])],
144
141
  ["DialogHeader", new Set(["DialogContent"])],
145
142
  ["DialogBody", new Set(["DialogContent"])],
146
143
  ["DialogFooter", new Set(["DialogContent"])],
144
+ ["DialogIntro", new Set(["DialogBody"])],
145
+ ["DialogIntroActions", new Set(["DialogIntro"])],
147
146
  ["DialogNavigation", new Set(["DialogHeader"])],
148
147
  ["DialogMedia", new Set(["DialogHeader"])],
149
- ["DialogTitle", new Set(["DialogHeader"])],
148
+ ["DialogTitle", new Set(["DialogHeader", "DialogIntro"])],
150
149
  ["DialogActions", new Set(["DialogHeader"])],
151
150
  ["DrawerHeader", new Set(["DrawerContent"])],
152
151
  ["DrawerBody", new Set(["DrawerContent"])],
153
152
  ["DrawerFooter", new Set(["DrawerContent"])],
153
+ ["DrawerIntro", new Set(["DrawerBody"])],
154
+ ["DrawerIntroActions", new Set(["DrawerIntro"])],
154
155
  ["DrawerNavigation", new Set(["DrawerHeader"])],
155
156
  ["DrawerMedia", new Set(["DrawerHeader"])],
156
- ["DrawerTitle", new Set(["DrawerHeader"])],
157
+ ["DrawerTitle", new Set(["DrawerHeader", "DrawerIntro"])],
157
158
  ["DrawerActions", new Set(["DrawerHeader"])],
158
- ["PaneHeader", new Set(["Pane", "Sidebar"])],
159
- ["PaneBody", new Set(["Pane", "Sidebar"])],
160
- ["PaneFooter", new Set(["Pane", "Sidebar"])],
161
- ["PaneMedia", new Set(["PaneHeader"])],
162
- ["PaneTitle", new Set(["PaneHeader"])],
163
- ["PaneDescription", new Set(["PaneHeader"])],
164
- ["PaneActions", new Set(["PaneHeader"])],
165
159
  ["AlertMedia", new Set(["Alert"])],
166
160
  ["AlertHeader", new Set(["Alert"])],
167
161
  ["AlertActions", new Set(["Alert"])],
@@ -205,12 +199,15 @@ const UI_STRUCTURAL_ROOTS = new Map([
205
199
  },
206
200
  ],
207
201
  [
208
- "Content",
202
+ "Surface",
209
203
  {
210
- header: "ContentHeader",
211
- body: "ContentBody",
212
- shorthand: new Set(["title", "description", "count", "actions"]),
213
- 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"]),
214
211
  },
215
212
  ],
216
213
  ]);
@@ -223,18 +220,61 @@ const UI_STRICT_DIRECT_COMPONENTS = new Set([
223
220
  "PageBack",
224
221
  "PageNavigation",
225
222
  "PageActions",
226
- "ContentHeader",
227
- "ContentBody",
228
- "ContentTitle",
229
- "ContentDescription",
230
- "ContentMeta",
231
- "ContentActions",
223
+ "SurfaceMedia",
224
+ "SurfaceTitle",
225
+ "SurfaceDescription",
226
+ "SurfaceMeta",
227
+ "SurfaceActions",
232
228
  "AlertMedia",
233
229
  "AlertHeader",
234
230
  "AlertActions",
235
231
  ]);
236
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
+
237
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
+ ],
238
278
  [
239
279
  "PageHeader",
240
280
  new Map([
@@ -262,6 +302,14 @@ const UI_REMOVED_PROPS = new Map([
262
302
  ]);
263
303
 
264
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`"],
265
313
  ["PageIntro", "use `PageHeader` como a única introdução estrutural"],
266
314
  ["PageDescription", "mova contexto relevante para o início de `PageBody`"],
267
315
  [
@@ -272,11 +320,54 @@ const UI_REMOVED_COMPONENTS = new Map([
272
320
  "DrawerDescription",
273
321
  "mova contexto relevante para o início de `DrawerBody`",
274
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`"],
275
350
  ]);
276
351
 
277
352
  const UI_STRUCTURAL_CONTAINERS = new Set(["PageShell"]);
278
353
 
279
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
+ ],
280
371
  [
281
372
  "PageHeader",
282
373
  {
@@ -288,15 +379,19 @@ const UI_STRUCTURAL_HEADERS = new Map([
288
379
  },
289
380
  ],
290
381
  [
291
- "ContentHeader",
382
+ "SurfaceHeader",
292
383
  {
293
- title: "ContentTitle",
384
+ title: "SurfaceTitle",
294
385
  optional: new Set([
295
- "ContentDescription",
296
- "ContentMeta",
297
- "ContentActions",
386
+ "SurfaceMedia",
387
+ "SurfaceDescription",
388
+ "SurfaceMeta",
389
+ "SurfaceActions",
298
390
  ]),
299
- 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,
300
395
  },
301
396
  ],
302
397
  [
@@ -1085,6 +1180,57 @@ function directStructuralChildren(node, imports) {
1085
1180
  return { names, hasOpaque };
1086
1181
  }
1087
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
+
1088
1234
  /**
1089
1235
  * Componentes declarados no próprio arquivo (função, classe ou arrow/function atribuída).
1090
1236
  * Um deles nunca é o `Pane` do Opus, então o `Split` recusaria o elemento mesmo que o
@@ -1270,7 +1416,7 @@ function countPageHeadingSources(page, imports) {
1270
1416
  const local = jsxLocalName(node);
1271
1417
  if (
1272
1418
  local !== null &&
1273
- ((imports.get(local) === "Content" &&
1419
+ ((imports.get(local) === "Surface" &&
1274
1420
  jsxNumericAttribute(node, "level") === 1) ||
1275
1421
  imports.get(local) === "PageState")
1276
1422
  ) {
@@ -1410,6 +1556,36 @@ export function checkUiStructure(file, text) {
1410
1556
  }
1411
1557
  }
1412
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
+
1413
1589
  const header =
1414
1590
  component === null ? undefined : UI_STRUCTURAL_HEADERS.get(component);
1415
1591
  if (header !== undefined) {
@@ -1514,7 +1690,30 @@ export function checkUiStructure(file, text) {
1514
1690
  // some aqui.
1515
1691
  // Com filhos estruturais, o que as regras de forma acham é defeito independente: misturar
1516
1692
  // shorthand com slots, ou um filho inesperado, não some quando a prop sai.
1517
- 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) {
1518
1717
  // Nada a acrescentar; os filhos seguem sendo visitados no fim de `visit`.
1519
1718
  } else if (shorthand && structural) {
1520
1719
  add(
@@ -1573,7 +1772,7 @@ export function checkUiStructure(file, text) {
1573
1772
  add(
1574
1773
  node,
1575
1774
  component,
1576
- "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.",
1577
1776
  );
1578
1777
  }
1579
1778
  }
package/bin/lib/copy.mjs CHANGED
@@ -94,12 +94,8 @@ const JSX_CHILD_ROLES = new Map([
94
94
  ['BreadcrumbLink', 'breadcrumb'],
95
95
  ['BreadcrumbPage', 'breadcrumb'],
96
96
  ['Button', 'button'],
97
- ['CardDescription', 'description'],
98
- ['CardTitle', 'title'],
99
97
  ['CommandEmpty', 'empty-state'],
100
98
  ['CommandItem', 'menu-item'],
101
- ['ContentDescription', 'description'],
102
- ['ContentTitle', 'title'],
103
99
  ['DialogDescription', 'dialog-body'],
104
100
  ['DialogTitle', 'title'],
105
101
  ['DrawerDescription', 'dialog-body'],
@@ -124,8 +120,8 @@ const JSX_CHILD_ROLES = new Map([
124
120
  // comando. Classificado como `button`, a política universal cobraria verbo de ação.
125
121
  ['PageBack', 'breadcrumb'],
126
122
  ['PageTitle', 'title'],
127
- ['PaneDescription', 'description'],
128
- ['PaneTitle', 'title'],
123
+ ['SurfaceDescription', 'description'],
124
+ ['SurfaceTitle', 'title'],
129
125
  ['TableCaption', 'description'],
130
126
  ['TableHead', 'heading'],
131
127
  ['TabsListItem', 'tab'],
@@ -222,20 +218,6 @@ const JSX_PROP_ROLES = new Map([
222
218
  ]),
223
219
  ],
224
220
  ['CommandInput', new Map([['placeholder', 'placeholder']])],
225
- [
226
- 'Content',
227
- new Map([
228
- ['title', 'title'],
229
- ['description', 'description'],
230
- ]),
231
- ],
232
- [
233
- 'ContentHeader',
234
- new Map([
235
- ['title', 'title'],
236
- ['description', 'description'],
237
- ]),
238
- ],
239
221
  [
240
222
  'DataState',
241
223
  new Map([
@@ -290,6 +272,13 @@ const JSX_PROP_ROLES = new Map([
290
272
  ['emptyText', 'empty-state'],
291
273
  ]),
292
274
  ],
275
+ [
276
+ 'Surface',
277
+ new Map([
278
+ ['title', 'title'],
279
+ ['description', 'description'],
280
+ ]),
281
+ ],
293
282
  ['Textarea', new Map([['placeholder', 'placeholder']])],
294
283
  ])
295
284
 
@@ -363,20 +352,6 @@ const JSX_PROP_CLASS = new Map([
363
352
  ]),
364
353
  ],
365
354
  ['CommandInput', new Map([['placeholder', 'className']])],
366
- [
367
- 'Content',
368
- new Map([
369
- ['title', 'className'],
370
- ['description', 'className'],
371
- ]),
372
- ],
373
- [
374
- 'ContentHeader',
375
- new Map([
376
- ['title', 'className'],
377
- ['description', 'className'],
378
- ]),
379
- ],
380
355
  ['Input', new Map([['placeholder', 'className']])],
381
356
  ['InputGroupInput', new Map([['placeholder', 'className']])],
382
357
  ['InputGroupTextarea', new Map([['placeholder', 'className']])],
@@ -394,6 +369,13 @@ const JSX_PROP_CLASS = new Map([
394
369
  ]),
395
370
  ],
396
371
  ['Select', new Map([['placeholder', 'className']])],
372
+ [
373
+ 'Surface',
374
+ new Map([
375
+ ['title', 'className'],
376
+ ['description', 'className'],
377
+ ]),
378
+ ],
397
379
  ['Textarea', new Map([['placeholder', 'className']])],
398
380
  ])
399
381
 
@@ -1,5 +1,11 @@
1
1
  # ADR 0023 — Superfícies compartilham uma anatomia e dois layouts
2
2
 
3
+ > **Atualização em 2026-10-01:** a ADR 0046 torna o body responsável pelo ritmo entre seus filhos
4
+ > diretos e dá ao conteúdo um respiro vertical maior que o das faixas; “regiões simétricas” abaixo
5
+ > descreve a implementação original. A ADR 0047 também remove `Pane` desta família: ele passa a ser
6
+ > somente a unidade de layout de `Split`, enquanto `Surface`, `Dialog` e `Drawer` preservam a
7
+ > anatomia.
8
+
3
9
  - **Status:** aceita.
4
10
  - **Data:** 2026-09-23.
5
11
  - **Complementa:** ADRs 0005, 0014, 0018, 0019 e 0021.
@@ -22,9 +22,9 @@ preserva `select` como apresentação padrão.
22
22
 
23
23
  `toggle` aplica-se a filtros `type: "select"` de escolha única e usa as mesmas opções, valor de
24
24
  repouso, regra de vazio, dependências e atualização de estado do controle padrão. A apresentação
25
- mostra todas as opções em um `ToggleGroup` conectado e não permite desmarcar a opção ativa quando o
26
- filtro não aceita vazio. Quando a opção vem de um dicionário e sua entrada declara um ícone do
27
- catálogo, o grupo apresenta ícone e rótulo juntos.
25
+ mostra todas as opções em um `ToggleGroup`; a composição visual vigente é definida pela ADR 0042.
26
+ O controle não permite desmarcar a opção ativa quando o filtro não aceita vazio. Quando a opção vem
27
+ de um dicionário e sua entrada declara um ícone do catálogo, o grupo apresenta ícone e rótulo juntos.
28
28
 
29
29
  O tipo do filtro continua descrevendo o valor e sua operação. A apresentação escolhe somente o
30
30
  controle usado para editá-lo.
@@ -21,9 +21,8 @@ A anatomia pública nomeia a posição de cada parte: `TabsListItem` pertence a
21
21
  `TabsPanel` identifica o painel associado. Os nomes anteriores, `TabsTrigger` e `TabsContent`, não
22
22
  são mantidos como aliases porque há um único consumidor e a migração é atômica.
23
23
 
24
- Escolhas únicas ou múltiplas que alteram um valor no contexto atual usam `ToggleGroup`. Quando o
25
- grupo precisa de um contorno conectado, o consumidor declara `variant="outline"` com `spacing={0}`;
26
- a borda pertence aos itens e as arestas internas duplicadas são removidas pela primitiva.
24
+ Escolhas únicas ou múltiplas que alteram um valor no contexto atual usam `ToggleGroup`. A composição
25
+ visual vigente do grupo é definida pela ADR 0042.
27
26
 
28
27
  ## Consequências
29
28
 
@@ -31,7 +30,7 @@ a borda pertence aos itens e as arestas internas duplicadas são removidas pela
31
30
  - Filtros e escolhas de formulário não usam semântica de tabs para obter uma aparência compacta.
32
31
  - Consumidores removem `variant="line"`; não há substituição para a antiga superfície preenchida.
33
32
  - Consumidores renomeiam `TabsTrigger` para `TabsListItem` e `TabsContent` para `TabsPanel`.
34
- - A documentação de ToggleGroup explicita como formar o contorno conectado.
33
+ - A documentação de ToggleGroup explicita seus modos de composição.
35
34
 
36
35
  ## Alternativas descartadas
37
36
 
@@ -56,5 +55,5 @@ da anatomia exposta ao consumidor. `ListItem` e `Panel` deixam a composição le
56
55
  - testes da primitiva confirmam a linha-base, o indicador ativo e a distribuição;
57
56
  - testes do `ActionFilterBar` confirmam que `presentation: "toggle"` usa seleção exclusiva sem
58
57
  alterar o estado transportado para a action;
59
- - a documentação de Tabs não oferece apresentação preenchida e a de ToggleGroup demonstra o grupo
60
- conectado com borda.
58
+ - a documentação de Tabs não oferece apresentação preenchida e a de ToggleGroup demonstra seus
59
+ modos próprios de escolha.