@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
@@ -15,19 +15,30 @@ exemplos leem as variáveis ao vivo; troque o tema do app e a página acompanha.
15
15
 
16
16
  ```tsx preview
17
17
  const SURFACES = [
18
- ['background', 'foreground', 'O fundo da página e o texto padrão.'],
19
- ['card', 'card-foreground', 'Superfície elevada (Card, painéis).'],
20
- ['popover', 'popover-foreground', 'Superfície flutuante (menus, popovers, dialogs).'],
21
- ['muted', 'muted-foreground', 'Fundo discreto e texto secundário.'],
22
- ['accent', 'accent-foreground', 'Hover e seleção.'],
23
- ['secondary', 'secondary-foreground', 'Ação secundária.'],
24
- ['primary', 'primary-foreground', 'A ação principal — alto contraste.'],
25
- ['destructive', 'destructive-foreground', 'Ação destrutiva e erro.'],
26
- ]
18
+ ["background", "foreground", "O fundo da página e o texto padrão."],
19
+ ["card", "card-foreground", "Superfície elevada (Surface, painéis)."],
20
+ [
21
+ "popover",
22
+ "popover-foreground",
23
+ "Superfície flutuante (menus, popovers, dialogs).",
24
+ ],
25
+ ["muted", "muted-foreground", "Fundo discreto e texto secundário."],
26
+ [
27
+ "accent",
28
+ "accent-foreground",
29
+ "Alias de compatibilidade para hover neutro.",
30
+ ],
31
+ ["secondary", "secondary-foreground", "Ação secundária."],
32
+ ["primary", "primary-foreground", "A ação principal — alto contraste."],
33
+ ["destructive", "destructive-foreground", "Ação destrutiva e erro."],
34
+ ];
27
35
  render(
28
36
  <div className="w-full overflow-hidden rounded-lg border border-border">
29
37
  {SURFACES.map(([token, fg, desc]) => (
30
- <div key={token} className="flex items-center gap-4 border-b border-border px-4 py-3 last:border-b-0">
38
+ <div
39
+ key={token}
40
+ className="flex items-center gap-4 border-b border-border px-4 py-3 last:border-b-0"
41
+ >
31
42
  <div
32
43
  className="flex h-10 w-28 shrink-0 items-center justify-center rounded-md border border-border text-xs font-medium"
33
44
  style={{ backgroundColor: `var(--${token})`, color: `var(--${fg})` }}
@@ -35,15 +46,75 @@ render(
35
46
  Texto
36
47
  </div>
37
48
  <div className="min-w-0">
38
- <div className="font-mono text-xs">--{token} · --{fg}</div>
49
+ <div className="font-mono text-xs">
50
+ --{token} · --{fg}
51
+ </div>
39
52
  <div className="text-xs text-muted-foreground">{desc}</div>
40
53
  </div>
41
54
  </div>
42
55
  ))}
43
56
  </div>,
44
- )
57
+ );
45
58
  ```
46
59
 
60
+ ### Controles e estados neutros
61
+
62
+ Canvas, superfície elevada, controle e interação formam uma escala opaca. Um input não muda de
63
+ tom ao sair do canvas e entrar num card, porque `control` representa o resultado final — não uma
64
+ cor translúcida a ser misturada com o ancestral. Da mesma forma, hover, seleção e pressão usam
65
+ papéis diferentes e preservam a mesma ordem comportamental nos dois temas.
66
+
67
+ ```tsx preview
68
+ const NEUTRAL_STATES = [
69
+ ["control", "Controle em repouso."],
70
+ ["interaction-hover", "Resposta transitória ao apontador ou foco de item."],
71
+ ["interaction-selected", "Seleção ou expansão persistente."],
72
+ ["interaction-pressed", "Pressão transitória de um controle neutro."],
73
+ ];
74
+ render(
75
+ <div className="grid w-full grid-cols-2 gap-2">
76
+ {NEUTRAL_STATES.map(([token, desc]) => (
77
+ <div
78
+ key={token}
79
+ className="rounded-md border border-border p-3"
80
+ style={{ backgroundColor: `var(--${token})` }}
81
+ >
82
+ <div className="font-mono text-xs">--{token}</div>
83
+ <div className="mt-1 text-xs text-muted-foreground">{desc}</div>
84
+ </div>
85
+ ))}
86
+ </div>,
87
+ );
88
+ ```
89
+
90
+ ### Ajuste de aparência
91
+
92
+ Os tokens neutros desta seção não são valores fixos: o tema os calcula a partir de quatro eixos,
93
+ declarados separadamente para o modo claro e para o modo escuro. Com os eixos no preset, o
94
+ resultado é o tema padrão. Uma aplicação que oferece preferências de aparência altera os eixos, e
95
+ toda a escala — superfícies, estados, bordas e texto secundário — se move em conjunto, sem perder a
96
+ ordem entre os papéis.
97
+
98
+ | Eixo | Custom property (`light` ou `dark`) | O que muda | Preset claro | Preset escuro |
99
+ | ----------------------- | ---------------------------------------- | ------------------------------------------------------ | ------------ | ------------- |
100
+ | Intensidade de cor | `--appearance-<modo>-color-intensity` | Quanto do matiz `--neutral-hue` aparece nos neutros | 0 | 0 |
101
+ | Contraste | `--appearance-<modo>-contrast` | Distância de hover, seleção, pressão, `muted` e bordas | 0 | 0 |
102
+ | Brilho das superfícies | `--appearance-<modo>-surface-brightness` | Luminosidade do canvas e das superfícies | 100 | 50 |
103
+ | Contraste entre camadas | `--appearance-<modo>-layer-contrast` | Distância entre canvas, controle e superfície elevada | 0 | 0 |
104
+
105
+ Cada eixo aceita um inteiro de 0 a 100. Os limites protegem o tema padrão: nenhum valor dessa faixa
106
+ reduz a separação entre os papéis nem o contraste do texto sobre as superfícies em repouso abaixo do
107
+ que o preset oferece. Por isso contraste e contraste entre camadas partem de zero e só aumentam, e o
108
+ texto secundário acompanha as superfícies quando o brilho muda.
109
+
110
+ A intensidade de cor tinge somente os neutros. `primary`, `destructive` e os contextos `info`,
111
+ `success`, `warning` e `danger` mantêm as próprias cores. O matiz aplicado vem de `--neutral-hue`,
112
+ que a aplicação define junto com a identidade.
113
+
114
+ Os nomes, os presets e a conversão de valores em custom properties ficam em
115
+ `@softize/opus/ui/appearance`. O passo a passo para aplicar e desfazer um ajuste está em
116
+ [Customização](/ui/customization). A ADR 0044 registra a decisão e os limites.
117
+
47
118
  ## Famílias contextuais
48
119
 
49
120
  Os tokens `primary`, `secondary` e `destructive` acima permanecem fundações de superfície. A API
@@ -52,7 +123,7 @@ família oferece sólido, foreground, superfície sutil, ênfase e borda. Use as
52
123
  `variant`; classes contextuais diretas ficam reservadas à implementação dos primitives.
53
124
 
54
125
  ```tsx preview
55
- const CONTEXTS = ['neutral', 'primary', 'info', 'success', 'warning', 'danger']
126
+ const CONTEXTS = ["neutral", "primary", "info", "success", "warning", "danger"];
56
127
  render(
57
128
  <div className="grid w-full grid-cols-3 gap-2">
58
129
  {CONTEXTS.map((context) => (
@@ -69,7 +140,7 @@ render(
69
140
  </div>
70
141
  ))}
71
142
  </div>,
72
- )
143
+ );
73
144
  ```
74
145
 
75
146
  ## Linhas e foco
@@ -79,15 +150,21 @@ render(
79
150
 
80
151
  ```tsx preview
81
152
  const LINES = [
82
- ['border', 'Bordas de contêineres (cards, tabelas, separadores).'],
83
- ['input', 'Borda e fundo sutil de campos de formulário.'],
84
- ['ring', 'O anel de foco (focus-visible).'],
85
- ]
153
+ ["border", "Bordas de contêineres (cards, tabelas, separadores)."],
154
+ ["input", "Borda de campos de formulário; o fundo usa control."],
155
+ ["ring", "O anel de foco (focus-visible)."],
156
+ ];
86
157
  render(
87
158
  <div className="w-full overflow-hidden rounded-lg border border-border">
88
159
  {LINES.map(([token, desc]) => (
89
- <div key={token} className="flex items-center gap-4 border-b border-border px-4 py-3 last:border-b-0">
90
- <div className="h-10 w-28 shrink-0 rounded-md border-2" style={{ borderColor: `var(--${token})` }} />
160
+ <div
161
+ key={token}
162
+ className="flex items-center gap-4 border-b border-border px-4 py-3 last:border-b-0"
163
+ >
164
+ <div
165
+ className="h-10 w-28 shrink-0 rounded-md border-2"
166
+ style={{ borderColor: `var(--${token})` }}
167
+ />
91
168
  <div className="min-w-0">
92
169
  <div className="font-mono text-xs">--{token}</div>
93
170
  <div className="text-xs text-muted-foreground">{desc}</div>
@@ -95,7 +172,7 @@ render(
95
172
  </div>
96
173
  ))}
97
174
  </div>,
98
- )
175
+ );
99
176
  ```
100
177
 
101
178
  ## Escala de elevação
@@ -107,16 +184,19 @@ render(
107
184
 
108
185
  ```tsx preview
109
186
  const LEVELS = [
110
- ['sm', 'Superfície em repouso.'],
111
- ['md', 'Flutuante intermediário.'],
112
- ['lg', 'Modal ou destaque forte.'],
113
- ]
187
+ ["sm", "Superfície em repouso."],
188
+ ["md", "Flutuante intermediário."],
189
+ ["lg", "Modal ou destaque forte."],
190
+ ];
114
191
  render(
115
192
  <div className="flex flex-wrap gap-8 py-4">
116
193
  {LEVELS.map(([level, desc]) => (
117
194
  <div key={level} className="flex flex-col items-center gap-3">
118
195
  <div
119
- className={'flex h-24 w-40 items-center justify-center rounded-xl border border-border bg-card text-xs text-card-foreground shadow-' + level}
196
+ className={
197
+ "flex h-24 w-40 items-center justify-center rounded-xl border border-border bg-card text-xs text-card-foreground shadow-" +
198
+ level
199
+ }
120
200
  >
121
201
  Texto
122
202
  </div>
@@ -127,7 +207,7 @@ render(
127
207
  </div>
128
208
  ))}
129
209
  </div>,
130
- )
210
+ );
131
211
  ```
132
212
 
133
213
  ## Escala relativa
@@ -172,7 +252,7 @@ render(
172
252
  </p>
173
253
  </div>
174
254
  </div>,
175
- )
255
+ );
176
256
  ```
177
257
 
178
258
  Alterar `font-size` em `html` permanece válido quando a aplicação quer recalibrar **toda** a
@@ -190,15 +270,17 @@ o mecanismo para fazer `text-sm` se chamar `text-base`.
190
270
  render(
191
271
  <div className="flex flex-wrap gap-4">
192
272
  {[
193
- ['rounded-xs', 'radius − 0.375rem'],
194
- ['rounded-sm', 'radius − 0.25rem'],
195
- ['rounded-md', 'radius − 0.125rem'],
196
- ['rounded-lg', 'radius (0.75rem)'],
197
- ['rounded-xl', 'radius + 0.25rem'],
198
- ['rounded-2xl', 'radius + 0.5rem'],
273
+ ["rounded-xs", "radius − 0.375rem"],
274
+ ["rounded-sm", "radius − 0.25rem"],
275
+ ["rounded-md", "radius − 0.125rem"],
276
+ ["rounded-lg", "radius (0.75rem)"],
277
+ ["rounded-xl", "radius + 0.25rem"],
278
+ ["rounded-2xl", "radius + 0.5rem"],
199
279
  ].map(([cls, calc]) => (
200
280
  <div key={cls} className="flex items-center gap-3">
201
- <div className={'h-12 w-12 border-2 border-foreground/30 bg-muted ' + cls} />
281
+ <div
282
+ className={"h-12 w-12 border-2 border-foreground/30 bg-muted " + cls}
283
+ />
202
284
  <div>
203
285
  <div className="font-mono text-xs">{cls}</div>
204
286
  <div className="text-xs text-muted-foreground">{calc}</div>
@@ -206,7 +288,7 @@ render(
206
288
  </div>
207
289
  ))}
208
290
  </div>,
209
- )
291
+ );
210
292
  ```
211
293
 
212
294
  ## Divergências declaradas vs shadcn
@@ -214,10 +296,12 @@ render(
214
296
  > Qualquer diferença em relação ao shadcn que não esteja declarada aqui (e no `theme.css`) é uma
215
297
  > divergência a sincronizar, conforme a skill `build-opus-ui`.
216
298
 
217
- 1. Formato HSL nos tokens (o upstream migrou para oklch), escolhido pela legibilidade; os valores
218
- acompanham o upstream, só o formato difere.
219
- 2. Elevação no tema escuro: `--card` e `--popover` ficam acima de `--background` (`10%` contra `3.9%`
220
- de luminosidade), para que dialog e card se destaquem de fato da página.
299
+ 1. Formato HSL nos tokens (o upstream migrou para oklch), escolhido pela legibilidade.
300
+ 2. Hierarquia opaca de superfícies e estados nos dois temas: canvas, card/popover, controle, hover,
301
+ seleção e pressão têm papéis próprios. No light, o canvas quase branco sustenta superfícies
302
+ brancas; no dark, cards e popovers permanecem acima do canvas. A ADR 0043 registra os valores e
303
+ as restrições de composição. A escala neutra é derivada dos eixos de aparência (ADR 0044); no
304
+ preset, os valores são os calibrados.
221
305
  3. Radius mapeado (`xs` a `2xl`) preservando o sentido pré-v4 dos utilitários `rounded-*`. Esse
222
306
  trecho derivado permanece monotônico quando a base muda; aplicações que também usam `3xl`
223
307
  precisam revisar a transição para esse degrau nativo ao customizar `--radius`.
@@ -61,7 +61,6 @@ import dockMd from "./content/dock.md?raw";
61
61
  import breadcrumbMd from "./content/breadcrumb.md?raw";
62
62
  import buttonMd from "./content/button.md?raw";
63
63
  import calendarMd from "./content/calendar.md?raw";
64
- import cardMd from "./content/card.md?raw";
65
64
  import metricCardMd from "./content/metric-card.md?raw";
66
65
  import carouselMd from "./content/carousel.md?raw";
67
66
  import askMd from "./content/ask.md?raw";
@@ -125,7 +124,7 @@ import actionTriggerMd from "./content/action-trigger.md?raw";
125
124
  import actionSearchDialogMd from "./content/action-list-dialog.md?raw";
126
125
  import pageMd from "./content/page.md?raw";
127
126
  import presentationMd from "./content/presentation.md?raw";
128
- import contentMd from "./content/content.md?raw";
127
+ import surfaceMd from "./content/surface.md?raw";
129
128
  import paneMd from "./content/pane.md?raw";
130
129
  import sidebarMd from "./content/sidebar.md?raw";
131
130
  import splitMd from "./content/split.md?raw";
@@ -454,9 +453,9 @@ export const UI_SECTIONS: DocSection[] = [
454
453
  ),
455
454
  },
456
455
  {
457
- slug: "content",
458
- title: "Content",
459
- render: pattern("Content", "content", contentMd),
456
+ slug: "surface",
457
+ title: "Surface",
458
+ render: pattern("Surface", "surface", surfaceMd),
460
459
  },
461
460
  {
462
461
  slug: "accordion",
@@ -468,7 +467,6 @@ export const UI_SECTIONS: DocSection[] = [
468
467
  title: "Aspect Ratio",
469
468
  render: comp("Aspect Ratio", "aspect-ratio", aspectRatioMd),
470
469
  },
471
- { slug: "card", title: "Card", render: comp("Card", "card", cardMd) },
472
470
  {
473
471
  slug: "metric-card",
474
472
  title: "Metric Card",
@@ -51,11 +51,12 @@ import { objectSchemaShape } from '../lib/object-schema.ts'
51
51
 
52
52
  /**
53
53
  * Dicionário estrutural pro resolvedor de `options: { kind: 'dictionary', ref }`:
54
- * qualquer coisa com `options()` → `{ value, label }`. O `DictType` do `t.dict`
55
- * satisfaz direto — sem este driver importar o driver de schema.
54
+ * qualquer coisa com `options()` → `{ value, label, icon? }`. O `DictType` do `t.dict`
55
+ * satisfaz direto — sem este driver importar o driver de schema. A metadata opcional
56
+ * continua sendo declarada pelo dicionário; o provider não inventa apresentação.
56
57
  */
57
58
  export interface DictLike {
58
- options(): Array<{ value: string; label: string }>
59
+ options(): Array<{ value: string; label: string; icon?: string }>
59
60
  }
60
61
 
61
62
  interface OpusContextValue {
package/src/ui/index.ts CHANGED
@@ -5,7 +5,10 @@
5
5
  * Aqui ficam apenas types/contratos compartilhados.
6
6
  */
7
7
 
8
- import type { ActionError, Paginated } from '../core/index.ts'
8
+ import type { ActionError, Paginated } from "../core/index.ts";
9
+
10
+ // Personalização de aparência (ADR 0044): sem React, também disponível em `./ui/appearance`.
11
+ export * from "./appearance.ts";
9
12
 
10
13
  /**
11
14
  * Metadado de descoberta de um componente — cada componente co-localiza um `meta`
@@ -13,18 +16,18 @@ import type { ActionError, Paginated } from '../core/index.ts'
13
16
  */
14
17
  export interface ComponentMeta {
15
18
  /** Nome canônico (kebab), ex.: 'button', 'dropdown-menu'. */
16
- name: string
19
+ name: string;
17
20
  /** Origem: descendente do shadcn (forkado) ou nativo do Opus. */
18
- ancestry: 'shadcn' | 'opus'
21
+ ancestry: "shadcn" | "opus";
19
22
  /** Quando usar / quando não — guia o agente de design a escolher certo. */
20
- whenToUse: string
23
+ whenToUse: string;
21
24
  /** Aviso de retirada no catálogo, com o caminho de migração. */
22
25
  deprecated?: {
23
26
  /** Alternativa recomendada; aceita `código` inline como `whenToUse`. */
24
- alternative: string
27
+ alternative: string;
25
28
  /** Versão em que a retirada começou, quando houver. */
26
- since?: string
27
- }
29
+ since?: string;
30
+ };
28
31
  }
29
32
 
30
33
  /**
@@ -32,23 +35,23 @@ export interface ComponentMeta {
32
35
  * shape derivado disto.
33
36
  */
34
37
  export interface ActionState<TData> {
35
- data: TData | undefined
36
- error: ActionError | undefined
37
- isIdle: boolean
38
- isLoading: boolean
39
- isSuccess: boolean
40
- isError: boolean
38
+ data: TData | undefined;
39
+ error: ActionError | undefined;
40
+ isIdle: boolean;
41
+ isLoading: boolean;
42
+ isSuccess: boolean;
43
+ isError: boolean;
41
44
  }
42
45
 
43
46
  export interface ListState<TItem> {
44
- items: TItem[]
45
- cursor: { next: string | null; prev?: string | null }
46
- total: number | undefined
47
- error: ActionError | undefined
48
- isIdle: boolean
49
- isLoading: boolean
50
- isSuccess: boolean
51
- isError: boolean
47
+ items: TItem[];
48
+ cursor: { next: string | null; prev?: string | null };
49
+ total: number | undefined;
50
+ error: ActionError | undefined;
51
+ isIdle: boolean;
52
+ isLoading: boolean;
53
+ isSuccess: boolean;
54
+ isError: boolean;
52
55
  }
53
56
 
54
57
  /**
@@ -62,7 +65,7 @@ export function idleAction<T>(): ActionState<T> {
62
65
  isLoading: false,
63
66
  isSuccess: false,
64
67
  isError: false,
65
- }
68
+ };
66
69
  }
67
70
 
68
71
  export function idleLookup<T>(): ListState<T> {
@@ -75,7 +78,7 @@ export function idleLookup<T>(): ListState<T> {
75
78
  isLoading: false,
76
79
  isSuccess: false,
77
80
  isError: false,
78
- }
81
+ };
79
82
  }
80
83
 
81
84
  /**
@@ -83,10 +86,10 @@ export function idleLookup<T>(): ListState<T> {
83
86
  */
84
87
  export function isPaginated(value: unknown): value is Paginated<unknown> {
85
88
  return (
86
- typeof value === 'object' &&
89
+ typeof value === "object" &&
87
90
  value !== null &&
88
- 'items' in value &&
91
+ "items" in value &&
89
92
  Array.isArray((value as { items: unknown }).items) &&
90
- 'cursor' in value
91
- )
93
+ "cursor" in value
94
+ );
92
95
  }
package/src/ui/meta.ts CHANGED
@@ -11,11 +11,11 @@ import type { ComponentMeta } from "./index.ts";
11
11
 
12
12
  /** Todos os metas, chaveados pelo nome canônico (kebab). Fonte única — zero duplicação. */
13
13
  export const componentMeta = {
14
- content: {
15
- name: "content",
14
+ surface: {
15
+ name: "surface",
16
16
  ancestry: "opus",
17
17
  whenToUse:
18
- "Organize uma região nomeada dentro de uma página ou de outra superfície. Use as propriedades de Content no caso comum e componha seus slots quando precisar controlar a estrutura. Numa página centrada em uma coleção, `level={1}` faz do Content o heading principal; nas demais, ele vem de PageTitle.",
18
+ 'Delimite e nomeie um trecho da tela: uma região da página, um grupo de campos, um registro, um item de grade ou um bloco clicável. `as` escolhe o elemento (`div`, `section`, `article` ou `fieldset`) e `asChild` entrega a superfície a um botão ou link. `framed` acrescenta a moldura, `elevation="raised"` dá fundo próprio e `layout` escolhe entre ritmo contínuo e faixas com divisores. No caso comum, declare `title`, `description`, `count`, `actions` e `media` na própria Surface; componha SurfaceHeader, SurfaceBody e SurfaceFooter quando precisar controlar a estrutura. Numa página centrada em uma coleção, `level={1}` faz da Surface o heading principal; nas demais, ele vem de PageTitle. Para um modal use Dialog, para um painel lateral use Drawer e para uma coluna de layout use Pane.',
19
19
  },
20
20
  ask: {
21
21
  name: "ask",
@@ -77,17 +77,11 @@ export const componentMeta = {
77
77
  whenToUse:
78
78
  "Inicie uma ação com um controle clicável. Use `context` para o significado, `variant` para o tratamento visual e `size` para a escala compartilhada (`xs`…`lg` e os quadrados `icon-*`); `icon` recebe um nó e `busy` comunica o andamento e impede um novo acionamento. Para ações relacionadas, use ButtonGroup conectado, espaçado ou dividido.",
79
79
  },
80
- card: {
81
- name: "card",
82
- ancestry: "shadcn",
83
- whenToUse:
84
- "Agrupe conteúdo relacionado em uma superfície delimitada. Use Card diretamente para um bloco simples ou componha cabeçalho, corpo e rodapé quando houver hierarquia e ações. CardContent permanece apenas como alias temporário de compatibilidade.",
85
- },
86
80
  "metric-card": {
87
81
  name: "metric-card",
88
82
  ancestry: "opus",
89
83
  whenToUse:
90
- "Apresente uma medida resumida, como um total ou uma contagem, com rótulo, valor em destaque e descrição opcional. O consumidor calcula e formata os valores. Use `secondaryMetric` para uma segunda leitura da mesma dimensão, exibida como lâmina posterior com rótulo próprio; `context` realça somente o ícone, `action` acrescenta uma ação diretamente relacionada e `loading` substitui rótulo e valor durante a carga. Com `onActivate`, `active` e `activationLabel`, informados juntos, o card vira um controle que aplica o recorte que a medida resume e expõe o estado por `aria-pressed`. Para conteúdo geral ou estrutura livre, use Card.",
84
+ "Apresente uma medida resumida, como um total ou uma contagem, com rótulo, valor em destaque e descrição opcional. O consumidor calcula e formata os valores. Use `secondaryMetric` para uma segunda leitura da mesma dimensão, exibida como lâmina posterior com rótulo próprio; `context` realça somente o ícone, `action` acrescenta uma ação diretamente relacionada e `loading` substitui rótulo e valor durante a carga. Com `onActivate`, `active` e `activationLabel`, informados juntos, o card vira um controle que aplica o recorte que a medida resume e expõe o estado por `aria-pressed`. Para conteúdo geral ou estrutura livre, use Surface.",
91
85
  },
92
86
  chat: {
93
87
  name: "chat",
@@ -119,12 +113,6 @@ export const componentMeta = {
119
113
  whenToUse:
120
114
  "Receba texto para envio sem montar uma conversa completa. Enter envia, Shift+Enter quebra a linha e `actions` acrescenta uma faixa para seletores relacionados. O componente é controlado; use Chat quando também precisar de mensagens e estado da conversa.",
121
115
  },
122
- "content-header": {
123
- name: "content-header",
124
- ancestry: "opus",
125
- whenToUse:
126
- 'Estruture uma região com título e corpo. No caso comum, declare título, mídia, descrição, contador e ações diretamente em Content; use `framed` quando a região precisar de limite próprio, `layout="sectioned"` quando o filho controlar o recuo do body, `frameVariant="legend"` para integrar visualmente o header à borda e `as="fieldset"` somente para agrupar campos sob uma legenda nativa. Componha ContentHeader apenas quando precisar controlar a anatomia.',
127
- },
128
116
  copyable: {
129
117
  name: "copyable",
130
118
  ancestry: "opus",
@@ -177,7 +165,7 @@ export const componentMeta = {
177
165
  name: "separator",
178
166
  ancestry: "shadcn",
179
167
  whenToUse:
180
- 'Marque a fronteira entre blocos de conteúdo que não pertencem a uma superfície com regiões. A linha horizontal fica entre blocos empilhados; com `orientation="vertical"`, ela separa itens lado a lado e herda a altura do contêiner. A linha é decorativa por padrão e fica fora da árvore de acessibilidade; use `decorative={false}` quando a divisão tiver significado. Não use Separator entre cabeçalho, corpo e rodapé de Card, Dialog ou Drawer: nessas superfícies, os divisores são derivados das regiões com `layout="sectioned"`.',
168
+ 'Marque a fronteira entre blocos de conteúdo que não pertencem a uma superfície com regiões. A linha horizontal fica entre blocos empilhados; com `orientation="vertical"`, ela separa itens lado a lado e herda a altura do contêiner. A linha é decorativa por padrão e fica fora da árvore de acessibilidade; use `decorative={false}` quando a divisão tiver significado. Não use Separator entre cabeçalho, corpo e rodapé de Surface, Dialog ou Drawer: nessas superfícies, os divisores são derivados das regiões com `layout="sectioned"`.',
181
169
  },
182
170
  skeleton: {
183
171
  name: "skeleton",
@@ -201,7 +189,7 @@ export const componentMeta = {
201
189
  name: "tabs",
202
190
  ancestry: "shadcn",
203
191
  whenToUse:
204
- 'Alterne entre painéis relacionados no mesmo nível de navegação. Cada TabsTrigger aponta para um TabsContent pelo mesmo `value`; TabsList controla o tratamento visual e Tabs define orientação, escala, distribuição e moldura. Use `distribution="equal"` para repartir a largura entre seções equivalentes e `framed` para delimitar o conjunto sem criar uma nova superfície.',
192
+ "Navegue entre painéis relacionados no mesmo nível. Cada TabsListItem aponta para um TabsPanel pelo mesmo `value`; TabsList usa uma linha para indicar a aba ativa, enquanto Tabs define orientação, escala, distribuição e moldura. Use ToggleGroup para escolher valores sem navegar entre painéis.",
205
193
  },
206
194
  textarea: {
207
195
  name: "textarea",
@@ -285,7 +273,7 @@ export const componentMeta = {
285
273
  name: "field",
286
274
  ancestry: "shadcn",
287
275
  whenToUse:
288
- 'Componha rótulo, controle, ajuda e erro com espaçamento consistente. Use a orientação vertical no caso comum e a horizontal quando controle e texto precisarem ficar lado a lado. FieldGroup organiza vários campos; quando o conjunto precisar de título, componha-o dentro de Content com `as="fieldset"`.',
276
+ 'Componha rótulo, controle, ajuda e erro com espaçamento consistente. Use a orientação vertical no caso comum e a horizontal quando controle e texto precisarem ficar lado a lado. FieldGroup organiza vários campos; quando o conjunto precisar de título, componha-o dentro de Surface com `as="fieldset"`.',
289
277
  },
290
278
  "input-otp": {
291
279
  name: "input-otp",
@@ -327,19 +315,19 @@ export const componentMeta = {
327
315
  name: "split",
328
316
  ancestry: "opus",
329
317
  whenToUse:
330
- "Divide uma área em panes em sequência horizontal ou vertical. Use `resizable` quando a pessoa deve ajustar a fronteira; o mesmo `<Split>` vira flex simples sem ele. Cada `<Pane>` declara tamanho inicial e mínimo; a anatomia do pane tem página própria. SurfaceAssistant especializa esse layout para assistência contextual dentro de Page, Dialog ou Drawer.",
318
+ "Divide uma área em panes em sequência horizontal ou vertical. Use `resizable` quando a pessoa deve ajustar a fronteira; o mesmo `<Split>` vira flex simples sem ele. Cada `<Pane>` declara apenas dimensão e crescimento; o filho compõe apresentação e rolagem. SurfaceAssistant especializa esse layout para assistência contextual dentro de Page, Dialog ou Drawer.",
331
319
  },
332
320
  sidebar: {
333
321
  name: "sidebar",
334
322
  ancestry: "opus",
335
323
  whenToUse:
336
- "Organize navegação global ou contextual em uma coluna lateral. Split e Pane definem posição e largura; Sidebar fornece a superfície e o colapso. Use SidebarNav para grupos planos e componha árvores com SidebarItem e SidebarTreeGroup; cabeçalho e rodapé próprios são do Pane que a hospeda.",
324
+ "Organize navegação global ou contextual em uma coluna lateral. Split e Pane definem posição e largura; Sidebar fornece a coluna e o colapso. Use SidebarNav para grupos planos e componha árvores com SidebarItem e SidebarTreeGroup; quando precisar de header, body ou footer visuais, envolva a Sidebar com Surface.",
337
325
  },
338
326
  pane: {
339
327
  name: "pane",
340
328
  ancestry: "opus",
341
329
  whenToUse:
342
- "Estruture a coluna ou faixa encaixada em um Split. PaneHeader nomeia a região e acomoda mídia e ações; ao lado desses slots, também aceita conteúdo livre, como uma busca ou a marca de uma barra lateral. PaneBody rola e PaneFooter mantém à vista o que não pode sair da tela. `layout` escolhe entre regiões seccionadas, com divisores derivados da vizinhança, e o fluxo contínuo. `inset` vale para o pane sem slots; com eles, o recuo pertence a cada região.",
330
+ "Declare o tamanho de uma região dentro de Split. Pane cuida somente de dimensão, limites e crescimento; o filho decide superfície, organização e rolagem. Use Surface quando a região precisar de header, body, footer, borda ou elevação, e Sidebar quando ela for uma navegação lateral.",
343
331
  },
344
332
  "scroll-area": {
345
333
  name: "scroll-area",
@@ -363,7 +351,7 @@ export const componentMeta = {
363
351
  name: "toggle",
364
352
  ancestry: "shadcn",
365
353
  whenToUse:
366
- "Alterne um estado diretamente no contexto atual. ToggleGroup reúne escolhas únicas ou múltiplas e compartilha aparência e escala entre os itens. Para um valor booleano com rótulo em formulário, prefira Switch ou Checkbox.",
354
+ 'Alterne um estado diretamente no contexto atual. ToggleGroup reúne escolhas únicas ou múltiplas com a mesma gramática estrutural de ButtonGroup; `mode="segmented"` mantém alternativas curtas visíveis numa superfície compartilhada, enquanto `variant` trata cada Toggle. Para navegar entre painéis, use Tabs.',
367
355
  },
368
356
 
369
357
  confirm: {
@@ -388,13 +376,13 @@ export const componentMeta = {
388
376
  name: "action-form-card",
389
377
  ancestry: "opus",
390
378
  whenToUse:
391
- "Apresente uma FormAction como seção delimitada da página. ActionFormCard combina a estrutura do Card com o comportamento de ActionForm. Para um modal, use ActionFormDialog; sem superfície adicional, use ActionForm.",
379
+ "Apresente uma FormAction como seção delimitada da página. ActionFormCard combina a estrutura de uma Surface elevada com o comportamento de ActionForm. Para um modal, use ActionFormDialog; sem superfície adicional, use ActionForm.",
392
380
  },
393
381
  "action-list": {
394
382
  name: "action-list",
395
383
  ancestry: "opus",
396
384
  whenToUse:
397
- "Apresente uma coleção pesquisável a partir de uma ListAction. O contrato declara colunas, filtros, período e busca; ActionList coordena visualizações, paginação, seleção em lote e sincronização com a URL. A tabela derivada não cria moldura própria. Em uma página, envolva a lista com Content para manter título e criação junto da coleção. Para um único recurso, use ActionView.",
385
+ "Apresente uma coleção pesquisável a partir de uma ListAction. O contrato declara colunas, filtros, período e busca; ActionList coordena visualizações, paginação, seleção em lote e sincronização com a URL. A tabela derivada não cria moldura própria. Em uma página, envolva a lista com Surface para manter título e criação junto da coleção. Para um único recurso, use ActionView.",
398
386
  },
399
387
  "action-trigger": {
400
388
  name: "action-trigger",
@@ -412,13 +400,13 @@ export const componentMeta = {
412
400
  name: "page",
413
401
  ancestry: "opus",
414
402
  whenToUse:
415
- "Estruture uma página com cabeçalho, estado e conteúdo na mesma anatomia. Page renderiza o `<main>` com um container centralizado de até 80rem (`max-w-7xl`); PageHeader reúne navegação contextual opcional, título e ações, enquanto PageBody recebe o conteúdo. PageShell pode fornecer navegação e ações conhecidas pela aplicação sem criar uma barra visual. Uma região interna pode fornecer o heading principal com Content. PageActionsTarget fica restrito a workspaces imersivos. PageState substitui o header e o conteúdo durante carregamento, falha ou ausência, por isso informações indispensáveis entram no corpo, como Alert ou texto. Para uma falha parcial, mantenha o estado na seção com DataState; para uma região disponível para criação ou vínculo, use Empty.",
403
+ "Estruture uma página com cabeçalho, estado e conteúdo na mesma anatomia. Page renderiza o `<main>` com um container centralizado de até 80rem (`max-w-7xl`); PageHeader reúne navegação contextual opcional, título e ações, enquanto PageBody recebe o conteúdo. PageShell pode fornecer navegação e ações conhecidas pela aplicação sem criar uma barra visual. Uma região interna pode fornecer o heading principal com `Surface level={1}`. PageActionsTarget fica restrito a workspaces imersivos. PageState substitui o header e o conteúdo durante carregamento, falha ou ausência, por isso informações indispensáveis entram no corpo, como Alert ou texto. Para uma falha parcial, mantenha o estado na seção com DataState; para uma região disponível para criação ou vínculo, use Empty.",
416
404
  },
417
405
  presentation: {
418
406
  name: "presentation",
419
407
  ancestry: "opus",
420
408
  whenToUse:
421
- "Descreva um recurso que precisa manter a mesma anatomia ao aparecer como Page, Dialog ou Drawer. Presentation organiza navegação, título, ações de cabeçalho, body e ações de rodapé sem redefinir o conteúdo para cada superfície. Em Page de listagem, Content mantém título e ações junto do ActionList; modais usam o header da superfície. Route pode declarar URL, surface e origem do registro para deep links. A definição persistente liga esses slots a actions Opus; a invocação carrega o estado JSON da execução. PresentationInspector abre um snapshot isolado, enquanto a Lens apresenta o inventário estático projetado no manifest.",
409
+ "Descreva um recurso que precisa manter a mesma anatomia ao aparecer como Page, Dialog ou Drawer. Presentation organiza navegação, título, ações de cabeçalho, body e ações de rodapé sem redefinir o conteúdo para cada superfície. Em Page de listagem, Surface mantém título e ações junto do ActionList; modais usam o header da superfície. Route pode declarar URL, surface e origem do registro para deep links. A definição persistente liga esses slots a actions Opus; a invocação carrega o estado JSON da execução. PresentationInspector abre um snapshot isolado, enquanto a Lens apresenta o inventário estático projetado no manifest.",
422
410
  },
423
411
  router: {
424
412
  name: "router",