create-questpie 2.0.2 → 2.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-questpie",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Create a new QUESTPIE project",
5
5
  "keywords": [
6
6
  "create",
@@ -295,7 +295,7 @@ export const posts = collection("posts")
295
295
  title: f.text(255).required().label("Title"),
296
296
  slug: f.text(255).required(),
297
297
  content: f.richText().localized(),
298
- status: f.select(["draft", "published"]).default("draft"),
298
+ status: f.select(["internal", "featured"]).default("internal"),
299
299
  author: f.relation("users"),
300
300
  tags: f.relation("tags").manyToMany({ through: "post_tags" }),
301
301
  cover: f.upload(),
@@ -376,9 +376,9 @@ export const posts = collection("posts")
376
376
  description: "It will become visible to all readers.",
377
377
  },
378
378
  handler: async ({ record, collections }) => {
379
- await collections.posts.updateById({
379
+ await collections.posts.transitionStage({
380
380
  id: record.id,
381
- data: { status: "published" },
381
+ stage: "published",
382
382
  });
383
383
  return { type: "success", toast: { message: "Published!" } };
384
384
  },
@@ -1238,6 +1238,8 @@ collection("pages").options({
1238
1238
  - `transitionStage({ id, stage: "published" })` → move between workflow stages
1239
1239
  - `beforeTransition` / `afterTransition` hooks
1240
1240
 
1241
+ For publishable pages with workflow enabled, workflow stage is the publication source. Public reads must pass `stage: "published"`. If public client/HTTP access is enabled, anonymous read access should require `input?.stage === "published"` so callers cannot omit `stage` and fetch the working draft. Preview/draft-mode reads may omit `stage` to show the working stage to authorized editors. Do not add duplicate `isPublished` guidance when workflow already controls publishing.
1242
+
1241
1243
  ### API Usage
1242
1244
 
1243
1245
  ```ts
@@ -2012,6 +2014,10 @@ collection("posts").preview({
2012
2014
  });
2013
2015
  ```
2014
2016
 
2017
+ Live Preview uses the existing admin `FormView`, Preview button, `LivePreviewMode`, and iframe. Do not introduce a separate visual-edit form API, a second default form view, or parallel preview API names. Preserve save, autosave, Cmd+S, history, workflow transitions, locks, and actions in the normal form lifecycle.
2018
+
2019
+ Frontend visual editing needs `useCollectionPreview`, `PreviewProvider`, `PreviewField`, and usually `BlockRenderer`; load the `questpie-admin` skill for the full frontend preparation checklist.
2020
+
2015
2021
  ### `.actions()` — Server Actions
2016
2022
 
2017
2023
  ```ts
@@ -2028,9 +2034,9 @@ collection("posts").actions(({ a, c, f }) => ({
2028
2034
  destructive: false,
2029
2035
  },
2030
2036
  handler: async ({ record, collections }) => {
2031
- await collections.posts.updateById({
2037
+ await collections.posts.transitionStage({
2032
2038
  id: record.id,
2033
- data: { status: "published" },
2039
+ stage: "published",
2034
2040
  });
2035
2041
  return { type: "success", toast: { message: "Published!" } };
2036
2042
  },
@@ -14,6 +14,7 @@ Server-first TypeScript application framework. Define your data schema once usin
14
14
  ## When to Apply
15
15
 
16
16
  Reference these guidelines when:
17
+
17
18
  - Creating or modifying collections, globals, routes, jobs, services, emails, blocks
18
19
  - Working with file conventions or codegen pipeline
19
20
  - Configuring adapters (queue, search, storage, realtime, email, KV)
@@ -24,58 +25,120 @@ Reference these guidelines when:
24
25
 
25
26
  ## Import Paths — Critical
26
27
 
27
- | Factory | Import From | Needs Codegen? |
28
- |---|---|---|
29
- | `collection(name)` | `#questpie/factories` | Yes |
30
- | `global(name)` | `#questpie/factories` | Yes |
31
- | `block(name)` | `#questpie/factories` | Yes |
32
- | `adminConfig({...})` | `#questpie/factories` | Yes |
33
- | `route()` | `"questpie"` | No |
34
- | `job({...})` | `"questpie"` | No |
35
- | `service()` | `"questpie"` | No |
36
- | `email({...})` | `"questpie"` | No |
37
- | `migration({...})` | `"questpie"` | No |
38
- | `seed({...})` | `"questpie"` | No |
39
- | `runtimeConfig({...})` | `"questpie"` | No |
40
- | `appConfig({...})` | `"questpie"` | No |
41
- | `authConfig({...})` | `"questpie"` | No |
42
- | `createClient<AppConfig>()` | `"questpie/client"` | No |
43
- | `createQuestpieQueryOptions()` | `"@questpie/tanstack-query"` | No |
28
+ | Factory | Import From | Needs Codegen? |
29
+ | ------------------------------ | ---------------------------- | -------------- |
30
+ | `collection(name)` | `#questpie/factories` | Yes |
31
+ | `global(name)` | `#questpie/factories` | Yes |
32
+ | `block(name)` | `#questpie/factories` | Yes |
33
+ | `adminConfig({...})` | `#questpie/factories` | Yes |
34
+ | `route()` | `"questpie"` | No |
35
+ | `job({...})` | `"questpie"` | No |
36
+ | `service()` | `"questpie"` | No |
37
+ | `email({...})` | `"questpie"` | No |
38
+ | `migration({...})` | `"questpie"` | No |
39
+ | `seed({...})` | `"questpie"` | No |
40
+ | `runtimeConfig({...})` | `"questpie"` | No |
41
+ | `appConfig({...})` | `"questpie"` | No |
42
+ | `authConfig({...})` | `"questpie"` | No |
43
+ | `createClient<AppConfig>()` | `"questpie/client"` | No |
44
+ | `createQuestpieQueryOptions()` | `"@questpie/tanstack-query"` | No |
45
+
46
+ ## Module And Plugin Configuration - Critical
47
+
48
+ Codegen imports `modules.ts` before runtime app creation to extract module-contributed plugins. Any module that contributes a `plugin`, discover patterns, generated factories, categories, views, components, fields, or config factories must be statically discoverable from `modules.ts`.
49
+
50
+ ### DO THIS
51
+
52
+ Use a static module and a plugin-discovered config file for runtime options:
53
+
54
+ ```ts title="modules.ts"
55
+ import { observabilityModule } from "@questpie/observability/server";
56
+
57
+ export default [observabilityModule] as const;
58
+ ```
59
+
60
+ ```ts title="config/observability.ts"
61
+ import { observabilityConfig } from "@questpie/observability/server";
62
+
63
+ export default observabilityConfig({
64
+ serviceName: "barbershop",
65
+ enabled: process.env.NODE_ENV === "production",
66
+ });
67
+ ```
68
+
69
+ The module reads config at runtime from `app.state.config.observability`:
70
+
71
+ ```ts
72
+ export const observabilityModule = module({
73
+ name: "questpie-observability",
74
+ plugin: observabilityPlugin(),
75
+ services: {
76
+ observability: service({
77
+ namespace: null,
78
+ lifecycle: "singleton",
79
+ create: ({ app, logger }) =>
80
+ createObservabilityService(app.state.config?.observability, logger),
81
+ }),
82
+ },
83
+ });
84
+ ```
85
+
86
+ ### DON'T DO THIS
87
+
88
+ Do not make runtime options the primary API for codegen-aware modules:
89
+
90
+ ```ts title="modules.ts"
91
+ export default [
92
+ observabilityModule({
93
+ serviceName: "barbershop",
94
+ }),
95
+ ] as const;
96
+ ```
97
+
98
+ Do not conditionally hide codegen-aware modules or plugins behind env/runtime checks:
99
+
100
+ ```ts title="modules.ts"
101
+ export default [
102
+ process.env.OTEL_ENABLED ? observabilityModule : undefined,
103
+ ].filter(Boolean);
104
+ ```
105
+
106
+ Factory modules are acceptable only for simple runtime-only modules whose plugin identity and codegen contributions do not change. For reusable packages that ship a `CodegenPlugin`, prefer **static module + `config/*.ts` singleton factory**.
44
107
 
45
108
  ## Reference Topics
46
109
 
47
110
  ### Core
48
111
 
49
- | Topic | File | Covers |
50
- |---|---|---|
51
- | Quickstart | `references/quickstart.md` | Scaffold, configure, codegen, migrate, serve — zero to running app |
52
- | Data Modeling | `references/data-modeling.md` | Collections, globals, fields, relations, options, localization |
53
- | Field Types | `references/field-types.md` | All built-in field types with options and operators |
54
- | Rules | `references/rules.md` | Access control (row/field level), hooks lifecycle, validation |
55
- | Business Logic | `references/business-logic.md` | Routes, jobs, services, email templates, context injection |
56
- | CRUD API | `references/crud-api.md` | Server-side `find`, `create`, `update`, `delete`, globals API |
57
- | Query Operators | `references/query-operators.md` | `where` clause operators by field type |
112
+ | Topic | File | Covers |
113
+ | --------------- | ------------------------------- | ------------------------------------------------------------------ |
114
+ | Quickstart | `references/quickstart.md` | Scaffold, configure, codegen, migrate, serve — zero to running app |
115
+ | Data Modeling | `references/data-modeling.md` | Collections, globals, fields, relations, options, localization |
116
+ | Field Types | `references/field-types.md` | All built-in field types with options and operators |
117
+ | Rules | `references/rules.md` | Access control (row/field level), hooks lifecycle, validation |
118
+ | Business Logic | `references/business-logic.md` | Routes, jobs, services, email templates, context injection |
119
+ | CRUD API | `references/crud-api.md` | Server-side `find`, `create`, `update`, `delete`, globals API |
120
+ | Query Operators | `references/query-operators.md` | `where` clause operators by field type |
58
121
 
59
122
  ### Infrastructure
60
123
 
61
- | Topic | File | Covers |
62
- |---|---|---|
63
- | Production | `references/production.md` | Queue, search, realtime, storage, email, KV adapter setup |
64
- | Auth | `references/auth.md` | Better Auth integration, session, providers, access patterns |
65
- | Adapters | `references/infrastructure-adapters.md` | All adapter configs: pg-boss, S3, SMTP, pgNotify, Redis |
124
+ | Topic | File | Covers |
125
+ | ---------- | --------------------------------------- | ------------------------------------------------------------ |
126
+ | Production | `references/production.md` | Queue, search, realtime, storage, email, KV adapter setup |
127
+ | Auth | `references/auth.md` | Better Auth integration, session, providers, access patterns |
128
+ | Adapters | `references/infrastructure-adapters.md` | All adapter configs: pg-boss, S3, SMTP, pgNotify, Redis |
66
129
 
67
130
  ### Extend
68
131
 
69
- | Topic | File | Covers |
70
- |---|---|---|
71
- | Extend | `references/extend.md` | Custom modules, fields, operators, adapters, codegen plugins |
72
- | Codegen Plugin API | `references/codegen-plugin-api.md` | Plugin architecture, category declarations, templates |
73
- | Multi-Tenancy | `references/multi-tenancy.md` | Scope isolation, workspace filtering, ScopeProvider |
132
+ | Topic | File | Covers |
133
+ | ------------------ | ---------------------------------- | ------------------------------------------------------------ |
134
+ | Extend | `references/extend.md` | Custom modules, fields, operators, adapters, codegen plugins |
135
+ | Codegen Plugin API | `references/codegen-plugin-api.md` | Plugin architecture, category declarations, templates |
136
+ | Multi-Tenancy | `references/multi-tenancy.md` | Scope isolation, workspace filtering, ScopeProvider |
74
137
 
75
138
  ### Client
76
139
 
77
- | Topic | File | Covers |
78
- |---|---|---|
140
+ | Topic | File | Covers |
141
+ | -------------- | ------------------------------ | ---------------------------------------------------------------- |
79
142
  | TanStack Query | `references/tanstack-query.md` | `q.collections.*`, `q.globals.*`, `q.routes.*`, realtime queries |
80
143
 
81
144
  ## Key Patterns — Quick Reference
@@ -86,23 +149,23 @@ Reference these guidelines when:
86
149
  import { collection } from "#questpie/factories";
87
150
 
88
151
  export default collection("posts")
89
- .fields(({ f }) => ({
90
- title: f.text().required(),
91
- status: f.select([{ value: "draft", label: "Draft" }]),
92
- author: f.relation("users").required(),
93
- }))
94
- .access({
95
- read: true,
96
- create: ({ session }) => !!session,
97
- update: ({ session, doc }) => doc.authorId === session?.user?.id,
98
- })
99
- .hooks({
100
- beforeChange: async ({ data, operation }) => {
101
- if (operation === "create") data.slug = slugify(data.title);
102
- return data;
103
- },
104
- })
105
- .options({ versioning: true, timestamps: true });
152
+ .fields(({ f }) => ({
153
+ title: f.text().required(),
154
+ status: f.select([{ value: "draft", label: "Draft" }]),
155
+ author: f.relation("users").required(),
156
+ }))
157
+ .access({
158
+ read: true,
159
+ create: ({ session }) => !!session,
160
+ update: ({ session, doc }) => doc.authorId === session?.user?.id,
161
+ })
162
+ .hooks({
163
+ beforeChange: async ({ data, operation }) => {
164
+ if (operation === "create") data.slug = slugify(data.title);
165
+ return data;
166
+ },
167
+ })
168
+ .options({ versioning: true, timestamps: true });
106
169
  ```
107
170
 
108
171
  ### Route
@@ -112,11 +175,11 @@ import { route } from "questpie";
112
175
  import z from "zod";
113
176
 
114
177
  export default route()
115
- .post()
116
- .schema(z.object({ period: z.enum(["day", "week", "month"]) }))
117
- .handler(async ({ input, collections }) => {
118
- return collections.posts.find({ where: { status: "published" } });
119
- });
178
+ .post()
179
+ .schema(z.object({ period: z.enum(["day", "week", "month"]) }))
180
+ .handler(async ({ input, collections }) => {
181
+ return collections.posts.find({ where: { status: "published" } });
182
+ });
120
183
  ```
121
184
 
122
185
  ### Job
@@ -126,13 +189,15 @@ import { job } from "questpie";
126
189
  import z from "zod";
127
190
 
128
191
  export default job({
129
- name: "sendReminder",
130
- schema: z.object({ userId: z.string() }),
131
- retryDelay: 5, // seconds (not ms!)
132
- handler: async ({ payload, email, collections }) => {
133
- const user = await collections.users.findOne({ where: { id: payload.userId } });
134
- await email.send("reminder", { to: user.email, data: { name: user.name } });
135
- },
192
+ name: "sendReminder",
193
+ schema: z.object({ userId: z.string() }),
194
+ retryDelay: 5, // seconds (not ms!)
195
+ handler: async ({ payload, email, collections }) => {
196
+ const user = await collections.users.findOne({
197
+ where: { id: payload.userId },
198
+ });
199
+ await email.send("reminder", { to: user.email, data: { name: user.name } });
200
+ },
136
201
  });
137
202
  ```
138
203
 
@@ -143,14 +208,17 @@ import { createClient } from "questpie/client";
143
208
  import type { AppConfig } from "#questpie";
144
209
 
145
210
  const client = createClient<AppConfig>({
146
- baseURL: typeof window !== "undefined" ? window.location.origin : process.env.APP_URL,
147
- basePath: "/api",
211
+ baseURL:
212
+ typeof window !== "undefined"
213
+ ? window.location.origin
214
+ : process.env.APP_URL,
215
+ basePath: "/api",
148
216
  });
149
217
 
150
218
  const { docs } = await client.collections.posts.find({
151
- where: { status: "published" },
152
- orderBy: { createdAt: "desc" },
153
- with: { author: true },
219
+ where: { status: "published" },
220
+ orderBy: { createdAt: "desc" },
221
+ with: { author: true },
154
222
  });
155
223
  ```
156
224
 
@@ -164,17 +232,28 @@ await queue.sendReminder.publish({ userId: "abc" });
164
232
 
165
233
  ## Common Mistakes
166
234
 
167
- | Severity | Mistake | Fix |
168
- |---|---|---|
169
- | CRITICAL | Files in wrong directory | Collections in `collections/`, routes in `routes/`, etc. |
170
- | CRITICAL | Missing `export default` on convention files | Codegen silently ignores files without default export |
235
+ | Severity | Mistake | Fix |
236
+ | -------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------- |
237
+ | CRITICAL | Files in wrong directory | Collections in `collections/`, routes in `routes/`, etc. |
238
+ | CRITICAL | Missing `export default` on convention files | Codegen silently ignores files without default export |
171
239
  | CRITICAL | Importing route/job/service from `#questpie/factories` | Use `"questpie"` — only collection/global/block/adminConfig use `#questpie/factories` |
172
- | HIGH | Forgetting `questpie generate` after adding files | Re-run codegen on any file add/remove in convention dirs |
173
- | HIGH | Job handler uses `input` instead of `payload` | Jobs destructure `{ payload }`, routes destructure `{ input }` |
174
- | HIGH | `queue.send("name", data)` | Use `queue.jobName.publish(data)` |
175
- | HIGH | `beforeCreate` / `afterCreate` hook names | Use `beforeChange` / `afterChange` with `operation === "create"` guard |
176
- | MEDIUM | Using npm/yarn instead of Bun | QUESTPIE requires Bun as package manager |
177
- | MEDIUM | Editing `.generated/` files | Never edit re-run `questpie generate` |
240
+ | HIGH | Forgetting `questpie generate` after adding files | Re-run codegen on any file add/remove in convention dirs |
241
+ | HIGH | Job handler uses `input` instead of `payload` | Jobs destructure `{ payload }`, routes destructure `{ input }` |
242
+ | HIGH | `queue.send("name", data)` | Use `queue.jobName.publish(data)` |
243
+ | HIGH | `beforeCreate` / `afterCreate` hook names | Use `beforeChange` / `afterChange` with `operation === "create"` guard |
244
+ | HIGH | Runtime options in codegen-aware modules | Use static `module({...})` + plugin-discovered `config/*.ts` factory |
245
+ | MEDIUM | Using npm/yarn instead of Bun | QUESTPIE requires Bun as package manager |
246
+ | MEDIUM | Editing `.generated/` files | Never edit — re-run `questpie generate` |
247
+
248
+ ## Preview And Workflow Rules
249
+
250
+ - Live Preview uses the existing admin `FormView`, Preview button, `LivePreviewMode`, and iframe. Do not introduce a separate visual-edit form API, a second default form view, or parallel preview API names.
251
+ - Preserve save, autosave, Cmd+S, history, workflow transitions, locks, and actions in the normal form lifecycle.
252
+ - Frontend visual editing needs `useCollectionPreview`, `PreviewProvider`, `PreviewField`, and usually `BlockRenderer`; load the `questpie-admin` skill for the full frontend preparation checklist.
253
+ - For publishable pages with workflow enabled, workflow stage is the publication source. Public frontend reads use `stage: "published"`.
254
+ - If public client/HTTP access is enabled, anonymous read access should require `input?.stage === "published"`; editor/preview reads can omit `stage` only when a session is present.
255
+ - Preview/draft-mode reads may load the working stage for authorized editors.
256
+ - Do not add duplicate `isPublished` guidance when workflow already controls publishing.
178
257
 
179
258
  ## Full Compiled Document
180
259
 
@@ -94,6 +94,10 @@ import { uniqueIndex } from "drizzle-orm/pg-core";
94
94
  })
95
95
  ```
96
96
 
97
+ Live Preview uses the existing admin `FormView`, Preview button, `LivePreviewMode`, and iframe. Do not introduce a separate visual-edit form API, a second default form view, or parallel preview API names.
98
+
99
+ When workflow is the publication source for pages, public reads use `stage: "published"` and preview/draft-mode reads can load the working stage for authorized editors. Do not add duplicate publication booleans for the same concern.
100
+
97
101
  ### Access Control
98
102
 
99
103
  ```ts
@@ -86,6 +86,70 @@ export default runtimeConfig({
86
86
  });
87
87
  ```
88
88
 
89
+ Use direct `runtimeConfig({ plugins })` registration only for standalone codegen plugins or custom setups that do not ship a module. Reusable packages should usually attach the plugin to a static module and let codegen extract it from `modules.ts`.
90
+
91
+ ### Configurable Codegen-Aware Modules
92
+
93
+ When a package ships a module and a `CodegenPlugin`, keep module identity static and put runtime options in a plugin-discovered config file. Codegen imports `modules.ts` before runtime app creation, so it must be able to see the same module/plugin tree regardless of environment or runtime options.
94
+
95
+ #### DO THIS
96
+
97
+ ```ts title="modules.ts"
98
+ import { observabilityModule } from "@questpie/observability/server";
99
+
100
+ export default [observabilityModule] as const;
101
+ ```
102
+
103
+ ```ts title="config/observability.ts"
104
+ import { observabilityConfig } from "@questpie/observability/server";
105
+
106
+ export default observabilityConfig({
107
+ serviceName: "barbershop",
108
+ enabled: process.env.NODE_ENV === "production",
109
+ otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
110
+ });
111
+ ```
112
+
113
+ ```ts title="@questpie/observability/server.ts"
114
+ export const observabilityModule = module({
115
+ name: "questpie-observability",
116
+ plugin: observabilityPlugin(),
117
+ services: {
118
+ observability: service({
119
+ namespace: null,
120
+ lifecycle: "singleton",
121
+ create: ({ app, logger }) =>
122
+ createObservabilityService(app.state.config?.observability, logger),
123
+ }),
124
+ },
125
+ });
126
+ ```
127
+
128
+ The plugin contributes `config/observability.ts` as a discover pattern and a typed singleton factory such as `observabilityConfig()`. The service reads the resolved config at runtime from `app.state.config.observability`.
129
+
130
+ #### DON'T DO THIS
131
+
132
+ Do not make runtime options the main API for modules that contribute codegen plugins:
133
+
134
+ ```ts title="modules.ts"
135
+ export default [
136
+ observabilityModule({
137
+ serviceName: "barbershop",
138
+ enabled: process.env.NODE_ENV === "production",
139
+ }),
140
+ ] as const;
141
+ ```
142
+
143
+ Do not conditionally include codegen-aware modules or plugins:
144
+
145
+ ```ts title="modules.ts"
146
+ export default [
147
+ process.env.OTEL_ENABLED ? observabilityModule : undefined,
148
+ ].filter(Boolean);
149
+ ```
150
+
151
+ Factory modules are acceptable only for simple runtime-only modules whose plugin identity and generated contributions do not change. If the package contributes discover patterns, generated factories, module categories, views, components, fields, or collection/global extensions, use **static module + `config/*.ts` singleton factory**.
152
+
89
153
  ### Plugin Lifecycle
90
154
 
91
155
  1. **Discovery** -- codegen scans for files matching category patterns and discover patterns
@@ -494,7 +494,7 @@ bun dev
494
494
 
495
495
  ## 12. Live Preview (Optional)
496
496
 
497
- Add split-screen live preview to any collection with `.preview()`. The current same-tab flow refreshes the preview iframe after save/autosave and supports field focus over `postMessage`.
497
+ Add split-screen live preview to any collection with `.preview()`. Live Preview uses the existing admin `FormView`, Preview button, `LivePreviewMode`, and iframe. Do not introduce a second default form view or parallel preview API names.
498
498
 
499
499
  ### Add Preview to a Collection
500
500
 
@@ -518,32 +518,47 @@ export default collection("pages")
518
518
 
519
519
  ### Add Preview Support to the Frontend Page
520
520
 
521
+ Frontend checklist:
522
+
523
+ 1. Call `useCollectionPreview({ initialData, onRefresh })`.
524
+ 2. Wrap the rendered output in `PreviewProvider`.
525
+ 3. Render from `preview.data`, not directly from loader data.
526
+ 4. Wrap editable scalar text in `PreviewField`.
527
+ 5. Render blocks with `BlockRenderer` when the page uses `f.blocks()`.
528
+
521
529
  ```tsx
522
530
  import {
531
+ BlockRenderer,
523
532
  PreviewField,
524
533
  PreviewProvider,
525
534
  useCollectionPreview,
526
535
  } from "@questpie/admin/client";
536
+ import admin from "@/questpie/admin/.generated/client";
527
537
 
528
- function PageView({ initialData }) {
538
+ function PageView({ page }) {
529
539
  const router = useRouter();
530
540
  const preview = useCollectionPreview({
531
- initialData,
541
+ initialData: page,
532
542
  onRefresh: () => router.invalidate(),
533
543
  });
534
544
 
535
545
  return (
536
- <PreviewProvider
537
- isPreviewMode={preview.isPreviewMode}
538
- focusedField={preview.focusedField}
539
- onFieldClick={preview.handleFieldClick}
540
- >
541
- <PreviewField field="title" as="h1">
546
+ <PreviewProvider preview={preview}>
547
+ <PreviewField field="title" editable="text" as="h1">
542
548
  {preview.data.title}
543
549
  </PreviewField>
550
+ <BlockRenderer
551
+ content={preview.data.content}
552
+ data={preview.data.content?._data}
553
+ renderers={admin.blocks}
554
+ selectedBlockId={preview.selectedBlockId}
555
+ onBlockClick={
556
+ preview.isPreviewMode ? preview.handleBlockClick : undefined
557
+ }
558
+ />
544
559
  </PreviewProvider>
545
560
  );
546
561
  }
547
562
  ```
548
563
 
549
- The `"hybrid"` strategy is recommended as the default it applies field patches instantly via `postMessage` while reconciling derived data (slugs, relations) through the server.
564
+ The form remains authoritative. Save, autosave, Cmd+S, history, workflow, locks, and actions stay in the existing form lifecycle.
@@ -88,6 +88,49 @@ Access functions receive `AppContext` with these properties:
88
88
  | `session` | Current auth session (null if unauthed) |
89
89
  | `db` | Database instance |
90
90
  | `collections` | Typed collection API |
91
+ | `request` | Current HTTP `Request` (headers, URL) |
92
+
93
+ Access functions may be async. Use `request` for request-scoped checks such as headers, tenant scope, CAPTCHA tokens, or signed public form tokens:
94
+
95
+ ```ts
96
+ import { ApiError } from "questpie";
97
+ import { isAdminRequest } from "@questpie/admin/shared";
98
+
99
+ type AccessCtx = {
100
+ request?: Request | null;
101
+ session?: { user?: unknown | null } | null;
102
+ };
103
+
104
+ async function canCreatePublicSubmission({ request, session }: AccessCtx) {
105
+ if (session?.user) return true;
106
+ if (request && isAdminRequest(request)) {
107
+ throw ApiError.unauthorized();
108
+ }
109
+
110
+ const token = request?.headers.get("x-captcha-token");
111
+ const valid = token ? await verifyCaptchaToken(token) : false;
112
+ if (valid) return true;
113
+
114
+ throw ApiError.forbidden({
115
+ operation: "create",
116
+ resource: "public_submissions",
117
+ reason: "CAPTCHA verification failed",
118
+ });
119
+ }
120
+
121
+ export default collection("public_submissions")
122
+ .fields(({ f }) => ({
123
+ message: f.textarea().required(),
124
+ }))
125
+ .access({
126
+ read: false,
127
+ create: canCreatePublicSubmission,
128
+ });
129
+ ```
130
+
131
+ For public anti-abuse checks, bypass already authenticated users before requiring a CAPTCHA token. Admin-origin requests should not be asked for CAPTCHA either, but remember that `isAdminRequest()` is a caller-intent signal, not authentication; if an admin-origin request reaches this rule without a session, fail it as unauthorized instead of accepting it.
132
+
133
+ Prefer throwing `ApiError.*` from access rules when callers need a specific structured error response. Returning `false` is fine for generic denial, but it produces the default forbidden message.
91
134
 
92
135
  ### System Access Mode
93
136
 
@@ -322,6 +365,25 @@ Live preview sessions use token-based authentication. When a preview iframe load
322
365
  - Access rules (`.access()`) still apply to all data fetched during preview, including prefetched relations and block data.
323
366
  - Row-level access (AccessWhere) filters are enforced even in preview context — a user cannot preview records they cannot read.
324
367
 
368
+ ### Workflow Published Reads
369
+
370
+ For publishable collections that use workflow stages, do not use `read: true` when public client or HTTP access is available. Gate anonymous reads to the published stage:
371
+
372
+ ```ts
373
+ .access({
374
+ read: ({ session, input }) => {
375
+ if (session?.user) return true;
376
+ return input?.stage === "published";
377
+ },
378
+ create: ({ session }) => !!session?.user,
379
+ update: ({ session }) => !!session?.user,
380
+ delete: ({ session }) => !!session?.user,
381
+ transition: ({ session }) => !!session?.user,
382
+ })
383
+ ```
384
+
385
+ Public frontend code must pass `stage: "published"`. Preview/draft-mode reads may omit `stage` only when the request has an authorized editor session.
386
+
325
387
  ### System Access and Preview
326
388
 
327
389
  Do not use `accessMode: "system"` to serve preview data. Preview requests should go through normal session-based access, with the preview token resolving to the editor's session. This ensures previewed content respects the same visibility rules as the final published page.
@@ -213,9 +213,9 @@ The admin renders drag-and-drop upload, image preview, file info, and remove but
213
213
 
214
214
  ## Live Preview
215
215
 
216
- Live Preview uses a split-screen iframe. The current implementation refreshes the iframe after save/autosave and uses `postMessage` for field/block focus sync.
216
+ Live Preview is one system: the existing collection `FormView`, Preview button, `LivePreviewMode`, and frontend iframe. Preserve the normal form lifecycle. Do not introduce a separate visual-edit form API, a second default form view, or a parallel preview surface.
217
217
 
218
- Preview V2 patch-based docs are design notes until `useQuestpiePreview`, `PreviewRoot`, and `PreviewBlock` are exported.
218
+ The admin form is authoritative. The iframe mirrors form state through `postMessage`, supports field/block focus, and may request inline scalar edits. Persistence still goes through existing save, autosave, Cmd+S, history, workflow, locks, and actions.
219
219
 
220
220
  ### Server Config
221
221
 
@@ -239,35 +239,57 @@ export const pages = collection("pages")
239
239
  });
240
240
  ```
241
241
 
242
- Current preview refreshes the iframe after save/autosave and supports field focus through `postMessage`.
242
+ Preview opens the existing split-screen editor. Patches and refresh/resync messages update the iframe mirror; save/autosave still writes through the form.
243
243
 
244
- ### Frontend Integration
244
+ ### Prepare a Frontend Page
245
245
 
246
- Use `useCollectionPreview` with `PreviewProvider` and `PreviewField`:
246
+ Use exported APIs only: `useCollectionPreview`, `PreviewProvider`, `PreviewField`, and `BlockRenderer`.
247
+
248
+ Checklist:
249
+
250
+ 1. Configure `.preview({ url })` on the collection.
251
+ 2. Load the same record shape the page normally renders.
252
+ 3. For workflow-published pages, public reads use `stage: "published"`; if the public client/HTTP API is exposed, anonymous read access also checks `input?.stage === "published"`. Authorized preview/draft reads can load the working record.
253
+ 4. Call `useCollectionPreview({ initialData, onRefresh })` in the page renderer.
254
+ 5. Wrap the visual output in `PreviewProvider`.
255
+ 6. Render from `preview.data`, not the original loader object.
256
+ 7. Wrap editable scalar text with `PreviewField field="..." editable="text" | "textarea"`.
257
+ 8. Render block content with `BlockRenderer`; pass `selectedBlockId` and `onBlockClick`.
258
+ 9. Keep add/remove/reorder/nesting block operations in the existing block editor.
247
259
 
248
260
  ```tsx
249
261
  import {
262
+ BlockRenderer,
250
263
  PreviewField,
251
264
  PreviewProvider,
252
265
  useCollectionPreview,
253
266
  } from "@questpie/admin/client";
267
+ import admin from "@/questpie/admin/.generated/client";
254
268
 
255
- function PagePreview({ initialData }) {
269
+ function PagePreview({ page }) {
256
270
  const router = useRouter();
257
271
  const preview = useCollectionPreview({
258
- initialData,
272
+ initialData: page,
259
273
  onRefresh: () => router.invalidate(),
260
274
  });
261
275
 
262
276
  return (
263
- <PreviewProvider
264
- isPreviewMode={preview.isPreviewMode}
265
- focusedField={preview.focusedField}
266
- onFieldClick={preview.handleFieldClick}
267
- >
268
- <PreviewField field="title" as="h1">
269
- {preview.data.title}
270
- </PreviewField>
277
+ <PreviewProvider preview={preview}>
278
+ <main className={preview.isPreviewMode ? "questpie-preview" : undefined}>
279
+ <PreviewField field="title" editable="text" as="h1">
280
+ {preview.data.title}
281
+ </PreviewField>
282
+
283
+ <BlockRenderer
284
+ content={preview.data.content}
285
+ data={preview.data.content?._data}
286
+ renderers={admin.blocks}
287
+ selectedBlockId={preview.selectedBlockId}
288
+ onBlockClick={
289
+ preview.isPreviewMode ? preview.handleBlockClick : undefined
290
+ }
291
+ />
292
+ </main>
271
293
  </PreviewProvider>
272
294
  );
273
295
  }
@@ -275,11 +297,15 @@ function PagePreview({ initialData }) {
275
297
 
276
298
  ### Key Principles
277
299
 
278
- - Current preview = save/autosave refresh plus field/block focus sync
279
- - `useCollectionPreview` sends `PREVIEW_READY`, `FIELD_CLICKED`, and `BLOCK_CLICKED`
300
+ - Keep `FormView`, the Preview button, and `LivePreviewMode`
301
+ - Never add a separate visual-edit form API, a second default form view, or parallel preview API names
302
+ - Preserve save/autosave/Cmd+S/history/workflow/locks/actions
303
+ - `useCollectionPreview` handles preview mode, mirrored data, refresh/resync, and focus state
280
304
  - `PreviewProvider` supplies preview context to `PreviewField`
281
- - Each message carries `sessionId`, `seq`, `timestamp`, `protocolVersion`
282
- - Preview wrappers must prevent accidental navigation in the iframe
305
+ - `PreviewField` annotates scalar fields and can opt into inline editing with `editable`
306
+ - `BlockRenderer` preserves block IDs and block scopes for block annotations
307
+ - Use `BlockScopeProvider` only for custom/manual block rendering outside `BlockRenderer`
308
+ - Validate all iframe messages before updating form state
283
309
 
284
310
  ## History & Versions
285
311
 
@@ -560,7 +586,7 @@ Section-level visibility:
560
586
  {
561
587
  type: "section",
562
588
  label: { en: "SEO" },
563
- hidden: ({ data }) => !data.isPublished,
589
+ hidden: ({ data }) => !data.showSeo,
564
590
  fields: [f.metaTitle, f.metaDescription],
565
591
  }
566
592
  ```
@@ -803,7 +829,7 @@ export const logs = collection("logs")
803
829
 
804
830
  ## Form Views and Live Preview
805
831
 
806
- Form views connect to the Live Preview V2 system when the collection has `.preview()` configured. The form editor becomes the source of `postMessage` patches every field change emits a patch through the bus, giving the preview iframe instant updates.
832
+ Form views connect to the existing Live Preview system when the collection has `.preview()` configured. Keep `v.collectionForm()` as the form surface; do not introduce a separate visual-edit form API, a second default form view, or parallel preview API names. The form editor remains the source of patches, refreshes, commits, and resyncs.
807
833
 
808
834
  ### Enabling Preview on a Collection
809
835
 
@@ -827,9 +853,25 @@ export const pages = collection("pages")
827
853
  ### How It Works
828
854
 
829
855
  1. The form view detects `.preview()` config and opens a split-screen layout
830
- 2. Save/autosave sends a `PREVIEW_REFRESH` message to the preview iframe
831
- 3. The preview page handles refreshes through `useCollectionPreview({ initialData, onRefresh })`
832
- 4. `PreviewProvider` and `PreviewField` wire field focus and click-to-focus messages
856
+ 2. The preview iframe mirrors form state with snapshot, patch, refresh, commit, and resync messages
857
+ 3. Save/autosave/Cmd+S/history/workflow/locks/actions stay in the form lifecycle
858
+ 4. The preview page uses `useCollectionPreview({ initialData, onRefresh })`
859
+ 5. `PreviewProvider`, `PreviewField`, and `BlockRenderer` wire field and block annotations
860
+
861
+ ### Frontend Preparation Checklist
862
+
863
+ Use this checklist before expecting visual editing to work:
864
+
865
+ 1. The collection has `.preview({ url })`.
866
+ 2. The page loader returns the complete rendered record shape.
867
+ 3. Public workflow reads use `stage: "published"`; if public client/HTTP access is enabled, anonymous read access also requires `input?.stage === "published"`. Preview/draft-mode reads load the working record for authorized editors.
868
+ 4. The page renderer calls `useCollectionPreview({ initialData, onRefresh })`.
869
+ 5. The rendered page is wrapped in `PreviewProvider preview={preview}`.
870
+ 6. UI reads from `preview.data`, not directly from loader data.
871
+ 7. Scalar text uses `PreviewField field="..." editable="text" | "textarea"` when inline editing is expected.
872
+ 8. Blocks render through `BlockRenderer` with `selectedBlockId` and `onBlockClick`.
873
+ 9. `BlockScopeProvider` is only needed for custom/manual block rendering outside `BlockRenderer`.
874
+ 10. Add/remove/reorder/nesting block operations stay in the existing block editor.
833
875
 
834
876
  ---
835
877
 
@@ -1111,11 +1153,35 @@ function PageRenderer({ page }) {
1111
1153
 
1112
1154
  ## Blocks in Live Preview
1113
1155
 
1114
- When a collection has `.preview()` configured, blocks can participate in preview focus by combining `BlockScopeProvider` with `PreviewField`.
1156
+ When a collection has `.preview()` configured, blocks participate in the existing Live Preview system through `BlockRenderer` and `PreviewField`. The form remains authoritative; block annotations only mirror values, focus fields, select blocks, or request inline scalar edits.
1157
+
1158
+ ### Preferred: BlockRenderer
1159
+
1160
+ Use `BlockRenderer` for normal frontend page rendering. It preserves `data-block-id`, routes block selection, and scopes nested `PreviewField` paths automatically.
1161
+
1162
+ ```tsx
1163
+ <BlockRenderer
1164
+ content={preview.data.content}
1165
+ data={preview.data.content?._data}
1166
+ renderers={admin.blocks}
1167
+ selectedBlockId={preview.selectedBlockId}
1168
+ onBlockClick={preview.isPreviewMode ? preview.handleBlockClick : undefined}
1169
+ />
1170
+ ```
1171
+
1172
+ Inside custom block renderers, annotate scalar values with `PreviewField`:
1173
+
1174
+ ```tsx
1175
+ <PreviewField field="title" editable="text" as="h2">
1176
+ {values.title}
1177
+ </PreviewField>
1178
+ ```
1179
+
1180
+ This resolves to `content._values.{blockId}.title` when rendered inside `BlockRenderer`.
1115
1181
 
1116
- ### BlockScopeProvider Wrapper
1182
+ ### Manual BlockScopeProvider Wrapper
1117
1183
 
1118
- Use `BlockScopeProvider` in your frontend to scope field paths inside a block:
1184
+ Use `BlockScopeProvider` only when you manually render blocks outside `BlockRenderer`:
1119
1185
 
1120
1186
  ```tsx
1121
1187
  import { BlockScopeProvider } from "@questpie/admin/client";
@@ -1134,7 +1200,7 @@ function PageRenderer({ blocks, previewData }) {
1134
1200
 
1135
1201
  `PreviewField` components inside the provider resolve paths like `content._values.{blockId}.title`.
1136
1202
 
1137
- Blocks with declarative prefetch (`{ with: { image: true } }`) resolve relations during reconcile the preview shows the image URL immediately after the server round-trip completes, not just the asset ID.
1203
+ Inline block edits target `_values`, for example `content._values.<blockId>.title`. Tree edits such as add, remove, reorder, and nesting stay in the existing block editor and should trigger refresh or resync when patching is not safe.
1138
1204
 
1139
1205
  ---
1140
1206
 
@@ -13,11 +13,11 @@ The QUESTPIE admin panel is a **projection of your server schema** — not the f
13
13
 
14
14
  ## Reference Topics
15
15
 
16
- | Topic | File | Covers |
17
- |---|---|---|
18
- | Views | `references/views.md` | List views, form views, dashboard, sidebar, filters, bulk actions, visibility, history |
19
- | Blocks | `references/blocks.md` | Block definitions, fields, prefetch, renderers, block picker |
20
- | Custom UI | `references/custom-ui.md` | Custom fields, custom views, registries, reactive fields, widgets |
16
+ | Topic | File | Covers |
17
+ | --------- | ------------------------- | -------------------------------------------------------------------------------------- |
18
+ | Views | `references/views.md` | List views, form views, dashboard, sidebar, filters, bulk actions, visibility, history |
19
+ | Blocks | `references/blocks.md` | Block definitions, fields, prefetch, renderers, block picker |
20
+ | Custom UI | `references/custom-ui.md` | Custom fields, custom views, registries, reactive fields, widgets |
21
21
 
22
22
  ## Full Compiled Document
23
23
 
@@ -222,9 +222,9 @@ The admin renders drag-and-drop upload, image preview, file info, and remove but
222
222
 
223
223
  ## Live Preview
224
224
 
225
- Live Preview uses a split-screen iframe. The current implementation refreshes the iframe after save/autosave and uses `postMessage` for field/block focus sync.
225
+ Live Preview is one system: the existing collection `FormView`, Preview button, `LivePreviewMode`, and frontend iframe. Preserve the normal form lifecycle. Do not introduce a separate visual-edit form API, a second default form view, or a parallel preview surface.
226
226
 
227
- Preview V2 patch-based docs are design notes until `useQuestpiePreview`, `PreviewRoot`, and `PreviewBlock` are exported.
227
+ The admin form is authoritative. The iframe mirrors form state through `postMessage`, supports field/block focus, and may request inline scalar edits. Persistence still goes through existing save, autosave, Cmd+S, history, workflow, locks, and actions.
228
228
 
229
229
  ### Server Config
230
230
 
@@ -248,35 +248,57 @@ export const pages = collection("pages")
248
248
  });
249
249
  ```
250
250
 
251
- Current preview refreshes the iframe after save/autosave and supports field focus through `postMessage`.
251
+ Preview opens the existing split-screen editor. Patches and refresh/resync messages update the iframe mirror; save/autosave still writes through the form.
252
252
 
253
- ### Frontend Integration
253
+ ### Prepare a Frontend Page
254
254
 
255
- Use `useCollectionPreview` with `PreviewProvider` and `PreviewField`:
255
+ Use exported APIs only: `useCollectionPreview`, `PreviewProvider`, `PreviewField`, and `BlockRenderer`.
256
+
257
+ Checklist:
258
+
259
+ 1. Configure `.preview({ url })` on the collection.
260
+ 2. Load the same record shape the page normally renders.
261
+ 3. For workflow-published pages, public reads use `stage: "published"`; if the public client/HTTP API is exposed, anonymous read access also checks `input?.stage === "published"`. Authorized preview/draft reads can load the working record.
262
+ 4. Call `useCollectionPreview({ initialData, onRefresh })` in the page renderer.
263
+ 5. Wrap the visual output in `PreviewProvider`.
264
+ 6. Render from `preview.data`, not the original loader object.
265
+ 7. Wrap editable scalar text with `PreviewField field="..." editable="text" | "textarea"`.
266
+ 8. Render block content with `BlockRenderer`; pass `selectedBlockId` and `onBlockClick`.
267
+ 9. Keep add/remove/reorder/nesting block operations in the existing block editor.
256
268
 
257
269
  ```tsx
258
270
  import {
271
+ BlockRenderer,
259
272
  PreviewField,
260
273
  PreviewProvider,
261
274
  useCollectionPreview,
262
275
  } from "@questpie/admin/client";
276
+ import admin from "@/questpie/admin/.generated/client";
263
277
 
264
- function PagePreview({ initialData }) {
278
+ function PagePreview({ page }) {
265
279
  const router = useRouter();
266
280
  const preview = useCollectionPreview({
267
- initialData,
281
+ initialData: page,
268
282
  onRefresh: () => router.invalidate(),
269
283
  });
270
284
 
271
285
  return (
272
- <PreviewProvider
273
- isPreviewMode={preview.isPreviewMode}
274
- focusedField={preview.focusedField}
275
- onFieldClick={preview.handleFieldClick}
276
- >
277
- <PreviewField field="title" as="h1">
278
- {preview.data.title}
279
- </PreviewField>
286
+ <PreviewProvider preview={preview}>
287
+ <main className={preview.isPreviewMode ? "questpie-preview" : undefined}>
288
+ <PreviewField field="title" editable="text" as="h1">
289
+ {preview.data.title}
290
+ </PreviewField>
291
+
292
+ <BlockRenderer
293
+ content={preview.data.content}
294
+ data={preview.data.content?._data}
295
+ renderers={admin.blocks}
296
+ selectedBlockId={preview.selectedBlockId}
297
+ onBlockClick={
298
+ preview.isPreviewMode ? preview.handleBlockClick : undefined
299
+ }
300
+ />
301
+ </main>
280
302
  </PreviewProvider>
281
303
  );
282
304
  }
@@ -284,11 +306,15 @@ function PagePreview({ initialData }) {
284
306
 
285
307
  ### Key Principles
286
308
 
287
- - Current preview = save/autosave refresh plus field/block focus sync
288
- - `useCollectionPreview` sends `PREVIEW_READY`, `FIELD_CLICKED`, and `BLOCK_CLICKED`
309
+ - Keep `FormView`, the Preview button, and `LivePreviewMode`
310
+ - Never add a separate visual-edit form API, a second default form view, or parallel preview API names
311
+ - Preserve save/autosave/Cmd+S/history/workflow/locks/actions
312
+ - `useCollectionPreview` handles preview mode, mirrored data, refresh/resync, and focus state
289
313
  - `PreviewProvider` supplies preview context to `PreviewField`
290
- - Each message carries `sessionId`, `seq`, `timestamp`, `protocolVersion`
291
- - Preview wrappers must prevent accidental navigation in the iframe
314
+ - `PreviewField` annotates scalar fields and can opt into inline editing with `editable`
315
+ - `BlockRenderer` preserves block IDs and block scopes for block annotations
316
+ - Use `BlockScopeProvider` only for custom/manual block rendering outside `BlockRenderer`
317
+ - Validate all iframe messages before updating form state
292
318
 
293
319
  ## History & Versions
294
320
 
@@ -281,11 +281,35 @@ function PageRenderer({ page }) {
281
281
 
282
282
  ## Blocks in Live Preview
283
283
 
284
- When a collection has `.preview()` configured, blocks can participate in preview focus by combining `BlockScopeProvider` with `PreviewField`.
284
+ When a collection has `.preview()` configured, blocks participate in the existing Live Preview system through `BlockRenderer` and `PreviewField`. The form remains authoritative; block annotations only mirror values, focus fields, select blocks, or request inline scalar edits.
285
285
 
286
- ### BlockScopeProvider Wrapper
286
+ ### Preferred: BlockRenderer
287
287
 
288
- Use `BlockScopeProvider` in your frontend to scope field paths inside a block:
288
+ Use `BlockRenderer` for normal frontend page rendering. It preserves `data-block-id`, routes block selection, and scopes nested `PreviewField` paths automatically.
289
+
290
+ ```tsx
291
+ <BlockRenderer
292
+ content={preview.data.content}
293
+ data={preview.data.content?._data}
294
+ renderers={admin.blocks}
295
+ selectedBlockId={preview.selectedBlockId}
296
+ onBlockClick={preview.isPreviewMode ? preview.handleBlockClick : undefined}
297
+ />
298
+ ```
299
+
300
+ Inside custom block renderers, annotate scalar values with `PreviewField`:
301
+
302
+ ```tsx
303
+ <PreviewField field="title" editable="text" as="h2">
304
+ {values.title}
305
+ </PreviewField>
306
+ ```
307
+
308
+ This resolves to `content._values.{blockId}.title` when rendered inside `BlockRenderer`.
309
+
310
+ ### Manual BlockScopeProvider Wrapper
311
+
312
+ Use `BlockScopeProvider` only when you manually render blocks outside `BlockRenderer`:
289
313
 
290
314
  ```tsx
291
315
  import { BlockScopeProvider } from "@questpie/admin/client";
@@ -304,4 +328,4 @@ function PageRenderer({ blocks, previewData }) {
304
328
 
305
329
  `PreviewField` components inside the provider resolve paths like `content._values.{blockId}.title`.
306
330
 
307
- Blocks with declarative prefetch (`{ with: { image: true } }`) resolve relations during reconcile the preview shows the image URL immediately after the server round-trip completes, not just the asset ID.
331
+ Inline block edits target `_values`, for example `content._values.<blockId>.title`. Tree edits such as add, remove, reorder, and nesting stay in the existing block editor and should trigger refresh or resync when patching is not safe.
@@ -161,7 +161,7 @@ Section-level visibility:
161
161
  {
162
162
  type: "section",
163
163
  label: { en: "SEO" },
164
- hidden: ({ data }) => !data.isPublished,
164
+ hidden: ({ data }) => !data.showSeo,
165
165
  fields: [f.metaTitle, f.metaDescription],
166
166
  }
167
167
  ```
@@ -404,7 +404,7 @@ export const logs = collection("logs")
404
404
 
405
405
  ## Form Views and Live Preview
406
406
 
407
- Form views connect to the Live Preview V2 system when the collection has `.preview()` configured. The form editor becomes the source of `postMessage` patches every field change emits a patch through the bus, giving the preview iframe instant updates.
407
+ Form views connect to the existing Live Preview system when the collection has `.preview()` configured. Keep `v.collectionForm()` as the form surface; do not introduce a separate visual-edit form API, a second default form view, or parallel preview API names. The form editor remains the source of patches, refreshes, commits, and resyncs.
408
408
 
409
409
  ### Enabling Preview on a Collection
410
410
 
@@ -428,6 +428,22 @@ export const pages = collection("pages")
428
428
  ### How It Works
429
429
 
430
430
  1. The form view detects `.preview()` config and opens a split-screen layout
431
- 2. Save/autosave sends a `PREVIEW_REFRESH` message to the preview iframe
432
- 3. The preview page handles refreshes through `useCollectionPreview({ initialData, onRefresh })`
433
- 4. `PreviewProvider` and `PreviewField` wire field focus and click-to-focus messages
431
+ 2. The preview iframe mirrors form state with snapshot, patch, refresh, commit, and resync messages
432
+ 3. Save/autosave/Cmd+S/history/workflow/locks/actions stay in the form lifecycle
433
+ 4. The preview page uses `useCollectionPreview({ initialData, onRefresh })`
434
+ 5. `PreviewProvider`, `PreviewField`, and `BlockRenderer` wire field and block annotations
435
+
436
+ ### Frontend Preparation Checklist
437
+
438
+ Use this checklist before expecting visual editing to work:
439
+
440
+ 1. The collection has `.preview({ url })`.
441
+ 2. The page loader returns the complete rendered record shape.
442
+ 3. Public workflow reads use `stage: "published"`; if public client/HTTP access is enabled, anonymous read access also requires `input?.stage === "published"`. Preview/draft-mode reads load the working record for authorized editors.
443
+ 4. The page renderer calls `useCollectionPreview({ initialData, onRefresh })`.
444
+ 5. The rendered page is wrapped in `PreviewProvider preview={preview}`.
445
+ 6. UI reads from `preview.data`, not directly from loader data.
446
+ 7. Scalar text uses `PreviewField field="..." editable="text" | "textarea"` when inline editing is expected.
447
+ 8. Blocks render through `BlockRenderer` with `selectedBlockId` and `onBlockClick`.
448
+ 9. `BlockScopeProvider` is only needed for custom/manual block rendering outside `BlockRenderer`.
449
+ 10. Add/remove/reorder/nesting block operations stay in the existing block editor.
@@ -1,9 +1,18 @@
1
1
  import { QueryClient } from "@tanstack/react-query";
2
2
 
3
+ const ONE_MINUTE = 60 * 1000;
4
+ const FIVE_MINUTES = 5 * ONE_MINUTE;
5
+
3
6
  export const queryClient = new QueryClient({
4
7
  defaultOptions: {
5
8
  queries: {
6
- staleTime: 60 * 1000,
9
+ staleTime: ONE_MINUTE,
10
+ gcTime: FIVE_MINUTES,
11
+ refetchOnWindowFocus: false,
12
+ retry: 1,
13
+ },
14
+ mutations: {
15
+ retry: 0,
7
16
  },
8
17
  },
9
18
  });
@@ -1,17 +1,28 @@
1
1
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
2
+ import { useMemo } from "react";
2
3
 
3
4
  import { AdminRouter } from "@questpie/admin/client";
4
5
 
6
+ function createAdminNavigate(navigate: ReturnType<typeof useNavigate>) {
7
+ return (path: string) => {
8
+ void navigate({ to: path });
9
+ };
10
+ }
11
+
5
12
  function AdminCatchAll() {
6
13
  const navigate = useNavigate();
7
14
  const params = Route.useParams();
8
15
  const splat = params._splat as string;
16
+ const handleNavigate = useMemo(
17
+ () => createAdminNavigate(navigate),
18
+ [navigate],
19
+ );
9
20
  const segments = splat ? splat.split("/").filter(Boolean) : [];
10
21
 
11
22
  return (
12
23
  <AdminRouter
13
24
  segments={segments}
14
- navigate={(path) => navigate({ to: path })}
25
+ navigate={handleNavigate}
15
26
  basePath="/admin"
16
27
  />
17
28
  );
@@ -1,16 +1,23 @@
1
1
  import { createFileRoute, useNavigate } from "@tanstack/react-router";
2
+ import { useMemo } from "react";
2
3
 
3
4
  import { AdminRouter } from "@questpie/admin/client";
4
5
 
6
+ function createAdminNavigate(navigate: ReturnType<typeof useNavigate>) {
7
+ return (path: string) => {
8
+ void navigate({ to: path });
9
+ };
10
+ }
11
+
5
12
  function AdminDashboard() {
6
13
  const navigate = useNavigate();
14
+ const handleNavigate = useMemo(
15
+ () => createAdminNavigate(navigate),
16
+ [navigate],
17
+ );
7
18
 
8
19
  return (
9
- <AdminRouter
10
- segments={[]}
11
- navigate={(path) => navigate({ to: path })}
12
- basePath="/admin"
13
- />
20
+ <AdminRouter segments={[]} navigate={handleNavigate} basePath="/admin" />
14
21
  );
15
22
  }
16
23