@softize/opus 13.1.0 → 15.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 (72) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +33 -5
  4. package/bin/lib/cli-shared.mjs +30 -1
  5. package/bin/lib/copy.mjs +279 -6
  6. package/bin/lib/db.mjs +2 -0
  7. package/docs/adr/0004-page-content-state-is-composed.md +39 -5
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
  9. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
  10. package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
  11. package/docs/code-style.md +4 -1
  12. package/docs/data-layer.md +9 -0
  13. package/package.json +1 -1
  14. package/registry/instructions/opus.md +3 -3
  15. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  16. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
  17. package/registry/templates/app/src/App.tsx +11 -6
  18. package/src/core/types.ts +3 -4
  19. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  20. package/src/ui/components/patterns/confirm.tsx +2 -31
  21. package/src/ui/components/patterns/content-header.tsx +42 -141
  22. package/src/ui/components/patterns/data-state.tsx +42 -68
  23. package/src/ui/components/patterns/form.tsx +15 -15
  24. package/src/ui/components/patterns/list.tsx +55 -34
  25. package/src/ui/components/patterns/page-state.tsx +81 -51
  26. package/src/ui/components/patterns/page.tsx +228 -97
  27. package/src/ui/components/patterns/state-surface.tsx +262 -0
  28. package/src/ui/components/patterns/surface-header.tsx +204 -0
  29. package/src/ui/components/patterns/trigger.tsx +9 -10
  30. package/src/ui/components/patterns/view.tsx +14 -16
  31. package/src/ui/components/primitives/alert.tsx +1 -33
  32. package/src/ui/components/primitives/avatar.tsx +15 -5
  33. package/src/ui/components/primitives/badge.tsx +2 -43
  34. package/src/ui/components/primitives/button-group.tsx +34 -8
  35. package/src/ui/components/primitives/button.tsx +31 -35
  36. package/src/ui/components/primitives/control.ts +69 -0
  37. package/src/ui/components/primitives/dot.tsx +1 -30
  38. package/src/ui/components/primitives/input-group.tsx +11 -8
  39. package/src/ui/components/primitives/item.tsx +3 -1
  40. package/src/ui/components/primitives/menu.tsx +1 -7
  41. package/src/ui/components/primitives/pagination.tsx +16 -8
  42. package/src/ui/components/primitives/select.tsx +2 -2
  43. package/src/ui/components/primitives/spinner.tsx +13 -16
  44. package/src/ui/components/primitives/switch.tsx +4 -1
  45. package/src/ui/components/primitives/tabs.tsx +5 -3
  46. package/src/ui/components/primitives/toggle.tsx +9 -4
  47. package/src/ui/docs/content/action-form.md +26 -0
  48. package/src/ui/docs/content/action-list-dialog.md +2 -2
  49. package/src/ui/docs/content/action-list.md +35 -2
  50. package/src/ui/docs/content/action-trigger.md +5 -4
  51. package/src/ui/docs/content/action-view.md +3 -2
  52. package/src/ui/docs/content/alert.md +16 -4
  53. package/src/ui/docs/content/avatar.md +7 -3
  54. package/src/ui/docs/content/button.md +48 -17
  55. package/src/ui/docs/content/communication.md +36 -0
  56. package/src/ui/docs/content/content.md +5 -4
  57. package/src/ui/docs/content/data-state.md +17 -13
  58. package/src/ui/docs/content/dialog.md +1 -4
  59. package/src/ui/docs/content/input.md +1 -1
  60. package/src/ui/docs/content/item.md +1 -1
  61. package/src/ui/docs/content/page.md +160 -37
  62. package/src/ui/docs/content/pagination.md +11 -9
  63. package/src/ui/docs/content/semantic-context.md +3 -2
  64. package/src/ui/docs/content/sidebar.md +2 -42
  65. package/src/ui/docs/content/spinner.md +9 -6
  66. package/src/ui/docs/content/switch.md +1 -1
  67. package/src/ui/docs/content/tabs.md +1 -1
  68. package/src/ui/docs/content/toggle.md +1 -1
  69. package/src/ui/drivers/react.tsx +1 -6
  70. package/src/ui/meta.ts +8 -8
  71. package/src/ui/react.tsx +16 -18
  72. package/src/ui/components/patterns/shell-nav.tsx +0 -154
@@ -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,134 +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
146
  /**
175
- * Header estrutural de Content; não é uma região autônoma.
176
- *
177
- * Compartilha o layout (título, meta, descrição e ações) com `PageHeader` (page.tsx); os dois
178
- * serão unificados numa próxima série — até lá, uma correção de layout em um deve ser espelhada
179
- * no outro.
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.
180
149
  */
181
150
  export function ContentHeader({
182
- title,
183
- meta,
184
- description,
185
- actions,
186
- level,
187
- variant,
188
151
  children,
189
- className,
190
152
  ...props
191
153
  }: ContentHeaderProps): ReactElement {
192
- const parent = useContext(ContentContext);
193
- const standaloneTitleId = useId();
194
- const shorthand = title !== undefined;
195
- if (parent === null && !shorthand) {
196
- throw new Error("ContentHeader deve estar dentro de Content.");
197
- }
198
- const content: ContentContextValue = {
199
- level: level ?? parent?.level ?? 2,
200
- titleId: parent?.titleId ?? standaloneTitleId,
201
- variant: variant ?? parent?.variant ?? "section",
202
- };
203
- const nodes = Children.toArray(
204
- shorthand ? (
205
- <>
206
- <ContentTitle>{title}</ContentTitle>
207
- {meta !== undefined && <ContentMeta>{meta}</ContentMeta>}
208
- {description !== undefined && (
209
- <ContentDescription>{description}</ContentDescription>
210
- )}
211
- {actions !== undefined && <ContentActions>{actions}</ContentActions>}
212
- </>
213
- ) : (
214
- children
215
- ),
216
- ).flatMap((node) =>
217
- isValidElement(node) && node.type === Fragment
218
- ? Children.toArray((node.props as { children?: ReactNode }).children)
219
- : [node],
220
- );
221
- const titles = nodes.filter(
222
- (node) => isValidElement(node) && node.type === ContentTitle,
223
- );
224
- const metas = nodes.filter(
225
- (node) => isValidElement(node) && node.type === ContentMeta,
226
- );
227
- const descriptions = nodes.filter(
228
- (node) => isValidElement(node) && node.type === ContentDescription,
229
- );
230
- const actionSlots = nodes.filter(
231
- (node) => isValidElement(node) && node.type === ContentActions,
154
+ const content = requiredContext(
155
+ useContext(ContentContext),
156
+ "ContentHeader",
157
+ "Content",
232
158
  );
233
- const recognized =
234
- titles.length + metas.length + descriptions.length + actionSlots.length;
235
-
236
- if (
237
- titles.length !== 1 ||
238
- metas.length > 1 ||
239
- descriptions.length > 1 ||
240
- actionSlots.length > 1 ||
241
- recognized !== nodes.length
242
- ) {
243
- throw new Error(
244
- "ContentHeader exige um ContentTitle e aceita no máximo um ContentDescription, ContentMeta e ContentActions como filhos diretos.",
245
- );
246
- }
247
-
248
159
  return (
249
160
  <ContentHeaderContext.Provider value={content}>
250
- <div
251
- data-slot="content-header"
252
- className={cn(
253
- actionSlots.length > 0 &&
254
- "flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
255
- className,
256
- )}
161
+ <SurfaceHeader
162
+ name="ContentHeader"
163
+ slot="content"
164
+ slots={{
165
+ title: ContentTitle,
166
+ count: ContentMeta,
167
+ description: ContentDescription,
168
+ actions: ContentActions,
169
+ }}
257
170
  {...props}
258
171
  >
259
- <div data-slot="content-header-heading" className="min-w-0">
260
- <div className="flex items-baseline gap-2">
261
- {titles}
262
- {metas}
263
- </div>
264
- {descriptions}
265
- </div>
266
- {actionSlots}
267
- </div>
172
+ {children}
173
+ </SurfaceHeader>
268
174
  </ContentHeaderContext.Provider>
269
175
  );
270
176
  }
@@ -293,11 +199,7 @@ export function ContentTitle({
293
199
  <Heading
294
200
  id={id ?? context.titleId}
295
201
  data-slot="content-title"
296
- className={cn(
297
- "font-semibold",
298
- context.variant === "page" ? "text-2xl tracking-tight" : "text-sm",
299
- className,
300
- )}
202
+ className={cn(surfaceHeaderClasses[context.variant].title, className)}
301
203
  {...props}
302
204
  />
303
205
  );
@@ -315,26 +217,29 @@ export function ContentDescription({
315
217
  return (
316
218
  <p
317
219
  data-slot="content-description"
318
- className={cn(
319
- "truncate text-muted-foreground",
320
- context.variant === "page" ? "mt-1 text-sm" : "mt-0.5 text-xs",
321
- className,
322
- )}
220
+ className={cn(surfaceHeaderClasses[context.variant].description, className)}
323
221
  {...props}
324
222
  />
325
223
  );
326
224
  }
327
225
 
226
+ /** O contador ao lado do título — o slot `count` do shorthand. */
328
227
  export function ContentMeta({
329
228
  className,
330
229
  ...props
331
230
  }: HTMLAttributes<HTMLSpanElement>): ReactElement {
332
- requiredContext(
231
+ const context = requiredContext(
333
232
  useContext(ContentHeaderContext),
334
233
  "ContentMeta",
335
234
  "ContentHeader",
336
235
  );
337
- 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
+ );
338
243
  }
339
244
 
340
245
  export function ContentActions({
@@ -349,11 +254,7 @@ export function ContentActions({
349
254
  return (
350
255
  <div
351
256
  data-slot="content-actions"
352
- className={cn(
353
- "flex w-full shrink-0 items-center sm:w-auto",
354
- context.variant === "page" ? "gap-4" : "gap-2",
355
- className,
356
- )}
257
+ className={cn(surfaceHeaderClasses[context.variant].actions, className)}
357
258
  {...props}
358
259
  />
359
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-context-danger-emphasis' : '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
  }
@@ -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
  *
@@ -40,7 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../pri
40
40
  import { Info } from 'lucide-react'
41
41
  import { Select, type SelectOption } from '../primitives/select.tsx'
42
42
  import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
43
- import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
43
+ import { Field, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
44
44
 
45
45
  // =============================================================================
46
46
  // Inferência de tipo de field a partir do Zod schema
@@ -112,8 +112,6 @@ function inferFieldKind(schema: ZodTypeAny): FieldKind {
112
112
  interface FieldSpec {
113
113
  label?: string
114
114
  placeholder?: string
115
- /** @deprecated Use `help` (ajuda junto à label) ou `description` do campo composto. */
116
- hint?: string
117
115
  /** Ajuda na label: ícone ⓘ + tooltip no hover. */
118
116
  help?: string
119
117
  /** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
@@ -173,11 +171,18 @@ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
173
171
  })
174
172
  }
175
173
 
176
- /** Ícone de ajuda na label (ⓘ + tooltip Radix). Self-contained (inclui o Provider) — só
177
- * renderiza quando o FieldSpec define `help`. `tabIndex={-1}`: fica FORA do autofocus do
178
- * diálogo (senão o modal foca no ícone ao abrir e o tooltip vem aberto); abre no hover.
179
- * preventDefault: clicar o ícone não dispara o label (não foca o input / não toggla). */
180
- 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 {
181
186
  if (help === undefined || help.trim().length === 0) return null
182
187
  return (
183
188
  <TooltipProvider delayDuration={300}>
@@ -289,9 +294,8 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
289
294
  const spec: FieldSpec = fields[name] ?? {}
290
295
  const fieldError = form.formState.errors[name]
291
296
  const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
292
- const descriptionId = spec.hint !== undefined ? `${name}-description` : undefined
293
297
  const errorId = errorMessage !== undefined ? `${name}-error` : undefined
294
- const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
298
+ const describedBy = errorId
295
299
  useEffect(() => {
296
300
  if (errorMessage === undefined) return
297
301
  const control = document.getElementById(name)
@@ -373,7 +377,6 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
373
377
  </span>
374
378
  <LabelHelp help={spec.help} />
375
379
  </FieldLabel>
376
- {spec.hint !== undefined && <FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>}
377
380
  {errorMessage !== undefined && <FieldError id={errorId}>{errorMessage}</FieldError>}
378
381
  </Field>
379
382
  )
@@ -595,9 +598,6 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
595
598
  />
596
599
  )}
597
600
 
598
- {spec.hint !== undefined && (
599
- <FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>
600
- )}
601
601
  {errorMessage !== undefined && (
602
602
  <FieldError id={errorId}>{errorMessage}</FieldError>
603
603
  )}