@voltro/cli 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  3. package/dist/agentsMd-DCY1RSs8.js +2 -0
  4. package/dist/apiBuild-CeUN55uk.js +2 -0
  5. package/dist/{apiBuild-CaPfoWku.js → apiBuild-DTWp0S_q.js} +2 -2
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-D-OnvNMf.js → build-D4ygSbnV.js} +114 -114
  8. package/dist/{checkCommand-C5elt0tW.js → checkCommand-Dg1G7Gwd.js} +6 -6
  9. package/dist/{checkCommand-D2ZduVlh.js → checkCommand-L7DTlpIF.js} +1 -1
  10. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  11. package/dist/{codegen-FEk8AZHb.js → codegen-DSLM8Su9.js} +2 -2
  12. package/dist/codegen-DjgxEOnD.js +2 -0
  13. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  14. package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-Cd4xkC6u.js} +9 -9
  15. package/dist/{commands-DyxAmhP0.js → commands-6Kzi92Np.js} +96 -73
  16. package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-Cq1PWvI1.js} +3 -3
  17. package/dist/{dataCommand-Bab9X7s8.js → dataCommand-DYzW8vkv.js} +3 -3
  18. package/dist/{dbCommand-06O2finM.js → dbCommand-B4NWZtGL.js} +3 -3
  19. package/dist/dbCommand-CSFWs9ev.js +2 -0
  20. package/dist/{dev-C6LGF4iY.js → dev-CmuvUKRq.js} +2236 -2219
  21. package/dist/{dev-GjJWAYo2.js → dev-cKUiZZsB.js} +1 -1
  22. package/dist/{doctorCommand-etMkflRc.js → doctorCommand-DCiFVMtZ.js} +21 -21
  23. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  24. package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-w1TrmgYP.js} +1 -1
  25. package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  26. package/dist/{envCommand-dSyKvRkM.js → envCommand-Cyynmcfa.js} +15 -15
  27. package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BwvQ8dVH.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-DKx3ba3S.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  31. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  32. package/dist/index.js +2 -2
  33. package/dist/{infoCommand-_53iOc_j.js → infoCommand-DlYlUPqs.js} +1 -1
  34. package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
  35. package/dist/{migrate-Cko9rswM.js → migrate-BK_Bbx-_.js} +2 -2
  36. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  37. package/dist/mobileCommand-DAum7tsG.js +2 -0
  38. package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
  39. package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
  40. package/dist/{probeCommand-DkGGLknv.js → probeCommand-_C0YU207.js} +1 -1
  41. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  42. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  43. package/dist/renderModeScan-43yQ2opo.js +147 -0
  44. package/dist/{renderProfile-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
  45. package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  46. package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CDGHQUFj.js} +1 -1
  47. package/dist/serveCommand-BiPe8BJm.js +2 -0
  48. package/dist/{serveCommand-CueKQgzl.js → serveCommand-Bje09q1v.js} +708 -708
  49. package/dist/serveEntry.js +1 -1
  50. package/dist/start-B0bnJgxI.js +3 -0
  51. package/dist/{start-ekPan8BT.js → start-Clz-1BHB.js} +511 -504
  52. package/dist/startEntry.js +1 -1
  53. package/dist/{staticCommand-xlSL-IWk.js → staticCommand-ey0kYmOT.js} +1 -1
  54. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  55. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  56. package/dist/{test-BWPQcRoB.js → test-D_kW4KMj.js} +1 -1
  57. package/dist/updateCommand-CIoVDKnj.js +2 -0
  58. package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-CRJlAOaM.js} +1 -1
  59. package/dist/{webDev-oczpugbx.js → webDev-DSI9SOhs.js} +1127 -1090
  60. package/dist/{webDev-C7jWJ5dX.js → webDev-DlvZO30c.js} +1 -1
  61. package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-BvzXNHji.js} +1 -1
  62. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  63. package/package.json +19 -19
  64. package/templates/AGENTS.core.md +2 -0
  65. package/templates/AGENTS.md +4 -2
  66. package/templates/agent-docs/_index.md +2 -2
  67. package/templates/agent-docs/_manifest.json +1 -1
  68. package/templates/agent-docs/ai.md +4 -4
  69. package/templates/agent-docs/authentication.md +72 -0
  70. package/templates/agent-docs/cli.md +4 -1
  71. package/templates/agent-docs/data.md +61 -12
  72. package/templates/agent-docs/database/advancedqueries.md +1 -1
  73. package/templates/agent-docs/database/migrations.md +1 -1
  74. package/templates/agent-docs/database/seedsdialects.md +64 -2
  75. package/templates/agent-docs/internationalization.md +2 -0
  76. package/templates/agent-docs/introduction.md +25 -0
  77. package/templates/agent-docs/local-first-mobile.md +139 -41
  78. package/templates/agent-docs/observability.md +4 -2
  79. package/templates/agent-docs/plugins/atlassian.md +2 -2
  80. package/templates/agent-docs/plugins/audit.md +2 -2
  81. package/templates/agent-docs/plugins/billing.md +1 -1
  82. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  83. package/templates/agent-docs/plugins/comments.md +22 -0
  84. package/templates/agent-docs/plugins/presence.md +32 -3
  85. package/templates/agent-docs/plugins/prometheus.md +2 -0
  86. package/templates/agent-docs/plugins/queue.md +47 -4
  87. package/templates/agent-docs/plugins.md +6 -6
  88. package/templates/agent-docs/reference.md +20 -1
  89. package/templates/agent-docs/routing.md +63 -9
  90. package/templates/agent-docs/scheduling.md +1 -1
  91. package/templates/agent-docs/schema-driven-ui.md +12 -1
  92. package/templates/agent-docs/templates/appshells.md +36 -4
  93. package/templates/agent-docs/whats-new.md +109 -135
  94. package/templates/apps/api-ai/package.json +6 -6
  95. package/templates/apps/api-auth/package.json +8 -8
  96. package/templates/apps/api-backend/package.json +7 -7
  97. package/templates/apps/api-backend-deactivation/package.json +7 -7
  98. package/templates/apps/api-backend-mail/package.json +8 -8
  99. package/templates/apps/api-backend-mariadb/package.json +9 -9
  100. package/templates/apps/api-backend-sqlite/package.json +8 -8
  101. package/templates/apps/api-backend-storage/package.json +8 -8
  102. package/templates/apps/api-cms/package.json +9 -9
  103. package/templates/apps/api-collab/README.md +3 -3
  104. package/templates/apps/api-collab/app.config.ts +1 -1
  105. package/templates/apps/api-collab/database/schema.ts +12 -8
  106. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  107. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  108. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  109. package/templates/apps/api-collab/package.json +8 -8
  110. package/templates/apps/api-collab/template.json +1 -1
  111. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  112. package/templates/apps/api-data-advanced/package.json +8 -8
  113. package/templates/apps/api-durable/package.json +8 -8
  114. package/templates/apps/api-feature-flags/package.json +9 -9
  115. package/templates/apps/api-governance/package.json +8 -8
  116. package/templates/apps/api-kv/package.json +8 -8
  117. package/templates/apps/api-moderation/package.json +8 -8
  118. package/templates/apps/api-observability/package.json +8 -8
  119. package/templates/apps/api-ratelimit/package.json +8 -8
  120. package/templates/apps/api-rbac/package.json +8 -8
  121. package/templates/apps/api-rest/package.json +7 -7
  122. package/templates/apps/api-row-history/package.json +8 -8
  123. package/templates/apps/api-saas/package.json +11 -10
  124. package/templates/apps/api-saas-starter/package.json +10 -10
  125. package/templates/apps/api-search/package.json +8 -8
  126. package/templates/apps/api-status/package.json +8 -8
  127. package/templates/apps/api-webhooks/package.json +9 -9
  128. package/templates/apps/changelog/package.json +7 -6
  129. package/templates/apps/edge-functions/package.json +2 -2
  130. package/templates/apps/frontend-admin/package.json +8 -8
  131. package/templates/apps/frontend-app/package.json +9 -9
  132. package/templates/apps/frontend-auth/package.json +8 -8
  133. package/templates/apps/frontend-blank/package.json +7 -7
  134. package/templates/apps/frontend-cms/package.json +9 -9
  135. package/templates/apps/frontend-collab/README.md +43 -24
  136. package/templates/apps/frontend-collab/app.config.ts +3 -3
  137. package/templates/apps/frontend-collab/package.json +14 -10
  138. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  139. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  140. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  141. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  142. package/templates/apps/frontend-collab/template.json +2 -2
  143. package/templates/apps/frontend-contact/package.json +7 -7
  144. package/templates/apps/frontend-dashboard/package.json +7 -7
  145. package/templates/apps/frontend-docs/package.json +8 -7
  146. package/templates/apps/frontend-i18n/package.json +6 -6
  147. package/templates/apps/frontend-landing/README.md +48 -0
  148. package/templates/apps/frontend-landing/app.config.ts +28 -0
  149. package/templates/apps/frontend-landing/package.json +7 -6
  150. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  151. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  152. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  153. package/templates/apps/frontend-landing/src/globals.css +15 -0
  154. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  155. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  156. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  157. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  158. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  159. package/templates/apps/frontend-landing/template.json +2 -2
  160. package/templates/apps/frontend-portal/package.json +8 -8
  161. package/templates/apps/frontend-saas/package.json +8 -8
  162. package/templates/apps/frontend-spa/package.json +7 -7
  163. package/templates/apps/frontend-ssr/package.json +7 -7
  164. package/templates/apps/frontend-ssr-api/package.json +8 -8
  165. package/templates/apps/frontend-static-blog/package.json +8 -7
  166. package/templates/apps/frontend-status/package.json +8 -8
  167. package/templates/apps/mobile-app/package.json +12 -11
  168. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  169. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  170. package/dist/agentsMd-SDDSkyl4.js +0 -2
  171. package/dist/apiBuild-DHtLXYx9.js +0 -2
  172. package/dist/codegen-BWpt3VgF.js +0 -2
  173. package/dist/codegenCommand-BOiWQ5hz.js +0 -137
  174. package/dist/dbCommand-B1EXBC6f.js +0 -2
  175. package/dist/doctorCommand-B0hX0tdz.js +0 -2
  176. package/dist/fileConventions-DASGEmj-.js +0 -35
  177. package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
  178. package/dist/renderModeScan-CUbOeOAg.js +0 -122
  179. package/dist/serveCommand-DsnrVN3U.js +0 -2
  180. package/dist/start-BJzZLbt8.js +0 -3
  181. package/dist/updateCommand-Bqql_rsQ.js +0 -2
@@ -245,6 +245,36 @@ export { searchParams } from '../../search/page'
245
245
 
246
246
  One schema, no drift — the mirror page decodes exactly what the original declares.
247
247
 
248
+ **Two scanners read this line, and they do not agree.** The isr refusal above is a
249
+ source scan, and so is the route-builder codegen that brands a route's URL with its
250
+ searchParams type — but they recognise different spellings, which is worth knowing
251
+ before you pick one:
252
+
253
+ | spelling on the mirror page | typed `withQuery` on the mirror route | `isr` + schema refused |
254
+ | --- | --- | --- |
255
+ | `export const searchParams = …` | yes | yes |
256
+ | `export { searchParams } from '../../search/page'` | **no** | yes |
257
+ | `export * from '../../search/page'` | **no** | **no** |
258
+ | `import { searchParams as base } …` + `export const searchParams = base` | yes | yes |
259
+
260
+ The middle two are the ones to watch. A clause re-export still decodes correctly at
261
+ runtime and is still refused on `isr` — but the route builder does not see it, so
262
+ `withQuery` on the mirror's URL falls back to untyped and nothing reports it. A star
263
+ re-export is seen by neither: the binding is on the module at runtime (`export *`
264
+ forwards every named export), so the page behaves as if it declared a schema while
265
+ the `isr` refusal never fires.
266
+
267
+ So: prefer the last row when you want the mirror route's links type-checked, and
268
+ never reach a schema through `export *` on an `isr` page.
269
+
270
+ ```tsx
271
+ // src/pages/[locale]/search/page.tsx — one schema, and both scanners see it
272
+ import { searchParams as base } from '../../search/page'
273
+
274
+ export const searchParams = base
275
+ export { default } from '../../search/page'
276
+ ```
277
+
248
278
  ### Routes without a schema
249
279
 
250
280
  `useSearchParams()` without an argument stays the raw `URLSearchParams` — nothing changes for a route that declares no schema:
@@ -521,7 +551,7 @@ export const renderMode = 'static' as const // 'static' | 'spa' | 'ssr' | 'isr
521
551
  | `ssr` | Every request | Never | Authenticated dashboards, search results, anything cookie-driven |
522
552
  | `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
553
 
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).
554
+ 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
555
 
526
556
  ## static (SSG)
527
557
 
@@ -2062,7 +2092,7 @@ export const interactive = 'islands' as const
2062
2092
 
2063
2093
  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
2094
 
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.
2095
+ 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
2096
 
2067
2097
  ## When to use islands
2068
2098
 
@@ -2449,9 +2479,14 @@ passthrough.
2449
2479
  this path; for `?image` assets quality is baked at build time from
2450
2480
  `images.quality`.
2451
2481
  - **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.
2482
+ - **Markdown-content images** (a blog's relative references) — copied into
2483
+ `dist/assets/content-media/<hash>.<ext>` by the content pipeline and the
2484
+ `src` rewritten to that URL. A relative source resolves against the markdown
2485
+ file that references it, and one that does not exist FAILS the build naming
2486
+ the path — a page that renders while its image 404s is the outcome this
2487
+ replaces. Absolute (`/…`) and remote sources are left untouched. Build-time
2488
+ TRANSFORMATION (resize / format) stays a named non-goal here: a markdown
2489
+ reference carries no width and no `sizes` to derive one from.
2455
2490
 
2456
2491
  ## `<Image>` without the pipeline
2457
2492
 
@@ -2730,7 +2765,7 @@ Two boundaries, stated rather than implied:
2730
2765
 
2731
2766
  ## A per-request CSP nonce — `cspNonce`
2732
2767
 
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.
2768
+ 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
2769
 
2735
2770
  ```ts
2736
2771
  import { randomBytes } from 'node:crypto'
@@ -2751,7 +2786,9 @@ export const csp = defineMiddleware({
2751
2786
  ```
2752
2787
 
2753
2788
  - **`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.
2789
+ - **`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`.
2790
+ - **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.
2791
+ - **`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
2792
 
2756
2793
  ## `match` — where it runs
2757
2794
 
@@ -2931,5 +2968,22 @@ would any navigation.
2931
2968
 
2932
2969
  `from` matches route patterns literally, so a `[locale]` mirror declares its
2933
2970
  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.
2971
+ `intercept: { from: '/[locale]/photos' }`.
2972
+
2973
+ **Declares — not re-exports.** `intercept` is read off the page module at
2974
+ runtime, so any re-export forwards it, `export *` included. A mirror that
2975
+ forwards the base page's `intercept` therefore inherits `from: '/photos'`, and
2976
+ `from` is compared against the background's route PATTERN, which for a mirror is
2977
+ `/[locale]/photos`. The two never match, so the overlay silently never opens and
2978
+ the modal renders standalone — no error, no warning, just a page where a modal
2979
+ was expected. This is the opposite failure from the [searchParams
2980
+ re-export](/docs/routing/pages#mirror-routes-share-one-schema), which is silently
2981
+ *lost*; `intercept` is silently *inherited with the wrong pattern*.
2982
+
2983
+ ```tsx
2984
+ // src/pages/[locale]/photos/[id]/page.tsx
2985
+ export { default, meta } from '../../../photos/[id]/page'
2986
+
2987
+ // NOT re-exported: the base's `from` names the un-prefixed pattern.
2988
+ export const intercept = { from: '/[locale]/photos' }
2989
+ ```
@@ -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
 
@@ -419,7 +419,18 @@ the SAME input schema the RPC path decodes:
419
419
  - unknown keys are dropped
420
420
 
421
421
  Validation runs through the same `validateFields` as the client-side
422
- validation, so the error texts are identical. Then:
422
+ validation, so the error texts are identical — in the request's language, not
423
+ in English. The handler resolves the locale from THIS request through the same
424
+ resolver that decided the surrounding page's language ([`voltro:locale` cookie
425
+ › `Accept-Language` › the app's
426
+ `defaultLocale`](/docs/i18n/overview#locale-resolution-order)); an app that
427
+ configures no `locales` gets `en`. Then:
428
+
429
+ Under URL-prefix i18n the referring URL wins over the cookie chain: a form on
430
+ `/de/todos` renders its errors in German even if the `voltro:locale` cookie says
431
+ otherwise, because there the prefix is which page you are on rather than a
432
+ preference. A first segment that is not a declared locale falls through to the
433
+ cookie chain.
423
434
 
424
435
  - **Success → `303 See Other`** (POST-redirect-GET): back to the submitting
425
436
  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