webcake-landing-mcp 1.0.1

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,372 @@
1
+ /**
2
+ * Element library: per-type AI usage hints, key specials docs, categories, and
3
+ * the global generation guide. Derived from the renderers in
4
+ * assets/render_v4/src/elements/*, the editor factory, and the event dispatcher.
5
+ * See docs/ai/page-element-schema.md for the full reference.
6
+ */
7
+ export const CANVAS = { desktopWidth: 960, mobileWidth: 420, defaultSectionHeight: 800 };
8
+ export const EVENT_TRIGGERS = ["click", "hover", "success", "unset"];
9
+ export const CLICK_ACTIONS = {
10
+ none: "Do nothing.",
11
+ open_link: "Open a URL. target = URL (often with a `target`/`blank` flag for new tab).",
12
+ open_popup: "Open a popup. target = popup element id.",
13
+ close_popup: "Close a popup. target = popup element id.",
14
+ scroll_to: "Smooth-scroll to an element. target = element/section id.",
15
+ show_section: "Show a hidden section. target = section id.",
16
+ hide_section: "Hide a section. target = section id.",
17
+ show_hide_element: "Toggle element visibility. target = element id.",
18
+ change_tab: "Switch tab. target = id.",
19
+ lightbox: "Open image in lightbox. target = image id/url.",
20
+ copy: "Copy text to clipboard. target = the text to copy.",
21
+ collapse: "Collapse/expand. target = id.",
22
+ set_field_value: "Set a form field value. target = field_name, plus set_value.",
23
+ back_to: "Go back. target = url/none.",
24
+ back_home: "Go to home.",
25
+ share: "Share. target = url / social network.",
26
+ play_audio: "Play audio. target = id.",
27
+ stop_audio: "Stop audio. target = id.",
28
+ open_cart: "Open cart.",
29
+ add_to_cart: "Add product to cart. target = product id.",
30
+ open_app: "Open chat/app. target = provider: botcake | whatsapp | mess_prefill | tiktok_prefill | line_prefill.",
31
+ change_color: "Change color.",
32
+ custom_js: "Run custom JS.",
33
+ };
34
+ export const HOVER_ACTIONS = {
35
+ change_color: "Change color on hover.",
36
+ change_background: "Change background on hover.",
37
+ change_text_color: "Change text color on hover.",
38
+ change_underline: "Underline on hover.",
39
+ change_overline: "Overline on hover.",
40
+ change_image: "Swap image on hover.",
41
+ animation_hover: "Play a hover animation.",
42
+ show_hide_element: "Reveal/hide a target element on hover.",
43
+ };
44
+ export const LIBRARY = {
45
+ // ---------------- layout / containers ----------------
46
+ section: {
47
+ type: "section", category: "layout", container: true,
48
+ summary: "Top-level vertical canvas block. The page is an array of sections stacked top→bottom.",
49
+ useWhen: "Always the outermost wrapper of any band of content (hero, features, pricing, footer…). One section per visual band.",
50
+ keySpecials: {
51
+ globalSection: "boolean — mark as a reusable global section (e.g. shared header/footer).",
52
+ globalSectionName: "name of the global section when globalSection=true.",
53
+ custom_class: "extra css class; 'fixed'/'footer' influence role detection.",
54
+ imageCompression: "boolean — compress background images.",
55
+ video_background_thumbnail: "thumbnail for a video background.",
56
+ },
57
+ },
58
+ "dynamic_page": {
59
+ type: "dynamic_page", category: "layout", container: true,
60
+ summary: "Section bound to a dataset for dynamic/templated content (blog, product detail).",
61
+ useWhen: "Building a page driven by a dataset record rather than static content.",
62
+ keySpecials: { imageCompression: "boolean." },
63
+ },
64
+ group: {
65
+ type: "group", category: "layout", container: true,
66
+ summary: "Groups children so they move and position together (position:absolute).",
67
+ useWhen: "Bundling an icon+text card, a badge cluster, or any reusable mini-layout you want to move as one.",
68
+ keySpecials: {},
69
+ },
70
+ grid: {
71
+ type: "grid", category: "layout", container: true,
72
+ summary: "Grid layout; config.column / config.row; children are grid-item.",
73
+ useWhen: "Repeating cards in a regular N-column grid (features, team, gallery of cards).",
74
+ keySpecials: { column: "(config) number of columns.", row: "(config) number of rows." },
75
+ },
76
+ "grid-item": {
77
+ type: "grid-item", category: "layout", container: true,
78
+ summary: "A single cell inside a grid (movable:false; laid out by the grid).",
79
+ useWhen: "Only as a direct child of grid.",
80
+ keySpecials: {},
81
+ },
82
+ carousel: {
83
+ type: "carousel", category: "layout", container: true,
84
+ summary: "Horizontal slider; config.slideWidth; children are slide.",
85
+ useWhen: "Testimonials, screenshots, or any swipeable set of slides.",
86
+ keySpecials: { slideWidth: "(config) width of each slide in px." },
87
+ },
88
+ slide: {
89
+ type: "slide", category: "layout", container: true,
90
+ summary: "One slide inside a carousel (movable:false).",
91
+ useWhen: "Only as a direct child of carousel.",
92
+ keySpecials: {},
93
+ },
94
+ popup: {
95
+ type: "popup", category: "layout", container: true,
96
+ summary: "Overlay popup. Hidden by default; opened/closed by events targeting its id.",
97
+ useWhen: "Thank-you dialog, lead form modal, promo. Place popups at the top level of `page` and trigger via a button's open_popup event.",
98
+ keySpecials: {},
99
+ example: {
100
+ id: "popthanks", type: "popup",
101
+ properties: { name: "Thank you", movable: false, sync: true },
102
+ responsive: {
103
+ desktop: { config: {}, styles: { width: 420, height: 220, background: "rgba(255,255,255,1)", borderRadius: "12px" } },
104
+ mobile: { config: {}, styles: { width: 360, height: 220, background: "rgba(255,255,255,1)", borderRadius: "12px" } },
105
+ },
106
+ specials: {}, runtime: {}, events: [],
107
+ children: [
108
+ { id: "popclose", type: "button",
109
+ properties: { name: "Close", movable: true, sync: true },
110
+ responsive: {
111
+ desktop: { config: {}, styles: { top: 150, left: 160, width: 100, height: 40, background: "rgba(76,175,80,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center" } },
112
+ mobile: { config: {}, styles: { top: 150, left: 130, width: 100, height: 40, background: "rgba(76,175,80,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center" } },
113
+ },
114
+ specials: { text: "Đóng" }, runtime: {},
115
+ events: [{ id: "ev1", type: "click", action: "close_popup", target: "popthanks" }] },
116
+ ],
117
+ },
118
+ },
119
+ // ---------------- content ----------------
120
+ "text-block": {
121
+ type: "text-block", category: "content", container: false,
122
+ summary: "Text. specials.text holds the content (may contain inline HTML); specials.tag sets the semantic tag.",
123
+ useWhen: "Any headline, paragraph, label. Use tag h1/h2 for headings, p for body. Style via responsive.styles (fontSize, color, fontWeight, textAlign).",
124
+ keySpecials: {
125
+ text: "string — the visible text; may include inline HTML (<b>, <br>, <span style>…).",
126
+ tag: "p | h1 | h2 | h3 | h4 | h5 | h6 | span | div.",
127
+ },
128
+ example: {
129
+ id: "headline1", type: "text-block",
130
+ properties: { name: "Headline", movable: true, sync: true },
131
+ responsive: {
132
+ desktop: { config: {}, styles: { top: 80, left: 180, width: 600, fontSize: 44, fontWeight: "bold", color: "rgba(255,255,255,1)", textAlign: "center" } },
133
+ mobile: { config: {}, styles: { top: 60, left: 20, width: 380, fontSize: 28, fontWeight: "bold", color: "rgba(255,255,255,1)", textAlign: "center" } },
134
+ },
135
+ specials: { text: "Bán hàng dễ hơn với Webcake", tag: "h1" },
136
+ runtime: {}, events: [],
137
+ },
138
+ },
139
+ "list-paragraph": {
140
+ type: "list-paragraph", category: "content", container: false,
141
+ summary: "Bulleted list. specials.text is a string of <li>…</li> items.",
142
+ useWhen: "Feature checklists, benefit lists. One <li> per bullet.",
143
+ keySpecials: {
144
+ text: "string of <li>item</li><li>item</li>… (no <ul> wrapper).",
145
+ iconSize: "(config) bullet icon size.", linePaddingLeft: "(config) text indent.",
146
+ },
147
+ },
148
+ "image-block": {
149
+ type: "image-block", category: "content", container: false,
150
+ summary: "Image. The editor renders the image from specials.src. config.overlay tints it.",
151
+ 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> (or https://picsum.photos/<w>/<h> for a photo). NEVER leave src empty (it renders blank). The user replaces placeholders later.",
152
+ keySpecials: { src: "image URL — REQUIRED. Use https://placehold.co/WxH (matching width×height) if you don't have a real image.", resize: "resize mode.", overlay: "(config) overlay color rgba(...)." },
153
+ example: {
154
+ id: "hero_img", type: "image-block",
155
+ properties: { name: "Image Block", movable: true, sync: true },
156
+ responsive: {
157
+ desktop: { config: {}, styles: { top: 40, left: 540, width: 360, height: 300, position: "absolute" } },
158
+ mobile: { config: {}, styles: { top: 260, left: 60, width: 300, height: 240, position: "absolute" } },
159
+ },
160
+ specials: { src: "https://placehold.co/360x300?text=Product", imageCompression: true },
161
+ runtime: {}, events: [],
162
+ },
163
+ },
164
+ rectangle: {
165
+ type: "rectangle", category: "content", container: false,
166
+ summary: "Colored block — divider, badge background, color band, card backdrop.",
167
+ useWhen: "Backgrounds behind text/groups, dividers, decorative shapes. Style via background/borderRadius/boxShadow.",
168
+ keySpecials: {},
169
+ },
170
+ line: {
171
+ type: "line", category: "content", container: false,
172
+ summary: "Horizontal rule / divider line.",
173
+ useWhen: "Separating content rows.",
174
+ keySpecials: {},
175
+ },
176
+ button: {
177
+ type: "button", category: "content", container: false,
178
+ summary: "Clickable button. Label in specials.text; behavior in the events array.",
179
+ 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.",
180
+ keySpecials: {
181
+ text: "button label.", required: "boolean — gate by form validity.",
182
+ format: "value formatting.", connectedSurvey: "link to a survey element.",
183
+ },
184
+ example: {
185
+ id: "cta_main", type: "button",
186
+ properties: { name: "CTA", movable: true, sync: true },
187
+ responsive: {
188
+ desktop: { config: {}, styles: { top: 300, left: 405, width: 150, height: 44, background: "rgba(246,4,87,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center", fontWeight: "bold" } },
189
+ mobile: { config: {}, styles: { top: 200, left: 135, width: 150, height: 44, background: "rgba(246,4,87,1)", color: "rgba(255,255,255,1)", borderRadius: "8px", textAlign: "center", fontWeight: "bold" } },
190
+ },
191
+ specials: { text: "Đăng ký ngay" }, runtime: {},
192
+ events: [{ id: "ev_cta", type: "click", action: "scroll_to", target: "form_section" }],
193
+ },
194
+ },
195
+ video: {
196
+ type: "video", category: "content", container: false,
197
+ summary: "Video player (YouTube/upload/etc).",
198
+ useWhen: "Demo or promo videos. Set specials.img to a poster placeholder (https://placehold.co/640x360) when there's no real thumbnail.",
199
+ keySpecials: { typeVideo: "youtube | upload | vimeo…", video_cdn: "video URL/id.", img: "poster/thumbnail — use a placeholder url if none.", autoReplay: "boolean — loop." },
200
+ },
201
+ gallery: {
202
+ type: "gallery", category: "content", container: true,
203
+ summary: "Multi-image gallery.",
204
+ useWhen: "Photo grids/galleries with several images. No image API — fill specials.media with placeholder URLs (https://placehold.co/600x400).",
205
+ keySpecials: { media: "array of image URLs (use placeholders if you have no real images)." },
206
+ },
207
+ "html-box": {
208
+ type: "html-box", category: "content", container: false,
209
+ summary: "Raw HTML embed.",
210
+ useWhen: "Embedding third-party widgets or custom markup the standard elements can't express.",
211
+ keySpecials: {},
212
+ },
213
+ "editor-blog": {
214
+ type: "editor-blog", category: "content", container: false,
215
+ summary: "Long-form rich text / article body.",
216
+ useWhen: "Blog/article content blocks.",
217
+ keySpecials: {},
218
+ },
219
+ // ---------------- form & inputs ----------------
220
+ form: {
221
+ type: "form", category: "form", container: true,
222
+ summary: "Wraps inputs; on submit creates a lead/FormData. Pixel tracking configured here.",
223
+ useWhen: "Any lead-capture / contact / registration form. Put input/textarea/select/button inside its children.",
224
+ keySpecials: {
225
+ field_type: "form field config.", form_type: "form behavior type.",
226
+ submit_success: "post-submit action/message.", validate: "validation config.",
227
+ fb_event_type: "Facebook pixel event (e.g. CompleteRegistration).",
228
+ fb_conversion_value: "FB conversion value.", fb_tracking_currency: "currency (VND…).",
229
+ tiktok_conversion_value: "TikTok conversion value.", tiktok_tracking_currency: "currency.",
230
+ },
231
+ },
232
+ input: {
233
+ type: "input", category: "form", container: false,
234
+ summary: "Single-line input. specials.field_name is the submitted data column (REQUIRED & unique).",
235
+ useWhen: "Name/email/phone fields. Set field_type to text/email/phone/number.",
236
+ keySpecials: {
237
+ field_name: "REQUIRED unique data key.", field_placeholder: "placeholder text.",
238
+ field_type: "text | email | phone | number.", required: "boolean.", formula: "computed value.",
239
+ },
240
+ example: {
241
+ id: "in_phone", type: "input",
242
+ properties: { name: "Input", movable: true, sync: true },
243
+ responsive: {
244
+ desktop: { config: {}, styles: { top: 60, left: 20, width: 360, height: 40 } },
245
+ mobile: { config: {}, styles: { top: 60, left: 20, width: 360, height: 40 } },
246
+ },
247
+ specials: { field_name: "phone", field_placeholder: "Số điện thoại", field_type: "phone", required: true },
248
+ runtime: {}, events: [],
249
+ },
250
+ },
251
+ textarea: { type: "textarea", category: "form", container: false, summary: "Multi-line input.", useWhen: "Messages, notes.", keySpecials: { field_name: "REQUIRED unique key.", field_placeholder: "placeholder." } },
252
+ select: { type: "select", category: "form", container: false, summary: "Dropdown select.", useWhen: "Pick one from a list.", keySpecials: { field_name: "REQUIRED.", options: "array of options." } },
253
+ checkbox: { type: "checkbox", category: "form", container: false, summary: "Single checkbox (consent, opt-in).", useWhen: "Agree-to-terms, single toggle.", keySpecials: { field_name: "REQUIRED." } },
254
+ "checkbox-group": { type: "checkbox-group", category: "form", container: true, summary: "Multiple checkboxes.", useWhen: "Multi-select options.", keySpecials: { field_name: "REQUIRED.", options: "array." } },
255
+ radio: { type: "radio", category: "form", container: true, summary: "Single-choice radio options.", useWhen: "Pick exactly one of a few.", keySpecials: { field_name: "REQUIRED.", options: "array." } },
256
+ address: { type: "address", category: "form", container: false, summary: "Province/District/Ward selector (multi-country).", useWhen: "Shipping/contact address.", keySpecials: { field_name: "REQUIRED.", detectAddress: "auto-detect.", hidden_commune: "hide ward level." } },
257
+ "country-select": { type: "country-select", category: "form", container: false, summary: "Country picker.", useWhen: "International forms.", keySpecials: { field_name: "REQUIRED." } },
258
+ "quantity_input": { type: "quantity_input", category: "form", container: false, summary: "Quantity stepper (+/-).", useWhen: "Order quantity.", keySpecials: { field_name: "REQUIRED." } },
259
+ "input-datetime": { type: "input-datetime", category: "form", container: false, summary: "Date/time picker.", useWhen: "Booking date, appointment.", keySpecials: { field_name: "REQUIRED." } },
260
+ "input-file": { type: "input-file", category: "form", container: false, summary: "File upload.", useWhen: "CV/receipt/photo upload.", keySpecials: { field_name: "REQUIRED." } },
261
+ signature: { type: "signature", category: "form", container: false, summary: "Hand-drawn signature pad.", useWhen: "Consent/contracts.", keySpecials: { field_name: "REQUIRED." } },
262
+ "verify-code": { type: "verify-code", category: "form", container: false, summary: "OTP / verification code field.", useWhen: "Phone/email verification.", keySpecials: { field_name: "REQUIRED." } },
263
+ "group-select": { type: "group-select", category: "form", container: true, summary: "Attribute/variant selector group (e.g. size+color+quantity).", useWhen: "Product variants with quantity.", keySpecials: {} },
264
+ "group-select-item": { type: "group-select-item", category: "form", container: false, summary: "One attribute inside group-select.", useWhen: "Child of group-select only.", keySpecials: { field_placeholder: "label.", field_quantity: "boolean — is the quantity field.", options: "choices." } },
265
+ // ---------------- commerce ----------------
266
+ "list-product": { type: "list-product", category: "commerce", container: false, summary: "Product list bound to a dataset/store.", useWhen: "Show purchasable products.", keySpecials: { format_title: "title format (e.g. 'sku').", numerical_order: "boolean.", remain_quantity_text: "low-stock label." } },
267
+ "search-list-product": { type: "search-list-product", category: "commerce", container: false, summary: "Search box + product list.", useWhen: "Searchable catalog.", keySpecials: {} },
268
+ "cart-items": { type: "cart-items", category: "commerce", container: false, summary: "Items currently in the cart.", useWhen: "Cart/checkout area.", keySpecials: {} },
269
+ "cart-quantity": { type: "cart-quantity", category: "commerce", container: false, summary: "Total cart quantity badge.", useWhen: "Cart icon counter.", keySpecials: {} },
270
+ "product-select": { type: "product-select", category: "commerce", container: false, summary: "Product / variant selector.", useWhen: "Choose product before order.", keySpecials: {} },
271
+ table: { type: "table", category: "commerce", container: false, summary: "Data table.", useWhen: "Pricing/comparison/spec tables.", keySpecials: {} },
272
+ // ---------------- marketing / dynamic ----------------
273
+ countdown: {
274
+ type: "countdown", category: "marketing", container: false,
275
+ summary: "Countdown timer (minute duration, fixed end time, or daily window).",
276
+ useWhen: "Urgency: limited offer, flash sale. Renders a fixed FOUR-slot flex row (day·hour·minute·second); each visible segment is sized 1/4 of the width regardless of how many show, so HIDING a segment (showDay/showSecond:false) leaves an empty gap on the right (the row is left-aligned, no built-in re-centering). Keep all four (showDay+showSecond:true) so the row fills evenly, and CENTER the whole box on the canvas: left = round((canvas - width)/2).",
277
+ keySpecials: {
278
+ type: "minute | endTime | daily.", duration: "minutes (when type=minute).",
279
+ startTime: "ISO start (endTime/daily).", endTime: "ISO end.",
280
+ showDay: "bool.", showSecond: "bool.", showText: "bool.", language: "label locale.", customTranslation: "custom labels.",
281
+ },
282
+ },
283
+ timegroup: { type: "timegroup", category: "marketing", container: false, summary: "Live current date/time display.", useWhen: "Show today's date/time.", keySpecials: {} },
284
+ "auto-number": { type: "auto-number", category: "marketing", container: false, summary: "Auto-incrementing number (e.g. fake view count).", useWhen: "Social-proof counters.", keySpecials: {} },
285
+ "random-number": { type: "random-number", category: "marketing", container: false, summary: "Random number display.", useWhen: "Randomized social proof.", keySpecials: {} },
286
+ notify: { type: "notify", category: "marketing", container: false, summary: "'Someone just bought…' toast notifications.", useWhen: "Social-proof popups.", keySpecials: {} },
287
+ "spin-wheel": { type: "spin-wheel", category: "marketing", container: false, summary: "Lucky-spin wheel with prizes.", useWhen: "Gamified lead capture / promos.", keySpecials: {} },
288
+ survey: {
289
+ type: "survey", category: "marketing", container: false,
290
+ summary: "Survey / image-choice question; each option submits a field.",
291
+ useWhen: "Quizzes, preference capture, image pickers.",
292
+ keySpecials: {
293
+ options: "array of {id,image,title,value,field_name}.", type: "text-image | text…",
294
+ multiOption: "boolean — allow multiple.", selectedBackground: "selected bg color.", selectedBorder: "selected border color.",
295
+ },
296
+ },
297
+ "alertMessage": { type: "alertMessage", category: "marketing", container: false, summary: "Alert / announcement banner.", useWhen: "Top-of-page notices.", keySpecials: {} },
298
+ };
299
+ export const GENERATION_GUIDE = `You are generating the JSON source of a Webcake landing page that the editor renders directly.
300
+
301
+ OUTPUT (top-level page source — matches the real editor shape)
302
+ - Return ONE JSON object:
303
+ { "page": [<section>...], "popup": [<popup>...], "settings": {...},
304
+ "options": { "currency":"VND", "mobileOnly":false, "versionID":null }, "cartConfigs": {} }
305
+ - "page" is an array of SECTIONS stacked vertically (index 0 = top). Each item MUST be type "section" (or "dynamic_page").
306
+ - "popup" is a SEPARATE top-level array of popup elements — do NOT nest popups inside "page". A button opens one via a click event { action:"open_popup", target:"<popup id>" }.
307
+ - All other elements (text, image, button, form…) live inside a section's "children".
308
+ - "settings" carries SEO + page config: title, description, keywords, favicon, fontGeneral, width_section {desktop:960,mobile:420}, country, fb_tracking_code, tiktok_script, extra_css, extra_script (call new_page_skeleton for a ready default).
309
+
310
+ ELEMENT NODE (every element)
311
+ { "id": "<unique ~8-char [A-Za-z0-9_]>", "type": "<type>",
312
+ "properties": { "name": "<label>", "movable": <bool>, "sync": true },
313
+ "responsive": { "desktop": { "config": {}, "styles": {} }, "mobile": { "config": {}, "styles": {} } },
314
+ "specials": { ...type-specific CONTENT... }, "runtime": {}, "events": [],
315
+ "children": [ ... ] } // children ONLY on container types
316
+
317
+ COORDINATE SYSTEM (critical)
318
+ - Absolute-positioning canvas (NOT flexbox). Children carry top/left/width/height in px (numbers).
319
+ - section has NO top/left; it has height (canvas height, default ${CANVAS.defaultSectionHeight}) and position:"relative".
320
+ - Canvas width is FIXED: desktop = ${CANVAS.desktopWidth}px, mobile = ${CANVAS.mobileWidth}px (settings.width_section). Provide BOTH breakpoints; do not overlap elements within a section.
321
+ - Every child must stay on-canvas: 0 ≤ left and left + width ≤ canvas width (${CANVAS.desktopWidth} desktop / ${CANVAS.mobileWidth} mobile). Same for top + height ≤ section height.
322
+
323
+ CENTERING & ALIGNMENT (do the math — do NOT eyeball \`left\`; off-center layouts are the #1 defect)
324
+ - \`textAlign:"center"\` only centers text INSIDE the element box. It does NOT move the box. To center the box on the canvas you MUST compute \`left\`.
325
+ - Center ONE element of width w: left = round((CANVAS - w) / 2).
326
+ desktop: left = round((${CANVAS.desktopWidth} - w) / 2) · mobile: left = round((${CANVAS.mobileWidth} - w) / 2).
327
+ e.g. a 300px box → desktop left = ${(CANVAS.desktopWidth - 300) / 2}, mobile left = ${Math.round((CANVAS.mobileWidth - 300) / 2)}.
328
+ - Full-width text/headline: pick a content width and center it. A safe content column is desktop width 800 (left 80) / mobile width 380 (left 20), with textAlign:"center".
329
+ - A ROW of N equal items (feature cards, countdown, logos, stats) — center the whole row as a block:
330
+ rowWidth = N*itemWidth + (N-1)*gap
331
+ startLeft = round((CANVAS - rowWidth) / 2)
332
+ item i (0-based) left = startLeft + i*(itemWidth + gap) ← gives equal outer margins and equal gaps.
333
+ Pick itemWidth+gap so rowWidth ≤ CANVAS. On mobile, either shrink items to fit ${CANVAS.mobileWidth}px or stack them vertically (same left, increasing top).
334
+ - Keep a consistent left edge for stacked content in a section (e.g. all centered on the same axis) so the section reads as aligned, not ragged.
335
+ - Mirror the centering on BOTH breakpoints with each breakpoint's own canvas width — never reuse a desktop \`left\` on mobile.
336
+
337
+ RULES
338
+ - Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
339
+ - Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px).
340
+ - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). There is NO image API yet, so set image-block specials.src to a PLACEHOLDER URL sized to the box: "https://placehold.co/<width>x<height>" (or "https://picsum.photos/<w>/<h>" for a photo). NEVER leave src empty — it renders blank and the page looks broken. gallery.media = array of such URLs; video.specials.img = a poster placeholder. The user replaces these later.
341
+ - CONTRAST: text must contrast with the section background (dark text on light sections, light text on dark sections). Don't put light-gray text on white or faint text on a dark background.
342
+ - movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
343
+ - Every form input MUST have a unique specials.field_name.
344
+ - events item: { "id", "type":"click"|"hover"|"success"|"unset", "action", "target", "appTarget":"", "hoverColor":"" }. For element-targeting actions (open_popup, close_popup, scroll_to, show_section, hide_section, show_hide_element) target = the target element's id; for open_link target = URL; for copy target = the text; target may be null (e.g. animation_hover).
345
+ - ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }. Keep "none" unless an entrance animation is wanted.
346
+ - Do NOT invent prices, phone numbers, addresses, or statistics. Output text in the requested language.
347
+
348
+ INTAKE — ask the user BEFORE generating (don't assume; ask 3–6 short, concrete questions, offer sensible defaults):
349
+ - Goal / page type: lead-gen, product/COD sale, event, invitation, app promo, portfolio…?
350
+ - Brand: name, what they sell, tone (premium/playful/minimal), language (vi/en…).
351
+ - Sections wanted (in order): e.g. hero, features, pricing, testimonials, FAQ, contact form, footer.
352
+ - Primary CTA + where it goes: open a form popup, scroll to form, call/Zalo, open link?
353
+ - Form fields to capture (if any): name, phone, email, address, quantity…? (use canonical field_names: full_name, phone_number, email, address, quantity).
354
+ - Branding details: primary color (rgba/hex), logo/image URLs, must-keep text, things to avoid.
355
+ - Target: desktop+mobile or mobile-only? Which organization to save into (list_organizations)?
356
+ Confirm a short outline (sections + CTA) with the user before building the full JSON.
357
+ NEVER invent prices, phone numbers, addresses, or statistics — ask or leave placeholders the user can fill.
358
+
359
+ WORKFLOW (recommended)
360
+ 0. INTAKE: ask the questions above, confirm the section outline.
361
+ 1. Call get_generation_guide (this) once, then new_page_skeleton for the top-level shape.
362
+ 2. For each element type you'll use, call get_element to learn its specials & see an example.
363
+ 3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
364
+ 4. Assemble { page, popup, settings, options, cartConfigs }.
365
+ 5. Call validate_page and fix every error.
366
+ 6. To save: call list_organizations, show the orgs to the user and ask which to use (default to is_default). Then create_page (dry_run first, then dry_run:false with the chosen organization_id).
367
+
368
+ EDITING an existing page
369
+ - list_pages → let the user pick (or take a page_id from a URL).
370
+ - get_page(page_id) → you get the live { page, popup, settings, ... }. Edit it surgically: change only the elements the user asked for (text/styles/specials/events); keep every other element, its id, and coordinates intact. Never regenerate the whole tree for a small change.
371
+ - To add an element: build it with new_element, give it a unique id, set top/left/width/height inside the right section's children.
372
+ - validate_page → update_page(page_id, source) (dry_run first, then dry_run:false).`;