@softize/opus 13.0.0 → 14.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 (164) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/PROMOTED.md +46 -0
  3. package/README.md +28 -19
  4. package/bin/cli.mjs +87 -216
  5. package/bin/lib/cli-shared.mjs +131 -0
  6. package/bin/lib/copy.mjs +276 -6
  7. package/bin/lib/db.mjs +16 -74
  8. package/bin/lib/gen-openapi.mjs +3 -3
  9. package/bin/lib/gen-runner.mjs +1 -1
  10. package/bin/lib/gen.mjs +14 -69
  11. package/bin/lib/mcp.mjs +3 -1
  12. package/bin/lib/seed.mjs +5 -62
  13. package/docs/code-style.md +4 -1
  14. package/docs/ownership-vs-shadcn-lock.md +2 -3
  15. package/docs/protocol.md +7 -7
  16. package/docs/releasing.md +8 -2
  17. package/package.json +7 -3
  18. package/registry/instructions/opus.md +3 -3
  19. package/registry/templates/app/package.json +1 -1
  20. package/registry/templates/app/src/App.tsx +11 -6
  21. package/registry/templates/app/src/main.tsx +4 -4
  22. package/src/audit/drivers/console.ts +1 -0
  23. package/src/auth/drivers/better-auth.ts +1 -0
  24. package/src/auth/drivers/jwt.ts +1 -0
  25. package/src/cache/drivers/memory.ts +1 -0
  26. package/src/client/drivers/fetch.ts +2 -1
  27. package/src/core/actions.ts +6 -1
  28. package/src/core/audit.ts +9 -3
  29. package/src/core/contracts.ts +7 -0
  30. package/src/core/domain.ts +1 -1
  31. package/src/core/errors.ts +18 -15
  32. package/src/core/index.ts +4 -2
  33. package/src/core/package-version.ts +26 -0
  34. package/src/core/reactions.ts +1 -1
  35. package/src/core/runtime.ts +33 -23
  36. package/src/core/schedules.ts +1 -1
  37. package/src/core/types.ts +5 -6
  38. package/src/dsl/eval.ts +2 -2
  39. package/src/dsl/kysely.ts +2 -2
  40. package/src/dsl/loads.ts +1 -1
  41. package/src/dsl/parser.ts +5 -5
  42. package/src/events/drivers/mitt.ts +1 -0
  43. package/src/mcp/index.ts +2 -1
  44. package/src/observability/drivers/opentelemetry.ts +1 -0
  45. package/src/queue/drivers/bullmq.ts +3 -3
  46. package/src/scheduler/drivers/node-cron.ts +3 -2
  47. package/src/scheduler/every.ts +7 -7
  48. package/src/schema/openapi.ts +3 -3
  49. package/src/seed/index.ts +29 -0
  50. package/src/server/drivers/fastify.ts +5 -2
  51. package/src/server/drivers/node.ts +9 -6
  52. package/src/server/index.ts +3 -1
  53. package/src/storage/drivers/fs.ts +1 -0
  54. package/src/testing/index.ts +3 -3
  55. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  56. package/src/ui/components/patterns/confirm.tsx +2 -31
  57. package/src/ui/components/patterns/content-header.tsx +44 -137
  58. package/src/ui/components/patterns/data-state.tsx +42 -68
  59. package/src/ui/components/patterns/dock.tsx +20 -3
  60. package/src/ui/components/patterns/form.tsx +26 -22
  61. package/src/ui/components/patterns/list.tsx +18 -12
  62. package/src/ui/components/patterns/page-state.tsx +39 -51
  63. package/src/ui/components/patterns/page.tsx +37 -54
  64. package/src/ui/components/patterns/sidebar.tsx +17 -6
  65. package/src/ui/components/patterns/state-surface.tsx +148 -0
  66. package/src/ui/components/patterns/surface-header.tsx +119 -0
  67. package/src/ui/components/patterns/trigger.tsx +21 -25
  68. package/src/ui/components/patterns/view.tsx +29 -22
  69. package/src/ui/components/primitives/alert.tsx +1 -27
  70. package/src/ui/components/primitives/ask.tsx +3 -3
  71. package/src/ui/components/primitives/avatar.tsx +15 -5
  72. package/src/ui/components/primitives/badge.tsx +5 -41
  73. package/src/ui/components/primitives/breadcrumb.tsx +2 -2
  74. package/src/ui/components/primitives/button.tsx +39 -30
  75. package/src/ui/components/primitives/calendar.tsx +28 -2
  76. package/src/ui/components/primitives/carousel.tsx +3 -3
  77. package/src/ui/components/primitives/chat.tsx +1 -1
  78. package/src/ui/components/primitives/checkbox.tsx +1 -1
  79. package/src/ui/components/primitives/command.tsx +2 -2
  80. package/src/ui/components/primitives/control.ts +72 -0
  81. package/src/ui/components/primitives/copyable.tsx +1 -1
  82. package/src/ui/components/primitives/dialog.tsx +12 -7
  83. package/src/ui/components/primitives/dot.tsx +1 -25
  84. package/src/ui/components/primitives/drawer.tsx +10 -3
  85. package/src/ui/components/primitives/field.tsx +3 -3
  86. package/src/ui/components/primitives/icon-picker.tsx +3 -1
  87. package/src/ui/components/primitives/input-group.tsx +12 -9
  88. package/src/ui/components/primitives/input-otp.tsx +1 -1
  89. package/src/ui/components/primitives/input.tsx +2 -2
  90. package/src/ui/components/primitives/item.tsx +3 -1
  91. package/src/ui/components/primitives/menu.tsx +1 -7
  92. package/src/ui/components/primitives/pagination.tsx +16 -8
  93. package/src/ui/components/primitives/progress.tsx +32 -3
  94. package/src/ui/components/primitives/radio-group.tsx +1 -1
  95. package/src/ui/components/primitives/resizable.tsx +3 -1
  96. package/src/ui/components/primitives/select.tsx +6 -6
  97. package/src/ui/components/primitives/slider.tsx +5 -1
  98. package/src/ui/components/primitives/sonner.tsx +3 -0
  99. package/src/ui/components/primitives/spinner.tsx +13 -16
  100. package/src/ui/components/primitives/switch.tsx +5 -1
  101. package/src/ui/components/primitives/tabs.tsx +6 -3
  102. package/src/ui/components/primitives/textarea.tsx +1 -1
  103. package/src/ui/components/primitives/toggle.tsx +9 -4
  104. package/src/ui/components/primitives/tooltip.tsx +1 -0
  105. package/src/ui/docs/changelog.tsx +1 -1
  106. package/src/ui/docs/content/action-form.md +36 -3
  107. package/src/ui/docs/content/action-list-dialog.md +2 -2
  108. package/src/ui/docs/content/action-list.md +13 -2
  109. package/src/ui/docs/content/action-trigger.md +12 -5
  110. package/src/ui/docs/content/action-view.md +11 -3
  111. package/src/ui/docs/content/ask.md +11 -0
  112. package/src/ui/docs/content/avatar.md +7 -3
  113. package/src/ui/docs/content/button.md +30 -14
  114. package/src/ui/docs/content/calendar.md +13 -0
  115. package/src/ui/docs/content/card.md +26 -0
  116. package/src/ui/docs/content/chat.md +20 -0
  117. package/src/ui/docs/content/cli.md +71 -19
  118. package/src/ui/docs/content/communication.md +36 -0
  119. package/src/ui/docs/content/composer.md +15 -0
  120. package/src/ui/docs/content/content.md +17 -1
  121. package/src/ui/docs/content/copyable.md +8 -0
  122. package/src/ui/docs/content/data-state.md +17 -13
  123. package/src/ui/docs/content/detail.md +19 -1
  124. package/src/ui/docs/content/dialog.md +1 -4
  125. package/src/ui/docs/content/dictionary-value.md +9 -2
  126. package/src/ui/docs/content/dock.md +8 -0
  127. package/src/ui/docs/content/dot.md +8 -0
  128. package/src/ui/docs/content/empty.md +2 -2
  129. package/src/ui/docs/content/getting-started.md +2 -2
  130. package/src/ui/docs/content/icon-picker.md +11 -0
  131. package/src/ui/docs/content/input.md +1 -1
  132. package/src/ui/docs/content/item.md +1 -1
  133. package/src/ui/docs/content/label.md +7 -0
  134. package/src/ui/docs/content/menu.md +6 -0
  135. package/src/ui/docs/content/metric-card.md +13 -0
  136. package/src/ui/docs/content/page.md +20 -4
  137. package/src/ui/docs/content/pagination.md +11 -9
  138. package/src/ui/docs/content/popover.md +6 -0
  139. package/src/ui/docs/content/progress.md +8 -11
  140. package/src/ui/docs/content/select.md +5 -5
  141. package/src/ui/docs/content/semantic-context.md +5 -4
  142. package/src/ui/docs/content/sidebar.md +13 -47
  143. package/src/ui/docs/content/skeleton.md +6 -0
  144. package/src/ui/docs/content/spinner.md +9 -6
  145. package/src/ui/docs/content/split.md +21 -0
  146. package/src/ui/docs/content/switch.md +1 -1
  147. package/src/ui/docs/content/tabs.md +1 -1
  148. package/src/ui/docs/content/textarea.md +7 -0
  149. package/src/ui/docs/content/toggle.md +1 -1
  150. package/src/ui/docs/content/tokens.md +4 -4
  151. package/src/ui/docs/content/truncate.md +8 -0
  152. package/src/ui/docs/content/ui.md +14 -0
  153. package/src/ui/docs/doc-client.tsx +5 -5
  154. package/src/ui/docs/doc.tsx +26 -14
  155. package/src/ui/docs/registry.tsx +5 -5
  156. package/src/ui/docs/standalone.tsx +2 -2
  157. package/src/ui/drivers/react.tsx +12 -12
  158. package/src/ui/lib/action-errors.ts +45 -0
  159. package/src/ui/lib/zod-pt-br.ts +31 -4
  160. package/src/ui/meta.ts +8 -8
  161. package/src/ui/react.tsx +10 -16
  162. package/src/ui/theme.css +10 -8
  163. package/src/vite/design.ts +6 -18
  164. package/src/ui/components/patterns/shell-nav.tsx +0 -147
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  Children,
3
- Fragment,
4
3
  createContext,
5
4
  isValidElement,
6
5
  useContext,
@@ -10,9 +9,14 @@ import {
10
9
  type ReactNode,
11
10
  } from "react";
12
11
  import { cn } from "../../lib/cn.ts";
12
+ import {
13
+ SurfaceHeader,
14
+ surfaceHeaderClasses,
15
+ type SurfaceHeaderVariant,
16
+ } from "./surface-header.tsx";
13
17
 
14
18
  export type ContentHeaderLevel = 1 | 2 | 3 | 4 | 5 | 6;
15
- export type ContentHeaderVariant = "page" | "section";
19
+ export type ContentHeaderVariant = SurfaceHeaderVariant;
16
20
 
17
21
  interface ContentContextValue {
18
22
  level: ContentHeaderLevel;
@@ -39,14 +43,14 @@ interface ContentBaseProps extends Omit<
39
43
  > {
40
44
  /** Nível semântico do heading, independente de seu destaque visual. */
41
45
  level?: ContentHeaderLevel;
42
- /** Hierarquia visual. `page` permanece somente para compatibilidade durante a versão 12. */
46
+ /** Hierarquia visual: `section` (default) ou `page`, a mesma de PageHeader. */
43
47
  variant?: ContentHeaderVariant;
44
48
  }
45
49
 
46
50
  interface ContentShorthandProps extends ContentBaseProps {
47
51
  title: ReactNode;
48
- /** Conteúdo complementar exibido ao lado do título, como uma contagem. */
49
- meta?: ReactNode;
52
+ /** Total de itens ao lado do título o mesmo `count` de Page. */
53
+ count?: number;
50
54
  description?: ReactNode;
51
55
  actions?: ReactNode;
52
56
  children: ReactNode;
@@ -54,7 +58,7 @@ interface ContentShorthandProps extends ContentBaseProps {
54
58
 
55
59
  interface ContentComposedProps extends ContentBaseProps {
56
60
  title?: never;
57
- meta?: never;
61
+ count?: never;
58
62
  description?: never;
59
63
  actions?: never;
60
64
  children: ReactNode;
@@ -65,7 +69,7 @@ export type ContentProps = ContentShorthandProps | ContentComposedProps;
65
69
  /** Região de conteúdo nomeada. A forma curta e a explícita produzem a mesma anatomia. */
66
70
  export function Content({
67
71
  title,
68
- meta,
72
+ count,
69
73
  description,
70
74
  actions,
71
75
  level = 2,
@@ -119,7 +123,7 @@ export function Content({
119
123
  <>
120
124
  <ContentHeader>
121
125
  <ContentTitle>{title}</ContentTitle>
122
- {meta !== undefined && <ContentMeta>{meta}</ContentMeta>}
126
+ {count !== undefined && <ContentMeta>{count}</ContentMeta>}
123
127
  {description !== undefined && (
124
128
  <ContentDescription>{description}</ContentDescription>
125
129
  )}
@@ -137,128 +141,36 @@ export function Content({
137
141
  );
138
142
  }
139
143
 
140
- interface ContentHeaderShorthandProps extends Omit<
141
- HTMLAttributes<HTMLDivElement>,
142
- "children" | "title"
143
- > {
144
- /** @deprecated Prefira os slots explícitos ou o shorthand de Content. */
145
- title: ReactNode;
146
- meta?: ReactNode;
147
- description?: ReactNode;
148
- actions?: ReactNode;
149
- /** @deprecated Declare o nível em Content. Compatibilidade da série 12. */
150
- level?: ContentHeaderLevel;
151
- /** @deprecated Declare a hierarquia visual em Content. Compatibilidade da série 12. */
152
- variant?: ContentHeaderVariant;
153
- children?: never;
154
- }
155
-
156
- interface ContentHeaderComposedProps extends Omit<
157
- HTMLAttributes<HTMLDivElement>,
158
- "title"
159
- > {
160
- /** @deprecated Declare o nível em Content. Compatibilidade da série 12. */
161
- level?: ContentHeaderLevel;
162
- /** @deprecated Declare a hierarquia visual em Content. Compatibilidade da série 12. */
163
- variant?: ContentHeaderVariant;
164
- title?: never;
165
- meta?: never;
166
- description?: never;
167
- actions?: never;
168
- children: ReactNode;
169
- }
170
-
171
- export type ContentHeaderProps =
172
- ContentHeaderShorthandProps | ContentHeaderComposedProps;
144
+ export type ContentHeaderProps = Omit<HTMLAttributes<HTMLDivElement>, "title">;
173
145
 
174
- /** Header estrutural de Content; não é uma região autônoma. */
146
+ /**
147
+ * Header estrutural de Content; não é uma região autônoma. A anatomia é a de
148
+ * `SurfaceHeader`, a mesma de `PageHeader`: só os nomes dos slots e a hierarquia mudam.
149
+ */
175
150
  export function ContentHeader({
176
- title,
177
- meta,
178
- description,
179
- actions,
180
- level,
181
- variant,
182
151
  children,
183
- className,
184
152
  ...props
185
153
  }: ContentHeaderProps): ReactElement {
186
- const parent = useContext(ContentContext);
187
- const standaloneTitleId = useId();
188
- const shorthand = title !== undefined;
189
- if (parent === null && !shorthand) {
190
- throw new Error("ContentHeader deve estar dentro de Content.");
191
- }
192
- const content: ContentContextValue = {
193
- level: level ?? parent?.level ?? 2,
194
- titleId: parent?.titleId ?? standaloneTitleId,
195
- variant: variant ?? parent?.variant ?? "section",
196
- };
197
- const nodes = Children.toArray(
198
- shorthand ? (
199
- <>
200
- <ContentTitle>{title}</ContentTitle>
201
- {meta !== undefined && <ContentMeta>{meta}</ContentMeta>}
202
- {description !== undefined && (
203
- <ContentDescription>{description}</ContentDescription>
204
- )}
205
- {actions !== undefined && <ContentActions>{actions}</ContentActions>}
206
- </>
207
- ) : (
208
- children
209
- ),
210
- ).flatMap((node) =>
211
- isValidElement(node) && node.type === Fragment
212
- ? Children.toArray((node.props as { children?: ReactNode }).children)
213
- : [node],
214
- );
215
- const titles = nodes.filter(
216
- (node) => isValidElement(node) && node.type === ContentTitle,
217
- );
218
- const metas = nodes.filter(
219
- (node) => isValidElement(node) && node.type === ContentMeta,
220
- );
221
- const descriptions = nodes.filter(
222
- (node) => isValidElement(node) && node.type === ContentDescription,
223
- );
224
- const actionSlots = nodes.filter(
225
- (node) => isValidElement(node) && node.type === ContentActions,
154
+ const content = requiredContext(
155
+ useContext(ContentContext),
156
+ "ContentHeader",
157
+ "Content",
226
158
  );
227
- const recognized =
228
- titles.length + metas.length + descriptions.length + actionSlots.length;
229
-
230
- if (
231
- titles.length !== 1 ||
232
- metas.length > 1 ||
233
- descriptions.length > 1 ||
234
- actionSlots.length > 1 ||
235
- recognized !== nodes.length
236
- ) {
237
- throw new Error(
238
- "ContentHeader exige um ContentTitle e aceita no máximo um ContentDescription, ContentMeta e ContentActions como filhos diretos.",
239
- );
240
- }
241
-
242
159
  return (
243
160
  <ContentHeaderContext.Provider value={content}>
244
- <div
245
- data-slot="content-header"
246
- className={cn(
247
- actionSlots.length > 0 &&
248
- "flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
249
- className,
250
- )}
161
+ <SurfaceHeader
162
+ name="ContentHeader"
163
+ slot="content"
164
+ slots={{
165
+ title: ContentTitle,
166
+ count: ContentMeta,
167
+ description: ContentDescription,
168
+ actions: ContentActions,
169
+ }}
251
170
  {...props}
252
171
  >
253
- <div data-slot="content-header-heading" className="min-w-0">
254
- <div className="flex items-baseline gap-2">
255
- {titles}
256
- {metas}
257
- </div>
258
- {descriptions}
259
- </div>
260
- {actionSlots}
261
- </div>
172
+ {children}
173
+ </SurfaceHeader>
262
174
  </ContentHeaderContext.Provider>
263
175
  );
264
176
  }
@@ -287,11 +199,7 @@ export function ContentTitle({
287
199
  <Heading
288
200
  id={id ?? context.titleId}
289
201
  data-slot="content-title"
290
- className={cn(
291
- "font-semibold",
292
- context.variant === "page" ? "text-2xl tracking-tight" : "text-sm",
293
- className,
294
- )}
202
+ className={cn(surfaceHeaderClasses[context.variant].title, className)}
295
203
  {...props}
296
204
  />
297
205
  );
@@ -309,26 +217,29 @@ export function ContentDescription({
309
217
  return (
310
218
  <p
311
219
  data-slot="content-description"
312
- className={cn(
313
- "truncate text-muted-foreground",
314
- context.variant === "page" ? "mt-1 text-sm" : "mt-0.5 text-xs",
315
- className,
316
- )}
220
+ className={cn(surfaceHeaderClasses[context.variant].description, className)}
317
221
  {...props}
318
222
  />
319
223
  );
320
224
  }
321
225
 
226
+ /** O contador ao lado do título — o slot `count` do shorthand. */
322
227
  export function ContentMeta({
323
228
  className,
324
229
  ...props
325
230
  }: HTMLAttributes<HTMLSpanElement>): ReactElement {
326
- requiredContext(
231
+ const context = requiredContext(
327
232
  useContext(ContentHeaderContext),
328
233
  "ContentMeta",
329
234
  "ContentHeader",
330
235
  );
331
- return <span data-slot="content-meta" className={className} {...props} />;
236
+ return (
237
+ <span
238
+ data-slot="content-meta"
239
+ className={cn(surfaceHeaderClasses[context.variant].count, className)}
240
+ {...props}
241
+ />
242
+ );
332
243
  }
333
244
 
334
245
  export function ContentActions({
@@ -343,11 +254,7 @@ export function ContentActions({
343
254
  return (
344
255
  <div
345
256
  data-slot="content-actions"
346
- className={cn(
347
- "flex w-full shrink-0 items-center sm:w-auto",
348
- context.variant === "page" ? "gap-4" : "gap-2",
349
- className,
350
- )}
257
+ className={cn(surfaceHeaderClasses[context.variant].actions, className)}
351
258
  {...props}
352
259
  />
353
260
  );
@@ -2,31 +2,39 @@
2
2
  * <DataState /> — o estado "carregando" da casa: o que mostrar ANTES do conteúdo chegar.
3
3
  *
4
4
  * Orquestra os estados de uma carga assíncrona (lista/fetch) num lugar só: erro (calmo, sem
5
- * jargão na tela), carregando (Spinner centralizado), vazio (texto) e, por fim, o conteúdo.
5
+ * jargão na tela), carregando (Spinner centralizado), vazio (Empty) e, por fim, o conteúdo.
6
6
  * Dissolve o `error ? … : isLoading ? <Spinner/> : empty ? … : children` repetido em toda
7
7
  * tela. NÃO confundir com "processando" (AÇÃO em andamento depois do clique) — isso é o
8
8
  * `busy` do Button. Pra placeholder com forma de conteúdo, use Skeleton.
9
+ *
10
+ * As superfícies são as mesmas de PageState (state-surface.tsx); aqui elas ficam na escala de
11
+ * uma seção: o vazio é a frase discreta com moldura sólida e o erro é o Alert com recuperação.
9
12
  */
10
13
  import * as React from 'react'
11
- import { cn } from '../../lib/cn.ts'
12
- import { Alert } from '../primitives/alert.tsx'
13
- import { Button } from '../primitives/button.tsx'
14
- import { Spinner } from '../primitives/spinner.tsx'
14
+ import {
15
+ DEFAULT_EMPTY_MESSAGE,
16
+ DEFAULT_ERROR_MESSAGE,
17
+ DEFAULT_RETRY_LABEL,
18
+ EmptySurface,
19
+ ErrorSurface,
20
+ LoadingSurface,
21
+ } from './state-surface.tsx'
15
22
 
16
23
  export interface DataStateProps {
17
24
  /** Carregando (antes do conteúdo) — mostra o Spinner centralizado. */
18
25
  loading?: boolean
19
- /** Erro da carga. A mensagem técnica NÃO vai pra tela (passe `errorText` pra um aviso). */
26
+ /** Erro da carga. A mensagem técnica NÃO vai pra tela (passe `errorMessage` pra um aviso). */
20
27
  error?: { message?: string } | null
21
- /** Sem itens — mostra o `emptyText`. */
28
+ /** Sem itens — mostra o `emptyMessage`. */
22
29
  empty?: boolean
23
- /** Texto do vazio (pt-BR, ex.: "Nenhum papel."). */
24
- emptyText?: string
25
- /** Aviso de erro (calmo, orientado ao usuário). Default: "Não foi possível carregar.". */
26
- errorText?: string
30
+ /** Frase do vazio (pt-BR, ex.: "Nenhum papel."). Default: "Nada por aqui.". */
31
+ emptyMessage?: string
32
+ /** Aviso de erro (calmo, orientado à pessoa). Default: "Não foi possível carregar.". */
33
+ errorMessage?: string
27
34
  /** Recuperação opcional exibida somente no estado de erro. */
28
35
  onRetry?: () => Promise<void> | void
29
- retryText?: string
36
+ /** Rótulo do botão de recuperação. Default: "Tentar de novo". */
37
+ retryLabel?: string
30
38
  /** Em TABELA: renderiza o estado como UMA linha (<tr><td colSpan>), não um bloco — pra
31
39
  * caber direto no <tbody>. Passe o nº de colunas da tabela. */
32
40
  colSpan?: number
@@ -39,78 +47,44 @@ export function DataState({
39
47
  loading = false,
40
48
  error = null,
41
49
  empty = false,
42
- emptyText = 'Nada por aqui.',
43
- errorText,
50
+ emptyMessage = DEFAULT_EMPTY_MESSAGE,
51
+ errorMessage = DEFAULT_ERROR_MESSAGE,
44
52
  onRetry,
45
- retryText = 'Tentar de novo',
53
+ retryLabel = DEFAULT_RETRY_LABEL,
46
54
  colSpan,
47
55
  children,
48
56
  }: DataStateProps): React.ReactElement {
49
57
  // Há conteúdo: passa direto (no modo tabela, são as <tr> dos itens).
50
58
  if (!error && !loading && !empty) return <>{children}</>
51
59
 
60
+ const status = error ? 'error' : loading ? 'loading' : 'empty'
61
+ const surface = error ? (
62
+ <ErrorSurface
63
+ title={errorMessage}
64
+ {...(onRetry !== undefined ? { onRetry } : {})}
65
+ retryLabel={retryLabel}
66
+ />
67
+ ) : loading ? (
68
+ <LoadingSurface className="py-10" />
69
+ ) : (
70
+ // A tabela já traz a moldura: dentro dela o vazio é só a frase (`bare`).
71
+ <EmptySurface title={emptyMessage} compact frame={colSpan === undefined ? 'structural' : 'bare'} />
72
+ )
73
+
52
74
  // Modo TABELA: o estado vira UMA linha de largura cheia (<td colSpan>), pra caber no <tbody>.
53
75
  if (colSpan !== undefined) {
54
- const status = error ? 'error' : loading ? 'loading' : 'empty'
55
76
  return (
56
77
  <tr data-slot="data-state" data-status={status}>
57
- <td
58
- colSpan={colSpan}
59
- className={cn('px-4 py-10 text-center text-sm', error ? 'text-destructive' : 'text-muted-foreground/60')}
60
- >
61
- {error ? (
62
- <div className="flex flex-col items-center gap-3">
63
- <span>{errorText ?? 'Não foi possível carregar.'}</span>
64
- {onRetry !== undefined && (
65
- <Button variant="outline" size="sm" onClick={() => void onRetry()}>
66
- {retryText}
67
- </Button>
68
- )}
69
- </div>
70
- ) : loading ? (
71
- <Spinner className="mx-auto" />
72
- ) : (
73
- emptyText
74
- )}
78
+ <td colSpan={colSpan} className="px-4 py-4 text-center text-sm">
79
+ {surface}
75
80
  </td>
76
81
  </tr>
77
82
  )
78
83
  }
79
84
 
80
- // Modo BLOCO (default).
81
- if (error) {
82
- return (
83
- <div data-slot="data-state" data-status="error">
84
- <Alert context="danger" description={errorText ?? 'Não foi possível carregar.'}>
85
- {onRetry !== undefined && (
86
- <Button variant="outline" size="sm" onClick={() => void onRetry()}>
87
- {retryText}
88
- </Button>
89
- )}
90
- </Alert>
91
- </div>
92
- )
93
- }
94
- if (loading) {
95
- return (
96
- <div
97
- data-slot="data-state"
98
- data-status="loading"
99
- role="status"
100
- aria-live="polite"
101
- className="py-10 text-center text-muted-foreground"
102
- >
103
- <Spinner className="mx-auto" />
104
- </div>
105
- )
106
- }
107
85
  return (
108
- <p
109
- data-slot="data-state"
110
- data-status="empty"
111
- className="rounded-lg border border-border py-10 text-center text-sm text-muted-foreground/60"
112
- >
113
- {emptyText}
114
- </p>
86
+ <div data-slot="data-state" data-status={status}>
87
+ {surface}
88
+ </div>
115
89
  )
116
90
  }
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import * as React from 'react'
16
+ import type { UiContext } from '../../../core/ui-context.ts'
16
17
  import { cn } from '../../lib/cn.ts'
17
18
  import { Button } from '../primitives/button.tsx'
18
19
  import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip.tsx'
@@ -142,7 +143,8 @@ export function DockAction({ icon, label, pressed, disabled = false, hint, class
142
143
  <Button
143
144
  data-slot="dock-action"
144
145
  size="icon"
145
- variant={pressed === true ? 'default' : 'ghost'}
146
+ context={pressed === true ? 'primary' : 'neutral'}
147
+ variant={pressed === true ? 'solid' : 'ghost'}
146
148
  aria-label={label}
147
149
  aria-pressed={pressed}
148
150
  disabled={disabled}
@@ -162,9 +164,22 @@ const STATUS_POSITION = {
162
164
  'top-left': 'top-3 left-3',
163
165
  } as const
164
166
 
167
+ export type SurfaceStatusContext = Exclude<UiContext, 'primary'>
168
+
169
+ /** `neutral` mantém o cinza do chrome; os demais tingem borda e texto pela família semântica. */
170
+ const STATUS_CONTEXT: Record<SurfaceStatusContext, string> = {
171
+ neutral: 'border-border text-muted-foreground',
172
+ info: 'border-context-info-border text-context-info-emphasis',
173
+ success: 'border-context-success-border text-context-success-emphasis',
174
+ warning: 'border-context-warning-border text-context-warning-emphasis',
175
+ danger: 'border-context-danger-border text-context-danger-emphasis',
176
+ }
177
+
165
178
  export interface SurfaceStatusProps {
166
179
  /** Canto da superfície onde o estado se ancora. */
167
180
  position?: keyof typeof STATUS_POSITION
181
+ /** O que o estado comunica (salvo, falhou, publicando…). Sem contexto, fica neutro. */
182
+ context?: SurfaceStatusContext
168
183
  /** Ações do recurso aberto — em geral o mesmo menu que a linha dele tem na navegação. */
169
184
  actions?: React.ReactNode
170
185
  className?: string
@@ -180,14 +195,16 @@ export interface SurfaceStatusProps {
180
195
  * Mensagem de sucesso é transitória por natureza; falha permanece até o estado mudar. Quem decide
181
196
  * isso é a aplicação, que conhece o ciclo — o componente só reserva o lugar.
182
197
  */
183
- export function SurfaceStatus({ position = 'top-right', actions, className, children }: SurfaceStatusProps): React.ReactElement {
198
+ export function SurfaceStatus({ position = 'top-right', context = 'neutral', actions, className, children }: SurfaceStatusProps): React.ReactElement {
184
199
  const hasActions = actions !== undefined && actions !== null
185
200
  return (
186
201
  <div
187
202
  data-slot="surface-status"
203
+ data-context={context}
188
204
  className={cn(
189
205
  'pointer-events-none absolute z-10 flex max-w-[calc(100%-1.5rem)] flex-row items-center gap-2',
190
- 'rounded-full border border-border bg-background/95 py-1.5 pl-3 text-xs text-muted-foreground shadow-sm backdrop-blur',
206
+ 'rounded-full border bg-background/95 py-1.5 pl-3 text-xs shadow-sm backdrop-blur',
207
+ STATUS_CONTEXT[context],
191
208
  hasActions ? 'pr-1' : 'pr-3',
192
209
  STATUS_POSITION[position],
193
210
  className,
@@ -4,7 +4,7 @@
4
4
  * Dois modos, um motor:
5
5
  * - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo.
6
6
  * - COMPOSIÇÃO (com children): você diagrama; <ActionFormField name /> coloca cada campo
7
- * (label/hint/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
7
+ * (label/help/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
8
8
  * JSX ({cond && <ActionFormField/>}); opções de runtime entram por prop no campo. O
9
9
  * FieldGroup canônico permanece nos dois modos; grids e seções entram como filhos explícitos.
10
10
  *
@@ -28,6 +28,7 @@ import {
28
28
  import { useDicts, useFormAction, type DictLike } from '../../drivers/react.tsx'
29
29
  import { z, type ZodTypeAny } from 'zod'
30
30
  import { cn } from '../../lib/cn.ts'
31
+ import { humanizeActionError } from '../../lib/action-errors.ts'
31
32
  import { objectSchemaShape } from '../../lib/object-schema.ts'
32
33
  import { toast } from '../primitives/sonner.tsx'
33
34
  import { Button } from '../primitives/button.tsx'
@@ -39,7 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../pri
39
40
  import { Info } from 'lucide-react'
40
41
  import { Select, type SelectOption } from '../primitives/select.tsx'
41
42
  import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
42
- import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
43
+ import { Field, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
43
44
 
44
45
  // =============================================================================
45
46
  // Inferência de tipo de field a partir do Zod schema
@@ -111,8 +112,7 @@ function inferFieldKind(schema: ZodTypeAny): FieldKind {
111
112
  interface FieldSpec {
112
113
  label?: string
113
114
  placeholder?: string
114
- hint?: string
115
- /** Ajuda na label: ícone ⓘ + tooltip no hover (explicação mais longa que o hint). */
115
+ /** Ajuda na label: ícone ⓘ + tooltip no hover. */
116
116
  help?: string
117
117
  /** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
118
118
  * Honra 'textarea' (string sem max), 'code' (textarea monoespaçada, ex.: SKILL.md),
@@ -126,7 +126,7 @@ interface FieldSpec {
126
126
  * No modo COMPOSIÇÃO o condicional pode (e deve) ser JSX de quem diagrama. */
127
127
  showWhen?: (input: Record<string, unknown>) => boolean
128
128
  /** Origem declarada das opções (static/dictionary/lookup) — ver a precedência
129
- * em `ActionFormField`. `dictionary` resolve pelos dicts do TbdlibProvider. */
129
+ * em `ActionFormField`. `dictionary` resolve pelos dicts do OpusProvider. */
130
130
  options?: OptionsSpec
131
131
  }
132
132
 
@@ -171,11 +171,18 @@ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
171
171
  })
172
172
  }
173
173
 
174
- /** Ícone de ajuda na label (ⓘ + tooltip Radix). Self-contained (inclui o Provider) — só
175
- * renderiza quando o FieldSpec define `help`. `tabIndex={-1}`: fica FORA do autofocus do
176
- * diálogo (senão o modal foca no ícone ao abrir e o tooltip vem aberto); abre no hover.
177
- * preventDefault: clicar o ícone não dispara o label (não foca o input / não toggla). */
178
- function LabelHelp({ help }: { help: string | undefined }): React.ReactElement | null {
174
+ /**
175
+ * Ícone de ajuda junto à label (ⓘ + tooltip Radix). Self-contained (inclui o Provider):
176
+ * renderiza somente quando `help` tem texto. É o mesmo slot que o `ActionFormField` usa para
177
+ * `FieldSpec.help`; exportado para que um formulário sem contrato mostre a ajuda no mesmo
178
+ * lugar (`<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>`) e o `opus copy` inventarie
179
+ * o texto de `help` como helper-text.
180
+ *
181
+ * `tabIndex={-1}`: fica FORA do autofocus do diálogo (senão o modal foca no ícone ao abrir e
182
+ * o tooltip vem aberto); abre só no hover. preventDefault: clicar o ícone não dispara o label
183
+ * (não foca o input / não toggla).
184
+ */
185
+ export function LabelHelp({ help }: { help: string | undefined }): React.ReactElement | null {
179
186
  if (help === undefined || help.trim().length === 0) return null
180
187
  return (
181
188
  <TooltipProvider delayDuration={300}>
@@ -208,7 +215,7 @@ function RequiredMark({ required }: { required: boolean }): React.ReactElement |
208
215
  if (!required) return null
209
216
  return (
210
217
  <>
211
- <span aria-hidden className="ml-0.5 text-destructive">
218
+ <span aria-hidden className="ml-0.5 text-context-danger-emphasis">
212
219
  *
213
220
  </span>
214
221
  <span className="sr-only"> (obrigatório)</span>
@@ -287,9 +294,8 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
287
294
  const spec: FieldSpec = fields[name] ?? {}
288
295
  const fieldError = form.formState.errors[name]
289
296
  const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
290
- const descriptionId = spec.hint !== undefined ? `${name}-description` : undefined
291
297
  const errorId = errorMessage !== undefined ? `${name}-error` : undefined
292
- const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
298
+ const describedBy = errorId
293
299
  useEffect(() => {
294
300
  if (errorMessage === undefined) return
295
301
  const control = document.getElementById(name)
@@ -371,7 +377,6 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
371
377
  </span>
372
378
  <LabelHelp help={spec.help} />
373
379
  </FieldLabel>
374
- {spec.hint !== undefined && <FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>}
375
380
  {errorMessage !== undefined && <FieldError id={errorId}>{errorMessage}</FieldError>}
376
381
  </Field>
377
382
  )
@@ -530,7 +535,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
530
535
  <Input
531
536
  id={`${name}-${i}-text`}
532
537
  value={item.text}
533
- placeholder={spec.placeholder ?? 'O quê (com critério)…'}
538
+ placeholder={spec.placeholder ?? 'Descreva o critério…'}
534
539
  onChange={(e) => setItems(items.map((it, j) => (j === i ? { ...it, text: e.target.value } : it)))}
535
540
  aria-invalid={errorMessage !== undefined}
536
541
  aria-describedby={describedBy}
@@ -593,9 +598,6 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
593
598
  />
594
599
  )}
595
600
 
596
- {spec.hint !== undefined && (
597
- <FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>
598
- )}
599
601
  {errorMessage !== undefined && (
600
602
  <FieldError id={errorId}>{errorMessage}</FieldError>
601
603
  )}
@@ -651,7 +653,7 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
651
653
  onSuccess?.(data)
652
654
  },
653
655
  onError: (err) => {
654
- toast.error(msgText(action.messages?.error, err.message))
656
+ toast.error(humanizeActionError(err, msgText(action.messages?.error, 'Não foi possível salvar.')))
655
657
  },
656
658
  })
657
659
 
@@ -669,13 +671,15 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
669
671
  ))
670
672
 
671
673
  // Banner de erro do servidor (chrome do próprio form, nos dois modos). O FieldGroup
672
- // mantém o mesmo ritmo entre campos, blocos compostos e este estado.
674
+ // mantém o mesmo ritmo entre campos, blocos compostos e este estado. Só a frase de
675
+ // negócio chega aqui; código e mensagem técnica ficam no log do servidor.
673
676
  const errorBanner =
674
677
  error !== undefined && !isSuccess ? (
675
678
  <div
676
- className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
679
+ role="alert"
680
+ className="rounded-md border border-context-danger-border bg-context-danger-subtle p-3 text-sm text-context-danger-emphasis"
677
681
  >
678
- <strong>{error.code}</strong>: {error.message}
682
+ {humanizeActionError(error, msgText(action.messages?.error, 'Não foi possível salvar.'))}
679
683
  </div>
680
684
  ) : null
681
685