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