@bettercms-ai/mcp 0.69.0 → 0.71.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.
@@ -128,6 +128,7 @@ resources: `bettercms://skill/bettercms` for this file, and each reference under
128
128
  | The same for an article entry, navigation in the Global Layout, tabs holding columns | [worked-examples-advanced.md](references/worked-examples-advanced.md) | a collection entry with a body, site chrome, or nested blocks | example, blog post, document body, mega menu, tabs, columns |
129
129
  | Componentize a derived site | [componentize.md](references/componentize.md) | `get_site_composition` shows pages made of fields only | componentize, sections, fields-only, get_componentize_plan |
130
130
  | Click-to-edit attributes, addresses, declared mode | [bindings.md](references/bindings.md) | `grep -rl "data-bcms-" src` finds files; a value the editor cannot select | data-bcms-field, click to edit, binding report, stega, declared |
131
+ | Animated sites: GSAP, ScrollTrigger, split text, Framer Motion, Lenis, marquees, carousels, Lottie, hydrated islands | [animation.md](references/animation.md) | `package.json` lists `gsap`, `lenis`, `framer-motion`, `swiper` or a text splitter; a publish refused with `SPLIT_PATTERN_UNKNOWN` or `ISLAND_PROPS_UNMATCHED`; a `client-only-island` gate exception | animation, GSAP, ScrollTrigger, SplitText, Lenis, Framer Motion, marquee, Swiper, Lottie, island, client:only, data-bcms-split |
131
132
  | Wiring, env, hosting modes, media host, snapshots | [frameworks.md](references/frameworks.md) | `package.json` lists an adapter; `bcms-content.json` exists | hosting, headless, env, codegen, types, revalidate, snapshot |
132
133
  | Astro adapter | [astro.md](references/astro.md) | the repo has `astro.config.*` | astro.config, Astro, BcmsBlocks, getStaticPaths |
133
134
  | Next.js adapter | [next.md](references/next.md) | the repo has `next.config.*` | next.config, Next.js, App Router, ISR, notFound, static export |
@@ -181,7 +182,12 @@ Stop at the first step that is not clean, fix, then restart. A report that does
181
182
  3. `get_site_composition`: every page you touched is made of sections, with the intended
182
183
  components.
183
184
  4. `list_seo_issues`: no issue on a page you created or edited.
184
- 5. `get_next_steps`: nothing left that belongs to your task; owner actions go in your final
185
- message by name.
186
-
187
- "Done" means the loop is clean, not that a tool call returned 200.
185
+ 5. `get_next_steps`: `receipt.complete` is `true`. While it is `false`, take the first row of
186
+ `receipt.gate.blocking`, run its `fix`, and read again; nothing else ends an imported site's
187
+ setup. The gate, its bars and the exceptions it accepts are in
188
+ [finish-site.md](references/finish-site.md). Suggestions still in `missing` after that, and
189
+ owner actions, go in your final message by name.
190
+
191
+ "Done" means the loop is clean and the gate reads complete, not that a tool call returned 200.
192
+ `bettercms init` reads the gate again when you exit and prints its numbers per page, so the
193
+ user sees them whatever your last message said.
@@ -0,0 +1,235 @@
1
+ ---
2
+ title: "Animated sites"
3
+ description: "Keeping GSAP, ScrollTrigger, SplitText, Framer Motion, Lenis, marquees, carousels, Lottie and hydrated islands working while their copy stays editable and publishable."
4
+ ---
5
+
6
+ # Animated sites: editable copy that keeps moving
7
+
8
+ Read this when `package.json` lists `gsap`, `lenis`, `framer-motion` or `motion`, `swiper`,
9
+ `split-type`, `splitting`, `lottie-web` or `three`, or when the built HTML carries
10
+ `astro-island`, split-text spans or a duplicated marquee track. Attribute grammar:
11
+ [bindings.md](bindings.md). Adapter wiring: [frameworks.md](frameworks.md).
12
+
13
+ ## 1. What a publish does to animated markup
14
+
15
+ A text publish on a project in the text-node mode (off by default, for new projects too: the
16
+ operator switches it on per project, or for the fleet) writes the change into the element's
17
+ text nodes and keeps every element inside it. That is what the recipes below rely on:
18
+
19
+ | The page has | A publish does |
20
+ |---|---|
21
+ | Inline markup inside the bound element (`<em>`, a highlight `<span>`, `<br>`) | changes the words, keeps the elements and their attributes; a bare `<em>` or `<span>` the new words leave empty is removed |
22
+ | Text cut into `word` / `char` / `line` spans, same word count | writes each span in place |
23
+ | The same, word count changed, spans made by a library at runtime | puts the plain text back; the library splits it on its next run |
24
+ | The same, spans written by your own template | rebuilds them from their own pattern, a new one copied from the last |
25
+ | A copy of the value under `aria-hidden="true"` or `data-bcms-clone` | writes the copy too (coverage kind `duplicate-track`) |
26
+ | The value inside `astro-island` props, `__NEXT_DATA__` or the app router's flight scripts | rewrites the serialized string (coverage kind `island-props`) |
27
+
28
+ It refuses, and says so on the publish, when the change would have to remove something:
29
+ `TEXT_IN_CHILD_FIELD` (the words belong to a field declared inside this one),
30
+ `TEXT_EMPTIES_ELEMENT` (a link, a scripted element or a wrapper with attributes would be left empty),
31
+ `SPLIT_PATTERN_UNKNOWN` (split markup that is not one row of like tokens) and
32
+ `ISLAND_PROPS_UNMATCHED` (a hydrated island draws the text from props that do not hold it).
33
+ Each is fixed in the template with the recipe for its library.
34
+
35
+ On a project in the older mode, the default, a text publish replaces the element's content with
36
+ plain text. There, bind only elements that hold text and nothing else.
37
+
38
+ ## 2. Two rules for every library
39
+
40
+ 1. **Bind the element that holds the words, never the element the animation owns.** A pinned
41
+ section, a scroll wrapper, a slider track and a motion container are layout. Their text
42
+ leaves are the fields.
43
+ 2. **Refresh measurements after an edit.** The editor posts `{ type: "bcms:content-applied" }`
44
+ to the page's window after it applies an edit. Longer copy changes heights, so anything that
45
+ measured the page must measure again:
46
+
47
+ ```js
48
+ window.addEventListener("message", (event) => {
49
+ if (event.data?.type !== "bcms:content-applied") return;
50
+ ScrollTrigger.refresh(); // or lenis.resize(), swiper.update(), split.revert() then split again
51
+ });
52
+ ```
53
+
54
+ `onContentApplied(handler)` from `@bettercms-ai/sdk` is the same listener with the check
55
+ written for you. On a published page nothing posts the message and the listener never runs.
56
+
57
+ ## 3. GSAP and ScrollTrigger
58
+
59
+ ```html
60
+ <section class="pin-wrap"> <!-- trigger and pin: no field -->
61
+ <div class="pin">
62
+ <h2 data-bcms-field="story.title">Built to last</h2>
63
+ <p data-bcms-field="story.lead">Forged, not cast.</p>
64
+ </div>
65
+ </section>
66
+ ```
67
+
68
+ ```js
69
+ gsap.to(".pin h2", { yPercent: -20, scrollTrigger: { trigger: ".pin-wrap", pin: ".pin", scrub: 1 } });
70
+ ```
71
+
72
+ Never put `data-bcms-field` on `.pin-wrap` or `.pin`: ScrollTrigger wraps a pinned element in
73
+ a spacer and writes inline styles on it every frame. Use `<BcmsField>` only around a single
74
+ element, where it adds no wrapper (section 10); a wrapper span between `.pin` and its `h2`
75
+ would break `.pin > h2`.
76
+
77
+ Check: with the page open, `ScrollTrigger.getAll().length` is the same before and after a
78
+ publish of every field on the route.
79
+
80
+ ## 4. SplitText, SplitType, Splitting.js
81
+
82
+ Bind the parent and say that it is split. Never bind a word or a character.
83
+
84
+ ```html
85
+ <h1 class="js-split" data-bcms-field="hero.title" data-bcms-split="words">Fast screws, shipped</h1>
86
+ ```
87
+
88
+ ```js
89
+ let split = new SplitText(".js-split", { type: "words" });
90
+ window.addEventListener("message", (event) => {
91
+ if (event.data?.type !== "bcms:content-applied") return;
92
+ split.revert();
93
+ split = new SplitText(".js-split", { type: "words" });
94
+ });
95
+ ```
96
+
97
+ `data-bcms-split` takes `chars`, `words` or `lines`. It tells the editor that the spans under
98
+ this element are one value, and it tells a publish that the children are tokens even when their
99
+ class is not `word`, `char` or `line`.
100
+
101
+ When your own template writes the spans (`title.split(" ").map(...)`), keep them one row of
102
+ the same tag, with whitespace or nothing between them, and no other element in the row:
103
+
104
+ ```astro
105
+ <h1 data-bcms-field="hero.title" data-bcms-split="words">
106
+ {title.split(" ").map((word) => <span class="word">{word}</span>)}
107
+ </h1>
108
+ ```
109
+
110
+ A `<br>` between two word spans makes the row unreadable once the word count changes
111
+ (`SPLIT_PATTERN_UNKNOWN`): put the break in CSS, or split by lines.
112
+
113
+ Check: publish a title with one more word, reload, and count `.word`: it equals the new word
114
+ count and no word sits outside a span.
115
+
116
+ ## 5. Framer Motion
117
+
118
+ ```tsx
119
+ <motion.section initial={{ opacity: 0 }} whileInView={{ opacity: 1 }}> {/* no field */}
120
+ <motion.h2 data-bcms-field="story.title" variants={rise}>{story.title}</motion.h2>
121
+ <p data-bcms-field="story.lead">{story.lead}</p>
122
+ </motion.section>
123
+ ```
124
+
125
+ Put `data-bcms-field` straight on the motion element that renders the text: it passes
126
+ `data-*` through to the DOM. Do not wrap a motion component in `<BcmsField>`; a component child
127
+ keeps the wrapper span, and `layout` animations measure the wrong box. For words animated one
128
+ by one, map them to `motion.span` under a parent carrying `data-bcms-split="words"`, as in
129
+ section 4. The component hydrates, so section 9 applies to its props.
130
+
131
+ Check: the built HTML for the route shows `data-bcms-field` on the `h2` itself, with no
132
+ `display:contents` span around it.
133
+
134
+ ## 6. Lenis and other smooth scroll
135
+
136
+ Lenis moves the page, not the content, so no binding changes. Two things do:
137
+
138
+ ```js
139
+ const lenis = new Lenis();
140
+ window.addEventListener("message", (event) => {
141
+ if (event.data?.type === "bcms:content-applied") lenis.resize();
142
+ });
143
+ ```
144
+
145
+ and any panel of yours that must scroll by itself inside the page takes `data-lenis-prevent`.
146
+ Never bind the element Lenis is given as `wrapper` or `content`.
147
+
148
+ Check: after an edit that adds two lines to a paragraph, the page still scrolls to the footer.
149
+
150
+ ## 7. Marquees and duplicated tracks
151
+
152
+ Render the track once with fields, and mark every copy:
153
+
154
+ ```astro
155
+ <div class="marquee">
156
+ <ul class="track">{logos.map((l, i) => <li class="logo" data-bcms-field={`logos[${i}].name`}>{l.name}</li>)}</ul>
157
+ <ul class="track" aria-hidden="true" data-bcms-clone>{logos.map((l) => <li class="logo">{l.name}</li>)}</ul>
158
+ </div>
159
+ ```
160
+
161
+ A copy under `aria-hidden="true"` or `data-bcms-clone` is not counted as unowned text, and a
162
+ publish writes it with the original when it is the same tag with the same classes and the same
163
+ text. A copy with neither marker is separate copy: it counts against the route's text coverage
164
+ and keeps the old words. A script that clones the track in the browser needs nothing: the clone
165
+ is made from the published HTML on every load.
166
+
167
+ Check: publish one logo name, then `document.querySelectorAll(".track")` shows the new name in
168
+ every track.
169
+
170
+ ## 8. Swiper and carousels
171
+
172
+ Bind what is inside a slide, never `.swiper-wrapper` or `.swiper-slide`:
173
+
174
+ ```html
175
+ <div class="swiper-slide"><h3 data-bcms-field="slides[0].title">Torque</h3></div>
176
+ ```
177
+
178
+ Loop mode clones slides in the browser (`.swiper-slide-duplicate`); those are made from the
179
+ published HTML and need no field. On `bcms:content-applied`, call `swiper.update()`.
180
+
181
+ Check: `get_binding_report` lists no `slides[n]` path as pending for the route.
182
+
183
+ ## 9. Hydrated islands and `client:only`
184
+
185
+ A hydrated component draws itself again from the props the build serialized. Keep the text it
186
+ shows equal to one string prop, whole:
187
+
188
+ ```astro
189
+ <Hero client:load title={page.hero.title} />
190
+ ```
191
+
192
+ ```tsx
193
+ export default function Hero({ title }) { return <h1 data-bcms-field="hero.title">{title}</h1>; }
194
+ ```
195
+
196
+ A publish then rewrites that prop with the text. It cannot when the component builds the text
197
+ (`${title}!`, `title.toUpperCase()`, a string literal in the component), or when the props hold
198
+ the same words more than once, under a key such as `id` or `slug`, or as `__html`: the publish reports
199
+ `ISLAND_PROPS_UNMATCHED` and the value goes live with the next build. Content passed as a
200
+ slot is plain HTML and needs no prop.
201
+
202
+ `client:only` renders nothing at build time, so the built page has no copy to own. The release
203
+ names every such island on its route and the setup gate lists it as a `client-only-island`
204
+ exception by itself. To make the copy editable, change the directive to `client:load` or
205
+ `client:visible`, or pass the copy in as a slot.
206
+
207
+ Check: view source on the built route, find the `astro-island`, and its `props` attribute holds
208
+ the published title after a publish.
209
+
210
+ ## 10. `<BcmsField>` adds no wrapper around one element
211
+
212
+ ```astro
213
+ <BcmsField path="hero.title"><h1 class="title">{page.hero.title}</h1></BcmsField>
214
+ ```
215
+
216
+ renders `<h1 class="title" data-bcms-field="hero.title">`: nothing sits between the parent and
217
+ the heading. Text, several children, and in Next a component child are still wrapped in a
218
+ `display:contents` span, and `as="div"` still gives a real wrapper. A site built with an older
219
+ package version keeps its wrapper spans until it is rebuilt.
220
+
221
+ ## 11. Lottie, canvas, WebGL, video
222
+
223
+ Pixels drawn by a script are not in the HTML, so nothing in them is a field by itself. What is
224
+ editable is what you pass in:
225
+
226
+ | Drawn by | Make editable by |
227
+ |---|---|
228
+ | Lottie | Bind the animation file as an image-or-file field on the container and load `path` from it. For words inside the animation, keep a text layer named after the field and set it at load (`anim.renderer.elements[i].updateDocumentData({ t: value })`), reading the value from a hidden bound element or a prop. |
229
+ | Canvas / WebGL / three.js | Read every string and texture URL from `data-*` attributes or props on the canvas's container; bind a visually hidden element per string and an `<img hidden>` per texture, and re-read them on `bcms:content-applied`. |
230
+ | Video | The poster and the source are fields on the `<video>`; burned-in captions are not text. |
231
+
232
+ Give the canvas `role="img"` and an `aria-label` bound to a field, or an adjacent caption. A
233
+ scene with no text a visitor reads needs no field; record it under the `decorative` exception.
234
+
235
+ Check: change the bound string, publish, reload: the drawn text reads the new value.
@@ -41,8 +41,8 @@ build. Pass `apiUrl` and `workspace` as options, or let the snapshot supply them
41
41
  |---|---|
42
42
  | `@bettercms-ai/astro/components/BcmsBlocks.astro` | Server render of a block tree. Pass `components` or instances render nothing |
43
43
  | `@bettercms-ai/astro/components/BcmsLiveBlocks.astro` | Same tree as a hydrated island that follows the editor's draft bridge |
44
- | `@bettercms-ai/astro/components/BcmsField.astro` | `path`, `kind` (`richtext`/`image`), `as`; emits `data-bcms-field` + `data-bcms-kind` |
45
- | `@bettercms-ai/astro/components/BcmsButton.astro` | A `button` field: `path`, `value`, `class`. One `<a>` with the label bound, the href in `data-bcms-props` and `data-variant` / `data-size` for your CSS; renders nothing without a label |
44
+ | `@bettercms-ai/astro/components/BcmsField.astro` | `path`, `kind` (`richtext`/`image`), `as`; emits `data-bcms-field` + `data-bcms-kind` on its single child element, with no wrapper ([animation.md](animation.md) §10) |
45
+ | `@bettercms-ai/astro/components/BcmsButton.astro` | A `button` field: `path`, `value`, `class`. One `<a>` with the label bound, the href in `data-bcms-props` and `data-variant` / `data-size`. Style only the looks the site has ([components.md](components.md) §5); renders nothing without a label |
46
46
  | `@bettercms-ai/astro/components/BcmsForm.astro` | Renders a delivered form and submits it ([forms-and-interactions.md](forms-and-interactions.md)) |
47
47
 
48
48
  `BcmsLiveBlocks` rules:
@@ -55,7 +55,11 @@ build. Pass `apiUrl` and `workspace` as options, or let the snapshot supply them
55
55
 
56
56
  A section root your own code renders spreads `sectionRootAttrs(block)` from `@bettercms-ai/astro`
57
57
  (`data-bcms-block`, `data-bcms-style` and the block's style), so the editor can move it and offer
58
- its spacing handles ([components.md](components.md) §5).
58
+ its spacing handles ([components.md](components.md) §5). It adds `data-bcms-hide` when `style.hideOn` is set: put `RESPONSIVE_CSS` (exported here) in your global CSS.
59
+
60
+ `<BcmsBlocks>` does not render a block outside its `window`, read against the `now` prop (default:
61
+ render time). Pass `showScheduled` on a draft preview to draw it, marked `data-bcms-window`
62
+ ([publishing.md](publishing.md) §6).
59
63
 
60
64
  `bind` and `bindProps` from `@bettercms-ai/astro/bindings` produce the same attributes as the Next
61
65
  helpers. Use them inside component instances so addresses resolve to the instance
@@ -20,8 +20,12 @@ cannot resolve an address guesses, which works once.
20
20
 
21
21
  **Put the attributes on the element that renders the value and nothing else: the `<img>` itself,
22
22
  the `<h1>` itself. Never a wrapper, never an element whose children you need.** A declared write
23
- replaces what is inside the element it names. Bind a `<section>` or a card and the band's own
24
- markup is what you overwrite; the page comes back as bare text where a layout used to be.
23
+ owns what is inside the element it names. Bind a `<section>` or a card and the band's own
24
+ markup is what a rich text or image write overwrites; the page comes back as bare text where a
25
+ layout used to be. A plain-text write keeps the inline elements inside its element (a highlight
26
+ `<span>`, split-text spans, a `<br>`) on a project in the text-node publish mode, and replaces
27
+ them on an older one: [animation.md](animation.md) §1 has both, and the recipe per animation
28
+ library.
25
29
 
26
30
  `data-bcms-kind` does not choose the editor; the MODEL does. The attribute says **"this element is
27
31
  the declared CONTAINER for the field"**, so the canvas repaints it whole. Leave it off and the
@@ -92,6 +96,30 @@ the fallback; the build renders it and the publish writes the editor's image. Fr
92
96
  images are in the Media Library, matched by file name. An image rendered inside a loop over a
93
97
  list is not bound automatically: bind it in the list's data file.
94
98
 
99
+ The codemod knows an image component by its IMPORT, not its name: `Image` and `Picture` from
100
+ `astro:assets` (aliased, or through `import * as assets`), and the default export of
101
+ `next/image` under any name. An import that reaches the page through `getImage()`, `hero.src` or
102
+ a style is refused as `IMAGE_ASSET_UNRESOLVED`, and the row's message names the import: declare
103
+ the element that shows it by hand (`data-bcms-field` + `data-bcms-kind="image"`).
104
+
105
+ Images need no code at all on a text-matched site. Every release derives an image field for each
106
+ picture a page shows: an `<img>` (its `src`, or where that is a placeholder a lazy loader's
107
+ `data-src`, then the largest `srcset` candidate), a whole `<picture>` as ONE field, a
108
+ `<video poster>`, an inline or STYLESHEET `background-image` (Tailwind `bg-[url(...)]` included)
109
+ and an inline `<svg>`. What it declines is listed with a reason in the binding report's
110
+ `imageGaps` ([media-and-brand.md](media-and-brand.md) Where images hide).
111
+
112
+ Publishing an image keeps the page responsive. A library image re-points every `srcset`
113
+ candidate and each `<source>` at the new picture at that candidate's own width and format, and
114
+ leaves `sizes`, `width`, `loading`, classes, ids and `data-*` as written; never hand-edit those
115
+ after a swap. A stylesheet background gets one inline `background-image` over the rule, removed
116
+ again when the value returns to the sheet's own picture.
117
+
118
+ To keep an image editable in code you write yourself, render it with `BcmsImage` and its `path`
119
+ (Astro: `@bettercms-ai/astro/components/BcmsImage.astro`; Next: `BcmsImage` from
120
+ `@bettercms-ai/next`): one `<img>` that declares the field, reads the value's alt, crop and focal
121
+ point, and offers a width ladder when given `sizes`.
122
+
95
123
  ## 3. Addresses
96
124
 
97
125
  The grammar is identical in `@bettercms-ai/next` and `@bettercms-ai/astro`; emit it with the SDK
@@ -103,7 +131,9 @@ helpers rather than by hand.
103
131
  this list. An `href`, an `src` and a `poster` are kind `url` (`cta.href|url|href`, not
104
132
  `|text|`); an `alt` is `text`. The same address on two elements is edited as two copies of one
105
133
  value (numbered `data-node-index`).
106
- - `<BcmsField path kind as>` wraps hand-rendered entry values. `path` is dotted keys with any
134
+ - `<BcmsField path kind as>` binds hand-rendered entry values. Around ONE element it adds no
135
+ wrapper: the attributes go on that element. Around text or several elements it renders a
136
+ `display:contents` span, and `as` names a real wrapper. `path` is dotted keys with any
107
137
  number of `[n]` indices: `title`, `hero.title`, `columns[0].links[2].href`.
108
138
  - Rich text is addressed per paragraph: `<base>#<_key>`. No write tool takes an address:
109
139
  `update_content_entry` replaces the whole field (with `merge: true` it keeps the other
@@ -207,3 +237,50 @@ still renders hard-coded copy is not connected. Client components never receive
207
237
  - `get_binding_report`'s `help.percent` is 100: every bound field also tells its editor what it
208
238
  is for. Binding a leaf makes it editable; help text makes it usable
209
239
  ([content-modeling.md](content-modeling.md), "Writing help text").
240
+
241
+ ## Reading the binding report and the conversion receipt
242
+
243
+ `get_binding_report` per slot (`slot` is `current` or `staging`; on a promote-gated project read
244
+ `current`, the tree the editor frames). Every figure, `unaddressable` included, is measured per
245
+ slot. `sha` is the build the report describes; `refreshRequired` is true when that is not the
246
+ build the slot serves. `builtRoutes` lists the routes the live build has HTML for (null = cannot
247
+ say). `layoutIncomplete` lists Layout paths the last content apply could not write (their publish
248
+ waits); `layoutUnread` lists Layout fields no route reads (`layoutUnreadJudged` false = not
249
+ judged).
250
+
251
+ - **Before any release** there is no report: pages 0, mode null, `refreshRequired` true.
252
+ `refreshReason` `never-generated` means only a release BetterCMS builds and serves writes a
253
+ report, so a headless site never gets one, redeploy or not; `outdated` means the next release
254
+ rewrites it.
255
+ - **`unmatched` reasons**: `not-declared`, `ambiguous-text`, `no-element`, `declared-only`,
256
+ `needs-wrap`, `refused`, `layout-owned`, `page-owned`, `layout-locator-stale`,
257
+ `locale-unvarianted`, `svg-unavailable`, `partial`, `unstamped` (a page list the content apply
258
+ left as built; `kind` reads `list <bound>/<total>`), `mismatch` (a text-match stamp whose element
259
+ shows more than the value, or no longer the text it was matched on, `data-bcms-from`; never
260
+ written) and `report-outdated`.
261
+ - A clean report proves each non-empty field has an element carrying its path, nothing more. A Global
262
+ Section counts only on the routes that render it (`layoutRoutes`).
263
+ - **`unaddressable`** is the copy the report cannot see: visible live text no field owns, by
264
+ route and bucket, biggest first (null = unmeasured: diff the visible text against the entry
265
+ values). Over `count / visible > 0.02`, fix it in the source (wrap the run in an element the
266
+ codemod can bind, or declare `<BcmsField path=…>`), deploy and read again (playbook §11).
267
+ - `canvas.lane` is the editor's live-preview lane (`bridge`, `draft-route`; `none` sends you to
268
+ `get_next_steps`). `coverage.error` `nothing-bound` means no element of this build is bound, so
269
+ its percentage describes another site.
270
+
271
+ `submit_conversion_receipt` answers `complete` (`sourceComplete` is the receipt's own verdict;
272
+ `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and
273
+ `pendingTotal`, and echoes the first 40 pending rows with their `fix`:
274
+ `{ action, file, line, col?, snippet, why?, kind? }`. `action` is one of `wrap-span`,
275
+ `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `snippet` and `why`
276
+ say what to change. On a `declare-richtext` with `kind: document`, bind the one element wrapping
277
+ every block of the Body, never the paragraph holding its first. A 404 `unknown-brief` means that
278
+ digest was never issued here. The same run owes the `--forms` receipt, submitted as a second call
279
+ under the same `briefDigest`, and the `styleEdits` of `convert --verify-styles` inside this
280
+ receipt; one listing a changed class, className or style is refused with 409 `style-edits`.
281
+
282
+ The complete brief (`get_conversion_brief { complete: true }`) also carries `forms` (id, status,
283
+ fields, submitUrl). After the binding pass, run the codemod's forms mode (`npx
284
+ @bettercms-ai/convert --forms`, with the brief and a receipt file) to wire them into the
285
+ repository's own `<form>` elements. Read `forms.pending`, and publish every form `forms.notes` names in the Forms tab: a draft
286
+ form rejects every submission with a 403.
@@ -25,8 +25,9 @@ Inside a section, pick the smallest shape that holds the children:
25
25
 
26
26
  - **Small identical items** (FAQ rows, feature bullets, logos): a `group` or `table` prop.
27
27
  - **One replaceable child with its own layout or variants** (a hero's media treatment): a `slot`
28
- prop. It holds exactly one instance, and never sits inside a `group` or `table` row.
29
- - **Several different children**: the section's ordered children, not a list of slots.
28
+ prop. It holds one instance, and never sits inside a `group` or `table` row.
29
+ - **Several different children**: the section's ordered children when the set is fixed; a `slot`
30
+ prop with `config.multiple` when the editor picks and orders them.
30
31
 
31
32
  Never let a component contain itself, directly or through a child.
32
33
 
@@ -40,8 +41,9 @@ Never let a component contain itself, directly or through a child.
40
41
  prose body and lists of object rows are sections. Read `pending` for what it will not offer:
41
42
  `NO_GROUP_ROOT` (keys are still the derive lane's own, such as `h1-welcome`: rename them into
42
43
  families first), `NOT_A_SECTION` (a lone scalar or page metadata; `detail: ENTRY_METADATA` is
43
- a template's frontmatter, `detail: DECLINED` a group the user declined) and `IMAGE_AS_TEXT`
44
- (a text field holding an image path; it plans once the field is an image). A nav, footer or
44
+ a template's frontmatter: author, images, tags and dates stay entry fields; `detail: DECLINED`
45
+ is a group the user declined) and `IMAGE_AS_TEXT` (a text field holding an image path; it plans
46
+ once the field is an image). A nav, footer or
45
47
  modal group is `NOT_A_SECTION` with `detail: LAYOUT_CHROME`: chrome is the Layout on every
46
48
  site, never a page band. Author the real sections among those with `create_component` and
47
49
  `set_page_content` instead.
@@ -23,9 +23,9 @@ reference is what the code does with what you write. Componentizing a derived si
23
23
  A component declares `props`. Each prop is `{ key, label, type, target: { blockId, path },
24
24
  defaultValue? }`.
25
25
 
26
- - `type` is one of exactly 18: `text`, `richtext`, `image`, `url`, `boolean`, `number`,
27
- `select`, `group`, `table`, `slot`, `form`, then `date`, `color`, `file`, `link`, `icon`,
28
- `multiselect` and `button`.
26
+ - `type` is one of exactly 30: `text`, `richtext`, `image`, `url`, `boolean`, `number`, `select`, `group`, `table`, `slot`,
27
+ `form`, then `date`, `color`, `file`, `link`, `icon`, `multiselect` and `button`, then `object`, `datetime`, `slug`,
28
+ `email`, `phone`, `json`, `location`, `tags`, `rating`, `code`, `reference` and `multi-reference`.
29
29
  - The last seven store what the same-named model field stores: `date` is `YYYY-MM-DD`, `color` a
30
30
  hex, `file` `{ url, name?, size? }`, `link` an href string or `{ href, rel?, newTab? }`, `icon` an icon name or an image
31
31
  value, `multiselect` a `string[]` drawn from `config.options`. A bad `defaultValue` is a 400.
@@ -38,14 +38,37 @@ defaultValue? }`.
38
38
  - Every prop takes `helpText` and the component a `description` ("Hero with a heading,
39
39
  supporting text and two buttons."). Leave them out and the platform writes them; write your
40
40
  own by the rules in [content-modeling.md](content-modeling.md) ("Writing help text").
41
- - A prop takes `showIf` (`{ match, rules: [{ field, op, value? }] }`): hidden until SIBLING props match. `op` is `eq`, `neq`, `in` or `isSet` (no `value`); model fields take the same four.
41
+ - A prop takes `showIf` (`{ match, rules: [{ field, op, value? }] }`): hidden until SIBLING props match. `op` is `eq`, `neq`, `in`, `isSet`, `notEmpty` or `empty`; the last three take no `value`, the first three need one. Props and model fields share all six, and a sub-field's `showIf` names its sibling sub-fields.
42
+ - `object` holds ONE `{ childKey: value }` built from `config.fields` (required), like the model field `object`. Groups, tables and objects nest to 10 levels counting the prop; an 11th is refused `field_depth_exceeded`. Aim an `object` at `props` or at a key your component code reads, never at a text slot such as a heading's `props.text` (`prop_target_unrenderable`).
43
+ - `datetime`, `slug`, `email`, `phone`, `json`, `location` (`{ lat, lng }`), `tags` (`string[]`), `rating` (0..`config.max`) and `code` (`{ language?, code }` or a string) hold what the same-named model field holds, and a placement's value is checked by that field's rules (`prop_value_invalid`). Like `object`, a `tags` or `code` prop never targets a text slot.
44
+ - `reference` holds one CMS item's id and `multi-reference` a list of them, as the model fields do: `config.contentModelId`, or `polymorphic: true` with `allowedModelSlugs` (one of the two is required), and `min`/`max` on the list. A placement's new id must exist (`REF_MISSING`); an id already stored is never re-checked. Page reads (delivery, preview) hydrate the ids to entries at their `depth`, so component code reads the entry; on a heading or button text slot the entry's title renders. A `multi-reference` never targets a text slot.
42
45
  - `update_component` replaces the whole `props` array. Read with `get_component`, change, write
43
46
  all props back.
44
- - `select` and `multiselect` need `config.options`; `slot` holds one nested component instance;
45
- `form` holds one Form document.
47
+ - `select` and `multiselect` need `config.options`; `slot` holds one nested component instance,
48
+ or a list of them with `config.multiple`; `form` holds one Form document.
46
49
  - `slot` and `form` are reference-shaped: `target.path` is `props` (the whole envelope) on an
47
50
  empty `component` or `form` block, and their values are `{ componentId, overrides? }` and
48
51
  `{ formId }`. Neither may appear inside a `group` or `table` row.
52
+ - **A `slot` with `config.multiple: true` holds a list.** Its value is
53
+ `[{ componentId, overrides? }, ...]` and its `target.path` is a container's child list:
54
+ `props.children` on a `section` block, or `props.columns.<n>` on a `columns` block (never
55
+ `props` on a `component` block). Each item is drawn there as a child component, in order.
56
+ Until a placement sets the slot, the children the definition holds in that list are shown;
57
+ `[]` shows none.
58
+ - `config.maxItems` defaults to 12 and cannot pass 50. `config.minItems` and
59
+ `config.componentIds` (which components may be listed) are checked on every write.
60
+ - An item may also carry `style` (use it for `hideOn`) and `window` with `from` / `until`
61
+ as UTC to the second (`2026-11-27T00:00:00Z`). A `defaultValue` must be an array and takes
62
+ no `window`.
63
+ - A write is a 400 whose message starts with the code: `slot_too_many`, `slot_too_few`,
64
+ `slot_item_invalid`, `slot_component_not_allowed`, `slot_component_unknown` (not a
65
+ component of this project), `component_cycle` (a component would end up inside itself,
66
+ through any chain), `component_too_deep` (more than 10 levels), `slot_target_invalid`,
67
+ `slot_not_multiple` (a list sent to a single slot). A value that is already stored is not
68
+ refused when the page is saved again.
69
+ - A child's copy is not click-to-edit on the canvas. Change it in the placement's
70
+ `overrides.<slot key>` list with `update_page`.
71
+ - Publish every listed component: a draft child renders nothing, like any draft component.
49
72
  - **A `form` prop stores an id, nothing more.** Modelling the questions again as props gives you
50
73
  two answers to "what does this form ask?", and they diverge at the first edit on either side.
51
74
  The save schema is `.strict()`, so posting the hydrated form object back is a 400, not a silent
@@ -81,6 +104,12 @@ source? } }`.
81
104
  component with a `sectionType`. A true one-off is a `section` block whose `props.children` hold
82
105
  its blocks.
83
106
  - Components that share a `sectionType` are one family: its variants.
107
+ - **A built-in section is a starting point, not a thing you place.** `list_components` shows the
108
+ locked `builtin:*` blueprints; `create_component` with `from: "builtin:hero-centered"` copies
109
+ one into the project as an ordinary draft component, then edit and publish it like any other.
110
+ Whatever you send (`name`, `slug`, `props`, `blockJson`, `sectionType`, `category`) wins over
111
+ the copy. `list_components` `sourceKey` says which built-in a component came from. A copy
112
+ never follows later changes to the built-in. An unknown id is 404 `BUILTIN_NOT_FOUND`.
84
113
  - **Give every component a library `category` as well.** Why: `sectionType` groups a family, the
85
114
  category is how the component library lists it — an uncategorised component is created fine and
86
115
  is then hard to find in the picker, which is where a human looks for it.
@@ -97,8 +126,9 @@ source? } }`.
97
126
  Default, Featured and Outline is ONE component with a `select` prop keyed `variant` whose options
98
127
  are the looks, and its markup picks classes from that value. Why: an editor switches the look in
99
128
  place and the content stays put, and the library holds one card, not three. Families of looks
100
- (`variantOf`, a style select on every section that places them) are for buttons: a new button
101
- look is `get_component`, then `create_component` with `variantOf` naming the base.
129
+ (`variantOf`, a style select on every section that places them) are for buttons. On a site
130
+ built in BetterCMS a new button look is `get_component`, then `create_component` with
131
+ `variantOf` naming the base. On an imported site the family is read, not written (§5).
102
132
  - Read `warnings` and act on them; they never block the write:
103
133
  - `create_component` / `update_component`: `VARIANT_PROP_KEYS_MISMATCH` (a key no sibling
104
134
  declares, or one siblings declare that this variant lacks) and `VARIANT_PROP_TYPE_MISMATCH`
@@ -128,7 +158,7 @@ Handle by code, never by retrying the same call:
128
158
  |---|---|---|
129
159
  | `COMPONENT_BLUEPRINT_MATERIALIZATION_REQUIRED` | You tried to publish a platform built-in | Create a project-local component from it, publish that |
130
160
  | `COMPONENT_PROJECT_SCOPED_RELEASE_REQUIRED` | The component is workspace-global | Create a project-local variant, publish that |
131
- | `COMPONENT_IMPLEMENTATION_NOT_READY` | A REPO-managed component lacks exact evidence and human approval, or a variant-group member's family contract is unpublished. Never a `create_component` component outside a variant group | Report `readiness`; the owner resolves it in the dashboard |
161
+ | `COMPONENT_IMPLEMENTATION_NOT_READY` | A REPO-managed component lacks exact evidence and human approval, or a variant-group member (the provisioned navigation and footer) has a family contract that is unpublished. Never a `create_component` component outside a variant group: that one is dashboard-managed and publishes on the preflight alone | Report `readiness`; the owner resolves it in the dashboard |
132
162
  | `COMPONENT_ORCHESTRATION_V2_REQUIRED` | The project runs implementation in Agent Dock | Continue there; stop here |
133
163
  | `COMPONENT_PUBLISH_CONFLICT` | The component or its evidence changed mid-publish | Re-read with `get_component`, then publish once more |
134
164
  | `COMPONENT_PROJECT_CONTEXT_MISMATCH` | The component's `projectId` differs from the request's project scope | Call with the component's own project |
@@ -188,8 +218,16 @@ A code-built site ships only what its templates draw, whatever the page's `block
188
218
  the repo root (`--dry-run` first); give each root it reports as skipped (`OWN_STYLE`, `SPREAD`,
189
219
  `BLOCK_ID_ONLY`, …) its block style by hand, confirm with `--verify-styles`, push and release.
190
220
  - **A `button` field renders with `BcmsButton`** (`path`, `value`): one `<a data-bcms-button>`
191
- carrying `data-variant` and `data-size`, so the site's CSS decides what each look looks like and
192
- every look control (style, size, arrow, icon, new tab) applies on the canvas and on publish.
221
+ carrying `data-variant` and `data-size`, so every look control (style, size, arrow, icon, new
222
+ tab) applies on the canvas and on publish.
223
+ - **A button's looks are the ones the site already has.** Each release measures the built pages
224
+ and keeps the project's Button family equal to the looks found there, named as the site names
225
+ them (`btn-outline` is Outline). Style offers those looks and no others, and a publish paints a
226
+ pick with the site's own classes. Never write CSS for a look so that a control "works": there
227
+ is no fixed list of variants to implement. A `button` value's `variant` is one of those looks;
228
+ a refused value comes back with the looks the project has. A look the site drops is flagged,
229
+ never deleted. A new look is the owner's design decision: add its class to the site, then
230
+ Rebuild.
193
231
  Without the SDK, write that same anchor: `data-bcms-button="<path>"`, the label in its own
194
232
  `<span data-bcms-field="<path>.label">`, the href through `data-bcms-props`. The arrow is the
195
233
  `svg.bcms-btn-arrow` it draws; never leave a hand-written arrow `<svg>` beside it, or turning
@@ -311,4 +349,13 @@ stored list.
311
349
  siblings.
312
350
  2. `get_site_composition`: every page is sections; every component you meant to publish is
313
351
  published.
352
+ - Per page: `kind` is `blocks`, `fields-only` or `empty`. `nonComponentBlocks` are loose
353
+ top-level bands the editor's section lane cannot outline.
354
+ - Per placement: the component's `status` is `published`, `draft` or `missing`. `inPicker` is
355
+ false for a component with no `sectionType`: "Add a section" does not offer it.
356
+ `emptyRequiredProps` is reported only here.
357
+ - Site-wide: `components` counts published and draft, `awaitingEvidence` and
358
+ `awaitingApproval`. A person approves in the dashboard, one component at a time. Then the
359
+ `notInPicker` and `unused` ids, and `layout.placements`: the chrome placed in the Global
360
+ Layout draft.
314
361
  3. `get_binding_report` on a page that uses the component: instance values bind.
@@ -46,7 +46,11 @@ collection: the model and its entries both point at the page (`content_models.pa
46
46
  A listing elsewhere (latest posts on home, related posts on an article) is a `collection` block
47
47
  with `source: { modelId }`, never copied entries. It also takes `filters` (max 5, `{ field, op,
48
48
  value }` with the content API operators), `sort` (`-publishedAt,title`), `pinnedIds` (max 10,
49
- shown first), `excludeCurrent` and `limit`. BetterCMS-rendered pages read it on every request;
49
+ shown first), `excludeCurrent` and `limit` (default 20), beside `order` (`newest` or `oldest`) and
50
+ `emptyText`. `cardComponentId` names the component each card is drawn through; without one a card
51
+ shows the entry's `titleField` (default `title`) and `excerptField`. On a `dynamic` page the block
52
+ lists that page's collection and draws ONE entry on `/<page>/<entrySlug>`, through
53
+ `detailComponentId` when set. BetterCMS-rendered pages read it on every request;
50
54
  a route the repo builds does not draw this block, so list there with the content API.
51
55
 
52
56
  | Source pattern | BetterCMS destination |
@@ -126,7 +130,7 @@ sitemap), RSS (dated by `publishedAt`), `llms.txt` (one link per published item)
126
130
  - **Lists are arrays; tags are taxonomy.** Features, steps, benefits and "what's included" are an
127
131
  `array` (`itemType: "text"`), or a repeater when each item carries its own state (included or
128
132
  not, an icon). Never a group holding one field. A label set that filters or classifies entries
129
- is a shared collection referenced with `multi-reference` (§2), not free text.
133
+ is a `tags` field; when each label has its own page, a collection referenced with `multi-reference` (§2).
130
134
  - **Never freeze a feed.** A list of entries on the source stays a listing: copying the nine
131
135
  visible cards into a repeater stops it updating. On a page BetterCMS renders, that is the
132
136
  `collection` block: on the collection's own dynamic page it lists that collection, and with
@@ -301,7 +305,8 @@ production cannot store it. Translate before the first write.
301
305
  | `string` | `text` with `richText: false` | `text` |
302
306
  | `text` | `longtext` | `text` |
303
307
  | `richtext` | `richtext` for section copy; `document` for the one body | `richtext` |
304
- | `number`, `rating` | `number` | `number` |
308
+ | `number` | `number` | `number` |
309
+ | `rating` | `number` with `config.min`, `max`, `step` and `ui.control: "rating"` (stars) | `number` |
305
310
  | `boolean` | `boolean` | `boolean` |
306
311
  | `slug` | `slug` (an entry's own slug stays on the entry) | `text` |
307
312
  | `url`, `link` | `link` | `link`; `url` for a media or embed address |
@@ -309,10 +314,11 @@ production cannot store it. Translate before the first write.
309
314
  | `date`, `datetime` | `date`, `datetime` | `date` (`YYYY-MM-DD`); a datetime stays `text` |
310
315
  | `color` | `color` | `color` |
311
316
  | `select` | `select` | `select` with `config.options` |
312
- | `multiselect`, `tags` | `array` (`itemType: "text"`); a taxonomy is a collection plus `multi-reference` | `multiselect` with `config.options` |
317
+ | `multiselect` | `multiselect` with `config.options` (a closed `string[]` choice list; `min`/`max` count the choices) | `multiselect` with `config.options` |
318
+ | `tags` | `tags` (a `string[]`; `config.options` are suggestions, `allowCustom: false` closes the list); a taxonomy with its own pages is a collection plus `multi-reference` | `tags` |
313
319
  | `image` | `image` | `image` |
314
320
  | `file`, `video` | `file`; a hosted video URL is a `link` | `file`; `url` for a hosted video |
315
- | `icon` | `text` with `richText: false` | `icon` (an icon name or an image) |
321
+ | `icon` | `icon` (an icon name or an uploaded image) | `icon` (an icon name or an image) |
316
322
  | `button` | `button` | `button`: one prop targeting the `props` of a `button` block (§4) |
317
323
  | `reference` | `reference`; `multi-reference` for many | none: keep it on the model |
318
324
  | `code` | `longtext` | `text` |
@@ -323,12 +329,14 @@ production cannot store it. Translate before the first write.
323
329
  | `array` of objects | `repeater` | `table` |
324
330
  | `array` of strings | `array` (`itemType: "text"`) | `table` |
325
331
  | `array` of several item shapes | `modular` with `config.blockSlugs` (create the block models first) | separate components |
326
- | `slot` | `sections` or `component-ref` | `slot` (one instance) |
332
+ | `slot` | `sections` or `component-ref` | `slot` (one instance; a list with `config.multiple`) |
327
333
  | a form `reference` | a `form` block inside the section | `form` |
328
334
 
329
- V2 features with no production shape are dropped and reported, never faked: typed table columns,
330
- per-breakpoint values, per-field localization, a slot with several
331
- children (several sections or nested components instead).
335
+ V2 features with no production shape are dropped and reported, never faked: a per-cell table
336
+ type, per-breakpoint values (only `style.hideOn` differs by breakpoint) and per-field
337
+ localization. Two have a shape now: typed table columns are a `repeater` with one field per column
338
+ and `ui.layout: "table"`, and a slot with several children is a `slot` prop with `config.multiple`
339
+ ([components.md](components.md) §1).
332
340
 
333
341
  | The plan says | Production word |
334
342
  |---|---|