@voltro/cli 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  4. package/dist/agentsMd-DCY1RSs8.js +2 -0
  5. package/dist/apiBuild-CeUN55uk.js +2 -0
  6. package/dist/{apiBuild-CSFI8QGq.js → apiBuild-DTWp0S_q.js} +11 -5
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D4ygSbnV.js +843 -0
  9. package/dist/{checkCommand-COmqc2cB.js → checkCommand-Dg1G7Gwd.js} +6 -6
  10. package/dist/{checkCommand-2SbqzukH.js → checkCommand-L7DTlpIF.js} +1 -1
  11. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  12. package/dist/{codegen-VF479Cnb.js → codegen-DSLM8Su9.js} +1 -1
  13. package/dist/codegen-DjgxEOnD.js +2 -0
  14. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  15. package/dist/{codemodRunner-r7J9lIa7.js → codemodRunner-Cd4xkC6u.js} +109 -11
  16. package/dist/{commands-Cc_nV8WI.js → commands-6Kzi92Np.js} +96 -73
  17. package/dist/{dashboardCommand-C-gKvwqh.js → dashboardCommand-Cq1PWvI1.js} +5 -5
  18. package/dist/{dataCommand-BgpBHnlB.js → dataCommand-DYzW8vkv.js} +3 -3
  19. package/dist/{dbCommand-sHedr-NJ.js → dbCommand-B4NWZtGL.js} +278 -237
  20. package/dist/dbCommand-CSFWs9ev.js +2 -0
  21. package/dist/{dev-CRHoCEiy.js → dev-CmuvUKRq.js} +2903 -2306
  22. package/dist/{dev--A3nsxA3.js → dev-cKUiZZsB.js} +1 -1
  23. package/dist/{doctorCommand-DtfJ3FA6.js → doctorCommand-DCiFVMtZ.js} +101 -69
  24. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  25. package/dist/{dormancyCommand-Drn7o0No.js → dormancyCommand-w1TrmgYP.js} +1 -1
  26. package/dist/{embeddingsCommand-Z-jO1fWN.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  27. package/dist/{envCommand-D4gCrrTZ.js → envCommand-Cyynmcfa.js} +8 -8
  28. package/dist/{evolveCommand-CMROeKeA.js → evolveCommand-BwvQ8dVH.js} +2 -2
  29. package/dist/fileConventions-l-RIXbx8.js +36 -0
  30. package/dist/{fileTaxonomy-DvDUV9wq.js → fileTaxonomy-CbyMQYx_.js} +42 -42
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/{frameworkTableAssembly-w-XnLa3q.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  34. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +2 -2
  38. package/dist/{infoCommand-DXM868o_.js → infoCommand-DlYlUPqs.js} +1 -1
  39. package/dist/{inspect-CtL_xTbu.js → inspect-Bd8-9wsi.js} +1 -1
  40. package/dist/inspect-CuoDInfZ.js +2 -0
  41. package/dist/{inspectGateHint-BjnFubmH.js → inspectGateHint-4LxkNtrz.js} +1 -1
  42. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  43. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  44. package/dist/{metaCommands-C6RFmF1r.js → metaCommands-x7RCi2AF.js} +9 -3
  45. package/dist/{migrate-D0F-eTlK.js → migrate-BK_Bbx-_.js} +2 -2
  46. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  47. package/dist/mobileCommand-DAum7tsG.js +2 -0
  48. package/dist/{pageConvention-CzUiSbtU.js → pageConvention-CMpfDN6r.js} +1 -1
  49. package/dist/{privacyCommand-DGdopOI6.js → privacyCommand-BCa2OoZG.js} +1 -1
  50. package/dist/{probeCommand-C9gazU0H.js → probeCommand-_C0YU207.js} +83 -24
  51. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  52. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  53. package/dist/renderModeScan-43yQ2opo.js +147 -0
  54. package/dist/{renderProfile-Ck32Fzxr.js → renderProfile-DvrhVJHa.js} +2 -2
  55. package/dist/{runtimeTrace-BPQyCmC5.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  56. package/dist/{sdkgen-Se88ifTd.js → sdkgen-CDGHQUFj.js} +1 -1
  57. package/dist/serveCommand-BiPe8BJm.js +2 -0
  58. package/dist/{serveCommand-DkP3OT0W.js → serveCommand-Bje09q1v.js} +889 -825
  59. package/dist/serveEntry.js +1 -1
  60. package/dist/start-B0bnJgxI.js +3 -0
  61. package/dist/{start-jw89Xbqy.js → start-Clz-1BHB.js} +633 -455
  62. package/dist/startEntry.js +1 -1
  63. package/dist/{staticCommand-BwNEDlSU.js → staticCommand-ey0kYmOT.js} +1 -1
  64. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  65. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  66. package/dist/{test-f3amja6a.js → test-D_kW4KMj.js} +1 -1
  67. package/dist/updateCommand-CIoVDKnj.js +2 -0
  68. package/dist/{updateCommand-BMk2e4ky.js → updateCommand-CRJlAOaM.js} +139 -115
  69. package/dist/{webDev-BgWL9gKV.js → webDev-DSI9SOhs.js} +1598 -1028
  70. package/dist/webDev-DlvZO30c.js +2 -0
  71. package/dist/{webhooksCommand-CoIO3jbj.js → webhooksCommand-BvzXNHji.js} +1 -1
  72. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  73. package/package.json +60 -19
  74. package/templates/AGENTS.core.md +2 -0
  75. package/templates/AGENTS.md +8 -4
  76. package/templates/agent-docs/_index.md +6 -4
  77. package/templates/agent-docs/_manifest.json +21 -5
  78. package/templates/agent-docs/ai.md +6 -6
  79. package/templates/agent-docs/authentication.md +73 -1
  80. package/templates/agent-docs/cli.md +6 -3
  81. package/templates/agent-docs/configuration.md +17 -0
  82. package/templates/agent-docs/data.md +522 -29
  83. package/templates/agent-docs/database/advancedqueries.md +8 -8
  84. package/templates/agent-docs/database/columntypes.md +2 -2
  85. package/templates/agent-docs/database/migrations.md +1 -1
  86. package/templates/agent-docs/database/querying.md +1 -1
  87. package/templates/agent-docs/database/schema.md +1 -1
  88. package/templates/agent-docs/database/seedsdialects.md +65 -3
  89. package/templates/agent-docs/database/transactions.md +3 -3
  90. package/templates/agent-docs/deployment.md +8 -0
  91. package/templates/agent-docs/internationalization.md +4 -2
  92. package/templates/agent-docs/introduction.md +32 -1
  93. package/templates/agent-docs/local-first-mobile.md +226 -30
  94. package/templates/agent-docs/observability.md +5 -1
  95. package/templates/agent-docs/plugins/atlassian.md +2 -2
  96. package/templates/agent-docs/plugins/audit.md +2 -2
  97. package/templates/agent-docs/plugins/auth.md +1 -1
  98. package/templates/agent-docs/plugins/billing.md +1 -1
  99. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  100. package/templates/agent-docs/plugins/comments.md +164 -0
  101. package/templates/agent-docs/plugins/notifications.md +47 -4
  102. package/templates/agent-docs/plugins/presence.md +45 -3
  103. package/templates/agent-docs/plugins/prometheus.md +3 -1
  104. package/templates/agent-docs/plugins/queue.md +172 -0
  105. package/templates/agent-docs/plugins.md +17 -13
  106. package/templates/agent-docs/reference.md +35 -4
  107. package/templates/agent-docs/routing.md +585 -7
  108. package/templates/agent-docs/scheduling.md +1 -1
  109. package/templates/agent-docs/schema-driven-ui.md +226 -4
  110. package/templates/agent-docs/security.md +3 -3
  111. package/templates/agent-docs/templates/appshells.md +36 -4
  112. package/templates/agent-docs/whats-new.md +134 -66
  113. package/templates/apps/api-ai/package.json +6 -6
  114. package/templates/apps/api-auth/package.json +8 -8
  115. package/templates/apps/api-backend/package.json +7 -7
  116. package/templates/apps/api-backend-deactivation/package.json +7 -7
  117. package/templates/apps/api-backend-mail/package.json +8 -8
  118. package/templates/apps/api-backend-mariadb/package.json +9 -9
  119. package/templates/apps/api-backend-sqlite/package.json +8 -8
  120. package/templates/apps/api-backend-storage/package.json +8 -8
  121. package/templates/apps/api-cms/package.json +9 -9
  122. package/templates/apps/api-collab/README.md +3 -3
  123. package/templates/apps/api-collab/app.config.ts +1 -1
  124. package/templates/apps/api-collab/database/schema.ts +12 -8
  125. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  126. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  127. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  128. package/templates/apps/api-collab/package.json +8 -8
  129. package/templates/apps/api-collab/template.json +1 -1
  130. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  131. package/templates/apps/api-data-advanced/package.json +8 -8
  132. package/templates/apps/api-durable/package.json +8 -8
  133. package/templates/apps/api-feature-flags/package.json +9 -9
  134. package/templates/apps/api-governance/package.json +8 -8
  135. package/templates/apps/api-kv/package.json +8 -8
  136. package/templates/apps/api-moderation/package.json +8 -8
  137. package/templates/apps/api-observability/package.json +8 -8
  138. package/templates/apps/api-ratelimit/package.json +8 -8
  139. package/templates/apps/api-rbac/package.json +8 -8
  140. package/templates/apps/api-rest/package.json +7 -7
  141. package/templates/apps/api-row-history/package.json +8 -8
  142. package/templates/apps/api-saas/package.json +11 -10
  143. package/templates/apps/api-saas-starter/package.json +10 -10
  144. package/templates/apps/api-search/package.json +8 -8
  145. package/templates/apps/api-status/package.json +8 -8
  146. package/templates/apps/api-webhooks/package.json +9 -9
  147. package/templates/apps/changelog/app.config.ts +26 -2
  148. package/templates/apps/changelog/content/releases/{0.1.0.mdx → v0-1-0.mdx} +0 -1
  149. package/templates/apps/changelog/content/releases/{0.2.0.mdx → v0-2-0.mdx} +0 -1
  150. package/templates/apps/changelog/package.json +9 -8
  151. package/templates/apps/changelog/src/collections/releases.collection.ts +48 -0
  152. package/templates/apps/changelog/src/globals.d.ts +1 -1
  153. package/templates/apps/changelog/src/locales/de.ts +1 -1
  154. package/templates/apps/changelog/src/locales/en.ts +1 -1
  155. package/templates/apps/changelog/src/pages/[locale]/[slug]/page.tsx +7 -4
  156. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +13 -41
  157. package/templates/apps/changelog/src/pages/[slug]/page.test.tsx +26 -51
  158. package/templates/apps/changelog/src/pages/[slug]/page.tsx +21 -19
  159. package/templates/apps/changelog/src/pages/page.test.tsx +14 -37
  160. package/templates/apps/changelog/src/pages/page.tsx +18 -12
  161. package/templates/apps/edge-functions/package.json +2 -2
  162. package/templates/apps/frontend-admin/package.json +8 -8
  163. package/templates/apps/frontend-app/package.json +9 -9
  164. package/templates/apps/frontend-auth/package.json +8 -8
  165. package/templates/apps/frontend-blank/package.json +7 -7
  166. package/templates/apps/frontend-cms/package.json +9 -9
  167. package/templates/apps/frontend-collab/README.md +43 -24
  168. package/templates/apps/frontend-collab/app.config.ts +3 -3
  169. package/templates/apps/frontend-collab/package.json +14 -10
  170. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  171. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  172. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  173. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  174. package/templates/apps/frontend-collab/template.json +2 -2
  175. package/templates/apps/frontend-contact/package.json +7 -7
  176. package/templates/apps/frontend-dashboard/package.json +7 -7
  177. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  178. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  179. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  180. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  181. package/templates/apps/frontend-docs/package.json +9 -6
  182. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  183. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  184. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  185. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  186. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  187. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  188. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  189. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  190. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  191. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  192. package/templates/apps/frontend-i18n/package.json +6 -6
  193. package/templates/apps/frontend-landing/README.md +48 -0
  194. package/templates/apps/frontend-landing/app.config.ts +28 -0
  195. package/templates/apps/frontend-landing/package.json +7 -6
  196. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  197. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  198. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  199. package/templates/apps/frontend-landing/src/globals.css +15 -0
  200. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  201. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  202. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  203. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  204. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  205. package/templates/apps/frontend-landing/template.json +2 -2
  206. package/templates/apps/frontend-portal/package.json +8 -8
  207. package/templates/apps/frontend-saas/package.json +8 -8
  208. package/templates/apps/frontend-spa/package.json +7 -7
  209. package/templates/apps/frontend-ssr/package.json +7 -7
  210. package/templates/apps/frontend-ssr-api/package.json +8 -8
  211. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  212. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  213. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  214. package/templates/apps/frontend-static-blog/package.json +9 -6
  215. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  216. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  217. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  218. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  219. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  220. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  221. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  222. package/templates/apps/frontend-status/package.json +8 -8
  223. package/templates/apps/mobile-app/package.json +12 -11
  224. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  225. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  226. package/dist/agentsMd-SDDSkyl4.js +0 -2
  227. package/dist/apiBuild-BYBpL7Pz.js +0 -2
  228. package/dist/build-CPgcMQug.js +0 -793
  229. package/dist/codegen-CctkDO-1.js +0 -2
  230. package/dist/codegenCommand-DCdG2JN-.js +0 -137
  231. package/dist/dbCommand-DNb6yeOG.js +0 -2
  232. package/dist/doctorCommand-CqoWA2p5.js +0 -2
  233. package/dist/fileConventions-DOqD3lPS.js +0 -34
  234. package/dist/frameworkTableAssembly-C6ETawPR.js +0 -2
  235. package/dist/inspect-CuGDYES0.js +0 -2
  236. package/dist/manifestBuild-CPjhvM62.js +0 -2
  237. package/dist/renderModeScan-CcH2X1_D.js +0 -120
  238. package/dist/serveCommand-DLc-BznW.js +0 -2
  239. package/dist/start-DfL3fOiN.js +0 -3
  240. package/dist/updateCommand-5gFVfK5q.js +0 -2
  241. package/dist/webDev-CZbTsDcH.js +0 -2
  242. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  243. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  244. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
@@ -173,8 +173,10 @@ A combined create-or-edit screen is a three-line wrapper:
173
173
  ### The customization ladder
174
174
 
175
175
  - **Rung 0 — zero config.** Fields render from the schema: a `Schema.Literal`
176
- union → `select`; string → text; boolean → checkbox; `Date` → date; nested
177
- object → a `custom` placeholder asking for a render-prop.
176
+ union → `select`; string → text; boolean → checkbox; `Schema.Date` → date;
177
+ a nested struct → a real `<fieldset>` SECTION with its legend and dotted
178
+ fields; widgets receive `onBlur`, so errors reveal on leave-field exactly
179
+ like the headless binding.
178
180
  - **Rung 1 — one custom widget** via a `<Field>` render-prop:
179
181
  ```tsx
180
182
  import { AutoForm, Field, AsyncSelect } from '@voltro/ui'
@@ -214,6 +216,189 @@ Schema.Struct({
214
216
  })
215
217
  ```
216
218
 
219
+ ### Validation — one schema, translated messages, server field errors
220
+
221
+ Client and server validate the SAME input schema, and the messages a user
222
+ sees are structured, not developer text. Every failed check maps to a stable
223
+ message id with params — `validation.required`, `validation.minLength {min}`,
224
+ `validation.minValue {min}` — rendered through a built-in en/de catalog. The
225
+ locale follows `<html lang>` (the framework's locale contract); override it
226
+ per form with `locale:`, or wire your own catalog in one line:
227
+
228
+ ```tsx
229
+ // ONCE, at the app root — every form under it resolves ids through your catalog
230
+ <ValidationMessagesProvider messages={(id, params) => t(id, params)}>
231
+ <App />
232
+ </ValidationMessagesProvider>
233
+ ```
234
+
235
+ A per-form `messages:` option still exists and wins over the provider; a
236
+ resolver returning `undefined` falls through to the built-ins per id, so a
237
+ partial catalog costs nothing.
238
+
239
+ A widget kit that resolves ids itself reads the same resolver with
240
+ `useValidationMessages()`. It returns the provider's function, or
241
+ `undefined` when no provider is mounted — so a kit can fall through to the
242
+ built-in catalog instead of shipping its own.
243
+
244
+ Errors key the FULL field path (`address.city`, `entries.0.startsAt`), and a
245
+ `message` annotation on a schema may BE an id with params — as may the issues
246
+ a struct-level `filter` returns, which land at THEIR field:
247
+
248
+ ```ts
249
+ const Input = Schema.Struct({
250
+ name: Schema.String.pipe(Schema.minLength(2)),
251
+ startsAt: Schema.Number,
252
+ endsAt: Schema.Number,
253
+ }).pipe(
254
+ Schema.filter((v) =>
255
+ v.startsAt < v.endsAt ? undefined : [{ path: ['endsAt'], message: 'validation.beforeStart' }],
256
+ ),
257
+ )
258
+ ```
259
+
260
+ **Server-side rules route to their field too.** An executor raises a typed
261
+ field error through the always-present `ctx.validation` — no declaration
262
+ needed, `ValidationError` is auto-merged into every mutation's and action's
263
+ wire error union exactly like `ScopeError`:
264
+
265
+ ```ts
266
+ // executor
267
+ if (await emailTaken(input.email)) {
268
+ return yield* ctx.validation.fail('email', 'validation.emailTaken')
269
+ }
270
+ yield* ctx.validation.require(input.startsAt < input.endsAt, 'endsAt', 'validation.beforeStart')
271
+ // several at once: ctx.validation.failAll([{ field, message }, …])
272
+ ```
273
+
274
+ The binding routes it: the message lands in `errors.email` (translated
275
+ through the same catalog), the form stays editable, and `submitError` only
276
+ carries what NO field can — a `BusinessRuleViolation` whose rule pinpointed
277
+ a `field` routes the same way. Custom widget kits get the same judgement via
278
+ `fieldIssuesOf(error)` from `@voltro/protocol`.
279
+
280
+ **Async checks gate the submit.** Bind a uniqueness probe to its field and
281
+ `submit` waits for it — an `invalid` verdict blocks with the message on that
282
+ field, an unsettled check fails closed after 5s:
283
+
284
+ ```tsx
285
+ const email = useAsyncValidation('app', 'users.emailAvailable', values.email ?? '', {
286
+ interpret: (r) => ({ valid: (r as { available: boolean }).available, message: 'Email taken' }),
287
+ })
288
+ const form = useFormBinding('app', 'users.create', { asyncFields: { email } })
289
+ ```
290
+
291
+ **The binding is typed — `toInput` included.** `createHooks<AppProcedures>('app')`
292
+ returns `useFormBinding` beside the other hooks — tag as a literal type,
293
+ `values`/`defaults` and the submit output inferred from the descriptor. With
294
+ `toInput`, the form gets its OWN `Values` shape and the mapper's return is
295
+ checked against the mutation's input by the compiler — a mapping that stops
296
+ producing the wire shape is a type error, not a runtime refusal.
297
+
298
+ ### The full binding — nested values, arrays, timing, one state
299
+
300
+ `useFormBinding` carries a complete form, not just flat fields. Nested structs
301
+ flatten into SECTIONS (`address.city`, grouped under `section('address')`),
302
+ arrays of structs become field arrays, and every field is reachable as a
303
+ bound handle a widget kit spreads onto its input:
304
+
305
+ ```tsx
306
+ const form = useFormBinding('app', 'employees.update', {
307
+ defaults: fromRow(employee),
308
+ toInput: (values) => ({ id: employee.id, ...employeePatch(values) }),
309
+ errorPath: { fullName: 'name' },
310
+ })
311
+
312
+ const city = form.field('address.city') // { value, setValue, onBlur, error, required, label, a11y, … }
313
+ form.array('entries').push({ startsAt: '' })
314
+ form.section('address') // the section's descriptors
315
+ form.state // { isDirty, canSubmit, isSubmitting, isSubmitSuccessful, submissionAttempts, errorCount, pending, … }
316
+ form.reset(nextDefaults) // switch the edited record without a remount
317
+ form.focusFirstInvalid()
318
+ ```
319
+
320
+ **Error timing has defaults a form can trust:** a form NEVER opens with
321
+ errors — a field reveals its error after ITS blur or after the first submit
322
+ attempt, then live (`validate: { onChange: 'afterTouched' }`; `'always'` and
323
+ `'never'` exist). `isValid`/`canSubmit` always tell the truth underneath, so
324
+ the save button disables correctly while the user is not yet being scolded.
325
+
326
+ **Values ≠ mutation input:** `toInput` maps form values to the wire input
327
+ BEFORE validation; input-schema issues route back to form fields via
328
+ `errorPath` (same-name fields map automatically). For composed saves,
329
+ `onSubmit: async ({ input, values, mutate }) => …` owns the write and keeps
330
+ optimistic + error routing.
331
+
332
+ **Per-field rendering:** with `subscribe: 'fields'` the binding only
333
+ re-renders on submission-level changes, and each field component subscribes
334
+ narrowly:
335
+
336
+ ```tsx
337
+ const BoundField = ({ form, path }: { form: FormBinding<Record<string, unknown>, unknown>; path: string }) => {
338
+ const f = useFormField(form, path)
339
+ return <input id={f.a11y.id} value={String(f.value ?? '')} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} aria-invalid={f.a11y['aria-invalid']} />
340
+ }
341
+ ```
342
+
343
+ Annotate structure on the schema itself: `formField({ section, order, label,
344
+ widget })` rides an annotation, `description` becomes help text, and
345
+ `Schema.Date` / `Schema.DateTimeUtc` map to date/datetime widgets. The engine
346
+ underneath is an implementation detail — no engine type appears in the public
347
+ API, and production builds stub its devtools channel automatically.
348
+
349
+ ### Reference fields, uploads, and testing the form
350
+
351
+ **Reference fields.** Mark a schema field as a table reference and it renders
352
+ as a picker-shaped field whose VALUE stays the id (or id list — which is
353
+ exactly what a target's declared `relations:` consumes):
354
+
355
+ ```ts
356
+ const EmployeesUpdateInput = Schema.Struct({
357
+ id: Schema.String,
358
+ storeId: Schema.String.annotations(formField({ reference: 'stores' })),
359
+ assignedStores: Schema.Array(Schema.String).annotations(formField({ reference: 'stores' })),
360
+ })
361
+ ```
362
+
363
+ The descriptor carries `widget: 'reference'` + `reference: 'stores'`; bind
364
+ the shipped `<AsyncSelect>` (or your own picker) to it via a render-prop —
365
+ the default registry deliberately renders the render-prop note, because a
366
+ live picker needs a query binding only the app can name.
367
+
368
+ **Uploads as field values.** `useUpload` returns a `fileId`; hold it in a
369
+ field and LINK it in the submit — the composed save is what `onSubmit` is
370
+ for, so the upload that never got attached cannot happen silently:
371
+
372
+ ```tsx
373
+ const form = useFormBinding('app', 'tasks.create', {
374
+ onSubmit: async ({ input, values, mutate }) => {
375
+ const row = await mutate(input)
376
+ // attachments: [...existing, ...uploaded fileIds] — linked HERE, not forgotten
377
+ return row
378
+ },
379
+ })
380
+ ```
381
+
382
+ **Leaving a dirty form** is guarded by the router: `useBlocker(form.state.isDirty)`
383
+ holds SPA navigations (Back button included) and arms the native
384
+ `beforeunload` prompt — see the routing docs.
385
+
386
+ **Testing.** `renderFormBinding` (from `@voltro/testing/client`) drives the
387
+ REAL binding against a fake api — fill, blur, submit, read the visible
388
+ errors; a mutation handler that throws `ValidationError({ field })`
389
+ exercises the same routing path a server refusal takes:
390
+
391
+ ```ts
392
+ const form = await renderFormBinding('users.create', {
393
+ binding: { schema: UsersCreateInput },
394
+ mutation: () => { throw new ValidationError({ field: 'email', message: 'validation.emailTaken' }) },
395
+ })
396
+ await form.submit()
397
+ expect(form.errors()['email']).toBe('validation.emailTaken')
398
+ ```
399
+
400
+ Runs under jsdom (`// @vitest-environment jsdom`).
401
+
217
402
  ### Forms without JavaScript
218
403
 
219
404
  On a server-rendered page, `<AutoForm>` works with JavaScript disabled — or not
@@ -234,7 +419,18 @@ the SAME input schema the RPC path decodes:
234
419
  - unknown keys are dropped
235
420
 
236
421
  Validation runs through the same `validateFields` as the client-side
237
- validation, so the error texts are identical. Then:
422
+ validation, so the error texts are identical — in the request's language, not
423
+ in English. The handler resolves the locale from THIS request through the same
424
+ resolver that decided the surrounding page's language ([`voltro:locale` cookie
425
+ › `Accept-Language` › the app's
426
+ `defaultLocale`](/docs/i18n/overview#locale-resolution-order)); an app that
427
+ configures no `locales` gets `en`. Then:
428
+
429
+ Under URL-prefix i18n the referring URL wins over the cookie chain: a form on
430
+ `/de/todos` renders its errors in German even if the `voltro:locale` cookie says
431
+ otherwise, because there the prefix is which page you are on rather than a
432
+ preference. A first segment that is not a declared locale falls through to the
433
+ cookie chain.
238
434
 
239
435
  - **Success → `303 See Other`** (POST-redirect-GET): back to the submitting
240
436
  page, or to `redirectTo` (same-origin relative paths only; anything else is
@@ -720,7 +916,33 @@ multi-device CRDT (single-user/device, honest about its limits).
720
916
 
721
917
  ```tsx
722
918
  const outbox = useOutbox({ send })
723
- // → { queue, enqueue, replay, online, pending, conflicts }
919
+ // → { queue, enqueue, replay, online, pending, conflicts, resolveConflict }
920
+ ```
921
+
922
+ **Durable across reloads.** Pass `persistence` and the queue survives:
923
+ entries rehydrate on mount, every transition persists (delivered entries are
924
+ compacted away), and a failing backing degrades to the in-memory behaviour
925
+ instead of breaking the outbox. The one real implementation is
926
+ `outboxPersistence()` from `@voltro/local-first`, backed by the same
927
+ `PersistenceAdapter` the sync engine drains — one durable queue per device:
928
+
929
+ ```tsx
930
+ import { createIndexedDbPersistence, outboxPersistence } from '@voltro/local-first'
931
+
932
+ const adapter = await createIndexedDbPersistence()
933
+ const outbox = useOutbox({ send, persistence: outboxPersistence(adapter) })
934
+ ```
935
+
936
+ **Resolving a conflict.** A conflicted entry blocks everything behind it
937
+ (causality). `resolveConflict(id, input)` replaces its input with the RESOLVED
938
+ value, returns it to pending and replays — typically computed with
939
+ `resolveWithPolicy()` from `@voltro/local-first`, where `crdtText()` columns
940
+ merge and scalars follow the declared `conflictPolicy()`:
941
+
942
+ ```tsx
943
+ outbox.resolveConflict(entry.id, resolveWithPolicy(policy, local, remote, {
944
+ crdtColumns: ['body'],
945
+ }))
724
946
  ```
725
947
 
726
948
 
@@ -195,7 +195,7 @@ would break every link into your api.
195
195
 
196
196
  ### Raw WebSocket gateways are guarded before the upgrade
197
197
 
198
- A [`defineWebSocket` gateway](/docs/data/subscriptions#raw-websocket-gateways--definewebsocket)
198
+ A [`defineWebSocket` gateway](/docs/data/subscriptions#raw-websocket-gateways-definewebsocket)
199
199
  mounts its own upgrade path beside the rpc socket, and the listener treats it
200
200
  exactly like the rpc upgrade:
201
201
 
@@ -479,7 +479,7 @@ deferred registry, the shell's bundle tags, and React's own bootstrap/settle
479
479
  scripts — while the policy header itself stays the middleware's to set via
480
480
  `responseHeaders`, carrying the same nonce. An `isr` page combined with
481
481
  `cspNonce` refuses the render: a cached nonce is a lie the browser enforces.
482
- Mechanics + examples: [Middleware → cspNonce](/docs/routing/middleware#a-per-request-csp-nonce--cspnonce).
482
+ Mechanics + examples: [Middleware → cspNonce](/docs/routing/middleware#a-per-request-csp-nonce-cspnonce).
483
483
 
484
484
  ## Incoming webhooks must verify their caller
485
485
 
@@ -582,7 +582,7 @@ dependency, like a rate-limit counter in Redis — catches internally and
582
582
  degrades **loudly**. `@voltro/plugin-ratelimit`'s `httpShield` does exactly
583
583
  that: a Redis outage means unlimited-with-a-warning, never a self-inflicted API
584
584
  outage. The full authoring guidance is in the
585
- [plugin contract](/docs/plugins/contract#onhttprequest-httprequestinterceptor--pre-auth-http-pipeline-hook).
585
+ [plugin contract](/docs/plugins/contract#onhttprequest-httprequestinterceptor-pre-auth-http-pipeline-hook).
586
586
 
587
587
  ## Response compression — and where BREACH sits
588
588
 
@@ -13,7 +13,7 @@ _A marketing landing page — hero, features, CTA. Static-rendered with zero JS
13
13
 
14
14
  A marketing landing page. The page exports `renderMode = 'static'` + `interactive = 'none'`, so `voltro build` pre-renders it to HTML and `voltro start` serves the file directly — zero framework JS on the wire. Template id: **`frontend-landing`**.
15
15
 
16
- It ships plain JSX (a hero, a features list, a CTA) you replace with your own copy no design-system dependency to fight. When you need a contact form or sign-up flow, switch `interactive: 'islands'` on the page and mark the interactive component with a `*.island.tsx` suffix so only that bundle ships.
16
+ It ships plain JSX (a hero, a features list, a CTA) you replace with your own copy, bilingual out of the box (URL-prefix i18n: `/` + `/de`), plus the two asset pipelines a marketing page actually needs: a **local hero image** through `?image` + `<Image>` and a **self-hosted woff2** declared under `fonts:`. When you need a contact form or sign-up flow, switch `interactive: 'islands'` on the page and mark the interactive component with a `*.island.tsx` suffix so only that bundle ships.
17
17
 
18
18
  ## Scaffold
19
19
 
@@ -27,14 +27,21 @@ voltro add-app marketing --template=frontend-landing --to acme
27
27
 
28
28
  ```text
29
29
  apps/acme/web/ # dir named by the app, not the template
30
- ├── app.config.ts # type:web, port:<allocated>
30
+ ├── app.config.ts # type:web, port:<allocated>, locales, fonts:
31
31
  ├── package.json
32
32
  ├── tsconfig.json
33
33
  └── src/
34
34
  ├── globals.css
35
+ ├── globals.d.ts # ambient '*.css' + '*?image'
36
+ ├── assets/hero.jpg # imported with ?image (build-time pipeline)
37
+ ├── fonts/Geist-Variable.woff2 # self-hosted, declared in app.config.ts
38
+ ├── fonts/LICENSE-Geist.txt # the face's licence, shipped beside it
39
+ ├── lib/locale.ts # URL-prefix i18n helpers
40
+ ├── locales/{en,de}.ts # the two catalogs
35
41
  └── pages/
36
42
  ├── layout.tsx # imports globals.css, renders {children}
37
- └── index.tsx # the landing page (hero · features · CTA)
43
+ ├── page.tsx # the landing page (hero · features · CTA)
44
+ └── [locale]/page.tsx # the /de mirror
38
45
  ```
39
46
 
40
47
  ## The page
@@ -88,9 +95,34 @@ import SignupForm from '../components/SignupForm.island'
88
95
 
89
96
  The surrounding HTML stays static; only the island hydrates.
90
97
 
98
+ ## The hero image — `?image` + `<Image>`
99
+
100
+ `src/assets/hero.jpg` is imported with the **`?image` suffix**, the explicit opt-in to the [build-time image pipeline](/docs/routing/assets): the import resolves to an optimized-asset object instead of vite's plain hashed URL, and `<Image>` renders it as a `<picture>` with one `<source>` per modern format.
101
+
102
+ ```tsx
103
+ import { Image } from '@voltro/web'
104
+ import hero from '../assets/hero.jpg?image'
105
+
106
+ <Image src={hero} alt="Abstract gradient artwork" priority sizes="(max-width: 900px) 100vw, 900px" />
107
+ ```
108
+
109
+ `width`, `height` and the blur placeholder are **not props** — they come off the asset, which is what reserves the box (CLS ≈ 0) without hand-written numbers. `priority` marks it the LCP image (eager + high fetch priority). Nothing here needs JS, so it survives `interactive: 'none'`.
110
+
111
+ The `*?image` ambient type is declared once in `src/globals.d.ts`. A **dynamic** `src` (a URL from a loader or CMS frontmatter) cannot be seen at build time — use the loader seam (`<Image src={url} loader={cdn} />`) instead.
112
+
113
+ > **Under `voltro test`** the image plugin is not wired (it is a dev/build transform), so a `?image` specifier resolves to a plain URL string. The shipped `page.test.tsx` `vi.mock`s the import with the asset object the pipeline produces — copy that pattern rather than hand-writing `width`/`height` on the page.
114
+
115
+ ## The font — self-hosted, no CDN
116
+
117
+ `app.config.ts` declares one family under [`fonts:`](/docs/routing/fonts), pointing at the woff2 committed in `src/fonts/`. The build content-hashes it and serves it from **your origin**, emits `@font-face`, computes a size-adjusted fallback face from the file's real metrics so the swap moves no text, and puts a `<link rel="preload">` in the shell.
118
+
119
+ Reference it from CSS through the `--font-geist` variable the shell defines (`globals.css` points the kit's `--font-sans` at it), or from TSX with `localFont('Geist')`.
120
+
121
+ **Swapping in your own face:** drop the `woff2` **and its licence file** into `src/fonts/`, then change `family` + `path`. The framework ships no font downloader on purpose — licence terms differ per family. The bundled Geist is SIL OFL 1.1 (`src/fonts/LICENSE-Geist.txt`).
122
+
91
123
  ## Styling
92
124
 
93
- `globals.css` is yours. Add `@import "tailwindcss"` plus the mandatory `@source "./**/*.{tsx,ts,jsx,js}"` glob if you want Tailwind, or `@import "@voltro/ui-shadcn/tokens.css"` (plus the kit `@source`) to pull in the design tokens and compose shadcn-style components on top.
125
+ `globals.css` imports `@voltro/ui-shadcn/tokens.css` (the design tokens) plus the mandatory `@source "./**/*.{tsx,ts,jsx,js}"` glob. Drop the kit import and use `@import "tailwindcss"` directly if you would rather start from nothing — but keep the `@source` line either way, and keep the `--font-sans` mapping if you keep the font declaration.
94
126
 
95
127
  ## What it doesn't ship
96
128