@voltro/cli 0.53.0 → 0.55.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 (191) hide show
  1. package/CHANGELOG.md +335 -0
  2. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  3. package/dist/agentsMd-DCY1RSs8.js +2 -0
  4. package/dist/{apiBuild-CaPfoWku.js → apiBuild-CMvLJM_K.js} +2 -2
  5. package/dist/apiBuild-Cl0IDx8c.js +2 -0
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-D-OnvNMf.js → build-S0QOzqPT.js} +115 -115
  8. package/dist/{checkCommand-D2ZduVlh.js → checkCommand-DNkY5kwF.js} +1 -1
  9. package/dist/{checkCommand-C5elt0tW.js → checkCommand-fbj9GDjN.js} +6 -6
  10. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  11. package/dist/codegen-CN6vMM4J.js +2 -0
  12. package/dist/{codegen-FEk8AZHb.js → codegen-SIepQtUl.js} +76 -65
  13. package/dist/codegenCommand-3TDJezom.js +42 -0
  14. package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-C2zxZUIw.js} +64 -9
  15. package/dist/{commands-DyxAmhP0.js → commands-BBYJ7Q3B.js} +96 -73
  16. package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-D2kmyCLL.js} +3 -3
  17. package/dist/{dataCommand-Bab9X7s8.js → dataCommand-BEPPQiTl.js} +267 -195
  18. package/dist/dbCommand-BTyBGhIA.js +2 -0
  19. package/dist/{dbCommand-06O2finM.js → dbCommand-DZTmOFT4.js} +3 -3
  20. package/dist/{dev-C6LGF4iY.js → dev-Ca_A_S9v.js} +2439 -2397
  21. package/dist/{dev-GjJWAYo2.js → dev-DfVZaoys.js} +1 -1
  22. package/dist/{doctorCommand-etMkflRc.js → doctorCommand-CGZJK_4o.js} +21 -21
  23. package/dist/doctorCommand-djmqEcDC.js +2 -0
  24. package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-DY2rYpTa.js} +1 -1
  25. package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-BoCqZsgp.js} +1 -1
  26. package/dist/{envCommand-dSyKvRkM.js → envCommand-Bxy2fOjc.js} +15 -15
  27. package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BsbZ-XDg.js} +2 -2
  28. package/dist/fileConventions-l-RIXbx8.js +36 -0
  29. package/dist/{fileTaxonomy-B7uxipWS.js → fileTaxonomy-CbyMQYx_.js} +37 -37
  30. package/dist/frameworkTableAssembly-Df2Ymp2f.js +2 -0
  31. package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-Do-cf6RJ.js} +96 -102
  32. package/dist/index.js +2 -2
  33. package/dist/{infoCommand-_53iOc_j.js → infoCommand-EmM3jPKD.js} +1 -1
  34. package/dist/{inspect-Bd8-9wsi.js → inspect-DCqILJ1G.js} +4 -0
  35. package/dist/inspect-DGJwpOAb.js +2 -0
  36. package/dist/interruptedReplace-CwnkBb2X.js +41 -0
  37. package/dist/interruptedReplace-qzmFI020.js +2 -0
  38. package/dist/manifestBuild-CJ2zvPvT.js +2 -0
  39. package/dist/{manifestBuild-Cqgsx2bM.js → manifestBuild-DjX5MoXy.js} +1 -1
  40. package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
  41. package/dist/{migrate-Cko9rswM.js → migrate-CGFZS-1a.js} +2 -2
  42. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  43. package/dist/mobileCommand-DAum7tsG.js +2 -0
  44. package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
  45. package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
  46. package/dist/{probeCommand-DkGGLknv.js → probeCommand-Bs3iVBSL.js} +1 -1
  47. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  48. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  49. package/dist/renderModeScan-43yQ2opo.js +147 -0
  50. package/dist/{renderProfile-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
  51. package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-C1BTpHGQ.js} +1 -1
  52. package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CXMwLg9n.js} +1 -1
  53. package/dist/{serveCommand-CueKQgzl.js → serveCommand-C7IrCD58.js} +899 -897
  54. package/dist/serveCommand-Cjt5S9hD.js +2 -0
  55. package/dist/serveEntry.js +1 -1
  56. package/dist/start-DH7cat4-.js +3 -0
  57. package/dist/{start-ekPan8BT.js → start-EOV7s1NZ.js} +544 -527
  58. package/dist/startEntry.js +1 -1
  59. package/dist/{staticCommand-xlSL-IWk.js → staticCommand-ey0kYmOT.js} +1 -1
  60. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  61. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  62. package/dist/{test-BWPQcRoB.js → test-DO27-x2P.js} +1 -1
  63. package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-C_jN1w18.js} +1 -1
  64. package/dist/updateCommand-nnFjDbl4.js +2 -0
  65. package/dist/{webDev-C7jWJ5dX.js → webDev-1XpVnYkW.js} +1 -1
  66. package/dist/{webDev-oczpugbx.js → webDev-B7vNj4Bq.js} +1231 -1186
  67. package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-B1LVcyO3.js} +1 -1
  68. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  69. package/package.json +31 -19
  70. package/templates/AGENTS.core.md +2 -0
  71. package/templates/AGENTS.md +4 -2
  72. package/templates/agent-docs/_index.md +2 -2
  73. package/templates/agent-docs/_manifest.json +1 -1
  74. package/templates/agent-docs/ai.md +4 -4
  75. package/templates/agent-docs/authentication.md +115 -0
  76. package/templates/agent-docs/cli.md +98 -12
  77. package/templates/agent-docs/data.md +121 -21
  78. package/templates/agent-docs/database/advancedqueries.md +1 -1
  79. package/templates/agent-docs/database/migrations.md +1 -1
  80. package/templates/agent-docs/database/seedsdialects.md +64 -2
  81. package/templates/agent-docs/internationalization.md +2 -0
  82. package/templates/agent-docs/introduction.md +25 -0
  83. package/templates/agent-docs/local-first-mobile.md +139 -41
  84. package/templates/agent-docs/observability.md +4 -2
  85. package/templates/agent-docs/plugins/atlassian.md +2 -2
  86. package/templates/agent-docs/plugins/audit.md +2 -2
  87. package/templates/agent-docs/plugins/billing.md +1 -1
  88. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  89. package/templates/agent-docs/plugins/comments.md +22 -0
  90. package/templates/agent-docs/plugins/presence.md +32 -3
  91. package/templates/agent-docs/plugins/prometheus.md +2 -0
  92. package/templates/agent-docs/plugins/queue.md +47 -4
  93. package/templates/agent-docs/plugins.md +52 -14
  94. package/templates/agent-docs/reference.md +25 -4
  95. package/templates/agent-docs/routing.md +81 -9
  96. package/templates/agent-docs/scheduling.md +1 -1
  97. package/templates/agent-docs/schema-driven-ui.md +137 -1
  98. package/templates/agent-docs/templates/appshells.md +36 -4
  99. package/templates/agent-docs/whats-new.md +75 -158
  100. package/templates/apps/api-ai/package.json +6 -6
  101. package/templates/apps/api-auth/package.json +8 -8
  102. package/templates/apps/api-backend/package.json +7 -7
  103. package/templates/apps/api-backend-deactivation/package.json +7 -7
  104. package/templates/apps/api-backend-mail/package.json +8 -8
  105. package/templates/apps/api-backend-mariadb/package.json +9 -9
  106. package/templates/apps/api-backend-sqlite/package.json +8 -8
  107. package/templates/apps/api-backend-storage/package.json +8 -8
  108. package/templates/apps/api-cms/package.json +9 -9
  109. package/templates/apps/api-collab/README.md +3 -3
  110. package/templates/apps/api-collab/app.config.ts +1 -1
  111. package/templates/apps/api-collab/database/schema.ts +12 -8
  112. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  113. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  114. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  115. package/templates/apps/api-collab/package.json +8 -8
  116. package/templates/apps/api-collab/template.json +1 -1
  117. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  118. package/templates/apps/api-data-advanced/package.json +8 -8
  119. package/templates/apps/api-durable/package.json +8 -8
  120. package/templates/apps/api-feature-flags/package.json +9 -9
  121. package/templates/apps/api-governance/package.json +8 -8
  122. package/templates/apps/api-kv/package.json +8 -8
  123. package/templates/apps/api-moderation/package.json +8 -8
  124. package/templates/apps/api-observability/package.json +8 -8
  125. package/templates/apps/api-ratelimit/package.json +8 -8
  126. package/templates/apps/api-rbac/package.json +8 -8
  127. package/templates/apps/api-rest/package.json +7 -7
  128. package/templates/apps/api-row-history/package.json +8 -8
  129. package/templates/apps/api-saas/package.json +11 -10
  130. package/templates/apps/api-saas-starter/package.json +10 -10
  131. package/templates/apps/api-search/package.json +8 -8
  132. package/templates/apps/api-status/package.json +8 -8
  133. package/templates/apps/api-webhooks/package.json +9 -9
  134. package/templates/apps/changelog/package.json +7 -6
  135. package/templates/apps/edge-functions/package.json +2 -2
  136. package/templates/apps/frontend-admin/package.json +8 -8
  137. package/templates/apps/frontend-app/package.json +9 -9
  138. package/templates/apps/frontend-auth/package.json +8 -8
  139. package/templates/apps/frontend-blank/package.json +7 -7
  140. package/templates/apps/frontend-cms/package.json +9 -9
  141. package/templates/apps/frontend-collab/README.md +43 -24
  142. package/templates/apps/frontend-collab/app.config.ts +3 -3
  143. package/templates/apps/frontend-collab/package.json +14 -10
  144. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  145. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  146. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  147. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  148. package/templates/apps/frontend-collab/template.json +2 -2
  149. package/templates/apps/frontend-contact/package.json +7 -7
  150. package/templates/apps/frontend-dashboard/package.json +7 -7
  151. package/templates/apps/frontend-docs/package.json +8 -7
  152. package/templates/apps/frontend-i18n/package.json +6 -6
  153. package/templates/apps/frontend-landing/README.md +48 -0
  154. package/templates/apps/frontend-landing/app.config.ts +28 -0
  155. package/templates/apps/frontend-landing/package.json +7 -6
  156. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  157. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  158. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  159. package/templates/apps/frontend-landing/src/globals.css +15 -0
  160. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  161. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  162. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  163. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  164. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  165. package/templates/apps/frontend-landing/template.json +2 -2
  166. package/templates/apps/frontend-portal/package.json +8 -8
  167. package/templates/apps/frontend-saas/package.json +8 -8
  168. package/templates/apps/frontend-spa/package.json +7 -7
  169. package/templates/apps/frontend-ssr/package.json +7 -7
  170. package/templates/apps/frontend-ssr-api/package.json +8 -8
  171. package/templates/apps/frontend-static-blog/package.json +8 -7
  172. package/templates/apps/frontend-status/package.json +8 -8
  173. package/templates/apps/mobile-app/package.json +12 -11
  174. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  175. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  176. package/dist/agentsMd-SDDSkyl4.js +0 -2
  177. package/dist/apiBuild-DHtLXYx9.js +0 -2
  178. package/dist/codegen-BWpt3VgF.js +0 -2
  179. package/dist/codegenCommand-BOiWQ5hz.js +0 -137
  180. package/dist/dbCommand-B1EXBC6f.js +0 -2
  181. package/dist/doctorCommand-B0hX0tdz.js +0 -2
  182. package/dist/fileConventions-DASGEmj-.js +0 -35
  183. package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
  184. package/dist/inspect-CuoDInfZ.js +0 -2
  185. package/dist/interruptedReplace-C3O3M1MM.js +0 -28
  186. package/dist/interruptedReplace-CvmiAM9K.js +0 -2
  187. package/dist/manifestBuild-C4-J1-m_.js +0 -2
  188. package/dist/renderModeScan-CUbOeOAg.js +0 -122
  189. package/dist/serveCommand-DsnrVN3U.js +0 -2
  190. package/dist/start-BJzZLbt8.js +0 -3
  191. package/dist/updateCommand-Bqql_rsQ.js +0 -2
@@ -61,6 +61,8 @@ these before hand-rolling a form, a table, or a picker** — full guide in
61
61
  | Hook | Purpose |
62
62
  |---|---|
63
63
  | [`useFormBinding`](/docs/ui/forms-and-tables) | Bind a form to a MUTATION — fields + validation from its input Schema; a server `ValidationError({ field })` routes to that field. |
64
+ | [`useFormField`](/docs/ui/forms-and-tables) | One field of the enclosing binding — value, blur, the display-gated error, a11y props. Re-renders that field alone. |
65
+ | [`useFormBindingContext`](/docs/ui/forms-and-tables) | The binding a `<FormBindingProvider>` (or `<AutoForm>`) mounted above — for a widget kit that needs the form itself, not one field. |
64
66
  | [`useDataTable`](/docs/ui/forms-and-tables) | Bind a table to a QUERY — live rows, columns derived from the output Schema, sort/filter/pagination. |
65
67
  | [`useQueryFilters`](/docs/ui/forms-and-tables) | Filter controls derived from a query's INPUT Schema (the read-side mirror of a form). |
66
68
  | [`useQueryField`](/docs/ui/forms-and-tables) | Query-bound picker — a debounced search term drives a live subscription. |
@@ -68,12 +70,14 @@ these before hand-rolling a form, a table, or a picker** — full guide in
68
70
  | [`useAsyncValidation`](/docs/ui/client-utilities/use-async-validation) | Live server-side validation (uniqueness, cross-row) over a query binding. |
69
71
  | [`useDebounced`](/docs/ui/client-utilities/use-debounced) | Debounce a value (search, filter, validation input). |
70
72
  | [`useRecord`](/docs/ui/client-utilities/use-record) | One live record from a "get" query, normalized (array → first row). |
73
+ | [`useValidationMessages`](/docs/ui/forms-and-tables) | The app-wide validation-message resolver from `<ValidationMessagesProvider>`, or `undefined` when none is mounted — for a widget kit that resolves message ids itself. |
71
74
 
72
75
  ## Files, Permissions, and Client Utilities
73
76
 
74
77
  | Hook | Purpose |
75
78
  |---|---|
76
79
  | [`useUpload`](/docs/plugins/storage) | File upload with progress + cancel, on every storage provider. Not base64 → action. |
80
+ | [`usePresenceChannel`](/docs/local-first/overview) | One presence wire for local-first: the peer roster plus a `publish`/`subscribe` pair for ephemeral payloads (cursors, typing), riding the existing presence lane rather than a second socket. Room-scoped — a mismatched room throws instead of delivering across rooms. |
77
81
  | [`useCan`](/docs/ui/client-utilities/use-can) | Scope/RBAC UI gate, over `<PermissionProvider>`. Lives in `@voltro/client` — scopes are a framework concept, so gating a button needs no rbac dependency. |
78
82
  | [`useCanAny`](/docs/ui/client-utilities/use-permissions) | OR variant of `useCan` — true when the subject holds AT LEAST ONE of the required scopes. |
79
83
  | [`usePermissions`](/docs/ui/client-utilities/use-permissions) / `<PermissionProvider>` | The current subject's scope set, fed once from your session query — the source `useCan` reads. Gates UI on the SAME scope strings the server checks. |
@@ -97,6 +101,23 @@ these before hand-rolling a form, a table, or a picker** — full guide in
97
101
  | [`useResumableAgentStream`](/docs/ai/streaming) | Agent stream that survives reload/reconnect. |
98
102
  | [`useDataCopilot`](/docs/ai/data-copilot) | Bind a data-copilot action by api name + tag. |
99
103
 
104
+ ## Plugin and Local-First Hooks
105
+
106
+ Shipped by an installed plugin or by `@voltro/local-first`, not by
107
+ `@voltro/client` — the import path is the package, and each takes the api name
108
+ as its last argument (default `'app'`). The rest of the surface reads exactly
109
+ like the hooks above.
110
+
111
+ | Hook | Package | Purpose |
112
+ |---|---|---|
113
+ | [`useWebPush`](/docs/plugins/notifications) | `@voltro/plugin-notifications/web` | Web-push permission flow, service-worker registration and subscribe/unsubscribe — `{ status, error?, subscribe, unsubscribe }`, where `status` distinguishes `unsupported` / `denied` / `subscribed` for THIS browser. |
114
+ | [`useComments`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | The live threads on one anchor plus every action on them (`create`, `edit`, `resolve`, `remove`, `react`, `markRead`) and the unread badge count. |
115
+ | [`useThread`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | One thread by id — a projection over the same live list, so it opens no second subscription. |
116
+ | [`useMentionSearch`](/docs/plugins/comments) | `@voltro/plugin-comments/web` | `@`-mention autocomplete over the app-declared, tenant-filtered directory. |
117
+ | [`useCrdtText`](/docs/local-first/overview#a-collaborative-text-field-usecrdttext) | `@voltro/local-first/react` | A collaborative text field bound to one `crdtText()` cell — merged text, minimal-span edits, the offline queue and `synced`. |
118
+ | [`useCrdtDoc`](/docs/local-first/overview#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) | `@voltro/local-first/react` | The sync half of a `crdtDoc()` column — one `SyncClient` + one live CRDT document per cell, with the echo guard that keeps a folded remote update from being pushed back. Hands the document to `useCrdtEditor`. |
119
+ | [`useCrdtEditor`](/docs/local-first/overview#rich-text-crdtdoc-usecrdtdoc-usecrdteditor) | `@voltro/local-first/editor` | A collaborative rich-text editor over a `crdtDoc()` column — one Tiptap instance bound to the shared document, carets bridged over an injected transport. |
120
+
100
121
  ## Where To Read Next
101
122
 
102
123
  - [Data hooks](/docs/reference/hooks-data)
@@ -210,9 +231,9 @@ type TeamsState = SubscriptionState<ReadonlyArray<Team>> & { readonly canEdit: b
210
231
  ```
211
232
 
212
233
  `loading` means **no data has arrived yet**, not "the subscription is still
213
- warming up". A cold-start failure leaves `loading` true and sets `error`, so a
214
- component branching on `loading` alone renders a skeleton forever — check
215
- `error`.
234
+ warming up". A cold-start failure is its own state `loading: false`,
235
+ `failed: true`, `error` non-optional — so branching on `loading` alone is safe;
236
+ render the failure off `failed`.
216
237
 
217
238
  Use `{ skip }` to defer until inputs are ready:
218
239
 
@@ -228,7 +249,7 @@ const { data } = useSubscription(
228
249
  ### Offline semantics with the local-first mirror
229
250
 
230
251
  With `@voltro/local-first`'s query mirror bound (see
231
- [the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror--durable-outbox)),
252
+ [the sync engine](/docs/local-first/overview#the-sync-engine-query-mirror-durable-outbox)),
232
253
  `useSubscription`'s behaviour extends offline WITHOUT a second API: a cold
233
254
  start seeds `data` (and `revision`) from the device's mirrored rows for the
234
255
  subject's partition, so `loading` resolves against local data when the server
@@ -207,6 +207,24 @@ src/pages/users/new/page.tsx # /users/new → wins (static beats dynamic)
207
207
  src/pages/[...rest]/page.tsx # everything else
208
208
  ```
209
209
 
210
+ ## Development mounts in React `StrictMode`
211
+
212
+ The client entry wraps the tree in `StrictMode`, so **in development every
213
+ effect runs twice, with a real unmount in between**. That is the point — it
214
+ surfaces effects that are not safe to re-run — but it has one consequence
215
+ worth stating outright, because it is expensive to rediscover:
216
+
217
+ **An effect that keys off "have I mounted before?" fires on the second mount.**
218
+ A deployment measured this as a picker that cleared its own just-loaded value:
219
+ a "when the dependency changes, clear the selection" effect built on a
220
+ mount-counting ref saw the second mount as a change, and an edit form opened
221
+ with an empty required field and a red message while the record had the value.
222
+ Visible only in development, which is exactly where it reads as a data bug.
223
+
224
+ The rule that survives the double mount: **compare VALUES, not runs.** A reset
225
+ that fires because "this is not the first run" is a reset waiting for the next
226
+ remount; one that fires because the dependency actually differs is not.
227
+
210
228
  ## Query strings
211
229
 
212
230
  Query params are orthogonal to the URL pattern — they never appear in the file path. A page declares its query contract as a **`searchParams` schema export**, the same page-export convention as `meta`, `loader`, and `renderMode`:
@@ -245,6 +263,36 @@ export { searchParams } from '../../search/page'
245
263
 
246
264
  One schema, no drift — the mirror page decodes exactly what the original declares.
247
265
 
266
+ **Two scanners read this line, and they do not agree.** The isr refusal above is a
267
+ source scan, and so is the route-builder codegen that brands a route's URL with its
268
+ searchParams type — but they recognise different spellings, which is worth knowing
269
+ before you pick one:
270
+
271
+ | spelling on the mirror page | typed `withQuery` on the mirror route | `isr` + schema refused |
272
+ | --- | --- | --- |
273
+ | `export const searchParams = …` | yes | yes |
274
+ | `export { searchParams } from '../../search/page'` | **no** | yes |
275
+ | `export * from '../../search/page'` | **no** | **no** |
276
+ | `import { searchParams as base } …` + `export const searchParams = base` | yes | yes |
277
+
278
+ The middle two are the ones to watch. A clause re-export still decodes correctly at
279
+ runtime and is still refused on `isr` — but the route builder does not see it, so
280
+ `withQuery` on the mirror's URL falls back to untyped and nothing reports it. A star
281
+ re-export is seen by neither: the binding is on the module at runtime (`export *`
282
+ forwards every named export), so the page behaves as if it declared a schema while
283
+ the `isr` refusal never fires.
284
+
285
+ So: prefer the last row when you want the mirror route's links type-checked, and
286
+ never reach a schema through `export *` on an `isr` page.
287
+
288
+ ```tsx
289
+ // src/pages/[locale]/search/page.tsx — one schema, and both scanners see it
290
+ import { searchParams as base } from '../../search/page'
291
+
292
+ export const searchParams = base
293
+ export { default } from '../../search/page'
294
+ ```
295
+
248
296
  ### Routes without a schema
249
297
 
250
298
  `useSearchParams()` without an argument stays the raw `URLSearchParams` — nothing changes for a route that declares no schema:
@@ -521,7 +569,7 @@ export const renderMode = 'static' as const // 'static' | 'spa' | 'ssr' | 'isr
521
569
  | `ssr` | Every request | Never | Authenticated dashboards, search results, anything cookie-driven |
522
570
  | `isr` | First request after build, then on revalidate | Per-key in-memory or Postgres | News feeds, listings, dashboards that change but not per-user |
523
571
 
524
- Those four are the **complete** set. An unrecognised value is a hard error naming the page — see [What doesn't work](#what-doesnt-work).
572
+ Those four are the **complete** set. An unrecognised value is a hard error naming the page — see [What doesn't work](#what-doesn-t-work).
525
573
 
526
574
  ## static (SSG)
527
575
 
@@ -2062,7 +2110,7 @@ export const interactive = 'islands' as const
2062
2110
 
2063
2111
  With `interactive: 'islands'`, the page's HTML is server-rendered and its script tag points at the page's own entry. That entry registers the page's islands, scans for island markers, and hydrates each one on its own schedule — the page component itself never runs in the browser.
2064
2112
 
2065
- Looking for Astro's **"Server Islands"** — per-request-rendered holes in otherwise static pages? In Voltro that is planned as **partial prerendering (PPR)**, not part of islands mode.
2113
+ Looking for Astro's **"Server Islands"** — per-request-rendered holes in otherwise static pages? In Voltro that is [**partial prerendering (PPR)**](/docs/routing/render-modes#partial-prerendering-ppr-cached-shell-per-request-holes): `ppr = true` on an `isr` page, a separate mechanism from islands mode. The two do not combine — ppr reveals its holes through hydration, so it requires `interactive: 'full'`.
2066
2114
 
2067
2115
  ## When to use islands
2068
2116
 
@@ -2449,9 +2497,14 @@ passthrough.
2449
2497
  this path; for `?image` assets quality is baked at build time from
2450
2498
  `images.quality`.
2451
2499
  - **Remote images** — same: loader seam, not the build pipeline.
2452
- - **Markdown-content images** (a blog's relative references) — copied +
2453
- hashed by the content pipeline; build-time transformation for those is a
2454
- named non-goal for now.
2500
+ - **Markdown-content images** (a blog's relative references) — copied into
2501
+ `dist/assets/content-media/<hash>.<ext>` by the content pipeline and the
2502
+ `src` rewritten to that URL. A relative source resolves against the markdown
2503
+ file that references it, and one that does not exist FAILS the build naming
2504
+ the path — a page that renders while its image 404s is the outcome this
2505
+ replaces. Absolute (`/…`) and remote sources are left untouched. Build-time
2506
+ TRANSFORMATION (resize / format) stays a named non-goal here: a markdown
2507
+ reference carries no width and no `sizes` to derive one from.
2455
2508
 
2456
2509
  ## `<Image>` without the pipeline
2457
2510
 
@@ -2730,7 +2783,7 @@ Two boundaries, stated rather than implied:
2730
2783
 
2731
2784
  ## A per-request CSP nonce — `cspNonce`
2732
2785
 
2733
- Return `cspNonce` and the framework stamps `nonce="…"` onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, and React's own bootstrap/settle scripts (via React's nonce support). The POLICY header stays yours: set it via `responseHeaders`, with the same nonce.
2786
+ Return `cspNonce` and the framework stamps `nonce="…"` onto every script tag of that render — the state script, the deferred registry, the shell's bundle tags, the islands entry, and React's own bootstrap and Suspense scripts (via React's nonce support). The POLICY header stays yours: set it via `responseHeaders`, with the same nonce.
2734
2787
 
2735
2788
  ```ts
2736
2789
  import { randomBytes } from 'node:crypto'
@@ -2751,7 +2804,9 @@ export const csp = defineMiddleware({
2751
2804
  ```
2752
2805
 
2753
2806
  - **`isr` + `cspNonce` refuses the render, loudly.** A cached nonce is a lie the browser enforces — the second visitor gets HTML whose nonce the policy header no longer matches. The ways out: `ssr` for nonce'd pages, or a hash-based CSP for `isr`.
2754
- - The PPR variant of that question is open until partial prerendering exists; a component for client-injected script tags (a `Script` component) is planned.
2807
+ - **`ppr` is refused for the same reason.** A [partial-prerendered](/docs/routing/render-modes#partial-prerendering-ppr-cached-shell-per-request-holes) page serves a cached shell whose inline registry scripts cannot carry a per-request nonce, so `cspNonce` on a `ppr` page is refused by name — same ways out as `isr`.
2808
+ - **Client-injected script tags carry the nonce too.** [`<Script>`](/docs/routing/third-party-scripts) propagates the DOCUMENT's own nonce onto the tag it injects, so a nonce'd `ssr` page needs no explicit `nonce` prop.
2809
+ - **`defer()` does not yet compose with `cspNonce`.** The settle `<script>` each [`<Await>`](/docs/routing/loaders-and-meta) boundary emits inside the streamed body is part of the RENDERED TREE — it is not one React injects, so React's nonce support does not reach it, and it is not in the `<head>` the framework stamps. Under `script-src 'nonce-…'` the browser blocks it, the deferred value is never published to the client registry, and the boundary stays on its fallback after hydration while the server HTML looks correct. Until that is closed, pick one per route: `defer()`, or a nonce'd CSP.
2755
2810
 
2756
2811
  ## `match` — where it runs
2757
2812
 
@@ -2931,5 +2986,22 @@ would any navigation.
2931
2986
 
2932
2987
  `from` matches route patterns literally, so a `[locale]` mirror declares its
2933
2988
  own: `/de/photos/[id]`'s page re-exports the base page and sets
2934
- `intercept: { from: '/[locale]/photos' }` — same pattern as the documented
2935
- searchParams schema re-export.
2989
+ `intercept: { from: '/[locale]/photos' }`.
2990
+
2991
+ **Declares — not re-exports.** `intercept` is read off the page module at
2992
+ runtime, so any re-export forwards it, `export *` included. A mirror that
2993
+ forwards the base page's `intercept` therefore inherits `from: '/photos'`, and
2994
+ `from` is compared against the background's route PATTERN, which for a mirror is
2995
+ `/[locale]/photos`. The two never match, so the overlay silently never opens and
2996
+ the modal renders standalone — no error, no warning, just a page where a modal
2997
+ was expected. This is the opposite failure from the [searchParams
2998
+ re-export](/docs/routing/pages#mirror-routes-share-one-schema), which is silently
2999
+ *lost*; `intercept` is silently *inherited with the wrong pattern*.
3000
+
3001
+ ```tsx
3002
+ // src/pages/[locale]/photos/[id]/page.tsx
3003
+ export { default, meta } from '../../../photos/[id]/page'
3004
+
3005
+ // NOT re-exported: the base's `from` names the un-prefixed pattern.
3006
+ export const intercept = { from: '/[locale]/photos' }
3007
+ ```
@@ -214,7 +214,7 @@ The handler receives a `ScheduleContext` — the same `app` a mutation gets, plu
214
214
  > tenant-scoped** — a schedule runs as `system` with no tenant. Reads see every
215
215
  > tenant's rows, and a write to a `tenant()` table fails with
216
216
  > `TenantScopeViolation` unless you pass `tenantId` explicitly. See
217
- > [below](#a-schedule-runs-as-the-system-subject--no-tenant). This sentence is
217
+ > [below](#a-schedule-runs-as-the-system-subject-no-tenant). This sentence is
218
218
  > here rather than only further down because "same shape as a mutation" is what
219
219
  > sets the expectation that gets violated.
220
220
 
@@ -257,6 +257,30 @@ const Input = Schema.Struct({
257
257
  )
258
258
  ```
259
259
 
260
+ **The ids, and the shapes worth knowing.** `required`, `minLength {min}`,
261
+ `maxLength {max}`, `betweenLength {min,max}`, `exactLength {amount}`,
262
+ `pattern`, `invalidEmail` / `invalidUrl` / `invalidUuid`, `minValue {min}`,
263
+ `maxValue {max}`, `minDate {min}` / `maxDate {max}`, `minItems` / `maxItems`,
264
+ `integer`, `invalid`, `checking`, `invalidFileType` / `fileTooLarge`.
265
+
266
+ Three of those exist because the generic answer is worse at the point of use:
267
+
268
+ - **Two length bounds on one field are ONE statement.** `minLength(2)` +
269
+ `maxLength(50)` produce `betweenLength {min,max}` — not "at least 2" for a
270
+ field whose rule is "between 2 and 50" — and `length(4)` produces
271
+ `exactLength {amount}`.
272
+ - **A declared `format` names the rule.** A regex never does: "Invalid format"
273
+ beside an email box tells nobody anything. Annotate the format and the id
274
+ gets specific — `Schema.String.pipe(Schema.pattern(EMAIL))
275
+ .annotations({ jsonSchema: { format: 'email' } })` → `validation.invalidEmail`.
276
+ - **A date bound is not a number bound.** `minDate` / `maxDate` rather than
277
+ `minValue` reading "must be at least 2026-01-01".
278
+
279
+ **Counting rules pass `count`.** `minItems` / `maxItems` carry `{ count }`
280
+ (alongside `min`/`max`) because that is the parameter an i18n layer selects a
281
+ plural form on — i18next keys pluralisation on a parameter named exactly
282
+ `count`, so a message carrying only `{min}` cannot be pluralised at all.
283
+
260
284
  **Server-side rules route to their field too.** An executor raises a typed
261
285
  field error through the always-present `ctx.validation` — no declaration
262
286
  needed, `ValidationError` is auto-merged into every mutation's and action's
@@ -383,6 +407,61 @@ const form = useFormBinding('app', 'tasks.create', {
383
407
  holds SPA navigations (Back button included) and arms the native
384
408
  `beforeunload` prompt — see the routing docs.
385
409
 
410
+ ### Rich text — and who sanitizes it
411
+
412
+ A rich-text field is `RichTextDocument`. Use it in the mutation input and the
413
+ form renders an editor with no further annotation:
414
+
415
+ ```ts
416
+ import { RichTextDocument } from '@voltro/web'
417
+
418
+ const ArticleUpdateInput = Schema.Struct({
419
+ id: Schema.String,
420
+ body: RichTextDocument,
421
+ })
422
+ ```
423
+
424
+ Display it with `<RichTextView doc={article.body} />`.
425
+
426
+ **The contract, because "who sanitizes" is the whole question.** The value is
427
+ **not an HTML string** — it is a closed document tree with a fixed set of node
428
+ types. There is no `html` node, no raw-markup escape hatch, no attribute bag,
429
+ so there is nothing to sanitize: anything that is not one of the declared nodes
430
+ simply fails to decode.
431
+
432
+ That makes the **`Schema` decode the boundary** — the server's existing,
433
+ non-bypassable input check, the same one every mutation input already passes
434
+ through. The guarantee is therefore not "somebody remembered to sanitize this
435
+ one"; it is that a document which reached your database is one of these shapes.
436
+
437
+ The rest follows from that:
438
+
439
+ - **A link's `href` is the one field pointing outward, and it is allowlisted**:
440
+ `http(s)`, `mailto:`, a `#fragment`, a `/path`. Nothing else — `javascript:`
441
+ and `data:` are refused by the decode, and control characters/whitespace are
442
+ stripped before the check, because `java\tscript:` navigates exactly like
443
+ `javascript:`.
444
+ - **Client-side sanitizing is not a security boundary and is not treated as
445
+ one.** The widget's parser runs in the browser for the editing experience;
446
+ the browser is where an attacker sits, so every property it maintains is
447
+ re-established by the decode on the server.
448
+ - **Rendering never uses `dangerouslySetInnerHTML`.** `<RichTextView>` maps
449
+ nodes to React elements and text to React children, so markup someone typed
450
+ into the box is markup the reader SEES. It also drops an href that would not
451
+ survive a decode — for the value that never went through one.
452
+
453
+ **The built-in editor** is a `<textarea>` over a small, closed markdown subset:
454
+ headings, `**bold**`, `*italic*`, `` `code` ``, `[text](href)`, `-` lists,
455
+ `>` quotes and fenced code. Everything it does not recognise stays literal text.
456
+ That is also what makes the field work with JavaScript off — the textarea posts
457
+ source, `/form/*` parses it, the same decode validates it. Register your own
458
+ `rich-text` widget (rung 2) for a WYSIWYG; the stored value shape is unchanged.
459
+
460
+ **Not the collaborative case.** Concurrent, multi-writer editing is
461
+ `crdtDoc()` + `useCrdtEditor` (`@voltro/local-first`) — a CRDT bytes column, a
462
+ sync lane, Tiptap. This is the single-editor field: one column, one writer,
463
+ ordinary JSON your server can validate, index and diff.
464
+
386
465
  **Testing.** `renderFormBinding` (from `@voltro/testing/client`) drives the
387
466
  REAL binding against a fake api — fill, blur, submit, read the visible
388
467
  errors; a mutation handler that throws `ValidationError({ field })`
@@ -399,6 +478,52 @@ expect(form.errors()['email']).toBe('validation.emailTaken')
399
478
 
400
479
  Runs under jsdom (`// @vitest-environment jsdom`).
401
480
 
481
+ ### What submit does with a failure, and what never reaches the wire
482
+
483
+ **`submit()` does not reject.** A form calls it from an `onSubmit` handler that
484
+ cannot await it, so a rejection has nowhere to go but the console — the form
485
+ sits there looking saved while the failure is invisible. It resolves
486
+ `undefined` instead, and the failure is state: field-routable errors land on
487
+ their field, everything else in `state.submitError`, with an optional
488
+ `onError` for a toast. That covers a composed `onSubmit` too — a follow-up
489
+ write failing on its OWN mutation handle is a failure the binding never saw
490
+ before, and it is the common shape (create the row, then its first child).
491
+
492
+ **Only declared keys are sent.** A form almost always carries more than the
493
+ mutation declares — a display toggle, a repeat control, a file held before
494
+ upload — and the server has refused undeclared input fields since 0.37. The
495
+ binding restricts the payload to the keys the input schema declares, which is
496
+ the rule the no-JS path already followed (`unknown keys are dropped`), so the
497
+ two submit paths agree. In development it warns once, naming what it dropped,
498
+ because a genuinely misplaced field should still be visible. `toInput` remains
499
+ the place to say what the write actually takes.
500
+
501
+ **`setValue` with an unchanged value is a no-op.** Every React state source is
502
+ expected to behave that way, and this one did not: each call produced a fresh
503
+ `values` object, so an effect depending on `values` that re-set a field to the
504
+ value it already held never settled.
505
+
506
+ A widget kit that needs the whole form rather than one field reads it with
507
+ `useFormBindingContext()` — the same provider, one level up.
508
+
509
+ **Server and browser derive the same form.** A page rendered on the server
510
+ resolves the mutation's input schema exactly as the browser will, so field
511
+ lists, labels and required marks match and hydration holds. (`voltro dev` and
512
+ `voltro start` both hand the descriptors over before rendering.)
513
+
514
+ ```tsx
515
+ const form = useFormBinding('app', 'employees.update', {
516
+ toInput: (values) => ({ id, ...employeePatch(values) }),
517
+ onError: (error) => toast.error(String(error)), // optional; state.submitError always carries it
518
+ })
519
+
520
+ // A field component anywhere below — no binding threaded through as a prop
521
+ const City = () => {
522
+ const f = useFormField('address.city')
523
+ return <input value={String(f.value ?? '')} onChange={(e) => f.setValue(e.target.value)} onBlur={f.onBlur} />
524
+ }
525
+ ```
526
+
402
527
  ### Forms without JavaScript
403
528
 
404
529
  On a server-rendered page, `<AutoForm>` works with JavaScript disabled — or not
@@ -419,7 +544,18 @@ the SAME input schema the RPC path decodes:
419
544
  - unknown keys are dropped
420
545
 
421
546
  Validation runs through the same `validateFields` as the client-side
422
- validation, so the error texts are identical. Then:
547
+ validation, so the error texts are identical — in the request's language, not
548
+ in English. The handler resolves the locale from THIS request through the same
549
+ resolver that decided the surrounding page's language ([`voltro:locale` cookie
550
+ › `Accept-Language` › the app's
551
+ `defaultLocale`](/docs/i18n/overview#locale-resolution-order)); an app that
552
+ configures no `locales` gets `en`. Then:
553
+
554
+ Under URL-prefix i18n the referring URL wins over the cookie chain: a form on
555
+ `/de/todos` renders its errors in German even if the `voltro:locale` cookie says
556
+ otherwise, because there the prefix is which page you are on rather than a
557
+ preference. A first segment that is not a declared locale falls through to the
558
+ cookie chain.
423
559
 
424
560
  - **Success → `303 See Other`** (POST-redirect-GET): back to the submitting
425
561
  page, or to `redirectTo` (same-origin relative paths only; anything else is
@@ -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