@softize/opus 17.2.0 → 18.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +62 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0010-page-header-owns-page-chrome.md +2 -2
  4. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
  5. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  6. package/docs/adr/0015-action-size-follows-interaction-density.md +1 -1
  7. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  8. package/package.json +1 -1
  9. package/registry/skills/build-opus-ui/SKILL.md +30 -20
  10. package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
  11. package/registry/skills/build-opus-ui/references/ui-patterns.md +30 -18
  12. package/src/core/presentation.ts +223 -24
  13. package/src/core/runtime.ts +3 -0
  14. package/src/core/types.ts +2 -0
  15. package/src/mcp/index.ts +1 -0
  16. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  17. package/src/ui/components/patterns/confirm.tsx +194 -157
  18. package/src/ui/components/patterns/content-header.tsx +17 -2
  19. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  20. package/src/ui/components/patterns/form.tsx +340 -222
  21. package/src/ui/components/patterns/list.tsx +43 -44
  22. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  23. package/src/ui/components/patterns/page-state.tsx +2 -0
  24. package/src/ui/components/patterns/page.tsx +165 -51
  25. package/src/ui/components/patterns/presentation.tsx +140 -84
  26. package/src/ui/components/patterns/surface-header.tsx +5 -6
  27. package/src/ui/components/patterns/trigger.tsx +113 -83
  28. package/src/ui/components/primitives/button.tsx +2 -2
  29. package/src/ui/components/primitives/chat.tsx +19 -5
  30. package/src/ui/components/primitives/control.ts +9 -3
  31. package/src/ui/components/primitives/dialog.tsx +16 -9
  32. package/src/ui/components/primitives/drawer.tsx +9 -6
  33. package/src/ui/docs/content/action-form-card.md +9 -8
  34. package/src/ui/docs/content/action-form-dialog.md +11 -12
  35. package/src/ui/docs/content/action-form.md +25 -25
  36. package/src/ui/docs/content/action-list.md +101 -70
  37. package/src/ui/docs/content/action-trigger.md +2 -2
  38. package/src/ui/docs/content/button.md +1 -1
  39. package/src/ui/docs/content/chat.md +4 -4
  40. package/src/ui/docs/content/content.md +29 -13
  41. package/src/ui/docs/content/dialog.md +27 -21
  42. package/src/ui/docs/content/drawer.md +8 -6
  43. package/src/ui/docs/content/page.md +43 -50
  44. package/src/ui/docs/content/presentation.md +39 -28
  45. package/src/ui/docs/doc-client.tsx +1 -1
  46. package/src/ui/meta.ts +4 -4
@@ -17,49 +17,69 @@
17
17
  * inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
18
18
  */
19
19
 
20
- import { createContext, useContext, useEffect, useId } from 'react'
21
- import type { UseFormReturn } from 'react-hook-form'
20
+ import { createContext, useContext, useEffect, useId } from "react";
21
+ import type { UseFormReturn } from "react-hook-form";
22
22
  import {
23
23
  getLogicalType,
24
24
  type FieldWidget,
25
25
  type FormContract,
26
26
  type OptionsSpec,
27
- } from '../../../core/index.ts'
28
- import { useDicts, useFormAction, type DictLike } from '../../drivers/react.tsx'
29
- import { z, type ZodTypeAny } from 'zod'
30
- import { cn } from '../../lib/cn.ts'
31
- import { humanizeActionError } from '../../lib/action-errors.ts'
32
- import { objectSchemaShape } from '../../lib/object-schema.ts'
33
- import { toast } from '../primitives/sonner.tsx'
34
- import { Button } from '../primitives/button.tsx'
35
- import { Input } from '../primitives/input.tsx'
36
- import { Textarea } from '../primitives/textarea.tsx'
37
- import { Checkbox } from '../primitives/checkbox.tsx'
38
- import { IconPicker } from '../primitives/icon-picker.tsx'
39
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
40
- import { Info } from 'lucide-react'
41
- import { Select, type SelectOption } from '../primitives/select.tsx'
42
- import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
43
- import { Field, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
27
+ } from "../../../core/index.ts";
28
+ import {
29
+ useDicts,
30
+ useFormAction,
31
+ type DictLike,
32
+ } from "../../drivers/react.tsx";
33
+ import { z, type ZodTypeAny } from "zod";
34
+ import { cn } from "../../lib/cn.ts";
35
+ import { humanizeActionError } from "../../lib/action-errors.ts";
36
+ import { objectSchemaShape } from "../../lib/object-schema.ts";
37
+ import { toast } from "../primitives/sonner.tsx";
38
+ import { Button, type ButtonVariant } from "../primitives/button.tsx";
39
+ import { ButtonGroup } from "../primitives/button-group.tsx";
40
+ import { Input } from "../primitives/input.tsx";
41
+ import { Textarea } from "../primitives/textarea.tsx";
42
+ import { Checkbox } from "../primitives/checkbox.tsx";
43
+ import { IconPicker } from "../primitives/icon-picker.tsx";
44
+ import {
45
+ Tooltip,
46
+ TooltipContent,
47
+ TooltipProvider,
48
+ TooltipTrigger,
49
+ } from "../primitives/tooltip.tsx";
50
+ import { Info } from "lucide-react";
51
+ import { Select, type SelectOption } from "../primitives/select.tsx";
52
+ import { ToggleGroup, ToggleGroupItem } from "../primitives/toggle-group.tsx";
53
+ import {
54
+ Field,
55
+ FieldError,
56
+ FieldGroup,
57
+ FieldLabel,
58
+ } from "../primitives/field.tsx";
44
59
 
45
60
  // =============================================================================
46
61
  // Inferência de tipo de field a partir do Zod schema
47
62
  // =============================================================================
48
63
 
49
64
  type FieldKind =
50
- | { kind: 'text'; required: boolean }
51
- | { kind: 'textarea'; required: boolean }
52
- | { kind: 'checkbox'; required: boolean }
53
- | { kind: 'select'; required: boolean; options: string[] }
54
- | { kind: 'multiselect'; required: boolean; options: string[] }
55
- | { kind: 'toggle-group'; required: boolean; multiple: boolean; options: string[] }
56
- | { kind: 'lines'; required: boolean }
57
- | { kind: 'refItems'; required: boolean }
58
- | { kind: 'icon'; required: boolean }
65
+ | { kind: "text"; required: boolean }
66
+ | { kind: "textarea"; required: boolean }
67
+ | { kind: "checkbox"; required: boolean }
68
+ | { kind: "select"; required: boolean; options: string[] }
69
+ | { kind: "multiselect"; required: boolean; options: string[] }
70
+ | {
71
+ kind: "toggle-group";
72
+ required: boolean;
73
+ multiple: boolean;
74
+ options: string[];
75
+ }
76
+ | { kind: "lines"; required: boolean }
77
+ | { kind: "refItems"; required: boolean }
78
+ | { kind: "icon"; required: boolean };
59
79
 
60
80
  function unwrap(schema: ZodTypeAny): { inner: ZodTypeAny; required: boolean } {
61
- let inner: ZodTypeAny = schema
62
- let required = true
81
+ let inner: ZodTypeAny = schema;
82
+ let required = true;
63
83
  // ZodOptional / ZodDefault / ZodNullable → desce no _def.innerType.
64
84
  while (
65
85
  inner instanceof z.ZodOptional ||
@@ -67,42 +87,46 @@ function unwrap(schema: ZodTypeAny): { inner: ZodTypeAny; required: boolean } {
67
87
  inner instanceof z.ZodNullable
68
88
  ) {
69
89
  if (inner instanceof z.ZodOptional || inner instanceof z.ZodDefault) {
70
- required = false
90
+ required = false;
71
91
  }
72
- const def = (inner as unknown as { _def: { innerType: ZodTypeAny } })._def
73
- inner = def.innerType
92
+ const def = (inner as unknown as { _def: { innerType: ZodTypeAny } })._def;
93
+ inner = def.innerType;
74
94
  }
75
- return { inner, required }
95
+ return { inner, required };
76
96
  }
77
97
 
78
98
  function inferFieldKind(schema: ZodTypeAny): FieldKind {
79
- const { inner, required } = unwrap(schema)
99
+ const { inner, required } = unwrap(schema);
80
100
 
81
101
  if (inner instanceof z.ZodBoolean) {
82
- return { kind: 'checkbox', required }
102
+ return { kind: "checkbox", required };
83
103
  }
84
104
 
85
105
  if (inner instanceof z.ZodEnum) {
86
- return { kind: 'select', required, options: inner.options as string[] }
106
+ return { kind: "select", required, options: inner.options as string[] };
87
107
  }
88
108
 
89
109
  // z.array(...) → multiselect (Select multiple). Opções estáticas só quando o elemento é
90
110
  // z.enum; pra z.array(z.string()) (ids em runtime) as opções vêm de fora (spec).
91
111
  if (inner instanceof z.ZodArray) {
92
- const el = (inner._def as { type: ZodTypeAny }).type
93
- return { kind: 'multiselect', required, options: el instanceof z.ZodEnum ? (el.options as string[]) : [] }
112
+ const el = (inner._def as { type: ZodTypeAny }).type;
113
+ return {
114
+ kind: "multiselect",
115
+ required,
116
+ options: el instanceof z.ZodEnum ? (el.options as string[]) : [],
117
+ };
94
118
  }
95
119
 
96
120
  if (inner instanceof z.ZodString) {
97
121
  const maxCheck = inner._def.checks.find(
98
- (c: { kind: string }) => c.kind === 'max',
99
- ) as { value: number } | undefined
122
+ (c: { kind: string }) => c.kind === "max",
123
+ ) as { value: number } | undefined;
100
124
  if (maxCheck !== undefined && maxCheck.value > 200) {
101
- return { kind: 'textarea', required }
125
+ return { kind: "textarea", required };
102
126
  }
103
127
  }
104
128
 
105
- return { kind: 'text', required }
129
+ return { kind: "text", required };
106
130
  }
107
131
 
108
132
  // =============================================================================
@@ -110,10 +134,10 @@ function inferFieldKind(schema: ZodTypeAny): FieldKind {
110
134
  // =============================================================================
111
135
 
112
136
  interface FieldSpec {
113
- label?: string
114
- placeholder?: string
137
+ label?: string;
138
+ placeholder?: string;
115
139
  /** Ajuda na label: ícone ⓘ + tooltip no hover. */
116
- help?: string
140
+ help?: string;
117
141
  /** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
118
142
  * Honra 'textarea' (string sem max), 'code' (textarea monoespaçada, ex.: SKILL.md),
119
143
  * 'lines' (z.array(z.string()) num textarea, um item por linha), 'refItems'
@@ -121,13 +145,13 @@ interface FieldSpec {
121
145
  * `ref` vêm de fieldOptions[campo]) e 'icon' (string com o nome kebab-case da
122
146
  * paleta da casa — renderiza o <IconPicker>) e 'toggle-group' para escolhas
123
147
  * declarativas com opções ricas. */
124
- widget?: FieldWidget
148
+ widget?: FieldWidget;
125
149
  /** Renderiza o campo só quando true pro input atual (ex.: clientId só se !staff).
126
150
  * No modo COMPOSIÇÃO o condicional pode (e deve) ser JSX de quem diagrama. */
127
- showWhen?: (input: Record<string, unknown>) => boolean
151
+ showWhen?: (input: Record<string, unknown>) => boolean;
128
152
  /** Origem declarada das opções (static/dictionary/lookup) — ver a precedência
129
153
  * em `ActionFormField`. `dictionary` resolve pelos dicts do OpusProvider. */
130
- options?: OptionsSpec
154
+ options?: OptionsSpec;
131
155
  }
132
156
 
133
157
  /** Opções declaradas no FieldSpec: static direto; dictionary via provider; lookup
@@ -136,39 +160,42 @@ function optionsFromSpec(
136
160
  spec: OptionsSpec | undefined,
137
161
  dicts: Record<string, DictLike>,
138
162
  ): SelectOption[] | undefined {
139
- if (spec === undefined) return undefined
140
- if (spec.kind === 'static') {
163
+ if (spec === undefined) return undefined;
164
+ if (spec.kind === "static") {
141
165
  return spec.items.map((i) => ({
142
166
  value: i.value,
143
- label: typeof i.label === 'string' ? i.label : i.label.default,
144
- }))
167
+ label: typeof i.label === "string" ? i.label : i.label.default,
168
+ }));
145
169
  }
146
- if (spec.kind === 'dictionary') {
147
- const dict = dicts[spec.ref]
148
- if (dict === undefined) return undefined
149
- return dict.options().map((o) => ({ value: o.value, label: o.label }))
170
+ if (spec.kind === "dictionary") {
171
+ const dict = dicts[spec.ref];
172
+ if (dict === undefined) return undefined;
173
+ return dict.options().map((o) => ({ value: o.value, label: o.label }));
150
174
  }
151
- return undefined
175
+ return undefined;
152
176
  }
153
177
 
154
178
  /** Fallback zero-config: o schema do contrato CARREGA o vocabulário do `t.dict`
155
179
  * (meta `params.keys/entries`) — labels sem registry no provider. Cobre o campo
156
180
  * dict e o multiselect com elemento dict. */
157
181
  function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
158
- const { inner } = unwrap(schema)
159
- const el = inner instanceof z.ZodArray ? (inner._def as { type: ZodTypeAny }).type : inner
160
- const meta = getLogicalType(el as object)
161
- if (meta?.logicalType !== 'dict') return undefined
182
+ const { inner } = unwrap(schema);
183
+ const el =
184
+ inner instanceof z.ZodArray
185
+ ? (inner._def as { type: ZodTypeAny }).type
186
+ : inner;
187
+ const meta = getLogicalType(el as object);
188
+ if (meta?.logicalType !== "dict") return undefined;
162
189
  const params = (meta.params ?? {}) as {
163
- keys?: unknown
164
- entries?: Record<string, { label?: unknown }>
165
- }
166
- const { keys, entries } = params
167
- if (!Array.isArray(keys) || entries === undefined) return undefined
190
+ keys?: unknown;
191
+ entries?: Record<string, { label?: unknown }>;
192
+ };
193
+ const { keys, entries } = params;
194
+ if (!Array.isArray(keys) || entries === undefined) return undefined;
168
195
  return (keys as string[]).map((k) => {
169
- const label = entries[k]?.label
170
- return { value: k, label: typeof label === 'string' ? label : k }
171
- })
196
+ const label = entries[k]?.label;
197
+ return { value: k, label: typeof label === "string" ? label : k };
198
+ });
172
199
  }
173
200
 
174
201
  /**
@@ -182,8 +209,12 @@ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
182
209
  * o tooltip vem aberto); abre só no hover. preventDefault: clicar o ícone não dispara o label
183
210
  * (não foca o input / não toggla).
184
211
  */
185
- export function LabelHelp({ help }: { help: string | undefined }): React.ReactElement | null {
186
- if (help === undefined || help.trim().length === 0) return null
212
+ export function LabelHelp({
213
+ help,
214
+ }: {
215
+ help: string | undefined;
216
+ }): React.ReactElement | null {
217
+ if (help === undefined || help.trim().length === 0) return null;
187
218
  return (
188
219
  <TooltipProvider delayDuration={300}>
189
220
  <Tooltip>
@@ -194,8 +225,8 @@ export function LabelHelp({ help }: { help: string | undefined }): React.ReactEl
194
225
  aria-label="Ajuda"
195
226
  onClick={(e) => {
196
227
  // Clicar o ícone não ativa o label (não foca/toggla o campo).
197
- e.preventDefault()
198
- e.stopPropagation()
228
+ e.preventDefault();
229
+ e.stopPropagation();
199
230
  }}
200
231
  className="inline-flex shrink-0 cursor-help text-muted-foreground/50 transition-colors hover:text-muted-foreground"
201
232
  >
@@ -207,12 +238,16 @@ export function LabelHelp({ help }: { help: string | undefined }): React.ReactEl
207
238
  <TooltipContent>{help}</TooltipContent>
208
239
  </Tooltip>
209
240
  </TooltipProvider>
210
- )
241
+ );
211
242
  }
212
243
 
213
244
  /** Marca de obrigatório na label — derivada do Zod (campo sem optional/default). */
214
- function RequiredMark({ required }: { required: boolean }): React.ReactElement | null {
215
- if (!required) return null
245
+ function RequiredMark({
246
+ required,
247
+ }: {
248
+ required: boolean;
249
+ }): React.ReactElement | null {
250
+ if (!required) return null;
216
251
  return (
217
252
  <>
218
253
  <span aria-hidden className="ml-0.5 text-context-danger-emphasis">
@@ -220,17 +255,17 @@ function RequiredMark({ required }: { required: boolean }): React.ReactElement |
220
255
  </span>
221
256
  <span className="sr-only"> (obrigatório)</span>
222
257
  </>
223
- )
258
+ );
224
259
  }
225
260
 
226
261
  /** action.messages.* pode ser string ou I18nRef ({ key, default }). Resolve pra texto. */
227
262
  function msgText(m: unknown, fallback: string): string {
228
- if (typeof m === 'string') return m
229
- if (m !== null && typeof m === 'object' && 'default' in m) {
230
- const d = (m as { default: unknown }).default
231
- if (typeof d === 'string') return d
263
+ if (typeof m === "string") return m;
264
+ if (m !== null && typeof m === "object" && "default" in m) {
265
+ const d = (m as { default: unknown }).default;
266
+ if (typeof d === "string") return d;
232
267
  }
233
- return fallback
268
+ return fallback;
234
269
  }
235
270
 
236
271
  // =============================================================================
@@ -239,13 +274,13 @@ function msgText(m: unknown, fallback: string): string {
239
274
 
240
275
  export interface ActionFormContextValue {
241
276
  /** RHF do useFormAction. O shape é dinâmico — os paths entram como string. */
242
- form: UseFormReturn<Record<string, unknown>>
243
- shape: Record<string, ZodTypeAny | undefined>
244
- fields: Record<string, FieldSpec>
245
- fieldOptions?: Record<string, SelectOption[]> | undefined
277
+ form: UseFormReturn<Record<string, unknown>>;
278
+ shape: Record<string, ZodTypeAny | undefined>;
279
+ fields: Record<string, FieldSpec>;
280
+ fieldOptions?: Record<string, SelectOption[]> | undefined;
246
281
  }
247
282
 
248
- const ActionFormContext = createContext<ActionFormContextValue | null>(null)
283
+ const ActionFormContext = createContext<ActionFormContextValue | null>(null);
249
284
 
250
285
  /**
251
286
  * O contexto do `<ActionForm>` pai — a válvula de escape pra CONTROLE CUSTOM no
@@ -256,9 +291,12 @@ const ActionFormContext = createContext<ActionFormContextValue | null>(null)
256
291
  * zod-resolver, erro inline e submit continuam do contrato.
257
292
  */
258
293
  export function useActionFormContext(): ActionFormContextValue {
259
- const ctx = useContext(ActionFormContext)
260
- if (ctx === null) throw new Error('useActionFormContext precisa estar dentro de um <ActionForm>.')
261
- return ctx
294
+ const ctx = useContext(ActionFormContext);
295
+ if (ctx === null)
296
+ throw new Error(
297
+ "useActionFormContext precisa estar dentro de um <ActionForm>.",
298
+ );
299
+ return ctx;
262
300
  }
263
301
 
264
302
  // =============================================================================
@@ -267,95 +305,109 @@ export function useActionFormContext(): ActionFormContextValue {
267
305
 
268
306
  export interface ActionFormFieldProps {
269
307
  /** Nome do campo no input do contrato (a chave em `fields`/schema). */
270
- name: string
308
+ name: string;
271
309
  /** Opções por id de runtime — sobrepõe o fieldOptions do form e o z.enum. */
272
- options?: SelectOption[]
310
+ options?: SelectOption[];
273
311
  /** Classes do invólucro do campo (ex.: col-span-2 numa grid). */
274
- className?: string
312
+ className?: string;
275
313
  }
276
314
 
277
- export function ActionFormField({ name, options: optionsProp, className }: ActionFormFieldProps): React.ReactElement | null {
278
- const ctx = useContext(ActionFormContext)
279
- if (ctx === null) throw new Error('ActionFormField precisa estar dentro de um <ActionForm>.')
280
- const dicts = useDicts()
281
- const { form, shape, fields, fieldOptions } = ctx
315
+ export function ActionFormField({
316
+ name,
317
+ options: optionsProp,
318
+ className,
319
+ }: ActionFormFieldProps): React.ReactElement | null {
320
+ const ctx = useContext(ActionFormContext);
321
+ if (ctx === null)
322
+ throw new Error("ActionFormField precisa estar dentro de um <ActionForm>.");
323
+ const dicts = useDicts();
324
+ const { form, shape, fields, fieldOptions } = ctx;
282
325
  // Assina TODOS os valores: alimenta o showWhen e os widgets controlados.
283
- const formValues = form.watch()
326
+ const formValues = form.watch();
284
327
 
285
328
  const focusControl = (): void => {
286
- const control = document.getElementById(name)
287
- if (control?.dataset.slot === 'toggle-group') {
288
- control.querySelector<HTMLElement>('[data-slot="toggle-group-item"]:not([disabled])')?.focus()
289
- return
329
+ const control = document.getElementById(name);
330
+ if (control?.dataset.slot === "toggle-group") {
331
+ control
332
+ .querySelector<HTMLElement>(
333
+ '[data-slot="toggle-group-item"]:not([disabled])',
334
+ )
335
+ ?.focus();
336
+ return;
290
337
  }
291
- control?.focus()
292
- }
293
-
294
- const spec: FieldSpec = fields[name] ?? {}
295
- const fieldError = form.formState.errors[name]
296
- const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
297
- const errorId = errorMessage !== undefined ? `${name}-error` : undefined
298
- const describedBy = errorId
338
+ control?.focus();
339
+ };
340
+
341
+ const spec: FieldSpec = fields[name] ?? {};
342
+ const fieldError = form.formState.errors[name];
343
+ const errorMessage =
344
+ typeof fieldError?.message === "string" ? fieldError.message : undefined;
345
+ const errorId = errorMessage !== undefined ? `${name}-error` : undefined;
346
+ const describedBy = errorId;
299
347
  useEffect(() => {
300
- if (errorMessage === undefined) return
301
- const control = document.getElementById(name)
348
+ if (errorMessage === undefined) return;
349
+ const control = document.getElementById(name);
302
350
  const firstInvalidToggle = control
303
- ?.closest('form')
304
- ?.querySelector<HTMLElement>('[data-slot="toggle-group"][aria-invalid="true"]')
305
- if (firstInvalidToggle?.id === name) focusControl()
306
- }, [errorMessage, name])
307
-
308
- const fieldSchema = shape[name]
309
- if (fieldSchema === undefined) return null
310
- if (typeof spec.showWhen === 'function' && !spec.showWhen(formValues)) return null
311
-
312
- const inferred = inferFieldKind(fieldSchema)
351
+ ?.closest("form")
352
+ ?.querySelector<HTMLElement>(
353
+ '[data-slot="toggle-group"][aria-invalid="true"]',
354
+ );
355
+ if (firstInvalidToggle?.id === name) focusControl();
356
+ }, [errorMessage, name]);
357
+
358
+ const fieldSchema = shape[name];
359
+ if (fieldSchema === undefined) return null;
360
+ if (typeof spec.showWhen === "function" && !spec.showWhen(formValues))
361
+ return null;
362
+
363
+ const inferred = inferFieldKind(fieldSchema);
313
364
  // `widget` no FieldSpec sobrepõe o auto-detect (ex.: textarea pra string sem max).
314
365
  const fieldKind: FieldKind =
315
- spec.widget === 'textarea' || spec.widget === 'code'
316
- ? { kind: 'textarea', required: inferred.required }
317
- : spec.widget === 'lines'
318
- ? { kind: 'lines', required: inferred.required }
319
- : spec.widget === 'refItems'
320
- ? { kind: 'refItems', required: inferred.required }
321
- : spec.widget === 'icon'
322
- ? { kind: 'icon', required: inferred.required }
323
- : spec.widget === 'toggle-group'
366
+ spec.widget === "textarea" || spec.widget === "code"
367
+ ? { kind: "textarea", required: inferred.required }
368
+ : spec.widget === "lines"
369
+ ? { kind: "lines", required: inferred.required }
370
+ : spec.widget === "refItems"
371
+ ? { kind: "refItems", required: inferred.required }
372
+ : spec.widget === "icon"
373
+ ? { kind: "icon", required: inferred.required }
374
+ : spec.widget === "toggle-group"
324
375
  ? {
325
- kind: 'toggle-group',
376
+ kind: "toggle-group",
326
377
  required: inferred.required,
327
- multiple: inferred.kind === 'multiselect',
378
+ multiple: inferred.kind === "multiselect",
328
379
  options:
329
- inferred.kind === 'select' || inferred.kind === 'multiselect'
380
+ inferred.kind === "select" ||
381
+ inferred.kind === "multiselect"
330
382
  ? inferred.options
331
383
  : [],
332
384
  }
333
- : inferred
385
+ : inferred;
334
386
  // Precedência das opções: prop do campo > fieldOptions do form > spec.options
335
387
  // (dictionary via provider, static direto) > meta do t.dict no schema
336
388
  // (zero-config) > chaves cruas do z.enum.
337
- const runtimeOptions = optionsProp ?? fieldOptions?.[name]
338
- const specOptions = optionsFromSpec(spec.options, dicts)
339
- const declaredOptions = runtimeOptions ?? specOptions
389
+ const runtimeOptions = optionsProp ?? fieldOptions?.[name];
390
+ const specOptions = optionsFromSpec(spec.options, dicts);
391
+ const declaredOptions = runtimeOptions ?? specOptions;
340
392
  const options: SelectOption[] =
341
393
  declaredOptions ??
342
394
  dictMetaOptions(fieldSchema) ??
343
- (fieldKind.kind === 'select' ||
344
- fieldKind.kind === 'multiselect' ||
345
- fieldKind.kind === 'toggle-group'
395
+ (fieldKind.kind === "select" ||
396
+ fieldKind.kind === "multiselect" ||
397
+ fieldKind.kind === "toggle-group"
346
398
  ? fieldKind.options.map((o) => ({ value: o, label: o }))
347
- : [])
399
+ : []);
348
400
  // Campo texto COM opções declaradas (runtime ou spec) → single-select por-id.
349
401
  const effectiveKind: FieldKind =
350
- fieldKind.kind === 'text' && declaredOptions !== undefined
351
- ? { kind: 'select', required: fieldKind.required, options: [] }
352
- : fieldKind
402
+ fieldKind.kind === "text" && declaredOptions !== undefined
403
+ ? { kind: "select", required: fieldKind.required, options: [] }
404
+ : fieldKind;
353
405
 
354
406
  const setValue = (v: unknown): void =>
355
- form.setValue(name, v, { shouldValidate: true, shouldDirty: true })
407
+ form.setValue(name, v, { shouldValidate: true, shouldDirty: true });
356
408
 
357
409
  // Checkbox tem layout próprio (controle + label na mesma linha).
358
- if (fieldKind.kind === 'checkbox') {
410
+ if (fieldKind.kind === "checkbox") {
359
411
  return (
360
412
  <Field
361
413
  className={className}
@@ -377,9 +429,11 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
377
429
  </span>
378
430
  <LabelHelp help={spec.help} />
379
431
  </FieldLabel>
380
- {errorMessage !== undefined && <FieldError id={errorId}>{errorMessage}</FieldError>}
432
+ {errorMessage !== undefined && (
433
+ <FieldError id={errorId}>{errorMessage}</FieldError>
434
+ )}
381
435
  </Field>
382
- )
436
+ );
383
437
  }
384
438
 
385
439
  return (
@@ -389,7 +443,12 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
389
443
  aria-invalid={errorMessage !== undefined}
390
444
  aria-describedby={describedBy}
391
445
  >
392
- <FieldLabel id={`${name}-label`} htmlFor={name} onClick={focusControl} className="items-center gap-1.5">
446
+ <FieldLabel
447
+ id={`${name}-label`}
448
+ htmlFor={name}
449
+ onClick={focusControl}
450
+ className="items-center gap-1.5"
451
+ >
393
452
  <span>
394
453
  {spec.label ?? name}
395
454
  <RequiredMark required={fieldKind.required} />
@@ -397,7 +456,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
397
456
  <LabelHelp help={spec.help} />
398
457
  </FieldLabel>
399
458
 
400
- {effectiveKind.kind === 'toggle-group' ? (
459
+ {effectiveKind.kind === "toggle-group" ? (
401
460
  effectiveKind.multiple ? (
402
461
  <ToggleGroup
403
462
  type="multiple"
@@ -420,7 +479,9 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
420
479
  className="h-auto min-h-14 w-full cursor-pointer justify-start whitespace-normal p-3 text-left data-[state=on]:border-primary data-[state=on]:bg-primary/5 data-[state=on]:shadow-sm"
421
480
  >
422
481
  <span className="min-w-0">
423
- <span className="block">{option.content ?? option.label}</span>
482
+ <span className="block">
483
+ {option.content ?? option.label}
484
+ </span>
424
485
  {option.hint !== undefined && (
425
486
  <span className="mt-1 block text-xs font-normal text-muted-foreground">
426
487
  {option.hint}
@@ -434,10 +495,10 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
434
495
  <ToggleGroup
435
496
  type="single"
436
497
  id={name}
437
- value={(formValues[name] as string | undefined) ?? ''}
498
+ value={(formValues[name] as string | undefined) ?? ""}
438
499
  onValueChange={(value) => {
439
- if (value !== '' || !effectiveKind.required) {
440
- setValue(value === '' ? undefined : value)
500
+ if (value !== "" || !effectiveKind.required) {
501
+ setValue(value === "" ? undefined : value);
441
502
  }
442
503
  }}
443
504
  variant="outline"
@@ -457,7 +518,9 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
457
518
  className="h-auto min-h-14 w-full cursor-pointer justify-start whitespace-normal p-3 text-left data-[state=on]:border-primary data-[state=on]:bg-primary/5 data-[state=on]:shadow-sm"
458
519
  >
459
520
  <span className="min-w-0">
460
- <span className="block">{option.content ?? option.label}</span>
521
+ <span className="block">
522
+ {option.content ?? option.label}
523
+ </span>
461
524
  {option.hint !== undefined && (
462
525
  <span className="mt-1 block text-xs font-normal text-muted-foreground">
463
526
  {option.hint}
@@ -468,7 +531,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
468
531
  ))}
469
532
  </ToggleGroup>
470
533
  )
471
- ) : effectiveKind.kind === 'multiselect' ? (
534
+ ) : effectiveKind.kind === "multiselect" ? (
472
535
  <Select
473
536
  multiple
474
537
  searchable
@@ -476,16 +539,16 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
476
539
  value={(formValues[name] as string[] | undefined) ?? []}
477
540
  onChange={(v) => setValue(v)}
478
541
  options={options}
479
- placeholder={spec.placeholder ?? 'Selecione…'}
542
+ placeholder={spec.placeholder ?? "Selecione…"}
480
543
  className="w-full"
481
544
  aria-invalid={errorMessage !== undefined}
482
545
  aria-describedby={describedBy}
483
546
  />
484
- ) : effectiveKind.kind === 'select' ? (
547
+ ) : effectiveKind.kind === "select" ? (
485
548
  // w-full: campo de FORM alinha com os inputs (todos cheios) — coluna uniforme.
486
549
  <Select
487
550
  id={name}
488
- value={(formValues[name] as string | undefined) ?? ''}
551
+ value={(formValues[name] as string | undefined) ?? ""}
489
552
  onChange={(v) => setValue(v)}
490
553
  options={options}
491
554
  // A régua do próprio Select: lista curta e conhecida se percorre com o olho;
@@ -494,30 +557,33 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
494
557
  // saber se o dicionário dele cresceu. (Veio de patch da Grand Brasil; subiu em
495
558
  // 8.6.9 porque patch local morre calado a cada bump.)
496
559
  searchable={options.length > 6}
497
- placeholder={spec.placeholder ?? 'Selecione…'}
560
+ placeholder={spec.placeholder ?? "Selecione…"}
498
561
  className="w-full"
499
562
  aria-invalid={errorMessage !== undefined}
500
563
  aria-describedby={describedBy}
501
564
  />
502
- ) : effectiveKind.kind === 'lines' ? (
565
+ ) : effectiveKind.kind === "lines" ? (
503
566
  // Lista de strings num textarea: um item por linha. split/join round-trip
504
567
  // estável (linhas vazias do meio da digitação são filtradas no servidor).
505
568
  <Textarea
506
569
  id={name}
507
- placeholder={spec.placeholder ?? ''}
570
+ placeholder={spec.placeholder ?? ""}
508
571
  rows={4}
509
572
  className="max-h-80"
510
- value={((formValues[name] as string[] | undefined) ?? []).join('\n')}
511
- onChange={(e) => setValue(e.target.value.split('\n'))}
573
+ value={((formValues[name] as string[] | undefined) ?? []).join("\n")}
574
+ onChange={(e) => setValue(e.target.value.split("\n"))}
512
575
  aria-invalid={errorMessage !== undefined}
513
576
  aria-describedby={describedBy}
514
577
  />
515
- ) : effectiveKind.kind === 'refItems' ? (
578
+ ) : effectiveKind.kind === "refItems" ? (
516
579
  // Lista composta {ref, text}: cada linha = select de referência (opções de
517
580
  // fieldOptions) + texto. Estruturado de propósito — o ref nunca é texto livre.
518
581
  (() => {
519
- const items = (formValues[name] as Array<{ ref: string; text: string }> | undefined) ?? []
520
- const setItems = (next: Array<{ ref: string; text: string }>): void => setValue(next)
582
+ const items =
583
+ (formValues[name] as
584
+ Array<{ ref: string; text: string }> | undefined) ?? [];
585
+ const setItems = (next: Array<{ ref: string; text: string }>): void =>
586
+ setValue(next);
521
587
  return (
522
588
  <div className="space-y-2">
523
589
  {items.map((item, i) => (
@@ -525,7 +591,13 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
525
591
  <Select
526
592
  id={i === 0 ? name : `${name}-${i}-ref`}
527
593
  value={item.ref}
528
- onChange={(v) => setItems(items.map((it, j) => (j === i ? { ...it, ref: v } : it)))}
594
+ onChange={(v) =>
595
+ setItems(
596
+ items.map((it, j) =>
597
+ j === i ? { ...it, ref: v } : it,
598
+ ),
599
+ )
600
+ }
529
601
  options={options}
530
602
  placeholder="Papel…"
531
603
  className="w-44 shrink-0"
@@ -535,8 +607,14 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
535
607
  <Input
536
608
  id={`${name}-${i}-text`}
537
609
  value={item.text}
538
- placeholder={spec.placeholder ?? 'Descreva o critério…'}
539
- onChange={(e) => setItems(items.map((it, j) => (j === i ? { ...it, text: e.target.value } : it)))}
610
+ placeholder={spec.placeholder ?? "Descreva o critério…"}
611
+ onChange={(e) =>
612
+ setItems(
613
+ items.map((it, j) =>
614
+ j === i ? { ...it, text: e.target.value } : it,
615
+ ),
616
+ )
617
+ }
540
618
  aria-invalid={errorMessage !== undefined}
541
619
  aria-describedby={describedBy}
542
620
  />
@@ -557,33 +635,39 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
557
635
  variant="outline"
558
636
  size="sm"
559
637
  id={items.length === 0 ? name : undefined}
560
- onClick={() => setItems([...items, { ref: '', text: '' }])}
561
- aria-invalid={items.length === 0 && errorMessage !== undefined ? true : undefined}
638
+ onClick={() => setItems([...items, { ref: "", text: "" }])}
639
+ aria-invalid={
640
+ items.length === 0 && errorMessage !== undefined
641
+ ? true
642
+ : undefined
643
+ }
562
644
  aria-describedby={items.length === 0 ? describedBy : undefined}
563
645
  >
564
646
  Adicionar item
565
647
  </Button>
566
648
  </div>
567
- )
649
+ );
568
650
  })()
569
- ) : effectiveKind.kind === 'icon' ? (
651
+ ) : effectiveKind.kind === "icon" ? (
570
652
  // Widget 'icon': o value é o NOME do ícone (kebab-case) — ver <IconPicker>.
571
653
  <IconPicker
572
654
  id={name}
573
- value={(formValues[name] as string | undefined) ?? ''}
655
+ value={(formValues[name] as string | undefined) ?? ""}
574
656
  onChange={(v) => setValue(v)}
575
- placeholder={spec.placeholder ?? 'Selecione um ícone…'}
657
+ placeholder={spec.placeholder ?? "Selecione um ícone…"}
576
658
  aria-invalid={errorMessage !== undefined}
577
659
  aria-describedby={describedBy}
578
660
  />
579
- ) : effectiveKind.kind === 'textarea' ? (
661
+ ) : effectiveKind.kind === "textarea" ? (
580
662
  // max-h: o Textarea v4 auto-cresce (field-sizing-content) — capa e scrolla
581
663
  // o CAMPO, não o form/dialog inteiro. widget 'code' = monoespaçado (SKILL.md).
582
664
  <Textarea
583
665
  id={name}
584
- placeholder={spec.placeholder ?? ''}
666
+ placeholder={spec.placeholder ?? ""}
585
667
  rows={5}
586
- className={spec.widget === 'code' ? 'max-h-80 font-mono text-xs' : 'max-h-80'}
668
+ className={
669
+ spec.widget === "code" ? "max-h-80 font-mono text-xs" : "max-h-80"
670
+ }
587
671
  aria-invalid={errorMessage !== undefined}
588
672
  aria-describedby={describedBy}
589
673
  {...form.register(name)}
@@ -591,7 +675,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
591
675
  ) : (
592
676
  <Input
593
677
  id={name}
594
- placeholder={spec.placeholder ?? ''}
678
+ placeholder={spec.placeholder ?? ""}
595
679
  aria-invalid={errorMessage !== undefined}
596
680
  aria-describedby={describedBy}
597
681
  {...form.register(name)}
@@ -602,47 +686,53 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
602
686
  <FieldError id={errorId}>{errorMessage}</FieldError>
603
687
  )}
604
688
  </Field>
605
- )
689
+ );
606
690
  }
607
691
 
608
692
  // =============================================================================
609
693
  // ActionForm
610
694
  // =============================================================================
611
695
 
612
- export interface ActionFormProps<TInput extends Record<string, unknown>, TData> {
613
- action: FormContract<TInput, TData>
614
- defaultValues?: Partial<TInput>
615
- onSuccess?: (data: TData) => void
616
- submitLabel?: string
617
- cancelLabel?: string
618
- onCancel?: () => void
696
+ export interface ActionFormProps<
697
+ TInput extends Record<string, unknown>,
698
+ TData,
699
+ > {
700
+ action: FormContract<TInput, TData>;
701
+ defaultValues?: Partial<TInput>;
702
+ onSuccess?: (data: TData) => void;
703
+ submitLabel?: string;
704
+ cancelLabel?: string;
705
+ /** Tratamento visual do cancelamento. Use outline quando ele dividir o footer com a ação principal. */
706
+ cancelVariant?: Extract<ButtonVariant, "ghost" | "outline">;
707
+ onCancel?: () => void;
619
708
  /** Bloqueia campos e ações sem desmontar o formulário. */
620
- disabled?: boolean
709
+ disabled?: boolean;
621
710
  /** Informa carregamento para superfícies que coordenam ações como um grupo. */
622
- onLoadingChange?: (loading: boolean) => void
711
+ onLoadingChange?: (loading: boolean) => void;
623
712
  /** Opções por campo pra select/multiselect carregados em runtime (ex.: papéis/skills
624
713
  * por id). Sobrepõe as opções estáticas inferidas do z.enum. Chave = nome do campo. */
625
- fieldOptions?: Record<string, SelectOption[]>
714
+ fieldOptions?: Record<string, SelectOption[]>;
626
715
  /** Classes do <form> (layout externo — ex.: coluna flex no dialog). */
627
- className?: string
716
+ className?: string;
628
717
  /** Slot do CORPO: recebe os campos montados e decide o invólucro. Default: inline. No
629
718
  * dialog, o ActionFormDialog injeta `DialogBody` (a área que rola). */
630
- body?: (fields: React.ReactNode) => React.ReactNode
719
+ body?: (fields: React.ReactNode) => React.ReactNode;
631
720
  /** Slot do RODAPÉ: recebe os botões (Cancelar/Salvar) — com o estado do form e DENTRO
632
721
  * do <form> — e decide o invólucro. Default: faixa de ação simples. No dialog, o
633
722
  * ActionFormDialog injeta `DialogFooter` (a faixa da casa). */
634
- footer?: (actions: React.ReactNode) => React.ReactNode
723
+ footer?: (actions: React.ReactNode) => React.ReactNode;
635
724
  /** Modo COMPOSIÇÃO: diagrame os campos com <ActionFormField name/> (grid, seções,
636
725
  * condicionais em JSX). Sem children, o modo AUTO monta todos na ordem do contrato. */
637
- children?: React.ReactNode
726
+ children?: React.ReactNode;
638
727
  }
639
728
 
640
729
  export function ActionForm<TInput extends Record<string, unknown>, TData>({
641
730
  action,
642
731
  defaultValues,
643
732
  onSuccess,
644
- submitLabel = 'Salvar',
645
- cancelLabel = 'Cancelar',
733
+ submitLabel = "Salvar",
734
+ cancelLabel = "Cancelar",
735
+ cancelVariant = "ghost",
646
736
  onCancel,
647
737
  disabled = false,
648
738
  onLoadingChange,
@@ -652,35 +742,53 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
652
742
  footer,
653
743
  children,
654
744
  }: ActionFormProps<TInput, TData>) {
655
- const formId = useId()
656
- const { form, submit, isLoading, error, isSuccess } = useFormAction<TInput, TData>(action, {
657
- ...(defaultValues !== undefined ? { defaultValues: defaultValues as never } : {}),
745
+ const formId = useId();
746
+ const { form, submit, isLoading, error, isSuccess } = useFormAction<
747
+ TInput,
748
+ TData
749
+ >(action, {
750
+ ...(defaultValues !== undefined
751
+ ? { defaultValues: defaultValues as never }
752
+ : {}),
658
753
  onSuccess: (data) => {
659
- toast.success(msgText(action.messages?.success, 'Concluído'))
660
- onSuccess?.(data)
754
+ toast.success(msgText(action.messages?.success, "Concluído"));
755
+ onSuccess?.(data);
661
756
  },
662
757
  onError: (err) => {
663
- toast.error(humanizeActionError(err, msgText(action.messages?.error, 'Não foi possível salvar.')))
758
+ toast.error(
759
+ humanizeActionError(
760
+ err,
761
+ msgText(action.messages?.error, "Não foi possível salvar."),
762
+ ),
763
+ );
664
764
  },
665
- })
765
+ });
666
766
 
667
767
  useEffect(() => {
668
- onLoadingChange?.(isLoading)
669
- return () => onLoadingChange?.(false)
670
- }, [isLoading, onLoadingChange])
768
+ onLoadingChange?.(isLoading);
769
+ return () => onLoadingChange?.(false);
770
+ }, [isLoading, onLoadingChange]);
671
771
 
672
- const shape = (objectSchemaShape(action.input) ?? {}) as Record<string, ZodTypeAny | undefined>
673
- const fields = action.fields as Record<string, FieldSpec>
772
+ const shape = (objectSchemaShape(action.input) ?? {}) as Record<
773
+ string,
774
+ ZodTypeAny | undefined
775
+ >;
776
+ const fields = action.fields as Record<string, FieldSpec>;
674
777
 
675
778
  // Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta
676
779
  // DialogBody/DialogFooter (fonte única do scroll e da faixa). Os botões ficam DENTRO do
677
780
  // <form> nos dois casos — o submit exige isso; o que muda é só o invólucro.
678
- const wrapBody = body ?? ((fields: React.ReactNode) => fields)
781
+ const wrapBody = body ?? ((fields: React.ReactNode) => fields);
679
782
  const wrapFooter =
680
783
  footer ??
681
- ((actions: React.ReactNode) => (
682
- <div className="mt-6 flex shrink-0 justify-end gap-2">{actions}</div>
683
- ))
784
+ ((actions: React.ReactNode) =>
785
+ onCancel === undefined ? (
786
+ <div className="mt-6 flex shrink-0 justify-end">{actions}</div>
787
+ ) : (
788
+ <ButtonGroup mode="spaced" className="mt-6 shrink-0 justify-end">
789
+ {actions}
790
+ </ButtonGroup>
791
+ ));
684
792
 
685
793
  // Banner de erro do servidor (chrome do próprio form, nos dois modos). O FieldGroup
686
794
  // mantém o mesmo ritmo entre campos, blocos compostos e este estado. Só a frase de
@@ -691,9 +799,12 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
691
799
  role="alert"
692
800
  className="rounded-md border border-context-danger-border bg-context-danger-subtle p-3 text-sm text-context-danger-emphasis"
693
801
  >
694
- {humanizeActionError(error, msgText(action.messages?.error, 'Não foi possível salvar.'))}
802
+ {humanizeActionError(
803
+ error,
804
+ msgText(action.messages?.error, "Não foi possível salvar."),
805
+ )}
695
806
  </div>
696
- ) : null
807
+ ) : null;
697
808
 
698
809
  return (
699
810
  <ActionFormContext.Provider
@@ -704,13 +815,20 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
704
815
  fieldOptions,
705
816
  }}
706
817
  >
707
- <form id={formId} onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
818
+ <form
819
+ id={formId}
820
+ onSubmit={submit}
821
+ className={cn("flex flex-col", className)}
822
+ data-action={action.name}
823
+ >
708
824
  {wrapBody(
709
825
  <fieldset disabled={disabled || isLoading} className="contents">
710
826
  <FieldGroup>
711
827
  {children !== undefined
712
828
  ? children
713
- : Object.keys(fields).map((name) => <ActionFormField key={name} name={name} />)}
829
+ : Object.keys(fields).map((name) => (
830
+ <ActionFormField key={name} name={name} />
831
+ ))}
714
832
  {errorBanner}
715
833
  </FieldGroup>
716
834
  </fieldset>,
@@ -722,7 +840,7 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
722
840
  <Button
723
841
  type="button"
724
842
  form={formId}
725
- variant="ghost"
843
+ variant={cancelVariant}
726
844
  disabled={disabled || isLoading}
727
845
  onClick={onCancel}
728
846
  >
@@ -743,5 +861,5 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
743
861
  )}
744
862
  </form>
745
863
  </ActionFormContext.Provider>
746
- )
864
+ );
747
865
  }