sitezen-mcp 1.0.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,1361 @@
1
+ # SiteZen — Conversion Engine Rules
2
+
3
+ > **What this is:** The single source of truth for what HTML the conversion engine should output for each block type. When a block is finalized here (status `✅ Approved`), its rules get ported into `src/lib/claude.ts` SYSTEM_PROMPT.
4
+ >
5
+ > **Structure:** ONE section per block type. All rules for that block live in its section, period. If we discuss a slider rule while testing accordion, the rule goes in the SLIDER section — never inserted chronologically.
6
+ >
7
+ > **Plugin contract reference:** See `PLUGIN_FEATURE_MAP.md` for what the plugin already supports. Every rule in this doc must map to a feature there.
8
+ >
9
+ > **No-bloat rule:** If a rule belongs to a specific block, it goes in that block's section. If it applies to every block, it goes in §0 Universal Rules. Nowhere else.
10
+ >
11
+ > **Status legend:**
12
+ > - ⏳ **Not started** — block discussion hasn't begun
13
+ > - 🟡 **In progress** — rules being defined; not yet ported to SYSTEM_PROMPT
14
+ > - ✅ **Approved & shipped** — rules in SYSTEM_PROMPT, tested live, marked stable
15
+ >
16
+ > **Plugin version required:** v1.27.13 or newer.
17
+ > **Test site:** https://absorbingchairs.s6-tastewp.com
18
+
19
+ ---
20
+
21
+ ## §0 Universal rules (apply to every block)
22
+
23
+ > These live in the SYSTEM_PROMPT skeleton already in `src/lib/claude.ts`. Don't duplicate here — only add new universal rules as they emerge from real testing.
24
+
25
+ - Section root: `<section id="sz-X" class="sz-fullwidth">` with all CSS scoped under `#sz-X`
26
+ - Layout from screenshot, values from Figma data
27
+ - Never invent text, colors, font sizes, card counts
28
+ - Never render text/buttons/interactives as images
29
+ - `clamp(MIN, Xvw, FIGMA_PX)` for fonts >18px and padding >32px
30
+ - Output: HTML only, no markdown fences, no preamble
31
+
32
+ ### §0.2 Strict responsive — desktop AND mobile, every conversion
33
+
34
+ Every section MUST work on desktop AND mobile. Non-negotiable.
35
+
36
+ - Use `clamp(MIN, Xvw, FIGMA_PX)` for any font-size > 18px and any padding/margin > 32px.
37
+ - Always include `@media (max-width: 1024px)` rules for tablet adjustments.
38
+ - Always include `@media (max-width: 768px)` rules for mobile — at minimum: stack any flex/grid that's horizontal on desktop, shrink fonts to readable sizes, reduce padding, simplify or hide complex decorations.
39
+ - Test mental model: if the section width is X (Figma frame width), the conversion must read well at viewport widths 1440 (desktop), 1024 (tablet), 768 (small tablet), and 375 (mobile). Picture each and confirm before emitting.
40
+ - For decorations that overlap text: provide explicit mobile positioning (left/right shifts, smaller width via clamp) so they don't drift off-canvas or cover readable text.
41
+ - Buttons / CTAs: stack vertically on mobile when horizontal in design AND when narrow viewport would crowd them.
42
+
43
+ ### §0.1 Smart auto-detection + selective confirmation (platform-side flow)
44
+
45
+ The conversion engine is NOT a dumb template-matcher. Two AIs are in the loop (the platform's pre-classifier + the conversion-time Opus call); use both:
46
+
47
+ **Per-section flow:**
48
+
49
+ 1. **Pre-classify (cheap, ~$0.01).** Before the expensive Opus conversion call, the platform runs a cheap pre-analysis on the section's screenshot + Figma structural data. Output for each visible component:
50
+ - `CONFIRMED` — high confidence about what kind of block it is (single best interpretation)
51
+ - `AMBIGUOUS` — 2+ plausible interpretations that change the markup substantially
52
+
53
+ 2. **Conditional confirmation modal.**
54
+ - If ALL items are `CONFIRMED` → skip the modal, go straight to conversion.
55
+ - If ANY items are `AMBIGUOUS` → show a modal that ONLY lists the ambiguous items, with the 2-3 candidate block types the pre-classifier considered. User picks one per item.
56
+ - Modal does NOT list every possible block — only the relevant candidates for THIS section.
57
+
58
+ 3. **Conversion call** receives the locked-in block types. Opus does NOT re-decide. The conversion's only job is to produce great HTML for the confirmed types.
59
+
60
+ **Examples of ambiguous cases that should trigger the modal:**
61
+
62
+ - 3 identical cards in a horizontal row with no carousel dots/arrows visible:
63
+ ( ) Static feature cards · ( ) Multi-card carousel · ( ) Dynamic post listing
64
+ - 3 stacked rows with heading + description on each:
65
+ ( ) Static feature list · ( ) Accordion (collapsed by default in design)
66
+ - A row of 4 cards with dots underneath:
67
+ ( ) Multi-card carousel · ( ) Dynamic post listing showing 4 of N · ( ) Static cards with decorative dots
68
+ - A horizontal strip of logos:
69
+ ( ) Static logo grid · ( ) Logo marquee (auto-scrolling)
70
+ - A heading + button at bottom-right of viewport:
71
+ ( ) Inline CTA at bottom of a section · ( ) Sticky CTA floating button (page-level)
72
+
73
+ **Examples that should NOT trigger the modal (pre-classifier is confident):**
74
+
75
+ - `<header>` / `<nav>` semantic with logo + menu items = header (clear)
76
+ - `<footer>` at bottom of page = footer (clear)
77
+ - A real `<form>` with input fields = form (clear)
78
+ - A YouTube video placeholder rectangle = video iframe (clear)
79
+ - 6 FAQ-style "Q:/A:" or chevron-marked items with hide/show controls = accordion (clear)
80
+
81
+ **Rule for the pre-classifier model when in doubt:** flag it as AMBIGUOUS. Asking the user once costs nothing; retrying after a wrong guess costs ~$0.30.
82
+
83
+ **Rule for the conversion-time Opus call:** the confirmed block types are AUTHORITATIVE. If the system message says "this section contains 1 carousel and 1 hero", do not invent an accordion or skip the carousel. Trust the upstream decision.
84
+
85
+ **Implementation note (for platform side, NOT this doc):** pre-classifier runs as a separate `/api/classify` call before the main `/api/convert`. UI handles the modal between them. The modal is OPTIONAL and only renders when the response has `ambiguous: []` non-empty.
86
+
87
+ ### §0.3 General rules distilled from real conversions (apply to EVERY block)
88
+
89
+ These are lessons paid for in real push iterations. Each rule replaces "guess + retry" with "look + emit correctly on the first try". They apply to every block — hero, slider, accordion, footer, anything.
90
+
91
+ **A. Look at the Figma frame as a RENDERED IMAGE before writing any HTML — node JSON is for VALUES, the rendered image is for LAYOUT.**
92
+
93
+ This is the single most-violated rule and the one that costs the most retries. The conversion engine MUST do this BEFORE emitting:
94
+
95
+ 1. **Render the section** as a PNG via `GET /v1/images/{file}?ids={section_id}&format=png&scale=1`. Download it. Actually *look* at it through the model's vision input — not just acknowledge it exists.
96
+
97
+ 2. **Read the visual composition** from the rendered image:
98
+ - Background color of the visible section (not the root frame's hidden fill — what the eye sees)
99
+ - Left-aligned vs centered vs right-aligned content
100
+ - Element stacking order and overlap relationships
101
+ - Which decorations sit beside which text
102
+ - Shape of components (pill vs rounded-rect vs square)
103
+ - Color of buttons and accents (sample from the actual rendered pixels)
104
+ - What's a real icon vs what's a text logo
105
+
106
+ 3. **Use the node JSON only for VALUES the image can't precisely show:**
107
+ - Exact text content (`characters` field) — never re-type from sight
108
+ - Exact font family / weight / size
109
+ - Exact hex color (sampled from `fills[].color`)
110
+ - Vector `fillGeometry[].path` for inline SVGs
111
+
112
+ **Anti-pattern that kept happening this session:** read JSON → infer layout from (x, y, w, h) numbers → emit HTML → look at user's screenshot of the broken result → patch → repeat. That's "iterate from screenshots of MY output", not "look at the design once and build it right". Eight commits of patching could have been one good first emit.
113
+
114
+ **The check before every conversion:** "Did I look at the rendered Figma image of THIS section in this turn?" If no, fetch and look, then code. If yes, proceed.
115
+
116
+ Categories of things the image makes obvious but the JSON regularly hides:
117
+ - A root-frame `fills` color can be hidden behind a foreground illustration whose own sky/bg is a different color — the *visible* bg is the foreground's, not the root's. JSON gives the wrong color; one glance at the render gives the right one.
118
+ - A generically named vector node ("Vector", "Group 12") is often a specific lockup or icon (a brand mark, a third-party logo, a domain-specific glyph). The image shows what it is; JSON doesn't.
119
+ - Button / input / pill shapes (circular vs square, pill vs rect, color) live in the rendered pixels — JSON gives fills and a bbox, but the shape itself is inferred from imagination if you don't look.
120
+ - A logo can be a real icon (circle, custom mark) rather than the literal text from a text node nearby.
121
+ - Composition (left vs centered vs right alignment, upper-third vs full-bleed) reads in a glance from the image. Reconstructing it from (x, y, w, h) numbers across many nodes is slow and error-prone.
122
+
123
+ **B. Walk the Figma tree DEEPLY before emitting. Decorations are buried.**
124
+ Designers nest decorative elements 3–5 levels deep inside auto-layout frames named `Group`, `Frame`, `accent`, `Underline_NN`, `decor`, or anonymous `Vector`. A shallow walk misses them and the conversion ships without underlines, leaves, badges, or swooshes. Before emitting any block:
125
+ - Recursively visit every child node (not just direct children of the section frame)
126
+ - Catalogue every TEXT, VECTOR, RECTANGLE-with-image-fill, and IMAGE node found
127
+ - For each visible element in the screenshot, confirm you have a Figma node for it
128
+ - If something is in the screenshot but no Figma node maps to it → walk deeper, don't fabricate
129
+
130
+ **C. Vector decorations — fetch the path, prefer inline SVG.**
131
+ For any node with `type: "VECTOR"`:
132
+ 1. Fetch via `/v1/files/{key}/nodes?ids={id}&geometry=paths` → returns `fillGeometry[].path` and `fills`.
133
+ 2. Simple (single path, solid fill) → emit **inline SVG** with `preserveAspectRatio="none"`, sized by `width:100%` + `height: clamp(...)`. Exact Figma fidelity, no PNG cropping bugs.
134
+ 3. Complex (multi-path, gradient, filter) → fall back to PNG `<img>` via `/v1/images`.
135
+ 4. NEVER recreate vector shapes with CSS pseudo-elements / `text-decoration` / `border` tricks. They will not match.
136
+
137
+ **D. Overlapping elements position relative to their smallest common ancestor.**
138
+ When decoration A visually overlaps text B in Figma, the positioning context is the smallest DOM ancestor that contains both — NOT the section root. Make that ancestor `position: relative`; place A `position: absolute` with `left/top` from the ancestor's top-left, using percentages so it scales. Positioning from section root drifts on resize. Putting the decoration in flex/inline-flex with the text pushes the text out of place.
139
+
140
+ **E. Stacked-zone layouts ≠ overlay layouts.**
141
+ If Figma shows discrete vertical zones (text band → image band → CTA band, stacked top to bottom) → use **flex column**, each zone is its own DOM block. If Figma shows a full-bleed background image with text floating on top of it → use **overlay** (`position:absolute` text on a bg image). Using overlay for a stacked design causes the "image eats the buttons" bug. Using stacked for an overlay design causes "text band sits awkwardly above the photo".
142
+
143
+ **F. Inside a flex column, NEVER size a child with `aspect-ratio`.**
144
+ Flex parents don't reliably honor `aspect-ratio` — the child can collapse to 0 height. Use explicit `height: clamp(MIN, Xvw, FIGMA_PX)` computed from the Figma proportions (e.g. a 1440×426 photo zone → `height: clamp(200px, 29.6vw, 426px)` because 426/1440 = 29.6%).
145
+
146
+ **G. Never render the whole section frame as one image.**
147
+ The Figma section is editable content — text the user will change, buttons they'll relink, images they'll swap. Rendering the section as one PNG kills all editing. Background decoration → render only the decoration sub-frame (no text inside) as an `sz-bg-only` image. Foreground content → real HTML.
148
+
149
+ **H. Pre-output validation checklist (mandatory before emitting any block).**
150
+ Before producing HTML for any block, mentally tick every item. Any unchecked item means "walk Figma deeper or read the spec again", not "ship and see".
151
+ - [ ] Every Figma TEXT node appears in the HTML, verbatim
152
+ - [ ] Every Figma VECTOR / image-fill RECTANGLE is either rendered (inline SVG or `<img>`) or explicitly confirmed as section background
153
+ - [ ] Every visible element in the screenshot maps to a real Figma node (no fabrications)
154
+ - [ ] Overlapping elements have positioning calculated from their smallest common ancestor, not the section root
155
+ - [ ] Section is built from real semantic HTML — never the whole frame as an image
156
+ - [ ] `clamp()` is used for every font-size > 18px and every padding/margin > 32px
157
+ - [ ] `@media (max-width: 1024px)` and `@media (max-width: 768px)` rules are present
158
+ - [ ] If the block contains interactives (slider, tabs, accordion, form, sticky CTA, marquee, counter, dropdown), the plugin's data-attribute contract from PLUGIN_FEATURE_MAP is honored exactly
159
+
160
+ **I. Trust the upstream classifier (from §0.1) — the block type is locked-in.**
161
+ The conversion call is NOT allowed to re-decide what kind of block this is. If the system message says "this is a hero containing a 3-slide slider", do not emit a hero without the slider, and do not invent additional accordions. The classifier has already resolved ambiguity with the user; second-guessing it costs a retry.
162
+
163
+ **J. Pixel-perfect responsiveness, not "fluid enough".**
164
+ Every section must read well at viewport widths 1440, 1280, 1024, 768, and 375 — picture each in turn. Decorations that overlap text on desktop need explicit mobile rules (smaller width, repositioned, or hidden). Horizontal flex/grid layouts that fit 4-across on desktop need explicit stacking on mobile. "It probably reflows fine" = it doesn't.
165
+
166
+ **K. Nav-inside-hero designs MUST be split into two sections.**
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
+ Rule:
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.
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`.
174
+
175
+ **L. NEVER use a px-based minimum on full-bleed background widths — pure vw only.**
176
+ 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.
177
+ Rule for any over-bleeding background image / illustration / decoration:
178
+ - Use a PURE viewport-width expression: `width: 216.7vw; max-width: none;` (no px floor).
179
+ - Pin the parent section with `max-width: 100vw; overflow: hidden`.
180
+ - Sanity check at viewport widths 1440 / 1024 / 768 / 375 — at every one, body must NOT show a horizontal scrollbar.
181
+ - Same rule for any element that intentionally overflows its container — never give it a px-based minimum that exceeds the viewport.
182
+
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.
198
+
199
+ **N. After-push verification — read the plugin's response detection block.**
200
+ 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.
201
+
202
+ **O. NEVER `object-fit: cover` an illustration onto a section taller than the illustration's natural aspect.**
203
+ `object-fit: cover` on an oversized container CROPS heavily and stretches/distorts the image visually — the cover algorithm scales the artwork to fill the smaller dimension, so if your section is 1100px tall and the artwork is 1757px tall (or vice versa with different widths), the result looks textured/distorted/wrong.
204
+ Rule for illustrations / hero photos:
205
+ - Default: `width: 100%; height: auto;` — preserves natural aspect, never distorts.
206
+ - For an illustration meant to sit as a horizon band at the bottom of a hero: `position:absolute; left:0; right:0; bottom:0; width:100%; height:auto;` — let the section's `min-height` define the empty space above and the illustration occupies its natural footprint.
207
+ - Use `object-fit: cover` ONLY for true cropping scenarios (photo backgrounds with text overlay) where you genuinely want the image to fill an arbitrary box and crop edges.
208
+
209
+ **P. WordPress theme styles for `form input` override yours — defend the input AND its container.**
210
+ Almost every WP theme has rules like `form input { width: 100% }` and `form input[type="email"] { padding: 12px; }` that win on specificity over a `#sz-hero .x` selector inside our `<style>` block. Result: our email signup that was supposed to be 520px wide stretches to fill the whole viewport, button drops below, layout breaks.
211
+ Rule for any form inputs inside a converted section:
212
+ - Put the input inside a flex row wrapper with its own `max-width`.
213
+ - On the row wrapper: `display:flex; width:100%; max-width:520px; box-sizing:border-box`.
214
+ - On the input: `flex:1 1 auto; min-width:0; width:100%; max-width:100%; box-sizing:border-box` — `min-width:0` is the critical part; without it, the input refuses to shrink below its intrinsic content width.
215
+ - On the button: `flex:0 0 auto; width:40px;` so it never gets crushed by a too-greedy input.
216
+ - Add `#sz-X .sz-X-inner * { box-sizing: border-box; }` so theme padding/borders don't inflate your max-width math.
217
+ - Same defense pattern for any element where a WP theme might inject `width:100%`: gallery `<img>`, `<button>`, `<select>`, `<textarea>`, etc.
218
+
219
+ **Q1. Never let any descendant compute a width > 100% of the section, even with `overflow:hidden`.**
220
+ The spec says `overflow:hidden` on the section creates a scroll container that contains descendants. In practice, on real WordPress sites with themes that wrap sections in their own containers and use `aspect-ratio` on the parent, the bleed can still leak to the body scrollbar. Repeatedly burned by this — any time an illustration uses `width:auto; height:100%` over a section narrower than the artwork's natural width, the rendered width computes to >100% of the section, and despite `overflow:hidden + isolation:isolate + contain:paint`, the body picks up a horizontal scrollbar.
221
+
222
+ Reliable rule: never give a descendant a width > 100% in the first place. For composite background illustrations whose source Figma group is wider than the section:
223
+ - Render at `width: 100%; height: auto;` and accept that the full source is compressed to the section's width (slight horizontal-only re-scale of the artwork).
224
+ - The visual cost: any element that was off-screen in Figma (the bleed) is now visible inside the section; any element positioned in Figma at e.g. 78% from left ends up at a different % because we're showing more of the artwork. Customer can re-center via Element Editor if precision matters.
225
+ - The reliability win: zero scrollbar regardless of WP theme. Always preferred over a fragile bleed-and-clip.
226
+
227
+ **Q2. Always defend the section against descendant horizontal overflow.**
228
+ The conversion engine cannot know what theme is active, so it has to defend itself at the section root:
229
+ ```
230
+ #sz-X {
231
+ width: 100%;
232
+ max-width: 100%;
233
+ overflow: hidden; /* clips any over-bleeding descendant */
234
+ isolation: isolate; /* contains z-index + prevents overflow leaks */
235
+ }
236
+ #sz-X * { box-sizing: border-box; }
237
+ ```
238
+ This is mandatory on every section. Without it, a single descendant with `width: 216vw` or a `<table>` with `min-width: 1500px` or an image with intrinsic width 2000px can force a body-level horizontal scrollbar that the user blames on the conversion.
239
+
240
+ **R. The plugin's `<form>` auto-create will hijack any real `<form>` element — wrap visual-only signup fields in `<div>`, not `<form>`.**
241
+
242
+ PLUGIN_FEATURE_MAP §5: when the plugin sees a real `<form>` containing `<input>` / `<textarea>` / `<select>`, it REPLACES the entire form HTML with `[sitezen_form id="X"]` — a managed-form shortcode that re-renders with the plugin's own structure: label above + plain wide `<input>` + plain rectangular Submit button below.
243
+
244
+ For a visual-only email signup (pill input + circular submit button styled to match the design), this destroys the styling. The customer's intended UI becomes a generic stock form.
245
+
246
+ Rule for any email-capture / search / "type here and press the button" pattern:
247
+ - DO NOT use a real `<form>` element — use `<div class="sz-X-signup" role="search">` (or just `<div>`).
248
+ - Use a styled `<input type="email">` (the input alone, without a `<form>` ancestor, doesn't trigger the auto-replace).
249
+ - Use `<a href="#" role="button" class="sz-X-signup-btn">` for the submit affordance, NOT `<button type="submit">` — buttons inside form-shaped markup can also trigger detection.
250
+ - Result: the pill / circular-button design renders exactly as authored; if the customer wants real form submission later, they can replace the div with a `<form>` in the editor or wire JS to the input.
251
+
252
+ If the design IS a real working contact / signup / multi-field form (the customer DOES want managed submissions, storage in WP admin, email notifications), THEN use `<form>` deliberately — the plugin will manage it via §5 features. That's the explicit opt-in path.
253
+
254
+ Detection signal: after every push, the response `detection.has_form: true` flag does NOT automatically mean the form was replaced — replacement requires a literal `<form>` element. But `has_form:true` is a useful "double-check that this was intentional" signal.
255
+
256
+ **S. Figma image-export URLs EXPIRE in ~30-60 min — must download + sideload to WP media library before embedding in pushed HTML.**
257
+ `/v1/images` returns short-lived S3 URLs from `figma-alpha-api.s3.us-west-2.amazonaws.com`. They die within an hour. If the conversion engine puts those URLs directly in `<img src="…">`, the live page shows broken images by the time anyone looks at it (or even before, if the push wasn't immediate).
258
+ Fixed pipeline:
259
+ 1. Conversion engine fetches the image from the Figma S3 URL.
260
+ 2. Uploads the binary to the WordPress site's media library via `POST /wp-json/wp/v2/media` (auth via the connection key — the plugin's `auth_admin_or_key` covers this) OR via a sidecar plugin endpoint `POST /wp-json/sitezen/v1/upload-media`.
261
+ 3. Receives the permanent WP-hosted URL (`/wp-content/uploads/…`).
262
+ 4. Substitutes the permanent URL into the HTML before pushing.
263
+ Never ship raw Figma S3 URLs to production. They look fine on first preview, then silently break.
264
+
265
+ **T. Pushes to tastewp test sites can 502 randomly — retry-with-backoff, and don't burn a debugging cycle assuming it's our HTML.**
266
+ The free test site infrastructure is flaky. Distinguish "the HTML is bad" from "the host is bad" by sending a 50-byte test section to the same `/push-html` endpoint. If the tiny one 502s too, it's the host — stop tweaking the conversion and either retry in a few minutes or switch test sites.
267
+
268
+ **U. Stop the bleed: a "simple hero section" should never take 15+ commits.**
269
+ This block alone has accumulated more iteration cycles than the rest of the product roadmap (docs, pricing, hosting, agency relationships, marketing, WP.org submission) deserves. Every retry burns ~$0.30 API + real human-in-loop minutes. The §0.3 rules above are a checklist — if a fresh hero conversion violates more than ONE of them on the first emit, the conversion engine isn't ready to ship; fix the prompt, not the output. The pre-output validation checklist (§0.3.H) is the brake — apply it before every emit, not after the user sees the broken page.
270
+
271
+ **V. Complex non-solid backgrounds → ONE image fills the section; text overlays on top.**
272
+ When the Figma section has a non-trivial background (illustration, scene, photo, multi-color composition with shapes), do NOT layer "section bg color + decoration image positioned at bottom". That creates a visible vertical seam wherever the section's solid color meets the image's own background pixels — the eye sees two different pinks/blues even when they're meant to look identical.
273
+
274
+ Correct pattern:
275
+ - Render the ENTIRE bg sub-frame (everything except text) as one image via `/v1/images`.
276
+ - Place the image as the section's first child in normal flow: `display:block; width:100%; height:auto; margin:0`. This makes the image set the section's natural height to its own aspect ratio — no seam possible because there's only one bg layer.
277
+ - Overlay all editable content via a `position:absolute; inset:0` wrapper with `z-index:2` and `pointer-events:none` (re-enable on children) so the bg image is the only thing drawing the section bg.
278
+ - Section keeps a `background:` color as a fallback only (matches the image's dominant bg color for the brief moment before the image loads).
279
+
280
+ Mobile fallback: when the image's natural aspect makes it too short for the content (e.g. 1.48 aspect-ratio image at 768px viewport is only 518px tall), switch the section to `display:flex; flex-direction:column`, put the content above with `order:1` and the image below with `order:2`. Single bg color (the image's own sky band continues into the content area's pink padding) acceptable on narrow viewports.
281
+
282
+ This rule supersedes the prior "bg color + bottom image" approach which was generating visible seams on every hero with a non-solid bg.
283
+
284
+ **W. When rendering a Figma group as a bg image, CHECK that the rendered PNG isn't asymmetrically masked.**
285
+ 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
+ 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.
287
+ Fix: render the smallest CHILD node that contains only the visible illustration content (the actual buildings/people/objects), NOT the parent group that includes the gradient mask. The child node renders uniformly transparent outside the illustration, with no masked-fill-then-transparent split. Layer it over a CSS solid color or gradient that fills the whole section.
288
+ Mantra: render the SMALLEST Figma node that contains exactly what you want, never a parent that includes mask layers.
289
+
290
+ **X. Section background-color must match the NEXT-SECTION color when the bg image has transparent regions (e.g. wave bottom, curved transition).**
291
+ Many hero/banner designs end with a curved/wave shape that visually transitions into the next section. The wave shape itself is usually transparent in the rendered Figma PNG — the visible "white wave" in Figma is actually the white page-bg or next-section bg showing through the wave's transparent area.
292
+
293
+ If you set the section's `background-color` to match the image's dominant color (e.g. navy for a navy banner), the transparent wave area shows navy → the wave appears the wrong color compared to Figma.
294
+
295
+ Rule:
296
+ - Identify the next section's bg color from the Figma frame above/below the hero. Most often it's the page bg (white).
297
+ - Set the hero section's `background-color` to that next-section color, NOT to the image's dominant color.
298
+ - The image's opaque regions cover what they need to; the transparent wave/curve regions show the next-section color through, matching Figma.
299
+ - Same rule for any section whose bg image has transparent regions at edges (top wave, side cutouts, etc.) — section bg-color should equal the page/adjacent-section color, not the image color.
300
+
301
+ **Y. Scroll-down arrows / "scroll for more" indicators → use the `.sz-scroll-down` contract so the customer can pick the target section from the editor.**
302
+ When a design has a small arrow / chevron / "scroll" badge near the bottom of the hero (a near-universal pattern in hero/banner designs), render it as:
303
+ ```html
304
+ <a class="sz-scroll-down" href="#" data-sz-scroll-target="" aria-label="Scroll to next section">
305
+ <svg ...><!-- down chevron --></svg>
306
+ </a>
307
+ ```
308
+ The plugin's frontend.js automatically wires up smooth-scroll on click. Default target = the section immediately after the containing hero. Customer can override via the Element Editor sidebar's "Scroll-Down Arrow" panel — a simple **text input** where they type the target section's ID (no `#` prefix). They set that ID on the target section using Gutenberg's standard "Advanced → Additional CSS class" field (or use the section's existing auto-generated ID). Stored on `data-sz-scroll-target`.
309
+
310
+ This matches WordPress conventions — every block has an Advanced > Additional CSS class field, so customers already know that flow. Simpler than a dropdown of "all sections on the page" which would require live cross-block discovery.
311
+
312
+ Never render scroll indicators as static images / non-interactive divs — they should always be functional.
313
+
314
+ **Z. When a section's `fills` is `GRADIENT_LINEAR` / `GRADIENT_RADIAL`, read the EXACT `gradientStops` from Figma — never approximate.**
315
+ Eyeballing a gradient from the rendered image always misses. The Figma JSON has the precise stops:
316
+ ```
317
+ fills[0].gradientStops = [
318
+ { position: 0.0, color: { r, g, b } },
319
+ { position: 0.255, color: { r, g, b } },
320
+ ...
321
+ ]
322
+ fills[0].gradientHandlePositions = [ {x,y}, {x,y}, {x,y} ] // direction
323
+ ```
324
+ Convert each stop's `color` to hex (multiply 0–1 channels by 255, round), use the position as the CSS stop %, and use the angle from `gradientHandlePositions` (handle 1 → handle 2) for the CSS `linear-gradient` angle.
325
+
326
+ Don't substitute a solid color for a gradient or a 2-stop guess for a 5-stop gradient — even small section bgs read visibly wrong when the gradient is off. This applies to any node with a GRADIENT fill, not just hero sections.
327
+
328
+ **AA. Every CTA button gets a Button Action contract — picks behavior in the editor, never ships dead.**
329
+ Ambiguous CTAs ("Watch our recap video", "Learn More", "Get Started", "Download") cannot be guessed perfectly at conversion time. Instead of refusing or guessing wrong, the conversion engine emits a SAFE DEFAULT + a contract so the customer can change the behavior in 10 seconds.
330
+
331
+ Contract on the `<a class="sz-btn">`:
332
+ ```html
333
+ <a class="sz-btn"
334
+ data-sz-action="video-popup" <!-- link | new-tab | video-popup | image-popup | template-popup -->
335
+ data-sz-action-src="<url>" <!-- video/image URL -->
336
+ data-sz-template-id="<id>" <!-- for template-popup only -->
337
+ href="...">CTA text</a>
338
+ ```
339
+
340
+ Defaults the engine chooses:
341
+ - Button labeled "watch …", "play …", "video" → `video-popup`
342
+ - Button labeled "view image", "see photo", "open gallery" → `image-popup`
343
+ - Button labeled "contact us", "request demo", "book" → `template-popup` (popup with a contact form template)
344
+ - Anything else → `link` (regular href)
345
+
346
+ Plugin behavior (sitezen-plugin v1.27.17+):
347
+ - Editor sidebar shows a "Button Action" panel whenever an `<a class="sz-btn">` is selected. Lets the customer pick the action + edit the URL or template id.
348
+ - Frontend `initButtonActions()` intercepts clicks on `[data-sz-action]` elements and shows a modal popup (video iframe / image / template HTML). `link` and `new-tab` are handled natively by `<a target>`.
349
+ - Template popups fetch HTML from `/wp-json/sitezen/v1/template-html/<id>` — customer designs the popup ONCE as a SiteZen template and links any number of buttons to open it.
350
+
351
+ Never refuse, never ship dead. Always emit + always editable.
352
+
353
+ **AB. When looking at the rendered Figma image, render the PARENT FRAME — not just the section — so you see the section in context, not against transparent canvas.**
354
+ Rendering a section node alone via `/v1/images?ids=<sectionId>` produces a PNG where the area OUTSIDE the section's own painted pixels is **transparent**. Most image viewers display PNG transparency as a dark checker / dark background, which can read as "dark bg" when the section's actual context is white (or vice versa). Result: you sample the wrong bg color, invert the text colors, and ship a section that's inverted from the design.
355
+
356
+ Rule:
357
+ - Before deciding section bg color or text contrast, render the PARENT FRAME (the page/screen the section sits inside) so you see what's actually behind/around the section.
358
+ - If a section's right half "looks dark" in its standalone render but the JSON text colors are dark (suitable for a light bg), the section is on a LIGHT bg and your standalone render is just showing transparency. Trust the JSON text colors and use white/light bg.
359
+ - Conversely, if JSON text colors are LIGHT (suitable for dark bg) and the standalone render shows light areas, those light areas are transparent and the real bg is dark.
360
+
361
+ The JSON text colors are usually the truth — they're authored to be readable against the actual bg. Use them to back-infer the bg color when the standalone render is misleading.
362
+
363
+ **AC. When a Figma image has a non-rectangular shape baked in (curved edge, wave, organic mask), DO NOT use `object-fit: cover` or set a rigid `height`.**
364
+ Figma renders shaped images (curved bottom, wave edge, organic cut-out) as PNGs where the shape is opaque and the surrounding area is transparent. The PNG's natural aspect ratio includes that transparent area. If you display it with `object-fit: cover` + a height constraint, the transparent surroundings get CROPPED — the curve becomes a hard straight edge.
365
+
366
+ Rule:
367
+ - Display shaped images at **natural aspect**: `display:block; width:100%; height:auto; max-width:100%`.
368
+ - No `object-fit: cover` (cropping kills the curve), no `min-height` / `height` on the container (forces rectangular box).
369
+ - The image's transparent surroundings then show the section's bg color through, preserving the curve visually.
370
+ - Pair this with §0.3.V — the section's bg should match what's behind/around the image (usually the page bg).
371
+
372
+ Reserves `object-fit: cover` for rectangular photos that need crop-to-fill ONLY. Any shaped/curved/masked image → natural aspect.
373
+
374
+ **AD. Composite multiple visual elements into ONE bg image (PIL), never stacked separate images.**
375
+ When a section's bg has multiple decorative shapes (photo + curve + wave + decoration), combine them into ONE composite PNG/JPG using `PIL.Image.alpha_composite()`. Upload the composite to a public host (`catbox.moe` works without auth) — the SiteZen plugin will auto-sideload from any public URL. The user's rule: "one image, never different images". This avoids the "scattered" look from stacking 2+ separate bg images with CSS positioning.
376
+
377
+ **AE. Erase text/buttons FROM the bg composite (paint white over their area).**
378
+ When using the full Figma section render as bg, the rendered image includes the text/button. Customer can't edit the rendered text. Fix: use `PIL.ImageDraw.rectangle(area, fill=(255,255,255,255))` to paint white over the text/button area in the composite, BEFORE uploading. Then position editable HTML overlays exactly where you erased. Customer edits the overlay, no text doubling.
379
+
380
+ **AF. Replicate Figma's element positioning in the COMPOSITE (cropping, overlapping).**
381
+ - Photo extending off the section's left edge (Figma): place photo with negative `x` offset in composite so it's cropped.
382
+ - Wave overlapping bottom of photo: place wave with `alpha_composite(wave, (0, H - wave_h))` so wave physically sits on top of photo's lower portion in the composite (not as a separate stacked element).
383
+
384
+ **AG. Crop wave/decoration shapes to JUST the shape, not the band below.**
385
+ Figma's wave Boolean nodes (e.g. Purpose Shape) include the wave curve PLUS the full colored band below it. The band is the NEXT section's bg, not part of this section. Crop the wave image to ~22-30% of its height (just the curve), not 44%+ — otherwise the wave portion dominates and overlaps content.
386
+
387
+ **AH. Editable properties go as INLINE `style="…"` on the element, not in `<style>` blocks.**
388
+ Element Editor color/style pickers can ONLY read inline styles, not styles inside `<style>{...}</style>` blocks. For any property the customer should be able to edit (bg color, font color, padding), put it as `style="background:#xxx"` directly on the element. Plugin's "Section Style" panel (v1.27.19+) reads inline `background-color` and lets the customer change it.
389
+
390
+ **AI. Map blocks need an actual iframe — `.sz-map` class alone won't render.**
391
+ For real interactive Google Maps, include the actual iframe in the HTML: `<iframe src="https://www.google.com/maps?q=ADDRESS&z=ZOOM&output=embed" width="100%" height="380" style="display:block;width:100%;height:380px;border:0" loading="lazy" allowfullscreen></iframe>`. The width + height HTML attributes are critical — browsers default `<iframe>` to 150px height without them. The `.sz-map` class + data attributes still let the Element Editor's Map panel appear so customer can change address/zoom.
392
+
393
+ **AJ. CPT listings inside custom layouts — use flat mode (`data-sz-post-flat="1"`).**
394
+ 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
+
396
+ **AK. Horizontal slider arrows — use plugin's `data-sz-slider-prev`/`data-sz-slider-next` (NOT inline `onclick`).**
397
+ 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
+ - `<button data-sz-slider-prev="#sz-board .track">←</button>`
399
+ - `<button data-sz-slider-next="#sz-board .track">→</button>`
400
+ - Selector points to the scroll container. Step = first child width + gap (auto-computed).
401
+ - Container CSS still needs: `display:flex; overflow-x:auto; scroll-behavior:smooth; scroll-snap-type:x mandatory; scrollbar-width:none`; cards `flex:0 0 calc(...); scroll-snap-align:start`.
402
+ - For the slider to visibly scroll, the cards' combined width must EXCEED the container — show fewer per view than total cards (e.g. 4 visible / 5 total). If all cards fit at once, arrows do nothing because there's nothing to scroll to.
403
+
404
+ **AL. User's screenshot WINS over my API render for visual decisions.**
405
+ The Figma API can render sections incorrectly (transparency rendered as dark, missing components, etc.). When the user provides a screenshot of how a section LOOKS in their Figma file, that's the ground truth — don't second-guess based on what my API render shows. Build to match the user's screenshot exactly.
406
+
407
+ **AN. Never use `<a>` to WRAP block-level card content — use an overlay `<a class="card-link">` inside the card instead.**
408
+ The plugin parses templates via PHP's `DOMDocument` (libxml HTML4 parser), which does NOT allow block elements inside `<a>` (HTML5-only behavior). Libxml silently strips or restructures `<a class="card"><img><div>…</div></a>` markup, producing empty fields and missing links in the final render. Always do:
409
+ ```
410
+ <div class="card" data-sz-card>
411
+ <a class="card-link" data-sz-post-field="url" href="#" style="position:absolute;inset:0;z-index:2;text-indent:-9999px">Read more</a>
412
+ <img data-sz-post-field="image" ...>
413
+ <div class="body" style="position:relative;z-index:1">
414
+ <p data-sz-post-field="title">…</p>
415
+ <p data-sz-post-field="excerpt">…</p>
416
+ </div>
417
+ </div>
418
+ ```
419
+ The overlay link absorbs whole-card clicks (z-index above the body) while the body stays selectable. Card uses `position:relative; overflow:hidden`.
420
+
421
+ **AO. CPT posts are auto-created as SiteZen blocks + auto-templated.**
422
+ Plugin v1.27.21+: every CPT post created from a Post Listing has `post_content` emitted as a `<!-- wp:sitezen/section -->` block (image + title + excerpt) — not classic HTML. A single-post `sz_template` is auto-created on first push, wired via `_sz_template_single_for` so visiting the post URL renders our blocks instead of the theme's default single layout. Rewrite rules auto-flush on plugin version change.
423
+
424
+ **AP. Theme post-header pollution on CPT singles is auto-hidden by the plugin.**
425
+ Plugin v1.27.22+: when a single post contains a `sitezen/section` block, the SEO module injects CSS that hides the theme's auto-rendered featured image, title, byline, post meta, comments, navigation. Designer's block IS the page. No extra rules needed in the conversion.
426
+
427
+ **AQ. CPT image dedup is a Figma design issue, NOT a plugin bug.**
428
+ Each Figma image URL gets its own attachment via `SiteZen_Images::optimize` (cached per URL hash). If all 5 cards display the same photo, the source Figma file uses the same placeholder image hash for all cards. Verify by inspecting the rendered `<img src>` — distinct local filenames = plugin is correct. Tell user to replace placeholders in Figma or upload featured images per post in WP admin.
429
+
430
+ **AR. Re-pushing to a page with multiple sections — beware merged block 0.**
431
+ 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
+
433
+ **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
+
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.
441
+
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.
446
+
447
+ 3. **Generic horizontal scroll** (`data-sz-slider-prev/next="<selector>"` arrows, v1.27.21):
448
+ - 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
+ - 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
+
451
+ 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
+
453
+ **AT. Accordion — plain + linked-panel contracts.**
454
+ Plugin contract (`initAccordion`, all versions):
455
+ - Section needs class `sz-has-accordion` (or be auto-detected via `.accordion-item` presence).
456
+ - Each item: `<div class="accordion-item">` (one MAY have `.open` for default-open).
457
+ - Trigger: `<button class="accordion-trigger">…</button>` (also accepts `.accordion-button`, `.sz-accordion-trigger`, `[data-toggle="collapse"]`).
458
+ - 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).
460
+
461
+ **Linked-panel variant (v1.27.25+):**
462
+ Use when accordion item should swap a SEPARATE element (right-side image, video, stat card) in sync with which item is open:
463
+ - Each item: add `data-sz-panel="<key>"` to the `.accordion-item`.
464
+ - Panel host elsewhere in the section: each panel is `<div data-sz-panel-key="<key>">…</div>`, hidden by default; only the one matching the open item gets `.is-active` toggled on.
465
+ - Style: `.panel { opacity:0; visibility:hidden } .panel.is-active { opacity:1; visibility:visible }` (or `display:none/block`).
466
+ - On init, plugin also syncs the default-open item's panel.
467
+
468
+ **AU. Figma `visible: false` fills/strokes/effects must be RESPECTED — don't read the color blindly.**
469
+ Figma node JSON exposes hidden layers in the layers panel as `{ ... "visible": false }`. The color/value is still in the API response, but the layer is INVISIBLE in the design. If you read it as if visible, you get inverted styling — e.g. cards that LOOK transparent on the dark canvas in Figma get rendered with a solid white background in the output.
470
+
471
+ Check before using any fill/stroke/effect:
472
+ ```py
473
+ for f in node.get('fills', []):
474
+ if f.get('visible') is False: continue # ignore hidden layers
475
+ # …use this fill
476
+ ```
477
+ Same for `strokes` and `effects`. This caught a transparent-card vs white-card mistake in the Intagono "Cuatro Pilares" + "Crecimiento" sections — Figma cards had `fills:[{visible:false, color:white}]` + a 1px #646262 stroke, meaning they read as transparent cards with thin gray borders on the dark canvas. Reading the white fill straight produced solid white cards that broke the design.
478
+
479
+ **AV. Right-aligned content from `textAlignHorizontal=RIGHT` + frame `counterAxisAlignItems=MAX`.**
480
+ Figma layout hints to honor when emitting HTML:
481
+ - `n.style.textAlignHorizontal == "RIGHT"` → `text-align:right` on that text element.
482
+ - Frame with `layoutMode=VERTICAL` and `counterAxisAlignItems=MAX` → children are right-pinned within that frame → in CSS `margin-left:auto` (or grid `justify-items:end`).
483
+ - Same for `MIN` → left-pin (default), `CENTER` → center.
484
+
485
+ Surfaced in the Intagono journey-accordion section: Frame 902 had `counterAxisAlignItems=MAX` (right-pinned within the 1248-container) AND its heading children had `textAlignHorizontal=RIGHT`. First emit ignored both and produced left-aligned content the user immediately flagged as wrong.
486
+
487
+ **AW. Tabs — separate `sz_template` per tab + `[hidden]` attribute (NOT `.active` class) controls panel visibility.**
488
+
489
+ Plugin contract (`initTabs` + block attribute `tabTemplates`):
490
+ - Section needs `.sz-has-tabs` class (or auto-detected).
491
+ - Buttons: `<button class="sz-tab-btn" role="tab">…</button>` (also `[role="tab"]`, `.tab-btn`, `.nav-tab`).
492
+ - Panels: `<div class="sz-tab-panel" role="tabpanel"></div>` (also `[role="tabpanel"]`, `.tab-pane`). EMPTY shells — content comes from templates.
493
+ - Plugin toggles panels via the **`hidden` HTML attribute**, NOT a class:
494
+ ```js
495
+ panels.forEach(function (p) { p.hidden = true; });
496
+ if (panels[i]) panels[i].hidden = false;
497
+ ```
498
+ - So CSS MUST use `.sz-tab-panel[hidden] { display:none }`, NOT `.sz-tab-panel { display:none } .sz-tab-panel.active { display:block }` — otherwise `display:none` always wins and only one panel ever shows.
499
+ - Pre-set `hidden` on panels 2+ in the emitted HTML; first panel stays visible.
500
+
501
+ **Separate template per tab (recommended for any Figma with multiple tabs):**
502
+ 1. For each tab, POST to `/wp-json/sitezen/v1/create-template` with `{title, content, type:'tab', wrapAsSitezen:true}` → returns `id`. Use the same demo content for tabs whose content isn't yet designed in Figma — each gets its own template so they stay independently editable.
503
+ 2. Push the section with `tabTemplates: [id1, id2, …]` in the payload (push-html passes this through to block attributes).
504
+ 3. Plugin's `substitute_templates($html, $attributes['tabTemplates'], 'sz-tab-panel')` replaces each panel's inner HTML with the template's post_content at render time.
505
+ 4. Each template appears in WP admin → SiteZen → Templates as a SiteZen Section block — fully editable. Editing one tab's template only affects that tab.
506
+
507
+ This is the right pattern for any "tabs with rich content" Figma (services, industries, plans, locations) and matches the workflow we use for sliders and post listings.
508
+
509
+ **AX. Footer — render as `<section>` for test pages, `<footer>` for site-wide template.**
510
+
511
+ Plugin auto-detects footers via `<footer>` tag + heuristic checks (presence of copyright text, multi-col link grid). When `is_footer: true` AND no `page_id` is in the push payload, the plugin auto-saves the HTML as a `sz_template` of type `footer` and activates it **site-wide** — every page on the site will render this footer.
512
+
513
+ Workflow when converting a footer section:
514
+
515
+ 1. **First push as `<section class="sz-fullwidth sz-site-footer-wrap">`** to a NEW test page with `page_title` + `slug` set. The `<section>` tag (not `<footer>`) prevents auto-detection, so it lands as a regular page where you can verify the look visually without affecting the live site.
516
+ 2. **Once approved**, re-push the SAME HTML with the wrapper changed to `<footer>` and NO `page_id` — plugin auto-routes to footer template and activates site-wide.
517
+
518
+ **Sub-rule — Newsletter `<form>` is auto-replaced by `[sitezen_form]` shortcode.**
519
+ Any `<form>` containing `<input>` triggers `auto_create_form()` (PLUGIN_FEATURE_MAP §5) — the static markup gets swapped for the plugin's managed form shortcode so submissions hit the SiteZen submissions pipeline + integrations. The trade-off: the rendered form may NOT match the Figma design (uses plugin's default label-above + wide input + plain Submit). If pixel-perfect newsletter look matters, replace the `<form>` with a `<button data-sz-action="form">Subscribe</button>` that opens a styled modal form instead, OR override the plugin's form CSS via section-scoped selectors.
520
+
521
+ **Sub-rule — Two-column split footers use a CSS grid `grid-template-columns: 1fr 1fr` (or weighted ratios), NOT absolute widths.**
522
+ CRBWA footer (94:3066) is 1920×732 with `Rectangle 12` (left, 1027w, #0e426c) + `Group 4` (right, 893w, #07355f darker with mountain image bg @35% + gradient overlay). Render as `display:grid; grid-template-columns:1.15fr 1fr` so it stays a split footer at all widths and collapses to single-column on mobile.
523
+
524
+ **Sub-rule — Rendered logos that are Figma VECTORs (multiple sub-paths in a `FRAME`) should be requested via the `/v1/images` render endpoint (PNG @2x), NOT reconstructed manually as SVG paths.** Use the AWS render URL directly in `<img src="…">`. The plugin's `SiteZen_Images::optimize` sideloads it into the WP media library on push.
525
+
526
+ **AY. Header — auto-routes to site-wide `sz_template` when `is_header` detected.**
527
+
528
+ Plugin auto-detects headers via `<header>` tag, `class="sz-site-header-wrap"`, or `class="sz-fullwidth"` near top + nav-like content. When `is_header: true` AND no `page_id` is in the push payload, the plugin saves the HTML as a `sz_template` of type `header` and **activates it site-wide** — every page renders this header.
529
+
530
+ Workflow when converting a header:
531
+ 1. Push HTML with `<section class="sz-fullwidth sz-site-header-wrap">` (or `<header>`) and NO `page_id` → plugin returns `{template_id, template_type:"header", message:"Header template saved and activated site-wide."}`.
532
+ 2. Verify by visiting ANY URL on the site — the new header now renders on every page.
533
+ 3. To switch between multiple header designs, manage them in WP admin → SiteZen → Templates (the active one is marked).
534
+
535
+ **Sub-rule — Two-tier headers (top contact bar + main navbar).**
536
+ Emit as TWO sibling rows inside the same section:
537
+ ```html
538
+ <section class="sz-fullwidth sz-site-header-wrap">
539
+ <div class="topbar">…contact info + socials + lang…</div>
540
+ <div class="navbar">…logo + nav + CTA…</div>
541
+ </section>
542
+ ```
543
+ Each row gets its own max-width inner container so both align to the same 1440-style grid but stay full-bleed.
544
+
545
+ **Sub-rule — Dropdown nav items use `:hover` + `:focus-within` (CSS-only, no JS).**
546
+ For nav items with `▾` chevrons, render a hidden `<ul class="dropdown">` inside `.nav-item` and toggle visibility via:
547
+ ```css
548
+ .nav-item .dropdown { opacity:0; visibility:hidden; transition:…, visibility 0s linear .2s; }
549
+ .nav-item:hover .dropdown, .nav-item:focus-within .dropdown { opacity:1; visibility:visible; transition:…, visibility 0s; }
550
+ ```
551
+ The `visibility 0s linear .2s` exit transition + `visibility 0s` enter is what makes the dropdown actually disappear AFTER the fade-out (not during it). `wp_kses` doesn't strip this — pure CSS survives the push intact.
552
+
553
+ **Sub-rule — Logo via `/v1/images` PNG render, NOT manual SVG rebuild (same as §0.3.AX footer).**
554
+ Logos that are multi-path Figma VECTOR groups (colored icon + wordmark) — request `/v1/images?ids=<NODE_ID>&format=png&scale=3` and use the returned AWS URL directly in `<img src="…">`. Don't rebuild as inline SVG — paths are too dense and brand color accuracy matters.
555
+
556
+ **Sub-rule — Mobile menu falls back to a `.menu-toggle` hamburger button.**
557
+ Hide `.nav` + `.cta` below `@media (max-width:760px)` and show a 44×44 hamburger button. Wiring of the off-canvas menu can be added later via the plugin's `data-sz-action="menu"` contract (or just static CSS-checkbox hack).
558
+
559
+ **AZ. Pricing tables — 3-up cards with elevated middle + live usage calculator.**
560
+
561
+ Pricing card grid pattern (Figma frame `151:2843`, 1440×1557):
562
+ - 3 cards `grid-template-columns: 1fr 1.05fr 1fr` — middle card slightly wider/taller and `translateY(-8px)` raised with shadow + dark `#0a0a0a` bg + white text.
563
+ - Each card has: label (uppercase tracking 0.06em) → desc → price block (huge `$N` + unit like `/DB` + rate caption + small note) → CTA button → feature list with `✓` (`#4ade80` green) or `–` (`#d1d5db` muted) bullets.
564
+ - "Most popular" pill badge: absolute-positioned at `top:-14px; right:24px`, brand-color bg, white text, rounded `100px`.
565
+ - Below cards: optional **usage calculator** card on `#f9fafb` rounded surface.
566
+
567
+ **Plugin contract (v1.27.26) — `[data-sz-pricing-calc]`:**
568
+ ```html
569
+ <div data-sz-pricing-calc
570
+ data-sz-rate-db="1" data-sz-rate-storage="0.02"
571
+ data-sz-min-db="1" data-sz-max-db="100"
572
+ data-sz-min-storage="0" data-sz-max-storage="10000">
573
+ <!-- one ±counter per variable -->
574
+ <button data-sz-calc-step="db" data-sz-calc-delta="-1">−</button>
575
+ <span data-sz-calc-val="db">5</span>
576
+ <button data-sz-calc-step="db" data-sz-calc-delta="1">+</button>
577
+
578
+ <button data-sz-calc-step="storage" data-sz-calc-delta="-50">−</button>
579
+ <span data-sz-calc-val="storage">200</span>
580
+ <button data-sz-calc-step="storage" data-sz-calc-delta="50">+</button>
581
+
582
+ <p data-sz-calc-total>$9</p>
583
+ <p data-sz-calc-breakdown>…</p>
584
+ </div>
585
+ ```
586
+ Plugin reads `data-sz-rate-*` and `data-sz-min-/max-` from the wrapper; on every +/− click it clamps `values[key]`, updates the visible `[data-sz-calc-val="key"]`, recomputes `total = sum(qty × rate)`, and writes back to `[data-sz-calc-total]` + `[data-sz-calc-breakdown]`.
587
+
588
+ **Use for any "configure your plan" pricing:** seats × user-rate, storage × per-GB, API calls × per-1k, etc. Variables aren't hardcoded — add as many `data-sz-calc-step="<key>"` rows as the design has, plus matching `data-sz-rate-<key>` attrs. No JS in section HTML required (would be stripped by `wp_kses` anyway — same gotcha as §0.3.AK slider arrows). All wiring lives in the plugin.
589
+
590
+ **Sub-rule — Monthly/yearly toggle.**
591
+ For pricing cards with a Monthly/Yearly switch (no Figma example yet in this file), use a CSS-only radio pattern: `<input type="radio" name="billing">` siblings + `:checked ~ .cards` selectors that swap visible prices via `display:none/block`. No JS, no plugin work needed — survives `wp_kses` because no event handlers are involved.
592
+
593
+ **⚠ CRITICAL — Pricing CARD container/class contract for Element Editor.**
594
+ The plugin's Element Editor (`renderPricingPanel` in `editor.js`) ALREADY supports the pricing block with "+ Add pricing card" / Remove / "Mark as POPULAR" controls. It looks for THESE EXACT classes — if you don't emit them, the side-panel editors don't appear and the user can't add more tiers:
595
+
596
+ ```html
597
+ <div class="sz-pricing"> <!-- the grid container -->
598
+ <div class="sz-pricing-card"> <!-- each tier card -->
599
+ <h3>Free</h3> <!-- card title (h3, REQUIRED) -->
600
+ <p class="sz-price">$0<span>/month</span></p> <!-- price block with .sz-price class -->
601
+ …features list…
602
+ </div>
603
+ <div class="sz-pricing-card sz-popular"> <!-- the elevated/highlighted card -->
604
+ <h3>Pro</h3>
605
+ <p class="sz-price">$29<span>/month</span></p>
606
+ </div>
607
+ <div class="sz-pricing-card">
608
+ <h3>Enterprise</h3>
609
+ <p class="sz-price">Custom</p>
610
+ </div>
611
+ </div>
612
+ ```
613
+
614
+ - Container MUST have `class="sz-pricing"` — plugin queries `.sz-pricing` to locate the group.
615
+ - Each card MUST have `class="sz-pricing-card"` — plugin queries `.sz-pricing-card` to enumerate cards.
616
+ - Card title MUST be in an `<h3>` element — plugin reads `card.querySelector('h3').textContent` for the side-panel label.
617
+ - Price MUST have `class="sz-price"` — plugin reads/writes via `card.querySelector('.sz-price')`.
618
+ - The highlighted/popular card adds `sz-popular` to the card class. The "Mark as POPULAR" toggle in the side panel manages this.
619
+
620
+ Style these classes however the design needs (Tailwind utility classes, inline styles, etc.) — but the contract classes MUST be present. If you emit `<div class="card">` instead of `<div class="sz-pricing-card">`, the Element Editor sidebar shows "Editors only appear for blocks present in this section" — exactly the bug we hit on the first pricing emit. Customer can't add tiers, can't toggle popular, can't edit titles by field. Don't ship without these classes.
621
+
622
+ **BA. Monthly/Yearly pricing toggle — CSS-only `:checked` radio pattern.**
623
+ No JS, no plugin work — survives `wp_kses` because no event handlers involved.
624
+ ```html
625
+ <input type="radio" name="bp" id="bp-m" checked>
626
+ <input type="radio" name="bp" id="bp-y">
627
+ <label for="bp-m">Monthly</label>
628
+ <label for="bp-y">Yearly</label>
629
+ <div class="cards">…<span class="n monthly">$9</span><span class="n yearly">$86</span>…</div>
630
+ ```
631
+ ```css
632
+ /* hide yearly by default */
633
+ .yearly { display:none; }
634
+ /* when #bp-y is checked, hide monthly + show yearly via sibling selector */
635
+ #bp-y:checked ~ .cards .monthly { display:none; }
636
+ #bp-y:checked ~ .cards .yearly { display:inline; }
637
+ /* style the active label */
638
+ #bp-m:checked ~ label[for="bp-m"], #bp-y:checked ~ label[for="bp-y"] { background:#0a0a0a; color:#fff; }
639
+ ```
640
+ Inputs MUST come BEFORE the labels and cards for the sibling selector (`~`) to reach them.
641
+
642
+ **Structure: radios + switch row + cards as siblings of ONE parent.**
643
+ ```html
644
+ <div class="wrap">
645
+ <input type="radio" name="pt" id="pt-m" checked>
646
+ <input type="radio" name="pt" id="pt-y">
647
+ <div class="switch"><label for="pt-m">Monthly</label><label for="pt-y">Yearly</label></div>
648
+ <div class="cards">…</div>
649
+ </div>
650
+ ```
651
+ NEVER put labels INSIDE the same flex container as the cards. First emit did this — `.switch` was `display:inline-flex` containing both the labels AND the cards row. Default flex `align-items:stretch` made the active label balloon to ~250px tall (height of the cards row), producing a giant black oval covering the whole left side. Keep `.switch` short — labels only — and use `align-items:center` on it for safety.
652
+
653
+ **⚠ Critical — wrap in a `<div>`, NEVER `<form>`.** The plugin's `auto_create_form` (PLUGIN_FEATURE_MAP §5) sees ANY `<form>` with `<input>` and replaces the entire form (including ALL child markup — cards, labels, everything) with a `[sitezen_form id="X"]` shortcode. The pricing table vanishes and a generic "Submit" form renders in its place. Confirmed on first emit: the user's screenshot showed only "pt" / "pt" select fields + Submit button + "Thank you" message. Rule applies to ANY pure-CSS pattern using `<input type="radio">` or `<input type="checkbox">` — keep them outside `<form>`.
654
+
655
+ **BB. Hover animations — pure CSS `transition` on `:hover`. No JS.**
656
+ Six validated patterns:
657
+ - **Lift** — `transform:translateY(-8px)` + deeper `box-shadow`
658
+ - **Scale** — `transform:scale(1.04)` + grown shadow
659
+ - **Glow ring** — `box-shadow:0 0 0 4px rgba(brand,0.15), 0 14px 32px rgba(brand,0.25)` + border-color
660
+ - **Color shift** — `background` + nested text color all transition together
661
+ - **Image zoom** — inner `<img>` scales inside `overflow:hidden` parent (no layout shift)
662
+ - **Underline grow** — `::after` pseudo with `width:0 → width:100%` on hover
663
+ All use `transition: <prop> .35s cubic-bezier(.22,.61,.36,1)` for snappy-but-natural easing. Skip image-zoom on `@media (prefers-reduced-motion: reduce)` if accessibility matters.
664
+
665
+ **BC. Custom SVG decorations — inline SVG + CSS `@keyframes` for animation.**
666
+ Beyond the wave/triangle peaks (§0.3.K, §0.3.AG), three pattern families validated:
667
+ - **Animated blob bg** — `<svg><path d="M..."/></svg>` with `@keyframes` morphing the `d` attribute between two organic shapes via `animation: name 14s ease-in-out infinite alternate`. Honor `@media (prefers-reduced-motion: reduce) { animation:none }`.
668
+ - **Masked grid overlay** — `background-image: linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px), linear-gradient(90deg, …)` with `background-size: 60px 60px` and `mask-image: radial-gradient(ellipse 60% 80% at center, #000 30%, transparent 80%)` for a fade-to-edges grid.
669
+ - **Per-card organic deco** — small absolute-positioned SVGs (concentric rings, Bezier curves with `Q cx,cy x,y` syntax, polygons with stacked `points`) at 0.6 opacity in card corners.
670
+
671
+ All inline SVG survives push intact — `wp_kses` allows `<svg>` content inside SiteZen block markup because the section's `sectionHtmlB64` decode bypasses kses re-processing.
672
+
673
+ **BD. Sticky elements — `position:sticky` with mandatory ancestor `overflow` audit.**
674
+ Two validated patterns:
675
+ - **Sticky sidebar in 2-column layout** — `display:grid; grid-template-columns:280px 1fr` + sidebar gets `position:sticky; top:24px`. Sidebar stops sticking when its grid row ends.
676
+ - **Sticky CTA bar pinned to bottom of viewport** — `position:sticky; bottom:0; margin:0 -1000px` (escape the wrapper's max-width to be full-bleed) + `z-index:9` + `box-shadow:0 -8px 30px rgba(0,0,0,0.18)` for depth.
677
+
678
+ **Critical gotcha:** if ANY ancestor (`html`, `body`, section wrapper, theme container) has `overflow:hidden` / `overflow:auto` / `overflow:scroll`, sticky silently breaks. Walk the DOM tree and find it. Mobile fallback: switch grid to `1fr` + sticky sidebar back to `position:static` so it doesn't dominate small viewports.
679
+
680
+ **BE. Image gallery with lightbox — pure CSS `:target` pattern. No JS, no library.**
681
+ Each thumbnail is `<a href="#img-N"><img></a>`. For each thumb, emit a matching `<div class="lightbox" id="img-N">` containing the full-size image + prev/next links + close link:
682
+ ```css
683
+ .lightbox { position:fixed; inset:0; background:rgba(10,10,10,0.92); display:flex; align-items:center; justify-content:center;
684
+ opacity:0; visibility:hidden; transition:opacity .3s ease, visibility 0s linear .3s; z-index:1000; }
685
+ .lightbox:target { opacity:1; visibility:visible; transition:opacity .3s ease, visibility 0s; }
686
+ ```
687
+ - Close button: `<a class="close" href="#">×</a>` — clearing the hash hides ALL lightboxes (none match `:target`).
688
+ - Prev/Next: `<a href="#img-N-1">‹</a>` / `<a href="#img-N+1">›</a>` — each link targets the sibling lightbox's id; last → first wraps.
689
+ - The delayed `visibility 0s linear .3s` exit transition + immediate `visibility 0s` enter is what makes the fade actually animate (visibility flips to `hidden` only AFTER opacity reaches 0).
690
+
691
+ Works on mobile, touch, keyboard tab, screen readers — all browser-native a-tag behavior. The only limitation: page scroll position jumps when the hash changes (browser default). Add `scroll-margin-top` on lightbox container if it bothers you.
692
+
693
+ **BF. Custom-code fallback — if a pattern isn't covered by a SiteZen block contract, emit inline HTML + CSS + (if needed) plugin-side JS. Don't refuse the design.**
694
+
695
+ Default behavior so far has been: "if the plugin has a contract for this pattern, use it; otherwise warn the user." That's wrong for production.
696
+
697
+ **New rule:** every Figma design must convert to SOMETHING that renders correctly on the frontend. If a SiteZen block contract doesn't exist:
698
+ 1. **Emit inline** — drop the raw HTML/CSS straight into the section block (`sectionHtmlB64`). It renders fine; no special block needed.
699
+ 2. **Sacrifice side-panel editability** — the section won't have a structured Element Editor for that custom piece. Customer edits it via the **Code** view in WP block editor (`Edit as HTML` on the SiteZen block) or via the section's `sectionHtmlB64` directly. **Document this clearly in the section's emit**: leave a `<!-- sz:custom-code -->` comment marker so the customer/support knows this piece is code-edited.
700
+ 3. **Plugin-side JS** when interactivity is needed — add a new `data-sz-*` contract and a small handler in `frontend.js`. We've done this for pricing calc (`data-sz-pricing-calc`), slider arrows (`data-sz-slider-prev/next`), linked accordion panels (`data-sz-panel/data-sz-panel-key`). Pattern: name the attribute, init on `DOMContentLoaded`, version-bump the plugin.
701
+
702
+ Customer expectation framing (from §0.3.AM cost ceiling): the conversion always produces a working visual on first emit. Edit-mode polish + Fix-Issue retries handle alignment / color / copy refinements. We never tell the user "this design can't be converted because the block doesn't exist."
703
+
704
+ **BG. Dual visual validation — Figma render + user screenshot.**
705
+
706
+ The platform now passes TWO visual references to every conversion call:
707
+ 1. **Figma node render** via `/v1/images?ids=<NODE>&format=png&scale=2` — exact pixel-perfect snapshot of the design surface (with `visible:false` layers correctly hidden by Figma's renderer, unlike the raw API JSON which exposes them).
708
+ 2. **User-uploaded screenshot** — from the new platform field. This is the ground truth for what the customer actually wants — captures their current Figma file state including any unsaved edits, comments, or local overlays.
709
+
710
+ Rule precedence when they disagree:
711
+ - **User screenshot WINS** for visual decisions (color, layout, spacing, hierarchy) — supersedes my Figma render in case of stale cache or unsaved Figma changes (existing §0.3.AL).
712
+ - **Figma access-token data WINS** for exact values — hex codes, font names + weights, image asset URLs, spacing tokens. (`/v1/files/{FILE}/nodes?ids=` returns precise numbers the screenshot can't.)
713
+ - **Plugin contracts WIN** for interactivity. Even if the user screenshot shows a custom slider animation, route through `data-sz-slider-*` first; only add a new contract (§0.3.BF) if the existing ones can't express it.
714
+
715
+ This dual-input model means I have THREE sources of truth per section: Figma JSON (exact values), Figma render (rasterized design), user screenshot (current intent). Cross-check all three on first emit — divergence between them is the strongest signal something's wrong (or stale).
716
+
717
+ **BH. Retry budget — 3 fix-issue passes before escalating to manual.**
718
+
719
+ Production loop (platform-side):
720
+ 1. **Emit 1** — conversion engine produces the section. ~90% pass rate per §0.3.BG.
721
+ 2. **Edit mode** — user makes inline tweaks (alignment, color, copy) via Element Editor side panel.
722
+ 3. **Fix Issue, pass 1** — user describes one issue → engine emits a targeted patch. ~95% pass rate.
723
+ 4. **Fix Issue, pass 2** — second targeted patch.
724
+ 5. **Fix Issue, pass 3** — third (final) targeted patch.
725
+ 6. **Escalate to manual** — surface to support, log the design + final state for §0.3 rule extraction.
726
+
727
+ 3 retries (not 5) is the right ceiling — if the engine hasn't converged in 3 passes, the prompt itself is wrong and adding retries just burns API cost without convergence. Each retry MUST be a **scoped patch** (re-emit only the changed CSS/HTML for the failing element), not a full re-conversion — full re-conversions tend to flip-flop between near-correct outputs.
728
+
729
+ **Implementation hint:** the Fix-Issue API call should include the previous emit's exact CSS, the user's specific complaint, the relevant Figma sub-tree, AND both visual references (§0.3.BG). The engine should output a diff or a single replaced rule-block, not a re-render of the whole section.
730
+
731
+ **BI. Responsive — every conversion MUST include mobile + tablet styles per standard breakpoints.**
732
+
733
+ Non-negotiable for every emit. The Figma file usually shows one viewport (1440 / 1920 desktop), but the conversion must produce a responsive section.
734
+
735
+ **Standard breakpoints to honor (in this order — mobile-first or desktop-first both fine, just consistent):**
736
+ - `@media (max-width: 1280px)` — small desktop / laptop
737
+ - `@media (max-width: 1024px)` — tablet landscape
738
+ - `@media (max-width: 768px)` — tablet portrait
739
+ - `@media (max-width: 560px)` — large mobile
740
+ - `@media (max-width: 380px)` — small mobile
741
+
742
+ **Mandatory per-section checks:**
743
+ 1. **Fluid typography** — use `clamp(min, vw-based, max)` for all `font-size` so headings scale smoothly. Never hard-pin `font-size: 47px` — emit `font-size: clamp(28px, 3.3vw, 47px)`.
744
+ 2. **Fluid spacing** — same `clamp()` pattern for `padding`, `margin`, `gap`. Sections breathe naturally between viewports.
745
+ 3. **Grid collapse** — multi-column grids (`grid-template-columns: repeat(N, 1fr)`) collapse to 2-up at tablet (`<=1024`), then 1-up at mobile (`<=560`). Never leave 4-up at 360px.
746
+ 4. **Image proportions** — every `<img>` gets `aspect-ratio` so it reserves space and prevents layout shift on slow connections.
747
+ 5. **Touch targets** — buttons / nav items minimum 44×44px on mobile (per Apple HIG / Google MD3). Pills with `padding:8px 16px` need padding bumped on small viewports.
748
+ 6. **Sticky behavior reset on mobile** — sidebars (§0.3.BD) revert to `position:static` below 768px so they don't dominate the viewport.
749
+ 7. **Off-canvas nav** — headers with >3 nav items (§0.3.AY) collapse to hamburger `.menu-toggle` below 760px. Top-bar contact info typically hidden entirely on mobile.
750
+ 8. **Horizontal scroll prevention** — every section gets `max-width:100vw; overflow:hidden` on its root. Catches absolute-positioned children that would otherwise create a scrollbar.
751
+
752
+ **Tablet view specifically** (`@media (max-width:1024px) and (min-width:561px)`):
753
+ - 3-col grids → 2-col
754
+ - 4-col grids → 2-col (NOT 3-col — wastes space)
755
+ - Hero copy that's `clamp(28px,3vw,42px)` stays inside that fluid range, no override needed
756
+ - Sticky CTA bars get smaller padding (`12px` vs `16px`)
757
+
758
+ **Mobile view** (`max-width:560px`):
759
+ - ALL grids → 1-col (rare exceptions: 2-col for very small cards like social icons)
760
+ - Card padding: `clamp(18px,4vw,28px)` shrinks naturally — don't hard-override
761
+ - Card grids: gap shrinks to `12-16px`
762
+ - Hide decorative `>=3rd` items in feature lists if they don't fit
763
+ - Buttons go full-width by default (`width:100%`) unless explicitly inline
764
+
765
+ **Verification:** every section emit ends with the breakpoint media queries that ACTUALLY get exercised — not just a stub `@media (max-width:768px) { /* mobile */ }`. The breakpoints we emitted today (Patterns Demo, all our other tests) use this discipline. If a section's grid doesn't collapse at 1024 + 560, that's a §0.3 violation; flag in QA.
766
+
767
+ **⚠ CRITICAL — Defeat agency theme CSS on text-align / margins / max-width with `!important`.**
768
+ Agency WordPress themes (Astra, GeneratePress, Kadence, OceanWP, Hello Elementor, etc.) ship with VERY high-specificity selectors targeting `.entry-content h2`, `.wp-block-* p`, etc. that override our inline styles + class styles. On the live WP page, our `text-align:center` headlines render LEFT, our `margin:0 auto` containers render flush-left, our `max-width:780px` blocks render full-width.
769
+
770
+ This is NOT a localhost vs production thing — it bites every conversion on every WP site. The platform preview iframe doesn't load theme CSS, so the bug is invisible until the user pushes and visits the live page (exactly what happened on the Bajaringan pricing test).
771
+
772
+ Rule: for the FOLLOWING property categories, EVERY rule MUST end with `!important`:
773
+ - `text-align: left | center | right | justify`
774
+ - `margin-left: auto`, `margin-right: auto`, `margin: 0 auto`, `margin-inline: auto`
775
+ - `max-width` on text containers
776
+ - `font-family` (themes override with their own default)
777
+ - `color` on headings (themes pin h1/h2/h3 to their accent color)
778
+
779
+ Example:
780
+ ```css
781
+ /* WRONG — theme wins */
782
+ #sz-pricing .h { text-align: center; max-width: 780px; margin: 0 auto; color: #0a0a0a; }
783
+
784
+ /* RIGHT — survives theme override */
785
+ #sz-pricing .h {
786
+ text-align: center !important;
787
+ max-width: 780px !important;
788
+ margin: 0 auto !important;
789
+ color: #0a0a0a !important;
790
+ }
791
+ ```
792
+ Do NOT use `!important` on every property — only the ones above. Padding, gap, border, background, font-size, font-weight, line-height are usually safe (themes don't typically override those).
793
+
794
+ **⚠ CRITICAL — Honor exact Figma spacing values, never round or guess.**
795
+ Figma frames expose `paddingTop`, `paddingLeft`, `paddingRight`, `paddingBottom`, and `itemSpacing` (gap between children). These are MANDATORY:
796
+ - Emit `padding-top: clamp(<smaller>, <vw>, <figma-px>px) !important` — NOT `padding-top: clamp(64px,5vw,100px)` if Figma says 96px (use 96, not 100).
797
+ - Emit `gap: <figma-itemSpacing>px` for flex/grid containers — NOT a "looks-about-right" value.
798
+ - For nested frames, propagate the per-frame spacing — don't replace inner-frame `itemSpacing` with the outer one.
799
+
800
+ If you can't see exact values in the Figma payload (some inner nodes might not be exposed), state in a code comment which value was approximated and from where (`/* gap approximated 24px — parent Container itemSpacing */`). Never silently substitute. First emit on the Bajaringan pricing section had ~50% padding values invented — this rule kills that.
801
+
802
+ **BJ. Push target — Page OR Template (sz_template). User picks upfront on input screen.**
803
+
804
+ The conversion engine now produces output destined for one of THREE targets, picked by the user on the input screen (not at push-time):
805
+
806
+ 1. **New page** — creates a fresh WordPress page with this section as block 0.
807
+ 2. **Existing page** — appends to (or replaces a block in) an existing page picked from the live `/api/wp-pages` list.
808
+ 3. **SiteZen template** (`sz_template` post type) — converts into a reusable template assignable to other sections:
809
+
810
+ | Template type | What it powers | Where it's assigned |
811
+ |---|---|---|
812
+ | `header` | Site-wide header | Auto-activates on push (any page that loads sees this header) |
813
+ | `footer` | Site-wide footer | Auto-activates on push |
814
+ | `tab` | One tab's content | Attached via `tabTemplates[i]` on a tabs section (§0.3.AW) |
815
+ | `accordion-body` | One accordion item's content | Attached via `accordionTemplates[i]` |
816
+ | `slider` | One slide's content | Attached via `slideTemplates[i]` |
817
+ | `single` | A CPT's single-post template | `_sz_template_single_for=<cpt>` meta (§0.3.AO) |
818
+ | `cpt` | CPT listing card | Used by Post Listing flat-mode renderer |
819
+ | `menu` | Nav menu / dropdown | Referenced by `data-sz-template-id` on nav items |
820
+ | `block` | Generic reusable block | Manual assignment by user |
821
+
822
+ **UI contract (convert page input screen):**
823
+ - Radio `pushTarget`: New page / Existing page / SiteZen template
824
+ - When `template` selected: nested controls for **template type** (dropdown, all 9 above) + **new or existing** (radio):
825
+ - **New** — text input for template name (idempotent — re-pushing same name overwrites).
826
+ - **Existing** — dropdown filtered by selected type, fetched from `/api/wp-templates?siteId=…&type=…`. Auto-fills name from picked template so re-push hits same record.
827
+ - Live refresh: `/api/wp-templates` re-fetched whenever site OR template-type changes (and only when `pushTarget === 'template'` to save requests).
828
+ - If selected existing template was deleted on WP between selection and push → reset to null + warn.
829
+
830
+ **Backend contract (`/api/push`):**
831
+ - New `pushTarget: 'template'` field routes the request to `POST /wp-json/sitezen/v1/create-template` instead of `/push-html`.
832
+ - Payload: `{ title: templateTitle, content: html, type: templateType, wrapAsSitezen: true }`.
833
+ - Returns `{ ok, templateId, templateType, templateTitle, pageUrl }` on success (pageUrl is the WP admin edit URL).
834
+ - History entry records `sectionName: "<Title> (template:<type>)"` so the History page distinguishes templates from page pushes.
835
+
836
+ **Frontend contract for engine prompt:** when `pushTarget === 'template'`, the engine prompt is augmented with the template type so it can adjust emission (e.g. a `header` template gets `<header class="sz-site-header-wrap">` wrapper instead of `<section>`; a `tab` template gets emitted as the inner content only, no outer `<section>` since it lives inside a `.sz-tab-panel`).
837
+
838
+ This closes the loop: every conversion now has a clear destination set BEFORE the API call runs — engine knows the wrapper to emit, plugin knows where to store the result, user knows where it will appear.
839
+
840
+ **AM. Output first-try discipline.**
841
+ 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
+
843
+ ---
844
+
845
+ ## §1 Hero — Status: ✅ Approved
846
+
847
+ ### Rules (block-specific)
848
+
849
+ Hero has NO special plugin contract — it's standard well-styled HTML. Universal rules in §0 cover everything that's needed.
850
+
851
+ 1. **Emit exactly what's in the design.** Same text content (verbatim from Figma TEXT NODES), same colors, same fonts, same spacing, same layout. No translation, no paraphrasing, no padding with Lorem ipsum.
852
+
853
+ 2. **Semantic structure** (use the tags that match the visible role — not just `<div>`s):
854
+ - `<h1>` for the main headline (one per page across all sections — if this section's heading is clearly secondary, use `<h2>`; the plugin's `SiteZen_SEO::fix_heading_hierarchy()` will rebalance at push if needed)
855
+ - Optional eyebrow `<p>` above the headline (small caps text, often uppercase letter-spacing)
856
+ - Optional subheading / body `<p>` below the headline
857
+ - Optional CTA(s) as `<a class="sz-btn">` or `<a class="sz-btn sz-btn-secondary">` for secondary
858
+ - Optional hero photo as a real `<img src="<figma-url>" alt="<descriptive>">` (designer can swap via Element Editor)
859
+
860
+ 3. **Background — defer to universal rules:**
861
+ - Solid colour from Figma → `background: <hex>` on the section
862
+ - Gradient with no overlay text → CSS `background: linear-gradient(...)` on the section
863
+ - Decorative pattern / sketch / wave that you can't recreate in CSS → render the DECORATION SUB-FRAME (no text inside) as `<img class="sz-bg-only" style="position:absolute; pointer-events:none; user-select:none; z-index:0">` behind content. NEVER render the whole section frame as an image.
864
+
865
+ 3a. **Inline decorations (accents, underlines, leaves, swooshes, ornamental shapes) — render Figma's actual vector, NEVER recreate in CSS:**
866
+ - Designers add many small decorations: a curved gold underline below an eyebrow, a leaf icon next to a headline, a brushstroke accent behind a word, etc.
867
+ - These are VECTOR / RECTANGLE / IMAGE_FILL children inside or alongside the text they decorate. **WALK THE FIGMA TREE DEEPLY** — they're rarely top-level; they sit inside auto-layout frames like `Underline_NN`, `accent`, `decor`, or as anonymous `Vector` siblings of the text.
868
+ - Render each decoration by its Figma image export (PNG or SVG via the `/v1/images` API) as a real `<img class="sz-bg-only" aria-hidden="true">`. The shape MUST come from Figma — CSS `text-decoration` or `::after` pseudo-elements are flat rectangles and DO NOT match shaped decorations.
869
+ - Positioning rule for inline decorations whose Figma bounding box OVERLAPS or sits adjacent to a text element:
870
+ - Make the smallest common ancestor (typically the content wrapper) `position: relative` — this becomes the decoration's positioning context.
871
+ - Position the decoration `position: absolute` with `left` / `top` calculated from the Figma offset relative to that ancestor (use percentages of the ancestor's width for horizontal so it scales responsively).
872
+ - Use `clamp(MIN, Xvw, FIGMA_PX)` for the decoration's size.
873
+ - DO NOT put the decoration in flex flow (inline-flex inside the headline) — it will push the text out of its real position and the decoration will drift off-center on resize.
874
+ - Add `pointer-events: none; user-select: none;` so clicks pass through to editable content.
875
+
876
+ 4. **Layout flexibility — Hero comes in many shapes; pick the right one from the screenshot:**
877
+ - Full-width centered (text centered on a bg image/colour)
878
+ - Left text + right image (split layout — use CSS Grid or Flex)
879
+ - Right text + left image (mirrored split)
880
+ - Full-bleed image with text overlay (text absolute-positioned on top, with overlay for contrast)
881
+ - Stacked layout (small image/icon above text, all centered)
882
+
883
+ 5. **Responsive (universal rule applies):** `clamp(MIN, Xvw, FIGMA_PX)` for headline font-size and big padding values. Mobile rules from §0.
884
+
885
+ 6. **A hero can CONTAIN a slider as a child component.** "Hero" is a section type (the first big section of a page), not a block constraint. If the hero's main visual area shows multiple slides with dots/arrows/carousel indicators in Figma, render the slider INSIDE the hero section — don't split it into two sections. The hero's surrounding chrome (title, eyebrow, CTAs, surrounding layout) is still hero-level; the slider is just the visual content area within it. Apply §2 Slider rules to the slider portion.
886
+ - Example: a hero with "Sliding showcase of 3 product photos behind a static headline" → the headline sits absolute on top, and the 3 photos rotate via `.sz-slider` markup behind them.
887
+ - Example: a hero whose entire content cycles ("Slide 1: Headline A + Button A → Slide 2: Headline B + Button B") → that IS a slider OF heroes — emit it as a single `<section>` with `.sz-slider` containing 3 `.sz-slide` children, each holding the hero content for that slide.
888
+ - The detection guideline (§0.1 pre-classifier): if the screenshot shows ANY of [carousel dots / prev-next arrows / multiple visible "slides" of the same shape], the hero contains a slider — never silently drop the indicators.
889
+
890
+ 7. **NO standalone interactive markup OTHER than what point 6 covers.** Hero itself has no tabs / accordion / form / etc. unless the design actually shows them inside the hero region.
891
+
892
+ 8. **Stacked-zone heroes vs overlay heroes — pick the right architecture from the screenshot:**
893
+
894
+ When a hero has a structured top→bottom layout in Figma (e.g. text/buttons at top, photo in middle, wave at bottom), **DO NOT** absolute-position the photo/wave as decorations behind the content. That fights itself: photo absolutes from top will overlap content; from bottom will float and wave will land on top of photo.
895
+
896
+ Instead use a **flex column** layout where each Figma zone is its own DOM block:
897
+
898
+ ```html
899
+ <section class="sz-fullwidth">
900
+ <div class="sz-hero-content"> ...text + buttons... </div>
901
+ <div class="sz-hero-photo">
902
+ <img src="...photo...">
903
+ <img class="sz-decor-wave"> <!-- absolute at bottom of THIS wrapper, not section -->
904
+ </div>
905
+ </section>
906
+ ```
907
+
908
+ With CSS: `section { display:flex; flex-direction:column }`, `.sz-hero-photo { position:relative; height: clamp(MIN, Xvw, FIGMA_PX) }`, `.sz-decor-wave { position:absolute; bottom:0; width:100%; height: clamp(MIN, Xvw, FIGMA_PX) }`.
909
+
910
+ ⚠️ **DO NOT use `aspect-ratio` for the photo wrapper inside a flex column** — flex parents don't reliably size children by aspect-ratio (the photo collapses to 0 height in some browsers). Use explicit `height: clamp(...)` calculated from Figma proportions instead (e.g. Figma 1440×426 → height: clamp(200px, 29.6vw, 426px) since 426/1440 = 29.6%).
911
+
912
+ ⚠️ **Vector decorations — fetch the actual path, don't guess from PNG renders.**
913
+
914
+ When you see a VECTOR node in Figma (wave, triangle, curve, divider, etc.), do this BEFORE emitting:
915
+
916
+ 1. **Fetch the path data** via Figma API: `GET /v1/files/{key}/nodes?ids={node_id}&geometry=paths`. The response has `fillGeometry[].path` (an SVG path string) and `fills` (colors).
917
+
918
+ 2. **If the path is simple** (single `<path>`, solid fill, no gradients/filters — typical for waves, triangles, dividers): emit **inline SVG** with the actual path, using `preserveAspectRatio="none"` so it stretches cleanly:
919
+ ```html
920
+ <svg class="sz-decor-wave sz-bg-only" viewBox="0 0 1440 95" preserveAspectRatio="none" aria-hidden="true">
921
+ <path d="M0 95L720 0L1440 95L0 95Z" fill="#29384d"/>
922
+ </svg>
923
+ ```
924
+ With CSS: `width: 100%; height: clamp(MIN, Xvw, FIGMA_PX)`. The SVG stretches exactly to the box. No PNG cropping bugs, no transparent-padding guesses, exact Figma fidelity.
925
+
926
+ 3. **If the path is complex** (multi-path, gradient fill, filters, brushstroke): fall back to Figma's PNG image render via `<img>`. Use `object-fit: cover` with `object-position` chosen to match how the shape is anchored in the design (bottom for waves/transitions, top for headers/banners).
927
+
928
+ **PNG renders are unreliable** because:
929
+ - The bounding box may include transparent padding (showing the full PNG makes the decoration look 2× too big)
930
+ - Plugin's image pipeline adds `srcset` and other attributes that interact with CSS in unpredictable ways
931
+ - Object-fit cropping requires guessing which portion of the image to keep
932
+
933
+ Always prefer the inline-SVG path when the shape is simple.
934
+
935
+ **Process for the conversion engine:** when a Figma node is type VECTOR, default to fetching its path data via `geometry=paths`. Only fall back to PNG when the geometry response indicates multiple paths, gradients, or other non-trivial fills.
936
+
937
+ Use **overlay heroes** (full-bleed photo bg + absolute text overlay) ONLY when the Figma actually shows text ON the photo (centered overlay). Trying to overlay text on a "photo at bottom" design produces the photo-eats-buttons bug.
938
+
939
+ 9. **First-try discipline — no exploratory re-pushes.** Hero is the most common section type; we cannot afford 2-3 push iterations to land each one. Before emitting HTML, do a complete Figma walk and verify all decorations / accents / underlines / overlapping elements are accounted for. The validation checklist below MUST be answered before output — if any item is unanswered, walk Figma deeper first.
940
+
941
+ Pre-output validation (mental checklist for the conversion engine):
942
+ - [ ] Every TEXT node from Figma data appears in the HTML
943
+ - [ ] Every inline decoration (Underline_*, accent, decor, leaf, badge, swoosh) is accounted for — either rendered as Figma image OR confirmed not present
944
+ - [ ] All overlapping elements (decoration overlaps text? small image overlaps headline?) have positioning calculated relative to their common ancestor, not section root
945
+ - [ ] Section background: solid colour OR gradient OR composite of separate decoration layers (NEVER full section frame as image)
946
+ - [ ] Responsive: clamp() on font-sizes >18px, padding >32px, decoration sizes
947
+ - [ ] Mobile rules in place: stacked content, smaller fonts, simplified/hidden complex decorations
948
+
949
+ ### Required plugin features (from PLUGIN_FEATURE_MAP)
950
+
951
+ None block-specific. Hero relies on:
952
+ - §1 Block attributes: `metaTitle`, `metaDescription`, `ogImage` may be set on this section (it's typically the first block on the page → its SEO attributes win)
953
+ - §10 SEO: `fix_heading_hierarchy()` ensures the `<h1>` story stays clean
954
+ - §17 Editor: standard per-element editing (text, images, buttons, links)
955
+
956
+ ### Open questions
957
+
958
+ *(none — Hero is the baseline; if anything turns up during testing it gets added here)*
959
+
960
+ ### Validated test URLs
961
+
962
+ - Earlier session: "Farming > Hero Section" — converted via the platform and confirmed "100% same" by the user.
963
+ - "Home" hero (80:1795) — Indonesian B2B steel roofing — stacked-zone with wave bottom.
964
+ - "Desktop - Large" hero (86:4637) — Hack the North style — overlay architecture with composite illustration as section's natural bg.
965
+ - "Homepage 1 — Banner" (94:2614) — CRBWA wastewater — full-bleed photo bg with transparent wave bottom (drove §0.3.X — section bg = next section color when image has transparent regions) AND scroll-down arrow (drove §0.3.Y — `.sz-scroll-down` contract + editor target picker).
966
+
967
+ ### Finalized: §1 is approved
968
+
969
+ After 23 §0.3 rules earned across 4 hero variants with very different architectures (stacked, overlay, full-bleed photo + transparent wave, illustration + sky), the engine produces pixel-perfect hero conversions on the first emit for any design that fits these patterns. A brand-new architecture pattern (e.g. video bg, 3D element, split carousel) would add §0.3.Z+ as it appears.
970
+
971
+ Hero rules are locked in `src/lib/claude.ts` SYSTEM_PROMPT (PER-BLOCK RULES → §1 HERO) and in §0.3 universal rules that apply across every block.
972
+
973
+ ---
974
+
975
+ ## §2 Slider / Carousel — Status: ⏳ Not started
976
+
977
+ ### Rules (block-specific)
978
+ *(empty)*
979
+
980
+ ### Required plugin features (from PLUGIN_FEATURE_MAP)
981
+ - §3 initSlider — auto-detects single vs multi-card via firstSlide.width/container ratio
982
+ - §1 Block attributes: `sliderShowDots`, `sliderShowArrows`, `sliderAutoplay`, `sliderLoop`, `sliderAutoplaySpeed`, `sliderPauseOnHover`
983
+ - §3 initCenterSlider — testimonial-style peek slider
984
+
985
+ ### Open questions
986
+ *(empty)*
987
+
988
+ ### Validated test URLs
989
+ *(empty)*
990
+
991
+ ---
992
+
993
+ ## §3 Testimonial — Status: ⏳ Not started
994
+
995
+ ### Rules (block-specific)
996
+ *(empty)*
997
+
998
+ ### Required plugin features
999
+ *(empty)*
1000
+
1001
+ ### Open questions
1002
+ *(empty)*
1003
+
1004
+ ### Validated test URLs
1005
+ *(empty)*
1006
+
1007
+ ---
1008
+
1009
+ ## §4 Tabs — Status: ⏳ Not started
1010
+
1011
+ ### Rules
1012
+ *(empty)*
1013
+
1014
+ ### Required plugin features
1015
+ - §3 initTabs — `.sz-tab-btn` + `.sz-tab-panel[hidden]`
1016
+ - §2 Detection: `has_tabs / tab_count`
1017
+ - §17 Editor: per-panel sz_template assignment
1018
+
1019
+ ### Open questions
1020
+ *(empty)*
1021
+
1022
+ ### Validated test URLs
1023
+ *(empty)*
1024
+
1025
+ ---
1026
+
1027
+ ## §5 Accordion — Status: ⏳ Not started
1028
+
1029
+ ### Rules
1030
+ *(empty)*
1031
+
1032
+ ### Required plugin features
1033
+ - §3 initAccordion — `.sz-accordion-item.open` + `.sz-accordion-trigger` + `.sz-accordion-body`
1034
+ - §3 initAccordionSearch — `<input data-sz-accordion-search>`
1035
+ - §11 Schema: `FAQPage` JSON-LD auto-generated
1036
+ - §17 Editor: per-item sz_template assignment
1037
+
1038
+ ### Open questions
1039
+ *(empty)*
1040
+
1041
+ ### Validated test URLs
1042
+ *(empty)*
1043
+
1044
+ ---
1045
+
1046
+ ## §6 Dynamic Post Listing (News / Blog / Services / Team / Projects / etc.) — Status: ⏳ Not started
1047
+
1048
+ ### Rules
1049
+ *(empty)*
1050
+
1051
+ ### Required plugin features
1052
+ - §4 Dynamic Post Pack — `data-sz-post-listing` + `data-sz-post-type` + `data-sz-card` + `data-sz-post-field`
1053
+ - §4 CPT auto-registration via `ensure_cpt` (public, has_archive, rewrite slug)
1054
+ - §4 Template tokens: `{{title}}`, `{{image}}`, `{{url}}`, `{{date}}`, `{{author}}`, `{{excerpt}}`, `{{categories}}`, `{{tags}}`
1055
+ - §17 Editor: Dynamic Post Listing panel (post count, filter, pagination, search)
1056
+ - §17 Editor: Single Post Template designation
1057
+ - §20 Admin: SiteZen → Post Types page
1058
+
1059
+ ### Open questions
1060
+ *(empty)*
1061
+
1062
+ ### Validated test URLs
1063
+ *(empty)*
1064
+
1065
+ ---
1066
+
1067
+ ## §7 CTA Banner — Status: ⏳ Not started
1068
+
1069
+ ### Rules
1070
+ *(empty)*
1071
+
1072
+ ### Required plugin features
1073
+ - §2 Detection: `types: cta`
1074
+
1075
+ ### Open questions
1076
+ *(empty)*
1077
+
1078
+ ### Validated test URLs
1079
+ *(empty)*
1080
+
1081
+ ---
1082
+
1083
+ ## §8 Features / Image grid / Static cards — Status: ⏳ Not started
1084
+
1085
+ ### Rules
1086
+ *(empty)*
1087
+
1088
+ ### Required plugin features
1089
+ *(empty)*
1090
+
1091
+ ### Open questions
1092
+ *(empty)*
1093
+
1094
+ ### Validated test URLs
1095
+ *(empty)*
1096
+
1097
+ ---
1098
+
1099
+ ## §9 Stats / Counters — Status: ⏳ Not started
1100
+
1101
+ ### Rules
1102
+ *(empty)*
1103
+
1104
+ ### Required plugin features
1105
+ - §3 initCounters — `[data-sz-counter][data-target="N"][data-duration="1500"]`
1106
+ - §17 Editor: Stats panel (per-stat target + label + remove + add)
1107
+
1108
+ ### Open questions
1109
+ *(empty)*
1110
+
1111
+ ### Validated test URLs
1112
+ *(empty)*
1113
+
1114
+ ---
1115
+
1116
+ ## §10 Pricing tables — Status: ⏳ Not started
1117
+
1118
+ ### Rules
1119
+ *(empty)*
1120
+
1121
+ ### Required plugin features
1122
+ - §2 Detection: `types: pricing`
1123
+ - §17 Editor: Pricing panel (per-card "Mark as Popular" + remove + add)
1124
+
1125
+ ### Open questions
1126
+ *(empty)*
1127
+
1128
+ ### Validated test URLs
1129
+ *(empty)*
1130
+
1131
+ ---
1132
+
1133
+ ## §11 Team cards — Status: ⏳ Not started
1134
+
1135
+ ### Rules
1136
+ *(empty)*
1137
+
1138
+ ### Required plugin features
1139
+ - §2 Detection: `types: team`
1140
+ - §17 Editor: Team panel (add/remove cards)
1141
+
1142
+ ### Open questions
1143
+ *(empty)*
1144
+
1145
+ ### Validated test URLs
1146
+ *(empty)*
1147
+
1148
+ ---
1149
+
1150
+ ## §12 Steps / Process timeline — Status: ⏳ Not started
1151
+
1152
+ ### Rules
1153
+ *(empty)*
1154
+
1155
+ ### Required plugin features
1156
+ - §2 Detection: `types: steps`
1157
+ - §17 Editor: Steps panel (per-step Title/Description/Image + layout toggle)
1158
+
1159
+ ### Open questions
1160
+ *(empty)*
1161
+
1162
+ ### Validated test URLs
1163
+ *(empty)*
1164
+
1165
+ ---
1166
+
1167
+ ## §13 Logo strip / Clients bar — Status: ⏳ Not started
1168
+
1169
+ ### Rules
1170
+ *(empty)*
1171
+
1172
+ ### Required plugin features
1173
+ - §3 initLogoMarquee — `.sz-logos[data-sz-marquee][data-sz-marquee-speed="30"]`
1174
+
1175
+ ### Open questions
1176
+ *(empty)*
1177
+
1178
+ ### Validated test URLs
1179
+ *(empty)*
1180
+
1181
+ ---
1182
+
1183
+ ## §14 Forms / Contact section — Status: ⏳ Not started
1184
+
1185
+ ### Rules
1186
+ *(empty)*
1187
+
1188
+ ### Required plugin features
1189
+ - §5 Form auto-create from `<form>` with `<input>/<textarea>/<select>`
1190
+ - §5 Replaced with `[sitezen_form id="X"]` shortcode at push
1191
+ - §20 Admin: SiteZen → Forms (field editor) + Submissions
1192
+
1193
+ ### Open questions
1194
+ *(empty)*
1195
+
1196
+ ### Validated test URLs
1197
+ *(empty)*
1198
+
1199
+ ---
1200
+
1201
+ ## §15 Video — Status: ⏳ Not started
1202
+
1203
+ ### Rules
1204
+ *(empty)*
1205
+
1206
+ ### Required plugin features
1207
+ - §2 Detection: `has_video`
1208
+ - §17 Editor: Replace with Video (YouTube/Vimeo URL → responsive iframe in `.sz-video-wrap`)
1209
+
1210
+ ### Open questions
1211
+ *(empty)*
1212
+
1213
+ ### Validated test URLs
1214
+ *(empty)*
1215
+
1216
+ ---
1217
+
1218
+ ## §16 Map — Status: ⏳ Not started
1219
+
1220
+ ### Rules
1221
+ *(empty)*
1222
+
1223
+ ### Required plugin features
1224
+ - §17 Editor: Map panel (address text + zoom slider + height)
1225
+ - Google Maps iframe in `.sz-map[data-sz-map-address][data-sz-map-zoom]`
1226
+
1227
+ ### Open questions
1228
+ *(empty)*
1229
+
1230
+ ### Validated test URLs
1231
+ *(empty)*
1232
+
1233
+ ---
1234
+
1235
+ ## §17 Header / Nav — Status: ⏳ Not started
1236
+
1237
+ ### Rules
1238
+ *(empty)*
1239
+
1240
+ ### Required plugin features
1241
+ - §2 Detection: `is_header` (auto-routes to sz_template)
1242
+ - §6 Templates — site-wide header injection via `wp_body_open`
1243
+ - §3 initDropdowns + data-sz-submenu (single + sub-submenu + mega)
1244
+ - §1 Block attribute: `hamburgerPosition`
1245
+
1246
+ ### Open questions
1247
+ *(empty)*
1248
+
1249
+ ### Validated test URLs
1250
+ *(empty)*
1251
+
1252
+ ---
1253
+
1254
+ ## §18 Footer — Status: ⏳ Not started
1255
+
1256
+ ### Rules
1257
+ *(empty)*
1258
+
1259
+ ### Required plugin features
1260
+ - §2 Detection: `is_footer` (auto-routes to sz_template)
1261
+ - §6 Templates — site-wide footer injection via `wp_footer`
1262
+
1263
+ ### Open questions
1264
+ *(empty)*
1265
+
1266
+ ### Validated test URLs
1267
+ *(empty)*
1268
+
1269
+ ---
1270
+
1271
+ ## §19 Sticky CTA (floating button) — Status: ⏳ Not started
1272
+
1273
+ ### Rules
1274
+ *(empty)*
1275
+
1276
+ ### Required plugin features
1277
+ - §3 initStickyCTA — `.sz-sticky-cta[data-sz-show-after][data-sz-position]`
1278
+ - §17 Editor: Sticky CTA panel
1279
+
1280
+ ### Open questions
1281
+ *(empty)*
1282
+
1283
+ ### Validated test URLs
1284
+ *(empty)*
1285
+
1286
+ ---
1287
+
1288
+ ## §20 Cookie banner — Status: ⏳ Not started
1289
+
1290
+ ### Rules
1291
+ *(empty)*
1292
+
1293
+ ### Required plugin features
1294
+ - §3 initCookieBanner — `.sz-cookie-banner[data-sz-cookie-name][data-sz-cookie-days]`
1295
+ - §17 Editor: Cookie Banner panel
1296
+
1297
+ ### Open questions
1298
+ *(empty)*
1299
+
1300
+ ### Validated test URLs
1301
+ *(empty)*
1302
+
1303
+ ---
1304
+
1305
+ ## §21 Animations — Status: ⏳ Not started
1306
+
1307
+ > Cross-cutting; not a block but applies to any element in any block.
1308
+
1309
+ ### Rules
1310
+ *(empty)*
1311
+
1312
+ ### Required plugin features
1313
+ - §3 initAnimations — `[data-sz-anim][data-sz-anim-trigger="on-load|on-scroll"][data-sz-anim-delay][data-sz-anim-duration][data-sz-anim-easing]`
1314
+
1315
+ ### Open questions
1316
+ *(empty)*
1317
+
1318
+ ### Validated test URLs
1319
+ *(empty)*
1320
+
1321
+ ---
1322
+
1323
+ ## §22 Decoration handling (cross-cutting) — Status: ⏳ Not started
1324
+
1325
+ > Rules for when to render decorations as images vs CSS — applies to every block.
1326
+
1327
+ ### Rules
1328
+ *(empty)*
1329
+
1330
+ ### Open questions
1331
+ *(empty)*
1332
+
1333
+ ### Validated test URLs
1334
+ *(empty)*
1335
+
1336
+ ---
1337
+
1338
+ ## Workflow
1339
+
1340
+ For each block we tackle:
1341
+
1342
+ 1. **Discuss** — you explain how this block should work / what plugin features it uses.
1343
+ 2. **Define rules** — I write them into the block's §N section (and nowhere else).
1344
+ 3. **Generate seed HTML** — I produce one example following the rules.
1345
+ 4. **Push to test site** — verify live.
1346
+ 5. **Iterate** — if broken, update only that block's §N. Never modify another block's section while discussing this one.
1347
+ 6. **Mark Approved** — when you confirm the block works, status flips to ✅ and the rules get ported into `src/lib/claude.ts` SYSTEM_PROMPT under "PER-BLOCK RULES".
1348
+ 7. **Move to next block.**
1349
+
1350
+ **Rule on cross-block memories:** if while testing accordion you remember something about slider, that note goes in §2 Slider's section (the relevant block's home), NOT chronologically after accordion. The doc stays organized by block, not by time.
1351
+
1352
+ ---
1353
+
1354
+ ## Port-in checklist (per block, on approval)
1355
+
1356
+ When a block flips to ✅ Approved:
1357
+ - [ ] Rules added to `src/lib/claude.ts` SYSTEM_PROMPT under PER-BLOCK RULES
1358
+ - [ ] Live test URL recorded above
1359
+ - [ ] Plugin contract version confirmed (still v1.27.13 or bumped)
1360
+ - [ ] If post-processor needed: added to `src/lib/normalize.ts` with comment "owned by §N"
1361
+ - [ ] Push to GitHub (commit message: `Block §N: <name> — approved + ported`)