@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.
- package/agents/w1-recon.md +9 -4
- package/agents/w2-decide.md +51 -28
- package/agents/w3-edit.md +70 -78
- package/agents/w4-verify.md +164 -69
- package/commands/browser.md +98 -35
- package/commands/cc-connect.md +6 -0
- package/commands/chat.md +13 -1
- package/commands/close.md +45 -19
- package/commands/create.md +20 -10
- package/commands/db-sync.md +5 -1
- package/commands/deploy.md +174 -87
- package/commands/do-autonomous.md +31 -20
- package/commands/do-improve.md +4 -4
- package/commands/do-show.md +4 -3
- package/commands/do.md +53 -13
- package/commands/go.md +75 -0
- package/commands/improve.md +4 -2
- package/commands/kill.md +6 -8
- package/commands/notify.md +15 -2
- package/commands/oo-push.md +24 -9
- package/commands/rag.md +6 -0
- package/commands/release.md +46 -25
- package/commands/restart.md +5 -4
- package/commands/see.md +45 -35
- package/commands/skill-create.md +38 -5
- package/commands/sync.md +21 -11
- package/hooks/scripts/auto-continue.sh +70 -0
- package/hooks/scripts/branch-pin.sh +85 -0
- package/hooks/scripts/git-add-guard.sh +16 -5
- package/hooks/scripts/post-edit-check.sh +16 -0
- package/hooks/scripts/session-start.sh +13 -8
- package/hooks/scripts/sync-priority-todo.sh +5 -0
- package/package.json +1 -1
- package/rules/api.md +9 -0
- package/rules/design.md +13 -3
- package/rules/documentation.md +30 -71
- package/rules/engine.md +8 -1
- package/rules/ui.md +5 -1
- package/scripts/__pycache__/do-rank.cpython-313.pyc +0 -0
- package/scripts/browser-check.mjs +44 -103
- package/scripts/cc-events-proof.sh +13 -12
- package/scripts/chrome.mjs +381 -0
- package/scripts/do-analyze.sh +109 -4
- package/scripts/do-auto.sh +350 -43
- package/scripts/do-consumer-sweep.sh +72 -0
- package/scripts/do-derives-check.sh +88 -0
- package/scripts/do-fleet.sh +50 -1
- package/scripts/do-killswitch-audit.py +33 -14
- package/scripts/do-promise-lint.sh +150 -4
- package/scripts/do-promise-settle.sh +131 -9
- package/scripts/do-prove.sh +197 -17
- package/scripts/do-rank.py +606 -20
- package/scripts/do-rubric.py +21 -2
- package/scripts/do-signal.sh +157 -17
- package/scripts/do-smoke.sh +9 -0
- package/scripts/do-substrate-check.sh +49 -0
- package/scripts/do-tasks-wire-check.sh +81 -0
- package/scripts/do-tier.sh +90 -5
- package/scripts/do-ui-gate.sh +332 -0
- package/scripts/do-untracked-gate.sh +231 -0
- package/scripts/do-walk.sh +344 -0
- package/scripts/factory-check.sh +747 -0
- package/scripts/factory-repo.sh +1361 -0
- package/scripts/factory-tasks-check.sh +85 -0
- package/scripts/outcome-pull.ts +279 -0
- package/scripts/promise-manifest.mjs +167 -0
- package/scripts/reconcile-allow.txt +5 -0
- package/scripts/rubric-weights.json +64 -0
- package/scripts/spine-canary.sh +20 -0
- package/scripts/typedb-cluster-status.sh +128 -0
- package/scripts/typedb-env.sh +182 -0
- package/scripts/typedb-flap-recorder.sh +85 -0
- package/scripts/typedb-probes/containment-probe.py +88 -0
- package/scripts/typedb-probes/panic-probe-version.py +92 -0
- package/scripts/typedb-probes/panic-probe.py +77 -0
- package/scripts/typedb-scratch.sh +73 -0
- package/scripts/urls-lint.sh +58 -0
- package/scripts/w4-rubric.ts +96 -12
- package/scripts/wf-check.mjs +75 -0
- package/skills/ai-ui/SKILL.md +1 -1
- package/skills/astro/SKILL.md +177 -132
- package/skills/astro/add-content-collection.md +29 -13
- package/skills/astro/check-build.md +19 -13
- package/skills/astro/create-component.md +36 -9
- package/skills/astro/create-page.md +39 -10
- package/skills/astro/optimize-performance.md +36 -8
- package/skills/cli/SKILL.md +254 -220
- package/skills/dev/SKILL.md +1 -1
- package/skills/directory-autofill/SKILL.md +68 -0
- package/skills/docs/SKILL.md +4 -4
- package/skills/mcp/SKILL.md +186 -156
- package/skills/promise-make/SKILL.md +2 -2
- package/skills/puck/SKILL.md +465 -250
- package/skills/react19/SKILL.md +196 -296
- package/skills/reactflow/SKILL.md +387 -412
- package/skills/sdk/SKILL.md +167 -106
- package/skills/shadcn/SKILL.md +245 -201
- package/skills/signal/SKILL.md +5 -0
- package/skills/sui/SKILL.md +6 -1
- package/skills/tutorial/SKILL.md +15 -0
- package/skills/typedb/SKILL.md +427 -217
- package/skills/typedb/examples/python-patterns.py +11 -4
- package/skills/typedb/examples/query-patterns.tql +8 -3
- package/skills/typedb/examples/schema-patterns.tql +4 -2
- package/skills/typedb/reference/migration-2x-3x.md +19 -1
- package/skills/typedb/reference/python-driver.md +17 -1
- package/skills/voice/SKILL.md +117 -0
- package/skills/writer/SKILL.md +29 -12
- package/templates/template-feature.md +26 -1
- package/templates/template-tests.md +4 -0
- package/templates/template-todo.md +65 -10
package/skills/puck/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
**
|
|
14
|
-
**Package:** `@puckeditor/core`
|
|
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
|
|
23
|
-
| `/shadcn` | Every block wraps a real
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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;
|
|
86
|
-
|
|
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
|
|
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.
|
|
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: "
|
|
103
|
-
| `{ type: "
|
|
104
|
-
| `{ type: "
|
|
105
|
-
| `{ type: "
|
|
106
|
-
| `{ type: "
|
|
107
|
-
| `{ type: "
|
|
108
|
-
| `{ type: "
|
|
109
|
-
| `{ type: "
|
|
110
|
-
| `{ type: "
|
|
111
|
-
| `{ type: "
|
|
112
|
-
|
|
113
|
-
###
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
```
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
import
|
|
169
|
-
import
|
|
170
|
-
import {
|
|
171
|
-
import
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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"
|
|
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
|
|
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={
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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:**
|
|
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
|
|
257
|
-
import
|
|
258
|
-
import type
|
|
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
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
395
|
+
import { bgTokenField, bgPatternField } from "@/lib/puck/bg-fields";
|
|
300
396
|
|
|
301
|
-
// In
|
|
397
|
+
// In one of the 12 block groups:
|
|
302
398
|
LandingHero: {
|
|
303
|
-
|
|
399
|
+
metadata: { surface: 'section', semantics: {}, ...web, category: 'cro',
|
|
400
|
+
icon: 'Rocket', description: 'Hero with headline and dual CTA' },
|
|
304
401
|
fields: {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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:
|
|
410
|
+
type: 'object' as const,
|
|
312
411
|
objectFields: {
|
|
313
|
-
label: { type:
|
|
314
|
-
href: { type:
|
|
315
|
-
}
|
|
412
|
+
label: { type: 'text' as const },
|
|
413
|
+
href: { type: 'text' as const },
|
|
414
|
+
},
|
|
316
415
|
},
|
|
317
416
|
secondaryCta: {
|
|
318
|
-
type:
|
|
417
|
+
type: 'object' as const,
|
|
319
418
|
objectFields: {
|
|
320
|
-
label: { type:
|
|
321
|
-
href: { type:
|
|
322
|
-
}
|
|
419
|
+
label: { type: 'text' as const },
|
|
420
|
+
href: { type: 'text' as const },
|
|
421
|
+
},
|
|
323
422
|
},
|
|
324
423
|
},
|
|
325
424
|
defaultProps: {
|
|
326
|
-
headline:
|
|
327
|
-
subhead:
|
|
328
|
-
primaryCta: { label:
|
|
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
|
-
|
|
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
|
|
441
|
+
### Per-block target — `metadata.targets`
|
|
337
442
|
|
|
338
|
-
Each block declares which render targets it supports
|
|
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
|
-
//
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
528
|
+
metadata: { surface: 'section', semantics: {}, ...web, category: 'ecommerce',
|
|
529
|
+
icon: 'Package', description: 'Data-bound product display' },
|
|
363
530
|
fields: {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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: {
|
|
382
|
-
render: ({
|
|
383
|
-
|
|
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">{
|
|
387
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
604
|
+
export function componentsToPuck(sections: PageSection[]): Data {
|
|
429
605
|
return {
|
|
430
|
-
root: { props: {} },
|
|
431
606
|
content: sections.map((s, i) => ({
|
|
432
|
-
type: s.component,
|
|
433
|
-
props: { ...s.props, id:
|
|
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
|
-
|
|
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
|
|
625
|
+
## Starter templates
|
|
444
626
|
|
|
445
|
-
Seed `Data`
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
670
|
+
A block opts into the email palette by spreading the shared `emailTarget` const
|
|
671
|
+
instead of `web`:
|
|
476
672
|
|
|
477
673
|
```ts
|
|
478
|
-
//
|
|
674
|
+
// config.tsx
|
|
675
|
+
const web = { targets: ['web'] as const };
|
|
676
|
+
const emailTarget = { targets: ['web', 'email'] as const };
|
|
677
|
+
|
|
479
678
|
Heading: {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
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 |
|
|
496
|
-
| **Data** | `lib/data-bindings.ts` + `ResourceType` | `{type, id
|
|
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
|
|
704
|
+
## Kill-switch
|
|
503
705
|
|
|
504
706
|
```bash
|
|
505
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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. **
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|