webcake-landing-mcp 1.0.55 → 1.0.56

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.
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.0.56",
4
+ "d": "11/06/2026",
5
+ "type": "Added",
6
+ "en": "The expand pipeline now automatically derives styles.background from specials.src for every image-block node; the live published renderer reads only…",
7
+ "vi": "Pipeline expand nay tự động tính styles.background từ specials.src cho mọi node image-block; renderer trên trang published chỉ đọc…"
8
+ },
2
9
  {
3
10
  "v": "1.0.55",
4
11
  "d": "10/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "en": "create_page, update_page, and add_section dry-run responses now include a draft_id, and all three tools now accept draft_id as an input parameter:…",
35
42
  "vi": "Các response dry-run của create_page, update_page và add_section nay đều trả về draft_id, đồng thời cả ba công cụ đều nhận draft_id làm tham số đầu…"
36
- },
37
- {
38
- "v": "1.0.50",
39
- "d": "10/06/2026",
40
- "type": "Added",
41
- "en": "New publish_page tool makes a page live: reads the page's current stored source, saves it as a new version, and creates or updates the…",
42
- "vi": "Công cụ publish_page mới giúp đưa trang lên live: đọc source đang lưu của trang, lưu thành phiên bản mới và tạo hoặc cập nhật bản ghi…"
43
43
  }
44
44
  ]
@@ -2,22 +2,60 @@ import { seedPosition, setStyle, setBox } from "../../../core/element.js";
2
2
  export const COMMERCE = [
3
3
  {
4
4
  type: "list-product", category: "commerce", container: false, defaultName: "ListProduct",
5
- summary: "Product list bound to the page store; clicking a card opens the popup-checkout overlay.",
5
+ summary: "Product list bound to the page store; clicking a card opens the popup-checkout overlay configured by page-level cartConfigs.checkoutConfig.",
6
6
  useWhen: "Show purchasable products.",
7
7
  keySpecials: {
8
+ // --- filter / layout ---
8
9
  select: "'product' | 'tag' | 'category' — which dimension to filter products by.",
9
- type: "'expect' | 'except' — treat the expect*/except* arrays as an allowlist (expect) or denylist (except).",
10
- expect: "array of product id strings to include (select='product', type='expect').",
10
+ type: "'expect' | 'except' — treat the expect*/except* arrays as an allowlist (expect) or denylist (except). FOOTGUN: type='expect' + select='product' + empty expect array renders ZERO products (empty allowlist = nothing; category/tag allowlists treat empty differently). SSR caps at 200 products.",
11
+ expect: "array of product id strings to include (select='product', type='expect'). Must be non-empty when type='expect', otherwise no products render.",
11
12
  except: "array of product id strings to exclude (select='product', type='except').",
12
13
  expectCategory: "array of category ids to include (select='category').",
13
14
  exceptCategory: "array of category ids to exclude (select='category').",
14
15
  expectTags: "array of tag slugs to include (select='tag').",
15
16
  exceptTags: "array of tag slugs to exclude (select='tag').",
16
- format_title: "'default' | 'sku' | 'sku-name' | 'name-category' product title composition.",
17
- format_price: "'range' | 'discount' — 'discount' shows the % off badge when original > retail.",
18
- direction: "'column' | (other) — 'column' = whole card is one click target; otherwise thumbnail + cart button each get handlers (and remain-quantity shows).",
17
+ direction: "'column' | (other) layout direction. With the SSR wrapper every card is fully clickable in BOTH directions; thumbnail+cart-only handlers are a legacy path.",
18
+ row_layout: "'layout_1' | 'layout_2' — row-direction card variant (row only).",
19
19
  numerical_order: "boolean — show numbered labels (01, 02 …) on thumbnails.",
20
- remain_quantity_text: "string low-stock label; {{value}} is replaced with the actual remaining count.",
20
+ // --- title / price ---
21
+ format_title: "'default' | 'sku' | 'sku-name' | 'name-category' — product title composition.",
22
+ format_price: "'range' | 'discount' | 'discount-revert' — 'discount' shows % off badge when original > retail; 'discount-revert' reverses the cost/price order + badge; any non-'range' value shows the first variation's original+retail pair.",
23
+ // --- quantity / stock ---
24
+ show_remain_quantity: "boolean — show remaining stock count (default true).",
25
+ remain_quantity_text: "string — low-stock label; {{value}} replaced with actual count.",
26
+ is_runout: "boolean — hide sold-out products.",
27
+ // --- rating / social proof ---
28
+ show_rating_star: "boolean — show fake star rating derived from product name. WARNING: if any product has a null name this throws at product.name.charCodeAt and crashes the build for that card.",
29
+ show_total_sold: "boolean — show total sold count.",
30
+ // --- image ---
31
+ hide_product_image: "boolean — hide product thumbnail.",
32
+ thumbnail_size: "number (px, default 195) — thumbnail width.",
33
+ thumbnail_height: "number (px) — thumbnail height override.",
34
+ background_size: "'cover' | 'contain' | … (default 'cover') — thumbnail background-size.",
35
+ // --- button ---
36
+ show_add_button: "'show' | 'hide' | 'hide_icon' (default 'show') — add-to-cart button visibility.",
37
+ text_add_button: "string — custom add-to-cart button label.",
38
+ iconCart: "url string — custom cart icon for the button.",
39
+ btn_width: "number (px, default 182) — button width.",
40
+ btn_height: "number (px) — button height.",
41
+ btn_border: "string — button border shorthand.",
42
+ border_type_btn: "string — button border type.",
43
+ border_width_btn: "number — button border width.",
44
+ // --- SKU / extras ---
45
+ show_extra_sku: "boolean — show additional SKU attributes on the card.",
46
+ // --- typography / font ---
47
+ font_family: "string — card font family.",
48
+ textAlign: "'left' | 'center' | 'right' — card text alignment.",
49
+ boldName: "boolean — bold product name.",
50
+ boldExtraTitle: "boolean — bold extra SKU title.",
51
+ list_product_font: "object — per-element font sizes (px numbers): { pName, pPrice, pOrgPrice, pQuantity, pButton, pStarSize, pTotalSold }.",
52
+ // --- flash sale ---
53
+ show_flash_sale_icon: "boolean (default true) — show flash-sale badge icon.",
54
+ // --- checkout overlay ---
55
+ // NOTE: the checkout overlay itself is configured by page-level
56
+ // cartConfigs.checkoutConfig (show_description / show_attrs /
57
+ // show_quantity / popup_position …), NOT by list-product specials.
58
+ // Only format_price passes through to the overlay from here.
21
59
  },
22
60
  seed: (el) => {
23
61
  seedPosition(el);
@@ -26,11 +64,21 @@ export const COMMERCE = [
26
64
  el.specials.format_title = "sku";
27
65
  el.specials.numerical_order = true;
28
66
  },
67
+ // Styles consumed beyond colorBtn (all exportCss.js):
68
+ // colorIconBtn, colorBtnBorder, colorTextBtn, colorBgIcon,
69
+ // colorTitle, colorPrice, colorQuantity, colorBg, colorFlashSale,
70
+ // colorTextSale, borderRadius.
29
71
  },
30
72
  {
31
73
  type: "search-list-product", category: "commerce", container: false, defaultName: "SearchListProduct",
32
- summary: "Search box + product results. Reads NO specials (all DOM-driven) and REQUIRES a co-existing list-product element on the page — openProduct() delegates to a list-product vm, so without one nothing opens.",
33
- useWhen: "Searchable catalog (pair it with a list-product element).",
74
+ summary: [
75
+ "Search box + product results overlay. Requires a co-existing list-product element on the page — openProduct() delegates to a list-product vm, so without one nothing opens.",
76
+ "CRASH: clicking search hard-crashes when product sync is absent (window.sync.products.findIndex, search-list-product.js:51) — page must have synced products.",
77
+ "Placeholder text and button label are NOT settable — they are locale-fixed by currency.",
78
+ "background + color style the SEARCH BUTTON only; borderRadius splits input-left / button-right.",
79
+ "A single search hit skips the results popup and opens the checkout overlay directly.",
80
+ ].join(" "),
81
+ useWhen: "Searchable catalog (pair it with a list-product element and ensure product sync is active).",
34
82
  keySpecials: {},
35
83
  seed: (el) => {
36
84
  seedPosition(el);
@@ -41,8 +89,14 @@ export const COMMERCE = [
41
89
  },
42
90
  {
43
91
  type: "cart-items", category: "commerce", container: false, defaultName: "CartItems",
44
- summary: "Items currently in the cart. Has NO element specials — the WCart system writes its inner HTML at runtime and it needs the cart active (is_cart_active). Item name/price/quantity font sizes come from the page-level cartConfigs.checkoutElements['CART-ITEM'] (itemNameSize/itemPriceSize/inputQuantitySize), not from this node.",
45
- useWhen: "Cart/checkout area (requires WCart active).",
92
+ summary: [
93
+ "DO NOT PLACE renders an empty string on the published page.",
94
+ "The publish renderer's type-switch has no cart-items case (index.js default ''), and render_v4 has no cart-items class.",
95
+ "The real cart-items UI is WCart's own floating drawer (div.cart_view) appended beside the cart icon (CartView.ts:172,249; floating.ts:164) — it is positioned by cart settings, not by this element.",
96
+ "Editor shows a placeholder product card; live page shows nothing (editor ≠ live).",
97
+ "The page-level cartConfigs.checkoutElements['CART-ITEM'] (itemNameSize / itemPriceSize / inputQuantitySize) styles the FLOATING DRAWER — values must be CSS strings WITH units (e.g. \"14px\"); bare numbers produce invalid CSS.",
98
+ ].join(" "),
99
+ useWhen: "Do NOT place this element. Configure cartConfigs.checkoutElements['CART-ITEM'] for drawer font sizes instead.",
46
100
  keySpecials: {},
47
101
  seed: (el) => {
48
102
  seedPosition(el);
@@ -51,11 +105,18 @@ export const COMMERCE = [
51
105
  },
52
106
  {
53
107
  type: "cart-quantity", category: "commerce", container: false, defaultName: "Cart Quantity",
54
- summary: "Quantity stepper (+/-) that controls a field in the parent variation group; publishes <id>__quantity-change on each click.",
55
- useWhen: "Per-variation quantity inside a cart/form group.",
108
+ summary: [
109
+ "Quantity stepper (+/-) for a product variation group.",
110
+ "Value always starts at 1 (hardcoded); minus clamps at 1; plus is unbounded.",
111
+ "Publishes <id>__quantity-change on each click so WCart can read input[type=number] in the variation group DOM.",
112
+ "ignoreOnHidden is DEAD CODE on live — the show/hide dispatcher only handles checkbox-group / select / radio / quantity_input; cart-quantity is not in that list, so hiding this element never suppresses its quantity contribution.",
113
+ "field_name path: the SSR input has no name attribute; quantity flows via WCart reading input[type=number] in the sprod variation group (WCart path — works with any field_name value, including the seeded cart_quantity_<id>).",
114
+ "The form quantity-LINK path (linkType / prodId|variationId) requires field_name==='quantity' AND field_type==='number' exactly — the seeded cart_quantity_<id> never satisfies that; only change field_name to 'quantity' when using the explicit form-link path, not the WCart group path.",
115
+ ].join(" "),
116
+ useWhen: "Per-variation quantity inside a WCart group.",
56
117
  keySpecials: {
57
- field_name: "REQUIRED identifies which field in the parent variation group this stepper controls.",
58
- ignoreOnHidden: "boolean — when hidden, suppress this element's quantity contribution (calls _addIgnoreField/_removeIgnoreField on the parent vm).",
118
+ field_name: "REQUIRED for the renderer. WCart group path: any unique value (seed's cart_quantity_<id> is fine). Form quantity-LINK path: must be exactly 'quantity' + field_type='number' + linkType/prodId/variationId.",
119
+ ignoreOnHidden: "boolean — DEAD CODE on live (dispatcher does not handle cart-quantity). Has no effect on quantity contribution when hidden.",
59
120
  },
60
121
  // NOT a FIELD_TYPE (field flag omitted) but the renderer still requires a
61
122
  // field_name, so the seed sets one explicitly.
@@ -77,13 +138,34 @@ export const COMMERCE = [
77
138
  },
78
139
  {
79
140
  type: "table", category: "commerce", container: false, defaultName: "Table",
80
- summary: "Data table rendered from a pre-fetched Google Sheets 2D array.",
81
- useWhen: "Pricing/comparison/spec tables (data must be pre-loaded into specials).",
141
+ summary: [
142
+ "Data table rendered server-side from specials.sourceTable (escaped HTML string).",
143
+ "The published SSR renders ONLY unescapeHTML(specials.sourceTable) — the Google Sheets branch is commented out in the publisher (index.js:1172-1226).",
144
+ "EDITOR ≠ LIVE: the editor is DATASET-driven (specials.datasetId → /api/datasets/records) and snapshots its rendered DOM back into specials.sourceTable on every save — an MCP-authored google_sheet_data-only table shows BLANK in the editor, and re-saving from the editor can WIPE sourceTable.",
145
+ "ALWAYS author specials.sourceTable (the escaped HTML string) as the primary content key.",
146
+ "dataType=1 + google_sheet_data: renders blank on first paint / no-JS / SEO (render_v4 Table class overwrites .table-wrapper client-side at table.js:16,81). A google_sheet_data with exactly 1 row renders an empty box (table.js:20-21).",
147
+ ].join(" "),
148
+ useWhen: "Pricing/comparison/spec tables. Always provide specials.sourceTable (escaped HTML); google_sheet_data alone is blank on SSR/SEO.",
82
149
  keySpecials: {
83
- dataType: "0 | 1 — MUST be 1 to render anything; the renderer returns early when dataType != 1.",
84
- source: "stringdata source label (metadata only).",
85
- sheetID: "stringGoogle Sheet document id (metadata only).",
86
- google_sheet_data: "string[][]the 2D table data; row 0 = headers as 'Title|type' where type ∈ image|video|link|time (absent type = plain text); rows 1+ are data cells.",
150
+ // --- content (primary) ---
151
+ sourceTable: "PRIMARY CONTENT escaped HTML string of the full rendered table. The SSR publisher outputs unescapeHTML(sourceTable) directly. Without this the published table is blank. Author this first; the editor will update it from its dataset on re-save.",
152
+ dataType: "0 | 1 set to 1 to enable the client-side Table renderer; 0 = no client-side render. Does not affect SSR (sourceTable is always used server-side).",
153
+ source: "string — data source label (metadata only; not rendered).",
154
+ sheetID: "string — Google Sheet document id (metadata only; not rendered on publish).",
155
+ google_sheet_data: "string[][] — 2D table data for the client-side renderer (render_v4); row 0 = headers as 'Title|type' (type ∈ image|video|link|time; absent = plain text); rows 1+ are data cells. BLANK on SSR/no-JS — always pair with sourceTable.",
156
+ // --- styling specials (all consumed by exportCss.js:2047-2106 / cssTable) ---
157
+ // These are specials, NOT styles. Values are CSS strings.
158
+ hidden_title: "truthy — hides thead on live (editor only hides when === 'none'). E.g. 'none' to hide on both.",
159
+ head_background: "CSS color string — header row background.",
160
+ head_color: "CSS color string — header row text color.",
161
+ row_even_background: "CSS color string — even data-row background.",
162
+ row_even_color: "CSS color string — even data-row text color.",
163
+ row_odd_background: "CSS color string — odd data-row background.",
164
+ row_odd_color: "CSS color string — odd data-row text color.",
165
+ padding_content: "CSS padding string — cell padding (e.g. '8px 12px').",
166
+ borderWidth: "CSS length string — table border width (e.g. '1px').",
167
+ borderColor: "CSS color string — table border color.",
168
+ borderStyle: "CSS border-style string — table border style (e.g. 'solid').",
87
169
  },
88
170
  seed: (el) => {
89
171
  seedPosition(el);
@@ -3,7 +3,7 @@ export const CONTENT = [
3
3
  {
4
4
  type: "text-block", category: "content", container: false, defaultName: "Text",
5
5
  summary: "Text. specials.text holds the content (may contain inline HTML); specials.tag sets the semantic tag. Supports template variables ({{key}}), formula mode, URL-param injection, and date formatting.",
6
- useWhen: "Any headline, paragraph, label. Use tag h1/h2 for headings, p for body. Style via responsive.styles (fontSize, color, fontWeight, textAlign). ALWAYS set color to CONTRAST the band it sits on: near-black (e.g. rgba(26,32,44,1)) on light bands, near-white ONLY on a dark/image band — white text on a light band renders invisible.",
6
+ useWhen: "Any headline, paragraph, label. Use tag h1/h2 for headings, p for body. Style via responsive.styles (fontSize, color, fontWeight, textAlign). ALWAYS set color to CONTRAST the band it sits on: near-black (e.g. rgba(26,32,44,1)) on light bands, near-white ONLY on a dark/image band — white text on a light band renders invisible. styles.background on a text-block = a GRADIENT TEXT FILL (emits -webkit-text-fill-color:transparent); you must also set styles['-webkitBackgroundClip']:'text' or the glyphs go invisible. The box background key is styles.backgroundTxt — use that for a colored box behind the text. NEVER set styles.background expecting a box fill.",
7
7
  keySpecials: {
8
8
  text: "string — the visible text; may include inline HTML (<b>, <br>, <span style>…). Also supports template variables: {{today}}, {{yesterday}}, {{tomorrow}} (formatted dates), {{coupon_text}}, {{coupon_code}}, {{coupon_codes}}, {{spin_turn_left}}, {{cart_total_price}}, {{cart_subtotal}}, {{cart_shipping_fee}}, {{cart_discount_code}}, {{voucher_price_cart}}, {{cart_item}}, {{cart_bonus_item}}, {{form_error_log}}, {{total_cart}}. Dynamic form field binding: {{formId__fieldName}} substitutes a field value from a sibling form.",
9
9
  tag: "p | h1 | h2 | h3 | h4 | h5 | h6 | span | div.",
@@ -15,6 +15,9 @@ export const CONTENT = [
15
15
  isFormat: "boolean — apply a date format to template date variables.",
16
16
  format: "string — dayjs format string (e.g. 'D/MM/YYYY') used when isFormat=true.",
17
17
  formParamSeparator: "string — separator between items when rendering {{formId__form_items}} lists.",
18
+ backgroundTxt: "(styles key) box background color rgba(...) — use this for a colored box behind the text. Do NOT use styles.background for a box fill; that key activates gradient text-fill mode.",
19
+ "-webkitBackgroundClip": "(styles key) set to 'text' when styles.background is a gradient — without it the gradient text-fill makes glyphs invisible.",
20
+ virtualHeight: "(config, per breakpoint, px) — live overrides height:auto with this value. If set it must be ≥ the rendered text height or live clips the text. Optional.",
18
21
  },
19
22
  seed: (el) => {
20
23
  seedPosition(el);
@@ -35,11 +38,17 @@ export const CONTENT = [
35
38
  },
36
39
  {
37
40
  type: "list-paragraph", category: "content", container: false, defaultName: "ListParagraph",
38
- summary: "Bulleted list. specials.text is a string of <li>…</li> items.",
39
- useWhen: "Feature checklists, benefit lists. One <li> per bullet.",
41
+ summary: "Bulleted list. specials.text is a string of <li>…</li> items. specials.text is REQUIRED — if omitted the live renderer renders the literal string 'undefined'.",
42
+ useWhen: "Feature checklists, benefit lists. One <li> per bullet. Always set iconSize and linePaddingLeft together — the live renderer defaults are iconSize=40 (very large) and linePaddingLeft=0 (text overlaps bullet). A safe starting config: iconSize:12, iconTop:5, linePaddingLeft:23.",
40
43
  keySpecials: {
41
- text: "string of <li>item</li><li>item</li>… (no <ul> wrapper). Bullet/icon styling lives in the per-breakpoint config, not specials: iconType ('shape'|'image'|'disc'|'circle'|'square'|'decimal'|'none'…), iconImage (SVG/URL), iconColor (rgba), iconFontSize, iconSize, iconTop, linePaddingLeft (text indent), linePaddingBottom (line spacing).",
42
- iconSize: "(config) bullet icon size.", linePaddingLeft: "(config) text indent.",
44
+ text: "string of <li>item</li><li>item</li>… (no <ul> wrapper). REQUIRED missing text renders the literal string 'undefined' on the live page.",
45
+ iconType: "(config, per breakpoint) 'shape' | 'image' | 'disc' | 'circle' | 'square' | 'decimal' | 'none'… — 'shape' uses an SVG mask colored by iconColor, 'image' uses a background image URL. Live default: 'shape'.",
46
+ iconImage: "(config, per breakpoint) SVG string (must start with '<svg', no leading whitespace — leading whitespace triggers a URL fetch) or image URL when iconType='image'.",
47
+ iconColor: "(config, per breakpoint) rgba(...) — bullet color when iconType='shape'. Live default: black.",
48
+ iconSize: "(config, per breakpoint) bullet icon size in px. Live default: 40 (very large) — always set this explicitly, e.g. 12.",
49
+ iconTop: "(config, per breakpoint) vertical offset for the bullet icon in px. Live default: 0. Use ~4–6 to vertically align with text.",
50
+ linePaddingLeft: "(config, per breakpoint) text indent in px. Live default: 0 (text overlaps bullet) — always set this explicitly to ≥ iconSize+4, e.g. 23.",
51
+ linePaddingBottom: "(config, per breakpoint) line spacing in px.",
43
52
  },
44
53
  seed: (el) => {
45
54
  setBox(el, 400);
@@ -50,13 +59,17 @@ export const CONTENT = [
50
59
  },
51
60
  {
52
61
  type: "image-block", category: "content", container: false, defaultName: "Image Block",
53
- summary: "Image. The editor renders the image from specials.src. config.overlay tints it.",
54
- useWhen: "Add images where a landing page would have them: hero/product shot, feature icons, about photo, logos. There is NO image API yet — set specials.src to a PLACEHOLDER URL sized to the box: https://placehold.co/<width>x<height>. NEVER leave src empty (it renders blank). The user replaces placeholders later.",
62
+ summary: "Image. The LIVE published page paints the image from styles.background (exportCss.js) — the server automatically derives this from specials.src on every expand (create/update/validate). specials.src is the EDITOR key; always set it and let the server keep both in sync. config.overlay tints the image.",
63
+ useWhen: "Add images where a landing page would have them: hero/product shot, feature icons, about photo, logos. Set specials.src to the image URL. Use https://placehold.co/WxH?text=Label if you don't have a real image. NEVER leave src empty it renders blank on the live page.",
55
64
  keySpecials: {
56
- src: "image URL — REQUIRED. Use https://placehold.co/WxH (matching width×height) if you don't have a real image.",
57
- resize: "numberimage crop behavior on resize; a value other than 300 triggers keep_solution (no-crop) mode.",
65
+ src: "image URL — REQUIRED. The editor reads this key; the server derives styles.background (the live renderer's key) automatically from it: 'center center/ cover no-repeat scroll content-box url(<src>) border-box'. If you hand-write styles.background it must contain url(...). Use https://placehold.co/WxH if no real image.",
66
+ keep_solution: "booleantrue = no-crop CDN resize (preserves aspect ratio); false = crop to box. Live reads config.keep_solution ?? specials.keep_solution. The editor's 'resize' key is editor-only sugar for this — set keep_solution directly.",
67
+ widthBgImage: "(config) CDN width for the background image crop — defaults to the element's styles.width.",
68
+ heightBgImage: "(config) CDN height for the background image crop — defaults to the element's styles.height.",
69
+ topBgImage: "(config) vertical crop offset in px — default 0.",
70
+ leftBgImage: "(config) horizontal crop offset in px — default 0.",
58
71
  enable_background_compare: "boolean — show a before/after image-comparison slider (companion config.backgroundCompare holds the second image).",
59
- overlay: "(config) overlay color rgba(...).",
72
+ overlay: "(config) overlay tint color rgba(...). Gradient border recipe: set styles.borderColor to linear-gradient/radial-gradient AND styles.borderImage to activate a gradient-border underlay.",
60
73
  },
61
74
  seed: (el) => {
62
75
  seedPosition(el);
@@ -64,39 +77,77 @@ export const CONTENT = [
64
77
  setStyle(el, "position", "absolute");
65
78
  el.specials.imageCompression = true;
66
79
  el.specials.src = imgPlaceholder(600, 400);
80
+ // Stamp styles.background from the placeholder src so the live renderer
81
+ // paints the image even before the normalization pass (seed is the baseline).
82
+ const bg = `center center/ cover no-repeat scroll content-box url(${imgPlaceholder(600, 400)}) border-box`;
83
+ el.responsive.desktop.styles.background = bg;
84
+ el.responsive.mobile.styles.background = bg;
67
85
  },
68
86
  example: {
69
87
  id: "hero_img", type: "image-block",
70
88
  responsive: {
71
- desktop: { styles: { top: 40, left: 540, width: 360, height: 300, position: "absolute" } },
72
- mobile: { styles: { top: 260, left: 60, width: 300, height: 240, position: "absolute" } },
89
+ desktop: {
90
+ styles: {
91
+ top: 40, left: 540, width: 360, height: 300, position: "absolute",
92
+ background: "center center/ cover no-repeat scroll content-box url(https://placehold.co/360x300?text=Product) border-box",
93
+ },
94
+ },
95
+ mobile: {
96
+ styles: {
97
+ top: 260, left: 60, width: 300, height: 240, position: "absolute",
98
+ background: "center center/ cover no-repeat scroll content-box url(https://placehold.co/300x240?text=Product) border-box",
99
+ },
100
+ },
73
101
  },
74
102
  specials: { src: "https://placehold.co/360x300?text=Product", imageCompression: true },
75
103
  },
76
104
  },
77
105
  {
78
106
  type: "rectangle", category: "content", container: false, defaultName: "Rectangle",
79
- summary: "Colored block — divider, badge background, color band, card backdrop.",
80
- useWhen: "Backgrounds behind text/groups, dividers, decorative shapes. Style via background/borderRadius/boxShadow.",
81
- keySpecials: {},
107
+ summary: "Colored block — divider, badge background, color band, card backdrop. Also doubles as an SVG icon/shape via per-breakpoint config.svgMask: set a raw <svg> string there and the renderer applies it as a mask; the shape color comes entirely from styles.background. Can also hold a CDN-resized image as a clipped background (styles.background with url(...)). Gradient border: set styles.borderColor to a linear/radial-gradient AND styles.borderImage — the renderer activates the gradient-border underlay. config.overlay adds a tint layer on top of a background image.",
108
+ useWhen: "Backgrounds behind text/groups, dividers, decorative shapes. Style via background/borderRadius/boxShadow. LEAF — it must NEVER have children (validation blocks it): for a card, wrap a group around it and make this the group's full-size FIRST child as the backdrop. For feature/benefit icons, USE THIS with config.svgMask + a brand-colored styles.background INSTEAD of emoji characters in text — it looks far more professional and scales cleanly on all devices.",
109
+ keySpecials: {
110
+ svgMask: "(config, per breakpoint) — raw <svg viewBox='…'>…</svg> string. The renderer base64-encodes it and applies it as -webkit-mask-image on the element; the shape color comes from styles.background (solid rgba or gradient). The SVG's own fill/stroke colors are IGNORED — only painted pixels (alpha) matter, so the SVG must paint via fill or stroke to produce a silhouette. Aspect: the published renderer force-inserts preserveAspectRatio='none' (SVG stretches to the box) but the editor preview keeps the SVG's own aspect ratio — so ALWAYS match the box aspect to the viewBox aspect (square box for a square viewBox) or editor and live render differently. Use single quotes for SVG attributes to avoid JSON-escaping noise. Set on BOTH desktop and mobile config or mobile renders a plain rectangle.",
111
+ overlay: "(config) tint layer rgba(...) drawn on top of the background image.",
112
+ },
82
113
  seed: (el) => {
83
114
  seedPosition(el);
84
115
  setBox(el, 100, 100);
85
116
  },
117
+ example: {
118
+ id: "icon_star", type: "rectangle",
119
+ responsive: {
120
+ desktop: {
121
+ styles: { top: 40, left: 456, width: 48, height: 48, background: "rgba(34,197,94,1)" },
122
+ config: { svgMask: "<svg viewBox='0 0 24 24'><path fill='black' d='M12 2l2.9 6.26L22 9.27l-5 4.87L18.18 21 12 17.27 5.82 21 7 14.14l-5-4.87 7.1-1.01z'/></svg>" },
123
+ },
124
+ mobile: {
125
+ styles: { top: 40, left: 186, width: 48, height: 48, background: "rgba(34,197,94,1)" },
126
+ config: { svgMask: "<svg viewBox='0 0 24 24'><path fill='black' d='M12 2l2.9 6.26L22 9.27l-5 4.87L18.18 21 12 17.27 5.82 21 7 14.14l-5-4.87 7.1-1.01z'/></svg>" },
127
+ },
128
+ },
129
+ specials: {},
130
+ },
86
131
  },
87
132
  {
88
133
  type: "line", category: "content", container: false, defaultName: "Line",
89
- summary: "Horizontal rule / divider line.",
90
- useWhen: "Separating content rows.",
134
+ summary: "Horizontal rule / divider line. The visible line is the TOP border only: the base CSS zeroes left/right/bottom borders. Thickness is styles.borderWidth (NOT styles.height — height is auto). The .line-css class adds padding:8px 0 so the line sits 8px below the element's top. Color via styles.borderColor; style via styles.borderStyle.",
135
+ useWhen: "Separating content rows. Always set borderWidth, borderStyle, and borderColor — without them the line renders as an invisible element.",
91
136
  keySpecials: {},
92
137
  seed: (el) => {
93
138
  setBox(el, 236);
139
+ el.responsive.desktop.styles.borderWidth = 1;
140
+ el.responsive.desktop.styles.borderStyle = "solid";
141
+ el.responsive.desktop.styles.borderColor = "rgba(208,213,221,1)";
142
+ el.responsive.mobile.styles.borderWidth = 1;
143
+ el.responsive.mobile.styles.borderStyle = "solid";
144
+ el.responsive.mobile.styles.borderColor = "rgba(208,213,221,1)";
94
145
  },
95
146
  },
96
147
  {
97
148
  type: "button", category: "content", container: false, defaultName: "Button",
98
149
  summary: "Clickable button. Label in specials.text; behavior in the events array. Supports the same template variable system as text-block ({{today}}, {{cart_total_price}}, {{formId__fieldName}}, etc.).",
99
- useWhen: "Every CTA. Add a click event: open_link (to URL), open_popup (lead modal), scroll_to (anchor). Inside a form, a button submits the form.",
150
+ useWhen: "Every CTA. Add a click event: open_link (to URL), open_popup (lead modal), scroll_to (anchor). Inside a form, a button submits the form. Hover color changes: use the modern change_color action with change_color_type:'text'|'background'|'border'|'reset' — the legacy change_background (--hover-color var) and change_text_color (--hover-text var) actions are broken on published pages because the CSS variables are never defined at publish time.",
100
151
  keySpecials: {
101
152
  text: "button label — supports template variables (same set as text-block: {{today}}, {{cart_total_price}}, {{formId__fieldName}}, etc.).",
102
153
  required: "boolean — gate by form validity.",
@@ -125,16 +176,17 @@ export const CONTENT = [
125
176
  },
126
177
  {
127
178
  type: "video", category: "content", container: false, defaultName: "Video",
128
- summary: "Video player (YouTube/Vimeo/upload/CDN). Use specials.typeVideo to select the source type.",
129
- useWhen: "Demo or promo videos. Set specials.img to a poster placeholder (https://placehold.co/640x360) when there's no real thumbnail.",
179
+ summary: "Video player (YouTube/Vimeo/upload/CDN). Required specials by typeVideo: 'youtube' specials.id REQUIRED (YouTube videoId); 'vimeo' → specials.video REQUIRED (full Vimeo URL); 'webcake' → specials.video REQUIRED (missing causes a video.replace TypeError that breaks the page); 'upload'/other → specials.video (src = video_cdn || video). Poster: first url() in styles.background takes precedence over specials.img — a flat background color (no url()) suppresses the img poster. specials.videoFit default 'cover'; false = 'contain'. showControl default-off hides Vimeo controls entirely.",
180
+ useWhen: "Demo or promo videos. Set specials.img to a poster placeholder (https://placehold.co/640x360) when there's no real thumbnail. Do NOT set a flat styles.background color — it suppresses the poster image.",
130
181
  keySpecials: {
131
- typeVideo: "'youtube' | 'vimeo' | 'webcake' | 'upload' — video source type.",
132
- video: "string — raw video URL (for upload/webcake/CDN types).",
133
- id: "string — YouTube video ID (for typeVideo='youtube').",
134
- video_cdn: "string — CDN video URL or identifier.",
135
- img: "string — poster/thumbnail image URL use a placeholder if none (https://placehold.co/640x360).",
182
+ typeVideo: "'youtube' | 'vimeo' | 'webcake' | 'upload' — video source type. REQUIRED to match the right specials keys below.",
183
+ id: "string — YouTube video ID. REQUIRED when typeVideo='youtube'. Example: 'dQw4w9WgXcQ'.",
184
+ video: "string — full video URL. REQUIRED when typeVideo='vimeo' (full Vimeo page URL, e.g. https://vimeo.com/123456) or typeVideo='webcake' (missing crashes the page with TypeError). For 'upload' / other CDN types this is the raw video file URL.",
185
+ video_cdn: "string — CDN video URL or identifier (for 'upload'/'webcake' types; renderer uses video_cdn ?? video as source).",
186
+ img: "string — poster/thumbnail image URL. Use a placeholder if none: https://placehold.co/640x360. Overridden by the first url() in styles.background — do NOT set a flat styles.background color or the poster is suppressed.",
187
+ videoFit: "string — 'cover' (default, fills box) | false (contain, shows letterbox).",
136
188
  autoReplay: "boolean — loop the video.",
137
- showControl: "boolean — show player controls.",
189
+ showControl: "boolean — show player controls (default off; when off, Vimeo controls are entirely hidden).",
138
190
  hideRelated: "boolean — hide YouTube related videos at end.",
139
191
  muteOnPlay: "boolean — mute audio when video plays.",
140
192
  autoPlay: "boolean — autoplay on load.",
@@ -148,18 +200,20 @@ export const CONTENT = [
148
200
  },
149
201
  {
150
202
  type: "gallery", category: "content", container: false, defaultName: "Gallery",
151
- summary: "Multi-image/video gallery with thumbnail strip. Content comes entirely from specials.media — this is NOT a container and has no children.",
152
- useWhen: "Photo grids/galleries with several images or videos. No image API — fill specials.media with placeholder image objects (see media). NEVER use plain URL strings — the gallery reads item.link and renders blank for a string.",
203
+ summary: "Multi-image/video gallery with thumbnail strip. Content comes entirely from specials.media — this is NOT a container and has no children. config.showThumbnail is tri-state: when UNSET the editor hides thumbnails but the live renderer shows an 80px strip overlapping the main image — always set it explicitly.",
204
+ useWhen: "Photo grids/galleries with several images or videos. No image API — fill specials.media with placeholder image objects (see media). NEVER use plain URL strings — the gallery reads item.link and renders blank for a string. Video items: use typeVideo 'youtube' or 'webcake' (NOT 'upload' — renders an empty item); set item.type:'video' so the play-overlay and zoom/lightbox fire correctly.",
153
205
  keySpecials: {
154
- media: "array of media OBJECTS (NOT plain URLs — the gallery reads item.link). Image item: {type:'image', link:'<url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} — use a https://placehold.co/WxH URL for link if no real image. Video item: {type:'image', link:'<poster-url>', linkVideo:'<video-url>', typeVideo:'youtube'|'upload', imageCompression:true}.",
206
+ media: "array of media OBJECTS (NOT plain URLs — the gallery reads item.link). Image item: {type:'image', link:'<url>', linkVideo:'', typeVideo:'youtube', imageCompression:true}. Video item: {type:'video', link:'<poster-url>', linkVideo:'<video-url>', typeVideo:'youtube'|'webcake', imageCompression:true} — item.type must be 'video' for the play-overlay and lightbox to fire; typeVideo 'upload' renders an empty item on live.",
207
+ showThumbnail: "(config) boolean — show the thumbnail strip. ALWAYS set explicitly: when unset the live renderer shows an 80px strip overlapping the main image while the editor hides it. Default when set: true (thumbnailWidth/Height:80, distanceAmong:10, position:bottom, distanceToGallery:10).",
155
208
  allowZoom: "'off' | 'carousel' | 'lightbox' — (config) zoom/lightbox mode when clicking an image.",
156
209
  showNavigation: "boolean — (config) show prev/next navigation arrows.",
157
- thumbnailAutoplay: "number (ms) | 'off' — (config) auto-advance thumbnails every N ms, or 'off' to disable.",
158
- thumbnailAutoplayRepeat: "boolean — (config) loop thumbnail autoplay.",
210
+ thumbnailAutoplay: "number (ms) | 'off' — (config) auto-advance thumbnails; delay default 3000ms. Starts only when thumbnailAutoplay != 'off' AND thumbnailAutoplayRepeat is truthy.",
211
+ thumbnailAutoplayRepeat: "boolean — (config) loop thumbnail autoplay. Required alongside thumbnailAutoplay for autoplay to start.",
159
212
  thumbnailPosition: "'top' | 'bottom' | 'left' | 'right' — (config) position of the thumbnail strip relative to the main image.",
160
- thumbnailWidth: "number — (config) width of each thumbnail in px.",
161
- thumbnailHeight: "number — (config) height of each thumbnail in px.",
162
- distanceAmong: "number — (config) gap between thumbnails in px.",
213
+ thumbnailWidth: "number — (config) width of each thumbnail in px. Default 80.",
214
+ thumbnailHeight: "number — (config) height of each thumbnail in px. Default 80.",
215
+ distanceAmong: "number — (config) gap between thumbnails in px. Default 10.",
216
+ distanceToGallery: "number — (config) gap between thumbnail strip and main image in px. Default 10.",
163
217
  },
164
218
  seed: (el) => {
165
219
  seedPosition(el);
@@ -173,14 +227,18 @@ export const CONTENT = [
173
227
  typeVideo: "youtube",
174
228
  imageCompression: true,
175
229
  }));
230
+ // Always set showThumbnail explicitly — unset causes the live renderer to
231
+ // overlay an 80px strip on the main image even though the editor hides it.
232
+ el.responsive.desktop.config = { ...el.responsive.desktop.config, showThumbnail: true };
233
+ el.responsive.mobile.config = { ...el.responsive.mobile.config, showThumbnail: true };
176
234
  // gallery has NO children — content comes entirely from specials.media (gallery.js never reads vm.children)
177
235
  },
178
236
  },
179
237
  {
180
238
  type: "html-box", category: "content", container: false, defaultName: "HTML Box",
181
- summary: "Raw HTML embed. specials.html holds the markup (rendered via v-html).",
239
+ summary: "Raw HTML embed. specials.html holds the markup stored HTML-escaped (unescaped at render via v-html). Contrast with editor-blog which stores html RAW. Embedded <iframe> is auto-stretched to 100%×100% of the box. Wrapper height is FIXED to styles.height — content taller than the box overflows. &nbsp; in the stored value becomes a space at render. Use unescape-safe HTML (e.g. '&lt;' → the literal character '<' after unescape).",
182
240
  useWhen: "Embedding third-party widgets or custom markup the standard elements can't express.",
183
- keySpecials: { html: "string — raw HTML content (the only content key; stored HTML-escaped, unescaped at render)." },
241
+ keySpecials: { html: "string — raw HTML content stored HTML-escaped (e.g. '&lt;p&gt;Hello&lt;/p&gt;'); the renderer unescapes it before injecting via v-html. The wrapper height is FIXED to styles.height — content that is taller overflows the box. An embedded <iframe> is auto-stretched to 100%×100% of the box." },
184
242
  seed: (el) => {
185
243
  seedPosition(el);
186
244
  setBox(el, 280, 310);
@@ -189,9 +247,9 @@ export const CONTENT = [
189
247
  },
190
248
  {
191
249
  type: "editor-blog", category: "content", container: false, defaultName: "Editor blog",
192
- summary: "Long-form rich text / article body. specials.html holds the rich-text markup.",
193
- useWhen: "Blog/article content blocks.",
194
- keySpecials: { html: "string — rich-text HTML content (stored HTML-escaped, unescaped at render)." },
250
+ summary: "Long-form rich text / article body. specials.html holds the rich-text markup stored RAW (NOT escaped). Contrast with html-box which stores escaped HTML. The publisher injects specials.html raw with NO unescape — storing escaped HTML causes the live page to display literal '&lt;p&gt;' text instead of rendered markup. Live wrapper height is FIXED to styles.height (the editor shows auto) — a long article overflows the band; set a generous height.",
251
+ useWhen: "Blog/article content blocks. Set styles.height to a generous value (≥ the rendered article height) — content taller than the box overflows the live page silently.",
252
+ keySpecials: { html: "string — rich-text HTML content stored RAW (not escaped). Store exactly what you want the browser to render — e.g. '<p>Hello</p>' not '&lt;p&gt;Hello&lt;/p&gt;'. Storing escaped HTML causes the live page to display literal tag strings. The live wrapper height is FIXED to styles.height — set generously." },
195
253
  seed: (el) => {
196
254
  el.responsive.mobile.styles.width = 340;
197
255
  el.responsive.mobile.styles.height = 303;