sitezen-mcp 1.0.0 → 1.4.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.
@@ -0,0 +1,65 @@
1
+ // Self-contained BETA license keys — no server required.
2
+ //
3
+ // A key embeds the tester's name + an expiry date, and is SIGNED with a secret
4
+ // so it can't be forged or date-edited. The MCP verifies the signature and the
5
+ // expiry locally on every run. Generate one per tester with scripts/make-key.mjs.
6
+ //
7
+ // SECURITY NOTE: the secret ships inside the published package, so a determined
8
+ // user could extract it and forge a key. That's acceptable for a trusted-tester
9
+ // beta. When we move to paid, validation moves server-side and the secret never
10
+ // reaches clients.
11
+ import * as crypto from "node:crypto";
12
+ const BETA_SECRET = "9f3c1a7e5b2d8064c1f4a9e7d3b60285f1c8a4e6d7902b3c5a1e0f7b";
13
+ function b64urlEncode(s) {
14
+ return Buffer.from(s, "utf8").toString("base64")
15
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
16
+ }
17
+ function b64urlDecode(s) {
18
+ s = s.replace(/-/g, "+").replace(/_/g, "/");
19
+ while (s.length % 4)
20
+ s += "=";
21
+ return Buffer.from(s, "base64").toString("utf8");
22
+ }
23
+ function sign(payloadB64) {
24
+ return crypto.createHmac("sha256", BETA_SECRET).update(payloadB64).digest("hex").slice(0, 12);
25
+ }
26
+ /** Build a signed beta key valid for `days` days from now. */
27
+ export function buildBetaKey(name, days) {
28
+ const exp = Date.now() + Math.max(1, days) * 24 * 60 * 60 * 1000;
29
+ const payload = { n: name, exp, v: 1 };
30
+ const p = b64urlEncode(JSON.stringify(payload));
31
+ return "SZ-BETA-" + p + "." + sign(p);
32
+ }
33
+ /** Verify a beta key: signature must match and it must not be expired. */
34
+ export function verifyBetaKey(key) {
35
+ const k = (key || "").trim();
36
+ if (!k.startsWith("SZ-BETA-"))
37
+ return { ok: false, reason: "invalid" };
38
+ const rest = k.slice("SZ-BETA-".length);
39
+ const dot = rest.lastIndexOf(".");
40
+ if (dot < 0)
41
+ return { ok: false, reason: "invalid" };
42
+ const p = rest.slice(0, dot);
43
+ const sig = rest.slice(dot + 1);
44
+ if (sign(p) !== sig)
45
+ return { ok: false, reason: "invalid" };
46
+ let payload;
47
+ try {
48
+ payload = JSON.parse(b64urlDecode(p));
49
+ }
50
+ catch {
51
+ return { ok: false, reason: "invalid" };
52
+ }
53
+ if (typeof payload.exp !== "number")
54
+ return { ok: false, reason: "invalid" };
55
+ if (Date.now() > payload.exp)
56
+ return { ok: false, reason: "expired", payload };
57
+ return { ok: true, payload };
58
+ }
59
+ /** Human-readable expiry date for a key (or null if unreadable). */
60
+ export function betaKeyExpiry(key) {
61
+ const c = verifyBetaKey(key);
62
+ if (c.payload && typeof c.payload.exp === "number")
63
+ return new Date(c.payload.exp);
64
+ return null;
65
+ }
@@ -167,10 +167,11 @@ Every section must read well at viewport widths 1440, 1280, 1024, 768, and 375
167
167
  Many designs draw the navbar at the very top of the hero frame in Figma. The plugin's section auto-detector treats any pushed section containing a `<nav>` (or large horizontal menu list at the top) as a `is_header` → routes the WHOLE section into the site-wide header template. Result: your hero text/illustration/CTA gets injected on every page of the site, and the actual page body is empty.
168
168
  Rule:
169
169
  - When the Figma frame for the hero contains a top-bar nav (logo + menu items + optional social/CTA on the right), split it.
170
- - Emit the navbar as its OWN section (`<header class="sz-fullwidth"><nav>…</nav></header>`) the plugin classifies it as a header and stores it once as a site-wide template.
171
- - Emit the hero body (title, subtitle, CTA, illustration, decorations) as a SEPARATE section, with NO `<nav>` element inside.
170
+ - Emit the navbar via its OWN `create_header_footer(template_type='header', html=…)` call — do NOT push it as page content and do NOT rely on auto-detection. **Wrap it in `<header class="sz-fullwidth">` AND put the menu in a real `<nav>`** (`<header class="sz-fullwidth"><nav>…links…</nav></header>`). If the source builds its menu from `<div>`/`<ul>` with NO `<nav>` tag (common in custom designs like KAIZEN), the auto-detector will MISS it and the bar gets baked into the page — so always add the `<nav>` wrapper yourself and use the explicit header tool.
171
+ - Emit the hero body (title, subtitle, CTA, illustration, decorations) as a SEPARATE section, with NO `<nav>`/`<header>` element inside.
172
172
  - In the hero section, reserve vertical space at the top equal to the nav's Figma height so the layout stays visually aligned (`<div class="sz-nav-spacer" style="height:clamp(40px,5vw,60px)" aria-hidden="true"></div>`).
173
- - Same rule applies to footers (logo + columns of links at the bottom of the design frame): split them off, the plugin auto-routes them via `is_footer`.
173
+ - Same rule applies to footers (logo + columns of links at the bottom of the design frame): split them off via `create_header_footer(template_type='footer')`.
174
+ - ⚠️ **WHY this matters beyond duplicate headers — WooCommerce pages.** Single product, shop, cart, checkout and account pages are rendered by the theme + WooCommerce, NOT from any SiteZen page you pushed. They ONLY show your header/footer if it is a **site-wide template** (`render_site_header` via `wp_body_open` reads the activated header). So a header baked into the home page = product pages fall back to the THEME's default header ("Just another WordPress site"). The header/footer being a real activated template is the ONLY way every WC page gets it. This is mandatory for any store conversion.
174
175
 
175
176
  **L. NEVER use a px-based minimum on full-bleed background widths — pure vw only.**
176
177
  A `width: clamp(1500px, 216.7vw, 3120px)` on an absolutely-positioned background image *does* clip when the parent has `overflow:hidden` for the visible part, but the 1500px MINIMUM forces the element itself to be at least 1500px wide, which can push **the body's horizontal scrollbar** out — every viewport narrower than 1500px sees a side scrollbar.
@@ -180,21 +181,13 @@ Rule for any over-bleeding background image / illustration / decoration:
180
181
  - Sanity check at viewport widths 1440 / 1024 / 768 / 375 — at every one, body must NOT show a horizontal scrollbar.
181
182
  - Same rule for any element that intentionally overflows its container — never give it a px-based minimum that exceeds the viewport.
182
183
 
183
- **M. Hero height = content + illustration band, NOT raw Figma frame aspect-ratio.**
184
- The Figma frame's tall aspect is the **designer's canvas view** they need room to draw the whole illustration even if on a real screen it should sit just below the content. On the web, content and illustration must be visually connected as one cohesive section band. Mapping `aspect-ratio: figma_w / figma_h` directly to the section creates a giant void between content (at the top) and illustration (at the bottom) on every viewport.
185
-
186
- Rule:
187
- - Compute section `min-height` as `content_height + illustration_band_height + small_gap`.
188
- - For an illustration of natural aspect `imgW : imgH` rendered at `width:100%`, illustration height = `viewport_width × (imgH / imgW)`. Total section `that + content_height`.
189
- - Use `min-height: clamp(MIN, X vw, MAX)` rather than `aspect-ratio: figma_w / figma_h`. Pick `X vw` so on the largest viewport the section equals roughly `content + illustration`.
190
- - Sanity check: on any viewport, the bottom of the illustration must sit at most ~200px below the last content element (CTA, form, etc.) — never with a huge empty void between them.
191
- *(Earlier draft of this rule said to cap tall heroes at ~95vw; that was wrong. A Figma hero of 1440×1757 isn't "designer padding" — the bottom 60% is the illustration, which IS the hero's visual content. Capping the section short crops it.)*
192
- Rule:
193
- - Use `aspect-ratio: <FIGMA_W> / <FIGMA_H>` on the section root so the on-screen hero scales proportionally to viewport width but keeps the Figma composition.
194
- - For a 1440×1757 hero this is `aspect-ratio: 1440 / 1757` (~122% tall). Don't fight it — the designer chose that height for a reason (illustration occupies the lower 2/3).
195
- - If a composite background illustration BLEEDS past the section width (Figma group is 3120 wide on a 1440 section), display it at natural aspect via `height:100%; width:auto; position:absolute; left:50%; transform:translateX(-50%)` and let `overflow:hidden` on the section clip the bleed. Natural aspect = no distortion.
196
- - On mobile, drop `aspect-ratio` to `auto` and switch to a content-first layout: content top, illustration as a width:180% band at the bottom (some artistic bleed preserved). Tall desktop heroes don't translate well to phone-portrait viewports.
197
- - Sanity-check that the on-screen rendering matches the Figma render side-by-side — same proportions, same composition, same illustration footprint.
184
+ **M. Hero height match the Figma composition; don't crop it, don't leave a void.**
185
+ A tall Figma hero (e.g. 1440×1757) is usually NOT designer paddingthe lower 2/3 is the illustration, which IS the hero's visual content. Preserve the composition:
186
+ - **Default — use `aspect-ratio: <FIGMA_W> / <FIGMA_H>`** on the section root so the hero scales proportionally to viewport width and keeps the Figma layout (1440×1757 → `aspect-ratio: 1440 / 1757`, ~122% tall). Don't cap it short — capping crops the illustration.
187
+ - If a composite background illustration BLEEDS past the section width, render it at natural aspect (`height:100%; width:auto; position:absolute; left:50%; transform:translateX(-50%)`) and let `overflow:hidden` on the section clip the bleed. Natural aspect = no distortion.
188
+ - **Only exception** — if the Figma frame's lower area is genuinely EMPTY (no illustration, just canvas whitespace below the content), do NOT map the raw aspect-ratio (it would create a giant void); instead set `min-height content_height + small_gap`.
189
+ - **Mobile:** drop `aspect-ratio` to `auto`, go content-first (content top, illustration as a ~180% band at the bottom with some artistic bleed). Tall desktop heroes don't translate to phone-portrait.
190
+ - **Sanity-check** side-by-side with the Figma render: same proportions, same composition; the illustration's bottom within ~200px of the last content element (no large empty void).
198
191
 
199
192
  **N. After-push verification — read the plugin's response detection block.**
200
193
  Every `/push-html` response includes a `detection` object: `is_header`, `is_footer`, `has_tabs`, `has_listing`, etc. ALWAYS check it after a push. If `is_header: true` came back unexpectedly, your section just got promoted to a site-wide template and the page body is empty. Fix the source HTML (per §0.3.K — split off the nav) and re-push. Never assume the push did what you meant; the plugin's auto-classifier is the source of truth for how the content will appear.
@@ -281,6 +274,19 @@ Mobile fallback: when the image's natural aspect makes it too short for the cont
281
274
 
282
275
  This rule supersedes the prior "bg color + bottom image" approach which was generating visible seams on every hero with a non-solid bg.
283
276
 
277
+ **§V.1 — RENDER-THE-REAL-NODE: the MANDATORY first move for any complex background or custom shape (waves, curves, masks, photo+gradient scenes). Verified pixel-perfect on the CRBWA hero.** v2.2.6. When a section's background is anything beyond a flat colour / simple gradient — a photo with gradient overlays, a masked scene, a wave/curve/organic divider, a multi-layer composition — DO NOT hand-build it with stacked CSS gradients or hand-drawn SVG paths. That path burns huge time and never matches (the eye catches every off curve). It also is NOT more editable — the complex bg is not something the user tweaks; they tweak the TEXT. So: **render Figma's own output for that background and place it 1:1.** This is structure-independent — it works even on a messy, badly-named file, because Figma renders pixels, not the layer tree. Exact recipe (follow verbatim):
278
+ 1. **Get the background render from the MCP — no manual API work.** `prepare_section` now returns **`background_render`** = `{url,width,height}` (or null): the section's background rendered straight from Figma with the text/buttons EXCLUDED (photo + gradients + masks + wave/curve shapes, exactly as designed, transparent below the wave). This is the automatic, end-user-safe source — the converting Claude does NOT need a Figma token, curl, or `get_figma_data`. (Internally it's the largest text-free container in the section, e.g. the CRBWA hero's `"Banner BG"` group.) If `background_render` is null, the bg is simple → fall back to colors_used/gradients[]/vector_svgs[].
279
+ 2. **(Optional) sanity-check it** by viewing `background_render.url` — confirm there's no text and the area below any wave is transparent. If it accidentally includes text or is wrong, fall back to the gradient/vector reconstruction instead.
280
+ 3. **Use it as the section bg.** Pass `background_render.url` in `figma_data.image_asset_urls` (so the validator allows it AND the plugin sideloads it permanently — never leave the figma-alpha S3 url, it expires). Put it as an `<img>` first child, `display:block;width:100%;height:auto` (desktop: the image sets the section's exact height → wave lands exactly right, zero hand-tuning).
281
+ 4. **Overlay editable text** in a `position:absolute;inset:0` flex wrapper; position the text column with PROPORTIONAL units (`padding-left:clamp(20px,12.5vw,240px)`, `max-width:min(560px,42%)`) so it matches the design's left-column ratio at every width. Type sizes use `clamp(min, <design-px>/<canvas-px>*100 vw, <design-px>px)` so desktop ≈ the Figma px and it scales down.
282
+ 5. **Transparent-below-wave shows the next section** → set the section `background:#fff` (or the real next-section colour) so the wave reveals white, per §X.
283
+ 6. **Mobile:** switch the `<img>` to `position:absolute;inset:0;object-fit:cover;object-position:<x>% bottom` so the WAVE stays visible (it crops the sky, not the curve) + a section `min-height` + a dark scrim gradient behind the text for contrast (the desktop dark panel gets cropped on narrow screens) + smaller headline clamp so lines don't each wrap.
284
+ 7. **If you only need the bare shape** (a wave/curve over an otherwise simple bg, not the whole composited scene) — use the matching `prepare_section.vector_svgs[].svg` (the real Figma vector, paste it verbatim). NEVER eyeball control points. If the shape is a full-height divider that `vector_svgs` didn't return (large waves sometimes get dropped), use `background_render` for the whole bg instead. For a crisp uniform crest line use `vector-effect="non-scaling-stroke"`, never a semi-transparent stretched stroke (it blurs).
285
+ GOTCHA — do NOT pass a headline that contains a coloured sub-`<span>` (e.g. "The **CRBWA**…") through `figma_data.text_nodes`: the enforce step fuzzy-matches the word and stamps the paragraph's small size/colour onto the span (shrinks "CRBWA" to 11px). Set that headline's typography inline yourself and omit it from `text_nodes`.
286
+ The division that makes it BOTH pixel-perfect AND editable: **complex/custom visuals → real Figma render; headline + paragraph + buttons + scroll-arrow → live HTML overlay.** See [[feedback-real-shape-not-handdrawn]].
287
+
288
+ **§V.2 — POSITION FROM FIGMA COORDINATES, NEVER BY EYE.** v2.2.7. The user supplies a Figma access token precisely so the MCP can read EXACT positions — use them. `prepare_section` returns **`placements`** = each child block's `{name, top_pct, left_pct, width_pct, height_pct, overflows}` as a percentage of the section. Place every block at those exact percentages instead of guessing margins. For an element that OVERLAPS / straddles an edge (a card that bleeds over a wave, a badge hanging off a corner, an image into the next section): if it's a sibling of the section (a separate frame, common in messy files), compute its position the same way from the two bounding boxes you already have — `top% = (childY − sectionY) / sectionHeight`. EXAMPLE (CRBWA board+cards): board section y=3830 h=1009, project-card frames y=4472 → cards sit at `(4472−3830)/1009 = 63.6%` down the section; the card height `692/1009 = 68.6%` means they overflow the bottom (place absolutely, or pull a flow block up by the exact amount). Driving the overlap off the real coordinate (not a hand-tuned negative margin) is what makes overlapping compositions pixel-perfect. NEVER eyeball an overlap/offset when the coordinates are in `placements` or derivable from the bboxes.
289
+
284
290
  **W. When rendering a Figma group as a bg image, CHECK that the rendered PNG isn't asymmetrically masked.**
285
291
  Many Figma "Background" or "Scene" groups contain a gradient-mask sublayer that clips the fill to only part of the group's bounding box. Rendering the parent group via `/v1/images` produces a PNG with the masked area filled and the un-masked area **transparent** — when laid over any section bg color, the transparent edge creates a visible vertical seam where the mask boundary sits.
286
292
  Detection: download the rendered PNG and visually verify it doesn't have transparent regions at the edges. If it does → don't use that group.
@@ -393,6 +399,16 @@ For real interactive Google Maps, include the actual iframe in the HTML: `<ifram
393
399
  **AJ. CPT listings inside custom layouts — use flat mode (`data-sz-post-flat="1"`).**
394
400
  Plugin v1.27.20+ added flat mode: the wrapping div KEEPS your custom classes (flex slider, grid, etc.) — cards render directly inside it, no `.sz-post-listing > .sz-post-cards` wrapper inserted. Use this for Post Listing slider/grid sections where layout matters. Without `data-sz-post-flat="1"`, the plugin overrides your wrapper and breaks the layout.
395
401
 
402
+ **AJ.1 — A PRODUCT/POST LISTING THAT IS A SLIDER: set the slider config per the design (these are EDITABLE later, you just set the right starting values).** When the design's product/post carousel is a slider, the listing element must carry, IN ADDITION to the listing attrs, the slider config — the plugin's Dynamic Post Listing panel exposes all of these and the render honours them:
403
+ - `data-sz-post-layout="slider"` — render as a slider (not a static grid).
404
+ - `data-sz-post-count="<TOTAL items>"` — set to the **total number of products/posts the slider should cycle through** (e.g. all 6 products), NOT the 2–3 cards drawn in the static design. A slider with only as many slides as fit the viewport has nothing to slide — count MUST exceed per-view for it to move.
405
+ - `data-sz-slider-per-view="<N>"` — how many cards are visible at once in the design; use **`auto`** when the design gives the card its OWN width (the card CSS then governs sizing — the safest default for design fidelity). Never leave it to fight the card CSS with a wrong fixed number.
406
+ - `data-sz-slider-arrows="1"` / `data-sz-slider-dots="1"` / `data-sz-slider-autoplay="1"` — set to **`1` only when the design actually shows them**, else omit/`0`.
407
+ - ARROWS: if the design has its OWN arrow buttons, KEEP them (wire via `data-sz-slider-prev`/`-next="#trackId"` per §0.3.AK) so the plugin REUSES the design arrows instead of drawing its plain defaults — do not drop them. If the design has none, omit arrows and the plugin makes a default pair only if `arrows="1"`.
408
+ - `data-sz-slider-delay="<ms>"` — when the design AUTOPLAYS, read the REAL interval from the source JS (the `setInterval`/`setTimeout(... , N)` driving the carousel, or a `data-interval`/AOS/Swiper `autoplay.delay` value) and put that number here so the speed matches the design. Default if absent: 4000ms.
409
+ - ⚠️ **Do NOT hardcode the card width with `!important`** (e.g. `.product-card{width:calc(25% - 23px)!important}`). The slider engine owns the per-view sizing; a baked `!important` width fights it and makes the editor's "Slides per view" do nothing. Let the card width come from `data-sz-slider-per-view` (number) or, for `auto`, the card's plain (non-`!important`) CSS.
410
+ The user can change every one of these afterwards in the editor; your job is to seed them to match the design so it slides correctly and looks right on first render.
411
+
396
412
  **AK. Horizontal slider arrows — use plugin's `data-sz-slider-prev`/`data-sz-slider-next` (NOT inline `onclick`).**
397
413
  WordPress's `wp_kses` strips inline `onclick=` and `<script>` from block content, so inline handlers silently fail in production. Plugin v1.27.21+ wires arrows by attribute:
398
414
  - `<button data-sz-slider-prev="#sz-board .track">←</button>`
@@ -431,23 +447,43 @@ Each Figma image URL gets its own attachment via `SiteZen_Images::optimize` (cac
431
447
  When a page already has several sections fused into one `wp:sitezen/section` block (typical after WP editor merges), `replace_block_index` only swaps individual blocks, not sections-within-a-block. A re-push of one section appends a NEW block; the old section inside block 0 still renders. Either: (a) extract the desired sections from block 0's `sectionHtmlB64` and re-push as block 0, or (b) instruct the user to delete the merged block in the editor. Always run `/debug-page/{id}` first and decode each block's `sectionHtmlB64` to map section→block before pushing.
432
448
 
433
449
  **AS. Use the plugin's EXISTING slider contracts — three modes, pick by Figma intent.**
434
- Before inventing new slider markup, check `PLUGIN_FEATURE_MAP` + `frontend.js` — the plugin already supports three distinct slider modes. Pick the one whose visual matches the Figma:
435
450
 
436
- 1. **Native multi-card / single-slide auto-detect** (`initBasicSlider`):
437
- - Markup: `<section class="sz-multi-card" data-sz-slider-mode="multi"> … <div class="sz-slides"><div class="sz-slide">…</div>…</div></section>`
438
- - Plugin auto-creates `.sz-prev` / `.sz-next` / `.dots > .sz-dot` if absent (toggle via `data-sz-slider-arrows="0/1"` / `data-sz-slider-dots="0/1"` on section).
439
- - Multi-card uses transform-translateX track; single-slide hides all but active. Mode auto-detected from slide:container width ratio; force with `data-sz-slider-mode="multi|single"`.
440
- - Use for: row-of-cards carousels where all visible cards have equal weight.
451
+ > **ONE UNIFIED SLIDER for EVERYTHING (HTML→WP AND Figma→WP).** Every slider — hero, testimonial, logo/brand strip, image gallery, product carousel, post carousel — uses the SAME engine via the `data-sz-slider` contract (mode 1 below; center/testimonial just adds `data-sz-slider-center="1"`). They therefore ALL inherit the identical sliding behaviour, design-fidelity (the engine respects the design's own card width and REUSES the design's own arrow buttons), and the SAME editable options the plugin exposes: **per-view, arrows, dots, autoplay, loop, autoplay-speed, pause-on-hover** (product/post sliders expose these in the Dynamic Post Listing panel; all other sliders in the Slider panel). So configure ANY slider the same way: seed `data-sz-slider-per-view` (or `auto`), `-arrows`/`-dots`/`-autoplay`/`-loop`/`-delay` to MATCH the design, KEEP the design's own arrow elements (don't draw defaults), and NEVER hardcode the card width with `!important` (it fights per-view). Do NOT hand-roll a custom JS slider, a CSS `@keyframes` crossfade, or the legacy `[data-sz-center-slider]` engine — one `data-sz-slider` contract covers all of them. This is identical for Figma conversions: a Figma carousel/testimonial/hero-slider → the same `data-sz-slider` markup + per-design config.
441
452
 
442
- 2. **Center-mode slider** (`initCenterSlider`, triggered by `[data-sz-center-slider]`):
443
- - Markup: `<div class="slider" data-sz-center-slider><div class="slider-viewport"><div class="slider-track"><div class="t-card is-active">…</div>…</div></div><button class="slider-prev">‹</button><button class="slider-next">›</button><div class="dots"><button class="sz-dot active"></button>…</div></div>`
444
- - Active card centered + scaled/opaque; previous & next peek at sides faded. Section root reads `data-sz-slider-autoplay/loop/speed/pause-hover`.
445
- - Use for: testimonial / showcase sliders where ONE card is the focus and side peeks tease context.
453
+ Before inventing new slider markup, check `PLUGIN_FEATURE_MAP` + `frontend.js` — the plugin already supports three distinct slider modes. Pick the one whose visual matches the Figma:
454
+
455
+ 1. **Standard slider THE DEFAULT (`data-sz-slider`, one engine: sz-swiper.js):**
456
+ - Markup: `<div class="sz-slider" data-sz-slider data-sz-slider-per-view="1" data-sz-slider-autoplay="1" data-sz-slider-loop="1" data-sz-slider-arrows="1" data-sz-slider-dots="1"><div class="sz-slides"><div class="sz-slide">…</div><div class="sz-slide">…</div></div></div>`
457
+ - ONE engine drives every standard slider. Control it ENTIRELY via `data-sz-slider-*` on the `.sz-slider` wrapper: `-per-view` (1 = single-slide hero / one-at-a-time; 3 = row of cards), `-arrows`/`-dots` (set `="1"` ONLY if the design actually shows them — default is none), `-autoplay`/`-loop`/`-speed`/`-delay`, `-gap`, and `-effect` (`slide` default; `fade`/`zoom`/`flip`/`ken-burns` = in-place STACKED transition for single-slide heroes).
458
+ - ⚠️ DEPRECATED — do NOT use `sz-multi-card` or `data-sz-slider-mode`. Those triggered a SECOND, legacy engine on top of this one → DOUBLE arrows + fighting autoplay on every slider. The single `data-sz-slider` contract above is the only correct one; single vs multi is just `data-sz-slider-per-view`.
459
+ - Use for: hero sliders, card/testimonial/logo carousels, image galleries — single OR multi, set per-view.
460
+ - **ENGINE OUTPUT CONTRACT — write your CSS against THESE real classes (the engine does NOT rename your slides):** The dependency-free engine keeps your `.sz-slide` elements as-is (it does NOT convert them to `.swiper-slide` — that only happens if the vendor Swiper bundle is loaded, which it is not). So:
461
+ - **Slides:** stay `.sz-slide`. The one currently shown gets `.is-active` → target `.sz-slide` for base styles and `.sz-slide.is-active` for the visible/effect state. Put a full-screen hero's `min-height`/`background-image` on `.sz-slide` itself (NOT a child), so each slide has a real box — otherwise stacked/fade slides collapse to 0 height and look black/blank.
462
+ - **Arrows (only created when `data-sz-slider-arrows="1"`):** `<button class="sz-car-arrow sz-car-prev">` and `class="sz-car-arrow sz-car-next"`, appended INSIDE the `.sz-slider`. To reposition/style them, target `.sz-car-arrow`, `.sz-car-prev`, `.sz-car-next` (NOT `.sz-prev`, `.swiper-button-*`, or `.slider-prev` — those never exist). e.g. `#sz-hero .sz-car-next{right:24px;bottom:24px;top:auto}`. ⚠️ VALID CSS ONLY: when offsetting with calc() ALWAYS put spaces around operators — `calc(8% + 65px)`, NEVER `calc(8%+65px)` (the no-space form is invalid and silently ignored, so the arrow lands in the wrong place). The engine fills the buttons with `‹`/`›` glyphs; if the source uses different arrow symbols, you can set them via CSS `::after`/`content` on `.sz-car-prev`/`.sz-car-next` (e.g. `content:'→'`).
463
+ - **Dots (only when `data-sz-slider-dots="1"`):** `<div class="sz-car-dots"><button class="sz-car-dot">…`, active dot = `.sz-car-dot.is-active`. Style via `.sz-car-dots` / `.sz-car-dot` / `.sz-car-dot.is-active`.
464
+ - **In-place EFFECT (fade/zoom/flip via `data-sz-slider-effect`):** the engine adds `.sz-slider-stacked` to the `.sz-slider`, stacks the slides absolutely, and toggles `.sz-slide.is-active`. A default fade ships in the plugin; to match a SOURCE effect exactly, add your own CSS on `.sz-slider-stacked .sz-slide` (hidden state) and `.sz-slider-stacked .sz-slide.is-active` (shown state), e.g. zoom: `.sz-slider-stacked .sz-slide{opacity:0;transform:scale(.94)} .sz-slider-stacked .sz-slide.is-active{opacity:1;transform:scale(1)}`. Do NOT rely on the source's own `<script>` (wp_kses strips it) — the engine drives timing; your CSS only paints the look on `.is-active`.
465
+
466
+ 2. **Center-mode slider — PREFER engine #1 with `data-sz-slider-center="1"` (one engine for everything).**
467
+ - The standard engine (mode 1, `data-sz-slider`) NOW does center mode itself: `<div class="sz-slider" data-sz-slider data-sz-slider-center="1" data-sz-slider-per-view="3" data-sz-slider-autoplay="1" data-sz-slider-loop="1"><div class="sz-slides"><div class="sz-slide is-active">…</div>…</div></div>`. The active card centers/scales; neighbours peek. Use this for testimonial / showcase sliders so they inherit the SAME sliding + options + design-fidelity logic as every other slider.
468
+ - The legacy separate `[data-sz-center-slider]` engine still exists for back-compat, but do NOT emit it for new conversions — use `data-sz-slider-center="1"` on engine #1 instead.
446
469
 
447
470
  3. **Generic horizontal scroll** (`data-sz-slider-prev/next="<selector>"` arrows, v1.27.21):
448
471
  - For Post Listing flat-mode tracks and any custom horizontal scroller that uses CSS `overflow-x:auto` + `scroll-snap`. Arrow buttons reference the track via selector and call `track.scrollBy(stepWidth)`.
449
472
  - Use for: CPT listings in slider layouts (already covered by §0.3.AJ), or any custom `overflow-x` track that doesn't fit modes 1 or 2.
450
473
 
474
+ **ARROW STYLING IS NOT OPTIONAL — replicate the SOURCE's arrows exactly (do NOT ship the engine's bare default).**
475
+ The engine creates `.sz-car-arrow.sz-car-prev/.sz-car-next` but paints them with a plain `‹/›` glyph and minimal style. If the source design's arrows are circular / bordered / a specific size / use a specific icon, you MUST emit CSS that reproduces that look — otherwise the slider works but looks "templated", which reads as a conversion bug. Copy the source's arrow rule onto `.sz-car-arrow` and friends. Example for a source `.nav-arrow{width:55px;height:55px;border:1px solid #333;border-radius:50%;background:rgba(0,0,0,.4);color:#fff}` with a right-arrow icon:
476
+ ```css
477
+ #sz-hero .sz-car-arrow{width:55px;height:55px;border:1px solid #333;border-radius:50%;background:rgba(0,0,0,.4);color:transparent;display:flex;align-items:center;justify-content:center}
478
+ #sz-hero .sz-car-prev::after{content:'\2190';color:#fff} /* ← match source icon */
479
+ #sz-hero .sz-car-next::after{content:'\2192';color:#fff} /* → */
480
+ #sz-hero .sz-car-arrow{position:absolute;bottom:50px} #sz-hero .sz-car-next{right:6%} #sz-hero .sz-car-prev{right:calc(6% + 75px)}
481
+ ```
482
+ Match position (the source `.slider-nav-arrows` container coords), gap, hover color, and the EXACT icon. Set `color:transparent` on the button if the engine glyph would otherwise show alongside your `::after` icon.
483
+
484
+ **JS-INJECTED carousels — materialize the items, then emit the slider contract.**
485
+ A source may build its slider/carousel content at runtime: an EMPTY track (`<div class="carousel-track" id="…-injector"></div>`) filled by a `<script>` from a JS data array (e.g. `loadCarouselDataTrack()` pushing `.carousel-cell`s). `wp_kses` strips that script, so if you copy the static HTML you get an EMPTY slider that the converter then "flattens" into a static grid — losing the slider entirely (real bug seen on KAIZEN "Curated Vault Highlights"). FIX: READ the JS data array in the source and MATERIALIZE each item as a real `.sz-slide` (or product card), wrapped in the `data-sz-slider` contract (mode 1, `per-view="3"` for a card row, arrows on, replicate the source's prev/next styling per the rule above). If those items are PRODUCTS, this is BOTH a slider AND a product listing → emit the slider markup with the cards as `data-sz-post-flat="1"` Product Listing cards so they become real WooCommerce products AND slide. Never let a JS-built carousel degrade into a static grid.
486
+
451
487
  Rule: **never** inline `onclick` or `<script>` in section HTML — WP `wp_kses` strips both. Always wire interactivity through one of the three plugin contracts.
452
488
 
453
489
  **AT. Accordion — plain + linked-panel contracts.**
@@ -456,7 +492,10 @@ Plugin contract (`initAccordion`, all versions):
456
492
  - Each item: `<div class="accordion-item">` (one MAY have `.open` for default-open).
457
493
  - Trigger: `<button class="accordion-trigger">…</button>` (also accepts `.accordion-button`, `.sz-accordion-trigger`, `[data-toggle="collapse"]`).
458
494
  - Body: `<div class="accordion-body">…</div>` (also `.accordion-collapse` / `.sz-accordion-body`). Body's `max-height` is animated by plugin from 0 → `scrollHeight` on open.
459
- - Plugin auto-closes any other open item when one opens (single-open behavior).
495
+ - ⚠️ **The collapse body MUST stay BARE — put ALL padding/margins/borders on an INNER wrapper, never on the body element itself.** The body is the `max-height:0; overflow:hidden` collapse element; any padding on IT leaves a visible strip when collapsed and fights the height animation. Correct structure:
496
+ `<div class="accordion-body"><div class="accordion-body-inner" style="padding:0 30px 30px">…answer…</div></div>`
497
+ Putting `padding:…` directly on `.accordion-body` (and ALSO on the inner) is the #1 cause of "accordion shows all answers open / won't collapse". Emit the body with NO inline style except (optionally) the font/color; spacing goes on `-inner`.
498
+ - Plugin auto-closes any other open item when one opens (single-open behavior). On init the plugin now sets each body's height explicitly (open → scrollHeight, closed → 0), so collapse no longer depends on the page CSS winning.
460
499
 
461
500
  **Linked-panel variant (v1.27.25+):**
462
501
  Use when accordion item should swap a SEPARATE element (right-side image, video, stat card) in sync with which item is open:
@@ -840,6 +879,139 @@ This closes the loop: every conversion now has a clear destination set BEFORE th
840
879
  **AM. Output first-try discipline.**
841
880
  Every retry costs ~$0.30 in API + ~2 minutes of human-in-loop iteration. The cost ceiling for a section is ONE conversion call. Treat each emit as final: walk Figma fully, follow §0.3 A–J, run the §0.3 H checklist, then output. The retry button exists for genuine errors (network, malformed JSON), NOT for "I didn't bother looking at Figma deeply enough".
842
881
 
882
+ **AN. ANIMATIONS — ANY animation (GSAP/ScrollTrigger/3D/Framer/AOS) — applies to EVERY section.** v1.93.0. (Universal so it reaches hero, features, gallery, listing — any animated section.) The design's animation `<script>` is stripped on import, but READ it first.
883
+ - **Most reliable & faithful path = the GENERIC custom contract** (a mechanical copy of the source's own values — works for ANY animation; a connected Claude does NOT need to know our preset catalog). For each animated element, read its tween + ScrollTrigger from the source and emit `data-sz-gsap="custom"` + `data-sz-gsap-from='{…GSAP vars…}'` (and/or `-to`) + optional `data-sz-gsap-scrub="true"`, `data-sz-gsap-pin="true"`, `data-sz-gsap-stagger="0.1"` (animate the element's CHILDREN), `data-sz-gsap-start/-end/-ease/-duration/-delay`.
884
+ - Vars = JSON of real GSAP props: `x, y, opacity, scale, rotation, rotationX, rotationY, z, skewX, filter, clipPath`, … Example — scrubbed Z-fly card deck: `data-sz-gsap="custom" data-sz-gsap-from='{"z":-800,"rotationX":45,"opacity":0}' data-sz-gsap-scrub="true" data-sz-gsap-pin="true" data-sz-gsap-stagger="0.12"`. The engine auto-adds `perspective` to the parent for 3D props and respects reduced-motion.
885
+ - **DETECTION:** any element targeted by `gsap.from/to/fromTo/timeline`, a `ScrollTrigger`, `data-scroll`, or an `AOS`/`data-aos` attribute MUST get a `data-sz-gsap` — never leave it static.
886
+ - **Named presets** (`fade-up`, `pin-reveal`, `horizontal-scroll`, `parallax`, `tilt-3d`, `flip-card`, `counter`, `marquee`, …) are a convenience: use them for FIGMA (no source script to read) or an obviously-standard effect. Whenever you CAN read the source tween (HTML/code), prefer `custom`.
887
+ - **FIDELITY (both flows):** keep the source's exact transform/perspective/opacity CSS for the LOOK; `data-sz-gsap` only drives the motion/timing.
888
+
889
+ **AO. DYNAMIC LISTINGS, FILTERS & VERIFY — IDENTICAL pipeline for BOTH Figma and HTML.** Whenever a section is a repeating listing (products, blog/journal, team, services, specs — any card grid / slider / masonry), and ESPECIALLY when it has a FILTER UI (category/tag pills, a filter sidebar, dropdown panels, colour swatches, a price slider, or a sub-category tree), follow this exact pipeline (the source differs, the steps don't):
890
+ 1. **Load the contract:** call `get_conversion_rules("post_listing")` — it carries the FULL listing contract: in-listing filter wiring (§6.F/§6.K), section-named CPTs (§6.N, never default `post`), arbitrary card fields `data-sz-post-field="<key>"` (§6.L), and the verify step (§6.M). Do this whenever you detect a listing/filter — don't rely on the generic WC rule alone.
891
+ 2. **Mark + preserve:** wrap the listing as `data-sz-post-listing` with `data-sz-post-type` (`product` for real shop items, else a section-named CPT like `sz_journal`), and PRESERVE the design's OWN filter markup, adding the `data-sz-filter-*` / `data-sz-attr` / `data-sz-price-*` wiring from §6.K (don't replace it with generic pills).
892
+ 3. **Create the data:** products → `create_products([...])`; every other listing auto-creates from its cards. If the listing has a filter, AFTER the items exist call `create_filter_data(post_type, taxonomies, attributes)` (post_type = the SAME slug you put in `data-sz-post-type`) so the filter terms/attributes are created and round-robin assigned — otherwise every filter click returns empty.
893
+ 4. **Verify:** call `get_rendered_preview(page_id)` and confirm the listing expanded into real cards AND the filter UI survived, BEFORE telling the user it's done (§6.M).
894
+
895
+ **AO.1 — DETECTION IS MANDATORY, and it works DIFFERENTLY for code vs Figma (this is the #1 silently-skipped step).** The STEPS above are identical; only how you *recognise* the filter differs by source. You MUST run the matching detection on every listing — a missed filter is the single most common "the site doesn't match the design" bug, and it's always a detection miss, never an engine limitation.
896
+ - **CODE / HTML import:** the filter controls already exist in the markup — but often (a) as plain `<button class="tab">`/`<a>`/`<li>` with category-like labels ("All · Men · Women", "All Product · Phone · Laptop"), (b) as a left sidebar of checkboxes, or (c) driven ONLY by JavaScript (an empty `<div id="grid"></div>` filled by `render(DATA)` where each datum has a `category`/`type`/`subgroup`/`color` field — see §6.J). Detection: scan the source for ANY control group whose labels or JS data-fields partition the items. Then TAG the existing controls in place (never replace them) with the §6.F contract and map the JS filter fields → taxonomies/attributes for `create_filter_data`. If the grid is JS-rendered, reconstruct the card template from the JS literal first (§6.J).
897
+ - **FIGMA:** there is no markup and no JS — the filter is a visual row/column of nodes. Detection: a horizontal row of short text/pill nodes sitting directly ABOVE a repeating card grid, or a vertical list of label+checkbox nodes beside it, IS a filter — not decoration. Render those nodes as the real controls (`<button class="sz-filter-pill" data-sz-term="<slug>">` etc.) carrying their node styling as inline CSS, first tab = `data-sz-term=""` ("All", `.is-active`), and derive term slugs from the labels. There is no JS data array to read, so you MUST call `create_filter_data` with taxonomies named from the visible labels so the terms exist.
898
+ - **BOTH — three hard invariants or the filter silently does nothing (or crashes):** (1) the `data-sz-post-listing` wrapper MUST enclose BOTH the control group AND the grid (the engine as of plugin v2.2.17 also tolerates a control row placed one level *outside* the wrapper as a safety net, but you must still aim to enclose it); (2) the listing MUST be QUERY mode — set `data-sz-post-type` + `data-sz-template` and DO NOT set `data-sz-post-bind`. A filter on a bound listing is invalid: bind mode renders the design's fixed cards straight into the grid with no swappable `.sz-post-cards` container, so a filter click has nothing to re-query (older plugins even threw a null error in `reload()`). **If a listing has ANY filter/switch/tab UI, it is a QUERY listing, full stop — never bind.** Bind mode is only for a filter-LESS curated/varied showcase; (3) the grid the cards render into must be the `.sz-post-cards` container that query mode emits — don't hand-bake cards into bind mode and then bolt a filter on. Always finish with the §6.M `get_rendered_preview` self-check: click-state classes present, controls tagged, cards real, and (for a filtered listing) NO `data-sz-post-bind` on the wrapper.
899
+
900
+ **AP. SINGLE-PRODUCT TEMPLATE — ask once after creating products (BOTH flows).** Whenever a conversion creates WooCommerce products (via `create_products` OR a `data-sz-post-type="product"` listing), call `get_wc_single_template`; if its mode is unset/`default` with no `template_id`, ask the user ONCE — as its own clear question, not buried in a summary: *"How should the single product page (what customers see when they click a product) look? (A) Use our polished default — works out of the box, recommended. (B) I'll convert my own product-detail design later — we keep the default running until you share it. (C) Generate one matching my brand — sampled from your product colours/fonts."* Then call `set_wc_single_product_template` with their pick. Identical for Figma and HTML.
901
+
902
+ **AQ. HEADER MICRO-CONTRACTS + the slider double-wrap trap (the "looks right, doesn't work" bugs).** These are REAL plugin features — use the exact markers, don't invent your own (an invented attribute renders but does nothing):
903
+ - **Search bar in a header** → `<form data-sz-search data-sz-search-type="auto"><input type="search" name="s">…</form>`. NEVER leave a header search as a bare `<form>` — the plugin would turn it into a CONTACT form. `data-sz-search` makes it the real product/post search.
904
+ - **"All Categories" button/panel** → an EMPTY `<ul data-sz-nav-source="wc-categories">` (or `"categories"`/`"auto"`) the plugin fills with live categories. A plain `<button>` opens nothing.
905
+ - **Social icons ("Follow us")** → SEPARATE `<a href="…">` links, one per platform, each with its own icon — NEVER one combined `<svg>` blob (not clickable, not editable).
906
+ - **Account** → `<a href="/my-account/">` (the real WooCommerce account page).
907
+ - **Cart / Wishlist** → `class="sz-header-cart"` + `<span data-sz-cart-count>` / `class="sz-header-wishlist"` + `<span data-sz-wishlist-count>` (live counts; the plugin also opens `sz-cart-drawer` / `sz-wishlist-drawer`).
908
+ - **Nav menu** → `<nav data-sz-nav data-sz-nav-root><ul><li>…` (plugin injects the hamburger + mobile dropdown). Put the MAIN menu items here (not just utility links).
909
+ - **Dismissible promo bar** (a top banner with an X/close) → `data-sz-dismissable` on the bar + `data-sz-dismiss` on the close control (optional `data-sz-dismiss-key="blackfriday"` to remember it). The plugin hides it on click. (Plugin v1.94.0.)
910
+ - **SLIDER DOUBLE-WRAP — CRITICAL:** NEVER wrap a product/post listing-slider in a SECOND manual `data-sz-slider`. A carousel is EITHER a listing-slider (`data-sz-post-listing data-sz-post-type="product" data-sz-post-layout="slider"`) OR a manual `data-sz-slider` of static cards — **never both nested**. Double-wrapping creates 2-3 fighting sliders with conflicting arrows/dots the editor can't control. A hero promo carousel of real products → the **listing-slider alone**.
911
+ - **SLIDER PER-VIEW — set the REAL number, never leave it 'auto':** count how many full cards are visible at once in the design and set `data-sz-slider-per-view` to that number (a hero showing one slide = `1`; a 3-up product row = `3`; add `-per-view-tablet`/`-per-view-mobile` too). Leaving it unset → the engine falls back to `auto`, and full-width cards then **overlap**. So always emit an explicit number from the design.
912
+ - **SLIDER ARROWS/DOTS FIDELITY (make them look like the design):** turn `data-sz-slider-arrows`/`data-sz-slider-dots` ON only if the design shows them. The engine renders working **arrows** as `.sz-car-arrow` (`.sz-car-prev` / `.sz-car-next`) and **dots** as `.sz-car-dots > .sz-car-dot` (active = `.sz-car-dot.is-active`). To make them match the design, include a scoped `<style>` painting THOSE classes to the design's exact arrow shape/size/position and dot style/position/active-colour — same pattern as the slider effect (the engine drives behaviour, your CSS paints the look). If the design already has its OWN arrow elements, keep them in the markup — the engine reuses them instead of creating its own.
913
+
914
+ **§0.AR — CURATED / FEATURED sections (Best Deals, Featured, hand-picked rows): DYNAMIC + SCOPED, never static, never "show all".** v1.95.0. A designed section that shows a SPECIFIC, hand-picked set of items (and often a big FEATURED card + a grid, each card with its OWN badge/discount/rating) is a DYNAMIC listing scoped to its own set — NOT static cards (the user couldn't change price/items later) and NOT a "query all" archive (it'd show the wrong items). Build it so the set + prices can change later with NO re-convert:
915
+ 1. **CREATE the real items with a SECTION CATEGORY:** `create_products([{… category:"Best Deals" …}])` (products), or assign each post/CPT to a section category/term. The category IS the editable set.
916
+ 2. **SCOPE the listing to that category:** emit a normal listing (`data-sz-post-listing` + `data-sz-post-type`) and add `data-sz-post-cat="best-deals"` (the section category's slug). The listing then shows ONLY that set, in order. To change it later: add/remove items from that category in admin → the section updates automatically (no re-convert). Optional manual override: `data-sz-post-include="<ids>"` (add) / `data-sz-post-exclude="<ids>"` (remove).
917
+ 3. **ANY LAYOUT — keep EVERY card exactly as designed (the slot model):** just emit the design's cards EXACTLY as they are — any arrangement, any per-card differences (a big featured cell, a wide cell, a different badge style, an asymmetric grid, whatever the designer made) — each marked `data-sz-card`, and keep the design's container CSS (grid/flex spans). The plugin treats each design card as its own SLOT and fills slot[i] with the i-th scoped item (live data), so the design's EXACT layout is reproduced with zero plugin changes per layout. You do NOT need to identify "featured vs grid" or pick a layout type — the cards ARE the layout. (Items beyond the designed slots fall back to the regular card; identical cards behave as one repeating template — a normal feed.)
918
+ 4. **PER-ITEM DATA is dynamic:** each card's price comes from the product; sale/discount from the product's sale price; rating from `{{rating}}`; a custom badge from `data-sz-post-field="badge"` (saved as item meta). So changing a price/badge in admin updates the card. Mark each card's fields per the listing contract.
919
+ This gives: pixel-perfect (the exact design), fully functional (real items, add-to-cart), editable, and future-flexible (swap the set or prices without re-converting). NEVER fall back to static cards or a uniform "all products" grid for a curated section.
920
+
921
+ **§0.AR.1 — ANTI-STATIC MANDATE (the #1 conversion defect — read this every time).** If a section shows items that have a **name + price + image** (or any of: "Add to cart", "Buy now", "Shop now", a rating, a wishlist heart, a "Quick view"), it IS a product section (or, for articles/team/projects with title+image+link, a post/CPT section) and you **MUST** convert it as REAL items + a marked listing — NEVER as static HTML cards. Static `<div>`/`<button>` cards have **no product behind them**, so add-to-cart, quick-view, wishlist, and the editor panels are all **dead** — this is exactly the failure to avoid. The required steps, every time:
922
+ 1. `create_products([...])` (or create the real posts/CPT) — real items in the dashboard.
923
+ 2. Wrap the cards in `data-sz-post-listing data-sz-post-type="product"` (+ `data-sz-post-cat="<section-slug>"` for a curated set).
924
+ 3. On each card: `data-sz-card` on the root + `data-sz-post-field="image|title|price|url|rating|badge"` on the dynamic elements + keep the design's "Add to cart" / "Quick view" / heart controls in the markup (the plugin auto-wires them — by label or marker — to the bound product).
925
+ **Do NOT render static "to be safe" / "to avoid the hijack."** The hero-hijack risk is ALREADY handled by the converter guard (a hero/banner with per-view=1 or an `<h1>` is never promoted to products), and the **bind-in-place engine keeps your cards pixel-perfect** (verified) — so there is ZERO visual downside to doing it the real way and a TOTAL functional loss to doing it static. If you ever catch yourself about to emit static product cards, that is the signal to call `create_products` + mark the listing instead.
926
+
927
+ **§0.AR.2 — STATIC-FALLBACK PRODUCT CARDS MUST STILL BE LIVE (price never frozen).** v2.2.3. There is ONE legitimate reason to NOT wrap a product card in `data-sz-post-listing`: a compact **multi-column mini-list** (e.g. four side-by-side columns "Flash Sale / Best Sellers / Top Rated / New Arrival", each a stack of small thumb+name+price rows). Promoting that to a listing wrapper collapses the multi-column grid, so those rows are built as plain `<a href=".../product/<slug>/">` cards. THAT IS ALLOWED — but the price MUST NOT be baked from the design (the recurring "every card shows $1,500 and editing the product does nothing" defect). EVERY such static product card MUST: (1) link the card `<a>` to the **real product permalink** (resolve via `create_products` / WC Store API — never a `#` or design URL), and (2) mark EVERY element that mirrors product-editor data with `data-sz-card-field`, so the whole card stays live (not just price): `data-sz-card-field="image"` on the thumb (works on a real `<img>` AND on a `<span style="background-image:url(...)">` thumb), `data-sz-card-field="title"` on the name, `data-sz-card-field="price"` on the active price, and `data-sz-card-field="sale_price"` (or `regular_price`) on the struck-through "was" price if the card shows one. The plugin's `refresh_static_product_cards` pass then refreshes ALL of them from the live product on every render — so editing the product's name, image, price, or sale price in WooCommerce reflects on the card. Layout-independent (works for ANY column count / card shape). Even with NO markers the pass rescues image + price (and the title on simple thumb+name+price rows) via the product link, but ALWAYS emit the full marker set so binding is explicit, not heuristic — heuristics can't safely guess which node is the title when a card also has a badge/rating. (Decorative design badges like "BEST DEALS"/"HOT" are NOT product data — leave them as drawn unless they map to a real product field.) RULE OF THUMB: a card with a real product behind it must NEVER show a hard-coded price/name/image — mark each so it tracks the product.
928
+
929
+ **§0.AR.3 — THE "CSS GOT STRIPPED" MYTH — a flat listing NEVER loses styling (stop retreating to static).** v2.2.3. The #1 reason a real, clickable dynamic listing gets abandoned mid-conversion is a FALSE belief: "I pushed the card grid, it became a `data-sz-post-listing`, and the `<style>` block / inline styles disappeared, so I rebuilt it fully static." **That diagnosis is wrong.** At render the plugin (`class-sitezen-assets.php`) EXTERNALIZES every `<style>` block to a cached `/wp-content/uploads/sitezen-css/<hash>.css` file and replaces the inline block with a `<link rel="stylesheet">` — the CSS is MOVED, not removed, and still fully applies. Inline `style=""` attributes are NEVER touched. (Confirmed live: the punchmitten "Latest News" section renders with its `#sz-clicon-news` CSS intact via the externalized link.) So a flat dynamic listing keeps the design pixel-for-pixel AND gives real, clickable posts/products. **Decision rule when you see a uniform card grid (blog/news/journal/services/team/projects/products):**
930
+ - **SINGLE uniform grid in the section** → DYNAMIC flat listing, ALWAYS. `data-sz-post-listing data-sz-post-flat="1"` + per-card `data-sz-card` + `data-sz-post-field="image|title|excerpt|url"` (+ `data-sz-post-type` = section CPT for articles, `product` for shop). The plugin auto-creates the real posts/products and makes each card clickable to its entry. Do NOT go static — static is exactly what drops the "real clickable posts" behaviour the user wants. If a first push looked unstyled, RE-CHECK the externalized CSS link before concluding anything; do not rebuild static on that hunch.
931
+ - **MULTIPLE independent mini-lists packed into ONE section** (the 4-column "Flash Sale / Best Sellers / Top Rated / New Arrival" case) → the ONLY case where static cards are correct, because one `data-sz-post-listing` can't represent 4 separate curated sets without collapsing the columns. Build static per §0.AR.2 (real permalinks + live `data-sz-card-field="price"`).
932
+ The line is "how many distinct lists" — not "will the CSS survive" (it always does).
933
+
934
+ **§0.AR.4 — A SECTION CPT IS ALREADY A REAL, CLICKABLE POST — never duplicate it into standard "Posts".** v2.2.3. The second half of the static-retreat mistake: after building the blog/news listing, the converter worries "these are only CPT entries, not *real* posts — to make them real and clickable I must also create the same posts in WordPress's default `post` type and link to those." **DO NOT.** That double-creates every entry (CPT copy + wp_posts copy), pollutes the site blog, and is completely unnecessary. The plugin registers every auto CPT with `'public' => true`, `'has_archive' => true`, and a `'rewrite'` slug (confirmed in `class-sitezen-dynamic-posts.php`), so each CPT entry HAS its own real permalink and opens a real single page — it is a real WordPress post in every functional sense, just a custom *type* (which keeps it from polluting the main blog, per the strict "never use `post`" rule). The whole-card click works identically to products: the flat listing stamps `data-sz-href="<permalink>"` + `data-sz-card-clickable`, so clicking the card opens that CPT entry. **Therefore:** for a blog/news/journal/articles grid, emit ONE dynamic listing with `data-sz-post-type="sz_<section>"` (the section CPT) and STOP — do not call any second create step, do not use `data-sz-post-type="post"`, do not link cards to hand-made standard posts. "Real + clickable" is already delivered by the CPT listing alone. (If the design ALSO has an article-DETAIL page, convert that page and wire it as the CPT's single template — that styles the destination, it does NOT mean you needed standard posts.)
935
+
936
+ **§0.AS.1 — BIND-IN-PLACE rendering (how the engine keeps it pixel-perfect).** v1.97.0. For a varied/curated product or post section (cards that differ — featured, mixed layout, designed showcase), the plugin now KEEPS the design's EXACT card markup and only refreshes each card's data (the elements you mark `data-sz-post-field="title|price|image|url|excerpt|rating|categories|sku|<meta>"`) live from its item, and wires add-to-cart. It does NOT rebuild the card. So your job is unchanged but MORE important: on every card, (a) keep `data-sz-card` on the card root, and (b) put `data-sz-post-field="…"` on each dynamic element (the image `<img>`, the title node, the price node, the link `<a>` with `url`, the add-to-cart with `data-sz-wc-cart="1"`). Everything you DON'T mark stays exactly as drawn (badges, decorations, layout). Uniform feeds (identical cards, dynamic count) still use template-repeat. Either way, the wrapper keeps `data-sz-post-listing` so the editor panel (count/filter/scope/load-more) attaches — functional AND editable.
937
+
938
+ **§0.AS — THE DESIGN IS THE LAYOUT (universal principle for EVERY block).** Never impose a layout or restructure a design to fit a block. For ANY component — hero, features/cards, stats, slider, tabs, accordion, listing, team, pricing, steps, nav, footer, CTA, anything — keep the design's EXACT markup + CSS (its grid/flex, sizes, spacing, per-item differences, whatever arrangement the designer made) and ONLY attach behavior through the `data-*` / class contract. The plugin's job is to drive *behavior* (slide, filter, count, fill slots with live data, open/close), NOT to dictate *structure*. Consequences you must follow: (1) emit the design's real elements, not a canned template — a 3-up grid, an asymmetric grid, a featured-in-the-middle layout, a list, a masonry wall are all just the designer's CSS, and all work with zero plugin changes. (2) Every element stays editable because it's clean semantic HTML + inline styles (Style Studio panels apply to everything). (3) When a block needs a structural hook (slider needs a `.sz-slides` track, tabs need panels), add the hook AROUND/ON the design's existing markup — never replace the design with the plugin's own boilerplate. This is why "any design, any layout" works: we bind data + behavior to the design, we never rebuild the design. If a layout ever seems unsupported, the fix is a thinner/more-general contract, NOT a new per-layout code path.
939
+
940
+ **§0.AT — INTERACTION PRIMITIVES (common behaviors that otherwise go static — wire them, don't drop them).** v1.96.0. Keep the design's exact markup and add the attribute; the plugin runs it (no author JS):
941
+ - **Countdown timer** (sale/urgency: "Ends in 16d 21h 57m 23s") → `data-sz-countdown="<ISO datetime>"` on the wrapper; the number elements get `data-sz-countdown-unit="days|hours|minutes|seconds"` (optional `data-sz-countdown-done="Ended"`). Ticks live every second. NEVER leave a timer as static text.
942
+ - **Toggle / show-hide** (a button that reveals a panel/details) → `data-sz-toggle` on the trigger + `data-sz-toggle-target="<selector>"` (or it toggles the next sibling); the target gets `.sz-open` (style it open via `.sz-open`).
943
+ - **Copy-to-clipboard** (coupon code, link) → `data-sz-copy="<text>"` (or `data-sz-copy-target="<selector>"`) on the button; optional `data-sz-copied="Copied!"`.
944
+ - **Quantity stepper** (− / input / + on a product card) → wrap in `data-sz-stepper`, the buttons get `data-sz-step-down` / `data-sz-step-up`, with a real `<input type="number" min max step>`.
945
+ These are the catch-alls for simple interactions with no dedicated component. For anything more complex with no primitive, prefer a SiteZen component (slider/tabs/accordion/listing) or a tiny self-contained vanilla `<script>` — never silently render it static.
946
+
947
+ **§0.AU — PER-SECTION DISCIPLINE (so the right thing is always called — the publish-gate for reliability).** For EVERY section you convert, run this loop — it's what guarantees a connected Claude (with no knowledge of our internals) produces a working, editable result:
948
+ 1. **Identify** what the section IS by its structure/behaviour (a slider? a listing? a featured product showcase? a counter? a header? a timer?).
949
+ 2. **Apply the contract** — call `get_conversion_rules(section_type)` when it matches a known type, and emit that contract's markup ON the design's own elements (per §0.AS — keep the design, attach behaviour). This is what makes the plugin DETECT the component and give its **editor panel** — so it's both functional AND editable. Skipping the contract = generic divs = no panel.
950
+ 3. **Wire interactivity** — any animation → `data-sz-gsap` (§0.AN); any timer/toggle/copy/stepper → the §0.AT primitive; any list of items → a listing (curated → §0.AR). Never leave a behaviour static.
951
+ 4. **VERIFY** — after the push, call `get_rendered_preview(page_id)` and confirm in the FINAL HTML that the listing expanded, the slider/filter/animation hooks are present, cards carry the design's classes, and (for products/listings) the editor contract attributes are on the wrapper. Do NOT tell the user a section is done until the preview confirms it.
952
+ The rule of thumb: **functionality is never enough on its own — every capability you add must come through a contract that ALSO gives the editor panel** (count/filter/scope/slider/etc.), so the user can change it later without code. If a behaviour can't be edited, it's not finished.
953
+
954
+ **§0.AV — REAL-WORLD COMPONENT PRIMITIVES (v1.98.0 — for blog/business/agency/SaaS, not just shops).** Keep the design's exact markup and add the attribute; the plugin runs it (no author JS). Use these so common non-WooCommerce components stop going static:
955
+ - **Progress bar** (skill/stat fill) → `data-sz-progress="75"` on the wrapper (the fill child = `data-sz-progress-bar`, or the element itself). Animates 0→75% when scrolled in.
956
+ - **Circular progress ring** → `data-sz-progress-ring="70"` on a wrapper that contains an SVG `<circle class="sz-ring-fill" r="…">`. The ring fills to 70% on scroll-in.
957
+ - **Star rating (display)** → `data-sz-rating="4.5"` (`data-sz-rating-max="5"`). If the design draws its own stars, style them with the CSS var `--sz-rating-fill`; if the element is empty, the plugin injects a default ★ overlay.
958
+ - **Read-more / clamp** → `data-sz-readmore` on the text block (`data-sz-readmore-lines="3"`) + a `data-sz-readmore-btn` toggle.
959
+ - **Tooltip** → `data-sz-tooltip="text"` on any element (hover/focus bubble).
960
+ - **Back-to-top** → `data-sz-back-to-top` on the button (appears after scroll, smooth-scrolls up).
961
+ - **View switcher (grid/list)** → buttons `data-sz-view-switch="grid|list"` + `data-sz-view-target="<selector>"` (defaults to the section's listing); toggles `.sz-view-grid`/`.sz-view-list` (style these in the design CSS).
962
+ - **Pricing monthly/yearly toggle** → a switch `data-sz-price-toggle`; each price node carries `data-sz-price-monthly="$19"` + `data-sz-price-yearly="$190"` (and optional `data-sz-price-period-monthly`/`-yearly` for the "/mo" label). Toggling swaps them. NEVER hard-code a single price when the design shows a monthly/yearly switch.
963
+ - **Before/after image slider** → `data-sz-before-after` wrapper with the after image `class="sz-ba-after"` + a `data-sz-ba-handle`; drag reveals.
964
+ - **Multi-open accordion** → add `data-sz-accordion-multi` on the accordion container (default is single-open / one-at-a-time).
965
+ **Note — these are CSS, already work, no special attribute:** vertical tabs, pill/underline tab styles, vertical/side accordion, masonry/asymmetric grids, mega-menu columns — they're just the designer's CSS on the existing tab/accordion/grid/menu contract (per §0.AS). Don't ask for a new contract for a layout that's only a CSS difference.
966
+
967
+ **§0.AW — DETECTION-TRIGGER MATRIX (the discoverability gate — scan EVERY section against this so the right contract is always emitted, in ONE pass).** When you see the design cue on the left, emit the contract on the right. This is what makes a connected Claude call everything at the right time instead of rendering static.
968
+ | If the design shows… | Emit (attach to the design's own markup) |
969
+ |---|---|
970
+ | items with name + price + image, or "Add to cart/Shop now", rating, heart, "Quick view" | REAL products + listing (§0.AR.1): `data-sz-post-listing data-sz-post-type="product"` + `data-sz-card` + `data-sz-post-field` |
971
+ | articles/team/projects/services with title + image + link | REAL posts/CPT + listing (same, `data-sz-post-type="<kind>"`) |
972
+ | a hand-picked / featured set (Best Deals, Featured) | listing + `data-sz-post-cat="<slug>"` (curated scope, §0.AR) |
973
+ | tab buttons switching panels | `.sz-tab-btn` + `.sz-tab-panel` (vertical/pill = CSS) ; deep-link → `data-sz-tab-hash` |
974
+ | **a row of tabs/buttons above a product/post grid** (e.g. "All Product · Smart Phone · Laptop · TV") | **It's a CATEGORY FILTER, NOT decorative tabs.** Wire per §6.F: put `data-sz-post-listing` on the wrapper that contains BOTH the tab row AND the grid; mark the tab group `class="sz-post-filters" data-sz-taxonomy="product_cat"`, each tab `class="sz-filter-pill" data-sz-term="<slug>"`, the "All" tab `data-sz-term=""`. NEVER leave them as static `class="tab"` buttons. |
975
+ | Q&A / expandable rows | accordion (`.sz-accordion-item`); independent rows → `data-sz-accordion-multi` |
976
+ | a row of cards that scrolls / has arrows or dots | slider: `data-sz-slider` + real `data-sz-slider-per-view="N"` (+ arrows/dots/autoplay as shown) |
977
+ | a number that counts up ("500+ clients") | `data-sz-counter data-target="500"` |
978
+ | a horizontal skill/stat bar | `data-sz-progress="75"` |
979
+ | a circular % ring | `data-sz-progress-ring="70"` (SVG `<circle class="sz-ring-fill">`) |
980
+ | star rating | `data-sz-rating="4.5"` |
981
+ | a countdown / "ends in" | `data-sz-countdown="<ISO>"` + `data-sz-countdown-unit` |
982
+ | monthly/yearly price switch | `data-sz-price-toggle` + `data-sz-price-monthly`/`-yearly` on prices |
983
+ | nav with submenu / mega-menu / mobile | `data-sz-submenu` (+ `data-sz-submenu-layout="mega"`) + hamburger; slide-in drawer → `data-sz-offcanvas` + `data-sz-offcanvas-open` |
984
+ | header that sticks / shrinks / hides on scroll | header block `isSticky` → `data-sz-sticky` (+ effect: shrink/slide/fade/hide, optional `data-sz-logo-swap`) |
985
+ | a slider that already has its OWN arrow elements | keep them — tag `data-sz-slider-prev` / `data-sz-slider-next` so the engine reuses them (don't let it create duplicates) |
986
+ | a search box | `data-sz-search` ; cart/wishlist icon → `sz-header-cart`/`sz-header-wishlist` |
987
+ | a contact/subscribe form | leave the `<form>` (auto-detected → real form) ; wizard with steps → `data-sz-form-steps` + `data-sz-form-step`/`-next`/`-prev` |
988
+ | "back to top" | `data-sz-back-to-top` ; grid/list toggle → `data-sz-view-switch` ; info-tooltip → `data-sz-tooltip` ; "read more" → `data-sz-readmore` |
989
+ | a **"Load more" / "Show more" / "View more"** button, OR numbered pagination, under a product/post grid | set `data-sz-post-paginate="loadmore"` (or `"numbered"`) on the listing — keep the design's button (the engine wires it to load real items). WITHOUT this the button is dead AND the editor shows "No pagination". If there is NO such control, omit the attribute (the listing shows its count; the engine never auto-adds Load-more). |
990
+ | before/after photos | `data-sz-before-after` + `.sz-ba-after` + `data-sz-ba-handle` |
991
+ | a play button / video | `data-sz-action="video-popup"` ; gallery image → `data-sz-action="image-popup"` |
992
+ | any entrance motion / parallax / reveal | `data-sz-gsap="<preset>"` or custom `data-sz-gsap-from/-to` (§0.AN) |
993
+ | a dismissible promo bar (X) | `data-sz-dismissable` + `data-sz-dismiss` ; copy-code → `data-sz-copy` ; qty +/- → `data-sz-stepper` |
994
+ **Rule: scan top-to-bottom once, tag every match, then VERIFY with `get_rendered_preview` (§0.AU). A section that matches a row above and is left static is a defect.**
995
+
996
+ **§0.AW.2 — EDITOR-PARITY CHECKLIST (run per section — encode the design's ACTUAL state for every block, so the editor panel + front-end always match). This is INTENT detection on the design's OWN markup — NOT a template. Any creative/novel layout works because you attach these to whatever the designer drew; you are reading what each part DOES, not forcing a shape.**
997
+ - **Listing (product / post / CPT):** `data-sz-post-listing` on the wrapper that contains the cards (and any filter UI); `data-sz-post-type`; `data-sz-post-count` = how many cards the design shows; `data-sz-card` + `data-sz-post-field="…"` on each card's dynamic parts; `data-sz-post-filter` + wired pills/tabs ONLY if the design has a filter; `data-sz-post-paginate="loadmore|numbered"` ONLY if the design has that button; `data-sz-post-cat/include/exclude` for a curated set.
998
+ - **Slider:** `data-sz-slider`; `data-sz-slider-per-view` = the REAL number of cards visible at once; `arrows`/`dots` = `1` only if the design shows them (reuse the design's own arrows via `data-sz-slider-prev/next`); `autoplay`/`loop`/`center`/`delay` = how the design actually behaves.
999
+ - **Tabs / Accordion:** the contract classes on the design's own elements; the default-open item keeps `.open`; independent (multi-open) accordion → `data-sz-accordion-multi`.
1000
+ - **Header:** `isSticky`/`data-sz-sticky` if it sticks; hamburger position; `data-sz-search` on the search; cart/wishlist classes; `data-sz-submenu`(+`mega`) for dropdowns.
1001
+ - **Nav submenu items + per-item styling (v2.2.13):** `data-sz-submenu` on a top nav link is a **base64-encoded JSON array** of items — each `{ "label", "url", "children": [ …same shape… ] }` (one level of nesting = sub-submenu). The plugin renders it into the real `.sz-has-submenu`/`.sz-submenu` markup and gives the customer an editor panel to add/remove items + set per-item colour/size. **Carry the source's real values when they exist:** if a CODE import's header actually contains dropdown items (real `<li><a>` under a nav item, possibly with their own inline `color`/`font-size`), emit those as the JSON items INCLUDING per-item **`"color": "#hex"`** and **`"size": <px number>`** when the source specifies them (the plugin applies them inline `!important` so each sub / sub-sub item can differ). When the design does NOT expose an expanded dropdown (the usual case — submenus are hover-revealed, so there's nothing to read), emit ONE placeholder item so the control appears, and let the customer fill labels/colours/sizes in the editor after conversion. RULE: design/code has the value → put it in the JSON so it auto-applies; it doesn't → placeholder + editor. NEVER invent submenu colours/sizes that aren't in the source.
1002
+ - **Menu / dropdown BACKGROUND — solid OR gradient (v2.2.14):** the dropdown submenu panel reads `--sz-submenu-bg` and the mobile drawer reads `--sz-mobile-nav-bg` (both accept a hex OR a full `linear-gradient(...)`); text colour is `--sz-submenu-color` / `--sz-mobile-nav-color`. When the design/code gives the menu or its dropdown a real background — a solid colour OR a gradient — set the matching variable on the nav/header element so it auto-applies (e.g. `style="--sz-submenu-bg:linear-gradient(135deg,#398bca,#4abded); --sz-submenu-color:#fff"`). Use the EXACT colour(s)/angle from `colors_used` / `gradients[]`. If the source doesn't specify one, leave it unset (the plugin defaults to a clean white panel) — the customer picks Solid/Gradient in the Navigation editor panel. Same "auto if present, else editor" model as everything else.
1003
+ - **Stats / interactions:** counter/progress/ring/rating/countdown/price-toggle/etc. — the primitive attribute carrying the design's value (count target, %, stars, deadline, monthly/yearly prices).
1004
+ - **Motion:** `data-sz-gsap="<preset>"` (or custom) matching the entrance/scroll effect the design implies.
1005
+ **THE RULE: set an attribute when the design HAS that feature; OMIT it when it doesn't (never default a value). The editor reads these to show its state; the engine no longer auto-guesses. A mismatch = the editor shows the wrong state and the site won't match the design.**
1006
+ **NEVER reject or templatize a creative/unfamiliar layout.** If a section doesn't match a known contract, detect its INTENT (what each element does) and encode that on the design's markup; if truly nothing fits, keep it pixel-perfect (static) and say so — do NOT bend the design into a template.
1007
+
1008
+ **§0.AW.1 — THE EDITOR MUST MIRROR THE DESIGN (set every editor-controlled attribute to match what the design shows).** The block editor panels READ these attributes to show their state, and the front-end engine no longer auto-guesses anything. So whatever a control's presence/absence is in the design, emit the matching attribute — exactly: design HAS a Load-more button → `data-sz-post-paginate="loadmore"`; design has NO pagination → omit it (do NOT default to a value). Design shows filter pills/tabs → wire the filter (§6.F); none → omit. Design shows slider arrows/dots → `data-sz-slider-arrows/dots="1"`; hidden → `"0"`. Design shows N cards → `data-sz-post-count="N"`. If you emit an attribute the design doesn't have (or omit one it does), the editor will display the WRONG state and the front-end won't match the design — the exact "I picked X but the site shows Y" bug. One source of truth: the design decides, you encode it, the editor reflects it.
1009
+
1010
+ **§0.AX — REMAINING CONTRACTS (off-canvas / multi-step form / deep-link tabs — v1.99.0, all attach to the design's own markup, never a template).**
1011
+ - **Off-canvas drawer**: the design's menu/panel = `data-sz-offcanvas` (+ `data-sz-offcanvas-side="left|right"`); the hamburger/trigger = `data-sz-offcanvas-open` (value = a selector or empty for the first drawer); close buttons = `data-sz-offcanvas-close`. Plugin toggles `.sz-oc-open` (style the slide in your CSS) + a backdrop. The menu markup stays exactly as designed.
1012
+ - **Multi-step form**: wrap the form in `data-sz-form-steps`; each step = `data-sz-form-step`; nav buttons = `data-sz-form-next` / `data-sz-form-prev`; optional `data-sz-form-progress`. The last step shows the real submit (the form itself is still auto-detected → real submission/email). Required fields are validated before advancing.
1013
+ - **Deep-link tabs**: add `data-sz-tab-hash="pricing"` to the tab button — opens from `#pricing` on load + updates the hash on click (browser back works). No new tab markup; it rides the existing tab contract.
1014
+
843
1015
  ---
844
1016
 
845
1017
  ## §1 Hero — Status: ✅ Approved
@@ -1046,9 +1218,48 @@ Hero rules are locked in `src/lib/claude.ts` SYSTEM_PROMPT (PER-BLOCK RULES →
1046
1218
  ## §6 Dynamic Post Listing (News / Blog / Services / Team / Projects / etc.) — Status: ⏳ Not started
1047
1219
 
1048
1220
  ### Rules
1049
- *(empty)*
1221
+
1222
+ **§6.F — FILTERS (category / tag / attribute / price, any layout, sub-categories).** v1.88.0. When a design (HTML or Figma) has a filter UI attached to a product/post listing — pills/tabs on top, a left-side panel, dropdowns, checkboxes/radios, a search, a price range, color/size swatches, or a category tree with +/arrow toggles — do BOTH of these:
1223
+
1224
+ > **⚠️ #1 MISS — category tabs rendered as static buttons.** A row of tabs/buttons above a product/post grid whose labels are category/type names ("All Product · Smart Phone · Laptop · TV", "All · Men · Women") **IS the category filter** — wire it, do NOT emit `<button class="tab">` static buttons. TWO things are mandatory or the filter silently does nothing: (a) the `data-sz-post-listing` wrapper must ENCLOSE both the tab row AND the grid (the engine's `collectFilters` only scans INSIDE the listing — tabs left outside the wrapper are invisible to it); (b) each tab needs the pill contract below. The "All"/"All Product" tab = `data-sz-term=""` (reset). This single miss is why a converted filter "doesn't work" while everything else does.
1225
+
1226
+
1227
+ 1. **PRESERVE the design's filter markup as-is** (keep its exact layout/style — never replace it with a generic strip) and ADD wiring attributes so the engine drives it. The filter markup MUST live INSIDE the `data-sz-post-listing` wrapper. Attribute contract (frontend.js `collectFilters`):
1228
+ - **Pill/button/link group:** wrap the group in `class="sz-post-filters" data-sz-taxonomy="category|post_tag|product_cat|product_tag"`; each option is `class="sz-filter-pill" data-sz-term="<term-slug>"`; the "All" option has `data-sz-term=""`. Single-select by default; add `data-sz-multi="1"` on the group for multi-select.
1229
+ - **Checkbox/radio:** `<input type="checkbox" data-sz-filter-tax="category" value="<term-slug>">`.
1230
+ - **Dropdown:** `<select data-sz-filter-tax="category"><option value="<slug>">…</select>`.
1231
+ - **Product attribute** (color/size/…): same controls but `data-sz-attr="color"` + `data-sz-term="<value-slug>"` (or `value=` on input/option).
1232
+ - **Price range:** two inputs `data-sz-price-min` / `data-sz-price-max` (number or range).
1233
+ - **Sub-category tree:** each parent is `[data-sz-filter-item]`; the +/arrow is `[data-sz-filter-toggle]`; the child list is `[data-sz-filter-children]` (hidden until toggled, shown via `.sz-open`). Clicking the parent NAME filters (children auto-included server-side); clicking the toggle only expands. If the layout is a flat dropdown/pill bar with NO room for a tree (e.g. the "Location / Price / Star Rating" dropdown bar), DON'T emit sub-categories — just the flat terms.
1234
+ - Multiple taxonomies AND together (category AND tag AND color); multiple terms in one taxonomy OR together. Works on grids AND sliders identically.
1235
+
1236
+ 2. **CREATE the filter DATA** by calling `create_filter_data` AFTER the products/posts exist. Pass the post_type, the taxonomies (with `children` for any +/arrow item), and attributes — using the REAL names from the design, placeholders only where the design doesn't show them. It round-robin assigns existing items so no option is empty. Then SHOW the returned structure to the user and tell them: rename terms / reassign items in WP Admin, or give you the correct mapping to redo. Term slugs you put in `data-sz-term` must match what `create_filter_data` creates (use the WP slug = sanitized name). Don't fabricate realistic-but-wrong per-item data silently — round-robin is the obvious-placeholder choice.
1050
1237
 
1051
1238
  ### Required plugin features
1239
+ **§6.J — JS-RENDERED listings (THE #1 reason a filter demo "doesn't convert").** v1.89.0. Many premium designs ship the grid containers EMPTY (`<div id="grid-target"></div>`) and render cards at runtime from a JS template literal + a JS data array (`renderProducts(arr)` mapping over `PRODUCTS_DATA`). The static DOM has NO cards, so naive conversion finds nothing. YOU MUST reconstruct:
1240
+ 1. Find the render function's template string (the ``` `<div class="card">…` ``` inside `.map(...)`) — that IS the card template. Convert it to a `data-sz-card` template with `{{title}}`/`{{image}}`/`{{price}}`/field markers, exactly as for a static card.
1241
+ 2. Find the data array(s) (`COMMERCE_PRODUCTS_DATA`, `MASONRY_POSTS_DATA`, …) — those are the ITEMS. Create them: products via `create_products`, posts/CPT via the post-listing render (the engine auto-creates), reading name/price/image/category/attributes from each array object's fields.
1242
+ 3. Wrap the (empty) container as the `data-sz-post-listing` with the reconstructed card template; the engine then renders the REAL items in the design's card. Map the JS data fields used for filtering (e.g. `subcategory`, `color`, `grade`, `location`) to taxonomies/attributes and pass them to `create_filter_data`.
1243
+ 4. IGNORE the demo JS (the inline `onclick`/`render*`/array code) in the output — it's the source of truth for STRUCTURE + DATA only; the engine replaces all of it. Never leave the empty container or claim "nothing to convert".
1244
+ 5. **CREATE EVERY ITEM in the data array, and set the count.** A CPT/post listing shows as many items as you create — if you emit ONE `data-sz-card` it creates ONE post and the listing shows 1. So for a JS data array of N items, create ALL N (one card per item, OR call create_products with all N), and set `data-sz-post-count="N"` (or higher) on the listing so none are hidden behind Load-more. For the masonry example with 3 dispatches, create 3 dispatch posts (with their real categories) and set count=3. Use each item's REAL fields (title/image/price/category/attribute) from the array — never one template card stretched to a placeholder.
1245
+
1246
+ **§6.N — STRICT: never the default `post` type — always a SECTION-NAMED CPT.** v1.91.2. Every non-product listing section becomes its OWN custom post type named after the section, NEVER WordPress's built-in `post`. Derive the CPT slug from the section heading: "Our Journal" → `sz_journal`, "Latest News" → `sz_news`, "The Masonry Stream" → `sz_dispatch`, "Specifications" → `sz_spec`. Set `data-sz-post-type="sz_<section>"` on the listing. The plugin auto-registers the CPT (public, has-archive) and attaches the standard **Category + Tag** taxonomies, so category/tag filters and `create_filter_data` work identically — without dumping unrelated sections into the site's blog. Add extra/custom taxonomies only when the design needs more than category/tag. The ONLY built-in types used are `product` (real WooCommerce shop items). Reason: a real site builder gets clean, separate, section-named content types they can manage individually, instead of a mixed "Posts" bucket.
1247
+
1248
+ **§6.K — Filter CONTROL TYPES & global bar (any layout).** v1.89.0. Beyond §6.F pills/checkboxes/dropdowns, wire these:
1249
+ - **Global filter bar** (one search/sort/reset row driving SEVERAL listings): wrap it `data-sz-filter-bar` with optional `data-sz-targets="<css-selector of the listings>"` (omit = every listing on the page). Put the search `input.sz-post-search-input`, the sort control, the price inputs, and the reset button INSIDE it.
1250
+ - **Sort**: any `<select data-sz-sort>` (or buttons with `data-sz-sort`) whose option values are `newest|price-asc|price-desc|popularity|rating|name`.
1251
+ - **Reset ("Clear/Reset All")**: `data-sz-filter-reset` on the button — clears every control in its listing (or, inside a global bar, all targeted listings).
1252
+ - **Colour swatches / clickable tiles** (NON-input filter controls): put `data-sz-attr="color"` (or `data-sz-filter-tax`) + `data-sz-term="<value-slug>"` on the swatch `<div>`/`<button>`; the engine toggles `.is-active` on click. Add `data-sz-multi="1"` for multi-select.
1253
+ - **Dropdown-PANEL filter** (a button that opens a floating panel of checkboxes): wrap each in `data-sz-filter-node`; the button gets `data-sz-filter-panel-trigger`; the floating panel gets `data-sz-filter-panel` (engine adds `.sz-open`, and closes on outside-click). The checkboxes inside use the normal `data-sz-filter-tax`/`data-sz-attr` contract.
1254
+ - **Masonry / asymmetric grid** (cards of varying size): set `data-sz-card-sizes="size-tall,size-wide,normal,…"` on the listing (the cycle of size classes from the design) AND keep those classes defined in CSS — the engine cycles them onto each rendered card so the dynamic grid keeps the varied layout.
1255
+ - All of these compose with §6.F combined filtering and work on grids AND sliders.
1256
+
1257
+ **§6.L — ARBITRARY card fields (any content type renders fully).** v1.90.0. A card is no longer limited to title/image/excerpt/price. For ANY extra column the design shows — spec deck (id/zone/status), real-estate (beds/baths/sqft), events (date/venue), team (role) — mark the element `data-sz-post-field="<key>"` with a lowercase meta key, and put a `{{<key>}}` token where it should render in the card. The engine saves each as post meta at creation and resolves the `{{<key>}}` token from meta at render. Built-in fields (title/excerpt/image/url/price/sale_price/sku/stock_status) keep their existing behavior — this is purely additive, so use real keys for the design's extra data instead of dropping it.
1258
+
1259
+ **§6.N — EVERY filterable list is a dynamic listing (incl. row/deck/switch lists).** v1.90.1. If a section has ANY filter/switch/tab UI over a set of similar items — even a plain ROW LIST with segmented "Show all / X" switches (no cards/images) — it MUST be a `data-sz-post-listing` (a CPT like `sz_spec`), with the switch buttons wired per §6.F (`.sz-post-filters`/`data-sz-filter-tax` + `data-sz-term`), NOT left as static HTML. A static list's filter does nothing. The row markup is the card template (use `data-sz-post-field`/`{{custom}}` per §6.L for id/zone/status columns). NOTE: the plugin now AUTO-CREATES each term named by the design's filter controls and assigns at least one item to it at bake — so as long as the controls carry real `data-sz-term`/`value` slugs and the items are this listing's, filters work even without a separate create_filter_data call (still call it when you have the real per-item mapping). Term slugs in the controls must be WP slugs (sanitized, lowercase-hyphen).
1260
+
1261
+ **§6.M — VERIFY before reporting done (self-check).** v1.90.0. After pushing a page that has listings/filters, call `get_rendered_preview(page_id)` and confirm in the FINAL HTML: (1) the listing expanded into real cards (not an empty wrapper or a bare `<!--sz-post-listing-->` comment), (2) any in-listing filter UI (sidebar/pills/swatches/dropdowns) survived, (3) cards carry the design's own classes. `get_page_html` shows only stored markup; `get_rendered_preview` shows what the visitor sees. Don't tell the user a filtered/listing page is done until the rendered preview confirms it.
1262
+
1052
1263
  - §4 Dynamic Post Pack — `data-sz-post-listing` + `data-sz-post-type` + `data-sz-card` + `data-sz-post-field`
1053
1264
  - §4 CPT auto-registration via `ensure_cpt` (public, has_archive, rewrite slug)
1054
1265
  - §4 Template tokens: `{{title}}`, `{{image}}`, `{{url}}`, `{{date}}`, `{{author}}`, `{{excerpt}}`, `{{categories}}`, `{{tags}}`
package/dist/errors.js CHANGED
@@ -21,8 +21,9 @@ export const Errors = {
21
21
  noPageName: () => fail("NO_PAGE_NAME", "What should I name this page in WordPress?", "Tell me the page title."),
22
22
  noSiteUrl: () => fail("NO_SITE_URL", "I don't know which WordPress site to push to. Please tell me the site URL (e.g. https://example.com).", "Share the site URL."),
23
23
  noConnectionKey: (siteUrl) => fail("NO_CONNECTION_KEY", `I don't have a saved SiteZen connection key for ${siteUrl}. Please share the connection key — you can find it in WordPress under SiteZen → Connection.`, "Share the connection key for that site."),
24
- noLicenseKey: () => fail("NO_LICENSE_KEY", "No SiteZen license key is set. Visit https://sitezen.io/pricing to grab one (the free plan is enough to start), then paste it into your claude_desktop_config.json as SITEZEN_LICENSE_KEY and restart Claude Desktop.", "Set SITEZEN_LICENSE_KEY in your Claude Desktop config, then restart."),
25
- invalidLicense: () => fail("INVALID_LICENSE", "Your SiteZen license key isn't recognised. Check it for typos, or generate a fresh one at https://sitezen.io/dashboard.", "Update SITEZEN_LICENSE_KEY in the config and restart Claude Desktop."),
24
+ noLicenseKey: () => fail("NO_LICENSE_KEY", "No SiteZen license key is set. Reach out for a beta key, then paste it into your claude_desktop_config.json as SITEZEN_LICENSE_KEY and restart Claude Desktop.", "Set SITEZEN_LICENSE_KEY in your Claude Desktop config, then restart."),
25
+ invalidLicense: () => fail("INVALID_LICENSE", "Your SiteZen license key isn't recognised. Check it for typos, or reach out to hello@sitezenai.com for a fresh beta key.", "Update SITEZEN_LICENSE_KEY in the config and restart Claude Desktop."),
26
+ betaExpired: () => fail("BETA_EXPIRED", "Your beta access is over. If you'd like to keep using SiteZen, you can upgrade to a paid plan by reaching out to us at hello@sitezenai.com.", "Tell the user their beta access has ended and to contact hello@sitezenai.com to upgrade. Do not attempt the conversion."),
26
27
  limitReachedConversions: (plan, upgradeUrl) => fail("LIMIT_REACHED_CONVERSIONS", `You've used all the conversions in your ${plan} plan. Upgrade at ${upgradeUrl} — your existing converted pages stay live.`, "Upgrade the plan to keep converting."),
27
28
  limitReachedSites: (plan, sitesAllowed, upgradeUrl) => fail("LIMIT_REACHED_SITES", `Your ${plan} plan allows ${sitesAllowed} site(s) and you're already at the limit. Disconnect a site you no longer use (disconnect_site), or upgrade at ${upgradeUrl}.`, "Disconnect an existing site or upgrade the plan."),
28
29
  noFigmaToken: () => fail("NO_FIGMA_TOKEN", "No Figma access token is set. Generate one at https://www.figma.com/developers/api#access-tokens, then paste it into your claude_desktop_config.json as FIGMA_TOKEN and restart Claude Desktop.", "Set FIGMA_TOKEN in your Claude Desktop config, then restart."),