@oneie/claude 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/agents/w1-recon.md +9 -4
  2. package/agents/w2-decide.md +51 -28
  3. package/agents/w3-edit.md +70 -78
  4. package/agents/w4-verify.md +164 -69
  5. package/commands/browser.md +98 -35
  6. package/commands/cc-connect.md +6 -0
  7. package/commands/chat.md +13 -1
  8. package/commands/close.md +45 -19
  9. package/commands/create.md +20 -10
  10. package/commands/db-sync.md +5 -1
  11. package/commands/deploy.md +174 -87
  12. package/commands/do-autonomous.md +31 -20
  13. package/commands/do-improve.md +4 -4
  14. package/commands/do-show.md +4 -3
  15. package/commands/do.md +53 -13
  16. package/commands/go.md +75 -0
  17. package/commands/improve.md +4 -2
  18. package/commands/kill.md +6 -8
  19. package/commands/notify.md +15 -2
  20. package/commands/oo-push.md +24 -9
  21. package/commands/rag.md +6 -0
  22. package/commands/release.md +46 -25
  23. package/commands/restart.md +5 -4
  24. package/commands/see.md +45 -35
  25. package/commands/skill-create.md +38 -5
  26. package/commands/sync.md +21 -11
  27. package/hooks/scripts/auto-continue.sh +70 -0
  28. package/hooks/scripts/branch-pin.sh +85 -0
  29. package/hooks/scripts/git-add-guard.sh +16 -5
  30. package/hooks/scripts/post-edit-check.sh +16 -0
  31. package/hooks/scripts/session-start.sh +13 -8
  32. package/hooks/scripts/sync-priority-todo.sh +5 -0
  33. package/package.json +1 -1
  34. package/rules/api.md +9 -0
  35. package/rules/design.md +13 -3
  36. package/rules/documentation.md +30 -71
  37. package/rules/engine.md +8 -1
  38. package/rules/ui.md +5 -1
  39. package/scripts/__pycache__/do-rank.cpython-313.pyc +0 -0
  40. package/scripts/browser-check.mjs +44 -103
  41. package/scripts/cc-events-proof.sh +13 -12
  42. package/scripts/chrome.mjs +381 -0
  43. package/scripts/do-analyze.sh +109 -4
  44. package/scripts/do-auto.sh +350 -43
  45. package/scripts/do-consumer-sweep.sh +72 -0
  46. package/scripts/do-derives-check.sh +88 -0
  47. package/scripts/do-fleet.sh +50 -1
  48. package/scripts/do-killswitch-audit.py +33 -14
  49. package/scripts/do-promise-lint.sh +150 -4
  50. package/scripts/do-promise-settle.sh +131 -9
  51. package/scripts/do-prove.sh +197 -17
  52. package/scripts/do-rank.py +606 -20
  53. package/scripts/do-rubric.py +21 -2
  54. package/scripts/do-signal.sh +157 -17
  55. package/scripts/do-smoke.sh +9 -0
  56. package/scripts/do-substrate-check.sh +49 -0
  57. package/scripts/do-tasks-wire-check.sh +81 -0
  58. package/scripts/do-tier.sh +90 -5
  59. package/scripts/do-ui-gate.sh +332 -0
  60. package/scripts/do-untracked-gate.sh +231 -0
  61. package/scripts/do-walk.sh +344 -0
  62. package/scripts/factory-check.sh +747 -0
  63. package/scripts/factory-repo.sh +1361 -0
  64. package/scripts/factory-tasks-check.sh +85 -0
  65. package/scripts/outcome-pull.ts +279 -0
  66. package/scripts/promise-manifest.mjs +167 -0
  67. package/scripts/reconcile-allow.txt +5 -0
  68. package/scripts/rubric-weights.json +64 -0
  69. package/scripts/spine-canary.sh +20 -0
  70. package/scripts/typedb-cluster-status.sh +128 -0
  71. package/scripts/typedb-env.sh +182 -0
  72. package/scripts/typedb-flap-recorder.sh +85 -0
  73. package/scripts/typedb-probes/containment-probe.py +88 -0
  74. package/scripts/typedb-probes/panic-probe-version.py +92 -0
  75. package/scripts/typedb-probes/panic-probe.py +77 -0
  76. package/scripts/typedb-scratch.sh +73 -0
  77. package/scripts/urls-lint.sh +58 -0
  78. package/scripts/w4-rubric.ts +96 -12
  79. package/scripts/wf-check.mjs +75 -0
  80. package/skills/ai-ui/SKILL.md +1 -1
  81. package/skills/astro/SKILL.md +177 -132
  82. package/skills/astro/add-content-collection.md +29 -13
  83. package/skills/astro/check-build.md +19 -13
  84. package/skills/astro/create-component.md +36 -9
  85. package/skills/astro/create-page.md +39 -10
  86. package/skills/astro/optimize-performance.md +36 -8
  87. package/skills/cli/SKILL.md +254 -220
  88. package/skills/dev/SKILL.md +1 -1
  89. package/skills/directory-autofill/SKILL.md +68 -0
  90. package/skills/docs/SKILL.md +4 -4
  91. package/skills/mcp/SKILL.md +186 -156
  92. package/skills/promise-make/SKILL.md +2 -2
  93. package/skills/puck/SKILL.md +465 -250
  94. package/skills/react19/SKILL.md +196 -296
  95. package/skills/reactflow/SKILL.md +387 -412
  96. package/skills/sdk/SKILL.md +167 -106
  97. package/skills/shadcn/SKILL.md +245 -201
  98. package/skills/signal/SKILL.md +5 -0
  99. package/skills/sui/SKILL.md +6 -1
  100. package/skills/tutorial/SKILL.md +15 -0
  101. package/skills/typedb/SKILL.md +427 -217
  102. package/skills/typedb/examples/python-patterns.py +11 -4
  103. package/skills/typedb/examples/query-patterns.tql +8 -3
  104. package/skills/typedb/examples/schema-patterns.tql +4 -2
  105. package/skills/typedb/reference/migration-2x-3x.md +19 -1
  106. package/skills/typedb/reference/python-driver.md +17 -1
  107. package/skills/voice/SKILL.md +117 -0
  108. package/skills/writer/SKILL.md +29 -12
  109. package/templates/template-feature.md +26 -1
  110. package/templates/template-tests.md +4 -0
  111. package/templates/template-todo.md +65 -10
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: puck
3
- description: Build with Puck — the drag-drop visual editor for ONE. Covers @puckeditor/core API, Config/Data types, block registries, PuckEditor (client:only) + PuckRenderer (SSR), iframe brand injection, data-bound blocks, email target mode, and the funnels-pages architecture.
3
+ description: Build with Puck — the drag-drop visual editor for ONE. Covers the @puckeditor/core API, Config/Data types, the block registry in lib/puck/, PuckEditor (client:only) + PuckRenderer (SSR), brand-token injection into the editor iframe, data-bound blocks via RecordPickerField, and the page/funnel storage columns. Use when adding or editing a Puck block, wiring a custom inspector field, debugging a block that renders in the editor but not on /p/, or changing how page data is stored.
4
4
  user-invocable: true
5
5
  allowed-tools: Read, Write, Edit, Glob, Grep, Bash
6
6
  ---
@@ -10,19 +10,22 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
10
10
  Puck is the drag-drop visual editor powering ONE's page system. Every landing page (`/p/`), funnel step (`/f/`), and eventually newsletter, product page, and course lesson is authored in one editor, stored as one typed JSON shape (Puck `Data`), and rendered by a small set of per-target renderers.
11
11
 
12
12
  **Architecture doc:** `text/funnels-pages-plan.md`
13
- **Repo:** `../apps/puckeditor/` (cloned from https://github.com/puckeditor/puck)
14
- **Package:** `@puckeditor/core` (^0.21.3, published on npm) — Puck moved from `@measured/puck`@0.20.x to the `@puckeditor` org; the funnels-pages doc set is synced to this name (2026-06-22). Source mirror: `../apps/puckeditor/`.
15
- **ONE block registry:** `one.ie/web/src/lib/puck/config.tsx`
13
+ **Source mirror:** `/Users/toc/Server/apps/puckeditor/` (cloned from https://github.com/puckeditor/puck) — read-only reference for upstream types
14
+ **Package:** `@puckeditor/core` — declared `^0.21.3`, installed **0.21.3**. Puck moved from `@measured/puck`@0.20.x to the `@puckeditor` org; the doc set is synced to this name.
15
+ **ONE block registry:** `one.ie/web/src/lib/puck/config.tsx` + its sibling `*-blocks.tsx` files
16
16
 
17
17
  ## Works With
18
18
 
19
19
  | Skill | Load when |
20
20
  |---|---|
21
21
  | `/astro` | The editor is a `client:only` island; the renderer runs SSR in `.astro` files |
22
- | `/react19` | Block `render` fns are React 19 components; use `use()` + `useTransition` for data-bound blocks |
23
- | `/shadcn` | Every block wraps a real shadcn component — brand tokens are inherited via the iframe injection |
22
+ | `/react19` | Block `render` fns are React 19 components |
23
+ | `/shadcn` | Every block wraps a real component — brand tokens reach the canvas via the iframe injection |
24
+ | `/reactflow` | `OrgChartView` and `WorkflowFlow` are lazy-loaded as blocks in `config.tsx` |
24
25
 
25
- Auto-loads with `*puck*` file patterns.
26
+ This skill is invoked explicitly, not by file glob. What *does* auto-load when you
27
+ edit a block file is `rules/react.md` (`**/*.tsx`) and `rules/design.md`
28
+ (`one.ie/web/**/*.{tsx,astro,css}`) — the 6-token ban list applies to every block.
26
29
 
27
30
  ---
28
31
 
@@ -45,36 +48,52 @@ import "@puckeditor/core/puck.css"; // in the PuckEditor component only
45
48
 
46
49
  A Puck config is the block registry: a map of component names to their field schema and render function.
47
50
 
51
+ In `config.tsx` the registry is not one literal — it is 12 block groups merged, then
52
+ wrapped, with categories derived from each block's `metadata.category`:
53
+
48
54
  ```ts
49
- import type { Config } from "@puckeditor/core";
50
-
51
- const config: Config = {
52
- // Optional: group palette by category
53
- categories: {
54
- Structural: { components: ["Heading", "Text", "Image", "Button", "Form", "Columns", "Spacer"] },
55
- CRO: { components: ["LandingHero", "ProofBar", "HowItWorks", "LandingFeatures", "Testimonials", "ComparisonTable", "PricingSection", "FAQSection", "SecondaryCTA"] },
56
- Funnel: { components: ["CheckoutBlock", "UpsellBlock"] },
57
- "Data-bound": { components: ["ProductBlock", "ListBlock"] },
58
- },
55
+ // one.ie/web/src/lib/puck/config.tsx the tail of the file
56
+ export const puckConfig: Config = {
57
+ components: withChrome({
58
+ ...blocks, ...dataBlocks, ...teamFlowBlocks, ...studioBlocks, ...appBlocks,
59
+ ...walletBlocks, ...socialBlocks, ...socialFormatBlocks, ...authBlocks,
60
+ ...aiBlocks, ...ecommerceNewBlocks, ...generalNewBlocks,
61
+ }),
62
+ categories: buildCategories(blocks, dataBlocks, teamFlowBlocks, studioBlocks,
63
+ appBlocks, walletBlocks, socialBlocks, socialFormatBlocks, authBlocks,
64
+ aiBlocks, ecommerceNewBlocks, generalNewBlocks),
65
+ };
66
+ ```
59
67
 
60
- components: {
61
- Heading: {
62
- label: "Heading",
63
- fields: {
64
- text: { type: "text" },
65
- level: { type: "select", options: [{ label: "H1", value: "h1" }, { label: "H2", value: "h2" }] },
66
- },
67
- defaultProps: { text: "Heading", level: "h1" },
68
- render: ({ text, level }) => {
69
- const Tag = level as "h1" | "h2";
70
- return <Tag className="font-bold tracking-tight">{text}</Tag>;
71
- },
72
- },
73
- // ... more blocks
68
+ `withChrome` and `buildCategories` are module-private helpers at the tail of
69
+ `config.tsx` (lines ~6732 and ~6776) — `withChrome` wraps every block's render
70
+ with the shared section chrome; `buildCategories` derives the palette groups from
71
+ each block's `metadata.category`.
72
+
73
+ Adding a block means adding a key to one of those groups never a new
74
+ top-level spread. A single block looks like this:
75
+
76
+ ```tsx
77
+ Heading: {
78
+ metadata: { surface: 'section', semantics: {}, ...web, category: 'content',
79
+ icon: 'Type', description: 'Section heading' },
80
+ fields: {
81
+ text: { type: 'text' as const },
82
+ level: { type: 'select' as const, options: [
83
+ { label: 'H1', value: 'h1' }, { label: 'H2', value: 'h2' },
84
+ ] },
74
85
  },
75
- };
86
+ defaultProps: { text: 'Heading', level: 'h1' },
87
+ render: ({ text, level }: { text: string; level: string }) => {
88
+ const Tag = level as 'h1' | 'h2';
89
+ return <Tag className="font-bold tracking-tight text-font">{text}</Tag>;
90
+ },
91
+ },
76
92
  ```
77
93
 
94
+ Note `as const` on every field `type` — without it TypeScript widens the literal
95
+ to `string` and the `Fields` type rejects the object.
96
+
78
97
  ### ComponentConfig anatomy
79
98
 
80
99
  ```ts
@@ -82,58 +101,82 @@ type ComponentConfig<Props> = {
82
101
  label?: string; // palette display name (defaults to component key)
83
102
  fields: Fields<Props>; // inspector form schema
84
103
  defaultProps?: Props; // initial values on drag-in
85
- render: (props: WithId<Props>) => JSX.Element; // the block renderer
86
- resolveData?: (data, params) => Promise<{props?>}>; // async data resolution (data-bound blocks)
104
+ render: (props: WithId<Props>) => JSX.Element; // the block renderer
105
+ metadata?: Record<string, any>; // ONE's extension point see below
106
+ resolveData?: (data, params) => Promise<{ props?: Partial<Props> }>;
87
107
  resolveFields?: (data, params) => Promise<Fields<Props>>; // dynamic inspector fields
88
- permissions?: { drag?, duplicate?, delete?, insert?, move? }; // per-block permission overrides
108
+ permissions?: { drag?; duplicate?; delete?; insert?; move? };
89
109
  inline?: boolean; // renders inline (no wrapper div)
90
110
  };
91
111
  ```
92
112
 
113
+ The 0.21 type is **strict about sibling keys** — anything ONE-specific must go
114
+ under `metadata`, never beside `fields`. `metadata` in this repo carries
115
+ `category`, `icon`, `description`, `preview`, `surface`, `semantics`, and
116
+ `targets`; `buildBlockIndex` (`lib/puck/block-index.ts`) reads exactly those, with
117
+ fallbacks `category → 'content'`, `icon → 'Square'`, `description → ''`.
118
+
93
119
  ---
94
120
 
95
121
  ## Field types
96
122
 
97
- The inspector is driven by `fields` in each block's config. ONE uses these field types:
123
+ The inspector is driven by `fields` in each block's config. Counts below are live
124
+ occurrences in `config.tsx` — they tell you which shapes are load-bearing here:
98
125
 
99
- | Type | Usage |
100
- |---|---|
101
- | `{ type: "text" }` | Single-line text input; `contentEditable?: true` for inline editing on canvas |
102
- | `{ type: "textarea" }` | Multi-line text |
103
- | `{ type: "number", min?, max?, step? }` | Numeric |
104
- | `{ type: "richtext" }` | Rich text with Tiptap; `contentEditable?: true` |
105
- | `{ type: "select", options: [{label, value}] }` | Dropdown |
106
- | `{ type: "radio", options: [{label, value}] }` | Radio group |
107
- | `{ type: "array", arrayFields: {…}, getItemSummary?, min?, max? }` | Repeating sub-items (e.g. list of CTA buttons) |
108
- | `{ type: "object", objectFields: {…} }` | Nested group |
109
- | `{ type: "external", fetchList, mapProp?, showSearch?, filterFields? }` | The data-bound picker (products, agents) |
110
- | `{ type: "custom", render: ({value, onChange}) => JSX }` | Fully custom inspector widget |
111
- | `{ type: "slot" }` | A droppable zone inside a block (DropZone) |
112
-
113
- ### External field — the data-bound picker
114
-
115
- Used for `ProductBlock`, `ListBlock`, and any block that binds to live workspace data:
126
+ | Type | Uses | Usage |
127
+ |---|---|---|
128
+ | `{ type: "text" }` | 421 | Single-line text input; `contentEditable?: true` for inline editing on canvas |
129
+ | `{ type: "radio", options: [{label, value}] }` | 111 | Radio group — the repo's boolean toggle |
130
+ | `{ type: "textarea" }` | 74 | Multi-line text |
131
+ | `{ type: "array", arrayFields: {…}, getItemSummary?, min?, max? }` | 50 | Repeating sub-items (feature rows, CTA lists) |
132
+ | `{ type: "number", min?, max?, step? }` | 27 | Numeric |
133
+ | `{ type: "select", options: [{label, value}] }` | 24 | Dropdown |
134
+ | `{ type: "custom", render: ({value, onChange}) => JSX }` | 9 | Custom inspector widget **how ONE does every picker** |
135
+ | `{ type: "slot" }` | 7 | A droppable zone inside a block |
136
+ | `{ type: "object", objectFields: {…} }` | 3 | Nested group |
137
+ | `{ type: "external", fetchList, mapProp?, showSearch? }` | 2 | Puck's built-in remote picker — rarely used here |
138
+ | `{ type: "richtext" }` | 1 | Rich text with Tiptap |
139
+
140
+ ### Custom field — how ONE builds a picker
141
+
142
+ ONE does **not** use Puck's `external` field for workspace data. Pickers are
143
+ `type: 'custom'` wrapping a lazy-loaded React field, so the field's browser deps
144
+ stay out of the SSR worker bundle. `config.tsx` lazy-imports four of them:
145
+ `RecordPickerField`, `TypeBindingField`, `ViewQueryField`, `BrandColorField`.
116
146
 
117
- ```ts
118
- import type { ExternalField } from "@puckeditor/core";
119
-
120
- const productPickerField: ExternalField = {
121
- type: "external",
122
- placeholder: "Search products…",
123
- showSearch: true,
124
- fetchList: async ({ query }) => {
125
- const res = await fetch(`/api/ask`, {
126
- method: "POST",
127
- body: JSON.stringify({ receiver: "products:list", data: { query } }),
128
- });
129
- const { result } = await res.json();
130
- return result?.items ?? [];
131
- },
132
- mapProp: (item) => ({ productId: item.id, name: item.name, price: item.price }),
133
- mapRow: (item) => ({ Name: item.name, Price: `$${item.price}` }),
134
- };
147
+ ```tsx
148
+ const RecordPickerField = lazy(() =>
149
+ import('@/components/puck/RecordPickerField').then((m) => ({ default: m.RecordPickerField }))
150
+ );
151
+
152
+ // inside a block's fields:
153
+ productId: {
154
+ type: 'custom' as const,
155
+ render: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
156
+ <Suspense
157
+ fallback={
158
+ <input
159
+ type="text"
160
+ value={value ?? ''}
161
+ onChange={(e) => onChange(e.target.value)}
162
+ style={{ width: '100%', padding: '4px' }}
163
+ />
164
+ }
165
+ >
166
+ <RecordPickerField value={value ?? ''} onChange={onChange} />
167
+ </Suspense>
168
+ ),
169
+ },
135
170
  ```
136
171
 
172
+ The `Suspense` fallback must be a working plain input — the editor is usable
173
+ while the field chunk loads.
174
+
175
+ Server-side, `lib/puck/pickable-records.ts` turns a bound resolver's list result
176
+ into flat `{ id, label }[]` for the picker. It resolves the receiver from the
177
+ binding (`boundReceiver(rt, 'list')`), calls it with a **public** caller context,
178
+ and reads the plural key by convention — `'product'` → `result.products`.
179
+
137
180
  ---
138
181
 
139
182
  ## `Data` shape (the stored IR)
@@ -148,13 +191,28 @@ type Data = {
148
191
  };
149
192
 
150
193
  // An empty page
151
- const emptyData: Data = { root: { props: {} }, content: [] };
194
+ const emptyData: Data = { root: {}, content: [] };
152
195
  ```
153
196
 
197
+ `root` is `{}` in every value this repo constructs (`migrate.ts`, `normalize.ts`,
198
+ `templates.ts`) — not `{ props: {} }`. Both satisfy the type; match the repo.
199
+
154
200
  **Where ONE stores it:**
155
201
  - Landing pages: `pages.data` column (D1) — row written by `pages:create` / `pages:edit`
156
202
  - Funnel steps: `funnel_definitions.steps[N].config.puck` (JSON column)
157
- - Newsletters (sweep C7): `broadcast.data` column
203
+
204
+ ### normalizePuckData — always run it on load
205
+
206
+ Puck merges `defaultProps` at **render** time only; it never writes them back into
207
+ the data. A block loaded from storage with sparse props renders its defaults on
208
+ the canvas but shows *empty* inspector fields. `lib/puck/normalize.ts` closes that
209
+ gap — stored props always win over defaults:
210
+
211
+ ```ts
212
+ import { normalizePuckData } from '@/lib/puck/normalize';
213
+
214
+ const data = normalizePuckData(JSON.parse(row.data));
215
+ ```
158
216
 
159
217
  ---
160
218
 
@@ -163,74 +221,82 @@ const emptyData: Data = { root: { props: {} }, content: [] };
163
221
  The editor must be `client:only` because Puck requires browser APIs. Use the `Puck` component from `@puckeditor/core`:
164
222
 
165
223
  ```tsx
166
- // one.ie/web/src/components/puck/PuckEditor.tsx
167
- "use client"; // Astro will ignore this; the client:only directive handles it
168
- import { Puck } from "@puckeditor/core";
169
- import "@puckeditor/core/puck.css";
170
- import { puckConfig } from "@/lib/puck/config";
171
- import type { Data } from "@puckeditor/core";
224
+ // one.ie/web/src/components/puck/PuckEditor.tsx — the real imports
225
+ // @ts-ignore puck.css has no type declarations but the import is valid at runtime
226
+ import '@puckeditor/core/puck.css'
227
+ import './puck-theme.css'
228
+ import { Puck, usePuck, type Data } from '@puckeditor/core'
229
+ import { puckConfig } from '@/lib/puck/config'
230
+ import { normalizePuckData } from '@/lib/puck/normalize'
231
+ import { computeStyleBlock, type SiteToken } from '@/lib/site'
232
+ ```
172
233
 
173
- interface Props {
174
- initialData: Partial<Data>;
175
- onPublish: (data: Data) => Promise<void>;
176
- }
234
+ The real component takes ~24 props, not two — `data`, `siteTokens`,
235
+ `siteDarkTokens`, `onChange`, `onPublish`, `workspace`, `pageSlug`, `title`,
236
+ `pageStatus`, `saved`, `error`, `publishing`, `customDomain`, `target = 'web'`,
237
+ and the toolbar/drawer callbacks. Read the `interface Props` before adding one.
177
238
 
178
- export function PuckEditor({ initialData, onPublish }: Props) {
179
- return (
180
- <Puck
181
- config={puckConfig}
182
- data={initialData}
183
- onPublish={onPublish}
184
- // Optional: inject brand tokens into the editor iframe
185
- iframe={{ enabled: true }}
186
- />
187
- );
188
- }
189
- ```
239
+ Mounted in Astro as `client:only="react"` Puck needs browser APIs and cannot
240
+ SSR:
190
241
 
191
- Mounted in Astro as:
192
242
  ```astro
193
- <PuckEditor client:only="react" initialData={data} onPublish={handlePublish} />
243
+ <PuckEditor client:only="react" data={data} siteTokens={tokens} pageSlug={slug} />
194
244
  ```
195
245
 
196
246
  ### Brand injection into the iframe
197
247
 
198
- The editor canvas runs in a sandboxed `<iframe>` — Tailwind 4 globals and workspace CSS tokens do **not** cascade in by default. Inject them via `computeStyleBlock` from `lib/site.ts`:
248
+ The editor canvas runs in a sandboxed `<iframe>` — Tailwind 4 globals and the
249
+ workspace's 6 brand tokens do **not** cascade in. Puck's `overrides.iframe`
250
+ receives the live `contentDocument`, which is the first-class injection point.
251
+ This is shipped in `PuckEditor.tsx`:
199
252
 
200
253
  ```tsx
201
- import { useEffect } from "react";
202
- import { computeStyleBlock } from "@/lib/site"; // (tokens, darkTokens?, font?) → CSS rule text (:root{--color-*}), goes in a <style>.textContent (site.ts:59)
203
- import { Puck } from "@puckeditor/core";
204
-
205
- // VERIFIED against @puckeditor/core@0.21 source: the `iframe` OVERRIDE receives the real
206
- // iframe contentDocument (AutoFrame/index.tsx:420). This is the first-class injection API —
207
- // no portal, no hack. `overrides.iframe: RenderFunc<{ children; document? }>`.
208
- const overrides = {
209
- iframe: ({ children, document }: { children: React.ReactNode; document?: Document }) => {
210
- useEffect(() => {
211
- if (!document?.head) return;
212
- const el = document.createElement("style");
213
- el.setAttribute("data-one-brand", "");
214
- el.textContent = computeStyleBlock(tokens, darkTokens, font); // tokens/darkTokens: Partial<Record<SiteToken,string>>, font?: string — passed into PuckEditor as workspace-branding props
215
- document.head.appendChild(el);
216
- return () => el.remove();
217
- }, [document]);
218
- return <>{children}</>;
219
- },
220
- };
221
-
222
254
  <Puck
223
255
  config={puckConfig}
224
- data={initialData}
225
- onPublish={onPublish}
226
- iframe={{ enabled: true }} // IframeConfig: { enabled?, waitForStyles?, syncHostStyles? }
227
- overrides={overrides}
256
+ data={normalizePuckData(data)}
257
+ overrides={{
258
+ iframe: ({ children, document: iframeDoc }: {
259
+ children: React.ReactNode;
260
+ document?: Document;
261
+ }) => {
262
+ // Inject the workspace's 6-token brand CSS into the editor iframe so the
263
+ // canvas paints in the tenant's colors, not the platform default.
264
+ useEffect(() => {
265
+ if (!iframeDoc || !siteTokens) return;
266
+ const style = iframeDoc.createElement('style');
267
+ style.textContent = computeStyleBlock(siteTokens);
268
+ iframeDoc.head.appendChild(style);
269
+ return () => style.remove();
270
+ }, [iframeDoc]);
271
+ return <>{children}</>;
272
+ },
273
+ }}
228
274
  />
229
275
  ```
230
276
 
231
- **Pre-mortem guard (verified step, not an open risk):** the `iframe` override is the documented injection point (`apps/puckeditor/apps/docs/.../overrides/iframe.mdx` — "useful for injecting styles"). W4 asserts a `<style data-one-brand>` lands in the iframe `<head>` and a `--color-*` var resolves inside the canvas. First confirm `computeStyleBlock`'s real arg signature at `site.ts:59`.
277
+ Rename the destructured `document` — shadowing the global inside a React
278
+ component breaks any sibling code that expects the real one.
279
+
280
+ `computeStyleBlock` (`lib/site.ts:59`) has the signature:
281
+
282
+ ```ts
283
+ computeStyleBlock(
284
+ tokens: Partial<Record<SiteToken, string>>,
285
+ darkTokens: Partial<Record<SiteToken, string>> = {},
286
+ font?: string,
287
+ ): string
288
+ ```
289
+
290
+ It emits `:root:not(.dark) { … }` and `:root.dark { … }` rules — both at
291
+ specificity (0,2,0), so they beat `Layout.astro`'s `:root` / `html.dark` defaults
292
+ regardless of where the bundler places them, and stored light values can never
293
+ leak into dark mode. It also derives `--color-on-{primary,secondary,tertiary}`
294
+ contrast labels for each brand fill. `PuckEditor` calls it with one argument.
232
295
 
233
- **Tailwind 4 guard:** Tailwind 4 uses `--color-*: initial` wipes at the `:root` level. Scope `@puckeditor/core/puck.css` to the editor chrome class only (e.g. `.puck-editor`). The renderer must import **no** Puck CSS.
296
+ **Tailwind 4 guard:** `Layout.astro` declares `--color-*: initial`, wiping the
297
+ default palette. Puck's own CSS is scoped by importing it *only* in
298
+ `PuckEditor.tsx` (a `client:only` island) alongside the local `puck-theme.css`
299
+ overrides. The renderer imports **no** Puck CSS.
234
300
 
235
301
  ---
236
302
 
@@ -252,21 +318,25 @@ Note the column split: the channels `edit_page`/`create_page` tools write the `p
252
318
  No editor chrome, no editor JS shipped to visitors. Pure server-side render via `<Render>`:
253
319
 
254
320
  ```tsx
255
- // one.ie/web/src/components/puck/PuckRenderer.tsx
256
- import { Render } from "@puckeditor/core";
257
- import { puckConfig } from "@/lib/puck/config";
258
- import type { Data } from "@puckeditor/core";
321
+ // one.ie/web/src/components/puck/PuckRenderer.tsx — the whole file
322
+ // SSR-safe. NO puck.css import (pulls editor chrome into SSR bundle).
323
+ // NO import of PuckEditor (client:only). Renders published Puck data server-side.
324
+ import { Render, type Data } from '@puckeditor/core'
325
+ import { puckConfig } from '@/lib/puck/config'
259
326
 
260
327
  interface Props {
261
- data: Data;
328
+ data: Data
262
329
  }
263
330
 
264
331
  export function PuckRenderer({ data }: Props) {
265
- // Render is fully SSR-safe no browser APIs, no editor state
266
- return <Render config={puckConfig} data={data} />;
332
+ if (!data || !data.content || data.content.length === 0) return null
333
+ return <Render config={puckConfig} data={data} />
267
334
  }
268
335
  ```
269
336
 
337
+ The empty-content guard matters: `<Render>` on an empty `content` array emits a
338
+ bare wrapper div that breaks page layout. Return `null` and let the caller decide.
339
+
270
340
  Used in Astro routes:
271
341
  ```astro
272
342
  ---
@@ -286,115 +356,219 @@ const data = JSON.parse(page.data); // from D1
286
356
 
287
357
  ## ONE's block registry (`lib/puck/config.tsx`)
288
358
 
289
- All blocks live in one export: `puckConfig`. The file declares:
290
- - **7 structural blocks:** `Heading`, `Text`, `Image`, `Button`, `Form`, `Columns`, `Spacer`
291
- - **9 CRO blocks (migrated 1:1):** `LandingHero`, `ProofBar`, `HowItWorks`, `LandingFeatures`, `Testimonials`, `ComparisonTable`, `PricingSection`, `FAQSection`, `SecondaryCTA`
292
- - **2 funnel widgets:** `CheckoutBlock` (wraps `PayPanel`), `UpsellBlock`
293
- - **2 data-bound blocks (sweep):** `ProductBlock`, `ListBlock`
359
+ All blocks reach the editor through one export: `puckConfig`. The groups it merges:
360
+
361
+ > **No counts here on purpose.** Every number this section ever carried went stale
362
+ > within days the registry grows constantly. To count, read `puckConfig.components`;
363
+ > `buildBlockIndex` gives you the list without loading `config.tsx`.
364
+
365
+ | Group | Declared in |
366
+ |---|---|
367
+ | `blocks` | `config.tsx` — structural, CRO, funnel, media, layout |
368
+ | `generalNewBlocks` | `general-{a,b,c}-blocks.tsx` |
369
+ | `ecommerceNewBlocks` | `ecommerce-{discover,evaluate-a,evaluate-b,persuade-a,persuade-b,convert,support}-blocks.tsx` |
370
+ | `socialFormatBlocks` | `social-format-blocks.tsx` |
371
+ | `socialBlocks` | `config.tsx` |
372
+ | `authBlocks` | `config.tsx` |
373
+ | `dataBlocks` | `config.tsx` |
374
+ | `appBlocks` | `config.tsx` |
375
+ | `studioBlocks` | `config.tsx` |
376
+ | `aiBlocks` | `ai-blocks.tsx` |
377
+ | `walletBlocks` | `config.tsx` |
378
+ | `teamFlowBlocks` | `config.tsx` |
379
+
380
+ Two subsets are pinned by `tests/unit/puck/config.test.ts` and must never
381
+ regress — the **7 structural** blocks (`Heading`, `Text`, `Image`, `Button`,
382
+ `Form`, `Columns`, `Spacer`) and the **9 CRO** blocks (`LandingHero`, `ProofBar`,
383
+ `HowItWorks`, `LandingFeatures`, `Testimonials`, `ComparisonTable`,
384
+ `PricingSection`, `FAQSection`, `SecondaryCTA`). The test asserts each is defined,
385
+ has a `render` function, and carries `metadata.targets` containing `'web'`.
386
+
387
+ For a live count or a palette search, use `buildBlockIndex(puckConfig)` from
388
+ `lib/puck/block-index.ts` — a pure function with no DOM or Puck-runtime import,
389
+ so it is trivially testable.
294
390
 
295
391
  Each CRO block wraps the existing component from `one.ie/web/src/components/cro/`:
296
392
 
297
393
  ```tsx
298
394
  import { LandingHero as LandingHeroComponent } from "@/components/cro/LandingHero";
299
- import type { Config } from "@puckeditor/core";
395
+ import { bgTokenField, bgPatternField } from "@/lib/puck/bg-fields";
300
396
 
301
- // In puckConfig.components:
397
+ // In one of the 12 block groups:
302
398
  LandingHero: {
303
- label: "Landing Hero",
399
+ metadata: { surface: 'section', semantics: {}, ...web, category: 'cro',
400
+ icon: 'Rocket', description: 'Hero with headline and dual CTA' },
304
401
  fields: {
305
- headline: { type: "text" },
306
- highlight: { type: "text" },
307
- subhead: { type: "text" },
308
- eyebrow: { type: "text" },
309
- frictionText: { type: "text" },
402
+ bgToken: bgTokenField,
403
+ bgPattern: bgPatternField,
404
+ headline: { type: 'text' as const },
405
+ highlight: { type: 'text' as const },
406
+ subhead: { type: 'text' as const },
407
+ eyebrow: { type: 'text' as const },
408
+ frictionText: { type: 'text' as const },
310
409
  primaryCta: {
311
- type: "object",
410
+ type: 'object' as const,
312
411
  objectFields: {
313
- label: { type: "text" },
314
- href: { type: "text" },
315
- }
412
+ label: { type: 'text' as const },
413
+ href: { type: 'text' as const },
414
+ },
316
415
  },
317
416
  secondaryCta: {
318
- type: "object",
417
+ type: 'object' as const,
319
418
  objectFields: {
320
- label: { type: "text" },
321
- href: { type: "text" },
322
- }
419
+ label: { type: 'text' as const },
420
+ href: { type: 'text' as const },
421
+ },
323
422
  },
324
423
  },
325
424
  defaultProps: {
326
- headline: "Your headline here",
327
- subhead: "Your sub-headline here",
328
- primaryCta: { label: "Get started", href: "#" },
425
+ headline: 'Your headline here',
426
+ subhead: 'Your sub-headline here',
427
+ primaryCta: { label: 'Get started', href: '#' },
329
428
  },
330
429
  render: (props) => <LandingHeroComponent {...props} />,
331
430
  },
332
431
  ```
333
432
 
334
- **Reuse contract:** CRO components are **never reimplemented** inside the block — they are always imported and wrapped. `wc -l` of `config.tsx` must stay reasonable; blocks are thin wrappers.
433
+ `bgTokenField` and `bgPatternField` (`lib/puck/bg-fields.tsx`) are the shared
434
+ background-token and pattern fields every themeable block reuses — import them,
435
+ never redeclare a background field inline.
436
+
437
+ **Reuse contract:** CRO components are **never reimplemented** inside the block —
438
+ they are always imported and wrapped. Blocks are thin wrappers; the component
439
+ lives in `components/cro/`, `components/booking/`, `components/auth/`, etc.
335
440
 
336
- ### Per-block target field (planned, C1 stubs it)
441
+ ### Per-block target `metadata.targets`
337
442
 
338
- Each block declares which render targets it supports. This gates the email palette in C7:
443
+ Each block declares which render targets it supports, which is what filters the
444
+ palette when the editor runs in a non-web `target` mode. This is shipped, and
445
+ `config.test.ts` asserts every structural and CRO block carries `'web'`.
339
446
 
340
447
  ```ts
341
- // VERIFIED: Puck's ComponentConfig REJECTS unknown sibling keys (0.21 type is strict —
448
+ // Puck's ComponentConfig REJECTS unknown sibling keys (the 0.21 type is strict —
342
449
  // render/label/defaultProps/fields/permissions/inline/resolveFields/resolveData/metadata).
343
- // `targets` is NOT a bare sibling — it lives under the supported `metadata` key:
344
- const block = {
345
- label: "Heading",
346
- metadata: { targets: ["web"] }, // future: ["web", "email"] for email-capable blocks
347
- fields: { … },
450
+ // `targets` is NOT a bare sibling — it lives under the supported `metadata` key.
451
+ // config.tsx spreads a shared `web` const rather than repeating the literal:
452
+ const web = { targets: ['web'] };
453
+
454
+ Heading: {
455
+ // `surface` and `semantics` are not optional — see § The chrome contract.
456
+ metadata: { surface: 'atomic', semantics: { heading: 'text' }, ...web,
457
+ category: 'content', icon: 'Type', description: 'Section heading' },
458
+ fields: { /* … */ },
348
459
  render: (props) => <…/>,
349
460
  };
350
461
  ```
351
462
 
352
- The editor reads `block.metadata.targets` when filtering the palette for `target:'email'` mode (newsletter composer). `metadata` is the supported channel for any ONE-specific block flag — never add bare keys to a Puck component config.
463
+ `metadata` is the supported channel for any ONE-specific block flag — never add
464
+ bare keys to a Puck component config.
465
+
466
+ ### The chrome contract (every block, no exceptions)
467
+
468
+ `tests/unit/blocks-enhance.test.ts` iterates `puckConfig` and fails the build if a
469
+ block skips either key. It is the reason a new block gets themeable for free.
470
+
471
+ **`surface`** — how much chrome the block may carry:
472
+
473
+ | Surface | Carries |
474
+ |---|---|
475
+ | `section` | background colour + texture + auto-contrast |
476
+ | `container` | background colour + auto-contrast; texture optional |
477
+ | `atomic` | text colour only — a fill on a Spacer is a bug |
478
+ | `social-preview` | images only, **never** a brand fill (a tweet card painted `--color-tertiary` stops being a tweet card) |
479
+ | `app` | images only |
480
+
481
+ A `section` must expose both `bgToken: bgTokenField` and `bgPattern: bgPatternField`;
482
+ a `container` must expose `bgToken`. `withChrome` paints them — do not read `bgToken`
483
+ in your render unless you also add the block to `SELF_CHROME`.
484
+
485
+ **`semantics`** — maps canonical roles to this block's own prop names,
486
+ `{ heading, body, cta, image }`. It exists because `genericEmailNode` was guessing
487
+ across ~20 aliases. Name only props that exist; a path (`testimonials[].avatar`) is
488
+ fine. `{}` is the honest answer for a Spacer.
489
+
490
+ **Contrast is never re-derived.** Import from `lib/puck/surface-chrome.ts`
491
+ (`surfaceChrome`, `onText`, `ON_TEXT`). The `on-*` classes must be **static strings** —
492
+ `text-on-${token}` compiles to nothing, which is exactly how text ends up the same
493
+ colour as the fill beneath it.
494
+
495
+ **Image fields** use the shared `imageSrcField` (`lib/puck/image-field.tsx`) so the
496
+ media library and R2 upload come for free. `src` means an image in `Image`,
497
+ `ImageBlock`, `PortfolioSection`, `LogoGridBlock`; in `VideoEmbed` and `MapEmbed` it is
498
+ an embed URL and stays a text box.
499
+
500
+ **Your defaultProps must render.** `blocks-enhance-ssr.test.ts` renders every fillable
501
+ block through `PuckRenderer` on a brand fill, with no exclusions — a block that crashes
502
+ on its own defaults fails there.
353
503
 
354
504
  ---
355
505
 
356
- ## Data-bound blocks (`resolveData`)
506
+ ## Data-bound blocks (`resolve-bindings`, not `resolveData`)
507
+
508
+ ONE does **not** use Puck's `resolveData` hook. A data-bound block stores only an
509
+ **id**, and `resolvePageBindings` (`lib/puck/resolve-bindings.ts`) resolves it
510
+ server-side before render, injecting the live record as `_resolved`. The block's
511
+ `render` stays synchronous and never fetches.
357
512
 
358
- A block that renders live workspace data (products, lists) uses Puck's `resolveData` hook to fetch on insert/load:
513
+ Three moving parts:
514
+
515
+ 1. **The field** stores the id — `type: 'custom'` + `RecordPickerField` (see
516
+ § Custom field above).
517
+ 2. **`resolve-bindings.ts`** walks `data.content` server-side, looks the id up
518
+ through the workspace's binding manifest, and writes `_resolved` into props.
519
+ 3. **`render`** reads `_resolved`, with the bare id as the fallback stub.
359
520
 
360
521
  ```tsx
522
+ import type { PriceRow } from '@/lib/storefront'
523
+
524
+ // ProductBlock is data-bound. resolvePageBindings resolves `productId` through the
525
+ // commerce binding server-side and injects the live product as `_resolved`; absent
526
+ // it (unbound id, or not published) the block falls back to the id stub.
361
527
  ProductBlock: {
362
- label: "Product",
528
+ metadata: { surface: 'section', semantics: {}, ...web, category: 'ecommerce',
529
+ icon: 'Package', description: 'Data-bound product display' },
363
530
  fields: {
364
- product: {
365
- type: "external",
366
- placeholder: "Pick a product…",
367
- showSearch: true,
368
- fetchList: async ({ query }) => {
369
- const r = await fetch("/api/ask", {
370
- method: "POST",
371
- body: JSON.stringify({ receiver: "products:list", data: { query, limit: 20 } }),
372
- });
373
- const { result } = await r.json();
374
- return result?.items ?? [];
375
- },
376
- mapProp: (item) => item, // store the whole record
531
+ bgToken: bgTokenField,
532
+ bgPattern: bgPatternField,
533
+ productId: {
534
+ type: 'custom' as const,
535
+ render: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
536
+ <Suspense fallback={<input type="text" value={value ?? ''}
537
+ onChange={(e) => onChange(e.target.value)} style={{ width: '100%', padding: '4px' }} />}>
538
+ <RecordPickerField value={value ?? ''} onChange={onChange} />
539
+ </Suspense>
540
+ ),
377
541
  },
378
- showPrice: { type: "radio", options: [{ label: "Yes", value: true }, { label: "No", value: false }] },
379
- showButton: { type: "radio", options: [{ label: "Yes", value: true }, { label: "No", value: false }] },
380
542
  },
381
- defaultProps: { product: null, showPrice: true, showButton: true },
382
- render: ({ product, showPrice, showButton }) => {
383
- if (!product) return <div className="text-muted-foreground text-sm p-4">Pick a product in the inspector.</div>;
543
+ defaultProps: { productId: '' },
544
+ render: ({ productId, _resolved }: {
545
+ productId: string
546
+ _resolved?: { name: string; description: string | null; images: string; prices: PriceRow[] }
547
+ }) => {
548
+ if (!_resolved) return <div className="text-font/60 text-sm p-4">Pick a product in the inspector.</div>;
384
549
  return (
385
- <div className="rounded-lg border p-6">
386
- <h3 className="font-semibold">{product.name}</h3>
387
- {showPrice && <p className="text-font/60">${product.price}</p>}
388
- {showButton && <button className="btn-primary mt-4">Buy</button>}
550
+ <div className="rounded-lg border p-6" style={{ borderColor: 'var(--color-border)' }}>
551
+ <h3 className="font-semibold text-font">{_resolved.name}</h3>
552
+ <p className="text-font/60">{_resolved.description}</p>
389
553
  </div>
390
554
  );
391
555
  },
392
556
  },
393
557
  ```
394
558
 
395
- > **C6 dependency:** `products:get` (read one product by ID) does not exist yet — only `products:list`. Until C6 lands, `ProductBlock` falls back to `products:list` + local filter. Never make N+1 list calls per block.
396
- >
397
- > **⚠ Shape correction (verified against `lib/resolvers/commerce.ts:73`):** the `fetchList` examples above are illustrative but wrong on the wire shape. `products:list` takes **`{ slug }`** (the workspace) and returns **`{ products: ProductWithPrices[] }`** — NOT `{ query }` / `{ items }`. There is **no server-side `query` filter** yet, so fetch the workspace's products once and filter client-side. Fix before C6: `const { result } = await r.json(); return (result?.products ?? []).filter(p => p.name.toLowerCase().includes(query.toLowerCase()))`.
559
+ `RecordBlock` is the generic counterpart: any manifest-declared bound type renders
560
+ through it, keyed by `typeKey` (matching the manifest entry) plus `recordId`.
561
+
562
+ **Wire shapes** (`lib/resolvers/commerce.ts`) — both receivers exist:
563
+
564
+ | Receiver | In | Out |
565
+ |---|---|---|
566
+ | `products:list` | `{ slug }` | `{ products: ProductWithPrices[] }` |
567
+ | `products:get` | `{ slug, pid }` | `{ product: ProductWithPrices \| null }` |
568
+
569
+ There is no server-side `query` filter on `products:list` — the picker fetches the
570
+ workspace's products once and filters client-side. Never make an N+1 call per block:
571
+ resolution is one server-side pass over the whole page, not one fetch per block.
398
572
 
399
573
  ---
400
574
 
@@ -417,73 +591,101 @@ function MyCustomField({ value, onChange }) {
417
591
 
418
592
  ## Migration (`components[]` → Puck `Data`)
419
593
 
420
- C3 migrates existing `pages.components` rows (the old `{component, props}[]` shape) to Puck `Data`. The transform is 1:1 because block `type` names match the old `component` names exactly:
594
+ `pages.components` (the legacy `PageSection[]` shape) converts to Puck `Data` 1:1,
595
+ because block `type` names match the old `component` names exactly. One source of
596
+ truth, used on both the write path (`resolvers/pages.ts` dual-write) and the read
597
+ path (`/p/[slug].astro` lazy-migrate):
421
598
 
422
599
  ```ts
423
- // one.ie/web/src/lib/puck/migrate.ts
424
- import type { Data } from "@puckeditor/core";
425
-
426
- type LegacySection = { component: string; props: Record<string, unknown> };
600
+ // one.ie/web/src/lib/puck/migrate.ts — the whole file
601
+ import type { Data } from '@puckeditor/core'
602
+ import type { PageSection } from '@/lib/pages'
427
603
 
428
- export function componentsToPuck(sections: LegacySection[]): Data {
604
+ export function componentsToPuck(sections: PageSection[]): Data {
429
605
  return {
430
- root: { props: {} },
431
606
  content: sections.map((s, i) => ({
432
- type: s.component, // e.g. "LandingHero" — 1:1 name match
433
- props: { ...s.props, id: `migrated-${i}` },
607
+ type: s.component,
608
+ props: { ...s.props, id: `${s.component}-${i}` },
434
609
  })),
435
- };
610
+ root: {},
611
+ } as unknown as Data
436
612
  }
437
613
  ```
438
614
 
439
- **Migration gate (C3 W4):** row-count parity `pages` rows must equal rows with non-null `pages.data` before deleting `PageRenderer.tsx`. The `components` column is kept for one release as a rollback.
615
+ Two details that are easy to get wrong: the id is `${s.component}-${i}`
616
+ (deterministic per position, so a re-migration is idempotent), and the `as unknown
617
+ as Data` cast is deliberate — `ComponentData` requires an `id` that the mapped
618
+ literal cannot prove structurally.
619
+
620
+ Both columns still exist. The chat tools (`edit_page` / `create_page`) write
621
+ `pages.components`; manual Puck edits write `pages.data`.
440
622
 
441
623
  ---
442
624
 
443
- ## Starter templates (C5)
625
+ ## Starter templates
444
626
 
445
- Seed `Data` objects that make the first page one click. Store in `lib/puck/templates.ts`:
627
+ Seed `Data` skeletons that make the first page one click, in
628
+ `lib/puck/templates.ts`. The export is **`funnelTemplates: PuckTemplate[]`** — an
629
+ array, not a keyed record:
446
630
 
447
631
  ```ts
448
- import type { Data } from "@puckeditor/core";
449
-
450
- export const OPT_IN_TEMPLATE: Data = {
451
- root: { props: {} },
452
- content: [
453
- { type: "LandingHero", props: { id: "hero", headline: "Get early access", subhead: "…", primaryCta: { label: "Join", href: "#" } } },
454
- { type: "Form", props: { id: "form", submitLabel: "Join waitlist", receiver: "funnel:advance" } },
455
- ],
456
- };
632
+ export interface PuckTemplate {
633
+ id: string;
634
+ name: string;
635
+ description: string;
636
+ // Static skeleton — applied instantly on click so the canvas is never blank.
637
+ data: PuckData;
638
+ // Sent to the chat on click; the model streams a complete, richly-filled
639
+ // version of this page back as Puck JSON, replacing the skeleton.
640
+ prompt: string;
641
+ }
457
642
 
458
- export const TEMPLATES: Record<string, { label: string; data: Data }> = {
459
- "opt-in": { label: "Opt-in", data: OPT_IN_TEMPLATE },
460
- "sales": { label: "Sales", data: SALES_TEMPLATE },
461
- "thanks": { label: "Thank you", data: THANKS_TEMPLATE },
462
- "webinar": { label: "Webinar", data: WEBINAR_TEMPLATE },
463
- };
643
+ export const funnelTemplates: PuckTemplate[] = [
644
+ { id: 'opt-in', name: '…', description: '…', data: { content: [...], root: {} }, prompt: '…' },
645
+ //
646
+ ];
464
647
  ```
465
648
 
649
+ The `prompt` field is the important half: a template is a skeleton **plus** an AI
650
+ instruction, not a finished page. `PuckEditor` imports `funnelTemplates` directly.
651
+
466
652
  ---
467
653
 
468
- ## Email target mode (sweep C7)
654
+ ## Email target mode
655
+
656
+ The editor runs in `target: 'web' | 'email'`, defaulting to `'web'`. `PuckEditor`
657
+ takes `target` as a prop and publishes it through `PuckTargetContext`
658
+ (`components/puck/puck-target-context.ts`) so `BlockPalette` — rendered inside
659
+ `overrides.drawer`, where there is no prop path — can filter by
660
+ `metadata.targets`.
469
661
 
470
- When composing a newsletter, the editor runs in `target:'email'` mode:
471
- - **Palette filtered** only blocks with `targets.includes('email')` appear
472
- - **Canvas width** — fixed 600px (email-client safe)
473
- - **Serializer** `broadcast/render.ts` reads the same Puck `Data` but emits table-based, inline-styled HTML with `safeUrl` scheme-allowlisting and `/go/:id` tracking
662
+ ```tsx
663
+ import { PuckTargetContext, usePuckTarget } from '@/components/puck/puck-target-context';
664
+
665
+ // In PuckEditor: <PuckTargetContext.Provider value={target}>…</PuckTargetContext.Provider>
666
+ // In BlockPalette:
667
+ const target = usePuckTarget(); // 'web' | 'email'
668
+ ```
474
669
 
475
- A block gains email support by adding an `email` impl to its block registration in `config.tsx`:
670
+ A block opts into the email palette by spreading the shared `emailTarget` const
671
+ instead of `web`:
476
672
 
477
673
  ```ts
478
- // Conceptual (C7 shape — not in C1):
674
+ // config.tsx
675
+ const web = { targets: ['web'] as const };
676
+ const emailTarget = { targets: ['web', 'email'] as const };
677
+
479
678
  Heading: {
480
- targets: ["web", "email"],
481
- emailRender: (props) => `<h2 style="font-size:24px;font-weight:700">${props.text}</h2>`,
482
- // ... standard Puck fields and render
483
- }
679
+ metadata: { ...emailTarget, category: 'content', icon: 'Type', description: '…' },
680
+ fields: { /* */ },
681
+ render: (props) => <…/>,
682
+ },
484
683
  ```
485
684
 
486
- **Email rendering is never done via React DOM `renderToString`** — that produces flexbox HTML that breaks in Gmail. The email serializer is a separate function that walks the `Data.content` array and calls each block's `emailRender()`.
685
+ **Email rendering is never done via React DOM `renderToString`** — that produces
686
+ flexbox HTML that breaks in Gmail. `lib/broadcast/render.ts` is a separate
687
+ serializer emitting table-based, inline-styled HTML with `safeUrl` scheme
688
+ allowlisting and `/go/:id` tracking.
487
689
 
488
690
  ---
489
691
 
@@ -492,24 +694,28 @@ Heading: {
492
694
  | Target | Renderer | Input | Output | ONE path |
493
695
  |---|---|---|---|---|
494
696
  | **Web** | `<Render>` (Puck SSR) | Puck `Data` | React → flexbox HTML | `PuckRenderer.tsx` |
495
- | **Email** | `broadcast/render.ts` serializer | Puck `Data` | Table-based, inline-style HTML | C7 (sweep) |
496
- | **Data** | `lib/data-bindings.ts` + `ResourceType` | `{type, id|filter}` | Live workspace rows | C6 (sweep) |
697
+ | **Email** | `broadcast/render.ts` serializer | Puck `Data` | Table-based, inline-style HTML | `lib/broadcast/` |
698
+ | **Data** | `lib/data-bindings.ts` + `ResourceType` | `{type, id\|filter}` | Live workspace rows | `lib/puck/resolve-bindings.ts` |
497
699
 
498
700
  Do not force email through React or web through the email serializer — they are separate, intentional.
499
701
 
500
702
  ---
501
703
 
502
- ## Kill-switch (the plan's exit criterion)
704
+ ## Kill-switch
503
705
 
504
706
  ```bash
505
- # Exits 0 only when the core (C1–C5) is complete:
707
+ # All three legs are green as of 2026-08-02:
506
708
  grep -q '@puckeditor/core' one.ie/web/package.json \
507
709
  && test -f one.ie/web/src/lib/puck/config.tsx \
508
710
  && ! test -f one.ie/web/src/components/cro/PageRenderer.tsx \
509
711
  && (cd one.ie/web && bun vitest run tests/unit/puck/)
510
712
  ```
511
713
 
512
- Note: `@puckeditor/core` is canonical across the funnels-pages doc set (synced 2026-06-22); `@measured/puck`@0.20.x is the superseded upstream name.
714
+ `tests/unit/puck/` holds 16 spec files `config.test.ts` pins the structural and
715
+ CRO block sets; the rest cover the block packs, bindings, migration, and restore.
716
+
717
+ Note: `@puckeditor/core` is canonical across the funnels-pages doc set;
718
+ `@measured/puck`@0.20.x is the superseded upstream name.
513
719
 
514
720
  ---
515
721
 
@@ -519,7 +725,6 @@ ONE does **not** use Puck's hosted cloud service or any Puck-managed storage. Th
519
725
 
520
726
  - `pages:create` / `pages:edit` — landing pages → `pages.data`
521
727
  - `funnel:update-step` — funnel step pages → `funnel_definitions.steps[N].config.puck`
522
- - `broadcast:update` (C7) — newsletters → `broadcast.data`
523
728
 
524
729
  The `onPublish` callback in `<Puck>` calls ONE's own `/api/ask` endpoint:
525
730
 
@@ -547,9 +752,12 @@ Do not use `resolveAllData` with external Puck cloud endpoints. All data resolut
547
752
  | Use Puck cloud / puckeditor.com hosted backend | ONE stores all page data in its own D1 via receivers | No `/puck/api` route; `onPublish` must call `/api/ask` |
548
753
  | Import `@puckeditor/core/puck.css` in `PuckRenderer` or any Astro layout | Puck chrome styles bleed into visitor pages | Grep: `puck.css` appears only in `PuckEditor.tsx` |
549
754
  | Set `prerender:true` on any `/p/` or `/f/` route | astro#16529: two React copies crash the page | All Puck routes must have `export const prerender = false` |
550
- | Reimplement CRO components inside block `render` fns | They already exist in `components/cro/`; re-registration is a thin wrapper | `wc -l config.tsx` gate + named import grep |
755
+ | Reimplement a component inside a block `render` fn | It already exists in `components/cro/`, `components/booking/`, etc.; the block is a thin wrapper | Named-import grep |
756
+ | Add a bare key beside `fields` in a block config | The 0.21 `ComponentConfig` type is strict and rejects it | Everything ONE-specific goes under `metadata` |
757
+ | Omit `as const` on a field `type` | TS widens the literal to `string` and `Fields` rejects the object | `bun run typecheck` |
551
758
  | Trust `body.actorId` or `body.slug` in receiver resolvers | IDOR — use `ctx.ownerSlug` (attested) only | `authorizeWorkspace(locals, slug, db)` on every media/page write route |
552
- | Fetch live data on every `render()` call | N+1 — use `resolveData` or the binding resolver | No `fetch` inside block `render` fns; data-bound blocks use `resolveData` or the `external` field |
759
+ | Fetch live data inside a block `render()` | N+1 — the page is resolved in one server-side pass | No `fetch` in a `render` fn; use `resolve-bindings.ts` + `_resolved` |
760
+ | Use a hex or a Tailwind palette class in a block | `rules/design.md` auto-loads here; the palette is wiped so it emits no CSS | `hook:design-check` blocks the edit |
553
761
  | Merge Puck with the Workflow (ReactFlow) canvas | Different substrates: pages = layout tree, workflows = signal DAG | `lib/puck/` and `lib/workflow*/` must stay separate |
554
762
 
555
763
  ---
@@ -558,11 +766,18 @@ Do not use `resolveAllData` with external Puck cloud endpoints. All data resolut
558
766
 
559
767
  1. **Read the plan** — `text/funnels-pages-plan.md` (architecture + pre-mortem table)
560
768
  2. **Run the kill-switch** to see current state before editing
561
- 3. **Check the block registry** — `lib/puck/config.tsx` for what exists (don't reimplement)
562
- 4. **Check `components/cro/`** the 9 CRO components are sources, not blocks; import them
563
- 5. **Check migration number** — `one.ie/web/migrations/` highest number before creating a new SQL file
564
- 6. **Keep all Puck routes `prerender:false`**
565
- 7. **Scope `puck.css` to the editor component only**
566
- 8. **authorizeWorkspace on every write route** (IDOR guard from `lib/analytics/authz.ts`)
567
- 9. **Put vitest files in `tests/unit/puck/`**not in `src/` (web vitest only discovers `tests/**`)
568
- 10. **Run via `bun vitest run`** (not `bunx vitest`) uses the local binary
769
+ 3. **Search the registry first** — the block you want probably exists. `buildBlockIndex` +
770
+ `searchBlocks` (`lib/puck/block-index.ts`) answer "does this block exist?"
771
+ without loading `config.tsx`
772
+ 4. **Pick the right group** — a new block joins an existing group; never add a
773
+ 13th top-level spread
774
+ 5. **Import, don't reimplement** the component lives in `components/`
775
+ 6. **Give it full `metadata`**`category`, `icon`, `description`, and a
776
+ `targets` spread (`...web` or `...emailTarget`); the palette and the block
777
+ index both read it
778
+ 7. **Check migration number** — `one.ie/web/migrations/` highest number before creating a new SQL file
779
+ 8. **Keep all Puck routes `prerender:false`**
780
+ 9. **Scope `puck.css` to `PuckEditor.tsx` only**
781
+ 10. **authorizeWorkspace on every write route** (IDOR guard from `lib/analytics/authz.ts`)
782
+ 11. **Put vitest files in `tests/unit/puck/`** — not in `src/` (web vitest only discovers `tests/**`)
783
+ 12. **Run via `bun vitest run`** (not `bunx vitest`) — uses the local binary