@softize/opus 23.0.0 → 24.0.1

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 (97) hide show
  1. package/CHANGELOG.md +99 -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/docs/adr/0049-inline-navigation-groups-with-the-heading.md +95 -0
  16. package/package.json +5 -1
  17. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  18. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  19. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  20. package/registry/templates/app/src/App.tsx +24 -12
  21. package/src/ui/appearance.ts +149 -0
  22. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  23. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  24. package/src/ui/components/patterns/confirm.tsx +3 -1
  25. package/src/ui/components/patterns/fill-region.ts +8 -7
  26. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  27. package/src/ui/components/patterns/form.tsx +8 -7
  28. package/src/ui/components/patterns/list.tsx +15 -19
  29. package/src/ui/components/patterns/page.tsx +15 -11
  30. package/src/ui/components/patterns/pane.tsx +9 -240
  31. package/src/ui/components/patterns/presentation.tsx +14 -14
  32. package/src/ui/components/patterns/sidebar.tsx +7 -17
  33. package/src/ui/components/patterns/split.tsx +111 -59
  34. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +90 -27
  35. package/src/ui/components/patterns/structural-media.tsx +26 -0
  36. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  37. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  38. package/src/ui/components/patterns/surface.tsx +620 -0
  39. package/src/ui/components/patterns/trigger.tsx +11 -9
  40. package/src/ui/components/primitives/ask.tsx +155 -83
  41. package/src/ui/components/primitives/button.tsx +11 -9
  42. package/src/ui/components/primitives/checkbox.tsx +8 -8
  43. package/src/ui/components/primitives/command.tsx +1 -1
  44. package/src/ui/components/primitives/composer.tsx +47 -45
  45. package/src/ui/components/primitives/detail.tsx +9 -2
  46. package/src/ui/components/primitives/dialog.tsx +60 -26
  47. package/src/ui/components/primitives/drawer.tsx +58 -12
  48. package/src/ui/components/primitives/icon-picker.tsx +90 -73
  49. package/src/ui/components/primitives/input-group.tsx +35 -28
  50. package/src/ui/components/primitives/input-otp.tsx +16 -16
  51. package/src/ui/components/primitives/input.tsx +20 -10
  52. package/src/ui/components/primitives/item.tsx +9 -12
  53. package/src/ui/components/primitives/menu.tsx +44 -65
  54. package/src/ui/components/primitives/metric-card.tsx +75 -59
  55. package/src/ui/components/primitives/radio-group.tsx +9 -9
  56. package/src/ui/components/primitives/select.tsx +344 -209
  57. package/src/ui/components/primitives/switch.tsx +10 -10
  58. package/src/ui/components/primitives/table.tsx +29 -24
  59. package/src/ui/components/primitives/tabs.tsx +4 -2
  60. package/src/ui/components/primitives/textarea.tsx +6 -6
  61. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  62. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  63. package/src/ui/components/primitives/toggle.tsx +82 -16
  64. package/src/ui/docs/DocBrowser.tsx +77 -69
  65. package/src/ui/docs/content/action-form-card.md +9 -9
  66. package/src/ui/docs/content/action-list.md +76 -70
  67. package/src/ui/docs/content/action-view.md +33 -18
  68. package/src/ui/docs/content/button.md +1 -1
  69. package/src/ui/docs/content/communication.md +1 -1
  70. package/src/ui/docs/content/compositions.md +479 -0
  71. package/src/ui/docs/content/customization.md +64 -11
  72. package/src/ui/docs/content/detail.md +8 -6
  73. package/src/ui/docs/content/dialog.md +105 -60
  74. package/src/ui/docs/content/drawer.md +68 -22
  75. package/src/ui/docs/content/field.md +14 -15
  76. package/src/ui/docs/content/icon-tile.md +14 -13
  77. package/src/ui/docs/content/page.md +44 -34
  78. package/src/ui/docs/content/pane.md +42 -290
  79. package/src/ui/docs/content/presentation.md +3 -3
  80. package/src/ui/docs/content/separator.md +6 -6
  81. package/src/ui/docs/content/sidebar.md +68 -266
  82. package/src/ui/docs/content/skeleton.md +20 -18
  83. package/src/ui/docs/content/split.md +14 -14
  84. package/src/ui/docs/content/surface.md +513 -0
  85. package/src/ui/docs/content/tabs.md +20 -20
  86. package/src/ui/docs/content/toggle.md +117 -65
  87. package/src/ui/docs/content/tokens.md +124 -40
  88. package/src/ui/docs/registry.tsx +10 -6
  89. package/src/ui/index.ts +30 -27
  90. package/src/ui/meta.ts +15 -27
  91. package/src/ui/react.tsx +40 -43
  92. package/src/ui/theme.css +311 -64
  93. package/src/ui/components/patterns/content-header.tsx +0 -528
  94. package/src/ui/components/patterns/surface-media.tsx +0 -69
  95. package/src/ui/components/primitives/card.tsx +0 -191
  96. package/src/ui/docs/content/card.md +0 -292
  97. package/src/ui/docs/content/content.md +0 -275
@@ -0,0 +1,71 @@
1
+ # ADR 0047 — Pane é somente uma unidade de layout do Split
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-01.
5
+ - **Substitui:** a anatomia visual de Pane definida pela ADR 0023.
6
+ - **Complementa:** ADR 0045, quanto ao uso de `Surface` para regiões visuais.
7
+
8
+ ## Contexto
9
+
10
+ `Pane` nasceu como filho dimensionável de `Split`, mas passou a acumular uma segunda função:
11
+ oferecia header, body, footer, título, mídia, ações, divisores, inset e ritmo visual. Essa anatomia
12
+ duplicava `Surface` e fazia a aparência de uma coluna depender do componente que deveria apenas
13
+ distribuir espaço.
14
+
15
+ A duplicação ficou visível quando uma mesma região precisou funcionar em página, drawer e split.
16
+ Alterar o ritmo de todos os bodies incluiu `PaneBody`, embora a decisão vigente já fosse deixar a
17
+ superfície visual a cargo de `Surface` ou de um pattern especializado, como `Sidebar`.
18
+
19
+ ## Decisão
20
+
21
+ `Pane` representa exclusivamente uma unidade espacial aceita por `Split`. Sua API pública mantém:
22
+
23
+ - `id`, para identidade e persistência do layout;
24
+ - `initialSize`, `minSize` e `maxSize`, para dimensionamento;
25
+ - `grow`, para ocupar o espaço remanescente;
26
+ - `className` e `children`, para o contêiner de layout.
27
+
28
+ `Pane` não aplica inset, fundo, borda, raio, divisor, orientação interna, rolagem nem anatomia de
29
+ conteúdo. As propriedades `inset` e `layout` são removidas.
30
+
31
+ Os slots `PaneHeader`, `PaneBody`, `PaneContent`, `PaneFooter`, `PaneMedia`, `PaneTitle`,
32
+ `PaneDescription` e `PaneActions` também são removidos, sem aliases de compatibilidade. O único
33
+ consumidor é migrado atomicamente:
34
+
35
+ - regiões visuais usam `Surface` e seus slots;
36
+ - barras laterais podem usar a anatomia própria de `Sidebar` ou conteúdo nativo;
37
+ - rolagem e organização interna pertencem ao filho inserido no pane.
38
+
39
+ ## Consequências
40
+
41
+ - `Pane` e `Split` passam a ter uma única responsabilidade observável: distribuir espaço.
42
+ - `Surface` volta a ser a fonte única da anatomia visual compartilhada.
43
+ - Uma região que precise de rolagem deve declará-la no seu conteúdo ou na superfície interna.
44
+ - Consumidores antigos deixam de compilar até substituir os slots e remover `inset`/`layout`.
45
+ - O checker orienta a migração de símbolos e propriedades removidos.
46
+
47
+ ## Alternativas descartadas
48
+
49
+ ### Manter apenas PaneBody
50
+
51
+ Ainda deixaria `Pane` responsável por rolagem e ritmo, criando uma anatomia parcial concorrente com
52
+ `SurfaceBody` e com o conteúdo especializado.
53
+
54
+ ### Manter os slots como aliases de Surface
55
+
56
+ Preservaria dois vocabulários para a mesma estrutura e permitiria que novas telas voltassem a
57
+ escolher a família errada. Como há um único consumidor migrado junto, a compatibilidade não traz
58
+ benefício proporcional.
59
+
60
+ ### Incorporar a superfície visual ao Split
61
+
62
+ Misturaria distribuição espacial com apresentação e impediria panes deliberadamente invisíveis.
63
+
64
+ ## Verificação
65
+
66
+ - a API pública exporta apenas `Pane`, `PaneProps` e `PaneSize` dessa família;
67
+ - `Pane` não contém tokens de padding, borda, fundo, raio, overflow ou anatomia flex interna;
68
+ - `Split` continua aceitando somente `Pane` como filho e preserva dimensionamento e resize;
69
+ - `opus check` reprova símbolos antigos e as propriedades removidas;
70
+ - busca nos consumidores não encontra nenhum slot `Pane*`, `inset` ou `layout` aplicado a `Pane`;
71
+ - testes renderizados cobrem split fixo, redimensionável e superfícies migradas.
@@ -0,0 +1,34 @@
1
+ # ADR 0048 — A intro do modal separa conteúdo e chrome
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-01.
5
+ - **Complementa:** ADR 0018, quanto à separação entre nome e contexto; ADR 0023, quanto à anatomia
6
+ de Dialog e Drawer; ADR 0046, quanto ao ritmo governado pelo body.
7
+
8
+ ## Contexto
9
+
10
+ Dialog e Drawer podem ter uma barra persistente com navegação, ação global e fechamento. Nessa
11
+ composição, colocar o título do recurso na barra mistura chrome e conteúdo; colocá-lo solto no body
12
+ preserva o desenho, mas viola a anatomia pública e não nomeia a relação entre título e ações locais.
13
+
14
+ ## Decisão
15
+
16
+ `DialogIntro` e `DrawerIntro` são o primeiro bloco opcional de seus respectivos bodies. Cada intro
17
+ exige o título da família e aceita `DialogIntroActions` ou `DrawerIntroActions`. O título usa a
18
+ escala principal da composição; o body continua responsável por inset, ritmo e rolagem.
19
+
20
+ As ações do header continuam sendo chrome da superfície. As ações da intro se relacionam ao título
21
+ e ao conteúdo. No formato compacto, título e ações permanecem no header e a intro é omitida.
22
+
23
+ ## Consequências
24
+
25
+ - o mesmo conteúdo funciona em layout contínuo ou seccionado sem título órfão;
26
+ - o fechamento e a navegação podem permanecer estáveis enquanto a intro rola com o conteúdo;
27
+ - o checker exige intro dentro do body, título dentro de header ou intro e ações no slot correto;
28
+ - exemplos e consumidores deixam de reconstruir a linha com `div` e classes locais.
29
+
30
+ ## Verificação
31
+
32
+ - testes renderizados cobrem os dois modais e a escala do título;
33
+ - `opus check` aceita as composições canônicas e reprova intro órfã ou sem título;
34
+ - a documentação mostra as formas com intro e compacta.
@@ -0,0 +1,95 @@
1
+ # ADR 0049 — Navegação inline forma um grupo com o título
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-01.
5
+ - **Complementa:** ADR 0023, quanto à linha do cabeçalho de Dialog e Drawer; ADR 0048, quanto ao
6
+ formato compacto, com título no cabeçalho.
7
+
8
+ ## Contexto
9
+
10
+ `StructuralHeader` monta a linha do cabeçalho de Dialog e Drawer com `leadingPlacement="inline"`:
11
+ a navegação abre a linha, a mídia e o título vêm em seguida, as ações ficam no extremo oposto e o
12
+ botão de fechar entra por último. Até a 24.0.0, todos esses nós eram irmãos da mesma linha flex e
13
+ recebiam o intervalo da família, que em Dialog e Drawer é `gap-4` (`1rem`).
14
+
15
+ Com um único intervalo, o voltar ou o recolher ficava tão longe do título quanto o título ficava das
16
+ ações. A proximidade não distinguia duas relações diferentes:
17
+
18
+ - a navegação diz de onde a pessoa veio ou como sair da etapa, e só faz sentido lida junto com o
19
+ nome da tela;
20
+ - as ações operam sobre a superfície inteira e formam um conjunto próprio, com ritmo interno denso
21
+ (`gap-2`, de `surfaceActionsClasses`).
22
+
23
+ A ADR 0048 fixou a escolha entre a intro e o formato compacto, em que o título permanece no
24
+ cabeçalho. A página **Composições** passou a mostrar as duas formas lado a lado, e a distância
25
+ uniforme ficou evidente no Drawer compacto, em que o recolher do painel precede o título.
26
+
27
+ ## Decisão
28
+
29
+ Quando um `StructuralHeader` com `leadingPlacement="inline"` recebe navegação e heading, os dois
30
+ formam um grupo contextual:
31
+
32
+ 1. Um wrapper `data-slot="<slot>-header-context"` (`flex min-w-0 flex-1 items-center gap-2`)
33
+ envolve a navegação e o heading. O intervalo entre eles passa a ser `gap-2` (`0.5rem`).
34
+ 2. As ações e o botão de fechar ficam fora do grupo, como irmãos dele na linha
35
+ `<slot>-header-row`, e continuam separados pelo intervalo da família (`gap-4` em Dialog e
36
+ Drawer). O ritmo interno das ações não muda.
37
+ 3. O grupo ocupa a folga (`flex-1`) e aceita compressão (`min-w-0`): as ações seguem no extremo
38
+ oposto e o título continua truncando quando não cabe.
39
+ 4. Quando há mídia na linha, mídia e heading ficam num wrapper `<slot>-header-identity` que
40
+ reaplica as classes da linha da família. A aproximação vale só entre a navegação e o que vem
41
+ depois dela; a distância da mídia até o título é a mesma que existiria sem navegação.
42
+ 5. A navegação mantém `data-slot="<slot>-navigation"` dentro do grupo, para que seletores de teste
43
+ e de CSS continuem encontrando a região nas duas apresentações.
44
+
45
+ Fora dessa combinação nada muda: navegação sem título, título sem navegação,
46
+ `leadingPlacement="above"`, usado pela `Page`, e o `Dialog mode="alert"`, cujo cabeçalho não usa
47
+ `StructuralHeader`. `SurfaceHeader` não tem slot de navegação, e as intros (`DialogIntro` e
48
+ `DrawerIntro`) também não aceitam navegação.
49
+
50
+ A regra mora uma vez em `structural-header.tsx`. Dialog e Drawer a herdam porque ambos declaram
51
+ `leadingPlacement="inline"` e passam `surfaceHeaderRowClasses(family)` como `rowClassName`; nenhuma
52
+ família reescreve o intervalo.
53
+
54
+ ## Consequências
55
+
56
+ - Em Dialog e Drawer com navegação e título, o voltar ou o recolher passa de `1rem` para `0.5rem`
57
+ do título, ou da mídia que o antecede. Ações e fechamento mantêm a mesma posição e o mesmo ritmo.
58
+ - A API pública não muda, por isso a correção sai como patch (24.0.1).
59
+ - O DOM ganha um nível nessa combinação, e dois quando há mídia: a navegação deixa de ser filha
60
+ direta de `<slot>-header-row` e passa a ser filha de `<slot>-header-context`. Seletores que
61
+ dependiam do filho direto, como `[data-slot=drawer-header-row] > [data-slot=drawer-navigation]`,
62
+ precisam usar o descendente; seletores pelo `data-slot` da navegação continuam válidos.
63
+ - Uma família nova que adote a linha inline com navegação herda o agrupamento sem configuração. Se
64
+ ela precisar de outra relação entre navegação e título, a mudança é desta ADR, não de um
65
+ `className` local.
66
+
67
+ ## Alternativas consideradas
68
+
69
+ - **Manter o intervalo único.** Não exige mudança, mas iguala duas relações diferentes.
70
+ Descartada.
71
+ - **Reduzir o intervalo da linha inteira para `gap-2`.** Aproximaria navegação e título, mas também
72
+ aproximaria as ações do título, que é a separação que a família precisa preservar. Descartada.
73
+ - **Ajustar a margem no slot de navegação de cada família**, por exemplo com `-mr-2` em
74
+ `DrawerNavigation`. Resolveria o caso visual, mas espalharia uma decisão da anatomia
75
+ compartilhada por cada família e dependeria de compensar um intervalo que continua existindo.
76
+ Descartada.
77
+ - **Levar a navegação para dentro do wrapper do heading.** Misturaria uma região de controle com o
78
+ bloco que nomeia a superfície e mudaria o elemento que absorve a compressão do título. Descartada.
79
+ - **Aplicar `gap-2` também entre mídia e título quando houver navegação.** Seria mais simples, mas
80
+ faria a distância da mídia até o título depender da presença da navegação. Descartada.
81
+ - **Expor o intervalo como propriedade pública.** Abriria uma variação por consumidor para uma
82
+ relação que deve ser igual em todas as superfícies. Descartada.
83
+
84
+ ## Verificação
85
+
86
+ - `tests/ui/drawer.test.tsx` fixa a regra no Drawer: com navegação, título e ações, a linha mantém
87
+ `gap-4`, o grupo `drawer-header-context` usa `gap-2` e contém o título, `drawer-actions` mantém
88
+ `gap-2` e não há wrapper de identidade sem mídia. Com mídia, `drawer-header-identity` fica dentro
89
+ do grupo, mantém `gap-4` e reúne mídia e título, sem a navegação.
90
+ - `tests/ui/dialog.test.tsx` cobre a mesma regra no Dialog com navegação, mídia, título e ações,
91
+ inclusive as ações como filhas diretas da linha, fora do grupo.
92
+ - Os testes existentes cobrem as combinações que não mudam: navegação e ações sem título continuam
93
+ na mesma linha, e ações isoladas continuam no extremo final.
94
+ - Na página **UI > Design > Composições**, o Drawer compacto exibe o recolher e o título no
95
+ cabeçalho e serve de verificação visual da regra.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "23.0.0",
3
+ "version": "24.0.1",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -205,6 +205,10 @@
205
205
  "types": "./src/ui/meta.ts",
206
206
  "default": "./src/ui/meta.ts"
207
207
  },
208
+ "./ui/appearance": {
209
+ "types": "./src/ui/appearance.ts",
210
+ "default": "./src/ui/appearance.ts"
211
+ },
208
212
  "./ui/docs": {
209
213
  "types": "./src/ui/docs/index.ts",
210
214
  "default": "./src/ui/docs/index.ts"
@@ -46,26 +46,26 @@ existentes, mantendo navegação observável e componentes reutilizáveis sem la
46
46
  8. Compor superfícies pela gramática estrutural do catálogo: `PageHeader` é a única introdução
47
47
  estrutural e reúne navegação contextual opcional, título e ações; `PageBody` contém o conteúdo.
48
48
  Na composição explícita, declare exatamente um heading principal com `PageTitle` no header,
49
- `Content level={1}` ou um `PageState` ativo no body. `PageShell` pode fornecer navegação e ações
50
- conhecidas pela aplicação, mas não cria uma barra visual. `Content` contém `ContentHeader` e `ContentBody`; Card,
51
- Drawer e Pane usam seus
52
- respectivos `*Body`. Para o caso direto, usar a sintaxe abreviada de `Page` (`title`,
53
- `actions`) ou de `Content` (`title`, `description`, `actions` e `count`); não misturá-la com o
49
+ `Surface level={1}` ou um `PageState` ativo no body. `PageShell` pode fornecer navegação e ações
50
+ conhecidas pela aplicação, mas não cria uma barra visual. `Surface` contém `SurfaceHeader` e
51
+ `SurfaceBody`; Drawer e Pane usam seus respectivos `*Body`. Para o caso direto, usar a sintaxe
52
+ abreviada de `Page` (`title`, `actions`) ou de `Surface` (`title`, `description`, `actions` e
53
+ `count`); não misturá-la com o
54
54
  header explícito. O título da página não carrega contador. Ajustar o
55
55
  nível do heading pela hierarquia semântica, não pelo destaque visual. O `Page` mantém seu teto
56
56
  centralizado padrão de `80rem`.
57
- Em `Content framed`, usar `layout="sectioned"` quando o filho direto já controlar o recuo do
58
- body, como `DetailGroup` com divisores, lista ou tabela; não reconstruir divisor, padding ou
59
- recorte por classes no consumidor. `frameVariant="legend"` continua sendo um acabamento da
60
- borda contínua e não combina com o layout seccionado.
57
+ Em `Surface framed`, usar `layout="sectioned"` e `SurfaceBody flush` quando o filho direto já
58
+ controlar o recuo do body, como `DetailGroup` com divisores, lista ou tabela; não reconstruir
59
+ divisor, padding ou recorte por classes no consumidor. Em grupos de formulário,
60
+ `as="fieldset"` transforma o header em `legend` nativa sem variante visual paralela.
61
61
  9. Manter margem e posicionamento no consumidor; componente reutilizável controla apenas
62
62
  seu interior.
63
63
  10. Não definir a fonte raiz em uma biblioteca ou componente. O navegador e a aplicação são
64
64
  responsáveis por `font-size` em `html`; medidas escaláveis da UI usam `rem` ou a escala
65
65
  relativa do Tailwind. Reservar `px` a hairlines e compensações presas à geometria da borda,
66
66
  com justificativa e cobertura explícitas.
67
- 11. Em uma página centrada em coleção, compor `Content + ActionList`: título e criação ficam em
68
- extremos opostos do `ContentHeader`; a criação usa botão textual `default`, sem ícone, com
67
+ 11. Em uma página centrada em coleção, compor `Surface + ActionList`: título e criação ficam em
68
+ extremos opostos do `SurfaceHeader`; a criação usa botão textual `default`, sem ícone, com
69
69
  `Criar recurso`. Busca, filtros, atualização, estados e paginação permanecem no `ActionList`.
70
70
  Reservar `toolbarActions` a operações ligadas ao recorte atual.
71
71
  12. Evoluir um pattern compartilhado apenas quando a recorrência e o contrato estiverem claros.
@@ -6,8 +6,8 @@
6
6
  - Execução estrutural: montar uma página de relatório isolada com `Page > PageHeader + PageBody`,
7
7
  outra em `PageShell` com navegação contextual acima de `Page > PageHeader + PageBody`, garantindo
8
8
  exatamente um `PageTitle` no header, e uma coleção nomeada com
9
- `Content > ContentHeader + ContentBody > ActionList`. Provar que criação fica em
10
- `ContentActions`, no extremo oposto ao título, como botão textual `default` sem ícone e com
9
+ `Surface > SurfaceHeader + SurfaceBody > ActionList`. Provar que criação fica em
10
+ `SurfaceActions`, no extremo oposto ao título, como botão textual `default` sem ícone e com
11
11
  `Criar recurso`, enquanto uma operação dependente do recorte usa `toolbarActions`; incluir
12
12
  `ActionFilterBar` separado do renderer, `ItemGroup` para
13
13
  uma coleção secundária e um `ActionFormDialog`; provar teto padrão de `80rem`, hierarquia por
@@ -18,13 +18,14 @@
18
18
  - Execução roteável: declarar Page, criação e edição com `Presentation.route`; provar deep link,
19
19
  hidratação do registro, fechamento para a lista e projeção integral no manifest.
20
20
  - Execução abreviada: montar outra página com `<Page title actions>` e uma seção com
21
- `<Content title description actions>`, provando que ambas produzem a mesma anatomia e que o lint
21
+ `<Surface title description actions>`, provando que ambas produzem a mesma anatomia e que o lint
22
22
  rejeita a mistura entre props abreviadas e headers explícitos.
23
- - Execução de forma: compor uma tabela dentro de Card sem moldura duplicada, manter a moldura
23
+ - Execução de forma: compor uma tabela dentro de `Surface` sem moldura duplicada, manter a moldura
24
24
  estrutural standalone em `rounded-lg` e os controles internos em `rounded-md`.
25
- - Execução de Content seccionado: compor `Content framed layout="sectioned"` com mídia e
26
- `DetailGroup` com divisores; provar header com divisor, body sem recuo e ausência de classes no
27
- consumidor que reconstruam essa anatomia. Reprovar a combinação com `frameVariant="legend"`.
25
+ - Execução de Surface seccionada: compor `Surface framed layout="sectioned"` com mídia e
26
+ `DetailGroup` com divisores; provar header com divisor, `SurfaceBody flush` sem recuo e ausência
27
+ de classes no consumidor que reconstruam essa anatomia. Provar `as="fieldset"` com `legend`
28
+ nativa sem `frameVariant`.
28
29
  - Reprova: criar uma casca `bg-card` que herda o texto global ou introduzir `rounded-widget`
29
30
  quando a escala `rounded-*` atende ao papel.
30
31
  - Reprova: trocar automaticamente o radius de um controle quando apenas seu tamanho muda ou
@@ -32,8 +33,8 @@
32
33
  - Reprova: reconstruir manualmente o container de página, usar `Empty` tracejado como vazio de
33
34
  tabela, alinhar toda coluna numérica à direita por inferência ou destacar “Cancelar” como ação
34
35
  primária em modal.
35
- - Reprova: deixar `ContentHeader` fora de `Content`, omitir `PageBody`/`ContentBody` na composição
36
- explícita, usar `CardContent`/`PaneContent` em código novo ou tratar `DialogContent` como Body.
36
+ - Reprova: deixar `SurfaceHeader` fora de `Surface`, omitir `PageBody`/`SurfaceBody` na composição
37
+ explícita, usar `Card*` ou `Content*` em código novo ou tratar `DialogContent` como Body.
37
38
  - Execução de dicionários: montar a lista de clientes com “Pessoa física/Empresa” e
38
39
  “Prospect/Cliente”; provar que o tipo ocupa a coluna “Tipo” como classificação (badge `outline`
39
40
  com os ícones declarados `user` e `building`), que o estágio ocupa a coluna “Estágio” como badge
@@ -9,7 +9,7 @@
9
9
  lista; não reconstruir essa decisão com condicionais no componente da página.
10
10
  - `Page` fornece o `<main>` e o container centralizado com teto padrão de `80rem`. Sua forma
11
11
  explícita exige `PageBody`, exatamente um heading principal e aceita `PageHeader` e `PageFooter`.
12
- Declare esse heading com `PageTitle` no header, `Content level={1}` ou um `PageState` ativo no
12
+ Declare esse heading com `PageTitle` no header, `Surface level={1}` ou um `PageState` ativo no
13
13
  body. `PageHeader` é a única introdução estrutural e reúne navegação, título e ações. `title` e `actions` no
14
14
  próprio `Page` são a abreviação para o caso direto.
15
15
  Não misturar as duas formas. Alterar `className` apenas quando a superfície tiver uma necessidade
@@ -26,8 +26,8 @@
26
26
  ações somente com ícone de chrome usam `icon`. O tamanho `sm` fica para ações operacionais em
27
27
  toolbar, seção ou coleção densa, e `xs`/`icon-xs` para ações internas de linha ou célula. Contexto
28
28
  e variante resolvem a hierarquia visual sem alterar essa medida. Em uma coleção, a ação de
29
- criar usa botão textual `default`, sem ícone, no extremo oposto do `ContentHeader`. Não monte
30
- `PaneHeader`, portal ou seletor global para reconstruir essa composição.
29
+ criar usa botão textual `default`, sem ícone, no extremo oposto do `SurfaceHeader`. Não monte
30
+ cabeçalho paralelo, portal ou seletor global para reconstruir essa composição.
31
31
  - `PageState` substitui todo o conteúdo principal quando a página carrega, falha ou está vazia. Na
32
32
  forma curta ele pode ser escrito como filho direto de `Page`, que cria o `PageBody`; na forma
33
33
  explícita, fica sozinho dentro de `PageBody`. Nos três estados ativos, o cabeçalho da Page
@@ -37,22 +37,23 @@
37
37
  `role="alert"`, usa a mesma composição central e sem moldura dos demais estados e apresenta a
38
38
  recuperação como botão `outline` textual. Estados de seção ou coleção continuam em `DataState`,
39
39
  `ActionView`, `ActionList` ou `Alert`; não elevar uma falha parcial a estado da página.
40
- - `Content` delimita uma seção e segue a mesma anatomia: `Content > ContentHeader (ContentTitle,
41
- ContentDescription, ContentMeta, ContentActions) + ContentBody`. `ContentHeader` nunca fica
42
- solto. `title`, `description`, `count` e `actions` no `Content` são a abreviação para o caso
43
- direto e não podem ser misturados ao header explícito. Na variante `page`, ações da coleção ficam
44
- no extremo oposto ao título; a criação usa botão textual `default`, sem ícone, com `Criar recurso`.
40
+ - `Surface` delimita uma região e segue a anatomia `Surface > SurfaceHeader (SurfaceMedia,
41
+ SurfaceTitle, SurfaceDescription, SurfaceMeta, SurfaceActions) + SurfaceBody + SurfaceFooter`.
42
+ Os slots nunca ficam soltos. `title`, `description`, `count`, `media` e `actions` são a abreviação
43
+ para o caso direto e não podem ser misturados ao header explícito. Em `level={1}`, ações da
44
+ coleção ficam no extremo oposto ao título; a criação usa botão textual `default`, sem ícone, com
45
+ `Criar recurso`.
45
46
  O diálogo de criação usa `Criar recurso` e o de edição, `Editar recurso`. `level` preserva a
46
- hierarquia semântica do heading. `Content framed` aceita `layout="sectioned"` quando o filho
47
- direto já possui recuo interno, como `DetailGroup` com divisores, lista ou tabela: o header recebe
48
- divisor e o body fica sem padding. `frameVariant="legend"` trata a borda contínua e não combina
49
- com esse layout.
50
- - Uma página centrada em coleção compõe `Content + ActionList`. O `ContentHeader` recebe título e
47
+ hierarquia semântica do heading. `Surface framed` aceita `layout="sectioned"`; declare
48
+ `SurfaceBody flush` quando o filho direto já possui recuo interno, como `DetailGroup` com
49
+ divisores, lista ou tabela. `as="fieldset"` cria a `legend` nativa sem variante de moldura.
50
+ - Uma página centrada em coleção compõe `Surface + ActionList`. O `SurfaceHeader` recebe título e
51
51
  criação; o `ActionList` mantém busca, filtros, atualização, resultados, estados e paginação.
52
52
  `toolbarActions` fica reservado a operações que dependem do recorte atual.
53
- - Card, Drawer e Pane nomeiam a região principal como `CardBody`, `DrawerBody` e `PaneBody`.
54
- `*Content` permanece reservado a raízes técnicas ou painéis cujo papel não é o corpo de uma
55
- estrutura, como `DialogContent`, `PopoverContent` e `TabsPanel`.
53
+ - `Surface` e Drawer nomeiam a região principal como `SurfaceBody` e `DrawerBody`. `Pane` é apenas
54
+ uma unidade dimensionável de `Split` e não possui anatomia interna. `*Content` permanece
55
+ reservado a raízes técnicas ou painéis cujo papel não é o corpo de uma estrutura, como
56
+ `DialogContent`, `PopoverContent` e `TabsPanel`.
56
57
  - Componentes compartilhados não impõem margem externa; páginas e shells compõem layout.
57
58
  - A UI assume layout desktop fixo e largura mínima suportada de `64rem`. Não introduzir
58
59
  breakpoints de viewport ou container queries isoladas; uma futura responsividade precisa começar
@@ -1,8 +1,13 @@
1
- import type { z } from 'zod'
2
- import { Badge, Card, DataState, useListAction } from '@softize/opus/ui/react'
3
- import { Task, taskList } from './domains/tasks/actions/list.ts'
1
+ import type { z } from "zod";
2
+ import {
3
+ Badge,
4
+ DataState,
5
+ Surface,
6
+ useListAction,
7
+ } from "@softize/opus/ui/react";
8
+ import { Task, taskList } from "./domains/tasks/actions/list.ts";
4
9
 
5
- type TaskItem = z.infer<typeof Task>
10
+ type TaskItem = z.infer<typeof Task>;
6
11
 
7
12
  /** Página inicial do esqueleto: prova a fundação (tema + componentes do Opus) e o
8
13
  * domínio-exemplo. Troque tudo — este arquivo é seu. */
@@ -10,14 +15,16 @@ export function App(): React.ReactElement {
10
15
  // O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
11
16
  // serve o backend no próprio dev server: dev normal executa o handler real;
12
17
  // `pnpm dev:design` responde com dado gerado a partir do contrato, isolado de qualquer backend.
13
- const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
18
+ const { items, isLoading, error, refetch } =
19
+ useListAction<TaskItem>(taskList);
14
20
 
15
21
  return (
16
22
  <main className="flex min-h-full items-center justify-center bg-background p-6">
17
- <Card className="w-full max-w-md p-6">
18
- <h1 className="mb-1 text-lg font-semibold">{'{{APP_NAME}}'}</h1>
23
+ <Surface framed elevation="raised" className="w-full max-w-md p-6">
24
+ <h1 className="mb-1 text-lg font-semibold">{"{{APP_NAME}}"}</h1>
19
25
  <p className="mb-4 text-sm text-muted-foreground">
20
- Esqueleto criado pelo opus create. A spec vive nas declarações do domínio.
26
+ Esqueleto criado pelo opus create. A spec vive nas declarações do
27
+ domínio.
21
28
  </p>
22
29
  {/* Carregando, erro e vazio saem do DataState; a lista só cuida dos itens. */}
23
30
  <DataState
@@ -29,14 +36,19 @@ export function App(): React.ReactElement {
29
36
  >
30
37
  <ul className="space-y-2">
31
38
  {items.map((t) => (
32
- <li key={t.id} className="flex items-center justify-between gap-2 text-sm">
39
+ <li
40
+ key={t.id}
41
+ className="flex items-center justify-between gap-2 text-sm"
42
+ >
33
43
  <span>{t.title}</span>
34
- <Badge context={t.done ? 'success' : 'neutral'}>{t.done ? 'Feita' : 'Aberta'}</Badge>
44
+ <Badge context={t.done ? "success" : "neutral"}>
45
+ {t.done ? "Feita" : "Aberta"}
46
+ </Badge>
35
47
  </li>
36
48
  ))}
37
49
  </ul>
38
50
  </DataState>
39
- </Card>
51
+ </Surface>
40
52
  </main>
41
- )
53
+ );
42
54
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @softize/opus/ui/appearance — personalização de aparência sem React (ADR 0044).
3
+ *
4
+ * O tema deriva a escala neutra de quatro eixos inteiros (0 a 100) declarados por modo em
5
+ * `theme.css`: `--appearance-light-*` e `--appearance-dark-*`. Este módulo publica os tipos, os
6
+ * presets oficiais e o helper que converte overrides parciais nas custom properties que o
7
+ * consumidor aplica onde quiser (em geral, inline em `<html>`).
8
+ *
9
+ * Reset não pertence ao Opus UI: um eixo sem override vira `null`, e o consumidor remove a
10
+ * propriedade para voltar ao cascade — ou seja, ao preset do tema ou ao CSS do próprio app.
11
+ * Persistência e interface de ajuste também ficam com o consumidor.
12
+ */
13
+
14
+ export type AppearanceMode = "light" | "dark";
15
+
16
+ export const appearanceModes = [
17
+ "light",
18
+ "dark",
19
+ ] as const satisfies readonly AppearanceMode[];
20
+
21
+ export type AppearanceAxis =
22
+ "colorIntensity" | "contrast" | "surfaceBrightness" | "layerContrast";
23
+
24
+ /** Ordem canônica dos eixos: a mesma usada em documentação, presets e custom properties. */
25
+ export const appearanceAxes = [
26
+ "colorIntensity",
27
+ "contrast",
28
+ "surfaceBrightness",
29
+ "layerContrast",
30
+ ] as const satisfies readonly AppearanceAxis[];
31
+
32
+ /** Valor completo de um modo: todo eixo é um inteiro entre `APPEARANCE_MIN` e `APPEARANCE_MAX`. */
33
+ export type AppearanceSettings = Readonly<Record<AppearanceAxis, number>>;
34
+
35
+ /** Override parcial: eixo ausente (ou não finito) segue o cascade em vez de ser reescrito. */
36
+ export type AppearanceOverrides = Partial<
37
+ Record<AppearanceAxis, number | null | undefined>
38
+ >;
39
+
40
+ export const APPEARANCE_MIN = 0;
41
+ export const APPEARANCE_MAX = 100;
42
+
43
+ /**
44
+ * Presets oficiais por modo — reproduzem exatamente os valores do tema sem personalização.
45
+ * Intensidade, contraste e contraste entre camadas partem de zero: o preset é o piso, e o ajuste
46
+ * só acrescenta cor ou separação. No claro, a superfície elevada já é branca; o brilho parte do
47
+ * máximo e só pode diminuir. No escuro, o brilho fica no meio para permitir um escuro mais
48
+ * profundo ou mais suave.
49
+ */
50
+ export const appearancePresets: Readonly<
51
+ Record<AppearanceMode, AppearanceSettings>
52
+ > = {
53
+ light: {
54
+ colorIntensity: 0,
55
+ contrast: 0,
56
+ surfaceBrightness: 100,
57
+ layerContrast: 0,
58
+ },
59
+ dark: {
60
+ colorIntensity: 0,
61
+ contrast: 0,
62
+ surfaceBrightness: 50,
63
+ layerContrast: 0,
64
+ },
65
+ };
66
+
67
+ const AXIS_PROPERTY: Record<AppearanceAxis, string> = {
68
+ colorIntensity: "color-intensity",
69
+ contrast: "contrast",
70
+ surfaceBrightness: "surface-brightness",
71
+ layerContrast: "layer-contrast",
72
+ };
73
+
74
+ export type AppearanceCustomProperty =
75
+ `--appearance-${AppearanceMode}-${string}`;
76
+
77
+ /** Nome da custom property que `theme.css` lê para um eixo de um modo. */
78
+ export function appearanceCustomProperty(
79
+ mode: AppearanceMode,
80
+ axis: AppearanceAxis,
81
+ ): AppearanceCustomProperty {
82
+ return `--appearance-${mode}-${AXIS_PROPERTY[axis]}`;
83
+ }
84
+
85
+ /**
86
+ * Normaliza um valor de eixo: arredonda para inteiro e limita ao intervalo. Retorna `undefined`
87
+ * para o que não é um número finito — o consumidor pode ter lido um valor corrompido de
88
+ * persistência, e a resposta correta é seguir o cascade, não travar a interface.
89
+ */
90
+ export function normalizeAppearanceValue(value: unknown): number | undefined {
91
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
92
+ return Math.min(APPEARANCE_MAX, Math.max(APPEARANCE_MIN, Math.round(value)));
93
+ }
94
+
95
+ /** Preset do modo com os overrides válidos aplicados por cima. */
96
+ export function resolveAppearance(
97
+ mode: AppearanceMode,
98
+ overrides: AppearanceOverrides = {},
99
+ ): AppearanceSettings {
100
+ const preset = appearancePresets[mode];
101
+ const resolved = { ...preset } as Record<AppearanceAxis, number>;
102
+ for (const axis of appearanceAxes) {
103
+ const value = normalizeAppearanceValue(overrides[axis]);
104
+ if (value !== undefined) resolved[axis] = value;
105
+ }
106
+ return resolved;
107
+ }
108
+
109
+ /**
110
+ * Converte overrides parciais nas custom properties do modo. Todo eixo aparece no resultado:
111
+ * com valor normalizado quando há override e `null` quando não há, para o consumidor remover a
112
+ * propriedade e deixar o cascade valer. Assim, o mesmo mapa serve para aplicar e para desfazer.
113
+ */
114
+ export function appearanceCustomProperties(
115
+ mode: AppearanceMode,
116
+ overrides: AppearanceOverrides = {},
117
+ ): Record<AppearanceCustomProperty, string | null> {
118
+ const properties = {} as Record<AppearanceCustomProperty, string | null>;
119
+ for (const axis of appearanceAxes) {
120
+ const value = normalizeAppearanceValue(overrides[axis]);
121
+ properties[appearanceCustomProperty(mode, axis)] =
122
+ value === undefined ? null : String(value);
123
+ }
124
+ return properties;
125
+ }
126
+
127
+ /** Alvo mínimo para aplicar propriedades: `CSSStyleDeclaration` satisfaz, e um stub de teste também. */
128
+ export interface AppearanceStyleTarget {
129
+ setProperty(name: string, value: string): void;
130
+ removeProperty(name: string): string | void;
131
+ }
132
+
133
+ /**
134
+ * Aplica os overrides de um modo num alvo de estilo. Eixos sem override são removidos, então
135
+ * chamar sem overrides devolve o elemento ao cascade — é o reset, feito pelo consumidor, sem
136
+ * estado no Opus.
137
+ */
138
+ export function applyAppearanceCustomProperties(
139
+ target: AppearanceStyleTarget,
140
+ mode: AppearanceMode,
141
+ overrides: AppearanceOverrides = {},
142
+ ): void {
143
+ for (const [name, value] of Object.entries(
144
+ appearanceCustomProperties(mode, overrides),
145
+ )) {
146
+ if (value === null) target.removeProperty(name);
147
+ else target.setProperty(name, value);
148
+ }
149
+ }