@softize/opus 23.0.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +241 -42
  4. package/bin/lib/copy.mjs +16 -34
  5. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +6 -0
  6. package/docs/adr/0039-filter-presentation-is-independent-from-filter-type.md +3 -3
  7. package/docs/adr/0040-tabs-use-a-single-navigation-presentation.md +5 -6
  8. package/docs/adr/0042-toggle-group-shares-the-grouped-control-grammar.md +79 -0
  9. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +73 -0
  10. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +133 -0
  11. package/docs/adr/0045-surface-replaces-content-and-card.md +230 -0
  12. package/docs/adr/0046-structural-body-owns-content-rhythm.md +103 -0
  13. package/docs/adr/0047-pane-is-only-a-split-layout-unit.md +71 -0
  14. package/docs/adr/0048-modal-intro-separates-content-from-chrome.md +34 -0
  15. package/package.json +5 -1
  16. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  17. package/registry/skills/build-opus-ui/references/evaluations.md +10 -9
  18. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -16
  19. package/registry/templates/app/src/App.tsx +24 -12
  20. package/src/ui/appearance.ts +149 -0
  21. package/src/ui/components/patterns/action-form-card.tsx +46 -33
  22. package/src/ui/components/patterns/action-list-dialog.tsx +2 -4
  23. package/src/ui/components/patterns/confirm.tsx +3 -1
  24. package/src/ui/components/patterns/fill-region.ts +8 -7
  25. package/src/ui/components/patterns/form-dialog.tsx +1 -3
  26. package/src/ui/components/patterns/form.tsx +8 -7
  27. package/src/ui/components/patterns/list.tsx +15 -19
  28. package/src/ui/components/patterns/page.tsx +15 -11
  29. package/src/ui/components/patterns/pane.tsx +9 -240
  30. package/src/ui/components/patterns/presentation.tsx +14 -14
  31. package/src/ui/components/patterns/sidebar.tsx +7 -17
  32. package/src/ui/components/patterns/split.tsx +111 -59
  33. package/src/ui/components/patterns/{surface-header.tsx → structural-header.tsx} +50 -19
  34. package/src/ui/components/patterns/structural-media.tsx +26 -0
  35. package/src/ui/components/patterns/surface-anatomy.ts +71 -61
  36. package/src/ui/components/patterns/surface-assistant.tsx +2 -4
  37. package/src/ui/components/patterns/surface.tsx +620 -0
  38. package/src/ui/components/patterns/trigger.tsx +11 -9
  39. package/src/ui/components/primitives/ask.tsx +155 -83
  40. package/src/ui/components/primitives/button.tsx +11 -9
  41. package/src/ui/components/primitives/checkbox.tsx +8 -8
  42. package/src/ui/components/primitives/command.tsx +1 -1
  43. package/src/ui/components/primitives/composer.tsx +47 -45
  44. package/src/ui/components/primitives/detail.tsx +9 -2
  45. package/src/ui/components/primitives/dialog.tsx +60 -26
  46. package/src/ui/components/primitives/drawer.tsx +58 -12
  47. package/src/ui/components/primitives/icon-picker.tsx +90 -73
  48. package/src/ui/components/primitives/input-group.tsx +35 -28
  49. package/src/ui/components/primitives/input-otp.tsx +16 -16
  50. package/src/ui/components/primitives/input.tsx +20 -10
  51. package/src/ui/components/primitives/item.tsx +9 -12
  52. package/src/ui/components/primitives/menu.tsx +44 -65
  53. package/src/ui/components/primitives/metric-card.tsx +75 -59
  54. package/src/ui/components/primitives/radio-group.tsx +9 -9
  55. package/src/ui/components/primitives/select.tsx +344 -209
  56. package/src/ui/components/primitives/switch.tsx +10 -10
  57. package/src/ui/components/primitives/table.tsx +29 -24
  58. package/src/ui/components/primitives/tabs.tsx +4 -2
  59. package/src/ui/components/primitives/textarea.tsx +6 -6
  60. package/src/ui/components/primitives/toggle-group-context.ts +24 -0
  61. package/src/ui/components/primitives/toggle-group.tsx +116 -74
  62. package/src/ui/components/primitives/toggle.tsx +82 -16
  63. package/src/ui/docs/DocBrowser.tsx +77 -69
  64. package/src/ui/docs/content/action-form-card.md +9 -9
  65. package/src/ui/docs/content/action-list.md +76 -70
  66. package/src/ui/docs/content/action-view.md +33 -18
  67. package/src/ui/docs/content/button.md +1 -1
  68. package/src/ui/docs/content/communication.md +1 -1
  69. package/src/ui/docs/content/customization.md +64 -11
  70. package/src/ui/docs/content/detail.md +8 -6
  71. package/src/ui/docs/content/dialog.md +100 -58
  72. package/src/ui/docs/content/drawer.md +63 -19
  73. package/src/ui/docs/content/field.md +14 -15
  74. package/src/ui/docs/content/icon-tile.md +14 -13
  75. package/src/ui/docs/content/page.md +34 -26
  76. package/src/ui/docs/content/pane.md +41 -290
  77. package/src/ui/docs/content/presentation.md +3 -3
  78. package/src/ui/docs/content/separator.md +6 -6
  79. package/src/ui/docs/content/sidebar.md +68 -266
  80. package/src/ui/docs/content/skeleton.md +20 -18
  81. package/src/ui/docs/content/split.md +14 -14
  82. package/src/ui/docs/content/surface.md +513 -0
  83. package/src/ui/docs/content/tabs.md +20 -20
  84. package/src/ui/docs/content/toggle.md +117 -65
  85. package/src/ui/docs/content/tokens.md +124 -40
  86. package/src/ui/docs/registry.tsx +4 -6
  87. package/src/ui/index.ts +30 -27
  88. package/src/ui/meta.ts +15 -27
  89. package/src/ui/react.tsx +40 -43
  90. package/src/ui/theme.css +311 -64
  91. package/src/ui/components/patterns/content-header.tsx +0 -528
  92. package/src/ui/components/patterns/surface-media.tsx +0 -69
  93. package/src/ui/components/primitives/card.tsx +0 -191
  94. package/src/ui/docs/content/card.md +0 -292
  95. package/src/ui/docs/content/content.md +0 -275
@@ -12,40 +12,39 @@
12
12
  * Nos dois, navegar emite `popstate`: host que guarda o path por conta própria acompanha.
13
13
  * `basePath` adapta o prefixo das URLs (`/docs` no Maestro, `/__docs` nos apps).
14
14
  */
15
- import { useEffect, useState } from 'react'
16
- import { DOC_SECTIONS, type DocSection, type DocEntry } from './registry'
17
- import { Pane, Split } from '../components/patterns/split.tsx'
15
+ import { useEffect, useState } from "react";
16
+ import { DOC_SECTIONS, type DocSection, type DocEntry } from "./registry";
17
+ import { Pane, Split } from "../components/patterns/split.tsx";
18
18
  import {
19
- PaneBody,
20
19
  Sidebar,
21
20
  SidebarGroupLabel,
22
21
  SidebarItem,
23
22
  SidebarTreeGroup,
24
- } from '../components/patterns/sidebar.tsx'
25
- import { navigate, usePathname } from '../router.ts'
23
+ } from "../components/patterns/sidebar.tsx";
24
+ import { navigate, usePathname } from "../router.ts";
26
25
 
27
26
  function findPage(sections: DocSection[], slug: string): DocEntry | undefined {
28
27
  for (const s of sections) {
29
28
  for (const g of s.groups) {
30
- const p = g.pages.find((p) => p.slug === slug)
31
- if (p) return p
29
+ const p = g.pages.find((p) => p.slug === slug);
30
+ if (p) return p;
32
31
  }
33
32
  }
34
- return undefined
33
+ return undefined;
35
34
  }
36
35
 
37
36
  export interface DocBrowserProps {
38
37
  /** Prefixo das rotas da doc. Default `/docs` (Maestro); `/__docs` na rota dev dos apps. */
39
- basePath?: string
38
+ basePath?: string;
40
39
  /** Path controlado pelo host (modo Maestro). Ausente = standalone (gerencia o próprio). */
41
- path?: string
40
+ path?: string;
42
41
  /** Seções a exibir (seção → grupo → página). Default = catálogo inteiro. */
43
- sections?: DocSection[]
42
+ sections?: DocSection[];
44
43
  /** Slugs antigos que devem abrir e substituir a URL pela página canônica. */
45
- redirects?: Readonly<Record<string, string>>
44
+ redirects?: Readonly<Record<string, string>>;
46
45
  /** Monta o `document.title` a partir do título da página atual (ex.: `(t) => `${t} · Opus``).
47
46
  * Ausente = não mexe no title. */
48
- documentTitle?: (pageTitle: string) => string
47
+ documentTitle?: (pageTitle: string) => string;
49
48
  }
50
49
 
51
50
  function DocSidebarTree({
@@ -53,67 +52,75 @@ function DocSidebarTree({
53
52
  activeSlug,
54
53
  onSelect,
55
54
  }: {
56
- sections: DocSection[]
57
- activeSlug?: string
58
- onSelect: (slug: string) => void
55
+ sections: DocSection[];
56
+ activeSlug?: string;
57
+ onSelect: (slug: string) => void;
59
58
  }): React.ReactElement {
60
- const [closedGroups, setClosedGroups] = useState<Set<string>>(() => new Set())
59
+ const [closedGroups, setClosedGroups] = useState<Set<string>>(
60
+ () => new Set(),
61
+ );
61
62
 
62
63
  useEffect(() => {
63
- const activeGroup = sections.flatMap((section, sectionIndex) =>
64
- section.groups.map((group, groupIndex) => ({
65
- key: `${sectionIndex}:${groupIndex}`,
66
- group,
67
- })),
68
- ).find(({ group }) => group.pages.some((page) => page.slug === activeSlug))
64
+ const activeGroup = sections
65
+ .flatMap((section, sectionIndex) =>
66
+ section.groups.map((group, groupIndex) => ({
67
+ key: `${sectionIndex}:${groupIndex}`,
68
+ group,
69
+ })),
70
+ )
71
+ .find(({ group }) =>
72
+ group.pages.some((page) => page.slug === activeSlug),
73
+ );
69
74
 
70
- if (activeGroup === undefined) return
75
+ if (activeGroup === undefined) return;
71
76
  setClosedGroups((current) => {
72
- if (!current.has(activeGroup.key)) return current
73
- const next = new Set(current)
74
- next.delete(activeGroup.key)
75
- return next
76
- })
77
- }, [activeSlug, sections])
77
+ if (!current.has(activeGroup.key)) return current;
78
+ const next = new Set(current);
79
+ next.delete(activeGroup.key);
80
+ return next;
81
+ });
82
+ }, [activeSlug, sections]);
78
83
 
79
84
  const toggleGroup = (key: string): void => {
80
85
  setClosedGroups((current) => {
81
- const next = new Set(current)
82
- if (next.has(key)) next.delete(key)
83
- else next.add(key)
84
- return next
85
- })
86
- }
86
+ const next = new Set(current);
87
+ if (next.has(key)) next.delete(key);
88
+ else next.add(key);
89
+ return next;
90
+ });
91
+ };
87
92
 
88
93
  const renderPage = (page: DocEntry): React.ReactElement => (
89
94
  <SidebarItem
90
95
  key={page.slug}
91
96
  label={page.title}
92
- badge={page.badge === undefined ? undefined : (
93
- <span className="rounded border border-border/60 px-1 font-mono text-[0.625rem] leading-tight text-muted-foreground/60">
94
- {page.badge}
95
- </span>
96
- )}
97
+ badge={
98
+ page.badge === undefined ? undefined : (
99
+ <span className="rounded border border-border/60 px-1 font-mono text-[0.625rem] leading-tight text-muted-foreground/60">
100
+ {page.badge}
101
+ </span>
102
+ )
103
+ }
97
104
  active={page.slug === activeSlug}
98
105
  onClick={() => onSelect(page.slug)}
99
106
  />
100
- )
107
+ );
101
108
 
102
109
  return (
103
110
  <nav aria-label="Navegação da documentação" className="space-y-4 p-2">
104
111
  {sections.map((section, sectionIndex) => (
105
112
  <div key={`${section.label}:${sectionIndex}`} className="space-y-0.5">
106
- {section.label.trim() !== '' && (
113
+ {section.label.trim() !== "" && (
107
114
  <SidebarGroupLabel>{section.label}</SidebarGroupLabel>
108
115
  )}
109
116
  {section.groups.map((group, groupIndex) => {
110
- const key = `${sectionIndex}:${groupIndex}`
111
- if (group.pages.length === 0) return null
112
- if ((group.label ?? '').trim() === '') {
113
- return group.pages.map((page) => renderPage(page))
117
+ const key = `${sectionIndex}:${groupIndex}`;
118
+ if (group.pages.length === 0) return null;
119
+ if ((group.label ?? "").trim() === "") {
120
+ return group.pages.map((page) => renderPage(page));
114
121
  }
115
122
 
116
- const expanded = !closedGroups.has(key)
123
+ const expanded = !closedGroups.has(key);
117
124
  return (
118
125
  <div key={key} className="space-y-0.5">
119
126
  <SidebarItem
@@ -129,16 +136,16 @@ function DocSidebarTree({
129
136
  </SidebarTreeGroup>
130
137
  )}
131
138
  </div>
132
- )
139
+ );
133
140
  })}
134
141
  </div>
135
142
  ))}
136
143
  </nav>
137
- )
144
+ );
138
145
  }
139
146
 
140
147
  export function DocBrowser({
141
- basePath = '/docs',
148
+ basePath = "/docs",
142
149
  path: controlledPath,
143
150
  sections = DOC_SECTIONS,
144
151
  redirects = {},
@@ -147,46 +154,47 @@ export function DocBrowser({
147
154
  // Standalone: o path vem do router da casa (`usePathname` — pathname É o estado).
148
155
  // Controlado: o host manda, e este valor é ignorado. O hook roda sempre (regra dos
149
156
  // hooks); o que muda é quem vence no `??`.
150
- const livePath = usePathname()
151
- const path = controlledPath ?? livePath
157
+ const livePath = usePathname();
158
+ const path = controlledPath ?? livePath;
152
159
  // Default = 1ª página do 1º grupo da 1ª seção (a doc abre na 1ª página).
153
- const defaultSlug = sections[0]?.groups[0]?.pages[0]?.slug ?? ''
160
+ const defaultSlug = sections[0]?.groups[0]?.pages[0]?.slug ?? "";
154
161
  // Slug = TODOS os segmentos após o basePath, unidos (rota aninhada: `/docs/guias/deploy`
155
162
  // → `guias/deploy`). Slug flat de 1 segmento continua igual (`join` de 1 elemento = ele).
156
- const rel = path.startsWith(basePath) ? path.slice(basePath.length) : ''
157
- const requestedSlug = rel.split('/').filter(Boolean).join('/') || defaultSlug
158
- const slug = redirects[requestedSlug] ?? requestedSlug
159
- const page = findPage(sections, slug) ?? findPage(sections, defaultSlug)
163
+ const rel = path.startsWith(basePath) ? path.slice(basePath.length) : "";
164
+ const requestedSlug = rel.split("/").filter(Boolean).join("/") || defaultSlug;
165
+ const slug = redirects[requestedSlug] ?? requestedSlug;
166
+ const page = findPage(sections, slug) ?? findPage(sections, defaultSlug);
160
167
 
161
168
  // `navigate` emite `popstate`, então o host é notificado nos dois modos — inclusive o
162
169
  // que ainda escuta o evento na unha, que era como este componente avisava antes.
163
- const go = (to: string): void => navigate(to)
170
+ const go = (to: string): void => navigate(to);
164
171
 
165
172
  useEffect(() => {
166
- if (requestedSlug !== slug) navigate(`${basePath}/${slug}`, { replace: true })
167
- }, [basePath, requestedSlug, slug])
173
+ if (requestedSlug !== slug)
174
+ navigate(`${basePath}/${slug}`, { replace: true });
175
+ }, [basePath, requestedSlug, slug]);
168
176
 
169
177
  // O title da aba acompanha a página atual (quando o host fornece o formato).
170
178
  useEffect(() => {
171
- if (page && documentTitle) document.title = documentTitle(page.title)
172
- }, [page, documentTitle])
179
+ if (page && documentTitle) document.title = documentTitle(page.title);
180
+ }, [page, documentTitle]);
173
181
 
174
182
  return (
175
183
  <Split className="h-full">
176
- <Pane initialSize="14rem" inset="none">
184
+ <Pane initialSize="14rem">
177
185
  <Sidebar className="w-full">
178
- <PaneBody>
186
+ <div className="min-h-0 flex-1 overflow-y-auto px-3 py-6">
179
187
  <DocSidebarTree
180
188
  sections={sections}
181
189
  activeSlug={page?.slug}
182
190
  onSelect={(nextSlug) => go(`${basePath}/${nextSlug}`)}
183
191
  />
184
- </PaneBody>
192
+ </div>
185
193
  </Sidebar>
186
194
  </Pane>
187
- <Pane key={page?.slug} grow inset="none" className="overflow-y-auto">
195
+ <Pane key={page?.slug} grow className="overflow-y-auto">
188
196
  <div className="mx-auto max-w-3xl px-8 py-8">{page?.render()}</div>
189
197
  </Pane>
190
198
  </Split>
191
- )
199
+ );
192
200
  }
@@ -1,7 +1,7 @@
1
1
  ## Formulário em uma seção
2
2
 
3
3
  Use `ActionFormCard` quando o formulário ocupar uma seção delimitada da página, ao lado de outros
4
- blocos. O componente aplica a estrutura e o espaçamento de [`Card`](/ui/card) ao
4
+ blocos. O componente aplica a estrutura e o espaçamento de [`Surface`](/ui/surface) ao
5
5
  [`ActionForm`](/ui/action-form), sem a faixa de ações de um modal. O título é opcional e segue a
6
6
  mesma regra de um formulário modal: nomeia a operação e o recurso, como `Criar workspace`, enquanto
7
7
  a ação principal mantém `Salvar`.
@@ -16,7 +16,7 @@ As ações ficam alinhadas no fim do rodapé e preservam a largura do conteúdo.
16
16
  acrescenta `Cancelar`, os dois botões formam um `ButtonGroup` espaçado. Para interromper o fluxo
17
17
  com um modal, use [`ActionFormDialog`](/ui/action-form-dialog).
18
18
 
19
- O título do card é um heading `h3`, o nível padrão de `CardTitle`, adequado a uma seção dentro de
19
+ O título do card é um heading `h3`, o nível padrão de `SurfaceTitle`, adequado a uma seção dentro de
20
20
  uma página que já tem o próprio título. Quando o card ocupar outro lugar na hierarquia — por
21
21
  exemplo, a seção principal logo abaixo do `h1` da página —, declare `titleLevel={2}`. O nível muda
22
22
  só a estrutura do documento, que leitores de tela usam para navegar; a aparência do título continua
@@ -24,11 +24,11 @@ a mesma.
24
24
 
25
25
  ## Propriedades de ActionFormCard
26
26
 
27
- | Propriedade | Tipo | Padrão | Descrição |
28
- | ------------------ | ---------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
29
- | `title` | `string` | | Título do cabeçalho. Sem ele e sem `description`, o card começa diretamente pelo corpo. |
30
- | `titleLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `3` | Nível do heading do título. Muda a hierarquia do documento, não a escala visual. |
31
- | `description` | `string` | | Texto de apoio abaixo do título. |
32
- | `cardClassName` | `string` | | Classes aplicadas à superfície do card. |
33
- | `className` | `string` | | Classes aplicadas ao `<form>` interno. |
27
+ | Propriedade | Tipo | Padrão | Descrição |
28
+ | ------------------- | ---------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | `title` | `string` | | Título do cabeçalho. Sem ele e sem `description`, o card começa diretamente pelo corpo. |
30
+ | `titleLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `3` | Nível do heading do título. Muda a hierarquia do documento, não a escala visual. |
31
+ | `description` | `string` | | Texto de apoio abaixo do título. |
32
+ | `cardClassName` | `string` | | Classes aplicadas à superfície do card. |
33
+ | `className` | `string` | | Classes aplicadas ao `<form>` interno. |
34
34
  | Demais propriedades | Propriedades de `ActionForm` | | `action`, `defaultValues`, `fieldOptions`, `submitLabel`, `cancelLabel`, `cancelVariant`, `onCancel`, `onSuccess`, `disabled`, `onLoadingChange` e `children` mantêm o comportamento de `ActionForm`. `body` e `footer` não são aceitas: o card fornece o corpo e o rodapé. |
@@ -34,18 +34,19 @@ render(
34
34
 
35
35
  ## Coleção nomeada
36
36
 
37
- Em uma página, envolva a listagem com [`Content`](/ui/content). O título e a criação ocupam
37
+ Em uma página, envolva a listagem com [`Surface`](/ui/surface). O título e a criação ocupam
38
38
  extremos opostos do cabeçalho da coleção; busca, filtros e atualização permanecem na toolbar do
39
39
  `ActionList`.
40
40
 
41
41
  ```tsx
42
- <Content
42
+ <Surface
43
+ as="section"
43
44
  title="Workspaces"
44
45
  level={1}
45
46
  actions={<Button>Criar workspace</Button>}
46
47
  >
47
48
  <ActionList action={workspaceList} input={{}} />
48
- </Content>
49
+ </Surface>
49
50
  ```
50
51
 
51
52
  Use `toolbarActions` somente quando a operação depender do recorte visível, como exportar os
@@ -94,12 +95,11 @@ ao modal “Filtros”; `external` mantém o filtro no estado navegável e no in
94
95
  apresentação para o consumidor, como cards de status acima da lista. No modal, filtros com a mesma
95
96
  `section` formam um grupo, e `advancedFilters.columns` distribui os grupos em até três colunas.
96
97
  Use `advancedFilters.sectionIcons` quando os grupos representarem dimensões recorrentes; a chave é
97
- o mesmo rótulo de `section`, e cada grupo usa `Content as="fieldset"` para manter uma legenda
98
- nativa. O ícone ocupa a mídia do grupo, antes do título, e recebe a moldura padrão: passe só o
99
- glifo. Um `IconTile` é adotado pela mídia com o que declara, e um ícone `null` não desenha nada.
100
- Quando os grupos precisarem de limites mais explícitos, `advancedFilters.sectionVariant: 'framed'`
101
- envolve cada seção e pede ao `Content` o acabamento `frameVariant="legend"`, integrando visualmente
102
- a legenda à borda sem atribuir esse design à semântica de `fieldset`.
98
+ o mesmo rótulo de `section`, e cada grupo usa `Surface as="fieldset"` para manter uma legenda
99
+ nativa. O ícone ocupa a mídia do grupo antes do título. Passe um `IconTile` quando quiser moldura;
100
+ um glifo solto permanece sem decoração, e um ícone `null` não desenha nada. Quando os grupos
101
+ precisarem de limites mais explícitos, `advancedFilters.sectionVariant: 'framed'` acrescenta a
102
+ borda da `Surface` sem mudar a semântica do `fieldset`.
103
103
  Um filtro `select` pode declarar `searchable: true` quando a lista fechada for longa; a opção geral
104
104
  continua disponível e participa da busca. Campos de data avançados usam calendário. A antiga `advanced: true` continua aceita durante a
105
105
  migração e equivale a `placement: 'advanced'`.
@@ -229,7 +229,7 @@ Por padrão, a lista cresce com os resultados e a rolagem acontece na janela ou
229
229
  hospeda. Quando a coleção é a região principal de uma tela de altura fixa, a barra de filtros e o
230
230
  rodapé podem continuar visíveis enquanto só os resultados rolam. Para isso, declare
231
231
  `layout="fill"` na [`Page`](/ui/page) e componha a lista com `PageBody` e
232
- [`Content`](/ui/content), como no exemplo abaixo. Role a tabela para ver o cabeçalho fixo.
232
+ [`Surface`](/ui/surface), como no exemplo abaixo. Role a tabela para ver o cabeçalho fixo.
233
233
 
234
234
  ```tsx preview col
235
235
  render(
@@ -237,17 +237,17 @@ render(
237
237
  <div className="h-80 w-full overflow-x-auto overflow-y-hidden rounded-lg border border-border">
238
238
  <Page layout="fill">
239
239
  <PageBody>
240
- <Content level={1}>
241
- <ContentHeader>
242
- <ContentTitle>Workspaces</ContentTitle>
243
- <ContentActions>
240
+ <Surface as="section" level={1}>
241
+ <SurfaceHeader>
242
+ <SurfaceTitle>Workspaces</SurfaceTitle>
243
+ <SurfaceActions>
244
244
  <Button>Criar workspace</Button>
245
- </ContentActions>
246
- </ContentHeader>
247
- <ContentBody>
245
+ </SurfaceActions>
246
+ </SurfaceHeader>
247
+ <SurfaceBody>
248
248
  <ActionList action={docWorkspaceList} input={{}} />
249
- </ContentBody>
250
- </Content>
249
+ </SurfaceBody>
250
+ </Surface>
251
251
  </PageBody>
252
252
  </Page>
253
253
  </div>
@@ -256,7 +256,7 @@ render(
256
256
  ```
257
257
 
258
258
  A página limita a própria altura à do contêiner que a hospeda, normalmente o shell, e `PageBody`,
259
- `Content` e `ContentBody` repassam essa altura até a lista, que passa a usar `layout="fill"` sem que
259
+ `Surface` e `SurfaceBody` repassam essa altura até a lista, que passa a usar `layout="fill"` sem que
260
260
  você o declare. Em uma [`Presentation`](/ui/presentation) de lista, basta `layout="fill"` no
261
261
  próprio `Presentation`. Só as regiões que contêm a lista mudam: uma seção irmã, como um resumo acima
262
262
  da coleção, mantém a altura do próprio conteúdo. Os demais filhos de cada região que repassa a
@@ -269,16 +269,16 @@ rola, sobre a cor da superfície e com uma linha inferior, e a moldura dos estad
269
269
  erro e vazio ocupa a altura disponível. Em outras visualizações e em `children`, a rolagem interna
270
270
  continua, mas não existe cabeçalho fixo.
271
271
 
272
- A lista só assume `fill` sozinha quando é filha direta de `PageBody`, `ContentBody` ou do corpo de
272
+ A lista só assume `fill` sozinha quando é filha direta de `PageBody`, `SurfaceBody` ou do corpo de
273
273
  uma `Presentation`, e a altura só chega até ela se não houver um contêiner próprio entre esses
274
274
  componentes no caminho. Nos demais casos, a decisão é sua:
275
275
 
276
276
  - Para a lista manter a altura dos resultados, como em uma seção secundária cuja rolagem pertence ao
277
277
  `PageBody`, declare `layout="flow"`.
278
- - Um contêiner próprio entre `ContentBody` e a lista, como um `TabsPanel`, não repassa a altura, e
278
+ - Um contêiner próprio entre `SurfaceBody` e a lista, como um `TabsPanel`, não repassa a altura, e
279
279
  a lista volta a `flow`. Declare `layout="fill"` na lista e dê ao contêiner as classes
280
280
  `flex min-h-0 flex-1 flex-col`.
281
- - Dentro de [`Card`](/ui/card), [`Dialog`](/ui/dialog), [`Drawer`](/ui/drawer) ou
281
+ - Dentro de [`Surface`](/ui/surface), [`Dialog`](/ui/dialog), [`Drawer`](/ui/drawer) ou
282
282
  [`Pane`](/ui/pane), a lista não herda o `fill` da página: a altura do corpo pertence à
283
283
  superfície.
284
284
 
@@ -335,17 +335,20 @@ render(
335
335
  render: (workspaces: Workspace[]) => (
336
336
  <div className="grid grid-cols-2 gap-3">
337
337
  {workspaces.map((w) => (
338
- <Card key={w.id}>
339
- <CardHeader>
340
- <CardTitle>{w.name}</CardTitle>
341
- <CardDescription>
338
+ <Surface framed elevation="raised" key={w.id}>
339
+ <SurfaceHeader>
340
+ <SurfaceTitle>{w.name}</SurfaceTitle>
341
+ <SurfaceDescription>
342
342
  {w.agents === 1 ? "1 agente" : `${w.agents} agentes`}
343
- </CardDescription>
344
- <CardAction>
345
- <DictionaryValue dict={docWorkspaceStatus} value={w.status} />
346
- </CardAction>
347
- </CardHeader>
348
- </Card>
343
+ </SurfaceDescription>
344
+ <SurfaceActions>
345
+ <DictionaryValue
346
+ dict={docWorkspaceStatus}
347
+ value={w.status}
348
+ />
349
+ </SurfaceActions>
350
+ </SurfaceHeader>
351
+ </Surface>
349
352
  ))}
350
353
  </div>
351
354
  ),
@@ -364,8 +367,8 @@ render(
364
367
  - **`depends: ['outroFiltro']`:** desabilita o filtro até que suas dependências tenham valor e o
365
368
  limpa quando uma delas muda.
366
369
  - **`presentation: 'toggle'`:** mantém visíveis as opções curtas de um `select` de escolha única;
367
- estado, URL e input da action continuam iguais aos do controle padrão. Entradas de dicionário
368
- com `icon` apresentam o ícone junto ao rótulo.
370
+ o `ToggleGroup` usa `mode="segmented"`, enquanto estado, URL e input da action continuam iguais
371
+ aos do controle padrão. Entradas de dicionário com `icon` apresentam o ícone junto ao rótulo.
369
372
  - **`options: { kind: 'dictionary', ref: 'sessionStatus' }`:** resolve opções e rótulos por um
370
373
  dicionário registrado no `OpusProvider`. `filterOptions` substitui essa fonte quando informado.
371
374
  - **`options: { kind: 'lookup', source: 'x.lookup' }`:** consulta uma action com `{ q }` e os valores
@@ -442,7 +445,10 @@ render(
442
445
  <ItemDescription>Cliente {w.client}.</ItemDescription>
443
446
  </ItemHeader>
444
447
  <ItemActions>
445
- <DictionaryValue dict={docWorkspaceStatus} value={w.status} />
448
+ <DictionaryValue
449
+ dict={docWorkspaceStatus}
450
+ value={w.status}
451
+ />
446
452
  </ItemActions>
447
453
  </Item>
448
454
  </React.Fragment>
@@ -475,13 +481,13 @@ render(
475
481
 
476
482
  ## Declaração na ListAction
477
483
 
478
- | Chave | O que declara |
479
- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
480
- | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` nasce oculta e pode ser exibida pelo seletor de colunas; `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
484
+ | Chave | O que declara |
485
+ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
486
+ | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` nasce oculta e pode ser exibida pelo seletor de colunas; `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
481
487
  | `filters` | `{ [nome]: { label, type, options?, multiple?, presentation?, searchable?, allowEmpty?, placement?, section?, depends?, … } }` — a toolbar. `placement` classifica o controle como `inline`, `advanced` ou `external`; filtros avançados com `section` são agrupados no diálogo; `external` mantém estado e input, mas delega a apresentação ao consumidor. `presentation: 'toggle'` mostra todas as opções curtas de um `select` de escolha única em um `ToggleGroup`; sem a propriedade, o controle continua sendo `Select`. `searchable` habilita busca local em `select`; `allowEmpty: false` omite a opção vazia. `depends` desabilita/cascateia; options `kind: 'lookup'` busca em uma action. Valor aplicado entra no input com o mesmo nome. |
482
- | `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Ela aparece depois dos filtros inline e imediatamente antes do acesso aos filtros avançados. A busca participa da contagem de controles inline: com mais de um controle, a barra exibe as labels; quando ela é o único recorte, sua label é omitida. |
483
- | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
484
- | `periods` | `{ value, label, default? }[]` — o controle de período (presets + Personalizado com calendário); `default: true` marca o preset inicial, senão vale o primeiro; materializa em `from`/`to` no input. |
488
+ | `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Ela aparece depois dos filtros inline e imediatamente antes do acesso aos filtros avançados. A busca participa da contagem de controles inline: com mais de um controle, a barra exibe as labels; quando ela é o único recorte, sua label é omitida. |
489
+ | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
490
+ | `periods` | `{ value, label, default? }[]` — o controle de período (presets + Personalizado com calendário); `default: true` marca o preset inicial, senão vale o primeiro; materializa em `from`/`to` no input. |
485
491
 
486
492
  ## useListAction
487
493
 
@@ -493,31 +499,31 @@ chamar a action.
493
499
 
494
500
  ## Propriedades de ActionList
495
501
 
496
- | Propriedade | Tipo | Padrão | Descrição |
497
- | ----------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
498
- | `action` | `ListActionLike` | | A ListAction do Opus (kind list, output = item). A paginação envia `limit`/`page` e usa o `total` devolvido. |
499
- | `input` | `TInput` | | O escopo base, como `{ workspaceId }`. A toolbar acrescenta busca, filtros e paginação sem sobrescrever essas chaves. |
500
- | `layout` | `'flow' \| 'fill'` | `'fill'` como filha direta de `PageBody`, `ContentBody` ou do corpo da `Presentation` em uma página `fill`; `'flow'` nos demais casos | Em `fill`, ocupa a altura de um ancestral limitado e rola somente a região de resultados; na tabela, o cabeçalho fica fixo. Sem ancestral limitado, equivale a `flow`. |
501
- | `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
502
- | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para os filtros select/lookup (chave = nome do filtro). |
503
- | `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
504
- | `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
505
- | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
506
- | `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
507
- | `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
508
- | `views` | `Record<string, ActionListView<TItem>>` | | Visualizações alternativas `{ label, icon?, render }`; o seletor alterna entre a tabela e estas. |
509
- | `beforeContent` | `ReactNode` | | Conteúdo entre a toolbar e o corpo, como um resumo que reage ao recorte atual. |
510
- | `columns` | `ActionListColumn<TItem>[]` | | Tabela explícita, que substitui as colunas declaradas no contrato. |
511
- | `children` | `(items, refetch) => ReactNode` | | Modo composição: layout livre; a toolbar segue. Tem precedência sobre columns. |
512
- | `batch` | `ActionListBatchAction[]` | | Ações em lote: `{ label, can?, run, confirm?: { title, message? }, destructive? }` — liga a multi-seleção. |
513
- | `rowId` | `(item) => string` | `item.id` | Identidade da linha para seleção. |
514
- | `pageSize` | `number` | `50` | Itens por página padrão (vira `limit`/`page` no input; handler devolve `total`). O usuário troca no popover de exibição. |
515
- | `state / onStateChange` | `ActionListState / (next) => void` | interno | Estado da toolbar controlado — para quem embala sincronizar com a URL. |
516
- | `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado, por exemplo quando um formulário inline aberto conta como conteúdo. |
517
- | `loading` | `boolean` | | Carga extra agregada à da consulta, como uma query irmã de que os `children` dependem. |
518
- | `emptyMessage` | `string` | `'Nenhum resultado.'` | Frase do estado vazio (o `Empty` do `DataState`). |
519
- | `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
520
- | `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
521
- | `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
522
- | `toolbarActions` | `ReactNode` | | Operações ligadas ao recorte atual, no fim da barra e separadas do grupo de controles. Os nós são renderizados como recebidos: nome acessível e tooltip de uma ação somente com ícone ficam com o consumidor. |
523
- | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram em um grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
502
+ | Propriedade | Tipo | Padrão | Descrição |
503
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
504
+ | `action` | `ListActionLike` | | A ListAction do Opus (kind list, output = item). A paginação envia `limit`/`page` e usa o `total` devolvido. |
505
+ | `input` | `TInput` | | O escopo base, como `{ workspaceId }`. A toolbar acrescenta busca, filtros e paginação sem sobrescrever essas chaves. |
506
+ | `layout` | `'flow' \| 'fill'` | `'fill'` como filha direta de `PageBody`, `SurfaceBody` ou do corpo da `Presentation` em uma página `fill`; `'flow'` nos demais casos | Em `fill`, ocupa a altura de um ancestral limitado e rola somente a região de resultados; na tabela, o cabeçalho fica fixo. Sem ancestral limitado, equivale a `flow`. |
507
+ | `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
508
+ | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para os filtros select/lookup (chave = nome do filtro). |
509
+ | `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
510
+ | `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
511
+ | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
512
+ | `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
513
+ | `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
514
+ | `views` | `Record<string, ActionListView<TItem>>` | | Visualizações alternativas `{ label, icon?, render }`; o seletor alterna entre a tabela e estas. |
515
+ | `beforeContent` | `ReactNode` | | Conteúdo entre a toolbar e o corpo, como um resumo que reage ao recorte atual. |
516
+ | `columns` | `ActionListColumn<TItem>[]` | | Tabela explícita, que substitui as colunas declaradas no contrato. |
517
+ | `children` | `(items, refetch) => ReactNode` | | Modo composição: layout livre; a toolbar segue. Tem precedência sobre columns. |
518
+ | `batch` | `ActionListBatchAction[]` | | Ações em lote: `{ label, can?, run, confirm?: { title, message? }, destructive? }` — liga a multi-seleção. |
519
+ | `rowId` | `(item) => string` | `item.id` | Identidade da linha para seleção. |
520
+ | `pageSize` | `number` | `50` | Itens por página padrão (vira `limit`/`page` no input; handler devolve `total`). O usuário troca no popover de exibição. |
521
+ | `state / onStateChange` | `ActionListState / (next) => void` | interno | Estado da toolbar controlado — para quem embala sincronizar com a URL. |
522
+ | `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado, por exemplo quando um formulário inline aberto conta como conteúdo. |
523
+ | `loading` | `boolean` | | Carga extra agregada à da consulta, como uma query irmã de que os `children` dependem. |
524
+ | `emptyMessage` | `string` | `'Nenhum resultado.'` | Frase do estado vazio (o `Empty` do `DataState`). |
525
+ | `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
526
+ | `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
527
+ | `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
528
+ | `toolbarActions` | `ReactNode` | | Operações ligadas ao recorte atual, no fim da barra e separadas do grupo de controles. Os nós são renderizados como recebidos: nome acessível e tooltip de uma ação somente com ícone ficam com o consumidor. |
529
+ | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram em um grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |