sliderpro-agentic-skills-etch 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1,14 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
3
+ // Installs the Slider Pro AI Connector skills into a project.
4
+ //
5
+ // The skills live in the docs repo and change more often than this package is
6
+ // published, so the current files are fetched at install time and the bundled
7
+ // copy is only a fallback for when the network is unavailable. Fetching is
8
+ // all-or-nothing: a partial fetch would mix a new skills file with stale
9
+ // component docs, which is worse than either source on its own.
10
+
11
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
12
  import { dirname, join, resolve } from "node:path";
5
13
  import { fileURLToPath } from "node:url";
14
+ import {
15
+ applyRewrites,
16
+ DEFAULT_REF,
17
+ looksLikeMarkdown,
18
+ OUT_COMPONENTS_DIR,
19
+ OUT_SKILLS_DIR,
20
+ rawUrl,
21
+ REPO,
22
+ REPO_MANIFEST,
23
+ } from "../lib/skills-source.js";
6
24
 
7
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
26
  const packageRoot = join(__dirname, "..");
9
- const sourceRoot = join(packageRoot, "skills-package");
27
+ const bundleRoot = join(packageRoot, "skills-package");
10
28
 
11
- const DIRS = ["slider-skills", "components"];
29
+ const DIRS = [OUT_SKILLS_DIR, OUT_COMPONENTS_DIR];
30
+ const TIMEOUT_MS = 10000;
12
31
 
13
32
  function printHelp() {
14
33
  console.log(`
@@ -18,27 +37,37 @@ Usage:
18
37
  npx sliderpro-agentic-skills-etch [directory] [options]
19
38
 
20
39
  Options:
21
- --force, -f Overwrite existing slider-skills/ or components/
22
- --help, -h Show this help message
40
+ --force, -f Overwrite existing slider-skills/ or components/
41
+ --offline Skip the network and install the copy bundled with this package
42
+ --ref <ref> Install from a branch or tag (default: ${DEFAULT_REF})
43
+ --help, -h Show this help message
44
+
45
+ By default the current skills are fetched from ${REPO} so they are never
46
+ older than this package. Without a network the bundled copy is used instead.
23
47
 
24
48
  Examples:
25
49
  npx sliderpro-agentic-skills-etch
26
50
  npx sliderpro-agentic-skills-etch ./my-site-project
27
- npx sliderpro-agentic-skills-etch --force
51
+ npx sliderpro-agentic-skills-etch --offline --force
28
52
  `);
29
53
  }
30
54
 
31
55
  function parseArgs(argv) {
32
- const options = { force: false, help: false, target: process.cwd() };
33
-
34
- for (const arg of argv) {
35
- if (arg === "--help" || arg === "-h") {
36
- options.help = true;
37
- } else if (arg === "--force" || arg === "-f") {
38
- options.force = true;
39
- } else if (!arg.startsWith("-")) {
40
- options.target = resolve(arg);
41
- } else {
56
+ const options = { force: false, help: false, offline: false, ref: DEFAULT_REF, target: process.cwd() };
57
+
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const arg = argv[i];
60
+ if (arg === "--help" || arg === "-h") options.help = true;
61
+ else if (arg === "--force" || arg === "-f") options.force = true;
62
+ else if (arg === "--offline") options.offline = true;
63
+ else if (arg === "--ref") {
64
+ options.ref = argv[++i];
65
+ if (!options.ref) {
66
+ console.error("--ref needs a branch or tag name");
67
+ process.exit(1);
68
+ }
69
+ } else if (!arg.startsWith("-")) options.target = resolve(arg);
70
+ else {
42
71
  console.error(`Unknown option: ${arg}`);
43
72
  printHelp();
44
73
  process.exit(1);
@@ -48,60 +77,120 @@ function parseArgs(argv) {
48
77
  return options;
49
78
  }
50
79
 
51
- function listConflicts(target) {
52
- return DIRS.filter((dir) => existsSync(join(target, dir))).map((dir) => `${dir}/`);
80
+ async function get(url) {
81
+ const controller = new AbortController();
82
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
83
+ try {
84
+ const res = await fetch(url, { signal: controller.signal, headers: { "user-agent": "sliderpro-agentic-skills-etch" } });
85
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
86
+ return await res.text();
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
53
90
  }
54
91
 
55
- function copyPackageFiles(target, force) {
56
- mkdirSync(target, { recursive: true });
92
+ function bundledManifest() {
93
+ return JSON.parse(readFileSync(join(bundleRoot, "manifest.json"), "utf8"));
94
+ }
57
95
 
58
- for (const dir of DIRS) {
59
- const source = join(sourceRoot, dir);
60
- const dest = join(target, dir);
96
+ /** Returns { files: [{out, content}] } or null if anything at all went wrong. */
97
+ async function fetchLive(ref) {
98
+ let manifest;
99
+ try {
100
+ manifest = JSON.parse(await get(rawUrl(ref, REPO_MANIFEST)));
101
+ } catch {
102
+ // The manifest may predate this feature on an older ref; the bundled list
103
+ // is still a reasonable guess at what to fetch.
104
+ try {
105
+ manifest = bundledManifest();
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ const entries = [...(manifest.skills || []), ...(manifest.components || [])];
112
+ if (entries.length === 0) return null;
61
113
 
62
- if (existsSync(dest) && !force) {
63
- continue;
114
+ const files = [];
115
+ for (const entry of entries) {
116
+ let text;
117
+ try {
118
+ text = await get(rawUrl(ref, entry.repo));
119
+ } catch {
120
+ return null;
64
121
  }
122
+ if (!looksLikeMarkdown(text)) return null;
123
+ files.push({ out: entry.out, content: applyRewrites(text) });
124
+ }
125
+ return files;
126
+ }
127
+
128
+ function writeFiles(target, files) {
129
+ for (const file of files) {
130
+ const dest = join(target, file.out);
131
+ mkdirSync(dirname(dest), { recursive: true });
132
+ writeFileSync(dest, file.content);
133
+ }
134
+ }
65
135
 
66
- cpSync(source, dest, { recursive: true });
136
+ function copyBundle(target) {
137
+ for (const dir of DIRS) {
138
+ const source = join(bundleRoot, dir);
139
+ if (existsSync(source)) cpSync(source, join(target, dir), { recursive: true });
67
140
  }
68
141
  }
69
142
 
70
- function countComponents() {
143
+ function countComponents(target) {
71
144
  try {
72
- return readdirSync(join(sourceRoot, "components")).filter((f) => f.endsWith(".md")).length;
145
+ return readdirSync(join(target, OUT_COMPONENTS_DIR)).filter((f) => f.endsWith(".md")).length;
73
146
  } catch {
74
147
  return 0;
75
148
  }
76
149
  }
77
150
 
78
- function main() {
79
- const { force, help, target } = parseArgs(process.argv.slice(2));
151
+ async function main() {
152
+ const { force, help, offline, ref, target } = parseArgs(process.argv.slice(2));
80
153
 
81
154
  if (help) {
82
155
  printHelp();
83
156
  return;
84
157
  }
85
158
 
86
- const conflicts = listConflicts(target);
87
-
159
+ const conflicts = DIRS.filter((dir) => existsSync(join(target, dir))).map((dir) => `${dir}/`);
88
160
  if (conflicts.length > 0 && !force) {
89
161
  console.error("Installation blocked, the following already exist:");
90
- for (const conflict of conflicts) {
91
- console.error(` ${conflict}`);
92
- }
162
+ for (const conflict of conflicts) console.error(` ${conflict}`);
93
163
  console.error("\nRe-run with --force to overwrite.");
94
164
  process.exit(1);
95
165
  }
96
166
 
97
- copyPackageFiles(target, force);
167
+ mkdirSync(target, { recursive: true });
168
+
169
+ let source;
170
+ if (offline) {
171
+ copyBundle(target);
172
+ source = "the copy bundled with this package (--offline)";
173
+ } else {
174
+ const files = await fetchLive(ref);
175
+ if (files) {
176
+ writeFiles(target, files);
177
+ source = `${REPO} (${ref})`;
178
+ } else {
179
+ copyBundle(target);
180
+ source = "the copy bundled with this package (network unavailable)";
181
+ }
182
+ }
98
183
 
99
184
  console.log(`Installed Slider Pro AI Connector skills to ${target}`);
100
- console.log(` slider-skills/slider-pro-skills.md`);
101
- console.log(` slider-skills/slider-pro-skills-reference.md`);
102
- console.log(` components/ (${countComponents()} component prop docs)`);
103
- console.log("\nPoint your AI coding agent at slider-skills/slider-pro-skills.md to load it.");
185
+ 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`);
188
+ console.log(` ${OUT_COMPONENTS_DIR}/ (${countComponents(target)} component prop docs)`);
189
+ console.log(`\nPoint your AI coding agent at ${OUT_SKILLS_DIR}/slider-pro-skills.md to load it.`);
104
190
  console.log('Then tell the agent: "npx @digital-gravy/etch-connector serve"');
105
191
  }
106
192
 
107
- main();
193
+ main().catch((err) => {
194
+ console.error(err.message || err);
195
+ process.exit(1);
196
+ });
@@ -0,0 +1,65 @@
1
+ // Single source of truth for where the skills come from and how their links are
2
+ // rewritten for an installed project. Both scripts/build.js (which bundles a
3
+ // copy at publish time) and bin/cli.js (which prefers a live copy at install
4
+ // time) import this, so the two paths cannot drift in how they rewrite links.
5
+
6
+ export const REPO = "udoro/SliderPro-Etch-Docs";
7
+ export const DEFAULT_REF = "master";
8
+ export const DOCS_URL = "https://design-with-cracka.gitbook.io/etchsliderpro";
9
+
10
+ // Where things live in the docs repo.
11
+ export const REPO_SKILLS_DIR = "ai-connector/slider-skills";
12
+ export const REPO_COMPONENTS_DIR = "components";
13
+ export const REPO_MANIFEST = "ai-connector/skills-manifest.json";
14
+
15
+ // Where they land in a user's project.
16
+ export const OUT_SKILLS_DIR = "slider-skills";
17
+ export const OUT_COMPONENTS_DIR = "components";
18
+
19
+ // The docs repo nests slider-skills/ two levels below the root
20
+ // (ai-connector/slider-skills/), so it links components/ as ../../components/.
21
+ // Installed, slider-skills/ sits directly under the project root, so that
22
+ // becomes ../components/.
23
+ //
24
+ // The sibling doc pages the skills link to do not ship in the package at all,
25
+ // so point those at the public site rather than leave a link resolving to
26
+ // nothing.
27
+ export const PATH_REWRITE = [
28
+ [/\.\.\/\.\.\/components\//g, "../components/"],
29
+ [/\.\.\/\.\.\/card-stack-templates\.md/g, `${DOCS_URL}/card-stack-templates`],
30
+ [/\.\.\/\.\.\/premade-templates\.md/g, `${DOCS_URL}/premade-templates`],
31
+ [/\.\.\/\.\.\/javascript-api\.md/g, `${DOCS_URL}/javascript-api`],
32
+ [/\.\.\/\.\.\/styling-and-responsive\.md/g, `${DOCS_URL}/styling-and-responsive`],
33
+ [/\.\.\/\.\.\/admin-settings\.md/g, `${DOCS_URL}/admin-settings`],
34
+ [/\.\.\/\.\.\/troubleshooting\.md/g, `${DOCS_URL}/troubleshooting`],
35
+ ];
36
+
37
+ export function applyRewrites(content) {
38
+ let out = content;
39
+ for (const [pattern, replacement] of PATH_REWRITE) out = out.replace(pattern, replacement);
40
+ return out;
41
+ }
42
+
43
+ /**
44
+ * Only a markdown LINK TARGET pointing outside the package is a defect: it would
45
+ * 404 for every installed user. A bare ../../ path in prose is not, because the
46
+ * skills files deliberately reference optional local files and always say what
47
+ * to do when they are absent.
48
+ */
49
+ export function findStaleLinks(content) {
50
+ return [...content.matchAll(/\]\((\.\.\/\.\.\/[^)]+)\)/g)].map((m) => m[1]);
51
+ }
52
+
53
+ export function rawUrl(ref, repoPath) {
54
+ return `https://raw.githubusercontent.com/${REPO}/${ref}/${repoPath}`;
55
+ }
56
+
57
+ /**
58
+ * A fetched file only counts if it is non-empty markdown. A proxy or an error
59
+ * page returning 200 with HTML would otherwise be written out as a skills file.
60
+ */
61
+ export function looksLikeMarkdown(text) {
62
+ if (!text || text.trim().length < 200) return false;
63
+ const head = text.slice(0, 400).toLowerCase();
64
+ return !head.includes("<!doctype html") && !head.includes("<html");
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliderpro-agentic-skills-etch",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "lib",
11
12
  "skills-package"
12
13
  ],
13
14
  "scripts": {
@@ -0,0 +1,43 @@
1
+ {
2
+ "generated": "2026-08-22",
3
+ "skills": [
4
+ {
5
+ "repo": "ai-connector/slider-skills/slider-pro-skills-reference.md",
6
+ "out": "slider-skills/slider-pro-skills-reference.md"
7
+ },
8
+ {
9
+ "repo": "ai-connector/slider-skills/slider-pro-skills.md",
10
+ "out": "slider-skills/slider-pro-skills.md"
11
+ }
12
+ ],
13
+ "components": [
14
+ {
15
+ "repo": "components/dwc-slide.md",
16
+ "out": "components/dwc-slide.md"
17
+ },
18
+ {
19
+ "repo": "components/dwc-slider-nav-button.md",
20
+ "out": "components/dwc-slider-nav-button.md"
21
+ },
22
+ {
23
+ "repo": "components/dwc-slider-pagination.md",
24
+ "out": "components/dwc-slider-pagination.md"
25
+ },
26
+ {
27
+ "repo": "components/dwc-slider-play-pause.md",
28
+ "out": "components/dwc-slider-play-pause.md"
29
+ },
30
+ {
31
+ "repo": "components/dwc-slider-progress.md",
32
+ "out": "components/dwc-slider-progress.md"
33
+ },
34
+ {
35
+ "repo": "components/dwc-slider-wrapper.md",
36
+ "out": "components/dwc-slider-wrapper.md"
37
+ },
38
+ {
39
+ "repo": "components/dwc-slider.md",
40
+ "out": "components/dwc-slider.md"
41
+ }
42
+ ]
43
+ }
@@ -142,6 +142,9 @@ etch.blocks.create(json, parentId?, index?) // returns new id
142
142
  etch.components.list() // [{ id, name }, ...]
143
143
  etch.components.getJson(id) // .properties and .blocks
144
144
  etch.styles.list() // [{ id, selector, type, collection, css }]
145
+ etch.styles.create(selector, css) // returns a STYLE ID; pass it to a class prop
146
+ etch.blocks.addClass(blockId, name) // plain elements only; throws on a component
147
+ etch.blocks.getJson(id).script // { code }, the JavaScript code block
145
148
  await etch.saveAsync()
146
149
  ```
147
150
 
@@ -198,6 +201,87 @@ Putting a Slider straight inside a Wrapper's `children` renders nothing.
198
201
  Controls are siblings of the Slider inside `Sliders_and_Controls`, or children of the Slider's own
199
202
  `Top__Controls` / `Bottom__Controls`. Both work, because controls find their own slider.
200
203
 
204
+ ### Classes: two different mechanisms
205
+
206
+ Adding a class to a plain element and adding one to a component are **not the same operation**, and
207
+ the component path is the one that matters here, because everything Slider Pro ships is a component.
208
+
209
+ **On a component**, `addClass` throws `Block "<id>" is not an HTML block.` Classes go through the
210
+ component's own class-typed prop, and the value is the **style id**, not the class name:
211
+
212
+ ```js
213
+ const styleId = etch.styles.create('.flow-demo', 'border-radius: 1rem;'); // returns an id
214
+ etch.blocks.setAttribute(wrapperId, 'customClass', styleId);
215
+ ```
216
+
217
+ Etch resolves the id to the selector name when it renders, so the page gets
218
+ `class="dwc-slider-wrapper flow-demo"`. The id never appears in the output. Store the id, not the
219
+ name, so the class survives being renamed in the Etch UI.
220
+
221
+ The class-typed props, one per component:
222
+
223
+ | Component | Prop |
224
+ | --- | --- |
225
+ | DWC Slider Wrapper | `customClass` |
226
+ | DWC Slider | `sliderClass` |
227
+ | DWC Slide, Progress, Play-Pause, Pagination | `class` |
228
+ | DWC Slider Nav Button | `buttonClass`, `buttonWrapperClass` |
229
+
230
+ **On a plain element**, use `addClass` with the bare class name, no dot and no id:
231
+
232
+ ```js
233
+ const id = etch.blocks.create(el('article', {}, []));
234
+ etch.blocks.addClass(id, 'fall-card');
235
+ ```
236
+
237
+ Passing a style id here does not resolve. It is treated as a literal name and sanitised, so
238
+ `30xsolr` silently becomes the class `xsolr`. Creating the element with `attributes: { class: '...' }`
239
+ works too and is fine when you are building a subtree in one call.
240
+
241
+ Either way the CSS rule itself is `etch.styles.create('.fall-card', '...')`, which is what the
242
+ selector has to match.
243
+
244
+
245
+ ### CSS conventions
246
+
247
+ Match the shipped templates. An install that already has premade sliders on it has these
248
+ conventions everywhere, and a build that ignores them reads as foreign.
249
+
250
+ **Naming.** The block is the slider, named after the design:
251
+
252
+ | Thing | Pattern | Example |
253
+ | --- | --- | --- |
254
+ | Wrapper class, slider designs | `.slider-<name>-wrapper` | `.slider-flow-wrapper` |
255
+ | Wrapper class, card stacks | `.slider-wrapper-<name>` | `.slider-wrapper-fall` |
256
+ | Slider class | `.slider-<name>` | `.slider-chronos` |
257
+ | Elements | `.slider-<name>__<element>` | `.slider-team__sync` |
258
+ | Modifiers | `.slider-<name>__<element>--<variant>` | `.slider-afterform__word--form` |
259
+
260
+ The wrapper class carries the word `wrapper` and the slider class carries the word `slider`. Do not
261
+ invent a short prefix of your own.
262
+
263
+ **One style entry per element class.** Never one nested parent entry holding the whole component.
264
+ `etch.styles.create()` per class, each with its own CSS:
265
+
266
+ ```js
267
+ const S = {};
268
+ S.wrapper = etch.styles.create('.slider-afterform-wrapper', '/* ... */');
269
+ S.caption = etch.styles.create('.slider-afterform__caption', '/* ... */');
270
+ S.title = etch.styles.create('.slider-afterform__caption-title', '/* ... */');
271
+ ```
272
+
273
+ Inside an element's **own** entry, nesting is preferred for media queries, pseudo-elements
274
+ (`&::after`), states (`&:hover`, `&.is-active`), ancestor states
275
+ (`.splide__slide.is-active &`) and child tag selectors (`& img`, `& svg`). Do **not** nest another
276
+ class's rules (`&__title`) inside a different class's entry: that class gets its own entry.
277
+
278
+ **Renaming is not one operation.** A style entry's selector and a block's `class` attribute are
279
+ independent. Renaming the selector rewrites the rendered class only where the class came from a
280
+ **component class prop**, because that prop stores the style id and resolves it at render. On a
281
+ plain element the `class` attribute is literal, so a selector rename leaves it pointing at a
282
+ selector that no longer exists. Rename both, or the element silently loses its CSS.
283
+
284
+
201
285
  ### Group props and the one-extra-brace rule
202
286
 
203
287
  Most Slider props live in groups (`layout`, `motion`, `slides`, `autoplay`, ...). A group is stored
@@ -373,6 +457,38 @@ setGroup(thumbId, 'sliderSetup', { sliderRole: 'thumbnails', transitionType: 'Lo
373
457
  Both inside the same Wrapper and they pair automatically. A Wrapper can hold more than one
374
458
  thumbnail slider, which is how Zeon runs a background layer and a strip off the same main.
375
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
+
376
492
  ### Carousel on mobile, grid on desktop
377
493
 
378
494
  ```js
@@ -407,43 +523,79 @@ so the CSS can fan the left and right sides in opposite directions:
407
523
  .deck-stack-featured &:is([data-pos='2'], [data-pos='-2']) { /* ring 2, both sides */ }
408
524
  ```
409
525
 
410
- The stack container carries **`data-tiers`**, the number of rings the CSS defines, which lets the
411
- same CSS serve a three-ring fan and a one-ring stack.
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.
412
529
 
413
530
  Writing an unsigned rank, or naming the attribute anything else, produces a flat pile: the rules
414
- above simply never match. The script does three things:
531
+ above simply never match. The shipped script does this:
415
532
 
416
533
  ```js
417
534
  // 1. make a custom property interpolable, so a blur can animate (case 2)
418
535
  CSS.registerProperty({ name: '--stack-focus', syntax: '<number>',
419
536
  inherits: true, initialValue: '0' });
420
537
 
421
- // 2. per card: SIGNED offset from the active card, clamped to the deepest ring
422
- var signed = index - activeIndex;
423
- card.setAttribute('data-pos', String(Math.max(-MAX_TIERS, Math.min(MAX_TIERS, signed))));
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;
424
541
 
425
- // 3. hit-testing: stack order by distance, and clicks off beyond the clickable rings
426
- var rank = Math.abs(signed);
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);
427
553
  card.style.zIndex = String(total - rank);
428
554
  lift.style.pointerEvents = rank <= NAV_RINGS ? 'auto' : 'none';
429
555
  ```
430
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
+
431
571
  Recompute on every change: watch the stack with a `MutationObserver` filtered to `class`, since
432
572
  `is-active` moving is the only signal you get.
433
573
 
434
574
  ### Where a script goes
435
575
 
436
- There is no script block type. Add a `script` element inside the wrapper:
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:
437
582
 
438
583
  ```js
439
- el('script', {}, [ text(SOURCE) ])
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
+ });
440
589
  ```
441
590
 
442
- Component `<script>` elements are left alone on Sync Without Slider wrappers, which is why a deck
443
- script keeps working. Do not add one to a wrapper that contains a real slider and expect the same.
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.
444
593
 
445
- Step 3 is not optional. Stacked cards are coplanar, so the browser hit-tests them by DOM order and
446
- a click near the front card can land on one three positions away.
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.
447
599
 
448
600
  Card counts for a looping deck: three are always visible (front plus two), and a loop needs a
449
601
  hidden slot at each end, so **five is the minimum** and nine gives the full three-row fan. Fall
@@ -581,6 +733,47 @@ rather than debugging a slider that will never start.
581
733
 
582
734
  **Do not touch the plugin's own stylesheet.** Style through the Slider Class and CSS variables.
583
735
 
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.
742
+
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.
748
+
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:
752
+
753
+ ```css
754
+ &.etch-builder-block { min-block-size: 640px; }
755
+ ```
756
+
757
+ **Do not anchor against a neighbour that stops shrinking.** A `vw` offset tuned at one width
758
+ silently collides at another, because `min()` and `clamp()` neighbours stop shrinking while the
759
+ `vw` keeps going. Derive the clearance from the neighbour's real footprint instead:
760
+
761
+ ```css
762
+ /* wrong: fits at 1771, overlaps the column from 1024 to 1430 */
763
+ right: 17.6vw;
764
+ /* right: reserve exactly what the column occupies */
765
+ right: calc(4vw + min(15vw, 195px) + 2.5vw);
766
+ ```
767
+
768
+ Check the mid range explicitly. Between the widest layout and the first breakpoint is where
769
+ side-by-side compositions fail, and it is the range nobody screenshots.
770
+
771
+ **A save can lag the front end.** `etch.saveAsync()` resolves before the change is necessarily
772
+ readable on the published page, so a fetch straight afterwards can return the previous value and
773
+ look like a failed write. Re-read through `etch.blocks.getAttribute` to confirm intent, and treat a
774
+ stale page as latency rather than loss. Reloading the builder tab flushes anything pending.
775
+
776
+
584
777
  ### Do not
585
778
 
586
779
  * Hardcode component IDs.