@softize/opus 8.6.6

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 (286) hide show
  1. package/CHANGELOG.md +1616 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/bin/cli.mjs +528 -0
  5. package/bin/lib/check.mjs +307 -0
  6. package/bin/lib/components.mjs +151 -0
  7. package/bin/lib/create.mjs +208 -0
  8. package/bin/lib/db-check-runner.mjs +86 -0
  9. package/bin/lib/db-migrate-runner.mjs +89 -0
  10. package/bin/lib/db-scaffold-runner.mjs +84 -0
  11. package/bin/lib/db.mjs +261 -0
  12. package/bin/lib/docs-include.mjs +48 -0
  13. package/bin/lib/gen-dicts.mjs +134 -0
  14. package/bin/lib/gen-docs.mjs +288 -0
  15. package/bin/lib/gen-manifest.mjs +102 -0
  16. package/bin/lib/gen-openapi.mjs +195 -0
  17. package/bin/lib/gen-runner.mjs +472 -0
  18. package/bin/lib/gen-stubs.mjs +463 -0
  19. package/bin/lib/gen.mjs +311 -0
  20. package/bin/lib/init.mjs +514 -0
  21. package/bin/lib/introspect.mjs +107 -0
  22. package/bin/lib/mcp.mjs +85 -0
  23. package/bin/lib/postinstall.mjs +56 -0
  24. package/docs/chat-event-protocol.md +85 -0
  25. package/docs/code-style.md +16 -0
  26. package/docs/data-layer.md +246 -0
  27. package/docs/ownership-vs-shadcn-lock.md +102 -0
  28. package/docs/protocol.md +2053 -0
  29. package/docs/releasing.md +110 -0
  30. package/docs/shellnav.md +131 -0
  31. package/package.json +338 -0
  32. package/registry/hooks/hooks.json +26 -0
  33. package/registry/hooks/link-memory-on-start.mjs +46 -0
  34. package/registry/hooks/opus-check-on-stop.mjs +114 -0
  35. package/registry/skills/create-action/SKILL.md +49 -0
  36. package/registry/skills/create-action/scaffold.mjs +122 -0
  37. package/registry/templates/app/_gitignore +3 -0
  38. package/registry/templates/app/_npmrc +1 -0
  39. package/registry/templates/app/_opus/_gitignore +5 -0
  40. package/registry/templates/app/_prettierrc.json +6 -0
  41. package/registry/templates/app/index.html +13 -0
  42. package/registry/templates/app/opus.config.ts +16 -0
  43. package/registry/templates/app/package.json +43 -0
  44. package/registry/templates/app/pnpm-workspace.yaml +11 -0
  45. package/registry/templates/app/public/favicon.svg +4 -0
  46. package/registry/templates/app/src/App.tsx +37 -0
  47. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
  48. package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
  49. package/registry/templates/app/src/domains/tasks/index.ts +13 -0
  50. package/registry/templates/app/src/index.css +18 -0
  51. package/registry/templates/app/src/main.tsx +25 -0
  52. package/registry/templates/app/tsconfig.json +20 -0
  53. package/registry/templates/app/vite.config.ts +46 -0
  54. package/registry/templates/monorepo/_gitignore +3 -0
  55. package/registry/templates/monorepo/_npmrc +1 -0
  56. package/registry/templates/monorepo/package.json +9 -0
  57. package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
  58. package/src/ai/ask.ts +64 -0
  59. package/src/ai/drivers/anthropic.ts +309 -0
  60. package/src/ai/index.ts +17 -0
  61. package/src/audit/drivers/console.ts +117 -0
  62. package/src/audit/drivers/pg.ts +172 -0
  63. package/src/audit/index.ts +51 -0
  64. package/src/auth/drivers/better-auth.ts +103 -0
  65. package/src/auth/drivers/jwt.ts +188 -0
  66. package/src/auth/index.ts +9 -0
  67. package/src/client/drivers/fetch.ts +202 -0
  68. package/src/client/index.ts +22 -0
  69. package/src/core/actions.ts +110 -0
  70. package/src/core/audit.ts +239 -0
  71. package/src/core/contracts.ts +137 -0
  72. package/src/core/domain.ts +310 -0
  73. package/src/core/errors.ts +181 -0
  74. package/src/core/index.ts +174 -0
  75. package/src/core/logical-type.ts +31 -0
  76. package/src/core/reactions.ts +81 -0
  77. package/src/core/runtime.ts +1167 -0
  78. package/src/core/schedules.ts +41 -0
  79. package/src/core/types.ts +1356 -0
  80. package/src/data/drivers/kysely.ts +389 -0
  81. package/src/data/index.ts +10 -0
  82. package/src/data/readonly-pool.ts +160 -0
  83. package/src/dsl/eval.ts +136 -0
  84. package/src/dsl/index.ts +29 -0
  85. package/src/dsl/kysely.ts +230 -0
  86. package/src/dsl/loads.ts +123 -0
  87. package/src/dsl/parser.ts +423 -0
  88. package/src/dsl/types.ts +113 -0
  89. package/src/events/drivers/mitt.ts +70 -0
  90. package/src/events/index.ts +9 -0
  91. package/src/log/drivers/pino.ts +57 -0
  92. package/src/log/index.ts +9 -0
  93. package/src/mcp/index.ts +62 -0
  94. package/src/queue/drivers/bullmq.ts +190 -0
  95. package/src/queue/index.ts +9 -0
  96. package/src/scheduler/drivers/node-cron.ts +93 -0
  97. package/src/scheduler/every.ts +45 -0
  98. package/src/scheduler/index.ts +9 -0
  99. package/src/schema/drivers/zod.ts +765 -0
  100. package/src/schema/entity.ts +439 -0
  101. package/src/schema/format/locale.ts +144 -0
  102. package/src/schema/index.ts +65 -0
  103. package/src/schema/openapi.ts +302 -0
  104. package/src/schema/scaffold.ts +160 -0
  105. package/src/server/drivers/fastify.ts +224 -0
  106. package/src/server/drivers/node.ts +386 -0
  107. package/src/server/index.ts +142 -0
  108. package/src/storage/drivers/fs.ts +90 -0
  109. package/src/storage/drivers/s3.ts +117 -0
  110. package/src/storage/index.ts +27 -0
  111. package/src/testing/fake.ts +298 -0
  112. package/src/testing/index.ts +324 -0
  113. package/src/ui/components/patterns/action-form-card.tsx +48 -0
  114. package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
  115. package/src/ui/components/patterns/app-shell.tsx +227 -0
  116. package/src/ui/components/patterns/confirm.tsx +226 -0
  117. package/src/ui/components/patterns/data-state.tsx +75 -0
  118. package/src/ui/components/patterns/form-dialog.tsx +64 -0
  119. package/src/ui/components/patterns/form.tsx +584 -0
  120. package/src/ui/components/patterns/list.tsx +1488 -0
  121. package/src/ui/components/patterns/page.tsx +46 -0
  122. package/src/ui/components/patterns/section-shell.tsx +246 -0
  123. package/src/ui/components/patterns/shell-nav.tsx +150 -0
  124. package/src/ui/components/patterns/sidebar.tsx +89 -0
  125. package/src/ui/components/patterns/split.tsx +93 -0
  126. package/src/ui/components/patterns/trigger.tsx +196 -0
  127. package/src/ui/components/patterns/view.tsx +84 -0
  128. package/src/ui/components/primitives/accordion.tsx +64 -0
  129. package/src/ui/components/primitives/alert-dialog.tsx +190 -0
  130. package/src/ui/components/primitives/alert.tsx +116 -0
  131. package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
  132. package/src/ui/components/primitives/avatar.tsx +107 -0
  133. package/src/ui/components/primitives/badge.tsx +37 -0
  134. package/src/ui/components/primitives/breadcrumb.tsx +109 -0
  135. package/src/ui/components/primitives/button-group.tsx +83 -0
  136. package/src/ui/components/primitives/button.tsx +102 -0
  137. package/src/ui/components/primitives/calendar.tsx +218 -0
  138. package/src/ui/components/primitives/card.tsx +56 -0
  139. package/src/ui/components/primitives/carousel.tsx +239 -0
  140. package/src/ui/components/primitives/chat.tsx +407 -0
  141. package/src/ui/components/primitives/checkbox.tsx +30 -0
  142. package/src/ui/components/primitives/collapsible.tsx +31 -0
  143. package/src/ui/components/primitives/command.tsx +182 -0
  144. package/src/ui/components/primitives/composer.tsx +121 -0
  145. package/src/ui/components/primitives/copyable.tsx +50 -0
  146. package/src/ui/components/primitives/dialog.tsx +147 -0
  147. package/src/ui/components/primitives/drawer.tsx +141 -0
  148. package/src/ui/components/primitives/empty.tsx +104 -0
  149. package/src/ui/components/primitives/field.tsx +246 -0
  150. package/src/ui/components/primitives/icon-picker.tsx +180 -0
  151. package/src/ui/components/primitives/input-group.tsx +168 -0
  152. package/src/ui/components/primitives/input-otp.tsx +75 -0
  153. package/src/ui/components/primitives/input.tsx +72 -0
  154. package/src/ui/components/primitives/item.tsx +193 -0
  155. package/src/ui/components/primitives/kbd.tsx +28 -0
  156. package/src/ui/components/primitives/label.tsx +22 -0
  157. package/src/ui/components/primitives/markdown.tsx +35 -0
  158. package/src/ui/components/primitives/menu.tsx +255 -0
  159. package/src/ui/components/primitives/pagination.tsx +127 -0
  160. package/src/ui/components/primitives/popover.tsx +87 -0
  161. package/src/ui/components/primitives/progress.tsx +29 -0
  162. package/src/ui/components/primitives/radio-group.tsx +43 -0
  163. package/src/ui/components/primitives/resizable.tsx +51 -0
  164. package/src/ui/components/primitives/scroll-area.tsx +56 -0
  165. package/src/ui/components/primitives/select.tsx +479 -0
  166. package/src/ui/components/primitives/separator.tsx +26 -0
  167. package/src/ui/components/primitives/skeleton.tsx +13 -0
  168. package/src/ui/components/primitives/slider.tsx +61 -0
  169. package/src/ui/components/primitives/sonner.tsx +46 -0
  170. package/src/ui/components/primitives/spinner.tsx +29 -0
  171. package/src/ui/components/primitives/switch.tsx +33 -0
  172. package/src/ui/components/primitives/table.tsx +114 -0
  173. package/src/ui/components/primitives/tabs.tsx +104 -0
  174. package/src/ui/components/primitives/textarea.tsx +18 -0
  175. package/src/ui/components/primitives/toggle-group.tsx +81 -0
  176. package/src/ui/components/primitives/toggle.tsx +45 -0
  177. package/src/ui/components/primitives/tooltip.tsx +55 -0
  178. package/src/ui/components/primitives/truncate.tsx +49 -0
  179. package/src/ui/docs/DocBrowser.tsx +90 -0
  180. package/src/ui/docs/changelog.tsx +80 -0
  181. package/src/ui/docs/content/accordion.md +86 -0
  182. package/src/ui/docs/content/action-form-card.md +24 -0
  183. package/src/ui/docs/content/action-form-dialog.md +30 -0
  184. package/src/ui/docs/content/action-form.md +125 -0
  185. package/src/ui/docs/content/action-list-dialog.md +68 -0
  186. package/src/ui/docs/content/action-list.md +194 -0
  187. package/src/ui/docs/content/action-trigger.md +72 -0
  188. package/src/ui/docs/content/action-view.md +47 -0
  189. package/src/ui/docs/content/actions.md +138 -0
  190. package/src/ui/docs/content/ai.md +112 -0
  191. package/src/ui/docs/content/alert-dialog.md +73 -0
  192. package/src/ui/docs/content/alert.md +69 -0
  193. package/src/ui/docs/content/app-shell.md +155 -0
  194. package/src/ui/docs/content/aspect-ratio.md +66 -0
  195. package/src/ui/docs/content/audit.md +84 -0
  196. package/src/ui/docs/content/auth.md +70 -0
  197. package/src/ui/docs/content/avatar.md +94 -0
  198. package/src/ui/docs/content/badge.md +48 -0
  199. package/src/ui/docs/content/breadcrumb.md +87 -0
  200. package/src/ui/docs/content/button-group.md +71 -0
  201. package/src/ui/docs/content/button.md +60 -0
  202. package/src/ui/docs/content/calendar.md +62 -0
  203. package/src/ui/docs/content/card.md +49 -0
  204. package/src/ui/docs/content/carousel.md +85 -0
  205. package/src/ui/docs/content/chat.md +69 -0
  206. package/src/ui/docs/content/checkbox.md +75 -0
  207. package/src/ui/docs/content/cli.md +58 -0
  208. package/src/ui/docs/content/collapsible.md +64 -0
  209. package/src/ui/docs/content/command.md +56 -0
  210. package/src/ui/docs/content/composer.md +50 -0
  211. package/src/ui/docs/content/confirm.md +120 -0
  212. package/src/ui/docs/content/copyable.md +30 -0
  213. package/src/ui/docs/content/customization.md +110 -0
  214. package/src/ui/docs/content/cycle.md +34 -0
  215. package/src/ui/docs/content/data-state.md +47 -0
  216. package/src/ui/docs/content/data.md +99 -0
  217. package/src/ui/docs/content/dialog.md +60 -0
  218. package/src/ui/docs/content/drawer.md +55 -0
  219. package/src/ui/docs/content/empty.md +66 -0
  220. package/src/ui/docs/content/events.md +61 -0
  221. package/src/ui/docs/content/field.md +58 -0
  222. package/src/ui/docs/content/getting-started.md +109 -0
  223. package/src/ui/docs/content/icon-picker.md +51 -0
  224. package/src/ui/docs/content/input-group.md +78 -0
  225. package/src/ui/docs/content/input-otp.md +72 -0
  226. package/src/ui/docs/content/input.md +78 -0
  227. package/src/ui/docs/content/item.md +84 -0
  228. package/src/ui/docs/content/kbd.md +62 -0
  229. package/src/ui/docs/content/label.md +32 -0
  230. package/src/ui/docs/content/log.md +55 -0
  231. package/src/ui/docs/content/markdown.md +41 -0
  232. package/src/ui/docs/content/mcp.md +44 -0
  233. package/src/ui/docs/content/menu.md +114 -0
  234. package/src/ui/docs/content/microcopy.md +83 -0
  235. package/src/ui/docs/content/page.md +34 -0
  236. package/src/ui/docs/content/pagination.md +99 -0
  237. package/src/ui/docs/content/popover.md +49 -0
  238. package/src/ui/docs/content/progress.md +69 -0
  239. package/src/ui/docs/content/queue.md +62 -0
  240. package/src/ui/docs/content/radio-group.md +77 -0
  241. package/src/ui/docs/content/resizable.md +86 -0
  242. package/src/ui/docs/content/router.md +56 -0
  243. package/src/ui/docs/content/runtime.md +77 -0
  244. package/src/ui/docs/content/scheduler.md +66 -0
  245. package/src/ui/docs/content/scroll-area.md +89 -0
  246. package/src/ui/docs/content/section-shell.md +121 -0
  247. package/src/ui/docs/content/select.md +342 -0
  248. package/src/ui/docs/content/separator.md +33 -0
  249. package/src/ui/docs/content/sidebar.md +38 -0
  250. package/src/ui/docs/content/skeleton.md +34 -0
  251. package/src/ui/docs/content/slider.md +64 -0
  252. package/src/ui/docs/content/spinner.md +37 -0
  253. package/src/ui/docs/content/split.md +33 -0
  254. package/src/ui/docs/content/storage.md +69 -0
  255. package/src/ui/docs/content/switch.md +69 -0
  256. package/src/ui/docs/content/table.md +102 -0
  257. package/src/ui/docs/content/tabs.md +94 -0
  258. package/src/ui/docs/content/testing.md +89 -0
  259. package/src/ui/docs/content/textarea.md +30 -0
  260. package/src/ui/docs/content/toast.md +67 -0
  261. package/src/ui/docs/content/toggle-group.md +81 -0
  262. package/src/ui/docs/content/toggle.md +72 -0
  263. package/src/ui/docs/content/tokens.md +171 -0
  264. package/src/ui/docs/content/tooltip.md +50 -0
  265. package/src/ui/docs/content/truncate.md +37 -0
  266. package/src/ui/docs/content/ui.md +40 -0
  267. package/src/ui/docs/content/upgrading.md +48 -0
  268. package/src/ui/docs/doc-client.tsx +214 -0
  269. package/src/ui/docs/doc.tsx +301 -0
  270. package/src/ui/docs/folder.tsx +149 -0
  271. package/src/ui/docs/index.ts +21 -0
  272. package/src/ui/docs/markdown.tsx +130 -0
  273. package/src/ui/docs/md-raw.d.ts +4 -0
  274. package/src/ui/docs/plugin.ts +104 -0
  275. package/src/ui/docs/registry.tsx +424 -0
  276. package/src/ui/docs/standalone.tsx +107 -0
  277. package/src/ui/drivers/react.tsx +627 -0
  278. package/src/ui/index.ts +92 -0
  279. package/src/ui/lib/cn.ts +10 -0
  280. package/src/ui/lib/zod-pt-br.ts +38 -0
  281. package/src/ui/meta.ts +412 -0
  282. package/src/ui/react.tsx +235 -0
  283. package/src/ui/router.ts +96 -0
  284. package/src/ui/theme.css +234 -0
  285. package/src/vite/design.ts +652 -0
  286. package/src/vite/index.ts +8 -0
@@ -0,0 +1,584 @@
1
+ /**
2
+ * <ActionForm action /> — renderiza um FormAction do Opus em UI do Opus.
3
+ *
4
+ * Dois modos, um motor:
5
+ * - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo;
6
+ * o espaçamento entre campos (space-y-6) é deste modo, que é quem diagrama.
7
+ * - COMPOSIÇÃO (com children): você diagrama; <ActionFormField name /> coloca cada campo
8
+ * (label/hint/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
9
+ * JSX ({cond && <ActionFormField/>}); opções de runtime entram por prop no campo. O
10
+ * espaçamento é 100% seu — nenhuma margem imposta (o campo não sabe onde será colocado).
11
+ *
12
+ * Auto-detect do Zod (nos dois modos):
13
+ * - z.enum(...) → <Select> com opções inferidas
14
+ * - z.string com max > 200 → <Textarea>
15
+ * - z.boolean → <Checkbox> · z.array → multiselect (Select multiple)
16
+ *
17
+ * Submit via useFormAction (RHF + zodResolver + cache invalidation). Erro do servidor
18
+ * inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
19
+ */
20
+
21
+ import { createContext, useContext } from 'react'
22
+ import type { UseFormReturn } from 'react-hook-form'
23
+ import { getLogicalType, type FormContract, type OptionsSpec } from '../../../core/index.ts'
24
+ import { useDicts, useFormAction, type DictLike } from '../../drivers/react.tsx'
25
+ import { z, type ZodTypeAny } from 'zod'
26
+ import { cn } from '../../lib/cn.ts'
27
+ import { toast } from '../primitives/sonner.tsx'
28
+ import { Button } from '../primitives/button.tsx'
29
+ import { Input } from '../primitives/input.tsx'
30
+ import { Textarea } from '../primitives/textarea.tsx'
31
+ import { Label } from '../primitives/label.tsx'
32
+ import { Checkbox } from '../primitives/checkbox.tsx'
33
+ import { IconPicker } from '../primitives/icon-picker.tsx'
34
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
35
+ import { Info } from 'lucide-react'
36
+ import { Select, type SelectOption } from '../primitives/select.tsx'
37
+
38
+ // =============================================================================
39
+ // Inferência de tipo de field a partir do Zod schema
40
+ // =============================================================================
41
+
42
+ type FieldKind =
43
+ | { kind: 'text'; required: boolean }
44
+ | { kind: 'textarea'; required: boolean }
45
+ | { kind: 'checkbox'; required: boolean }
46
+ | { kind: 'select'; required: boolean; options: string[] }
47
+ | { kind: 'multiselect'; required: boolean; options: string[] }
48
+ | { kind: 'lines'; required: boolean }
49
+ | { kind: 'refItems'; required: boolean }
50
+ | { kind: 'icon'; required: boolean }
51
+
52
+ function unwrap(schema: ZodTypeAny): { inner: ZodTypeAny; required: boolean } {
53
+ let inner: ZodTypeAny = schema
54
+ let required = true
55
+ // ZodOptional / ZodDefault / ZodNullable → desce no _def.innerType.
56
+ while (
57
+ inner instanceof z.ZodOptional ||
58
+ inner instanceof z.ZodDefault ||
59
+ inner instanceof z.ZodNullable
60
+ ) {
61
+ if (inner instanceof z.ZodOptional || inner instanceof z.ZodDefault) {
62
+ required = false
63
+ }
64
+ const def = (inner as unknown as { _def: { innerType: ZodTypeAny } })._def
65
+ inner = def.innerType
66
+ }
67
+ return { inner, required }
68
+ }
69
+
70
+ function inferFieldKind(schema: ZodTypeAny): FieldKind {
71
+ const { inner, required } = unwrap(schema)
72
+
73
+ if (inner instanceof z.ZodBoolean) {
74
+ return { kind: 'checkbox', required }
75
+ }
76
+
77
+ if (inner instanceof z.ZodEnum) {
78
+ return { kind: 'select', required, options: inner.options as string[] }
79
+ }
80
+
81
+ // z.array(...) → multiselect (Select multiple). Opções estáticas só quando o elemento é
82
+ // z.enum; pra z.array(z.string()) (ids em runtime) as opções vêm de fora (spec).
83
+ if (inner instanceof z.ZodArray) {
84
+ const el = (inner._def as { type: ZodTypeAny }).type
85
+ return { kind: 'multiselect', required, options: el instanceof z.ZodEnum ? (el.options as string[]) : [] }
86
+ }
87
+
88
+ if (inner instanceof z.ZodString) {
89
+ const maxCheck = inner._def.checks.find(
90
+ (c: { kind: string }) => c.kind === 'max',
91
+ ) as { value: number } | undefined
92
+ if (maxCheck !== undefined && maxCheck.value > 200) {
93
+ return { kind: 'textarea', required }
94
+ }
95
+ }
96
+
97
+ return { kind: 'text', required }
98
+ }
99
+
100
+ // =============================================================================
101
+ // FieldSpec (subset usado aqui)
102
+ // =============================================================================
103
+
104
+ interface FieldSpec {
105
+ label?: string
106
+ placeholder?: string
107
+ hint?: string
108
+ /** Ajuda na label: ícone ⓘ + tooltip no hover (explicação mais longa que o hint). */
109
+ help?: string
110
+ /** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
111
+ * Honra 'textarea' (string sem max), 'code' (textarea monoespaçada, ex.: SKILL.md),
112
+ * 'lines' (z.array(z.string()) num textarea, um item por linha), 'refItems'
113
+ * (z.array(z.object({ref, text})) — linhas de referência + texto; as opções do
114
+ * `ref` vêm de fieldOptions[campo]) e 'icon' (string com o nome kebab-case da
115
+ * paleta da casa — renderiza o <IconPicker>). */
116
+ widget?: string
117
+ /** Renderiza o campo só quando true pro input atual (ex.: clientId só se !staff).
118
+ * No modo COMPOSIÇÃO o condicional pode (e deve) ser JSX de quem diagrama. */
119
+ showWhen?: (input: Record<string, unknown>) => boolean
120
+ /** Origem declarada das opções (static/dictionary/lookup) — ver a precedência
121
+ * em `ActionFormField`. `dictionary` resolve pelos dicts do TbdlibProvider. */
122
+ options?: OptionsSpec
123
+ }
124
+
125
+ /** Opções declaradas no FieldSpec: static direto; dictionary via provider; lookup
126
+ * fica de fora (origem em action — entra por `fieldOptions`/prop por enquanto). */
127
+ function optionsFromSpec(
128
+ spec: OptionsSpec | undefined,
129
+ dicts: Record<string, DictLike>,
130
+ ): SelectOption[] | undefined {
131
+ if (spec === undefined) return undefined
132
+ if (spec.kind === 'static') {
133
+ return spec.items.map((i) => ({
134
+ value: i.value,
135
+ label: typeof i.label === 'string' ? i.label : i.label.default,
136
+ }))
137
+ }
138
+ if (spec.kind === 'dictionary') {
139
+ const dict = dicts[spec.ref]
140
+ if (dict === undefined) return undefined
141
+ return dict.options().map((o) => ({ value: o.value, label: o.label }))
142
+ }
143
+ return undefined
144
+ }
145
+
146
+ /** Fallback zero-config: o schema do contrato CARREGA o vocabulário do `t.dict`
147
+ * (meta `params.keys/entries`) — labels sem registry no provider. Cobre o campo
148
+ * dict e o multiselect com elemento dict. */
149
+ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
150
+ const { inner } = unwrap(schema)
151
+ const el = inner instanceof z.ZodArray ? (inner._def as { type: ZodTypeAny }).type : inner
152
+ const meta = getLogicalType(el as object)
153
+ if (meta?.logicalType !== 'dict') return undefined
154
+ const params = (meta.params ?? {}) as {
155
+ keys?: unknown
156
+ entries?: Record<string, { label?: unknown }>
157
+ }
158
+ const { keys, entries } = params
159
+ if (!Array.isArray(keys) || entries === undefined) return undefined
160
+ return (keys as string[]).map((k) => {
161
+ const label = entries[k]?.label
162
+ return { value: k, label: typeof label === 'string' ? label : k }
163
+ })
164
+ }
165
+
166
+ /** Ícone de ajuda na label (ⓘ + tooltip Radix). Self-contained (inclui o Provider) — só
167
+ * renderiza quando o FieldSpec define `help`. `tabIndex={-1}`: fica FORA do autofocus do
168
+ * diálogo (senão o modal foca no ícone ao abrir e o tooltip vem aberto); abre só no hover.
169
+ * preventDefault: clicar o ícone não dispara o label (não foca o input / não toggla). */
170
+ function LabelHelp({ help }: { help: string | undefined }): React.ReactElement | null {
171
+ if (help === undefined || help.trim().length === 0) return null
172
+ return (
173
+ <TooltipProvider delayDuration={300}>
174
+ <Tooltip>
175
+ <TooltipTrigger asChild>
176
+ <button
177
+ type="button"
178
+ tabIndex={-1}
179
+ aria-label="Ajuda"
180
+ onClick={(e) => {
181
+ // Clicar o ícone não ativa o label (não foca/toggla o campo).
182
+ e.preventDefault()
183
+ e.stopPropagation()
184
+ }}
185
+ className="inline-flex shrink-0 cursor-help text-muted-foreground/50 transition-colors hover:text-muted-foreground"
186
+ >
187
+ <Info className="h-3.5 w-3.5" />
188
+ </button>
189
+ </TooltipTrigger>
190
+ {/* max-w-sm: frase de help típica (~50 caracteres) cabe numa linha; o
191
+ text-balance do primitivo só divide o que realmente transborda. */}
192
+ <TooltipContent className="max-w-sm">{help}</TooltipContent>
193
+ </Tooltip>
194
+ </TooltipProvider>
195
+ )
196
+ }
197
+
198
+ /** Asterisco de obrigatório na label — derivado do Zod (campo sem optional/default).
199
+ * aria-hidden: leitor de tela valida pelo erro do resolver, não pelo símbolo. */
200
+ function RequiredMark({ required }: { required: boolean }): React.ReactElement | null {
201
+ if (!required) return null
202
+ return (
203
+ <span aria-hidden className="ml-0.5 text-destructive">
204
+ *
205
+ </span>
206
+ )
207
+ }
208
+
209
+ /** action.messages.* pode ser string ou I18nRef ({ key, default }). Resolve pra texto. */
210
+ function msgText(m: unknown, fallback: string): string {
211
+ if (typeof m === 'string') return m
212
+ if (m !== null && typeof m === 'object' && 'default' in m) {
213
+ const d = (m as { default: unknown }).default
214
+ if (typeof d === 'string') return d
215
+ }
216
+ return fallback
217
+ }
218
+
219
+ // =============================================================================
220
+ // Contexto — liga o <ActionFormField> ao form/contrato do <ActionForm> pai
221
+ // =============================================================================
222
+
223
+ export interface ActionFormContextValue {
224
+ /** RHF do useFormAction. O shape é dinâmico — os paths entram como string. */
225
+ form: UseFormReturn<Record<string, unknown>>
226
+ shape: Record<string, ZodTypeAny | undefined>
227
+ fields: Record<string, FieldSpec>
228
+ fieldOptions?: Record<string, SelectOption[]> | undefined
229
+ }
230
+
231
+ const ActionFormContext = createContext<ActionFormContextValue | null>(null)
232
+
233
+ /**
234
+ * O contexto do `<ActionForm>` pai — a válvula de escape pra CONTROLE CUSTOM no
235
+ * modo composição: um campo com UI própria (grade de permissões, canvas, o que
236
+ * for) participa do form do contrato sem abandonar o ActionForm. Leia com
237
+ * `form.watch(name)`, escreva com `form.setValue(name, v, { shouldValidate:
238
+ * true, shouldDirty: true })` e mostre erro com `form.formState.errors[name]` —
239
+ * zod-resolver, erro inline e submit continuam do contrato.
240
+ */
241
+ export function useActionFormContext(): ActionFormContextValue {
242
+ const ctx = useContext(ActionFormContext)
243
+ if (ctx === null) throw new Error('useActionFormContext precisa estar dentro de um <ActionForm>.')
244
+ return ctx
245
+ }
246
+
247
+ // =============================================================================
248
+ // ActionFormField — um campo do contrato, onde você quiser
249
+ // =============================================================================
250
+
251
+ export interface ActionFormFieldProps {
252
+ /** Nome do campo no input do contrato (a chave em `fields`/schema). */
253
+ name: string
254
+ /** Opções por id de runtime — sobrepõe o fieldOptions do form e o z.enum. */
255
+ options?: SelectOption[]
256
+ /** Classes do invólucro do campo (ex.: col-span-2 numa grid). */
257
+ className?: string
258
+ }
259
+
260
+ export function ActionFormField({ name, options: optionsProp, className }: ActionFormFieldProps): React.ReactElement | null {
261
+ const ctx = useContext(ActionFormContext)
262
+ if (ctx === null) throw new Error('ActionFormField precisa estar dentro de um <ActionForm>.')
263
+ const dicts = useDicts()
264
+ const { form, shape, fields, fieldOptions } = ctx
265
+ // Assina TODOS os valores: alimenta o showWhen e os widgets controlados.
266
+ const formValues = form.watch()
267
+
268
+ // Clique na label foca o controle — todo controle da casa honra o id (o Select
269
+ // reaplica o dele, que o cmdk sobrescreve).
270
+ const focusControl = (): void => {
271
+ document.getElementById(name)?.focus()
272
+ }
273
+
274
+ const spec: FieldSpec = fields[name] ?? {}
275
+ const fieldSchema = shape[name]
276
+ if (fieldSchema === undefined) return null
277
+ if (typeof spec.showWhen === 'function' && !spec.showWhen(formValues)) return null
278
+
279
+ const inferred = inferFieldKind(fieldSchema)
280
+ // `widget` no FieldSpec sobrepõe o auto-detect (ex.: textarea pra string sem max).
281
+ const fieldKind: FieldKind =
282
+ spec.widget === 'textarea' || spec.widget === 'code'
283
+ ? { kind: 'textarea', required: inferred.required }
284
+ : spec.widget === 'lines'
285
+ ? { kind: 'lines', required: inferred.required }
286
+ : spec.widget === 'refItems'
287
+ ? { kind: 'refItems', required: inferred.required }
288
+ : spec.widget === 'icon'
289
+ ? { kind: 'icon', required: inferred.required }
290
+ : inferred
291
+ const fieldError = form.formState.errors[name]
292
+ const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
293
+ // Precedência das opções: prop do campo > fieldOptions do form > spec.options
294
+ // (dictionary via provider, static direto) > meta do t.dict no schema
295
+ // (zero-config) > chaves cruas do z.enum.
296
+ const runtimeOptions = optionsProp ?? fieldOptions?.[name]
297
+ const specOptions = optionsFromSpec(spec.options, dicts)
298
+ const declaredOptions = runtimeOptions ?? specOptions
299
+ const options: SelectOption[] =
300
+ declaredOptions ??
301
+ dictMetaOptions(fieldSchema) ??
302
+ (fieldKind.kind === 'select' || fieldKind.kind === 'multiselect'
303
+ ? fieldKind.options.map((o) => ({ value: o, label: o }))
304
+ : [])
305
+ // Campo texto COM opções declaradas (runtime ou spec) → single-select por-id.
306
+ const effectiveKind: FieldKind =
307
+ fieldKind.kind === 'text' && declaredOptions !== undefined
308
+ ? { kind: 'select', required: fieldKind.required, options: [] }
309
+ : fieldKind
310
+
311
+ const setValue = (v: unknown): void =>
312
+ form.setValue(name, v, { shouldValidate: true, shouldDirty: true })
313
+
314
+ // Checkbox tem layout próprio (controle + label na mesma linha).
315
+ if (fieldKind.kind === 'checkbox') {
316
+ return (
317
+ <div className={cn('space-y-2', className)}>
318
+ <label className="flex items-center gap-2">
319
+ <Checkbox
320
+ id={name}
321
+ checked={(formValues[name] as boolean | undefined) ?? false}
322
+ onCheckedChange={(v) => setValue(v === true)}
323
+ />
324
+ <span className="text-sm font-medium">
325
+ {spec.label ?? name}
326
+ <RequiredMark required={fieldKind.required} />
327
+ </span>
328
+ <LabelHelp help={spec.help} />
329
+ </label>
330
+ {spec.hint !== undefined && <p className="text-xs text-muted-foreground">{spec.hint}</p>}
331
+ {errorMessage !== undefined && <p className="text-xs text-destructive">{errorMessage}</p>}
332
+ </div>
333
+ )
334
+ }
335
+
336
+ return (
337
+ <div className={cn('space-y-2', className)}>
338
+ <Label htmlFor={name} onClick={focusControl} className="flex items-center gap-1.5">
339
+ <span>
340
+ {spec.label ?? name}
341
+ <RequiredMark required={fieldKind.required} />
342
+ </span>
343
+ <LabelHelp help={spec.help} />
344
+ </Label>
345
+
346
+ {effectiveKind.kind === 'multiselect' ? (
347
+ <Select
348
+ multiple
349
+ searchable
350
+ id={name}
351
+ value={(formValues[name] as string[] | undefined) ?? []}
352
+ onChange={(v) => setValue(v)}
353
+ options={options}
354
+ placeholder={spec.placeholder ?? 'Selecione…'}
355
+ className="w-full"
356
+ />
357
+ ) : effectiveKind.kind === 'select' ? (
358
+ // w-full: campo de FORM alinha com os inputs (todos cheios) — coluna uniforme.
359
+ <Select
360
+ id={name}
361
+ value={(formValues[name] as string | undefined) ?? ''}
362
+ onChange={(v) => setValue(v)}
363
+ options={options}
364
+ placeholder={spec.placeholder ?? 'Selecione…'}
365
+ className="w-full"
366
+ />
367
+ ) : effectiveKind.kind === 'lines' ? (
368
+ // Lista de strings num textarea: um item por linha. split/join round-trip
369
+ // estável (linhas vazias do meio da digitação são filtradas no servidor).
370
+ <Textarea
371
+ id={name}
372
+ placeholder={spec.placeholder ?? ''}
373
+ rows={4}
374
+ className="max-h-80"
375
+ value={((formValues[name] as string[] | undefined) ?? []).join('\n')}
376
+ onChange={(e) => setValue(e.target.value.split('\n'))}
377
+ />
378
+ ) : effectiveKind.kind === 'refItems' ? (
379
+ // Lista composta {ref, text}: cada linha = select de referência (opções de
380
+ // fieldOptions) + texto. Estruturado de propósito — o ref nunca é texto livre.
381
+ (() => {
382
+ const items = (formValues[name] as Array<{ ref: string; text: string }> | undefined) ?? []
383
+ const setItems = (next: Array<{ ref: string; text: string }>): void => setValue(next)
384
+ return (
385
+ <div className="space-y-2">
386
+ {items.map((item, i) => (
387
+ <div key={i} className="flex items-start gap-2">
388
+ <Select
389
+ value={item.ref}
390
+ onChange={(v) => setItems(items.map((it, j) => (j === i ? { ...it, ref: v } : it)))}
391
+ options={options}
392
+ placeholder="Papel…"
393
+ className="w-44 shrink-0"
394
+ />
395
+ <Input
396
+ value={item.text}
397
+ placeholder={spec.placeholder ?? 'O quê (com critério)…'}
398
+ onChange={(e) => setItems(items.map((it, j) => (j === i ? { ...it, text: e.target.value } : it)))}
399
+ />
400
+ <Button
401
+ type="button"
402
+ variant="ghost"
403
+ size="sm"
404
+ className="shrink-0 px-2 text-muted-foreground"
405
+ onClick={() => setItems(items.filter((_, j) => j !== i))}
406
+ aria-label="Remover item"
407
+ >
408
+ ×
409
+ </Button>
410
+ </div>
411
+ ))}
412
+ <Button
413
+ type="button"
414
+ variant="outline"
415
+ size="sm"
416
+ onClick={() => setItems([...items, { ref: '', text: '' }])}
417
+ >
418
+ Adicionar item
419
+ </Button>
420
+ </div>
421
+ )
422
+ })()
423
+ ) : effectiveKind.kind === 'icon' ? (
424
+ // Widget 'icon': o value é o NOME do ícone (kebab-case) — ver <IconPicker>.
425
+ <IconPicker
426
+ id={name}
427
+ value={(formValues[name] as string | undefined) ?? ''}
428
+ onChange={(v) => setValue(v)}
429
+ placeholder={spec.placeholder ?? 'Selecione um ícone…'}
430
+ />
431
+ ) : effectiveKind.kind === 'textarea' ? (
432
+ // max-h: o Textarea v4 auto-cresce (field-sizing-content) — capa e scrolla
433
+ // o CAMPO, não o form/dialog inteiro. widget 'code' = monoespaçado (SKILL.md).
434
+ <Textarea
435
+ id={name}
436
+ placeholder={spec.placeholder ?? ''}
437
+ rows={5}
438
+ className={spec.widget === 'code' ? 'max-h-80 font-mono text-xs' : 'max-h-80'}
439
+ {...form.register(name)}
440
+ />
441
+ ) : (
442
+ <Input
443
+ id={name}
444
+ placeholder={spec.placeholder ?? ''}
445
+ {...form.register(name)}
446
+ />
447
+ )}
448
+
449
+ {spec.hint !== undefined && (
450
+ <p className="text-xs text-muted-foreground">{spec.hint}</p>
451
+ )}
452
+ {errorMessage !== undefined && (
453
+ <p className="text-xs text-destructive">{errorMessage}</p>
454
+ )}
455
+ </div>
456
+ )
457
+ }
458
+
459
+ // =============================================================================
460
+ // ActionForm
461
+ // =============================================================================
462
+
463
+ export interface ActionFormProps<TInput extends Record<string, unknown>, TData> {
464
+ action: FormContract<TInput, TData>
465
+ defaultValues?: Partial<TInput>
466
+ onSuccess?: (data: TData) => void
467
+ submitLabel?: string
468
+ cancelLabel?: string
469
+ onCancel?: () => void
470
+ /** Opções por campo pra select/multiselect carregados em runtime (ex.: papéis/skills
471
+ * por id). Sobrepõe as opções estáticas inferidas do z.enum. Chave = nome do campo. */
472
+ fieldOptions?: Record<string, SelectOption[]>
473
+ /** Classes do <form> (layout externo — ex.: coluna flex no dialog). */
474
+ className?: string
475
+ /** Slot do CORPO: recebe os campos montados e decide o invólucro. Default: inline. No
476
+ * dialog, o ActionFormDialog injeta `DialogBody` (a área que rola). */
477
+ body?: (fields: React.ReactNode) => React.ReactNode
478
+ /** Slot do RODAPÉ: recebe os botões (Cancelar/Salvar) — com o estado do form e DENTRO
479
+ * do <form> — e decide o invólucro. Default: faixa de ação simples. No dialog, o
480
+ * ActionFormDialog injeta `DialogFooter` (a faixa da casa). */
481
+ footer?: (actions: React.ReactNode) => React.ReactNode
482
+ /** Modo COMPOSIÇÃO: diagrame os campos com <ActionFormField name/> (grid, seções,
483
+ * condicionais em JSX). Sem children, o modo AUTO monta todos na ordem do contrato. */
484
+ children?: React.ReactNode
485
+ }
486
+
487
+ export function ActionForm<TInput extends Record<string, unknown>, TData>({
488
+ action,
489
+ defaultValues,
490
+ onSuccess,
491
+ submitLabel = 'Salvar',
492
+ cancelLabel = 'Cancelar',
493
+ onCancel,
494
+ fieldOptions,
495
+ className,
496
+ body,
497
+ footer,
498
+ children,
499
+ }: ActionFormProps<TInput, TData>) {
500
+ const { form, submit, isLoading, error, isSuccess } = useFormAction<TInput, TData>(action, {
501
+ ...(defaultValues !== undefined ? { defaultValues: defaultValues as never } : {}),
502
+ onSuccess: (data) => {
503
+ toast.success(msgText(action.messages?.success, 'Concluído'))
504
+ onSuccess?.(data)
505
+ },
506
+ onError: (err) => {
507
+ toast.error(msgText(action.messages?.error, err.message))
508
+ },
509
+ })
510
+
511
+ const shape = (action.input as unknown as z.ZodObject<z.ZodRawShape>).shape
512
+ const fields = action.fields as Record<string, FieldSpec>
513
+
514
+ // Corpo e rodapé como SLOTS: por padrão renderizam inline; o ActionFormDialog injeta
515
+ // DialogBody/DialogFooter (fonte única do scroll e da faixa). Os botões ficam DENTRO do
516
+ // <form> nos dois casos — o submit exige isso; o que muda é só o invólucro.
517
+ const wrapBody = body ?? ((fields: React.ReactNode) => fields)
518
+ const wrapFooter =
519
+ footer ??
520
+ ((actions: React.ReactNode) => (
521
+ <div className="mt-6 flex shrink-0 justify-end gap-2">{actions}</div>
522
+ ))
523
+
524
+ // Banner de erro do servidor (chrome do próprio form, nos dois modos). No AUTO o
525
+ // space-y-6 do wrapper espaça; na COMPOSIÇÃO ele carrega a própria separação (mt-6).
526
+ const errorBanner =
527
+ error !== undefined && !isSuccess ? (
528
+ <div
529
+ className={cn(
530
+ 'rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive',
531
+ children !== undefined && 'mt-6',
532
+ )}
533
+ >
534
+ <strong>{error.code}</strong>: {error.message}
535
+ </div>
536
+ ) : null
537
+
538
+ return (
539
+ <ActionFormContext.Provider
540
+ value={{
541
+ form: form as unknown as UseFormReturn<Record<string, unknown>>,
542
+ shape,
543
+ fields,
544
+ fieldOptions,
545
+ }}
546
+ >
547
+ <form onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
548
+ {wrapBody(
549
+ children !== undefined ? (
550
+ // COMPOSIÇÃO: nenhum espaçamento imposto — o componente não sabe onde será
551
+ // colocado; a diagramação (grid/gap/space-y) é 100% de quem compõe.
552
+ <>
553
+ {children}
554
+ {errorBanner}
555
+ </>
556
+ ) : (
557
+ // AUTO: aqui o ActionForm É o pai que diagrama — o space-y-6 é dele.
558
+ <div className="space-y-6">
559
+ {Object.keys(fields).map((name) => (
560
+ <ActionFormField key={name} name={name} />
561
+ ))}
562
+ {errorBanner}
563
+ </div>
564
+ ),
565
+ )}
566
+
567
+ {wrapFooter(
568
+ <>
569
+ {onCancel !== undefined && (
570
+ <Button type="button" variant="outline" onClick={onCancel}>
571
+ {cancelLabel}
572
+ </Button>
573
+ )}
574
+ {/* Gated por dirty: só habilita com mudança real (igual aos forms antigos).
575
+ busy = a submissão em andamento (spinner + disable vêm do Button). */}
576
+ <Button type="submit" busy={isLoading} disabled={!form.formState.isDirty}>
577
+ {submitLabel}
578
+ </Button>
579
+ </>,
580
+ )}
581
+ </form>
582
+ </ActionFormContext.Provider>
583
+ )
584
+ }