sliderpro-agentic-skills-etch 0.2.0 → 0.2.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.
package/bin/cli.js CHANGED
@@ -140,6 +140,24 @@ function copyBundle(target) {
140
140
  }
141
141
  }
142
142
 
143
+ // When each skills file is meant to be read. The split exists so that only the
144
+ // entry file is read up front, so saying so here is part of the install.
145
+ const SKILL_ROLES = {
146
+ "slider-pro-skills.md": "read this first, every session",
147
+ "slider-pro-skills-build.md": "read only when building from scratch",
148
+ "slider-pro-skills-reference.md": "lookup only, never read whole",
149
+ };
150
+
151
+ function installedSkills(target) {
152
+ try {
153
+ return readdirSync(join(target, OUT_SKILLS_DIR))
154
+ .filter((f) => f.endsWith(".md"))
155
+ .sort();
156
+ } catch {
157
+ return [];
158
+ }
159
+ }
160
+
143
161
  function countComponents(target) {
144
162
  try {
145
163
  return readdirSync(join(target, OUT_COMPONENTS_DIR)).filter((f) => f.endsWith(".md")).length;
@@ -183,10 +201,13 @@ async function main() {
183
201
 
184
202
  console.log(`Installed Slider Pro AI Connector skills to ${target}`);
185
203
  console.log(` source: ${source}`);
186
- console.log(` ${OUT_SKILLS_DIR}/slider-pro-skills.md`);
187
- console.log(` ${OUT_SKILLS_DIR}/slider-pro-skills-reference.md`);
204
+ for (const name of installedSkills(target)) {
205
+ const role = SKILL_ROLES[name];
206
+ console.log(` ${OUT_SKILLS_DIR}/${name}${role ? ` (${role})` : ""}`);
207
+ }
188
208
  console.log(` ${OUT_COMPONENTS_DIR}/ (${countComponents(target)} component prop docs)`);
189
209
  console.log(`\nPoint your AI coding agent at ${OUT_SKILLS_DIR}/slider-pro-skills.md to load it.`);
210
+ console.log("It reads the other two only when a task needs them.");
190
211
  console.log('Then tell the agent: "npx @digital-gravy/etch-connector serve"');
191
212
  }
192
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliderpro-agentic-skills-etch",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Installs the Slider Pro for Etch AI Connector skills files and component docs into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,3 +58,41 @@ Set **Custom Pagination Mode** to `Template` and design the *first* item as your
58
58
 
59
59
  - **Numbers** count up starting from whatever number you type, so `1` produces `1, 2, 3, ...` while `2003` produces `2003, 2004, 2005, ...`. Leading zeros are preserved as the number grows (`007` → `007, 008, ..., 010, 011, ...`).
60
60
  - **Letters and roman numerals** (`a`, `A`, `i`, `I`) always start at the beginning of their sequence (a/b/c…, i/ii/iii…). Only numbers support a custom starting point.
61
+
62
+ ## Styling the active item
63
+
64
+ Slider Pro adds the class `is-active` to whichever pagination item matches the slide on screen. Use
65
+ it to style the current one:
66
+
67
+ ```css
68
+ .my-dot { background: #e5e7eb; }
69
+ .my-dot.is-active { background: #45bf55; }
70
+ ```
71
+
72
+ The class moves on its own as the slider changes, so you do not need any JavaScript. It works the
73
+ same in `Default` and `Template` mode.
74
+
75
+ ***
76
+
77
+ ## If your own layout does not apply
78
+
79
+ The pagination lays itself out as a flex row or column, using the **Direction** and **Gap**
80
+ settings above, and it is only as tall as its items.
81
+
82
+ If you write your own layout on it, for example a grid, and nothing seems to happen, this is why:
83
+ your rule and the plugin's rule are equally specific, so the plugin's wins. Put the plugin's class
84
+ in front of yours to make your rule stronger:
85
+
86
+ ```css
87
+ /* has no effect on its own */
88
+ .my-pagination { display: grid; }
89
+
90
+ /* wins */
91
+ .dwc-slider-pagination-wrapper.my-pagination {
92
+ display: grid;
93
+ block-size: 100%;
94
+ gap: 0;
95
+ }
96
+ ```
97
+
98
+ Remember to reset `gap` if you set your own spacing, because the flex gap still applies.
@@ -1,6 +1,10 @@
1
1
  {
2
- "generated": "2026-08-22",
2
+ "generated": "2026-08-29",
3
3
  "skills": [
4
+ {
5
+ "repo": "ai-connector/slider-skills/slider-pro-skills-build.md",
6
+ "out": "slider-skills/slider-pro-skills-build.md"
7
+ },
4
8
  {
5
9
  "repo": "ai-connector/slider-skills/slider-pro-skills-reference.md",
6
10
  "out": "slider-skills/slider-pro-skills-reference.md"
@@ -0,0 +1,438 @@
1
+ ---
2
+ icon: hammer
3
+ ---
4
+
5
+ # AI Skills Reference: Building
6
+
7
+ Read this when you are building a slider, a card deck or a template **from scratch**. For changing
8
+ a prop, adjusting CSS or fixing an existing build, `slider-pro-skills.md` (same folder) is enough.
9
+
10
+ Everything in `slider-pro-skills.md` still applies here: the native-first gate, the one-extra-brace
11
+ rule, the class mechanisms, the CSS conventions and the gotchas. This file adds only what building
12
+ from nothing needs. Read it after that file, never instead of it.
13
+
14
+ ***
15
+
16
+ ## Block JSON
17
+
18
+ ### Component instances are the unit of work
19
+
20
+ A Slider Pro build is a tree of `etch/component` blocks, each naming a component and carrying a
21
+ flat `attributes` map of prop values. **Two different shapes describe the same tree, and mixing
22
+ them up is the first thing that goes wrong in a session.**
23
+
24
+ **The live shape**, which `etch.blocks.getTree()` returns and `etch.blocks.create()` accepts:
25
+
26
+ ```json
27
+ {
28
+ "type": "etch/component",
29
+ "version": 1,
30
+ "context": {},
31
+ "children": [],
32
+ "componentId": 6455,
33
+ "attributes": { "wrapperHeight": "350px", "spaceBetweenSliders": "50px" }
34
+ }
35
+ ```
36
+
37
+ `version`, `context` and `children` are required. Block ids are strings (`"ot2wnef"`), not numbers.
38
+
39
+ **The export shape**, which you only ever see inside a premade template `.json` file, uses
40
+ `blockName`, nests under `attrs`, and calls the component id `ref`:
41
+
42
+ ```json
43
+ { "blockName": "etch/component", "attrs": { "ref": 6455, "attributes": {} }, "innerBlocks": [] }
44
+ ```
45
+
46
+ Never pass the export shape to `create()`. It is rejected with
47
+ `Invalid block JSON: expected string, received undefined` at `path: ["type"]`. To put a template on
48
+ the page, hand its whole `gutenbergBlock` to `etch.blocks.pasteAsync()` instead.
49
+
50
+ ### Children go in a slot, never directly on the component
51
+
52
+ A component's children must be wrapped in an `etch/slot-content` node naming the slot they fill.
53
+ Putting a Slider straight inside a Wrapper's `children` renders nothing.
54
+
55
+ ```json
56
+ { "type": "etch/slot-content", "version": 1, "context": {}, "children": [], "slotName": "Slides" }
57
+ ```
58
+
59
+ | Component | Slots |
60
+ | --- | --- |
61
+ | DWC Slider Wrapper | `Sliders_and_Controls` |
62
+ | DWC Slider | `Top__Controls`, `Slides`, `Bottom__Controls` |
63
+ | DWC Slide | `Content` |
64
+ | DWC Slider Pagination | `PaginationButtons` |
65
+ | DWC Slider Nav Button | `Nav_Btn_Content` |
66
+ | DWC Slider Progress, DWC Slider Play-Pause | none |
67
+
68
+ Controls are siblings of the Slider inside `Sliders_and_Controls`, or children of the Slider's own
69
+ `Top__Controls` / `Bottom__Controls`. Both work, because controls find their own slider.
70
+
71
+ ***
72
+
73
+ ## Design recipes
74
+
75
+ Each of these is a shipped template reduced to the settings that produce it.
76
+
77
+ ### Cover-flow carousel (Slider Flow): no script, no CSS
78
+
79
+ The whole effect is the `slides.*` group, which applies transforms to inactive slides and separate
80
+ `-Active` values to the current one.
81
+
82
+ ```js
83
+ setGroup(sliderId, 'layout', { slidesPerPage: '3 lg:1', gapBetweenSlides: '20px',
84
+ sliderEdgeOffset: 'lg:20% md:18%' });
85
+ setGroup(sliderId, 'motion', { focus: 'center', loop: '{true}', speed: '800',
86
+ updateOnMove: '{true}' });
87
+ setGroup(sliderId, 'slides', { perspective: '950px', opacity: '0.3', scale: '0.9',
88
+ borderRadius: '1rem',
89
+ transition: 'transform 0.5s ease, opacity 0.5s ease' });
90
+ setGroup(sliderId, 'dimensions', { sliderHeight: '350px md:300px sm:180px' });
91
+ ```
92
+
93
+ `motion.focus: 'center'` is what puts the active slide in the middle. The neighbours are dimmed and
94
+ shrunk purely by `slides.opacity` and `slides.scale`, which apply to inactive slides only.
95
+
96
+ For an angled flip, add `slides.rotateY` and turn on `slides.flipNextRotateY` so the slide on the
97
+ other side mirrors the angle instead of repeating it. Same for `translateX` with
98
+ `flipNextTranslateX`.
99
+
100
+ ### Logo marquee (Slider Marquee): no script
101
+
102
+ Two Sliders in one Wrapper, running in opposite directions.
103
+
104
+ ```js
105
+ // both rows
106
+ setGroup(sliderId, 'dimensions', { slideAutoWidth: '{true}' }); // slides size to content
107
+ setGroup(sliderId, 'motion', { loop: '{true}' });
108
+ setGroup(sliderId, 'autoscroll', { infiniteScroll: '{true}', scrollSpeed: '1',
109
+ pauseOnHover: '{false}', pauseOnFocus: '{false}' });
110
+ setGroup(sliderId, 'layout', { gapBetweenSlides: '2.4rem' });
111
+
112
+ // second row only
113
+ setGroup(secondId, 'sliderSetup', { slldeDirection: 'rtl' }); // note the spelling
114
+ ```
115
+
116
+ Edge Fade goes on the **Wrapper** (`props.edgeFade`), not the sliders, so both rows fade as one
117
+ unit. `props.pauseSlidersOnHover` on the Wrapper pauses both rows together.
118
+
119
+ Infinite Scroll needs the Auto-Scroll extension enabled in admin settings, which is the default.
120
+
121
+ ### Synced elements (Team, Stack)
122
+
123
+ Driving arbitrary elements on the page from the slider is one prop.
124
+
125
+ ```js
126
+ setGroup(sliderId, 'sliderSetup', { syncCustomElement: '.timeline-node',
127
+ syncCustomElementNav: '{true}' });
128
+ ```
129
+
130
+ The plugin then moves `is-active`, `is-prev` and `is-next` across those elements as the slider
131
+ moves, and with nav on, clicking one jumps the slider to it. **You write the CSS for the three
132
+ states.** The elements do not need to be inside the slider and do not need to match the slide
133
+ count.
134
+
135
+ The selector takes a comma-separated list, so one slider can drive several groups at once. Team
136
+ uses `'.slider-team__sync, .slider-team__sync-heading'` to move a portrait and a heading together.
137
+
138
+ Grep `## 3. Sync Custom Element` in the reference for multiple selectors and the overlap caveat.
139
+
140
+ ### Main and thumbnails in one wrapper (Zeon)
141
+
142
+ A full-bleed hero with a thumbnail strip needs no sync prop at all, just roles:
143
+
144
+ ```js
145
+ setGroup(mainId, 'sliderSetup', { sliderRole: 'main', transitionType: 'Fade' });
146
+ setGroup(thumbId, 'sliderSetup', { sliderRole: 'thumbnails', transitionType: 'Loop' });
147
+ ```
148
+
149
+ Both inside the same Wrapper and they pair automatically. A Wrapper can hold more than one
150
+ thumbnail slider, which is how Zeon runs a background layer and a strip off the same main.
151
+
152
+ ### Animating slide content: no script
153
+
154
+ Splide puts `is-active` on the current `.splide__slide`, which is all you need to animate anything
155
+ inside it. Give the element its resting state, then reveal it from the active slide:
156
+
157
+ ```css
158
+ /* in .slider-<name>__figure's own entry */
159
+ opacity: 0;
160
+ transform: translateY(30px);
161
+ transition: opacity 820ms cubic-bezier(0.22, 0.68, 0.24, 1),
162
+ transform 820ms cubic-bezier(0.22, 0.68, 0.24, 1);
163
+
164
+ .splide__slide.is-active & { opacity: 1; transform: translateY(0); }
165
+ ```
166
+
167
+ Stagger a caption by putting the same pair on each line with a growing `transition-delay`
168
+ (thumbnail 140ms, status 200ms, title 260ms, body 320ms) so the block assembles rather than
169
+ appearing at once.
170
+
171
+ Two things that bite:
172
+
173
+ * **Keep layout transforms in the animated transform.** An element centred with
174
+ `translateX(-50%)` must animate as `translateX(-50%) translateY(30px)`, or it jumps sideways on
175
+ every transition. Where a breakpoint drops the centring, restate the transform there too.
176
+ * **A slow drift on the backdrop** (`scale(1.06)` to `scale(1)` over several seconds) makes a
177
+ change read as movement rather than a cut. Put it on the image, not the slide, so it does not
178
+ fight the transition.
179
+
180
+ This works with the Fade transition, which crossfades the slides underneath while the contents
181
+ move independently.
182
+
183
+
184
+ ### Numbered steps beside a slider: no script
185
+
186
+ A vertical list of steps next to an image, where the number highlights as the image changes, uses
187
+ **two mechanisms on one slider**, split by whether the content repeats:
188
+
189
+ * **What repeats goes in the pagination.** The numbered markers are identical except for the digit,
190
+ which is exactly what `customPaginationMode: 'Template'` does: put one item in the
191
+ `PaginationButtons` slot with a literal `1` in it and the engine clones it per slide, substituting
192
+ the number. Add a slide later and the marker appears by itself. The active marker gets
193
+ `is-active`, and each is a real button, so clicking navigates.
194
+ * **What differs goes in a synced set.** Titles and descriptions are per step, and a clone cannot
195
+ carry them, so those are your own elements driven by
196
+ `sliderSetup.syncCustomElement` with `syncCustomElementNav` on.
197
+
198
+ ```js
199
+ setGroup(sliderId, 'sliderSetup', { transitionType: 'Fade',
200
+ syncCustomElement: '.step',
201
+ syncCustomElementNav: '{true}' });
202
+ etch.blocks.setAttribute(pagId, 'customPaginationMode', 'Template');
203
+ ```
204
+
205
+ Do not force one mechanism to do both. Template mode cannot give each clone its own text, and
206
+ faking it with a script to fill the clones is the kind of work the native features already cover.
207
+
208
+ **Give the template two levels.** The cloned root has to stretch to its row so it can carry the
209
+ connecting line, while the circle stays a fixed square at the top:
210
+
211
+ ```
212
+ .mark <- the cloned root; receives is-active, fills the row, draws the line
213
+ .mark-dot <- the circle and the number
214
+ ```
215
+
216
+ State travels from the root to the dot through custom properties, so no descendant selector has to
217
+ fight the plugin for the same box:
218
+
219
+ ```css
220
+ .mark { --dot-bg: #e5e7eb; --dot-fg: #4b5563; }
221
+ .mark.is-active { --dot-bg: #45bf55; --dot-fg: #fff; }
222
+ .mark-dot { background: var(--dot-bg); color: var(--dot-fg); }
223
+ ```
224
+
225
+ **Let the row count come from the slides, not the stylesheet.** Put both columns in one parent row
226
+ so they are the same height, then give each `grid-auto-rows`. Marker *n* then lines up with card
227
+ *n* whatever the copy length, and adding a slide needs no CSS change:
228
+
229
+ ```css
230
+ .rail { display: grid; grid-template-columns: auto 1fr; gap: 0 to-rem(24px); }
231
+ .marks,
232
+ .steps { display: grid; grid-auto-rows: minmax(0, 1fr); }
233
+ ```
234
+
235
+ **Hang the connecting line off each marker, not the container.** A single line drawn on the
236
+ container has to be told where to stop, which means hardcoding the count. Per marker it is
237
+ self-terminating:
238
+
239
+ ```css
240
+ .mark:not(:last-child)::after {
241
+ content: ""; position: absolute;
242
+ inset-inline-start: 50%; transform: translateX(-50%);
243
+ inset-block-start: calc(var(--dot-top) + var(--dot-size));
244
+ inset-block-end: 0;
245
+ inline-size: to-rem(2px); background: #e5e7eb;
246
+ }
247
+ ```
248
+
249
+ The last marker gets no line for free, at any slide count.
250
+
251
+ Three traps, all silent:
252
+
253
+ * **Use `minmax(0, 1fr)`, not `1fr`.** `1fr` means `minmax(auto, 1fr)`, so a longer description
254
+ expands its own row and the two columns drift apart by a few pixels per row.
255
+ * **Match `gap` on both columns.** The pagination carries a flex `gap` of its own, which shortens
256
+ every row on that side only. Set it explicitly, including `0`.
257
+ * **The pagination will not accept your layout without a co-class.** It sets `display: flex` and
258
+ `block-size: fit-content` on `.dwc-slider-pagination-wrapper`, which ties with a single class of
259
+ yours and wins on order. Write the override **inside the entry its class prop already points at**,
260
+ so it stays findable from the element:
261
+
262
+ ```css
263
+ /* the .marks entry itself */
264
+ position: relative;
265
+
266
+ &.dwc-slider-pagination-wrapper {
267
+ display: grid; grid-auto-rows: minmax(0, 1fr); block-size: 100%; gap: 0;
268
+ }
269
+ ```
270
+
271
+
272
+ ### Carousel on mobile, grid on desktop
273
+
274
+ ```js
275
+ setGroup(sliderId, 'layout', { layoutMode: 'static md:slider', gridColumns: '3 sm:1' });
276
+ ```
277
+
278
+ Above `md` it is a plain CSS grid; at `md` and below it is a real carousel. The slider is built and
279
+ torn down live as the viewport crosses the breakpoint. **Controls are hidden in static mode**,
280
+ because there is no live slider to drive.
281
+
282
+ ### Card decks with no slider (Deck, Fall): the one place script is allowed
283
+
284
+ Structure: a Wrapper with `sliderlessSync.customElement` pointing at your cards, no Slider inside,
285
+ cards nested three deep as `card > lift > content` so the 3-D survives.
286
+
287
+ ```js
288
+ setGroup(wrapperId, 'sliderlessSync', {
289
+ customElement: '.deck-card', customElementNav: '{true}',
290
+ loop: '{true}', autoplay: '{true}', autoplayInterval: '4000'
291
+ });
292
+ ```
293
+
294
+ CSS handles the front card and its two neighbours from `is-active` / `is-prev` / `is-next`. What
295
+ CSS cannot do is know that a given card is *three* back, so the rows behind, the z-index ladder and
296
+ the click targets need a script. That is case 1 of the native-first gate.
297
+
298
+ The contract between that script and the CSS is one attribute, **`data-pos`**, which the script
299
+ derives from `is-active` and writes on every card. It is a **signed** offset from the active card,
300
+ so the CSS can fan the left and right sides in opposite directions:
301
+
302
+ ```css
303
+ .deck-stack-featured &:is([data-pos='2'], [data-pos='-2']) { /* ring 2, both sides */ }
304
+ ```
305
+
306
+ The stack container carries **`data-tiers`**, how many rings are actually in play. It is computed,
307
+ not fixed: a looping deck needs spare cards to hide the wrap behind, so the script reduces the
308
+ tiers when there are too few cards.
309
+
310
+ Writing an unsigned rank, or naming the attribute anything else, produces a flat pile: the rules
311
+ above simply never match. The shipped script does this:
312
+
313
+ ```js
314
+ // 1. make a custom property interpolable, so a blur can animate (case 2)
315
+ CSS.registerProperty({ name: '--stack-focus', syntax: '<number>',
316
+ inherits: true, initialValue: '0' });
317
+
318
+ // 2. how many rings this deck can afford, given looping and the card count
319
+ var mayLoop = wrap.dataset.loop === 'true' && total >= MIN_LOOP;
320
+ stack.dataset.tiers = mayLoop ? Math.min(MAX_TIERS, Math.floor((total - 3) / 2)) : MAX_TIERS;
321
+
322
+ // 3. per card: SIGNED offset from the active card. On a looping deck the offset
323
+ // wraps, so the far end of the list reads as the near side, not as distance 8.
324
+ var d = n - active;
325
+ if (mayLoop) {
326
+ if (d > total / 2) d -= total;
327
+ if (d < -total / 2) d += total;
328
+ }
329
+ card.dataset.pos = d;
330
+
331
+ // 4. hit-testing: stack order by distance, and clicks off beyond the clickable rings
332
+ var rank = Math.abs(d);
333
+ card.style.zIndex = String(total - rank);
334
+ lift.style.pointerEvents = rank <= NAV_RINGS ? 'auto' : 'none';
335
+ ```
336
+
337
+ Do not clamp `data-pos` to the tier count. Cards past the deepest ring should match no rule and
338
+ stay stacked at the back; clamping piles them onto the last visible ring instead.
339
+
340
+ Both decks also set `--i` on each line of card content so the CSS can stagger it:
341
+
342
+ ```js
343
+ card.querySelectorAll('.deck-card-featured__content > *')
344
+ .forEach(function (line, j) { line.style.setProperty('--i', j); });
345
+ ```
346
+
347
+ Step 4 is Deck only. **Fall's script is much smaller**: it sets `--i` and a signed `data-pos`, and
348
+ nothing else. Fall never loops, so there is no wrap to fold and no z-index ladder to maintain,
349
+ because the cards fall past each other rather than fanning around a front card.
350
+
351
+ Recompute on every change: watch the stack with a `MutationObserver` filtered to `class`, since
352
+ `is-active` moving is the only signal you get.
353
+
354
+ ### Where a script goes
355
+
356
+ **Not in a `<script>` element.** Etch silently drops one from the render, so the page looks right
357
+ in the builder and ships with no behaviour at all.
358
+
359
+ Every block has an optional `script` field (`EtchBlockScript`), which is Etch's JavaScript code
360
+ block. Put the code on the **component instance that owns the markup**, which for a card stack is
361
+ the Wrapper:
362
+
363
+ ```js
364
+ const wrapperId = etch.blocks.create({
365
+ type: 'etch/component', version: 1, context: {}, children: [ /* ... */ ],
366
+ componentId: C['DWC Slider Wrapper'], attributes: {},
367
+ script: { code: "(function () { /* ... */ })();" }
368
+ });
369
+ ```
370
+
371
+ `code` is **plain source** over the API. The base64 you see in a premade template `.json` is only
372
+ how the export serialises it; do not encode it yourself.
373
+
374
+ Etch renders it as `<script type="module" defer>`, so it runs after parsing and top-level `await`
375
+ is available. Read it back with `etch.blocks.getJson(id).script`.
376
+
377
+ Component scripts are also the reason a card deck keeps working: the bridge leaves Sync Without
378
+ Slider wrappers unreconstructed precisely so an author's script keeps observing the original nodes.
379
+
380
+ Card counts for a looping deck: three are always visible (front plus two), and a loop needs a
381
+ hidden slot at each end, so **five is the minimum** and nine gives the full three-row fan. Fall
382
+ never loops, so it has no minimum.
383
+
384
+ ***
385
+
386
+ ## Block builders
387
+
388
+ These extend the four helpers in `slider-pro-skills.md`. Include both sets.
389
+
390
+ ```js
391
+ function node(extra, children) {
392
+ return Object.assign({ version: 1, context: {}, children: children || [] }, extra);
393
+ }
394
+
395
+ function component(componentId, attributes, children) {
396
+ return node({ type: 'etch/component', componentId: componentId,
397
+ attributes: attributes || {} }, children);
398
+ }
399
+
400
+ function slot(slotName, children) {
401
+ return node({ type: 'etch/slot-content', slotName: slotName }, children);
402
+ }
403
+
404
+ function el(tag, attributes, children) {
405
+ return node({ type: 'etch/element', tag: tag, attributes: attributes || {} }, children);
406
+ }
407
+
408
+ function text(t) { return node({ type: 'etch/text', text: t }); }
409
+ ```
410
+
411
+ ### Build a wrapper with N slides
412
+
413
+ ```js
414
+ const C = comps();
415
+ const slides = [];
416
+ for (let i = 0; i < 5; i++) {
417
+ slides.push(component(C['DWC Slide'], {}, [
418
+ slot('Content', [ el('div', {}, [ text('Slide ' + (i + 1)) ]) ])
419
+ ]));
420
+ }
421
+
422
+ const id = etch.blocks.create(
423
+ component(C['DWC Slider Wrapper'], {}, [
424
+ slot('Sliders_and_Controls', [
425
+ component(C['DWC Slider'], {}, [ slot('Slides', slides) ]),
426
+ component(C['DWC Slider Nav Button'], {}),
427
+ component(C['DWC Slider Pagination'], {})
428
+ ])
429
+ ])
430
+ );
431
+ return id;
432
+ ```
433
+
434
+ An empty Slide has no content and so no height. Give slides something to size before wondering why
435
+ the slider is a flat line.
436
+
437
+ Build **one** first, screenshot it, then scale to the full count. Do not generate twenty slides
438
+ before you have seen one render.
@@ -189,6 +189,7 @@ matches the components installed on the site. Do not hand-edit this section.
189
189
  | Background Blur | `props.style.backgroundBlur` | `style` | `8px` | Shown when `props.progressType === "counter" \|\| (props.progressType === "circular" && props.circularCounter)` |
190
190
  | Progress Fill Color | `props.style.progressFillColor` | `style` | `#ff4d6a` | Shown when `props.progressType === "bar" \|\| props.progressType === "circular"` |
191
191
  | Progress Fill Color 2 | `props.style.progressFillColor2` | `style` | `#ff8fa3` | Shown when `props.progressType === "bar"` |
192
+ | Progress Height | `props.style.progressHeight` | `style` | `3px` | Shown when `props.progressType === "bar"` |
192
193
  | Progress Track Color | `props.style.progressTrackColor` | `style` | `rgba(255, 255, 255, 0.1)` | Shown when `props.progressType === "bar" \|\| props.progressType === "circular"` |
193
194
  | Background Color | `props.style.backgroundColor` | `style` | `rgba(0, 0, 0, 0.4)` | Shown when `props.progressType === "counter"` |
194
195
  | Border Radius | `props.style.borderRadius` | `style` | `20px` | Shown when `props.progressType === "counter"` |
@@ -280,9 +281,13 @@ Arrows, dots, pagination, progress, play/pause and the lightbox are styled with
280
281
  properties, not props. The template class is **`.slider-navigation-vars`**, holding 94 variables.
281
282
 
282
283
  **Per-slider styling.** Each Slider's **Slider Class** prop (`props.sliderClass`) names the class
283
- its variables are read from, so two sliders on one page style independently. The plugin renames
284
- the auto-generated class on save, so never style directly on `.slider-navigation-vars`: add your
285
- own class (e.g. `.my-slider`) and style that. Classes you add yourself are never renamed.
284
+ its variables are read from, so two sliders on one page style independently. The plugin gives each
285
+ slider its own minted `.dwc-slider-vars-*` copy of that class, and mints a fresh one whenever a
286
+ slider is duplicated. So never style directly on `.slider-navigation-vars`, and never write that
287
+ shared class into the prop: doing so stops the minting and makes every duplicate share one set of
288
+ variables. Add your own class (e.g. `.my-slider`) alongside the minted one and style that. Classes
289
+ you add yourself are never touched. See "`sliderClass` carries a minted per-instance class" in
290
+ [slider-pro-skills.md](slider-pro-skills.md).
286
291
 
287
292
  ### Arrows
288
293
 
@@ -4,9 +4,13 @@ icon: robot
4
4
 
5
5
  # AI Skills Reference
6
6
 
7
- Everything an agent needs to build and configure **Slider Pro for Etch** through the Etch AI
8
- Connector. Read this file in full at session start. Its companion,
9
- `slider-pro-skills-reference.md`, is lookup-only: Grep into it, never read it whole.
7
+ Everything an agent needs to configure **Slider Pro for Etch** through the Etch AI Connector. Read
8
+ this file in full at session start. It has two companions in the same folder, and neither is read
9
+ up front:
10
+
11
+ * **`slider-pro-skills-build.md`** is the building guide: block JSON, slots, the design recipes
12
+ and the block builders. Read it when you are creating something from scratch, and not otherwise.
13
+ * **`slider-pro-skills-reference.md`** is lookup-only. Grep into it, never read it whole.
10
14
 
11
15
  ### When to consult the reference file
12
16
 
@@ -106,8 +110,15 @@ npx @digital-gravy/etch-connector eval "return etch.blocks.getTree().length" -t
106
110
  npx @digital-gravy/etch-connector eval -f ./script.js -t "<tab>" --timeout 30000
107
111
  ```
108
112
 
109
- Anything longer than one expression goes in a file with `-f`. Inline multi-line quoting breaks in
110
- every shell eventually.
113
+ Anything longer than one expression goes in a file with `-f`, and use an **absolute path**: a bare
114
+ `./script.js` resolves against whatever directory happens to be current.
115
+
116
+ **Write that file with your file-writing tool, never a shell heredoc.** Script bodies are full of
117
+ quotes, backticks, `$` and regex, all of which the shell tries to interpret first. `cat > f <<'EOF'`
118
+ fails to parse and writes nothing, so you lose the call and learn nothing. Write the file directly
119
+ and the shell never sees the contents.
120
+
121
+ Keep every temp script in a working subdirectory you made for the task, and delete it when done.
111
122
 
112
123
  ### Safe mode
113
124
 
@@ -143,64 +154,13 @@ etch.components.list() // [{ id, name }, ...]
143
154
  etch.components.getJson(id) // .properties and .blocks
144
155
  etch.styles.list() // [{ id, selector, type, collection, css }]
145
156
  etch.styles.create(selector, css) // returns a STYLE ID; pass it to a class prop
157
+ etch.styles.update(id, { selector?, css? }) // NOTE the object. See the warning below
158
+ etch.styles.delete(id)
146
159
  etch.blocks.addClass(blockId, name) // plain elements only; throws on a component
147
160
  etch.blocks.getJson(id).script // { code }, the JavaScript code block
148
161
  await etch.saveAsync()
149
162
  ```
150
163
 
151
- ### Component instances are the unit of work
152
-
153
- A Slider Pro build is a tree of `etch/component` blocks, each naming a component and carrying a
154
- flat `attributes` map of prop values. **Two different shapes describe the same tree, and mixing
155
- them up is the first thing that goes wrong in a session.**
156
-
157
- **The live shape**, which `etch.blocks.getTree()` returns and `etch.blocks.create()` accepts:
158
-
159
- ```json
160
- {
161
- "type": "etch/component",
162
- "version": 1,
163
- "context": {},
164
- "children": [],
165
- "componentId": 6455,
166
- "attributes": { "wrapperHeight": "350px", "spaceBetweenSliders": "50px" }
167
- }
168
- ```
169
-
170
- `version`, `context` and `children` are required. Block ids are strings (`"ot2wnef"`), not numbers.
171
-
172
- **The export shape**, which you only ever see inside a premade template `.json` file, uses
173
- `blockName`, nests under `attrs`, and calls the component id `ref`:
174
-
175
- ```json
176
- { "blockName": "etch/component", "attrs": { "ref": 6455, "attributes": {} }, "innerBlocks": [] }
177
- ```
178
-
179
- Never pass the export shape to `create()`. It is rejected with
180
- `Invalid block JSON: expected string, received undefined` at `path: ["type"]`. To put a template on
181
- the page, hand its whole `gutenbergBlock` to `etch.blocks.pasteAsync()` instead.
182
-
183
- ### Children go in a slot, never directly on the component
184
-
185
- A component's children must be wrapped in an `etch/slot-content` node naming the slot they fill.
186
- Putting a Slider straight inside a Wrapper's `children` renders nothing.
187
-
188
- ```json
189
- { "type": "etch/slot-content", "version": 1, "context": {}, "children": [], "slotName": "Slides" }
190
- ```
191
-
192
- | Component | Slots |
193
- | --- | --- |
194
- | DWC Slider Wrapper | `Sliders_and_Controls` |
195
- | DWC Slider | `Top__Controls`, `Slides`, `Bottom__Controls` |
196
- | DWC Slide | `Content` |
197
- | DWC Slider Pagination | `PaginationButtons` |
198
- | DWC Slider Nav Button | `Nav_Btn_Content` |
199
- | DWC Slider Progress, DWC Slider Play-Pause | none |
200
-
201
- Controls are siblings of the Slider inside `Sliders_and_Controls`, or children of the Slider's own
202
- `Top__Controls` / `Bottom__Controls`. Both work, because controls find their own slider.
203
-
204
164
  ### Classes: two different mechanisms
205
165
 
206
166
  Adding a class to a plain element and adding one to a component are **not the same operation**, and
@@ -230,8 +190,7 @@ The class-typed props, one per component:
230
190
  **On a plain element**, use `addClass` with the bare class name, no dot and no id:
231
191
 
232
192
  ```js
233
- const id = etch.blocks.create(el('article', {}, []));
234
- etch.blocks.addClass(id, 'fall-card');
193
+ etch.blocks.addClass(plainElementId, 'fall-card'); // no dot, no style id
235
194
  ```
236
195
 
237
196
  Passing a style id here does not resolve. It is treated as a literal name and sanitised, so
@@ -275,6 +234,35 @@ Inside an element's **own** entry, nesting is preferred for media queries, pseud
275
234
  (`.splide__slide.is-active &`) and child tag selectors (`& img`, `& svg`). Do **not** nest another
276
235
  class's rules (`&__title`) inside a different class's entry: that class gets its own entry.
277
236
 
237
+ **Wrap px values in `to-rem()`.** Etch preprocesses style-entry CSS and converts them, so write
238
+ `to-rem(80px)`, never a raw `80px` and never a hand-converted `5rem`. It works inside media queries
239
+ too. Leave unitless values alone (`z-index`, `opacity`, `line-height`, aspect ratios).
240
+
241
+ ```css
242
+ --offset: to-rem(80px);
243
+ @media (width <= to-rem(767px)) { --offset: to-rem(40px); }
244
+ ```
245
+
246
+ **Every rule for an element belongs in the entry its class prop already points at.** It is tempting
247
+ to answer a specificity problem with a second, more specific entry, for example
248
+ `.dwc-slider-pagination-wrapper.my-class`. Do not. That entry is attached to no block, so selecting
249
+ the element in Etch does not reveal it and the author cannot find the rule to edit. Nest the
250
+ stronger selector inside the element's own entry instead, which gives the same specificity:
251
+
252
+ ```css
253
+ /* inside the .my-class entry */
254
+ &.dwc-slider-pagination-wrapper { display: grid; }
255
+ ```
256
+
257
+ A style entry that no block references also survives deleting the element it was written for.
258
+
259
+
260
+ **`etch.styles.update` takes an object, and a string fails silently.** The signature is
261
+ `update(id, { selector, css })` and it reads `arg.css`. Hand it a bare CSS string and `arg.css` is
262
+ `undefined`, so it falls back to the entry's existing CSS, writes that back unchanged, and **throws
263
+ nothing**. The call looks like it worked. Read the entry back from `etch.styles.list()` and compare
264
+ the CSS before believing an update landed.
265
+
278
266
  **Renaming is not one operation.** A style entry's selector and a block's `class` attribute are
279
267
  independent. Renaming the selector rewrites the rendered class only where the class came from a
280
268
  **component class prop**, because that prop stores the style id and resolves it at render. On a
@@ -314,16 +302,50 @@ the prop reference.
314
302
 
315
303
  ### Visual verification
316
304
 
317
- You cannot see the page from inside safe mode. After any visual change:
305
+ You cannot see the page from inside safe mode, and a read-back is not a substitute. A
306
+ `getAttribute` read proves a value **persisted**; only looking at output proves the page is
307
+ **right**. Say which of the two you reached.
308
+
309
+ **You can reach rendered verification on your own.** The Etch builder sits behind the user's
310
+ WordPress session, but the published page does not. Save first, or you will verify the old page.
311
+
312
+ **1. Fetch the published page.** Cheapest, and often enough. Proves classes, attributes and text
313
+ reached the markup.
314
+
315
+ ```bash
316
+ curl -s "https://the-site.com/the-page/" -o page.html
317
+ grep -o 'data-focus="[^"]*"' page.html # did the prop reach the engine?
318
+ ```
319
+
320
+ Count matches with `grep -o ... | wc -l`, not `grep -c`, which counts lines: rendered pages are
321
+ minified onto very few lines and `grep -c` will report 1 for everything. Remember the page also
322
+ inlines your style entries, so a class name appears once more than it does in the markup.
323
+
324
+ **2. Screenshot it with your own headless browser.** Launch a fresh instance on a spare port with
325
+ its own throwaway profile, then kill it and delete the profile when done. The page is public, so it
326
+ needs no session.
318
327
 
319
328
  ```bash
320
- npx @digital-gravy/etch-connector shot -t "<tab>" -s ".my-slider" -o ./out.png
321
- npx @digital-gravy/etch-connector html ".my-slider" -t "<tab>"
322
- npx @digital-gravy/etch-connector computed ".my-slider .splide__slide" -t "<tab>" --props transform,opacity
329
+ chrome.exe --headless=new --remote-debugging-port=9555 \
330
+ --user-data-dir="<temp dir>/cdp-profile" --no-first-run about:blank &
323
331
  ```
324
332
 
325
- Then actually look at the screenshot. A slider that mounted is not the same as a slider that looks
326
- right.
333
+ Then drive it over CDP: `Page.navigate`, wait for `Page.loadEventFired`, wait again for Splide to
334
+ mount and images to decode, then `Page.captureScreenshot`. `Runtime.evaluate` gives you
335
+ `getComputedStyle` and `getBoundingClientRect`, which is how you turn "the dots look small" into a
336
+ measurement. **Then actually look at the screenshot.** A slider that mounted is not a slider that
337
+ looks right.
338
+
339
+ Write attribute probes as `[data-x="false"]`, not `[data-x]`: the bare attribute selector matches
340
+ whatever the value is, so a switched-off control still counts and you will report it as still there.
341
+
342
+ > **Do not use the connector's `shot`, `html` or `computed`, and never ask the user to relaunch
343
+ > their browser with `--remote-debugging-port=9222`.** Those attach to the user's own Chrome, so
344
+ > they cost the logged-in session being tested and fail outright when that port is not already
345
+ > open. The route above needs neither.
346
+
347
+ **What this cannot show you**, and has to be handed to the user: unpublished pages, logged-in-only
348
+ content, and anything you have not saved yet.
327
349
 
328
350
  ### Saving
329
351
 
@@ -366,7 +388,7 @@ Four rules follow from it:
366
388
 
367
389
  ## Decision tree
368
390
 
369
- **Build from scratch:** components and props first, then CSS.
391
+ **Build from scratch:** read `slider-pro-skills-build.md` now. Components and props first, then CSS.
370
392
 
371
393
  Reach for a premade template only when the user names one ("use Deck Featured"). Then paste its
372
394
  JSON and customise, and read `https://design-with-cracka.gitbook.io/etchsliderpro/card-stack-templates` or `https://design-with-cracka.gitbook.io/etchsliderpro/premade-templates` for
@@ -378,231 +400,6 @@ configure it the way its documentation describes instead of inventing new CSS ov
378
400
 
379
401
  ***
380
402
 
381
- ## Design recipes
382
-
383
- Each of these is a shipped template reduced to the settings that produce it.
384
-
385
- ### Cover-flow carousel (Slider Flow): no script, no CSS
386
-
387
- The whole effect is the `slides.*` group, which applies transforms to inactive slides and separate
388
- `-Active` values to the current one.
389
-
390
- ```js
391
- setGroup(sliderId, 'layout', { slidesPerPage: '3 lg:1', gapBetweenSlides: '20px',
392
- sliderEdgeOffset: 'lg:20% md:18%' });
393
- setGroup(sliderId, 'motion', { focus: 'center', loop: '{true}', speed: '800',
394
- updateOnMove: '{true}' });
395
- setGroup(sliderId, 'slides', { perspective: '950px', opacity: '0.3', scale: '0.9',
396
- borderRadius: '1rem',
397
- transition: 'transform 0.5s ease, opacity 0.5s ease' });
398
- setGroup(sliderId, 'dimensions', { sliderHeight: '350px md:300px sm:180px' });
399
- ```
400
-
401
- `motion.focus: 'center'` is what puts the active slide in the middle. The neighbours are dimmed and
402
- shrunk purely by `slides.opacity` and `slides.scale`, which apply to inactive slides only.
403
-
404
- For an angled flip, add `slides.rotateY` and turn on `slides.flipNextRotateY` so the slide on the
405
- other side mirrors the angle instead of repeating it. Same for `translateX` with
406
- `flipNextTranslateX`.
407
-
408
- ### Logo marquee (Slider Marquee): no script
409
-
410
- Two Sliders in one Wrapper, running in opposite directions.
411
-
412
- ```js
413
- // both rows
414
- setGroup(sliderId, 'dimensions', { slideAutoWidth: '{true}' }); // slides size to content
415
- setGroup(sliderId, 'motion', { loop: '{true}' });
416
- setGroup(sliderId, 'autoscroll', { infiniteScroll: '{true}', scrollSpeed: '1',
417
- pauseOnHover: '{false}', pauseOnFocus: '{false}' });
418
- setGroup(sliderId, 'layout', { gapBetweenSlides: '2.4rem' });
419
-
420
- // second row only
421
- setGroup(secondId, 'sliderSetup', { slldeDirection: 'rtl' }); // note the spelling
422
- ```
423
-
424
- Edge Fade goes on the **Wrapper** (`props.edgeFade`), not the sliders, so both rows fade as one
425
- unit. `props.pauseSlidersOnHover` on the Wrapper pauses both rows together.
426
-
427
- Infinite Scroll needs the Auto-Scroll extension enabled in admin settings, which is the default.
428
-
429
- ### Synced elements (Team, Stack)
430
-
431
- Driving arbitrary elements on the page from the slider is one prop.
432
-
433
- ```js
434
- setGroup(sliderId, 'sliderSetup', { syncCustomElement: '.timeline-node',
435
- syncCustomElementNav: '{true}' });
436
- ```
437
-
438
- The plugin then moves `is-active`, `is-prev` and `is-next` across those elements as the slider
439
- moves, and with nav on, clicking one jumps the slider to it. **You write the CSS for the three
440
- states.** The elements do not need to be inside the slider and do not need to match the slide
441
- count.
442
-
443
- The selector takes a comma-separated list, so one slider can drive several groups at once. Team
444
- uses `'.slider-team__sync, .slider-team__sync-heading'` to move a portrait and a heading together.
445
-
446
- Grep `## 3. Sync Custom Element` in the reference for multiple selectors and the overlap caveat.
447
-
448
- ### Main and thumbnails in one wrapper (Zeon)
449
-
450
- A full-bleed hero with a thumbnail strip needs no sync prop at all, just roles:
451
-
452
- ```js
453
- setGroup(mainId, 'sliderSetup', { sliderRole: 'main', transitionType: 'Fade' });
454
- setGroup(thumbId, 'sliderSetup', { sliderRole: 'thumbnails', transitionType: 'Loop' });
455
- ```
456
-
457
- Both inside the same Wrapper and they pair automatically. A Wrapper can hold more than one
458
- thumbnail slider, which is how Zeon runs a background layer and a strip off the same main.
459
-
460
- ### Animating slide content: no script
461
-
462
- Splide puts `is-active` on the current `.splide__slide`, which is all you need to animate anything
463
- inside it. Give the element its resting state, then reveal it from the active slide:
464
-
465
- ```css
466
- /* in .slider-<name>__figure's own entry */
467
- opacity: 0;
468
- transform: translateY(30px);
469
- transition: opacity 820ms cubic-bezier(0.22, 0.68, 0.24, 1),
470
- transform 820ms cubic-bezier(0.22, 0.68, 0.24, 1);
471
-
472
- .splide__slide.is-active & { opacity: 1; transform: translateY(0); }
473
- ```
474
-
475
- Stagger a caption by putting the same pair on each line with a growing `transition-delay`
476
- (thumbnail 140ms, status 200ms, title 260ms, body 320ms) so the block assembles rather than
477
- appearing at once.
478
-
479
- Two things that bite:
480
-
481
- * **Keep layout transforms in the animated transform.** An element centred with
482
- `translateX(-50%)` must animate as `translateX(-50%) translateY(30px)`, or it jumps sideways on
483
- every transition. Where a breakpoint drops the centring, restate the transform there too.
484
- * **A slow drift on the backdrop** (`scale(1.06)` to `scale(1)` over several seconds) makes a
485
- change read as movement rather than a cut. Put it on the image, not the slide, so it does not
486
- fight the transition.
487
-
488
- This works with the Fade transition, which crossfades the slides underneath while the contents
489
- move independently.
490
-
491
-
492
- ### Carousel on mobile, grid on desktop
493
-
494
- ```js
495
- setGroup(sliderId, 'layout', { layoutMode: 'static md:slider', gridColumns: '3 sm:1' });
496
- ```
497
-
498
- Above `md` it is a plain CSS grid; at `md` and below it is a real carousel. The slider is built and
499
- torn down live as the viewport crosses the breakpoint. **Controls are hidden in static mode**,
500
- because there is no live slider to drive.
501
-
502
- ### Card decks with no slider (Deck, Fall): the one place script is allowed
503
-
504
- Structure: a Wrapper with `sliderlessSync.customElement` pointing at your cards, no Slider inside,
505
- cards nested three deep as `card > lift > content` so the 3-D survives.
506
-
507
- ```js
508
- setGroup(wrapperId, 'sliderlessSync', {
509
- customElement: '.deck-card', customElementNav: '{true}',
510
- loop: '{true}', autoplay: '{true}', autoplayInterval: '4000'
511
- });
512
- ```
513
-
514
- CSS handles the front card and its two neighbours from `is-active` / `is-prev` / `is-next`. What
515
- CSS cannot do is know that a given card is *three* back, so the rows behind, the z-index ladder and
516
- the click targets need a script. That is case 1 of the native-first gate.
517
-
518
- The contract between that script and the CSS is one attribute, **`data-pos`**, which the script
519
- derives from `is-active` and writes on every card. It is a **signed** offset from the active card,
520
- so the CSS can fan the left and right sides in opposite directions:
521
-
522
- ```css
523
- .deck-stack-featured &:is([data-pos='2'], [data-pos='-2']) { /* ring 2, both sides */ }
524
- ```
525
-
526
- The stack container carries **`data-tiers`**, how many rings are actually in play. It is computed,
527
- not fixed: a looping deck needs spare cards to hide the wrap behind, so the script reduces the
528
- tiers when there are too few cards.
529
-
530
- Writing an unsigned rank, or naming the attribute anything else, produces a flat pile: the rules
531
- above simply never match. The shipped script does this:
532
-
533
- ```js
534
- // 1. make a custom property interpolable, so a blur can animate (case 2)
535
- CSS.registerProperty({ name: '--stack-focus', syntax: '<number>',
536
- inherits: true, initialValue: '0' });
537
-
538
- // 2. how many rings this deck can afford, given looping and the card count
539
- var mayLoop = wrap.dataset.loop === 'true' && total >= MIN_LOOP;
540
- stack.dataset.tiers = mayLoop ? Math.min(MAX_TIERS, Math.floor((total - 3) / 2)) : MAX_TIERS;
541
-
542
- // 3. per card: SIGNED offset from the active card. On a looping deck the offset
543
- // wraps, so the far end of the list reads as the near side, not as distance 8.
544
- var d = n - active;
545
- if (mayLoop) {
546
- if (d > total / 2) d -= total;
547
- if (d < -total / 2) d += total;
548
- }
549
- card.dataset.pos = d;
550
-
551
- // 4. hit-testing: stack order by distance, and clicks off beyond the clickable rings
552
- var rank = Math.abs(d);
553
- card.style.zIndex = String(total - rank);
554
- lift.style.pointerEvents = rank <= NAV_RINGS ? 'auto' : 'none';
555
- ```
556
-
557
- Do not clamp `data-pos` to the tier count. Cards past the deepest ring should match no rule and
558
- stay stacked at the back; clamping piles them onto the last visible ring instead.
559
-
560
- Both decks also set `--i` on each line of card content so the CSS can stagger it:
561
-
562
- ```js
563
- card.querySelectorAll('.deck-card-featured__content > *')
564
- .forEach(function (line, j) { line.style.setProperty('--i', j); });
565
- ```
566
-
567
- Step 4 is Deck only. **Fall's script is much smaller**: it sets `--i` and a signed `data-pos`, and
568
- nothing else. Fall never loops, so there is no wrap to fold and no z-index ladder to maintain,
569
- because the cards fall past each other rather than fanning around a front card.
570
-
571
- Recompute on every change: watch the stack with a `MutationObserver` filtered to `class`, since
572
- `is-active` moving is the only signal you get.
573
-
574
- ### Where a script goes
575
-
576
- **Not in a `<script>` element.** Etch silently drops one from the render, so the page looks right
577
- in the builder and ships with no behaviour at all.
578
-
579
- Every block has an optional `script` field (`EtchBlockScript`), which is Etch's JavaScript code
580
- block. Put the code on the **component instance that owns the markup**, which for a card stack is
581
- the Wrapper:
582
-
583
- ```js
584
- const wrapperId = etch.blocks.create({
585
- type: 'etch/component', version: 1, context: {}, children: [ /* ... */ ],
586
- componentId: C['DWC Slider Wrapper'], attributes: {},
587
- script: { code: "(function () { /* ... */ })();" }
588
- });
589
- ```
590
-
591
- `code` is **plain source** over the API. The base64 you see in a premade template `.json` is only
592
- how the export serialises it; do not encode it yourself.
593
-
594
- Etch renders it as `<script type="module" defer>`, so it runs after parsing and top-level `await`
595
- is available. Read it back with `etch.blocks.getJson(id).script`.
596
-
597
- Component scripts are also the reason a card deck keeps working: the bridge leaves Sync Without
598
- Slider wrappers unreconstructed precisely so an author's script keeps observing the original nodes.
599
-
600
- Card counts for a looping deck: three are always visible (front plus two), and a loop needs a
601
- hidden slot at each end, so **five is the minimum** and nine gives the full three-row fan. Fall
602
- never loops, so it has no minimum.
603
-
604
- ***
605
-
606
403
  ## Script library
607
404
 
608
405
  Include these at the top of any script.
@@ -624,25 +421,6 @@ function setGroup(id, key, patch) {
624
421
  etch.blocks.setAttribute(id, key, '{' + JSON.stringify(next) + '}');
625
422
  }
626
423
 
627
- function node(extra, children) {
628
- return Object.assign({ version: 1, context: {}, children: children || [] }, extra);
629
- }
630
-
631
- function component(componentId, attributes, children) {
632
- return node({ type: 'etch/component', componentId: componentId,
633
- attributes: attributes || {} }, children);
634
- }
635
-
636
- function slot(slotName, children) {
637
- return node({ type: 'etch/slot-content', slotName: slotName }, children);
638
- }
639
-
640
- function el(tag, attributes, children) {
641
- return node({ type: 'etch/element', tag: tag, attributes: attributes || {} }, children);
642
- }
643
-
644
- function text(t) { return node({ type: 'etch/text', text: t }); }
645
-
646
424
  function findByRef(nodes, componentId, out) {
647
425
  out = out || [];
648
426
  for (const n of nodes || []) {
@@ -653,35 +431,6 @@ function findByRef(nodes, componentId, out) {
653
431
  }
654
432
  ```
655
433
 
656
- ### Build a wrapper with N slides
657
-
658
- ```js
659
- const C = comps();
660
- const slides = [];
661
- for (let i = 0; i < 5; i++) {
662
- slides.push(component(C['DWC Slide'], {}, [
663
- slot('Content', [ el('div', {}, [ text('Slide ' + (i + 1)) ]) ])
664
- ]));
665
- }
666
-
667
- const id = etch.blocks.create(
668
- component(C['DWC Slider Wrapper'], {}, [
669
- slot('Sliders_and_Controls', [
670
- component(C['DWC Slider'], {}, [ slot('Slides', slides) ]),
671
- component(C['DWC Slider Nav Button'], {}),
672
- component(C['DWC Slider Pagination'], {})
673
- ])
674
- ])
675
- );
676
- return id;
677
- ```
678
-
679
- An empty Slide has no content and so no height. Give slides something to size before wondering why
680
- the slider is a flat line.
681
-
682
- Build **one** first, screenshot it, then scale to the full count. Do not generate twenty slides
683
- before you have seen one render.
684
-
685
434
  ### Read the current state of a slider
686
435
 
687
436
  ```js
@@ -710,6 +459,28 @@ mistake, and `ltr sm:ttb` does the opposite of what it looks like.
710
459
 
711
460
  **Never set a prop to its default.** It adds noise to the markup and hides real intent.
712
461
 
462
+ **Adding a control component does not remove the built-in one. Switch the built-in off yourself.**
463
+ Three of them ship **on**, and each renders a second set of controls underneath your design:
464
+
465
+ | Built-in | Prop | Default |
466
+ | --- | --- | --- |
467
+ | Arrows | `props.navigation.navigationArrows` | `true` |
468
+ | Pagination dots | `props.navigation.paginationDots` | `true` |
469
+ | Play/pause toggle | `props.autoplay.playPauseButton` | `true` |
470
+
471
+ Dropping in a DWC Slider Pagination while `paginationDots` is still `true` gives you two sets of
472
+ dots, and `playPauseButton` puts a circular toggle at the slider's bottom-right of a design that
473
+ never asked for one. This is the one place the "never set a prop to its default" rule reads
474
+ backwards: you are not setting a default, you are turning an unwanted default off.
475
+
476
+ **Pagination dot size is derived from the dot font size. Change the font size, not the size.**
477
+ `props.dot.size` is declared as `calc(var(--font-size) * 2)`, so it tracks `props.dot.fontSize`
478
+ by design. Writing a fixed `size` severs that and the dots stop scaling with the type. Leave
479
+ `size` alone and set `fontSize` to half the dot you want: `0.32rem` gives a 10px dot.
480
+
481
+ Default mode also puts the slide **number** inside each dot. For plain dots set
482
+ `props.dot.textColor` and `props.dot.activeTextColor` to `transparent`.
483
+
713
484
  **A nested inner slider must be Slide or Fade, never Loop or Infinite Scroll.** A looping slider
714
485
  makes hidden copies of its slides, and copying a slider that contains a slider breaks both. To wrap
715
486
  around, use Fade, or Slide with Rewind.
@@ -733,27 +504,124 @@ rather than debugging a slider that will never start.
733
504
 
734
505
  **Do not touch the plugin's own stylesheet.** Style through the Slider Class and CSS variables.
735
506
 
736
- **A prop that gates a component's internals must be set explicitly, even to its default.** This is
737
- the one exception to "never set a prop to its default", and it is invisible when you hit it. A
738
- component's declared default populates the Etch settings panel; it is **not** written onto an
739
- instance you create programmatically. When a condition inside the component reads a
740
- flag prop and the key is absent, the expression does not resolve and the condition returns
741
- false **whichever operator it uses**. Both branches vanish at once.
507
+ **A component's declared default is never written onto an instance you create.** Defaults populate
508
+ the Etch settings panel. They are not present in the attributes map of a block you create through
509
+ the API, so `getAttribute` returns nothing for them. This is the one exception to "never set a prop
510
+ to its default", and it fails silently in two different ways.
511
+
512
+ **Symptom one, a condition that never matches.** When a condition inside the component reads a flag
513
+ prop and the key is absent, the expression does not resolve and the condition returns false
514
+ **whichever operator it uses**, so both branches vanish at once. A DWC Slider Nav Button created
515
+ with `navigationType` alone renders `<button>` with no icon: the default arrow sits behind
516
+ `useCustomArrow isFalsy` and the custom SVG behind `useCustomArrow isTruthy`, and neither appears.
517
+ Writing `useCustomArrow: '{false}'`, already the default, makes the arrow render. If a component
518
+ renders structurally but its inner content is missing, check this first.
519
+
520
+ **Symptom two, a class prop that silently replaces the default.** Class props are arrays of
521
+ **space-separated style ids**. Writing one id does not add to the default, it becomes the whole
522
+ value, and the default is gone with no error. Setting `sliderClass` to your own class alone drops
523
+ the slider's navigation variables, which carry the arrow, pagination, play/pause and progress
524
+ styling, and nothing looks wrong until one of those controls misbehaves. Always read, append,
525
+ write back, never overwrite.
526
+
527
+ ## `sliderClass` carries a minted per-instance class
528
+
529
+ Every slider needs its **own copy** of the navigation variables so two sliders on one page can be
530
+ styled independently. The plugin mints that copy for you as a class named `.dwc-slider-vars-XXXXXX`
531
+ whose CSS is copied from `.slider-navigation-vars`. A correctly built slider holds the minted class
532
+ plus any design class of your own, which is what every premade template does:
533
+
534
+ ```
535
+ .dwc-slider-vars-vsj76d .slider-chronos
536
+ ```
537
+
538
+ **Never put `.slider-navigation-vars` into the slot yourself.** Resolving it by selector and writing
539
+ that shared id looks right and renders right, so nothing warns you. It silently breaks duplication:
540
+ the plugin only manages classes matching `.dwc-slider-vars-*`, so a slider carrying the shared
541
+ default gets no fresh class when it is duplicated, and every copy then reads one set of variables.
542
+ Recolouring one slider's arrows changes them all. This is the single easiest way to ship a slider
543
+ that looks finished and is not.
544
+
545
+ **Create first, append second.** Leave `sliderClass` alone when you create the slider. The plugin
546
+ polls roughly every two seconds and mints a class for any instance that appeared after the builder
547
+ loaded while the prop is still unset. Once it has, read the value back and append your design class
548
+ to what it wrote:
549
+
550
+ ```js
551
+ const ids = (etch.blocks.getAttribute(sliderId, 'sliderClass') || '').split(/\s+/).filter(Boolean);
552
+ // ids now starts with a minted .dwc-slider-vars-* id. Append to it, never replace it.
553
+ ids.push(myStyleId);
554
+ etch.blocks.setAttribute(sliderId, 'sliderClass', ids.join(' '));
555
+ ```
556
+
557
+ If the prop is still empty after a few seconds, mint one yourself. Copy the CSS from
558
+ `.slider-navigation-vars` and keep the required name shape, because a class named anything else is
559
+ not managed:
560
+
561
+ ```js
562
+ const src = etch.styles.list().find(s => s.selector === '.slider-navigation-vars');
563
+ const taken = new Set(etch.styles.list().map(s => s.selector));
564
+ let selector;
565
+ do {
566
+ selector = '.dwc-slider-vars-' + Math.random().toString(36).slice(2, 8);
567
+ } while (taken.has(selector));
568
+ const varsId = etch.styles.create(selector, src.css);
569
+ ```
570
+
571
+ **Resolve by selector, not by the documented id.** The prop reference records `sliderClass`'s
572
+ default as a style id, but that id differs per install, so look the style up by
573
+ `.slider-navigation-vars` when you need its CSS to copy.
742
574
 
743
- The worked case: a DWC Slider Nav Button created with `navigationType` alone renders
744
- `<button>` with no icon. The default arrow sits behind `useCustomArrow isFalsy` and the custom SVG
745
- behind `useCustomArrow isTruthy`, and neither appears. Writing `useCustomArrow: '{false}'`, which is
746
- already the default, makes the arrow render. If a component renders structurally but its inner
747
- content is missing, this is the first thing to check.
575
+ **Verify the shape before you call the slider done.** Read `sliderClass` back and confirm it holds
576
+ exactly one `.dwc-slider-vars-*` id, plus your design class if you added one. A slider holding
577
+ `.slider-navigation-vars`, or holding no vars class at all, is a defect you introduced.
748
578
 
749
- **Give the slider a builder height when slide content is absolutely positioned.** A slide whose
750
- children are all `position: absolute` has no intrinsic height, so before the slider initialises it
751
- collapses to nothing and the layout is unusable in the builder. Guard it:
579
+ **Anything whose resting state waits on `is-active` breaks in edit mode.** The slider runs in
580
+ Preview but not while the user is editing, so in edit mode no slide carries `is-active` and none of
581
+ your active-state rules apply. Height that comes from the active slide collapses; an element
582
+ resting at `opacity: 0` stays invisible. Guard on the condition "nothing is active yet", which is
583
+ true only in edit mode and switches itself off the moment the slider runs:
752
584
 
753
585
  ```css
754
- &.etch-builder-block { min-block-size: 640px; }
586
+ .figure { opacity: 0; }
587
+ .splide__slide.is-active .figure { opacity: 1; }
588
+ .splide:not(:has(.splide__slide.is-active)) .figure { opacity: 1; }
755
589
  ```
756
590
 
591
+ **Where the check goes depends on which element receives the class.** Above, the slide becomes
592
+ active and the figure is inside it, so the test sits on a shared ancestor. With Sync Custom Element
593
+ your own elements receive `is-active` themselves, so test their container instead:
594
+ `.steps:not(:has(.step.is-active)) .step`. A single canned selector does not fit every case; work
595
+ out which element actually gets the class first.
596
+
597
+ `.etch-builder-block` alone is not enough. It says "in the builder", not "not running", so a rule
598
+ gated only on it can also suppress the effect in Preview.
599
+
600
+ **The same gate is needed for anything positioned against the slide, not just for `is-active`
601
+ states.** A slide's height arrives with Splide. Until it mounts the slide has none, so absolutely
602
+ positioned children have nothing to sit in, and the whole design collapses into a pile in the
603
+ builder. That covers an overlay caption, a corner button and an `inset: 0` image alike. Give the slide a height under
604
+ the same "nothing is active yet" condition, so it applies in the builder and stops the moment the
605
+ slider runs:
606
+
607
+ ```css
608
+ /* inside the slide class's own entry */
609
+ position: relative;
610
+
611
+ .splide:not(:has(.splide__slide.is-active)) & {
612
+ min-block-size: to-rem(480px);
613
+ }
614
+ ```
615
+
616
+ Build the design so the builder shows something honest. An author who cannot see the slide cannot
617
+ edit its content.
618
+
619
+ **Read computed style, not source, to decide what the plugin already does.** `getComputedStyle(el)`
620
+ answers directly and in one call. Do not conclude from page source that a rule is absent: component
621
+ styles are inlined into the page but the engine's stylesheet is a separate file, so a rule can be
622
+ in force while being nowhere in the HTML. Presence in the DOM is not visibility either, since a
623
+ hidden element is still there.
624
+
757
625
  **Do not anchor against a neighbour that stops shrinking.** A `vw` offset tuned at one width
758
626
  silently collides at another, because `min()` and `clamp()` neighbours stop shrinking while the
759
627
  `vw` keeps going. Derive the clearance from the neighbour's real footprint instead:
@@ -768,6 +636,19 @@ right: calc(4vw + min(15vw, 195px) + 2.5vw);
768
636
  Check the mid range explicitly. Between the widest layout and the first breakpoint is where
769
637
  side-by-side compositions fail, and it is the range nobody screenshots.
770
638
 
639
+ **Missing Preview and Grid buttons on a slider you just built mean the install is older than
640
+ 1.2.1, not that the build is broken.** The builder watches the canvas and attaches those controls
641
+ to sliders as they appear, but before 1.2.1 that watcher only started if a slider was **already**
642
+ on the page when the builder loaded. Build onto an empty page there and nothing is watching, so the
643
+ controls never appear until the tab is reloaded, however the blocks were added, by script or by
644
+ hand.
645
+
646
+ You cannot fix it from a script on those versions: the refresh is an iframe reload, and safe mode
647
+ has no `window` or `document` to reach it with. Do not paper over it by sending the user to reload
648
+ every time. Confirm the blocks and the published page are right, tell them it is fixed in 1.2.1,
649
+ and mention that a reload brings the controls back meanwhile. Preview is how they watch the slider
650
+ actually run, so it is worth naming rather than leaving them to find it.
651
+
771
652
  **A save can lag the front end.** `etch.saveAsync()` resolves before the change is necessarily
772
653
  readable on the published page, so a fetch straight afterwards can return the previous value and
773
654
  look like a failed write. Re-read through `etch.blocks.getAttribute` to confirm intent, and treat a