@softize/opus 12.6.3 → 12.7.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 (35) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +20 -2
  3. package/bin/cli.mjs +10 -1
  4. package/bin/lib/gen-manifest.mjs +1 -0
  5. package/bin/lib/gen-runner.mjs +29 -1
  6. package/bin/lib/seed-runner.mjs +152 -0
  7. package/bin/lib/seed.mjs +229 -0
  8. package/docs/adr/0002-structured-seeds-are-declared-and-bound.md +130 -0
  9. package/docs/protocol.md +10 -1
  10. package/docs/seeds.md +132 -0
  11. package/package.json +5 -1
  12. package/registry/instructions/opus.md +4 -0
  13. package/registry/skills/apply-opus-seed/SKILL.md +45 -0
  14. package/registry/skills/apply-opus-seed/agents/openai.yaml +4 -0
  15. package/registry/skills/apply-opus-seed/references/evaluations.md +8 -0
  16. package/registry/skills/create-opus-seed/SKILL.md +55 -0
  17. package/registry/skills/create-opus-seed/agents/openai.yaml +4 -0
  18. package/registry/skills/create-opus-seed/references/contract.md +16 -0
  19. package/registry/skills/create-opus-seed/references/evaluations.md +8 -0
  20. package/registry/skills/create-opus-seed/scripts/scaffold.mjs +78 -0
  21. package/registry/skills/implement-opus-change/SKILL.md +3 -1
  22. package/src/core/index.ts +2 -0
  23. package/src/core/types.ts +16 -1
  24. package/src/seed/index.ts +391 -0
  25. package/src/ui/components/patterns/form.tsx +131 -19
  26. package/src/ui/components/primitives/detail.tsx +113 -0
  27. package/src/ui/components/primitives/tabs.tsx +1 -1
  28. package/src/ui/docs/content/action-form.md +10 -0
  29. package/src/ui/docs/content/cli.md +17 -0
  30. package/src/ui/docs/content/detail.md +38 -0
  31. package/src/ui/docs/registry.tsx +2 -0
  32. package/src/ui/drivers/react.tsx +3 -2
  33. package/src/ui/lib/object-schema.ts +36 -0
  34. package/src/ui/meta.ts +7 -1
  35. package/src/ui/react.tsx +9 -0
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Seeds estruturados são operações de projeto, não actions do runtime.
3
+ *
4
+ * `defineSeed` mantém a declaração pura e compartilhável. `bindSeed` liga a
5
+ * implementação server-only que a CLI carrega pelo `opus.config.ts`.
6
+ */
7
+
8
+ export interface SeedProfile {
9
+ /** Situação que este perfil representa para quem vai aplicá-lo. */
10
+ description: string
11
+ /** Métricas que `verify` precisa encontrar após uma aplicação bem-sucedida. */
12
+ expected?: Readonly<Record<string, number>>
13
+ }
14
+
15
+ export interface SeedSafety {
16
+ /** Escopos de dados em que o seed pode abrir conexão. */
17
+ scopes: readonly SeedScope[]
18
+ }
19
+
20
+ export type SeedScope = 'local' | 'test' | 'isolated-preview'
21
+
22
+ export interface SeedDefinition {
23
+ /** Identidade estável, em segmentos lowercase separados por ponto. */
24
+ name: string
25
+ /** Versão positiva do dataset e de suas invariantes. */
26
+ version: number
27
+ description: string
28
+ profiles: Readonly<Record<string, SeedProfile>>
29
+ defaultProfile: string
30
+ dependsOn?: readonly string[]
31
+ safety: SeedSafety
32
+ }
33
+
34
+ export interface SeedContext<Database = unknown> {
35
+ database: Database | null
36
+ profile: string
37
+ scope: SeedScope
38
+ }
39
+
40
+ export interface SeedTargetContext<Database = unknown> {
41
+ database: Database | null
42
+ scope: SeedScope
43
+ }
44
+
45
+ /**
46
+ * Atesta que a conexão aberta corresponde ao escopo solicitado, antes de qualquer binding.
47
+ * Deve retornar `true` explicitamente; ausência de retorno e valores falsos falham fechado.
48
+ */
49
+ export type SeedTargetAssertion<Database = unknown> = (
50
+ context: SeedTargetContext<Database>,
51
+ ) => boolean | Promise<boolean>
52
+
53
+ export interface SeedPlan {
54
+ summary: string
55
+ operations: readonly string[]
56
+ }
57
+
58
+ export interface SeedReport {
59
+ summary: string
60
+ metrics: Readonly<Record<string, number>>
61
+ }
62
+
63
+ export interface SeedBinding<Database = unknown> {
64
+ plan(context: SeedContext<Database>): SeedPlan | Promise<SeedPlan>
65
+ apply(context: SeedContext<Database>): SeedReport | Promise<SeedReport>
66
+ verify(context: SeedContext<Database>): SeedReport | Promise<SeedReport>
67
+ }
68
+
69
+ export interface BoundSeed<Database = unknown> extends SeedDefinition {
70
+ readonly binding: SeedBinding<Database>
71
+ }
72
+
73
+ export type SeedRegistryItem = SeedDefinition | BoundSeed
74
+
75
+ export interface SeedDiagnostic {
76
+ code:
77
+ | 'seed.duplicate'
78
+ | 'seed.unbound'
79
+ | 'seed.missing_dependency'
80
+ | 'seed.cyclic_dependency'
81
+ message: string
82
+ seed: string
83
+ }
84
+
85
+ export interface SeedRegistryCheck {
86
+ ok: boolean
87
+ diagnostics: readonly SeedDiagnostic[]
88
+ /** Ordem topológica; dependências aparecem antes de seus consumidores. */
89
+ ordered: readonly BoundSeed[]
90
+ }
91
+
92
+ const SEED_NAME_RE = /^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/
93
+ const PROFILE_RE = /^[a-z][a-z0-9-]*$/
94
+ const SCOPE_RE = /^[a-z][a-z0-9-]*$/
95
+ const SEED_SCOPES = new Set<SeedScope>(['local', 'test', 'isolated-preview'])
96
+
97
+ export function defineSeed<const Definition extends SeedDefinition>(
98
+ definition: Definition,
99
+ ): Definition {
100
+ validateDefinition(definition)
101
+ return definition
102
+ }
103
+
104
+ /**
105
+ * Projeta somente os metadados públicos do seed.
106
+ *
107
+ * A projeção é deliberadamente profunda: objetos recebidos de TypeScript ou
108
+ * JavaScript podem carregar campos adicionais, mas manifestos e respostas da
109
+ * CLI nunca devem reproduzi-los por espalhamento ou referência.
110
+ */
111
+ export function publicSeedDefinition(seed: SeedDefinition): SeedDefinition {
112
+ validateDefinition(seed)
113
+ return {
114
+ name: seed.name,
115
+ version: seed.version,
116
+ description: seed.description,
117
+ profiles: Object.fromEntries(
118
+ Object.entries(seed.profiles).map(([name, profile]) => [
119
+ name,
120
+ {
121
+ description: profile.description,
122
+ ...(profile.expected === undefined ? {} : { expected: { ...profile.expected } }),
123
+ },
124
+ ]),
125
+ ),
126
+ defaultProfile: seed.defaultProfile,
127
+ ...(seed.dependsOn === undefined ? {} : { dependsOn: [...seed.dependsOn] }),
128
+ safety: { scopes: [...seed.safety.scopes] },
129
+ }
130
+ }
131
+
132
+ export function bindSeed<Database = unknown>(
133
+ definition: SeedDefinition,
134
+ binding: SeedBinding<Database>,
135
+ ): BoundSeed<Database> {
136
+ validateDefinition(definition)
137
+ validateBinding(binding)
138
+ return { ...definition, binding }
139
+ }
140
+
141
+ export function isSeedDefinition(value: unknown): value is SeedDefinition {
142
+ try {
143
+ validateDefinition(value)
144
+ return true
145
+ } catch {
146
+ return false
147
+ }
148
+ }
149
+
150
+ export function isBoundSeed(value: unknown): value is BoundSeed {
151
+ if (!isSeedDefinition(value)) return false
152
+ try {
153
+ validateBinding((value as Partial<BoundSeed>).binding)
154
+ return true
155
+ } catch {
156
+ return false
157
+ }
158
+ }
159
+
160
+ export function checkSeedRegistry(items: readonly SeedRegistryItem[]): SeedRegistryCheck {
161
+ const diagnostics: SeedDiagnostic[] = []
162
+ const byName = new Map<string, BoundSeed>()
163
+ const seen = new Set<string>()
164
+
165
+ for (const item of items) {
166
+ validateDefinition(item)
167
+ if (seen.has(item.name)) {
168
+ diagnostics.push({
169
+ code: 'seed.duplicate',
170
+ seed: item.name,
171
+ message: `Seed duplicado: ${item.name}`,
172
+ })
173
+ continue
174
+ }
175
+ seen.add(item.name)
176
+ if (!isBoundSeed(item)) {
177
+ diagnostics.push({
178
+ code: 'seed.unbound',
179
+ seed: item.name,
180
+ message: `Seed ${item.name} precisa de plan, apply e verify via bindSeed`,
181
+ })
182
+ continue
183
+ }
184
+ byName.set(item.name, item)
185
+ }
186
+
187
+ for (const item of items) {
188
+ for (const dependency of item.dependsOn ?? []) {
189
+ if (!seen.has(dependency)) {
190
+ diagnostics.push({
191
+ code: 'seed.missing_dependency',
192
+ seed: item.name,
193
+ message: `Seed ${item.name} depende de ${dependency}, que não está registrado`,
194
+ })
195
+ }
196
+ }
197
+ }
198
+
199
+ const ordered: BoundSeed[] = []
200
+ const visiting = new Set<string>()
201
+ const visited = new Set<string>()
202
+ const cycles = new Set<string>()
203
+
204
+ const visit = (name: string, path: readonly string[]): void => {
205
+ if (visited.has(name)) return
206
+ if (visiting.has(name)) {
207
+ const start = path.indexOf(name)
208
+ const cycle = [...path.slice(start), name]
209
+ const key = cycle.join(' -> ')
210
+ if (!cycles.has(key)) {
211
+ cycles.add(key)
212
+ diagnostics.push({
213
+ code: 'seed.cyclic_dependency',
214
+ seed: name,
215
+ message: `Dependência cíclica entre seeds: ${key}`,
216
+ })
217
+ }
218
+ return
219
+ }
220
+ const seed = byName.get(name)
221
+ if (seed === undefined) return
222
+ visiting.add(name)
223
+ for (const dependency of seed.dependsOn ?? []) visit(dependency, [...path, name])
224
+ visiting.delete(name)
225
+ visited.add(name)
226
+ ordered.push(seed)
227
+ }
228
+
229
+ for (const name of byName.keys()) visit(name, [])
230
+
231
+ return {
232
+ ok: diagnostics.length === 0,
233
+ diagnostics,
234
+ ordered: diagnostics.some((diagnostic) => diagnostic.code === 'seed.cyclic_dependency')
235
+ ? []
236
+ : ordered,
237
+ }
238
+ }
239
+
240
+ export function resolveSeedProfile(seed: SeedDefinition, requested?: string): string {
241
+ const profile = requested ?? seed.defaultProfile
242
+ if (!Object.hasOwn(seed.profiles, profile)) {
243
+ throw new Error(
244
+ `Perfil ${profile} não existe no seed ${seed.name}; disponíveis: ${Object.keys(seed.profiles).join(', ')}`,
245
+ )
246
+ }
247
+ return profile
248
+ }
249
+
250
+ export function assertSeedExecutionAllowed(
251
+ seed: SeedDefinition,
252
+ scope: string | undefined,
253
+ environment = runtimeNodeEnvironment(),
254
+ ): asserts scope is SeedScope {
255
+ const normalizedEnvironment = environment?.trim().toLowerCase()
256
+ if (normalizedEnvironment !== 'development' && normalizedEnvironment !== 'test') {
257
+ throw new Error('Seeds exigem NODE_ENV=development ou NODE_ENV=test antes de abrir conexão')
258
+ }
259
+ if (scope === undefined || scope.trim().length === 0) {
260
+ throw new Error('Informe --scope ou OPUS_SEED_SCOPE antes de abrir o banco')
261
+ }
262
+ if (!seed.safety.scopes.some((allowedScope) => allowedScope === scope)) {
263
+ throw new Error(
264
+ `Escopo ${scope} não é permitido para ${seed.name}; permitidos: ${seed.safety.scopes.join(', ')}`,
265
+ )
266
+ }
267
+ }
268
+
269
+ function runtimeNodeEnvironment(): string | undefined {
270
+ return (
271
+ globalThis as typeof globalThis & {
272
+ process?: { env?: { NODE_ENV?: string } }
273
+ }
274
+ ).process?.env?.NODE_ENV
275
+ }
276
+
277
+ export function validateSeedPlan(value: unknown): SeedPlan {
278
+ if (!isRecord(value) || !isNonEmptyString(value.summary) || !Array.isArray(value.operations)) {
279
+ throw new Error('plan precisa retornar { summary, operations }')
280
+ }
281
+ if (!value.operations.every(isNonEmptyString)) {
282
+ throw new Error('plan.operations precisa conter somente descrições não vazias')
283
+ }
284
+ return value as unknown as SeedPlan
285
+ }
286
+
287
+ export function validateSeedReport(value: unknown): SeedReport {
288
+ if (!isRecord(value) || !isNonEmptyString(value.summary) || !isMetrics(value.metrics)) {
289
+ throw new Error('apply/verify precisa retornar { summary, metrics } com valores numéricos finitos')
290
+ }
291
+ return value as unknown as SeedReport
292
+ }
293
+
294
+ export function assertSeedExpectedMetrics(
295
+ seed: SeedDefinition,
296
+ profile: string,
297
+ report: SeedReport,
298
+ ): void {
299
+ const expected = seed.profiles[profile]?.expected
300
+ if (expected === undefined) return
301
+ const mismatches = Object.entries(expected).flatMap(([metric, wanted]) => {
302
+ const actual = report.metrics[metric]
303
+ return actual === wanted ? [] : [`${metric}: esperado ${wanted}, encontrado ${String(actual)}`]
304
+ })
305
+ if (mismatches.length > 0) {
306
+ throw new Error(`Verificação de ${seed.name} falhou — ${mismatches.join('; ')}`)
307
+ }
308
+ }
309
+
310
+ function validateDefinition(value: unknown): asserts value is SeedDefinition {
311
+ if (!isRecord(value)) throw new Error('Seed precisa ser um objeto')
312
+ if (typeof value.name !== 'string' || !SEED_NAME_RE.test(value.name)) {
313
+ throw new Error('Seed name deve usar ao menos dois segmentos lowercase, como customers.scenarios')
314
+ }
315
+ if (!Number.isInteger(value.version) || Number(value.version) <= 0) {
316
+ throw new Error(`Seed ${value.name} precisa de version inteira positiva`)
317
+ }
318
+ if (!isNonEmptyString(value.description)) {
319
+ throw new Error(`Seed ${value.name} precisa de description`)
320
+ }
321
+ if (!isRecord(value.profiles) || Object.keys(value.profiles).length === 0) {
322
+ throw new Error(`Seed ${value.name} precisa de ao menos um profile`)
323
+ }
324
+ for (const [profileName, profile] of Object.entries(value.profiles)) {
325
+ if (!PROFILE_RE.test(profileName)) {
326
+ throw new Error(`Profile ${profileName} de ${value.name} possui nome inválido`)
327
+ }
328
+ if (!isRecord(profile) || !isNonEmptyString(profile.description)) {
329
+ throw new Error(`Profile ${profileName} de ${value.name} precisa de description`)
330
+ }
331
+ if (profile.expected !== undefined && !isMetrics(profile.expected)) {
332
+ throw new Error(`Profile ${profileName} de ${value.name} possui métricas esperadas inválidas`)
333
+ }
334
+ }
335
+ if (typeof value.defaultProfile !== 'string' || !Object.hasOwn(value.profiles, value.defaultProfile)) {
336
+ throw new Error(`defaultProfile de ${value.name} precisa apontar para um profile existente`)
337
+ }
338
+ if (value.dependsOn !== undefined) {
339
+ if (!Array.isArray(value.dependsOn)) throw new Error(`dependsOn de ${value.name} precisa ser uma lista`)
340
+ const dependencies = value.dependsOn
341
+ const unique = new Set(dependencies)
342
+ if (
343
+ dependencies.some((dependency) => typeof dependency !== 'string' || !SEED_NAME_RE.test(dependency)) ||
344
+ unique.size !== dependencies.length ||
345
+ unique.has(value.name)
346
+ ) {
347
+ throw new Error(`dependsOn de ${value.name} possui dependência inválida, repetida ou autorreferente`)
348
+ }
349
+ }
350
+ if (!isRecord(value.safety) || !Array.isArray(value.safety.scopes) || value.safety.scopes.length === 0) {
351
+ throw new Error(`Seed ${value.name} precisa declarar safety.scopes`)
352
+ }
353
+ const scopes = value.safety.scopes
354
+ if (
355
+ scopes.some((scope) => typeof scope !== 'string' || !SCOPE_RE.test(scope)) ||
356
+ new Set(scopes).size !== scopes.length ||
357
+ scopes.some((scope) => !SEED_SCOPES.has(scope as SeedScope))
358
+ ) {
359
+ throw new Error(
360
+ `safety.scopes de ${value.name} aceita somente local, test e isolated-preview, sem repetições`,
361
+ )
362
+ }
363
+ }
364
+
365
+ function validateBinding(value: unknown): asserts value is SeedBinding {
366
+ if (
367
+ !isRecord(value) ||
368
+ typeof value.plan !== 'function' ||
369
+ typeof value.apply !== 'function' ||
370
+ typeof value.verify !== 'function'
371
+ ) {
372
+ throw new Error('bindSeed exige as funções plan, apply e verify')
373
+ }
374
+ }
375
+
376
+ function isMetrics(value: unknown): value is Readonly<Record<string, number>> {
377
+ return (
378
+ isRecord(value) &&
379
+ Object.entries(value).every(
380
+ ([name, metric]) => isNonEmptyString(name) && typeof metric === 'number' && Number.isFinite(metric),
381
+ )
382
+ )
383
+ }
384
+
385
+ function isRecord(value: unknown): value is Record<string, unknown> {
386
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
387
+ }
388
+
389
+ function isNonEmptyString(value: unknown): value is string {
390
+ return typeof value === 'string' && value.trim().length > 0
391
+ }
@@ -17,12 +17,18 @@
17
17
  * inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
18
18
  */
19
19
 
20
- import { createContext, useContext } from 'react'
20
+ import { createContext, useContext, useEffect } from 'react'
21
21
  import type { UseFormReturn } from 'react-hook-form'
22
- import { getLogicalType, type FormContract, type OptionsSpec } from '../../../core/index.ts'
22
+ import {
23
+ getLogicalType,
24
+ type FieldWidget,
25
+ type FormContract,
26
+ type OptionsSpec,
27
+ } from '../../../core/index.ts'
23
28
  import { useDicts, useFormAction, type DictLike } from '../../drivers/react.tsx'
24
29
  import { z, type ZodTypeAny } from 'zod'
25
30
  import { cn } from '../../lib/cn.ts'
31
+ import { objectSchemaShape } from '../../lib/object-schema.ts'
26
32
  import { toast } from '../primitives/sonner.tsx'
27
33
  import { Button } from '../primitives/button.tsx'
28
34
  import { Input } from '../primitives/input.tsx'
@@ -32,6 +38,7 @@ import { IconPicker } from '../primitives/icon-picker.tsx'
32
38
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
33
39
  import { Info } from 'lucide-react'
34
40
  import { Select, type SelectOption } from '../primitives/select.tsx'
41
+ import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
35
42
  import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
36
43
 
37
44
  // =============================================================================
@@ -44,6 +51,7 @@ type FieldKind =
44
51
  | { kind: 'checkbox'; required: boolean }
45
52
  | { kind: 'select'; required: boolean; options: string[] }
46
53
  | { kind: 'multiselect'; required: boolean; options: string[] }
54
+ | { kind: 'toggle-group'; required: boolean; multiple: boolean; options: string[] }
47
55
  | { kind: 'lines'; required: boolean }
48
56
  | { kind: 'refItems'; required: boolean }
49
57
  | { kind: 'icon'; required: boolean }
@@ -111,8 +119,9 @@ interface FieldSpec {
111
119
  * 'lines' (z.array(z.string()) num textarea, um item por linha), 'refItems'
112
120
  * (z.array(z.object({ref, text})) — linhas de referência + texto; as opções do
113
121
  * `ref` vêm de fieldOptions[campo]) e 'icon' (string com o nome kebab-case da
114
- * paleta da casa — renderiza o <IconPicker>). */
115
- widget?: string
122
+ * paleta da casa — renderiza o <IconPicker>) e 'toggle-group' para escolhas
123
+ * declarativas com opções ricas. */
124
+ widget?: FieldWidget
116
125
  /** Renderiza o campo só quando true pro input atual (ex.: clientId só se !staff).
117
126
  * No modo COMPOSIÇÃO o condicional pode (e deve) ser JSX de quem diagrama. */
118
127
  showWhen?: (input: Record<string, unknown>) => boolean
@@ -194,14 +203,16 @@ function LabelHelp({ help }: { help: string | undefined }): React.ReactElement |
194
203
  )
195
204
  }
196
205
 
197
- /** Asterisco de obrigatório na label — derivado do Zod (campo sem optional/default).
198
- * aria-hidden: leitor de tela valida pelo erro do resolver, não pelo símbolo. */
206
+ /** Marca de obrigatório na label — derivada do Zod (campo sem optional/default). */
199
207
  function RequiredMark({ required }: { required: boolean }): React.ReactElement | null {
200
208
  if (!required) return null
201
209
  return (
202
- <span aria-hidden className="ml-0.5 text-destructive">
203
- *
204
- </span>
210
+ <>
211
+ <span aria-hidden className="ml-0.5 text-destructive">
212
+ *
213
+ </span>
214
+ <span className="sr-only"> (obrigatório)</span>
215
+ </>
205
216
  )
206
217
  }
207
218
 
@@ -264,7 +275,30 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
264
275
  // Assina TODOS os valores: alimenta o showWhen e os widgets controlados.
265
276
  const formValues = form.watch()
266
277
 
278
+ const focusControl = (): void => {
279
+ const control = document.getElementById(name)
280
+ if (control?.dataset.slot === 'toggle-group') {
281
+ control.querySelector<HTMLElement>('[data-slot="toggle-group-item"]:not([disabled])')?.focus()
282
+ return
283
+ }
284
+ control?.focus()
285
+ }
286
+
267
287
  const spec: FieldSpec = fields[name] ?? {}
288
+ const fieldError = form.formState.errors[name]
289
+ const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
290
+ const descriptionId = spec.hint !== undefined ? `${name}-description` : undefined
291
+ const errorId = errorMessage !== undefined ? `${name}-error` : undefined
292
+ const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
293
+ useEffect(() => {
294
+ if (errorMessage === undefined) return
295
+ const control = document.getElementById(name)
296
+ const firstInvalidToggle = control
297
+ ?.closest('form')
298
+ ?.querySelector<HTMLElement>('[data-slot="toggle-group"][aria-invalid="true"]')
299
+ if (firstInvalidToggle?.id === name) focusControl()
300
+ }, [errorMessage, name])
301
+
268
302
  const fieldSchema = shape[name]
269
303
  if (fieldSchema === undefined) return null
270
304
  if (typeof spec.showWhen === 'function' && !spec.showWhen(formValues)) return null
@@ -280,12 +314,17 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
280
314
  ? { kind: 'refItems', required: inferred.required }
281
315
  : spec.widget === 'icon'
282
316
  ? { kind: 'icon', required: inferred.required }
283
- : inferred
284
- const fieldError = form.formState.errors[name]
285
- const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
286
- const descriptionId = spec.hint !== undefined ? `${name}-description` : undefined
287
- const errorId = errorMessage !== undefined ? `${name}-error` : undefined
288
- const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
317
+ : spec.widget === 'toggle-group'
318
+ ? {
319
+ kind: 'toggle-group',
320
+ required: inferred.required,
321
+ multiple: inferred.kind === 'multiselect',
322
+ options:
323
+ inferred.kind === 'select' || inferred.kind === 'multiselect'
324
+ ? inferred.options
325
+ : [],
326
+ }
327
+ : inferred
289
328
  // Precedência das opções: prop do campo > fieldOptions do form > spec.options
290
329
  // (dictionary via provider, static direto) > meta do t.dict no schema
291
330
  // (zero-config) > chaves cruas do z.enum.
@@ -295,7 +334,9 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
295
334
  const options: SelectOption[] =
296
335
  declaredOptions ??
297
336
  dictMetaOptions(fieldSchema) ??
298
- (fieldKind.kind === 'select' || fieldKind.kind === 'multiselect'
337
+ (fieldKind.kind === 'select' ||
338
+ fieldKind.kind === 'multiselect' ||
339
+ fieldKind.kind === 'toggle-group'
299
340
  ? fieldKind.options.map((o) => ({ value: o, label: o }))
300
341
  : [])
301
342
  // Campo texto COM opções declaradas (runtime ou spec) → single-select por-id.
@@ -343,7 +384,7 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
343
384
  aria-invalid={errorMessage !== undefined}
344
385
  aria-describedby={describedBy}
345
386
  >
346
- <FieldLabel htmlFor={name} className="items-center gap-1.5">
387
+ <FieldLabel id={`${name}-label`} htmlFor={name} onClick={focusControl} className="items-center gap-1.5">
347
388
  <span>
348
389
  {spec.label ?? name}
349
390
  <RequiredMark required={fieldKind.required} />
@@ -351,7 +392,78 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
351
392
  <LabelHelp help={spec.help} />
352
393
  </FieldLabel>
353
394
 
354
- {effectiveKind.kind === 'multiselect' ? (
395
+ {effectiveKind.kind === 'toggle-group' ? (
396
+ effectiveKind.multiple ? (
397
+ <ToggleGroup
398
+ type="multiple"
399
+ id={name}
400
+ value={(formValues[name] as string[] | undefined) ?? []}
401
+ onValueChange={(value) => setValue(value)}
402
+ variant="outline"
403
+ spacing={2}
404
+ aria-labelledby={`${name}-label`}
405
+ aria-invalid={errorMessage !== undefined}
406
+ aria-describedby={describedBy}
407
+ className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] items-stretch gap-2"
408
+ >
409
+ {options.map((option) => (
410
+ <ToggleGroupItem
411
+ key={option.value}
412
+ value={option.value}
413
+ disabled={option.disabled}
414
+ aria-label={option.label}
415
+ 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"
416
+ >
417
+ <span className="min-w-0">
418
+ <span className="block">{option.content ?? option.label}</span>
419
+ {option.hint !== undefined && (
420
+ <span className="mt-1 block text-xs font-normal text-muted-foreground">
421
+ {option.hint}
422
+ </span>
423
+ )}
424
+ </span>
425
+ </ToggleGroupItem>
426
+ ))}
427
+ </ToggleGroup>
428
+ ) : (
429
+ <ToggleGroup
430
+ type="single"
431
+ id={name}
432
+ value={(formValues[name] as string | undefined) ?? ''}
433
+ onValueChange={(value) => {
434
+ if (value !== '' || !effectiveKind.required) {
435
+ setValue(value === '' ? undefined : value)
436
+ }
437
+ }}
438
+ variant="outline"
439
+ spacing={2}
440
+ aria-labelledby={`${name}-label`}
441
+ aria-required={effectiveKind.required}
442
+ aria-invalid={errorMessage !== undefined}
443
+ aria-describedby={describedBy}
444
+ className="grid w-full grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] items-stretch gap-2"
445
+ >
446
+ {options.map((option) => (
447
+ <ToggleGroupItem
448
+ key={option.value}
449
+ value={option.value}
450
+ disabled={option.disabled}
451
+ aria-label={option.label}
452
+ 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"
453
+ >
454
+ <span className="min-w-0">
455
+ <span className="block">{option.content ?? option.label}</span>
456
+ {option.hint !== undefined && (
457
+ <span className="mt-1 block text-xs font-normal text-muted-foreground">
458
+ {option.hint}
459
+ </span>
460
+ )}
461
+ </span>
462
+ </ToggleGroupItem>
463
+ ))}
464
+ </ToggleGroup>
465
+ )
466
+ ) : effectiveKind.kind === 'multiselect' ? (
355
467
  <Select
356
468
  multiple
357
469
  searchable
@@ -543,7 +655,7 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
543
655
  },
544
656
  })
545
657
 
546
- const shape = (action.input as unknown as z.ZodObject<z.ZodRawShape>).shape
658
+ const shape = (objectSchemaShape(action.input) ?? {}) as Record<string, ZodTypeAny | undefined>
547
659
  const fields = action.fields as Record<string, FieldSpec>
548
660
 
549
661
  // Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta