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