@voltro/cli 0.51.0 → 0.53.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 (233) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/THIRD-PARTY-NOTICES.md +8311 -3318
  3. package/dist/{agentsMd-D6yD7IQv.js → agentsMd-0l980yhL.js} +4 -1
  4. package/dist/agentsMd-SDDSkyl4.js +2 -0
  5. package/dist/{apiBuild-CPDHXF72.js → apiBuild-CaPfoWku.js} +11 -5
  6. package/dist/apiBuild-DHtLXYx9.js +2 -0
  7. package/dist/bin.js +1 -1
  8. package/dist/build-D-OnvNMf.js +843 -0
  9. package/dist/{checkCommand-DNuPiWMc.js → checkCommand-C5elt0tW.js} +92 -46
  10. package/dist/checkCommand-D2ZduVlh.js +2 -0
  11. package/dist/{cloudCmd-F4YJeqM3.js → cloudCmd-QUXh-b5w.js} +1 -1
  12. package/dist/codegen-BWpt3VgF.js +2 -0
  13. package/dist/{codegen-CrMXs4hb.js → codegen-FEk8AZHb.js} +2 -2
  14. package/dist/{codegenCommand-BNBHcNNj.js → codegenCommand-BOiWQ5hz.js} +12 -12
  15. package/dist/{codemodRunner-BDVixlSw.js → codemodRunner-BjtB2lq6.js} +691 -545
  16. package/dist/{commands-B1OiS9bX.js → commands-DyxAmhP0.js} +36 -36
  17. package/dist/{dashboardCommand-C-vvPY1B.js → dashboardCommand-BdKTyT13.js} +3 -3
  18. package/dist/{dataCommand-C1GxXW5q.js → dataCommand-Bab9X7s8.js} +27 -27
  19. package/dist/{dbCommand-If4Y1xQ-.js → dbCommand-06O2finM.js} +277 -236
  20. package/dist/dbCommand-B1EXBC6f.js +2 -0
  21. package/dist/{dev-kdAg9Q7l.js → dev-C6LGF4iY.js} +2998 -2379
  22. package/dist/dev-GjJWAYo2.js +3 -0
  23. package/dist/doctorCommand-B0hX0tdz.js +2 -0
  24. package/dist/{doctorCommand-nKmeW78u.js → doctorCommand-etMkflRc.js} +332 -220
  25. package/dist/{dormancyCommand-CY3wa_SW.js → dormancyCommand-UwZ1AZzB.js} +1 -1
  26. package/dist/{embeddingsCommand-BDLIgje_.js → embeddingsCommand-C70zWHwo.js} +1 -1
  27. package/dist/{envCommand-C6V_xVlT.js → envCommand-dSyKvRkM.js} +15 -15
  28. package/dist/{evolveCommand-D3c4DSfN.js → evolveCommand-CG0_ebO5.js} +2 -2
  29. package/dist/fileConventions-DASGEmj-.js +35 -0
  30. package/dist/{fileTaxonomy-CJfgOllU.js → fileTaxonomy-B7uxipWS.js} +55 -55
  31. package/dist/fontPipeline-LxIHa1vo.js +2 -0
  32. package/dist/fontPipeline-Tsh8kZfA.js +152 -0
  33. package/dist/frameworkTableAssembly-C_7Z-rMs.js +2 -0
  34. package/dist/{frameworkTableAssembly-BwJVEKLr.js → frameworkTableAssembly-DKx3ba3S.js} +5 -5
  35. package/dist/imagePipeline-B_GVJgm6.js +2 -0
  36. package/dist/imagePipeline-CBZmjT4i.js +127 -0
  37. package/dist/index.js +1 -1
  38. package/dist/{infoCommand-BnRFEF1o.js → infoCommand-_53iOc_j.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/inspectMetrics-CGF94puw.js +143 -0
  43. package/dist/manifestBuild-C4-J1-m_.js +2 -0
  44. package/dist/{manifestBuild-CuU1VrSm.js → manifestBuild-Cqgsx2bM.js} +1 -1
  45. package/dist/{metaCommands-CfRLra0s.js → metaCommands-Cn2oboG4.js} +9 -3
  46. package/dist/{migrate-DehuBakM.js → migrate-Cko9rswM.js} +2 -2
  47. package/dist/{pageConvention-cEiRxdab.js → pageConvention-C938S8oC.js} +1 -1
  48. package/dist/{privacyCommand-XejDMvmu.js → privacyCommand-DWTQMC6R.js} +2 -2
  49. package/dist/{probeCommand-C9gazU0H.js → probeCommand-DkGGLknv.js} +83 -24
  50. package/dist/{projectScaffold-mIX_DpSe.js → projectScaffold-B4dmTlwT.js} +1 -1
  51. package/dist/{projectScaffold-BIl97_E6.js → projectScaffold-EzlErR4E.js} +1 -1
  52. package/dist/{renderModeScan-D7J1B7Kw.js → renderModeScan-CUbOeOAg.js} +28 -11
  53. package/dist/{renderProfile-1OWWAAtx.js → renderProfile-CskIgAfn.js} +2 -2
  54. package/dist/{runtimeTrace-ZsBU7Tkx.js → runtimeTrace-c0APJz7E.js} +1 -1
  55. package/dist/{sdkgen-O4XqWOjM.js → sdkgen-BiQCgIEr.js} +1 -1
  56. package/dist/serveCommand-CueKQgzl.js +2443 -0
  57. package/dist/serveCommand-DsnrVN3U.js +2 -0
  58. package/dist/serveEntry.js +1 -1
  59. package/dist/start-BJzZLbt8.js +3 -0
  60. package/dist/start-ekPan8BT.js +1510 -0
  61. package/dist/startEntry.js +1 -1
  62. package/dist/{staticCommand-Dr2M6tpU.js → staticCommand-xlSL-IWk.js} +1 -1
  63. package/dist/{test-rXFq4S76.js → test-BWPQcRoB.js} +1 -1
  64. package/dist/updateCommand-Bqql_rsQ.js +2 -0
  65. package/dist/{updateCommand-Bs322Q78.js → updateCommand-C_8I8Rzo.js} +139 -115
  66. package/dist/webDev-C7jWJ5dX.js +2 -0
  67. package/dist/{webDev-B-ubQEMX.js → webDev-oczpugbx.js} +1767 -913
  68. package/dist/{webhooksCommand-FLYY9IXh.js → webhooksCommand-4SVPDjKg.js} +1 -1
  69. package/package.json +72 -18
  70. package/templates/AGENTS.core.md +11 -0
  71. package/templates/AGENTS.md +19 -6
  72. package/templates/agent-docs/_index.md +8 -6
  73. package/templates/agent-docs/_manifest.json +31 -15
  74. package/templates/agent-docs/ai.md +2 -2
  75. package/templates/agent-docs/authentication.md +1 -1
  76. package/templates/agent-docs/cli.md +97 -15
  77. package/templates/agent-docs/configuration.md +17 -0
  78. package/templates/agent-docs/data.md +680 -33
  79. package/templates/agent-docs/database/advancedqueries.md +7 -7
  80. package/templates/agent-docs/database/columntypes.md +2 -2
  81. package/templates/agent-docs/database/querying.md +1 -1
  82. package/templates/agent-docs/database/schema.md +2 -2
  83. package/templates/agent-docs/database/seedsdialects.md +2 -2
  84. package/templates/agent-docs/database/transactions.md +3 -3
  85. package/templates/agent-docs/deployment.md +30 -3
  86. package/templates/agent-docs/internationalization.md +2 -2
  87. package/templates/agent-docs/introduction.md +52 -0
  88. package/templates/agent-docs/local-first-mobile.md +132 -7
  89. package/templates/agent-docs/observability.md +2 -0
  90. package/templates/agent-docs/plugins/ai-flows.md +1 -1
  91. package/templates/agent-docs/plugins/audit.md +5 -5
  92. package/templates/agent-docs/plugins/auth.md +1 -1
  93. package/templates/agent-docs/plugins/cdc-out.md +2 -2
  94. package/templates/agent-docs/plugins/comments.md +142 -0
  95. package/templates/agent-docs/plugins/notifications.md +47 -4
  96. package/templates/agent-docs/plugins/presence.md +16 -3
  97. package/templates/agent-docs/plugins/prometheus.md +1 -1
  98. package/templates/agent-docs/plugins/queue.md +129 -0
  99. package/templates/agent-docs/plugins/{versioning.md → row-history.md} +22 -22
  100. package/templates/agent-docs/plugins/storage.md +2 -2
  101. package/templates/agent-docs/plugins.md +38 -12
  102. package/templates/agent-docs/reference.md +54 -5
  103. package/templates/agent-docs/routing.md +868 -50
  104. package/templates/agent-docs/schema-driven-ui.md +292 -5
  105. package/templates/agent-docs/security.md +125 -8
  106. package/templates/agent-docs/templates/apibackends.md +14 -14
  107. package/templates/agent-docs/templates/overview.md +1 -1
  108. package/templates/agent-docs/whats-new.md +171 -54
  109. package/templates/apps/api-ai/package.json +6 -7
  110. package/templates/apps/api-ai/tests/summarize.test.ts +1 -1
  111. package/templates/apps/api-auth/package.json +8 -8
  112. package/templates/apps/api-backend/package.json +7 -7
  113. package/templates/apps/api-backend-deactivation/package.json +7 -7
  114. package/templates/apps/api-backend-mail/package.json +8 -8
  115. package/templates/apps/api-backend-mariadb/package.json +9 -9
  116. package/templates/apps/api-backend-sqlite/package.json +8 -8
  117. package/templates/apps/api-backend-storage/package.json +8 -8
  118. package/templates/apps/api-cms/package.json +9 -10
  119. package/templates/apps/api-collab/package.json +8 -8
  120. package/templates/apps/api-data-advanced/package.json +8 -8
  121. package/templates/apps/api-durable/package.json +8 -8
  122. package/templates/apps/api-feature-flags/package.json +9 -9
  123. package/templates/apps/api-governance/package.json +8 -8
  124. package/templates/apps/api-kv/package.json +8 -8
  125. package/templates/apps/api-moderation/package.json +8 -8
  126. package/templates/apps/api-observability/package.json +8 -8
  127. package/templates/apps/api-ratelimit/package.json +8 -8
  128. package/templates/apps/api-rbac/package.json +8 -8
  129. package/templates/apps/api-rest/package.json +7 -7
  130. package/templates/apps/{api-versioning → api-row-history}/README.md +3 -3
  131. package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.server.ts +1 -1
  132. package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.server.ts +1 -1
  133. package/templates/apps/{api-versioning → api-row-history}/app.config.ts +3 -3
  134. package/templates/apps/{api-versioning → api-row-history}/database/schema.ts +1 -1
  135. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.server.ts +1 -1
  136. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.create.mutation.ts +1 -1
  137. package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.ts +1 -1
  138. package/templates/apps/{api-versioning → api-row-history}/package.json +8 -8
  139. package/templates/apps/api-row-history/template.json +6 -0
  140. package/templates/apps/{api-versioning → api-row-history}/tests/documents.create.test.ts +1 -1
  141. package/templates/apps/api-saas/app.config.ts +1 -0
  142. package/templates/apps/api-saas/package.json +10 -11
  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 +8 -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/package.json +10 -10
  168. package/templates/apps/frontend-collab/src/pages/page.test.tsx +10 -9
  169. package/templates/apps/frontend-collab/src/pages/page.tsx +42 -65
  170. package/templates/apps/frontend-contact/package.json +7 -7
  171. package/templates/apps/frontend-dashboard/package.json +7 -7
  172. package/templates/apps/frontend-docs/content/docs/de/guides/first-page.md +4 -0
  173. package/templates/apps/frontend-docs/content/docs/de/intro/getting-started.md +4 -0
  174. package/templates/apps/frontend-docs/content/docs/en/guides/first-page.md +4 -0
  175. package/templates/apps/frontend-docs/content/docs/en/intro/getting-started.md +4 -0
  176. package/templates/apps/frontend-docs/package.json +8 -7
  177. package/templates/apps/frontend-docs/src/collections/docs.collection.ts +21 -0
  178. package/templates/apps/frontend-docs/src/locales/de.ts +0 -5
  179. package/templates/apps/frontend-docs/src/locales/en.ts +0 -5
  180. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug]/page.tsx +17 -9
  181. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +10 -5
  182. package/templates/apps/frontend-docs/src/pages/[locale]/page.tsx +8 -0
  183. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.test.tsx +53 -15
  184. package/templates/apps/frontend-docs/src/pages/docs/[...slug]/page.tsx +27 -33
  185. package/templates/apps/frontend-docs/src/pages/page.test.tsx +17 -3
  186. package/templates/apps/frontend-docs/src/pages/page.tsx +16 -12
  187. package/templates/apps/frontend-i18n/package.json +6 -6
  188. package/templates/apps/frontend-landing/package.json +6 -7
  189. package/templates/apps/frontend-portal/package.json +8 -8
  190. package/templates/apps/frontend-saas/package.json +8 -8
  191. package/templates/apps/frontend-spa/package.json +7 -7
  192. package/templates/apps/frontend-ssr/package.json +7 -7
  193. package/templates/apps/frontend-ssr-api/package.json +8 -8
  194. package/templates/apps/frontend-static-blog/content/posts/cms-to-ssg.md +12 -0
  195. package/templates/apps/frontend-static-blog/content/posts/hello-static.md +12 -0
  196. package/templates/apps/frontend-static-blog/content/posts/islands-not-hydration.md +14 -0
  197. package/templates/apps/frontend-static-blog/package.json +8 -6
  198. package/templates/apps/frontend-static-blog/src/collections/posts.collection.ts +27 -0
  199. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug]/page.tsx +7 -4
  200. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +7 -1
  201. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.test.tsx +19 -9
  202. package/templates/apps/frontend-static-blog/src/pages/blog/[slug]/page.tsx +19 -21
  203. package/templates/apps/frontend-static-blog/src/pages/page.test.tsx +18 -5
  204. package/templates/apps/frontend-static-blog/src/pages/page.tsx +21 -14
  205. package/templates/apps/frontend-status/package.json +8 -8
  206. package/templates/apps/mobile-app/package.json +4 -4
  207. package/dist/agentsMd-Bu_XQgVf.js +0 -2
  208. package/dist/apiBuild-GDKuGOMV.js +0 -2
  209. package/dist/build-DETLZAFt.js +0 -752
  210. package/dist/checkCommand-CWcnDArJ.js +0 -2
  211. package/dist/codegen-DiMn2KkZ.js +0 -2
  212. package/dist/dbCommand-C27HIsGE.js +0 -2
  213. package/dist/dev-CK522MV5.js +0 -3
  214. package/dist/doctorCommand-BK4l18eG.js +0 -2
  215. package/dist/fileConventions-Cof68_BL.js +0 -33
  216. package/dist/frameworkTableAssembly-CVDB2hCq.js +0 -2
  217. package/dist/inspect-CuGDYES0.js +0 -2
  218. package/dist/inspectMetrics-CfdKLh6t.js +0 -72
  219. package/dist/manifestBuild-CPjhvM62.js +0 -2
  220. package/dist/serveCommand-BRnPCxVd.js +0 -2
  221. package/dist/serveCommand-DdiYNBBu.js +0 -2362
  222. package/dist/start-BLNmWkLa.js +0 -1154
  223. package/dist/start-Dzicuyw8.js +0 -3
  224. package/dist/updateCommand-eXB35SEv.js +0 -2
  225. package/dist/webDev-DposiF3j.js +0 -2
  226. package/templates/apps/api-versioning/template.json +0 -6
  227. package/templates/apps/changelog/scripts/generate-rss.mjs +0 -38
  228. package/templates/apps/changelog/src/lib/releases.ts +0 -21
  229. package/templates/apps/frontend-static-blog/src/content/posts.ts +0 -64
  230. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.asOf.action.ts +0 -0
  231. /package/templates/apps/{api-versioning → api-row-history}/actions/documents.history.action.ts +0 -0
  232. /package/templates/apps/{api-versioning → api-row-history}/mutations/documents.update.mutation.server.ts +0 -0
  233. /package/templates/apps/{api-versioning → api-row-history}/tsconfig.json +0 -0
@@ -209,36 +209,51 @@ src/pages/[...rest]/page.tsx # everything else
209
209
 
210
210
  ## Query strings
211
211
 
212
- Voltro doesn't bake query params into the query — they're orthogonal to the URL pattern:
212
+ 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`:
213
213
 
214
214
  ```tsx
215
- import { useLocation } from '@voltro/web'
215
+ // src/pages/search/page.tsx
216
+ import { Schema } from 'effect'
217
+ import { useSearchParams } from '@voltro/web'
216
218
 
217
- const Page = () => {
218
- const pathname = useLocation()
219
+ export const searchParams = Schema.Struct({
220
+ q: Schema.optionalWith(Schema.String, { default: () => '' }),
221
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
222
+ })
223
+
224
+ export default function SearchPage() {
225
+ const { q, page } = useSearchParams(searchParams) // q: string · page: number
219
226
  // …
220
- // For the search string, parse it from the request URL via useServerRequest()
221
- // (SSR) or window.location.search (client after hydration).
222
227
  }
223
228
  ```
224
229
 
225
- For SSR pages that need server-side query parsing:
230
+ `useSearchParams(searchParams)` — the page passes its **own** export — returns the decoded, typed shape, SSR-aware: the same call site reads the request URL on the server and `window.location.search` on the client. An invalid query string falls back to the schema's defaults instead of crashing the render, so every field must be optional or carry a default (`Schema.optionalWith(..., { default })` — a schema that cannot decode an empty query throws at the first read, naming the fix). Links to the route type-check against the same schema via [typed `withQuery`](/docs/routing/navigation#typed-withquery).
226
231
 
227
- ```tsx
228
- import { useServerRequest } from '@voltro/web'
232
+ Two boundaries worth knowing:
229
233
 
230
- export const renderMode = 'ssr' as const
234
+ - **`renderMode: 'isr'` + a `searchParams` export is refused at boot** — the isr cache is keyed by path (plus tenant + locale), so the first query's variant would be cached and served for every other query. Use `ssr`, or drop the export and read the query client-side only. See [Render modes](/docs/routing/render-modes#isr-incremental-static-regeneration).
235
+ - **`renderMode: 'static'` build renders see only the schema's defaults** — a build has no query string. The client decodes the live query after hydration; a static page keyed off search params is a client-side concern.
231
236
 
232
- export default function SearchPage() {
233
- const req = useServerRequest()
234
- const q = req
235
- ? new URL(req.url, 'http://x').searchParams.get('q') ?? ''
236
- : new URLSearchParams(window.location.search).get('q') ?? ''
237
- //
238
- }
237
+ ### Mirror routes share ONE schema
238
+
239
+ Bilingual apps with mirrored trees (`pages/x/page.tsx` + `pages/[locale]/x/page.tsx`) re-export the original page's schema instead of copying it:
240
+
241
+ ```tsx
242
+ // src/pages/[locale]/search/page.tsx
243
+ export { searchParams } from '../../search/page'
239
244
  ```
240
245
 
241
- Parse the query string explicitly via `useServerRequest()` on the server and `window.location.search` on the client, as shown above.
246
+ One schema, no drift the mirror page decodes exactly what the original declares.
247
+
248
+ ### Routes without a schema
249
+
250
+ `useSearchParams()` without an argument stays the raw `URLSearchParams` — nothing changes for a route that declares no schema:
251
+
252
+ ```tsx
253
+ import { useSearchParams } from '@voltro/web'
254
+
255
+ const q = useSearchParams().get('q') ?? ''
256
+ ```
242
257
 
243
258
  ## Co-locating components, hooks and tests
244
259
 
@@ -584,7 +599,7 @@ else — React's render loop still runs to completion in one pass, so a slow
584
599
  render is still a slow render. The lever that matters is `defer()`: it puts a
585
600
  real `<Suspense>` boundary in the tree, which is what lets the server return to
586
601
  the event loop while a slow value is still pending. See
587
- [Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer--await).
602
+ [Deferring slow data](/docs/routing/loaders-and-meta#deferring-slow-data-defer).
588
603
 
589
604
  Measured against a real server rendering a page with one 400ms deferred field:
590
605
  first body byte at 7ms, the deferred chunk at 408ms — and a probe firing every
@@ -599,8 +614,11 @@ Two consequences worth knowing:
599
614
  framework hands the URL to React instead, so it goes out `async` at the end
600
615
  of the shell and hydration starts immediately.
601
616
  - **`isr` and `static` are still buffered**, because both produce a stored
602
- artefact rather than a response. That is also why `defer()` is an error on
603
- those modes.
617
+ artefact rather than a response. `defer()` is an error on those modes —
618
+ with ONE exception: an isr page that exports `ppr = true` (partial
619
+ prerendering, below) caches the shell and appends its deferred holes per
620
+ request. On `static` it stays an error even with `ppr` — a static file is
621
+ served by any dumb file host, which cannot append anything.
604
622
 
605
623
  Apps do not call the renderer directly. If you are building your own server on
606
624
  top of `@voltro/web/ssr`, `renderPageToStream` is the entry point — it takes the
@@ -637,6 +655,88 @@ Cache backends:
637
655
  - `memory` *(default)* — in-process, doesn't survive restarts.
638
656
  - `postgres` — `SSR_CACHE=postgres`. Survives restarts, shared across api instances.
639
657
 
658
+ An `isr` page that also declares a [`searchParams` schema](/docs/routing/pages#query-strings) is refused at boot — the cache is keyed by path (plus tenant + locale), not by query, so the first query's variant would be served for every other query; use `ssr`, or drop the export and read the query client-side only.
659
+
660
+ ### isr renders are anonymous
661
+
662
+ An `isr` render is a **shared** render: the HTML it produces is cached and served
663
+ to every visitor inside the revalidate window. The framework therefore strips
664
+ credential material before the render runs — the cookie jar (except the
665
+ `voltro:locale` cookie), the `authorization` header, and every `x-voltro-*`
666
+ header never reach an isr page's loaders, `ctx.query`, or `useServerRequest()`.
667
+ `x-tenant` and `accept-language` survive, because the cache key (tenant + locale)
668
+ is derived from them.
669
+
670
+ Concretely: a loader on an isr page that reads subject-scoped data gets the
671
+ **anonymous** answer — the same one every visitor will see — instead of caching
672
+ the first visitor's data for everyone. This applies identically under
673
+ `voltro dev` and `voltro start`, so a page cannot look personalised in dev and
674
+ silently serve shared HTML in production. A page whose loader needs the signed-in
675
+ subject belongs on `renderMode: 'ssr'`.
676
+
677
+ ## Partial prerendering (ppr) — cached shell + per-request holes
678
+
679
+ An isr page can combine a **cached, anonymous shell** with **per-request
680
+ dynamic holes**: the shell comes straight out of the cache (or the miss
681
+ render), and the deferred fields stream in behind it on the SAME response —
682
+ personalised, never cached.
683
+
684
+ ```tsx
685
+ export const renderMode = 'isr' as const
686
+ export const revalidate = 60
687
+ export const ppr = true
688
+
689
+ export const loader = ({ headers }) => defer(
690
+ { title: 'Dashboard' }, // SHELL — cached, anonymous
691
+ { greeting: personalGreeting(headers) }, // HOLE — per request, never cached
692
+ )
693
+ ```
694
+
695
+ How it works, and what each half may do:
696
+
697
+ - **The shell render is fail-closed, not merely stripped.** Its loader context
698
+ and `useServerRequest()` snapshot THROW by name when a credential is read
699
+ (`cookie`, `authorization`, `x-voltro-*` headers; any cookie but
700
+ `voltro:locale`): the first request answers with an error naming the read
701
+ and the fix, instead of baking silently-empty subject data into an artefact
702
+ served to everyone. `x-tenant` and `accept-language` stay readable — they
703
+ are cache-key inputs.
704
+ - **A hole is an `async` function.** Its credential reads happen inside the
705
+ promise: on the shell pass they reject harmlessly into the `<Await>`
706
+ fallback; on the per-request hole pass they see the real request. A
707
+ credential read in the EAGER half (or synchronously while building the
708
+ hole promise) is the named error above — that is the fail-closed contract.
709
+ - **Holes reveal through hydration.** The shell carries the `<Await>`
710
+ fallbacks, the registry bootstrap and the deferred-id payload; each hole
711
+ value is appended as an inline settle script the moment its promise
712
+ resolves, and the hydrated `<Await>` renders it. `ppr` therefore requires
713
+ `interactive: 'full'` — `'none'` ships no JS to reveal anything and
714
+ `'islands'` never hydrates the page root; both are refused by name.
715
+ - **The eager half runs twice per request** (shell render on a cache miss,
716
+ hole pass always). That is the cost model on purpose: eager data is the
717
+ cheap, cacheable half; per-subject work belongs in the holes.
718
+ - **Client-side navigation** to a ppr page runs the loader in the browser —
719
+ holes resolve through the client defer path, same `<Await>` markup.
720
+ - **Invalidation is the shell's**: `revalidate`, `cacheInvalidatesOn` and
721
+ on-demand revalidation purge the SHELL entry; holes are never cached, so
722
+ there is nothing to invalidate.
723
+ - **Layout loaders cannot defer on a ppr page** (v1): holes live in the page
724
+ loader; a deferring layout is refused by name.
725
+ - **CSP nonces are refused on ppr exactly as on isr** — the cached shell
726
+ carries inline registry scripts that cannot be per-request-nonce'd. Use
727
+ `renderMode: 'ssr'` for nonce'd pages, or a hash-based policy.
728
+ - **Without JavaScript** (a text crawler, JS disabled) the hole fallbacks
729
+ stay visible — the shell is complete, correct HTML; only the holes remain
730
+ in their pending state. There is deliberately NO "buffer fully for
731
+ crawlers" mode: user-agent sniffing serves different documents to crawlers
732
+ and users, which is the cloaking failure class.
733
+ - **A client that disconnects mid-response** simply stops receiving settle
734
+ scripts; nothing corrupts, the loader's own work completes server-side.
735
+
736
+ The response carries `x-voltro-ppr: shell+holes` next to the usual
737
+ `x-voltro-cache` headers, and the server counts shell serves, hole passes and
738
+ hole latency (see [Observability](/docs/observability/overview)).
739
+
640
740
  ## Tenant-aware ISR
641
741
 
642
742
  For multi-tenant ISR (each tenant gets its own cache entry):
@@ -662,6 +762,63 @@ The framework reads Postgres logical replication; writes to `posts` or `comments
662
762
 
663
763
  Requires `SSR_CACHE=postgres` and a `wal_level=logical` Postgres.
664
764
 
765
+ ## On-demand revalidation
766
+
767
+ The third invalidation axis, next to time (`revalidate`) and CDC
768
+ (`cacheInvalidatesOn`): server code in the api process drops ISR cache entries
769
+ imperatively, on **every** web replica — including on dialects that have no
770
+ CDC at all (sqlite, mysql, memory), which is the case this exists for.
771
+
772
+ ```ts
773
+ import { revalidatePath, revalidateTable, revalidateTag } from '@voltro/runtime'
774
+
775
+ // inside a mutation / action / webhook receiver / REST route handler:
776
+ await revalidateTable('posts') // drop every route whose cacheInvalidatesOn lists 'posts'
777
+ await revalidatePath('/blog/[slug]') // drop every cached instance of the route
778
+ await revalidatePath('/pricing') // drop one concrete path (all tenant+locale variants)
779
+ await revalidatePath('/pricing', { tenant: 'acme' }) // …one tenant's variants only
780
+ await revalidateTag('pricing') // drop every route whose cacheInvalidatesOn lists the tag
781
+ ```
782
+
783
+ **Tags are tables that never were one.** `cacheInvalidatesOn` accepts free
784
+ strings, so one mechanism covers both: declare `cacheInvalidatesOn:
785
+ ['posts', 'pricing']` on any number of routes and `revalidateTag('pricing')`
786
+ drops them all — the `revalidateTag` thinking Next.js users bring works
787
+ unchanged.
788
+
789
+ **How it travels.** The api process publishes; every `voltro start` replica
790
+ subscribes. Two transports, either or both:
791
+
792
+ - **postgres**: a `pg_notify` on the same LISTEN connection the CDC
793
+ invalidator already holds — a postgres deployment needs **no broker**.
794
+ - **a broker**: set `BROADCAST_URL` (`redis://` or `nats://`) on **both** the
795
+ api and the web deployment. This is the path for non-postgres dialects. On
796
+ a broker shared by several projects, also set `VOLTRO_BROADCAST_NAMESPACE`
797
+ on both sides — the channel is namespaced by that variable (the api's and
798
+ the web app's names differ, so a name-derived namespace can't pair them).
799
+
800
+ A web process with ISR routes and **neither** transport warns at boot
801
+ (`NO revalidation transport`) — calls then change nothing and cached pages
802
+ live out their own `revalidate` window. Under `voltro dev` there is no ISR
803
+ cache; the calls are debug-logged no-ops.
804
+
805
+ Three edges, all deliberate:
806
+
807
+ - **Only `isr` routes.** `revalidatePath` against a `static` route logs a
808
+ named error on the web process — static HTML is a build artifact `voltro
809
+ start` never re-renders; rebuild to change it. (The transport is
810
+ fire-and-forget, so the error surfaces in the web replica's log, not at the
811
+ call site.)
812
+ - **Purge-during-render is guarded.** A background SWR refresh (or miss fill)
813
+ that started before the purge landed is discarded instead of writing the
814
+ pre-purge page back with a full TTL — a per-key generation counter, on both
815
+ cache backends.
816
+ - **On postgres you don't need this for the plain publish case** — a route
817
+ declaring `cacheInvalidatesOn: ['<table>']` is already dropped by CDC when
818
+ the table changes. Reach for the imperative API for non-postgres dialects,
819
+ pattern purges of routes whose loaders read data indirectly, and tag
820
+ fanout.
821
+
665
822
  ## spa (client-only, with an optional SSR layout shell)
666
823
 
667
824
  ```tsx
@@ -1383,6 +1540,55 @@ They're plain async functions. They can't call `useSubscription`, `useState`, et
1383
1540
 
1384
1541
  If you need a reactive query (live updates), use `useSubscription` in the component AFTER hydration; for the initial render's data, use the loader.
1385
1542
 
1543
+ ## OG images from a template — `ogImage`
1544
+
1545
+ Declare the page's `og:image` as a satori JSX template and the framework
1546
+ produces the PNG: at BUILD time for `static` pages (hashed into
1547
+ `dist/assets/og/`, tags injected with the absolute `seo.siteUrl`), ON DEMAND
1548
+ for `ssr` pages over a signed route with a cache.
1549
+
1550
+ ```tsx
1551
+ export const ogImage = ({ params, loaderData, locale }: {
1552
+ params: Record<string, string>
1553
+ loaderData: unknown
1554
+ locale: string
1555
+ }) => ({
1556
+ type: 'div',
1557
+ props: {
1558
+ style: {
1559
+ display: 'flex', width: '100%', height: '100%',
1560
+ background: '#0b1220', color: '#fff', fontSize: 72, fontFamily: 'Inter',
1561
+ alignItems: 'center', justifyContent: 'center',
1562
+ },
1563
+ children: `My post ${params['slug'] ?? ''} (${locale})`,
1564
+ },
1565
+ })
1566
+ ```
1567
+
1568
+ `meta` wiring is automatic: `og:image`, `twitter:image` and `twitter:card`
1569
+ land in the head — unless your `meta` already sets `og:image`, which then
1570
+ wins (no duplicate tag for crawlers to pick at random).
1571
+
1572
+ **Preconditions, decided rather than improvised:**
1573
+
1574
+ - **A declared font is REQUIRED** ([Fonts](/docs/routing/fonts)) — satori
1575
+ cannot render text without a font buffer, and there is no bundled default
1576
+ (that would ship a license artifact). Missing font → a named build/boot
1577
+ error with the fix. The renderer uses the ORIGINAL un-subsetted files, so
1578
+ glyphs outside your declared subsets still render.
1579
+ - **`ssr` pages need `VOLTRO_OG_SECRET` in multi-replica deploys.** The
1580
+ render signs the on-demand URL (HMAC over route + params + tenant +
1581
+ locale; tampering answers 403) and a per-boot minted secret only verifies
1582
+ in the process that signed it — behind a load balancer set the env var
1583
+ (same value on every replica), or the deploy boot refuses, loudly. Images
1584
+ are cached in the same backend as the page cache; tenant and locale are
1585
+ part of the key wherever the template uses them.
1586
+ - **Emoji are not supported** — satori renders them only via a per-glyph CDN
1587
+ fetch, which collides with the no-external-requests posture. Use an image
1588
+ in the template instead: a `?image` import's `blurDataURL`/`src`, or any
1589
+ data-URI (`src: \`data:image/png;base64,…\``) inside an `img` element of
1590
+ the template — the standard avatar/logo card works that way.
1591
+
1386
1592
  ## Anti-patterns
1387
1593
 
1388
1594
  - **Calling `ctx.ai.generate(...)` in a loader without timeouts.** Loaders shouldn't take >2s. For slow data, render a Suspense fallback + `useSubscription` after hydration.
@@ -1423,7 +1629,7 @@ import { routes } from './.framework/routes' // generated by `voltro dev`
1423
1629
  ```
1424
1630
 
1425
1631
  - `routes['/pattern'](params)` → `VoltroRouteUrl`. Missing/extra params are a type error.
1426
- - `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand.
1632
+ - `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand. On a route whose page declares a `searchParams` schema, the params type-check against it (below).
1427
1633
  - `withHash(url, 'section-3')` — append a `#hash`, keeps the brand.
1428
1634
  - `externalUrl('https://example.com')` — the escape hatch for anything the
1429
1635
  codegen can't model: cross-origin, `mailto:`, `tel:`, hash-only, or a
@@ -1438,6 +1644,28 @@ import { withQuery, withHash, externalUrl } from '@voltro/web'
1438
1644
  <Link to={externalUrl('mailto:hi@x.com')}>Email us</Link>
1439
1645
  ```
1440
1646
 
1647
+ ### Typed `withQuery`
1648
+
1649
+ For a route whose page exports a [`searchParams` schema](/docs/routing/pages#query-strings),
1650
+ the generated builder brands the URL with the schema's decoded shape — through a
1651
+ **type-only** import, so no page module enters the routes file's value graph and
1652
+ code-splitting stays intact. `withQuery` then type-checks the params against the
1653
+ page's contract: a misspelt key or a wrong value type is a compile error.
1654
+
1655
+ ```tsx
1656
+ <Link to={withQuery(routes['/notes'](), { page: 2 })}>Page 2</Link>
1657
+ // withQuery(routes['/notes'](), { pgae: 2 }) → compile error (unknown key)
1658
+ // withQuery(routes['/notes'](), { page: 'x' }) → compile error (wrong type)
1659
+ ```
1660
+
1661
+ The encode is canonical and schema-free: strings pass through, numbers and
1662
+ booleans via `String()`, arrays become repeated keys (`?tag=a&tag=b`), and
1663
+ `undefined` params are omitted. A `Date` (or any object) is refused loudly —
1664
+ there is no canonical URL form the type layer could guarantee; declare the field
1665
+ as a string/number transform in the page's `searchParams` schema and pass that
1666
+ instead. Routes of `siblingApps` stay untyped — their pages live in another
1667
+ app's compile graph.
1668
+
1441
1669
  ## `<Link>`
1442
1670
 
1443
1671
  ```tsx
@@ -1563,6 +1791,59 @@ The router restores the previous scroll position on **back/forward** navigations
1563
1791
 
1564
1792
  Push/replace navigations still scroll to top (or to the hash target); only back/forward restores.
1565
1793
 
1794
+ ## View transitions
1795
+
1796
+ Opt in to the browser's [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) for SPA navigations — the browser cross-fades the old and new page (and lets you animate individual elements) with zero animation library:
1797
+
1798
+ ```ts
1799
+ // app.config.ts
1800
+ export default {
1801
+ type: 'web' as const,
1802
+ name: 'MyApp',
1803
+ router: {
1804
+ viewTransitions: true,
1805
+ },
1806
+ }
1807
+ ```
1808
+
1809
+ With the flag on, every route swap — `<Link>` clicks, `navigate(...)`, back/forward — runs through `document.startViewTransition`. Individual navigations override the default in either direction:
1810
+
1811
+ ```tsx
1812
+ navigate('/reports', { transition: false }) // this one swaps plainly
1813
+ <Link to={routes['/photos/[id]']({ id })} transition>Open</Link> // this one transitions even when the app default is off
1814
+ ```
1815
+
1816
+ **Fallback is exact.** In a browser without the API, and for users with `prefers-reduced-motion: reduce`, navigation behaves precisely as without the flag — same timing, no animation, nothing to feature-detect yourself.
1817
+
1818
+ **Styling is plain CSS, not a framework DSL.** The default is a full-page cross-fade. To animate a specific element independently (the classic shared-element move), give it a `view-transition-name` and style the browser's pseudo-elements:
1819
+
1820
+ ```css
1821
+ .post-cover { view-transition-name: post-cover; }
1822
+
1823
+ /* Tune the root cross-fade */
1824
+ ::view-transition-old(root) { animation-duration: 150ms; }
1825
+ ::view-transition-new(root) { animation-duration: 150ms; }
1826
+
1827
+ /* The named element morphs between its old and new position */
1828
+ ::view-transition-group(post-cover) { animation-duration: 300ms; }
1829
+ ```
1830
+
1831
+ An element that keeps its `view-transition-name` across both pages is morphed from its old to its new position automatically — that is the whole shared-element recipe.
1832
+
1833
+ Three behaviors worth knowing, all deliberate:
1834
+
1835
+ - **`defer()` fields resolve outside the transition.** The transition animates to the committed page — with a deferred field still showing its fallback. The field's later resolution is an ordinary React update, not a second animation. Same rule for an explicit `Pending` skeleton: the swap **to** the skeleton is the transition; the settled content arrives un-animated.
1836
+ - **Rapid navigation skips, never queues.** Navigating again while a transition is animating skips the running one (per the API's spec) and the last navigation wins — no queue, no dead time.
1837
+ - **Overlays and modals do not transition.** A view transition snapshots the whole viewport, so running one on an overlay opening would cross-fade the entire page for a change that visually touches one layer. Router view transitions therefore apply to **route navigations only**; overlay/dialog state changes never trigger one.
1838
+
1839
+ **Static / multi-page documents:** a full-document navigation (between `renderMode: 'static'` pages, or any MPA link) never goes through the SPA router — opt those into the browser's cross-document transitions with CSS alone, no framework involvement:
1840
+
1841
+ ```css
1842
+ @view-transition { navigation: auto; }
1843
+ ```
1844
+
1845
+ **Coming from Astro?** There is no `transition:persist` equivalent because none is needed — persistent state lives in a [layout](/docs/routing/layouts), and layouts stay mounted across SPA navigations natively.
1846
+
1566
1847
  ## Blocking navigation (unsaved changes)
1567
1848
 
1568
1849
  `useBlocker` holds a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.
@@ -1631,7 +1912,37 @@ window.history.forward() // forward
1631
1912
 
1632
1913
  ## Reading + writing search params
1633
1914
 
1634
- Read the query string with `useSearchParams()` — a plain `URLSearchParams`. It's SSR-aware (on the server it reads the request URL; on the client, `window.location.search`):
1915
+ The recommended way to read the query string is **typed**: declare the page's
1916
+ query contract as a `searchParams` schema export and pass that same export to
1917
+ `useSearchParams(...)`:
1918
+
1919
+ ```tsx
1920
+ import { Schema } from 'effect'
1921
+ import { useSearchParams } from '@voltro/web'
1922
+
1923
+ export const searchParams = Schema.Struct({
1924
+ tab: Schema.optionalWith(Schema.String, { default: () => 'overview' }),
1925
+ page: Schema.optionalWith(Schema.NumberFromString, { default: () => 1 }),
1926
+ tags: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
1927
+ })
1928
+
1929
+ export default function Notes() {
1930
+ const { tab, page, tags } = useSearchParams(searchParams)
1931
+ // tab: string · page: number · tags: readonly string[]
1932
+ }
1933
+ ```
1934
+
1935
+ - **SSR-aware** — the same call site decodes the request URL on the server and `window.location.search` on the client.
1936
+ - **Total** — an invalid query string is never a crash or a 500: the decode falls back to the schema's defaults, exactly like visiting without a query.
1937
+ - **Every field must be optional or carry a default** (`Schema.optionalWith(..., { default })`). A schema that cannot decode an empty query throws at the first read, naming the fix — that is a definition error, not a runtime input problem.
1938
+ - **Array fields keep their shape** — `?tag=a&tag=b` decodes to `['a', 'b']`, and a single `?tag=a` decodes to `['a']`, not a bare string.
1939
+
1940
+ The same schema types links to the route — see [typed `withQuery`](#typed-withquery)
1941
+ above — and the page-export convention itself is documented in
1942
+ [Pages → Query strings](/docs/routing/pages#query-strings).
1943
+
1944
+ `useSearchParams()` **without** an argument stays the raw `URLSearchParams` —
1945
+ the fallback for routes that declare no schema:
1635
1946
 
1636
1947
  ```tsx
1637
1948
  import { useSearchParams } from '@voltro/web'
@@ -1639,7 +1950,7 @@ import { useSearchParams } from '@voltro/web'
1639
1950
  const tab = useSearchParams().get('tab') ?? 'overview'
1640
1951
  ```
1641
1952
 
1642
- Write it with `useSetSearchParams()` — the setter updates the query string on the current pathname (via `navigate`), so the URL changes **and** every reader re-renders immediately:
1953
+ Write the query with `useSetSearchParams()` — the setter updates the query string on the current pathname (via `navigate`), so the URL changes **and** every reader re-renders immediately:
1643
1954
 
1644
1955
  ```tsx
1645
1956
  import { useSearchParams, useSetSearchParams } from '@voltro/web'
@@ -1673,6 +1984,32 @@ setParams({ page: '2' }, { push: true })
1673
1984
 
1674
1985
  During SSR there is no history to write — read `useSearchParams()` off the request URL for the first paint and call `useSetSearchParams()` on the client after hydration.
1675
1986
 
1987
+ ### Typed writes
1988
+
1989
+ Pass the page's `searchParams` schema to get the **typed** setter. Its object form REPLACES the query — same semantics as the untyped form; a field you leave out decodes to its default on the next read. Its updater form receives the **current decoded params**, so a merge is one explicit spread — the pagination flip that keeps `?filter` stops being a hand-rolled merge:
1990
+
1991
+ ```tsx
1992
+ import { useSetSearchParams } from '@voltro/web'
1993
+ import { searchParams } from './page'
1994
+
1995
+ const setParams = useSetSearchParams(searchParams)
1996
+ setParams({ page: 2 }) // replaces → ?page=2 (filter dropped)
1997
+ setParams((p) => ({ ...p, page: p.page + 1 })) // keeps ?filter — typed merge
1998
+ ```
1999
+
2000
+ A misspelt key or wrong value type in the object form is a compile error; in the updater, the typed `p` is the guard (`p.pgae` does not compile).
2001
+
2002
+ For a plain `<Link>` that keeps the current query, compose the two primitives you already have — decode the current params, spread them into `withQuery`:
2003
+
2004
+ ```tsx
2005
+ const current = useSearchParams(searchParams)
2006
+ <Link to={withQuery(routes['/search'](), { ...current, page: current.page + 1 })}>Next</Link>
2007
+ ```
2008
+
2009
+ That composition is also the whole story on **retaining params across navigations**: there is no implicit retain list — a param survives a navigation only if the link (or setter) encodes it, which keeps every URL self-describing. Spread what must survive; everything else resets to its schema default.
2010
+
2011
+ **Layout-level schemas are deliberately not a layer of their own:** the schema is a page export. A layout (or any co-located component) that needs the same params imports the page's schema and calls `useSearchParams(searchParams)` with it — composition per schema import, one schema, no drift.
2012
+
1676
2013
  ## Prefetching programmatically
1677
2014
 
1678
2015
  ```tsx
@@ -1719,15 +2056,13 @@ export const renderMode = 'static' as const
1719
2056
  export const interactive = 'islands' as const
1720
2057
  ```
1721
2058
 
1722
- > **Islands cut hydration WORK, not DOWNLOAD — read this before you pick the mode.**
1723
- >
1724
- > `interactive: 'islands'` ships **exactly the same JavaScript** as `interactive: 'full'`. Measured on the framework's reference fixture, the same page: `full` = 195,229 bytes gzipped of first-load JS, `islands` = 195,231 bytes. That is the whole difference — two bytes of noise.
2059
+ > **Islands cut hydration WORK and DOWNLOAD — the page ships its own lean entry.**
1725
2060
  >
1726
- > The reason is structural, not a missing optimisation pass: the generated browser entry imports `mount` and your `App` at value level, so the browser has already downloaded, parsed and evaluated the entire app bundle before the islands branch is even reached. Only `interactive: 'none'` removes bytes todayit strips every `<script type="module">` and `<link rel="modulepreload">` from the page's HTML.
1727
- >
1728
- > So islands are the right choice when the cost you want back is **CPU on the main thread** (hydration walking a large tree, effects firing across a page of prose). They are the wrong choice if you adopted them to make the download smaller for that, use `interactive: 'none'` and put the interactive bits behind a separate page, or accept the full payload.
2061
+ > `voltro build` emits a dedicated browser entry per `interactive: 'islands'` page: react + the island runtime + exactly that page's islands not the router, not the Effect runtime, not the subscription cache, not the app shell. Measured on the framework's reference fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25): an islands page is **59.6 KB gzipped first-load** vs **181.9 KB gzipped** for the same page as `full` a factor of ~3. The bundle-budget test additionally pins a hard <70 KB bound AND the ratio (<50 % of the full page). `interactive: 'none'` stays at 0 B.
2062
+
2063
+ 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.
1729
2064
 
1730
- With `interactive: 'islands'`, the page's HTML keeps its script tags and the app bundle still loads; what changes is that `mount()` skips hydrating the page tree and instead scans for island markers, hydrating each one on its own schedule.
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.
1731
2066
 
1732
2067
  ## When to use islands
1733
2068
 
@@ -1735,15 +2070,19 @@ With `interactive: 'islands'`, the page's HTML keeps its script tags and the app
1735
2070
  - **Docs** that are mostly text but have a search modal + theme toggle.
1736
2071
  - **Blog posts** with an embedded poll or comment widget.
1737
2072
 
1738
- In each case what you get back is hydration time, not bytes: React's *runtime* overhead applies only to the interactive parts, while the *download* is unchanged. If the page has no interactive part at all, `interactive: 'none'` is strictly better — it ships no JavaScript.
2073
+ In each case you get back both halves: hydration work runs only inside the islands, and the download shrinks to react + the island runtime + those islands. If the page has no interactive part at all, `interactive: 'none'` is strictly better — it ships no JavaScript.
2074
+
2075
+ `interactive: 'none'` does not take forms with it. The strip removes every module script and modulepreload, but leaves `<form>` markup — and the form-flash JSON script (`#__voltro_form_flash__`, inert JSON, not executable code) — in place. An `<AutoForm>` on an `interactive: 'none'` page is therefore fully usable without a single byte of JavaScript: it renders `action="/form/<mutationTag>"` + `method="post"` and submits as a native form POST. Details: [Forms without JavaScript](/docs/ui/forms-and-tables).
1739
2076
 
1740
2077
  ## Writing an island
1741
2078
 
1742
2079
  Wrap a component with `island(Component, { name, hydrate })` and default-export the result. The plain component is NOT enough — without the `island()` call the component is never registered, and at hydration time the runtime logs `island "…" not registered`.
1743
2080
 
2081
+ `island` comes from the react-only subpath **`@voltro/web/islands`** (only react + react-dom/client in its graph). Importing the `@voltro/web` barrel inside an island file is a BUILD ERROR — see the import rules below.
2082
+
1744
2083
  ```tsx
1745
2084
  // src/components/LikeButton.island.tsx
1746
- import { island } from '@voltro/web'
2085
+ import { island } from '@voltro/web/islands'
1747
2086
  import { useState } from 'react'
1748
2087
 
1749
2088
  const LikeButton = ({ initial }: { initial: number }) => {
@@ -1759,7 +2098,7 @@ export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
1759
2098
  ```
1760
2099
 
1761
2100
  - **`name`** — the stable id under which the component is registered. Must be unique within the app. Both the SSR and the client bundle import the file, so the same `island()` call runs on both sides and registers the component in each.
1762
- - **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The five strategies are in the table below.
2101
+ - **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The six strategies are in the table below.
1763
2102
 
1764
2103
  Use it in a page:
1765
2104
 
@@ -1790,8 +2129,8 @@ What happens at build:
1790
2129
  <button>❤ 42</button>
1791
2130
  </div>
1792
2131
  ```
1793
- 2. The island bundles into the client chunk.
1794
- 3. The client runtime scans for `[data-voltro-island]` markers, looks each name up in its registry, and hydrates that `<div>` per its `data-island-hydrate` strategy.
2132
+ 2. The island compiles into the page's own browser entry — react + the island runtime + this page's islands (see [How the per-page entry works](#how-the-per-page-entry-works)).
2133
+ 3. That entry scans for `[data-voltro-island]` markers, looks each name up in its registry, and hydrates that `<div>` per its `data-island-hydrate` strategy.
1795
2134
 
1796
2135
  The rest of the page stays as inert HTML.
1797
2136
 
@@ -1805,33 +2144,57 @@ Each island declares WHEN it hydrates via the `hydrate` option (default `'visibl
1805
2144
  | `idle` | When the browser is idle (`requestIdleCallback`, `setTimeout` fallback) | Important widgets that don't need instant interactivity — analytics, secondary nav. |
1806
2145
  | `visible` (default) | When the element scrolls into the viewport (IntersectionObserver) | Anything below the fold — comment box, related-articles carousel. |
1807
2146
  | `interaction` | On the first pointer / keyboard event on the element | Heavy widgets users *might* touch — embedded playground, deep tree viewer. Defers cost until commitment. |
2147
+ | `only` | Client-only: the server renders an empty placeholder, the client mounts fresh with `createRoot` instead of hydrating | Components that touch `window` during render — chart/map libraries. |
1808
2148
  | `never` | Never — the server-rendered HTML stays inert | Server-only displays that never change after SSR (a build-time status badge). |
1809
2149
 
1810
2150
  Mix freely inside one page: a `load` search box, a `visible` comment widget, and a `never` build banner can all coexist.
1811
2151
 
1812
2152
  ## What each mode actually costs
1813
2153
 
1814
- Measured on the framework's reference web fixture — the same page, three values of `interactive`, first-load JavaScript read out of the page's own built HTML (entry script + every `modulepreload`) and gzipped:
2154
+ Measured on the framework's reference web fixture (pinned in `packages/web/bundle-budget.json`, as of 2026-08-25) — the same page, three values of `interactive`, first-load JavaScript read out of the page's own built HTML (entry script + every `modulepreload`) and gzipped:
1815
2155
 
1816
2156
  | Mode | JS shipped | Hydration |
1817
2157
  |---|---|---|
1818
- | `interactive: 'full'` | 195,229 B gz | The whole page tree |
1819
- | `interactive: 'islands'` | 195,231 B gz | Only the marked islands, each on its own strategy |
2158
+ | `interactive: 'full'` | ≈181.9 KB gz — the app entry: router, Effect runtime, subscription cache, app shell | The whole page tree |
2159
+ | `interactive: 'islands'` | ≈59.6 KB gz — a per-page entry: react + the island runtime + this page's islands | Only the marked islands, each on its own strategy |
1820
2160
  | `interactive: 'none'` | 0 B — every module script and modulepreload is stripped from the HTML | None |
1821
2161
 
1822
- Two things to take from that table. **`islands` is not a download optimisation** — it is level with `full` to within two bytes, and the numbers above are the whole story, not a "before we finish the work" snapshot. And **`none` is the one that removes bytes**, because it is the only mode that removes the script tags.
2162
+ Two things to take from that table. **`islands` IS a download optimisation now** — an islands page ships roughly a third of the full page's first-load JS, because its entry carries no router, no Effect runtime, no subscription cache and no app shell. And **`none` remains the floor**: it is the only mode that removes the script tags entirely.
1823
2163
 
1824
- The floor those first two numbers sit on is the framework's browser runtime React plus the Effect-based RPC client and it is pinned in CI so it cannot drift silently. Reproduce it yourself:
2164
+ Both islands numbers are pinned in CI — the hard <70 KB bound and the <50 %-of-full ratio so the gap cannot drift shut silently. Reproduce it yourself:
1825
2165
 
1826
2166
  ```sh
1827
2167
  node packages/web/scripts/bundle-budget.mjs
1828
2168
  ```
1829
2169
 
1830
- ## Making islands reduce bytes too
2170
+ ## How the per-page entry works
2171
+
2172
+ `voltro build` emits one browser entry per `interactive: 'islands'` page. The build finds the page's islands by walking the page's **relative import graph** for `*.island.tsx` files — transitively, through intermediate components. Only what is reachable from an island file ships; the page component itself may import anything, because on an islands page it never runs in the browser.
2173
+
2174
+ Two rules to know:
1831
2175
 
1832
- It is a real gap, and it is a build-pipeline change rather than a runtime one: the browser entry would have to be emitted *per page*, so an islands page's entry imports the island runtime and its own islands instead of the whole app. Nothing in the page's own code can shortcut it — the bytes are pulled in above `mount()`, by the entry that imports it.
2176
+ - **`interactive` must be a source LITERAL.** `export const interactive = 'islands' as const` selects the lean entry; a computed value does not the page then ships the full entry as before, and the build says so loudly.
2177
+ - **It applies on all three paths.** `voltro build` (SSG), `voltro start` (ssr/isr islands pages) and `voltro dev` (the same entry mechanism, on demand) — including the import-rule violations below, which fire in dev already, not first in the build.
1833
2178
 
1834
- Until then, if download size is what you are optimising, reach for `interactive: 'none'`.
2179
+ ### What an island may import (build errors, not runtime crashes)
2180
+
2181
+ An island file — or anything in its relative import graph — must NOT import:
2182
+
2183
+ - `@voltro/web` (the barrel — router hooks, `<Link>`)
2184
+ - `@voltro/i18n` (`useT`)
2185
+
2186
+ An island hydrates provider-less in its own root, so these hooks would throw there — and the barrel would additionally drag the Effect runtime into the lean entry. The build error names the file and the specifier.
2187
+
2188
+ Allowed: `@voltro/web/islands`, react, relative browser-safe imports — and `@voltro/client` / `@voltro/ui` (both count as framework usage and trigger the client boot below).
2189
+
2190
+ ### Framework islands: `useSubscription` and friends
2191
+
2192
+ An `@voltro/client` import in the island graph is DETECTED — that page's entry then boots the rpc client (a `VoltroRuntimeProvider` around each island root), and the island receives live data. Pages whose islands are purely presentational never pay the client core.
2193
+
2194
+ ### Limits
2195
+
2196
+ - An islands page reached via **SPA navigation** from a full page runs inside the already-loaded app bundle — the saving applies to the first visit / hard load of the islands page.
2197
+ - All islands of one page **share one entry** (no per-island lazy chunk) — the hydrate strategies control WHEN an island hydrates, not when it loads.
1835
2198
 
1836
2199
  ## Island boundaries
1837
2200
 
@@ -1851,7 +2214,7 @@ Each island is independent — there is no shared React root across islands. To
1851
2214
 
1852
2215
  ## Props serialisation
1853
2216
 
1854
- Props passed to an island must be **JSON-serialisable**. The framework serialises them into the marker's `data-island-props` attribute + hydrates with the same values.
2217
+ Island props cross the boundary **as JSON in an HTML attribute**. The framework serialises them into the marker's `data-island-props` attribute + hydrates with the same values. `Date` arrives as an ISO string, `Map`/`Set` as `{}`, and functions are lost — in dev the framework warns, naming the island and the prop. Pass JSON shapes and reconstruct richer types inside the island.
1855
2218
 
1856
2219
  OK:
1857
2220
 
@@ -1887,13 +2250,13 @@ If you need to pass a function reference, define it INSIDE the island.
1887
2250
 
1888
2251
  ## Inspecting
1889
2252
 
1890
- Each `*.island.tsx` becomes its own chunk, so `.framework/dist/assets/` carries an `island-<Name>-<hash>.js` per island alongside the app chunks. Listing that directory is the whole report:
2253
+ Each islands page gets its own lean entry in `.framework/dist/assets/`, alongside the app entry that full pages share. Listing that directory is the whole report:
1891
2254
 
1892
2255
  ```sh
1893
2256
  ls -l .framework/dist/assets
1894
2257
  ```
1895
2258
 
1896
- Per-island chunks are cached independently across deploys and fetched in parallel but note they are *additional* files reachable from the app bundle, not a replacement for it. See the mode table above for what actually reaches the browser.
2259
+ All islands of one page share that page's entrythere is no per-island lazy chunk. See the mode table above for what actually reaches the browser.
1897
2260
 
1898
2261
  ## Anti-patterns
1899
2262
 
@@ -1982,14 +2345,312 @@ the `@source` is misconfigured.
1982
2345
 
1983
2346
 
1984
2347
 
2348
+ ---
2349
+
2350
+ <!-- source: en/routing/assets.md -->
2351
+ ## Images & static assets
2352
+
2353
+ _The build-time image pipeline — ?image imports become srcSet variants (AVIF/WebP + fallback) with inferred dimensions and a blur placeholder; the <Image> primitive, the CDN loader seam, and the sharp setup._
2354
+
2355
+ `<Image>` is the responsive image primitive; the `?image` import suffix is the
2356
+ build-time pipeline behind it. Together they replace next/image: variants and
2357
+ placeholders are produced at build time for static assets, on demand in dev.
2358
+
2359
+ ## The pipeline: `?image` imports
2360
+
2361
+ ```tsx
2362
+ import { Image } from '@voltro/web'
2363
+ import hero from '../assets/hero.jpg?image'
2364
+
2365
+ export default function Page(): React.ReactElement {
2366
+ return <Image src={hero} alt="Team photo" priority />
2367
+ }
2368
+ ```
2369
+
2370
+ The `?image` suffix turns the import into an optimized asset object instead of
2371
+ a URL string: the build encodes every ladder width up to the intrinsic width
2372
+ (640 … 3840, capped) as AVIF + WebP plus a same-family fallback (jpeg/png),
2373
+ hashes the variants into `dist/assets/`, reads the intrinsic `width`/`height`,
2374
+ and inlines a 16px blur placeholder as a data URI. `<Image>` renders it as a
2375
+ `<picture>` with one `<source>` per modern format; dimensions and blur are
2376
+ inferred — the CLS-required `width`/`height` props stop being hand-written for
2377
+ imported assets, and `placeholder="blur"` is the default (opt out with
2378
+ `placeholder="empty"`).
2379
+
2380
+ The suffix is an explicit opt-in **on purpose**: a bare image import keeps
2381
+ Vite's plain hashed-URL semantics, so existing `<img src={imported}>` and CSS
2382
+ references are untouched.
2383
+
2384
+ Add the ambient type once per app (`src/voltro-image.d.ts`):
2385
+
2386
+ ```ts
2387
+ declare module '*?image' {
2388
+ const asset: {
2389
+ readonly src: string
2390
+ readonly width: number
2391
+ readonly height: number
2392
+ readonly blurDataURL: string
2393
+ readonly srcSet: string
2394
+ readonly sources: ReadonlyArray<{ readonly type: string; readonly srcSet: string }>
2395
+ }
2396
+ export default asset
2397
+ }
2398
+ ```
2399
+
2400
+ ## Dev vs build vs start
2401
+
2402
+ - **`voltro build`** encodes variants into `dist/assets/` under content
2403
+ hashes. The transforms run through a persistent cache
2404
+ (`.framework/image-cache/`), so the second build re-encodes nothing — 500
2405
+ posts × 8 widths × 2 formats is a one-time cost.
2406
+ - **`voltro dev`** serves transforms on demand from
2407
+ `/_voltro/image/<assetId>` (same cache). The endpoint answers ONLY for
2408
+ assets registered by an actual `?image` import — a free path parameter
2409
+ would be a dev file-read surface.
2410
+ - **`voltro start`** serves build artifacts only. There is deliberately no
2411
+ production transform endpoint — no transform-DoS surface. This is a
2412
+ documented dev/prod divergence.
2413
+
2414
+ ## Configuration
2415
+
2416
+ ```ts
2417
+ // app.config.ts (web)
2418
+ export default {
2419
+ type: 'web' as const,
2420
+ name: 'MyApp',
2421
+ images: {
2422
+ formats: ['avif', 'webp'], // modern formats, in <source> order (default)
2423
+ quality: 75, // encode quality for every variant (default)
2424
+ },
2425
+ }
2426
+ ```
2427
+
2428
+ ## sharp — the native encoder
2429
+
2430
+ The pipeline runs on [sharp](https://sharp.pixelplumbing.com), shipped as an
2431
+ **optional dependency of @voltro/cli** — auto-available in every project,
2432
+ nothing to install. sharp ≥0.33 ships prebuilt binaries as `@img/*` platform
2433
+ packages with no install script, so pnpm 10's build-script approval gate does
2434
+ not apply.
2435
+
2436
+ If your installer **omits optional dependencies**, the platform prebuilds are
2437
+ dropped: sharp resolves but throws on load. The pipeline then serves original
2438
+ images with ONE loud warning naming the fix (`pnpm add -D sharp`, or reinstall
2439
+ without omitting optional deps), and `voltro doctor` distinguishes
2440
+ "not installed" from "installed but binary missing". Never a silent
2441
+ passthrough.
2442
+
2443
+ ## Limits (and the answer for each)
2444
+
2445
+ - **Dynamic `src`** — a URL from `loaderData` or CMS frontmatter cannot be
2446
+ seen at build time. Use the loader seam: `<Image src={url} loader={cdn}>`
2447
+ against your image CDN or the storage plugin's public-serve endpoint (which
2448
+ resizes on the fly). The `quality` prop flows into the loader for exactly
2449
+ this path; for `?image` assets quality is baked at build time from
2450
+ `images.quality`.
2451
+ - **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.
2455
+
2456
+ ## `<Image>` without the pipeline
2457
+
2458
+ Everything from before still holds for plain string `src`: lazy loading +
2459
+ async decode by default, `priority` for the LCP image, required
2460
+ `width`/`height` (or `fill`) for CLS, `sizes`, and the pluggable
2461
+ `ImageLoader`/`ImageConfigProvider` seam. See the reference for the full prop
2462
+ table.
2463
+
2464
+
2465
+
2466
+ ---
2467
+
2468
+ <!-- source: en/routing/third-party-scripts.md -->
2469
+ ## Third-party scripts
2470
+
2471
+ _The <Script> component — declared loading strategies (afterInteractive, lazyOnload), process-wide dedupe, remount-safe onLoad, behavior per interactive mode, and the CSP story for client-injected tags._
2472
+
2473
+ `<Script>` loads third-party scripts declaratively instead of hand-rolled
2474
+ `useEffect` + `createElement('script')` blocks — with a decided answer for
2475
+ every mode the page can be in.
2476
+
2477
+ ```tsx
2478
+ import { Script } from '@voltro/web'
2479
+
2480
+ // Analytics after hydration (the default strategy):
2481
+ <Script
2482
+ src="https://eu.i.posthog.com/static/array.js"
2483
+ onLoad={() => {
2484
+ // The hand-written PostHog browser snippet — @voltro/plugin-posthog is a
2485
+ // SERVER-side track sink and ships no browser snippet; this is the
2486
+ // client half, wired the way PostHog's docs describe.
2487
+ const w = window as { posthog?: { init: (key: string, opts: { api_host: string }) => void } }
2488
+ w.posthog?.init('phc_your_project_key', { api_host: 'https://eu.i.posthog.com' })
2489
+ }}
2490
+ />
2491
+
2492
+ // GTM bootstrap — the inline variant (id is REQUIRED: it is the dedupe key):
2493
+ <Script id="gtm-init">{`window.dataLayer = window.dataLayer || []`}</Script>
2494
+
2495
+ // A chat widget nobody needs before the browser is idle:
2496
+ <Script src="https://widget.example.com/loader.js" strategy="lazyOnload" />
2497
+ ```
2498
+
2499
+ ## Strategies
2500
+
2501
+ - **`afterInteractive`** (default) — injected after this component mounts,
2502
+ i.e. after hydration. Never render-blocking; the shell head stays clean.
2503
+ - **`lazyOnload`** — waits for browser idle (`requestIdleCallback`, with a
2504
+ `setTimeout` fallback for Safari).
2505
+
2506
+ There is **no `beforeInteractive`**. The honest alternative for a
2507
+ must-run-first script (a consent manager) is a literal `<script>` tag in the
2508
+ shell head — a `<link rel="preload">` is *not* an answer: it fetches but never
2509
+ executes. And no `worker` strategy (the Partytown class is its own decision).
2510
+
2511
+ ## Dedupe + remount semantics
2512
+
2513
+ Scripts deduplicate **process-wide** by `src` (external) or `id` (inline) —
2514
+ the registry is global, so two `<Script>` tags for one widget produce one
2515
+ request and one execution, even across an islands page's separate bundle.
2516
+
2517
+ A script is **never unloaded**. Navigate away and back and the script does
2518
+ not re-execute and nothing is re-fetched — but `onLoad` **fires again**,
2519
+ answered from the registry (the classic next/script bug where a remounted
2520
+ component's `onLoad` never fires is pinned by test here). `onError` behaves
2521
+ the same for a failed load.
2522
+
2523
+ ## Behavior per `interactive` mode — decided, not accidental
2524
+
2525
+ - **`full`** — as described above.
2526
+ - **`none`** — zero-JS means zero: the app bundle never ships, so a
2527
+ `<Script>` can never inject. The build **warns by name** instead of
2528
+ silently doing nothing.
2529
+ - **`islands`** — outside an island nothing mounts, so a `<Script>` in the
2530
+ page's static part never fires; the build warns. Inside an island it runs
2531
+ when that island hydrates — a `visible` island's script loads when it
2532
+ scrolls into view, which is often exactly the lazy behavior you want.
2533
+
2534
+ ## CSP
2535
+
2536
+ `<Script>` injects client-side, so the per-request nonce your middleware
2537
+ mints ([CSP nonces](/docs/security/production-hardening)) is stamped into
2538
+ server-rendered tags — not into tags created in the browser. The component's
2539
+ answer:
2540
+
2541
+ - An explicit `nonce` prop always wins.
2542
+ - Otherwise the injector propagates the **document's own nonce** (read off an
2543
+ existing nonce'd script element). On an SSR page under a nonce'd CSP the
2544
+ injected tag therefore carries the request's nonce automatically.
2545
+ - On a **`static` page there is no per-request nonce path at all** — the
2546
+ documented options are `'strict-dynamic'` (scripts injected by an
2547
+ allowed/nonce'd bootstrap are permitted, which is exactly this shape) or a
2548
+ hash-based policy.
2549
+
2550
+ The inline variant is covered by the same rules — under a strict CSP an
2551
+ inline snippet needs the nonce or `'strict-dynamic'` like any other injected
2552
+ script.
2553
+
2554
+
2555
+
2556
+ ---
2557
+
2558
+ <!-- source: en/routing/fonts.md -->
2559
+ ## Fonts
2560
+
2561
+ _Self-hosted local fonts — declared once in app.config.ts, delivered as hashed woff2 with @font-face, a size-adjusted fallback face (the CLS guard), a preload link and opt-in subsetting. No font CDN request ever leaves the browser._
2562
+
2563
+ Declare local font files once and the framework does the rest: content-hashed
2564
+ self-hosting, `@font-face` with `font-display`, a **size-adjusted fallback
2565
+ face** so the swap moves nothing, a `<link rel="preload">` in the shell head,
2566
+ and opt-in unicode-range subsetting.
2567
+
2568
+ ```ts
2569
+ // app.config.ts (web)
2570
+ export default {
2571
+ type: 'web' as const,
2572
+ name: 'MyApp',
2573
+ fonts: [{
2574
+ family: 'Inter',
2575
+ src: [
2576
+ { path: 'src/fonts/Inter-Variable.woff2', weight: '100 900' }, // variable range
2577
+ // …or discrete faces — Regular+Bold is any real project's minimum:
2578
+ // { path: 'src/fonts/Inter-400.woff2', weight: 400 },
2579
+ // { path: 'src/fonts/Inter-700.woff2', weight: 700 },
2580
+ // { path: 'src/fonts/Inter-Italic.woff2', weight: 400, style: 'italic' },
2581
+ ],
2582
+ display: 'swap',
2583
+ subsets: ['latin'],
2584
+ fallback: 'Arial',
2585
+ }],
2586
+ }
2587
+ ```
2588
+
2589
+ ```tsx
2590
+ import { localFont } from '@voltro/web'
2591
+
2592
+ const inter = localFont('Inter') // { variable: '--font-inter', fontFamily: 'var(--font-inter)' }
2593
+
2594
+ export default function Page(): React.ReactElement {
2595
+ return <main style={{ fontFamily: inter.fontFamily }}>…</main>
2596
+ }
2597
+ ```
2598
+
2599
+ The shell defines `--font-inter` as `'Inter', 'Inter Fallback', Arial,
2600
+ sans-serif` — use it from any CSS. Every render mode ships the same head tags
2601
+ (the CSS + preload are baked into the ONE generated shell that dev, static
2602
+ prerender, SSR streaming and `voltro start` all serve).
2603
+
2604
+ ## Why the fallback face matters (CLS)
2605
+
2606
+ Until the web font arrives, text renders in the fallback — and a fallback
2607
+ with different metrics reflows the page when the swap happens. The pipeline
2608
+ reads the font file's real metrics (fontkit) and emits an `'Inter Fallback'`
2609
+ face: `local('Arial')` with `size-adjust`, `ascent-override`,
2610
+ `descent-override` and `line-gap-override` computed by the capsize formula so
2611
+ the fallback occupies the SAME space. The swap becomes invisible; CLS ≈ 0.
2612
+
2613
+ ## Subsetting
2614
+
2615
+ `subsets: ['latin']` (and/or `'latin-ext'`) rewrites each face to just that
2616
+ unicode range via `subset-font`, declared with a matching `unicode-range` so
2617
+ the browser only downloads what the page's characters need. Measured in the
2618
+ framework's own e2e: the subset ships at well under half the source size.
2619
+
2620
+ ## GDPR — no font CDN, ever
2621
+
2622
+ Nothing here talks to Google Fonts (or any font host) at runtime — the files
2623
+ ship from YOUR origin, content-hashed and immutable. That is the compliance
2624
+ answer German courts made concrete (LG München, remote Google-Fonts
2625
+ embedding): the user's IP never reaches a font CDN because no request leaves
2626
+ your domain. The framework's e2e asserts exactly that — a full page load with
2627
+ zero foreign-host requests.
2628
+
2629
+ **Getting the files:** there is deliberately no Google-Fonts download helper
2630
+ (license terms differ per family — that step stays yours). The manual path:
2631
+ download the family from fonts.google.com (or the foundry), drop the
2632
+ `woff2`/`ttf` into `src/fonts/`, declare it. Done once, committed with the
2633
+ repo.
2634
+
2635
+ ## Tooling (optional, degrades loudly)
2636
+
2637
+ `fontkit` (metrics) and `subset-font` (subsetting) ship as optional
2638
+ dependencies of @voltro/cli — script-free, nothing to approve. If your
2639
+ installer omits optional dependencies: fonts still self-host with
2640
+ `@font-face` + preload, but the fallback metrics and subsets are skipped —
2641
+ with ONE named warning each, and `voltro doctor` reports which half is
2642
+ missing and the fix. Never a silent downgrade.
2643
+
2644
+
2645
+
1985
2646
  ---
1986
2647
 
1987
2648
  <!-- source: en/routing/middleware.md -->
1988
2649
  ## Middleware
1989
2650
 
1990
- _'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, and say which routes it runs on.'_
2651
+ _'`middleware.ts` — the web app''s one server-only hook: renew a credential before the SSR render uses it, shape the response (headers, a CSP nonce), and say which routes it runs on.'_
1991
2652
 
1992
- `middleware.ts` at the web app root runs **before** a server render binds its data. It exists for one job — renewing a credential — and it is deliberately narrow about everything else.
2653
+ `middleware.ts` at the web app root runs **before** a server render binds its data. It exists for two jobs — renewing a credential the render is about to use, and shaping the RESPONSE (`responseHeaders`, a `cspNonce`) — and it is deliberately narrow about everything else.
1993
2654
 
1994
2655
  ```ts
1995
2656
  // middleware.ts — server-only. NOT app.config.ts, which is imported into the
@@ -2017,7 +2678,7 @@ A cookie older than the IdP's token lifetime — practically every first page vi
2017
2678
 
2018
2679
  ## What it receives, and what it can return
2019
2680
 
2020
- `run` gets a read-only request and returns `{ headers?, setCookies? }` — or nothing, to change nothing.
2681
+ `run` gets a read-only request and returns `{ headers?, setCookies?, responseHeaders?, cspNonce? }` — or nothing, to change nothing. `headers` and `setCookies` shape the REQUEST this render sees; `responseHeaders` and `cspNonce` shape the RESPONSE it produces (their own sections below).
2021
2682
 
2022
2683
  | Field | |
2023
2684
  |---|---|
@@ -2046,6 +2707,52 @@ The middleware produces one view of the request that everything downstream reads
2046
2707
 
2047
2708
  So a hook that renews **only** via `setCookies` — no `headers` at all, which is the normal shape for a cookie-session IdP — still authenticates this render's rpc calls: the `Cookie` header is rebuilt from the updated jar. A `maxAge` of `0` deletes, so a hook that signs someone out renders them signed out. If you return an explicit `cookie` header yourself, yours wins.
2048
2709
 
2710
+ ## Response headers — `responseHeaders`
2711
+
2712
+ `responseHeaders` is applied to what this render SENDS — every render-shaped response on both boot paths: `ssr` and `isr` renders, the `spa` shell, and a loader's redirect or 404.
2713
+
2714
+ ```ts
2715
+ export const session = defineMiddleware({
2716
+ match: { under: '/app' },
2717
+ run: async () => ({
2718
+ responseHeaders: {
2719
+ 'x-frame-options': 'DENY',
2720
+ 'referrer-policy': 'no-referrer',
2721
+ },
2722
+ }),
2723
+ })
2724
+ ```
2725
+
2726
+ Two boundaries, stated rather than implied:
2727
+
2728
+ - **`responseHeaders` act on the RENDER — an isr cache HIT does not re-run the middleware,** so a HIT does not carry the headers the MISS's render produced. For an `isr` page, either set cache-independent headers at the proxy, or accept that only MISS/refresh responses carry them.
2729
+ - **Prerendered `static` pages never render at request time,** so there is no middleware run to attach headers to. That is the documented proxy recipe: headers on static files belong on whatever serves them.
2730
+
2731
+ ## A per-request CSP nonce — `cspNonce`
2732
+
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.
2734
+
2735
+ ```ts
2736
+ import { randomBytes } from 'node:crypto'
2737
+ import { defineMiddleware } from '@voltro/web/middleware'
2738
+
2739
+ export const csp = defineMiddleware({
2740
+ match: { under: '/app' },
2741
+ run: async () => {
2742
+ const nonce = randomBytes(16).toString('base64url') // fresh per request
2743
+ return {
2744
+ cspNonce: nonce,
2745
+ responseHeaders: {
2746
+ 'content-security-policy': `script-src 'nonce-${nonce}' 'strict-dynamic'`,
2747
+ },
2748
+ }
2749
+ },
2750
+ })
2751
+ ```
2752
+
2753
+ - **`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.
2755
+
2049
2756
  ## `match` — where it runs
2050
2757
 
2051
2758
  Without a `match`, a middleware runs on every server-rendered route, including your marketing pages. That is an IdP round trip on the page least able to afford one.
@@ -2115,3 +2822,114 @@ The file is loaded **once per boot** — it is app code with a stable identity,
2115
2822
  **`voltro dev` therefore RESTARTS when you edit it**, the same way a hard-restart field in `app.config.ts` does, and says so in the log. Once-per-boot is documented, and it is still the rule most easily forgotten — everything else in a dev server hot-reloads, so a sabotaged middleware that changes nothing reads as a hook that was never wired.
2116
2823
 
2117
2824
  It runs on both SSR boot paths, `voltro dev` and `voltro start`, with the cookies written on every response arm.
2825
+
2826
+
2827
+
2828
+ ---
2829
+
2830
+ <!-- source: en/routing/intercepting-routes.md -->
2831
+ ## Intercepting routes
2832
+
2833
+ _Modal-with-URL — a page that opens as an overlay above the still-mounted origin on soft navigation, renders standalone on a hard load, and closes on Back; declared with one page export, no directory grammar._
2834
+
2835
+ An intercepting route is the **modal-with-URL** pattern: navigating from a
2836
+ gallery to a photo opens the photo as an overlay *above the still-mounted
2837
+ gallery* — the URL is the photo's, sharing/reloading it shows the standalone
2838
+ photo page, and Back closes the overlay with the gallery exactly as you left
2839
+ it (typed filters, scroll position, mounted state — nothing re-runs).
2840
+
2841
+ ## Declaring one
2842
+
2843
+ One export on the PAGE, no directory grammar:
2844
+
2845
+ ```tsx
2846
+ // src/pages/photos/[id]/page.tsx
2847
+ export const renderMode = 'ssr' as const
2848
+ export const intercept = { from: '/photos' }
2849
+
2850
+ export const loader = ({ params }) => fetchPhoto(params.id)
2851
+
2852
+ export default function PhotoDetail() {
2853
+ const photo = useLoaderData<Photo>()
2854
+ return <figure>…</figure>
2855
+ }
2856
+ ```
2857
+
2858
+ `from` names one or more ROUTE PATTERNS (`'/photos'`,
2859
+ `['/photos', '/albums/[id]']`). A soft navigation that arrives from one of
2860
+ them renders this page inside a native `<dialog>` overlay; a soft navigation
2861
+ from anywhere else — and every hard load — renders it standalone. That
2862
+ asymmetry is the feature: the same URL is a lightweight preview in context
2863
+ and a full page out of context.
2864
+
2865
+ ## The three paths, precisely
2866
+
2867
+ - **Soft navigation from a `from` route** — the overlay opens. The origin
2868
+ page stays MOUNTED: its state, subscriptions and scroll position are
2869
+ untouched (the router keeps rendering it as the background tree; nothing
2870
+ unmounts, no loader re-runs). Only the modal page's own loader runs —
2871
+ layout loaders do not (the overlay renders the page alone above the
2872
+ background's chrome), and its `Pending` shows *inside* the overlay, never
2873
+ as a full-screen swap.
2874
+ - **Hard load / reload** — standalone, always. The server knows nothing of
2875
+ interception; it renders the page as itself, with its own `meta`. A reload
2876
+ of an open modal deliberately IGNORES the overlay state that survives in
2877
+ `history.state` — the server rendered standalone and hydration must match
2878
+ it.
2879
+ - **Back** — closes the overlay (it is a real history entry). Focus returns
2880
+ to the element that opened it (native `<dialog>` semantics), the body
2881
+ scroll lock releases, and the background — which never went anywhere —
2882
+ needs no restore.
2883
+
2884
+ Nested modals stack: a modal that soft-navigates to another intercepting
2885
+ route (its `from` naming the modal's pattern) opens above it, and Back
2886
+ closes only the topmost.
2887
+
2888
+ ## Navigation blockers hold the Back gesture
2889
+
2890
+ `useBlocker` now guards **popstate** too. Back is a modal's primary close
2891
+ gesture, and before this it bypassed every blocker: the browser moves the
2892
+ URL first, so the router *reverts* the move (`history.go(-delta)`) when a
2893
+ blocker holds it and surfaces `retry`/`reset` as for any blocked navigation.
2894
+ ESC inside the overlay routes through the same path — a dirty form holds
2895
+ both. This is a behaviour CHANGE of a documented hook: a blocker that used
2896
+ to be silently skipped on Back now fires.
2897
+
2898
+ ## Two trees, two query strings
2899
+
2900
+ While an overlay is open the URL carries the MODAL's query. Each tree reads
2901
+ its own: `useSearchParams` in the background keeps decoding the background's
2902
+ query (an open modal cannot reset a filter), and `useSetSearchParams` writes
2903
+ to the calling tree's URL — a background setter never writes onto the
2904
+ modal's URL, and a modal setter (a `?zoom=` tweak) replaces without tearing
2905
+ down its own background.
2906
+
2907
+ ## What it is NOT
2908
+
2909
+ - **Parallel `@slot` routes are a declared non-goal.** Next.js pairs
2910
+ interception with independent slot navigation (`@team`/`@analytics`,
2911
+ per-slot `loading.tsx`/`default.tsx`). Here, dashboard split panes are
2912
+ COMPONENTS in a layout, not a routing concept — this page delivers the
2913
+ intercepting/modal half only.
2914
+ - **Islands / zero-JS pages don't intercept.** Interception is a client
2915
+ router behaviour; `interactive: 'islands'` pages have no SPA navigation
2916
+ and `'none'` ships no JS. Full-hydration pages only.
2917
+ - **Overlays do not View-Transition.** Route transitions apply to route
2918
+ swaps; an overlay opening is a layer change, not a page change (see
2919
+ [Navigation](/docs/routing/navigation)).
2920
+
2921
+ ## Chrome, focus, and styling
2922
+
2923
+ The framework renders the overlay as a native `<dialog>` opened with
2924
+ `showModal()` — focus trap, `::backdrop` and focus restoration come from the
2925
+ platform, and the body scroll is locked while open. It is deliberately
2926
+ unstyled: target `dialog[data-vweb-overlay]` (and `::backdrop`) from your
2927
+ CSS or a kit. The route announcer announces the modal's title on open, as it
2928
+ would any navigation.
2929
+
2930
+ ## Locale-prefixed apps
2931
+
2932
+ `from` matches route patterns literally, so a `[locale]` mirror declares its
2933
+ 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.