@bettercms-ai/mcp 0.83.0 → 0.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +86 -86
  2. package/SKILL.md +160 -160
  3. package/dist/index.js +38 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/kit-catalog.json +1509 -1509
  6. package/dist/skills/bettercms/SKILL.md +206 -206
  7. package/dist/skills/bettercms/references/accessibility.md +171 -171
  8. package/dist/skills/bettercms/references/animation.md +296 -296
  9. package/dist/skills/bettercms/references/astro.md +148 -148
  10. package/dist/skills/bettercms/references/bindings.md +366 -366
  11. package/dist/skills/bettercms/references/componentize.md +185 -185
  12. package/dist/skills/bettercms/references/components.md +415 -415
  13. package/dist/skills/bettercms/references/content-experiments.md +62 -62
  14. package/dist/skills/bettercms/references/content-modeling.md +382 -382
  15. package/dist/skills/bettercms/references/detection.md +465 -465
  16. package/dist/skills/bettercms/references/dock-input.md +121 -121
  17. package/dist/skills/bettercms/references/editor-chrome.md +92 -92
  18. package/dist/skills/bettercms/references/external-content.md +78 -78
  19. package/dist/skills/bettercms/references/finish-site.md +377 -377
  20. package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
  21. package/dist/skills/bettercms/references/frameworks.md +159 -159
  22. package/dist/skills/bettercms/references/getting-started.md +156 -156
  23. package/dist/skills/bettercms/references/integrations.md +364 -364
  24. package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
  25. package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
  26. package/dist/skills/bettercms/references/localization.md +85 -85
  27. package/dist/skills/bettercms/references/media-and-brand.md +181 -181
  28. package/dist/skills/bettercms/references/next.md +189 -189
  29. package/dist/skills/bettercms/references/other-frameworks.md +85 -85
  30. package/dist/skills/bettercms/references/portable-text.md +213 -213
  31. package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
  32. package/dist/skills/bettercms/references/publishing.md +289 -289
  33. package/dist/skills/bettercms/references/seo.md +357 -357
  34. package/dist/skills/bettercms/references/shopify-headless.md +52 -52
  35. package/dist/skills/bettercms/references/site-blueprints.md +895 -895
  36. package/dist/skills/bettercms/references/site-review.md +74 -74
  37. package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
  38. package/dist/skills/bettercms/references/worked-examples.md +885 -885
  39. package/dist/skills/bettercms/references/write-limits.md +217 -217
  40. package/package.json +1 -1
@@ -1,296 +1,296 @@
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`, `locomotive-scroll`, `framer-motion` or
9
- `motion`, `swiper`, `split-type`, `splitting`, `lottie-web`, `three` or `@react-three/fiber`, or
10
- when the built HTML carries
11
- `astro-island`, split-text spans or a duplicated marquee track. Attribute grammar:
12
- [bindings.md](bindings.md). Adapter wiring: [frameworks.md](frameworks.md).
13
-
14
- ## 1. What a publish does to animated markup
15
-
16
- The bound element picks the writer. One that holds text and nothing else is replaced whole, as
17
- it always was. One that holds markup (inline elements, split spans, a `<br>`, or
18
- `data-bcms-split`) is written into its text nodes, and every element inside it is kept. That is
19
- what the recipes below rely on:
20
-
21
- | The page has | A publish does |
22
- |---|---|
23
- | 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 |
24
- | Text cut into `word` / `char` / `line` spans, same word count | writes each span in place |
25
- | 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 |
26
- | The same, spans written by your own template | rebuilds them from their own pattern, a new one copied from the last |
27
- | A split headline under an `aria-label` that says the same words (SplitText's own output) | one field on the headline: writes the label and the words |
28
- | A counter with its number in `data-target`, `data-count` or a like attribute | writes the attribute; writes the number shown only where it showed the old target, never a script's starting `0` |
29
- | A copy of the value under `aria-hidden="true"` or `data-bcms-clone` | writes the copy too (coverage kind `duplicate-track`) |
30
- | The value inside `astro-island` props, `__NEXT_DATA__` or the app router's flight scripts | rewrites the serialized string (coverage kind `island-props`) |
31
-
32
- It refuses, and says so on the publish, when the change would have to remove something:
33
- `TEXT_IN_CHILD_FIELD` (the words belong to a field declared inside this one),
34
- `TEXT_EMPTIES_ELEMENT` (a link, a scripted element or a wrapper with attributes would be left empty),
35
- `SPLIT_PATTERN_UNKNOWN` (split markup that is not one row of like tokens) and
36
- `ISLAND_PROPS_UNMATCHED` (a hydrated island draws the text from props that do not hold it).
37
- Each is fixed in the template with the recipe for its library.
38
-
39
- The last two rows follow a value only where the element was written into its text nodes. An
40
- operator can switch a project, or the fleet, to write every text value that way, so a plain-text
41
- value's copies and props follow it too; and can switch the text-node writer off, after which a
42
- publish replaces an element's content with plain text and refuses what that would break.
43
-
44
- ## 2. Two rules for every library
45
-
46
- 1. **Bind the element that holds the words, never the element the animation owns.** A pinned
47
- section, a scroll wrapper, a slider track and a motion container are layout. Their text
48
- leaves are the fields.
49
- 2. **Refresh measurements after an edit.** The editor posts `{ type: "bcms:content-applied" }`
50
- to the page's window after it applies an edit. Longer copy changes heights, so anything that
51
- measured the page must measure again:
52
-
53
- ```js
54
- window.addEventListener("message", (event) => {
55
- if (event.data?.type !== "bcms:content-applied") return;
56
- ScrollTrigger.refresh(); // or lenis.resize(), swiper.update(), split.revert() then split again
57
- });
58
- ```
59
-
60
- `onContentApplied(handler)` from `@bettercms-ai/sdk` is the same listener with the check
61
- written for you. On a published page nothing posts the message and the listener never runs.
62
-
63
- ## 3. GSAP and ScrollTrigger
64
-
65
- ```html
66
- <section class="pin-wrap"> <!-- trigger and pin: no field -->
67
- <div class="pin">
68
- <h2 data-bcms-field="story.title">Built to last</h2>
69
- <p data-bcms-field="story.lead">Forged, not cast.</p>
70
- </div>
71
- </section>
72
- ```
73
-
74
- ```js
75
- gsap.to(".pin h2", { yPercent: -20, scrollTrigger: { trigger: ".pin-wrap", pin: ".pin", scrub: 1 } });
76
- ```
77
-
78
- Never put `data-bcms-field` on `.pin-wrap` or `.pin`: ScrollTrigger wraps a pinned element in
79
- a spacer and writes inline styles on it every frame. Use `<BcmsField>` only around a single
80
- element, where it adds no wrapper (section 10); a wrapper span between `.pin` and its `h2`
81
- would break `.pin > h2`.
82
-
83
- Check: with the page open, `ScrollTrigger.getAll().length` is the same before and after a
84
- publish of every field on the route.
85
-
86
- ## 4. SplitText, SplitType, Splitting.js
87
-
88
- Bind the parent and say that it is split. Never bind a word or a character.
89
-
90
- ```html
91
- <h1 class="js-split" data-bcms-field="hero.title" data-bcms-split="words">Fast screws, shipped</h1>
92
- ```
93
-
94
- ```js
95
- let split = new SplitText(".js-split", { type: "words" });
96
- window.addEventListener("message", (event) => {
97
- if (event.data?.type !== "bcms:content-applied") return;
98
- split.revert();
99
- split = new SplitText(".js-split", { type: "words" });
100
- });
101
- ```
102
-
103
- `data-bcms-split` takes `chars`, `words` or `lines`. It tells the editor that the spans under
104
- this element are one value, and it tells a publish that the children are tokens even when their
105
- class is not `word`, `char` or `line`.
106
-
107
- When your own template writes the spans (`title.split(" ").map(...)`), keep them one row of
108
- the same tag, with whitespace or nothing between them, and no other element in the row:
109
-
110
- ```astro
111
- <h1 data-bcms-field="hero.title" data-bcms-split="words">
112
- {title.split(" ").map((word) => <span class="word">{word}</span>)}
113
- </h1>
114
- ```
115
-
116
- A `<br>` between two word spans makes the row unreadable once the word count changes
117
- (`SPLIT_PATTERN_UNKNOWN`): put the break in CSS, or split by lines.
118
-
119
- Check: publish a title with one more word, reload, and count `.word`: it equals the new word
120
- count and no word sits outside a span.
121
-
122
- ## 5. Framer Motion
123
-
124
- ```tsx
125
- <motion.section initial={{ opacity: 0 }} whileInView={{ opacity: 1 }}> {/* no field */}
126
- <motion.h2 data-bcms-field="story.title" variants={rise}>{story.title}</motion.h2>
127
- <p data-bcms-field="story.lead">{story.lead}</p>
128
- </motion.section>
129
- ```
130
-
131
- Put `data-bcms-field` straight on the motion element that renders the text: it passes
132
- `data-*` through to the DOM. Do not wrap a motion component in `<BcmsField>`; a component child
133
- keeps the wrapper span, and `layout` animations measure the wrong box. For words animated one
134
- by one, map them to `motion.span` under a parent carrying `data-bcms-split="words"`, as in
135
- section 4. The component hydrates, so section 9 applies to its props.
136
-
137
- Check: the built HTML for the route shows `data-bcms-field` on the `h2` itself, with no
138
- `display:contents` span around it.
139
-
140
- ## 6. Lenis and other smooth scroll
141
-
142
- Lenis moves the page, not the content, so no binding changes. Two things do:
143
-
144
- ```js
145
- const lenis = new Lenis();
146
- window.addEventListener("message", (event) => {
147
- if (event.data?.type === "bcms:content-applied") lenis.resize();
148
- });
149
- ```
150
-
151
- and any panel of yours that must scroll by itself inside the page takes `data-lenis-prevent`.
152
- Never bind the element Lenis is given as `wrapper` or `content`.
153
-
154
- In the Visual Editor the canvas owns the scroll: it marks the page's `<body>` with
155
- `data-lenis-prevent`, so the wheel there is the browser's own and has no easing. That is the
156
- editor, not a fault in the site: Lenis still reports every scroll (ScrollTrigger and anything
157
- else hung on `lenis.on("scroll")` runs), its own `scrollTo` still eases, and the published page
158
- is untouched. Do not remove the attribute or "repair" the easing for the canvas. What other
159
- scroll libraries need there is in section 12.
160
-
161
- Check: after an edit that adds two lines to a paragraph, the page still scrolls to the footer.
162
-
163
- ## 7. Marquees and duplicated tracks
164
-
165
- Render the track once with fields, and mark every copy:
166
-
167
- ```astro
168
- <div class="marquee">
169
- <ul class="track">{logos.map((l, i) => <li class="logo" data-bcms-field={`logos[${i}].name`}>{l.name}</li>)}</ul>
170
- <ul class="track" aria-hidden="true" data-bcms-clone>{logos.map((l) => <li class="logo">{l.name}</li>)}</ul>
171
- </div>
172
- ```
173
-
174
- A copy under `aria-hidden="true"` or `data-bcms-clone` is not counted as unowned text, and a
175
- publish writes it with the original when it is the same tag with the same classes and the same
176
- text. A copy with neither marker is separate copy: it counts against the route's text coverage
177
- and keeps the old words. A script that clones the track in the browser needs nothing: the clone
178
- is made from the published HTML on every load.
179
-
180
- Check: publish one logo name, then `document.querySelectorAll(".track")` shows the new name in
181
- every track.
182
-
183
- ## 8. Swiper and carousels
184
-
185
- Bind what is inside a slide, never `.swiper-wrapper` or `.swiper-slide`:
186
-
187
- ```html
188
- <div class="swiper-slide"><h3 data-bcms-field="slides[0].title">Torque</h3></div>
189
- ```
190
-
191
- Loop mode clones slides in the browser (`.swiper-slide-duplicate`); those are made from the
192
- published HTML and need no field. On `bcms:content-applied`, call `swiper.update()`.
193
-
194
- Check: `get_binding_report` lists no `slides[n]` path as pending for the route.
195
-
196
- ## 9. Hydrated islands and `client:only`
197
-
198
- A hydrated component draws itself again from the props the build serialized. Keep the text it
199
- shows equal to one string prop, whole:
200
-
201
- ```astro
202
- <Hero client:load title={page.hero.title} />
203
- ```
204
-
205
- ```tsx
206
- export default function Hero({ title }) { return <h1 data-bcms-field="hero.title">{title}</h1>; }
207
- ```
208
-
209
- A publish then rewrites that prop with the text. It cannot when the component builds the text
210
- (`${title}!`, `title.toUpperCase()`, a string literal in the component), or when the props hold
211
- the same words more than once, under a key such as `id` or `slug`, or as `__html`: the publish reports
212
- `ISLAND_PROPS_UNMATCHED` and the value goes live with the next build. Content passed as a
213
- slot is plain HTML and needs no prop.
214
-
215
- `client:only` renders nothing at build time, so the built page has no copy to own. The release
216
- names every such island on its route and the setup gate lists it as a `client-only-island`
217
- exception by itself. To make the copy editable, change the directive to `client:load` or
218
- `client:visible`, or pass the copy in as a slot.
219
-
220
- Check: view source on the built route, find the `astro-island`, and its `props` attribute holds
221
- the published title after a publish.
222
-
223
- ## 10. `<BcmsField>` adds no wrapper around one element
224
-
225
- ```astro
226
- <BcmsField path="hero.title"><h1 class="title">{page.hero.title}</h1></BcmsField>
227
- ```
228
-
229
- renders `<h1 class="title" data-bcms-field="hero.title">`: nothing sits between the parent and
230
- the heading. Text, several children, and in Next a component child are still wrapped in a
231
- `display:contents` span, and `as="div"` still gives a real wrapper. A site built with an older
232
- package version keeps its wrapper spans until it is rebuilt.
233
-
234
- `<BcmsSections>` follows the same rule for a whole section: the block id, the style marker and
235
- the block's style go on the section's own root, and in Astro the list is delimited by two HTML
236
- comments, never an element. `main > section`, `:first-child` and a ScrollTrigger on `.hero` match
237
- what they matched before. The one exception is a section that is not one element (several roots,
238
- an island, in Next a component child with no function form): it gets a box of ours, a node
239
- between `main` and the section. With no style on the block the box is `display:contents` and
240
- draws nothing. Once the block has a style the box is `display:flow-root` and carries it: one
241
- block box, so in a flex or grid parent its children are one item instead of several, and their
242
- margins stop collapsing with the neighbours'. Give such a section one root element, or the
243
- function form ([next.md](next.md)), when a selector or the layout depends on it.
244
-
245
- ## 11. Lottie, canvas, WebGL, video
246
-
247
- Pixels drawn by a script are not in the HTML, so nothing in them is a field by itself. What is
248
- editable is what you pass in:
249
-
250
- | Drawn by | Make editable by |
251
- |---|---|
252
- | 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. |
253
- | 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`. |
254
- | Video | The poster and the source are fields on the `<video>`; burned-in captions are not text. |
255
-
256
- Give the canvas `role="img"` and an `aria-label` bound to a field, or an adjacent caption. A
257
- scene with no text a visitor reads needs no field; record it under the `decorative` exception.
258
-
259
- A canvas drawn over the page (a fixed full-screen scene behind or above the copy) does not block
260
- the editor: a click looks through an element that is no field and no control to the field under
261
- it. Keep the copy in the HTML above or below the canvas, never only inside the scene.
262
-
263
- Check: change the bound string, publish, reload: the drawn text reads the new value.
264
-
265
- ## 12. In the editor's canvas
266
-
267
- The Visual Editor shows the real page with its scripts running, and owns three things there: the
268
- scroll (the page scrolls its own window, as it does live), the pointer (a click selects a field)
269
- and the place on the page (picking a field in the panel scrolls to it). Motion that reads the
270
- window's scroll or the clock runs there as it does live. Only what takes one of those three away
271
- has to stand down, and the site decides that with one question:
272
-
273
- ```js
274
- import { inEditor } from "@bettercms-ai/sdk"; // 1.31+. Without the SDK: window.name === "bcms-editor"
275
-
276
- const smooth = inEditor() ? null : new LocomotiveScroll({ el, smooth: true });
277
- ```
278
-
279
- `inEditor()` is true from the page's first script and through every navigation inside the
280
- canvas, and false on the published page. What each kind of page does there:
281
-
282
- | The page uses | In the canvas | You write |
283
- |---|---|---|
284
- | Native scroll, CSS scroll-driven animations (`animation-timeline`), Motion `useScroll` / `whileInView`, ScrollTrigger pin and scrub | runs as live | nothing |
285
- | Lenis, Locomotive Scroll 5 | runs; the wheel has no easing (section 6) | nothing |
286
- | GSAP ScrollSmoother | runs; the window still scrolls, the content eases behind it | nothing |
287
- | Locomotive Scroll 4, any script that locks the window and moves the content by a transform | the page works under its own wheel, but the editor cannot scroll it and a field picked in the panel is not brought into view | `smooth: !inEditor()`, so the window scrolls |
288
- | A wheel-driven slide deck (GSAP `Observer`, fullPage) | the same | do not create the observer when `inEditor()`; let the sections stack and scroll |
289
- | A scene with its own scroll layer (react-three-fiber `ScrollControls`, a fixed `overflow: auto` layer) | runs; picking a field scrolls that layer | nothing |
290
- | An intro or preloader that holds the page (`overflow: hidden` until it ends, a full-screen cover) | plays on every reload of the canvas, and covers the page while it does | skip it when `inEditor()` |
291
-
292
- Never use `inEditor()` to change what a field renders, to hide a section or to stop a timeline:
293
- the canvas must show what a visitor gets. It is for scroll takeover, intros and nothing else.
294
-
295
- Check: open the page in the Visual Editor, scroll with the wheel, drag the page's scrollbar to the
296
- footer, then pick a field of the first section in the panel: the canvas returns to it.
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`, `locomotive-scroll`, `framer-motion` or
9
+ `motion`, `swiper`, `split-type`, `splitting`, `lottie-web`, `three` or `@react-three/fiber`, or
10
+ when the built HTML carries
11
+ `astro-island`, split-text spans or a duplicated marquee track. Attribute grammar:
12
+ [bindings.md](bindings.md). Adapter wiring: [frameworks.md](frameworks.md).
13
+
14
+ ## 1. What a publish does to animated markup
15
+
16
+ The bound element picks the writer. One that holds text and nothing else is replaced whole, as
17
+ it always was. One that holds markup (inline elements, split spans, a `<br>`, or
18
+ `data-bcms-split`) is written into its text nodes, and every element inside it is kept. That is
19
+ what the recipes below rely on:
20
+
21
+ | The page has | A publish does |
22
+ |---|---|
23
+ | 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 |
24
+ | Text cut into `word` / `char` / `line` spans, same word count | writes each span in place |
25
+ | 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 |
26
+ | The same, spans written by your own template | rebuilds them from their own pattern, a new one copied from the last |
27
+ | A split headline under an `aria-label` that says the same words (SplitText's own output) | one field on the headline: writes the label and the words |
28
+ | A counter with its number in `data-target`, `data-count` or a like attribute | writes the attribute; writes the number shown only where it showed the old target, never a script's starting `0` |
29
+ | A copy of the value under `aria-hidden="true"` or `data-bcms-clone` | writes the copy too (coverage kind `duplicate-track`) |
30
+ | The value inside `astro-island` props, `__NEXT_DATA__` or the app router's flight scripts | rewrites the serialized string (coverage kind `island-props`) |
31
+
32
+ It refuses, and says so on the publish, when the change would have to remove something:
33
+ `TEXT_IN_CHILD_FIELD` (the words belong to a field declared inside this one),
34
+ `TEXT_EMPTIES_ELEMENT` (a link, a scripted element or a wrapper with attributes would be left empty),
35
+ `SPLIT_PATTERN_UNKNOWN` (split markup that is not one row of like tokens) and
36
+ `ISLAND_PROPS_UNMATCHED` (a hydrated island draws the text from props that do not hold it).
37
+ Each is fixed in the template with the recipe for its library.
38
+
39
+ The last two rows follow a value only where the element was written into its text nodes. An
40
+ operator can switch a project, or the fleet, to write every text value that way, so a plain-text
41
+ value's copies and props follow it too; and can switch the text-node writer off, after which a
42
+ publish replaces an element's content with plain text and refuses what that would break.
43
+
44
+ ## 2. Two rules for every library
45
+
46
+ 1. **Bind the element that holds the words, never the element the animation owns.** A pinned
47
+ section, a scroll wrapper, a slider track and a motion container are layout. Their text
48
+ leaves are the fields.
49
+ 2. **Refresh measurements after an edit.** The editor posts `{ type: "bcms:content-applied" }`
50
+ to the page's window after it applies an edit. Longer copy changes heights, so anything that
51
+ measured the page must measure again:
52
+
53
+ ```js
54
+ window.addEventListener("message", (event) => {
55
+ if (event.data?.type !== "bcms:content-applied") return;
56
+ ScrollTrigger.refresh(); // or lenis.resize(), swiper.update(), split.revert() then split again
57
+ });
58
+ ```
59
+
60
+ `onContentApplied(handler)` from `@bettercms-ai/sdk` is the same listener with the check
61
+ written for you. On a published page nothing posts the message and the listener never runs.
62
+
63
+ ## 3. GSAP and ScrollTrigger
64
+
65
+ ```html
66
+ <section class="pin-wrap"> <!-- trigger and pin: no field -->
67
+ <div class="pin">
68
+ <h2 data-bcms-field="story.title">Built to last</h2>
69
+ <p data-bcms-field="story.lead">Forged, not cast.</p>
70
+ </div>
71
+ </section>
72
+ ```
73
+
74
+ ```js
75
+ gsap.to(".pin h2", { yPercent: -20, scrollTrigger: { trigger: ".pin-wrap", pin: ".pin", scrub: 1 } });
76
+ ```
77
+
78
+ Never put `data-bcms-field` on `.pin-wrap` or `.pin`: ScrollTrigger wraps a pinned element in
79
+ a spacer and writes inline styles on it every frame. Use `<BcmsField>` only around a single
80
+ element, where it adds no wrapper (section 10); a wrapper span between `.pin` and its `h2`
81
+ would break `.pin > h2`.
82
+
83
+ Check: with the page open, `ScrollTrigger.getAll().length` is the same before and after a
84
+ publish of every field on the route.
85
+
86
+ ## 4. SplitText, SplitType, Splitting.js
87
+
88
+ Bind the parent and say that it is split. Never bind a word or a character.
89
+
90
+ ```html
91
+ <h1 class="js-split" data-bcms-field="hero.title" data-bcms-split="words">Fast screws, shipped</h1>
92
+ ```
93
+
94
+ ```js
95
+ let split = new SplitText(".js-split", { type: "words" });
96
+ window.addEventListener("message", (event) => {
97
+ if (event.data?.type !== "bcms:content-applied") return;
98
+ split.revert();
99
+ split = new SplitText(".js-split", { type: "words" });
100
+ });
101
+ ```
102
+
103
+ `data-bcms-split` takes `chars`, `words` or `lines`. It tells the editor that the spans under
104
+ this element are one value, and it tells a publish that the children are tokens even when their
105
+ class is not `word`, `char` or `line`.
106
+
107
+ When your own template writes the spans (`title.split(" ").map(...)`), keep them one row of
108
+ the same tag, with whitespace or nothing between them, and no other element in the row:
109
+
110
+ ```astro
111
+ <h1 data-bcms-field="hero.title" data-bcms-split="words">
112
+ {title.split(" ").map((word) => <span class="word">{word}</span>)}
113
+ </h1>
114
+ ```
115
+
116
+ A `<br>` between two word spans makes the row unreadable once the word count changes
117
+ (`SPLIT_PATTERN_UNKNOWN`): put the break in CSS, or split by lines.
118
+
119
+ Check: publish a title with one more word, reload, and count `.word`: it equals the new word
120
+ count and no word sits outside a span.
121
+
122
+ ## 5. Framer Motion
123
+
124
+ ```tsx
125
+ <motion.section initial={{ opacity: 0 }} whileInView={{ opacity: 1 }}> {/* no field */}
126
+ <motion.h2 data-bcms-field="story.title" variants={rise}>{story.title}</motion.h2>
127
+ <p data-bcms-field="story.lead">{story.lead}</p>
128
+ </motion.section>
129
+ ```
130
+
131
+ Put `data-bcms-field` straight on the motion element that renders the text: it passes
132
+ `data-*` through to the DOM. Do not wrap a motion component in `<BcmsField>`; a component child
133
+ keeps the wrapper span, and `layout` animations measure the wrong box. For words animated one
134
+ by one, map them to `motion.span` under a parent carrying `data-bcms-split="words"`, as in
135
+ section 4. The component hydrates, so section 9 applies to its props.
136
+
137
+ Check: the built HTML for the route shows `data-bcms-field` on the `h2` itself, with no
138
+ `display:contents` span around it.
139
+
140
+ ## 6. Lenis and other smooth scroll
141
+
142
+ Lenis moves the page, not the content, so no binding changes. Two things do:
143
+
144
+ ```js
145
+ const lenis = new Lenis();
146
+ window.addEventListener("message", (event) => {
147
+ if (event.data?.type === "bcms:content-applied") lenis.resize();
148
+ });
149
+ ```
150
+
151
+ and any panel of yours that must scroll by itself inside the page takes `data-lenis-prevent`.
152
+ Never bind the element Lenis is given as `wrapper` or `content`.
153
+
154
+ In the Visual Editor the canvas owns the scroll: it marks the page's `<body>` with
155
+ `data-lenis-prevent`, so the wheel there is the browser's own and has no easing. That is the
156
+ editor, not a fault in the site: Lenis still reports every scroll (ScrollTrigger and anything
157
+ else hung on `lenis.on("scroll")` runs), its own `scrollTo` still eases, and the published page
158
+ is untouched. Do not remove the attribute or "repair" the easing for the canvas. What other
159
+ scroll libraries need there is in section 12.
160
+
161
+ Check: after an edit that adds two lines to a paragraph, the page still scrolls to the footer.
162
+
163
+ ## 7. Marquees and duplicated tracks
164
+
165
+ Render the track once with fields, and mark every copy:
166
+
167
+ ```astro
168
+ <div class="marquee">
169
+ <ul class="track">{logos.map((l, i) => <li class="logo" data-bcms-field={`logos[${i}].name`}>{l.name}</li>)}</ul>
170
+ <ul class="track" aria-hidden="true" data-bcms-clone>{logos.map((l) => <li class="logo">{l.name}</li>)}</ul>
171
+ </div>
172
+ ```
173
+
174
+ A copy under `aria-hidden="true"` or `data-bcms-clone` is not counted as unowned text, and a
175
+ publish writes it with the original when it is the same tag with the same classes and the same
176
+ text. A copy with neither marker is separate copy: it counts against the route's text coverage
177
+ and keeps the old words. A script that clones the track in the browser needs nothing: the clone
178
+ is made from the published HTML on every load.
179
+
180
+ Check: publish one logo name, then `document.querySelectorAll(".track")` shows the new name in
181
+ every track.
182
+
183
+ ## 8. Swiper and carousels
184
+
185
+ Bind what is inside a slide, never `.swiper-wrapper` or `.swiper-slide`:
186
+
187
+ ```html
188
+ <div class="swiper-slide"><h3 data-bcms-field="slides[0].title">Torque</h3></div>
189
+ ```
190
+
191
+ Loop mode clones slides in the browser (`.swiper-slide-duplicate`); those are made from the
192
+ published HTML and need no field. On `bcms:content-applied`, call `swiper.update()`.
193
+
194
+ Check: `get_binding_report` lists no `slides[n]` path as pending for the route.
195
+
196
+ ## 9. Hydrated islands and `client:only`
197
+
198
+ A hydrated component draws itself again from the props the build serialized. Keep the text it
199
+ shows equal to one string prop, whole:
200
+
201
+ ```astro
202
+ <Hero client:load title={page.hero.title} />
203
+ ```
204
+
205
+ ```tsx
206
+ export default function Hero({ title }) { return <h1 data-bcms-field="hero.title">{title}</h1>; }
207
+ ```
208
+
209
+ A publish then rewrites that prop with the text. It cannot when the component builds the text
210
+ (`${title}!`, `title.toUpperCase()`, a string literal in the component), or when the props hold
211
+ the same words more than once, under a key such as `id` or `slug`, or as `__html`: the publish reports
212
+ `ISLAND_PROPS_UNMATCHED` and the value goes live with the next build. Content passed as a
213
+ slot is plain HTML and needs no prop.
214
+
215
+ `client:only` renders nothing at build time, so the built page has no copy to own. The release
216
+ names every such island on its route and the setup gate lists it as a `client-only-island`
217
+ exception by itself. To make the copy editable, change the directive to `client:load` or
218
+ `client:visible`, or pass the copy in as a slot.
219
+
220
+ Check: view source on the built route, find the `astro-island`, and its `props` attribute holds
221
+ the published title after a publish.
222
+
223
+ ## 10. `<BcmsField>` adds no wrapper around one element
224
+
225
+ ```astro
226
+ <BcmsField path="hero.title"><h1 class="title">{page.hero.title}</h1></BcmsField>
227
+ ```
228
+
229
+ renders `<h1 class="title" data-bcms-field="hero.title">`: nothing sits between the parent and
230
+ the heading. Text, several children, and in Next a component child are still wrapped in a
231
+ `display:contents` span, and `as="div"` still gives a real wrapper. A site built with an older
232
+ package version keeps its wrapper spans until it is rebuilt.
233
+
234
+ `<BcmsSections>` follows the same rule for a whole section: the block id, the style marker and
235
+ the block's style go on the section's own root, and in Astro the list is delimited by two HTML
236
+ comments, never an element. `main > section`, `:first-child` and a ScrollTrigger on `.hero` match
237
+ what they matched before. The one exception is a section that is not one element (several roots,
238
+ an island, in Next a component child with no function form): it gets a box of ours, a node
239
+ between `main` and the section. With no style on the block the box is `display:contents` and
240
+ draws nothing. Once the block has a style the box is `display:flow-root` and carries it: one
241
+ block box, so in a flex or grid parent its children are one item instead of several, and their
242
+ margins stop collapsing with the neighbours'. Give such a section one root element, or the
243
+ function form ([next.md](next.md)), when a selector or the layout depends on it.
244
+
245
+ ## 11. Lottie, canvas, WebGL, video
246
+
247
+ Pixels drawn by a script are not in the HTML, so nothing in them is a field by itself. What is
248
+ editable is what you pass in:
249
+
250
+ | Drawn by | Make editable by |
251
+ |---|---|
252
+ | 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. |
253
+ | 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`. |
254
+ | Video | The poster and the source are fields on the `<video>`; burned-in captions are not text. |
255
+
256
+ Give the canvas `role="img"` and an `aria-label` bound to a field, or an adjacent caption. A
257
+ scene with no text a visitor reads needs no field; record it under the `decorative` exception.
258
+
259
+ A canvas drawn over the page (a fixed full-screen scene behind or above the copy) does not block
260
+ the editor: a click looks through an element that is no field and no control to the field under
261
+ it. Keep the copy in the HTML above or below the canvas, never only inside the scene.
262
+
263
+ Check: change the bound string, publish, reload: the drawn text reads the new value.
264
+
265
+ ## 12. In the editor's canvas
266
+
267
+ The Visual Editor shows the real page with its scripts running, and owns three things there: the
268
+ scroll (the page scrolls its own window, as it does live), the pointer (a click selects a field)
269
+ and the place on the page (picking a field in the panel scrolls to it). Motion that reads the
270
+ window's scroll or the clock runs there as it does live. Only what takes one of those three away
271
+ has to stand down, and the site decides that with one question:
272
+
273
+ ```js
274
+ import { inEditor } from "@bettercms-ai/sdk"; // 1.31+. Without the SDK: window.name === "bcms-editor"
275
+
276
+ const smooth = inEditor() ? null : new LocomotiveScroll({ el, smooth: true });
277
+ ```
278
+
279
+ `inEditor()` is true from the page's first script and through every navigation inside the
280
+ canvas, and false on the published page. What each kind of page does there:
281
+
282
+ | The page uses | In the canvas | You write |
283
+ |---|---|---|
284
+ | Native scroll, CSS scroll-driven animations (`animation-timeline`), Motion `useScroll` / `whileInView`, ScrollTrigger pin and scrub | runs as live | nothing |
285
+ | Lenis, Locomotive Scroll 5 | runs; the wheel has no easing (section 6) | nothing |
286
+ | GSAP ScrollSmoother | runs; the window still scrolls, the content eases behind it | nothing |
287
+ | Locomotive Scroll 4, any script that locks the window and moves the content by a transform | the page works under its own wheel, but the editor cannot scroll it and a field picked in the panel is not brought into view | `smooth: !inEditor()`, so the window scrolls |
288
+ | A wheel-driven slide deck (GSAP `Observer`, fullPage) | the same | do not create the observer when `inEditor()`; let the sections stack and scroll |
289
+ | A scene with its own scroll layer (react-three-fiber `ScrollControls`, a fixed `overflow: auto` layer) | runs; picking a field scrolls that layer | nothing |
290
+ | An intro or preloader that holds the page (`overflow: hidden` until it ends, a full-screen cover) | plays on every reload of the canvas, and covers the page while it does | skip it when `inEditor()` |
291
+
292
+ Never use `inEditor()` to change what a field renders, to hide a section or to stop a timeline:
293
+ the canvas must show what a visitor gets. It is for scroll takeover, intros and nothing else.
294
+
295
+ Check: open the page in the Visual Editor, scroll with the wheel, drag the page's scrollbar to the
296
+ footer, then pick a field of the first section in the panel: the canvas returns to it.