webcake-landing-mcp 1.0.74 → 1.0.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -172,7 +172,7 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
172
172
  |-------|---------------|
173
173
  | **[Connect your IDE / claude.ai](docs/connect-mcp.md)** | Step-by-step connection for every client (npx & hosted URL), troubleshooting table. |
174
174
  | **[Configuration](docs/configuration.md)** | Env vars, `--env` presets, browser `login`, per-request headers, getting a JWT. |
175
- | **[Tools reference](docs/tools.md)** | All 20 tools in detail + the step-by-step workflow + model notes. |
175
+ | **[Tools reference](docs/tools.md)** | All 21 tools in detail + the step-by-step workflow + model notes. |
176
176
  | **[Usage examples](docs/usage-examples.md)** | Three end-to-end walkthroughs: build from a brief, surgical edit, inspect a type. |
177
177
  | **[Manual / advanced install](docs/manual-install.md)** | Shell installers, cloned builds, hand-written per-IDE config. |
178
178
  | **[Page-element schema](docs/page-element-schema.md)** | The full element-model reference (+ [every special/event](docs/element-specials-reference.md)). |
@@ -181,13 +181,13 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
181
181
 
182
182
  ## 🧰 The tools at a glance
183
183
 
184
- 20 tools in five groups — full descriptions in **[docs/tools.md](docs/tools.md)**:
184
+ 21 tools in five groups — full descriptions in **[docs/tools.md](docs/tools.md)**:
185
185
 
186
186
  | Group | Tools | Needs |
187
187
  |-------|-------|-------|
188
188
  | **Reference** | `get_generation_guide` · `list_elements` · `get_element` · `get_page_schema` | nothing |
189
189
  | **Generation** | `new_element` · `new_page_skeleton` · `validate_page` | nothing |
190
- | **Media** | `search_images` (real Pexels stock photos) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) | nothing |
190
+ | **Media** | `search_images` (real Pexels stock photos) · `get_icon_svg` (Material Symbols / Font Awesome icon names → inline SVG via Iconify) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) | nothing |
191
191
  | **Ingest** | `ingest_html` · `ingest_url` (recreate an existing page) | nothing |
192
192
  | **Persistence** | `list_organizations` · `create_page` · `list_pages` · `find_pages` · `get_page` · `update_page` · `add_section` · `patch_page` · `publish_page` | `WEBCAKE_API_BASE` + `WEBCAKE_JWT` |
193
193
 
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.76",
4
+ "d": "15/06/2026",
5
+ "type": "Added",
6
+ "en": "validate_page now warns when specials.custom_css sets layout or structural CSS properties (position, top, left, right, bottom, inset, width, height,…",
7
+ "vi": "validate_page nay cảnh báo khi specials.custom_css đặt các thuộc tính CSS layout hoặc cấu trúc (position, top, left, right, bottom, inset, width,…"
8
+ },
9
+ {
10
+ "v": "1.0.75",
11
+ "d": "15/06/2026",
12
+ "type": "Added",
13
+ "en": "New get_icon_svg tool resolves Material Symbols (ms:<name>) and Font Awesome (fa:<name>) icon-font references to real inline SVG markup via the…",
14
+ "vi": "Tool mới get_icon_svg resolve tên icon-font Material Symbols (ms:<name>) và Font Awesome (fa:<name>) thành SVG inline thực sự qua Iconify API công…"
15
+ },
2
16
  {
3
17
  "v": "1.0.74",
4
18
  "d": "13/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "ingest_html and ingest_url now automatically convert absolute-canvas builder exports (LadiPage-family / Webcake-published HTML) into a ready-to-save…",
28
42
  "vi": "ingest_html và ingest_url nay tự động chuyển đổi các bản export từ builder absolute-canvas (LadiPage-family / Webcake-published HTML) thành source…"
29
- },
30
- {
31
- "v": "1.0.70",
32
- "d": "13/06/2026",
33
- "type": "Changed",
34
- "en": "upload_images default changed from dry_run:true to dry_run:false — the tool now uploads images and returns the hosted URL map on every call without…",
35
- "vi": "Mặc định của upload_images được đổi từ dry_run:true thành dry_run:false — tool này nay upload ảnh và trả về bản đồ URL trong mọi lần gọi mà không…"
36
- },
37
- {
38
- "v": "1.0.69",
39
- "d": "12/06/2026",
40
- "type": "Changed",
41
- "en": "upload_images dry-run response now returns an action_required field (replacing the previous soft hint) that explicitly blocks the model from…",
42
- "vi": "Phản hồi dry-run của upload_images nay trả về trường action_required (thay thế hint mềm trước đó) để chặn model lắp ráp trang hoặc dùng placeholder…"
43
43
  }
44
44
  ]
@@ -15,7 +15,7 @@ OUTPUT (top-level page source — matches the real editor shape)
15
15
  - "page" is an array of SECTIONS stacked vertically (index 0 = top). Each item MUST be type "section" (or "dynamic_page").
16
16
  - "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>" }.
17
17
  - All other elements (text, image, button, form…) live inside a section's "children".
18
- - "settings" carries SEO + page config: title, description, keywords, robots, canonical, favicon, fontGeneral, width_section {desktop:960,mobile:420}, country, currency, fb_tracking_code, tiktok_script, extra_css, extra_script, bhet (head code), bbet (body-end code) (call new_page_skeleton for a ready default).
18
+ - "settings" carries SEO + page config: title, description, keywords, robots, canonical, favicon, fontGeneral, width_section {desktop:960|1200, mobile:420|360 — the canvas width CHOICE; see the canvas-width rule below}, country, currency, fb_tracking_code, tiktok_script, extra_css, extra_script, bhet (head code), bbet (body-end code) (call new_page_skeleton for a ready default).
19
19
 
20
20
  ELEMENT NODE (every element)
21
21
  { "id": "<unique ~8-char [A-Za-z0-9_]>", "type": "<type>",
@@ -34,7 +34,7 @@ FULL ELEMENT CATALOG (all ${ELEMENT_TYPES.length} types — the complete menu, n
34
34
  COORDINATE SYSTEM (critical)
35
35
  - Absolute-positioning canvas (NOT flexbox). Children carry top/left/width/height in px (numbers).
36
36
  - section has NO top/left; it has height (canvas height, default ${CANVAS.defaultSectionHeight}) and position:"relative".
37
- - 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.
37
+ - Canvas width is a per-breakpoint CHOICE you set in settings.width_section — desktop ${CANVAS.desktopWidthOptions.join(" or ")}px, mobile ${CANVAS.mobileWidthOptions.join(" or ")}px (these are the ONLY allowed values; editor default ${CANVAS.desktopWidth}/${CANVAS.mobileWidth}). PICK the width to fit the design, then place EVERY element's top/left/width in THAT coordinate space (changing width does NOT rescale elements — coords are absolute in the chosen width): use desktop 1200 for wide, multi-column, or editorial layouts and ALWAYS when cloning a reference drawn wider than 960 (e.g. a Google Stitch screen at ~1280 — clone at 1200 and map its coords by ×1200/sourceWidth, so 2-/3-column bands aren't crushed into 960 leaving big empty gaps); use 960 for simple, single-column, text-led pages. Mobile: 360 to match a ~360–390 design, else 420. Set settings.width_section.desktop/mobile to your choice up front. Provide BOTH breakpoints; do not overlap elements within a section.
38
38
  - 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.
39
39
 
40
40
  CENTERING & ALIGNMENT (do the math — do NOT eyeball \`left\`; off-center layouts are the #1 defect)
@@ -120,7 +120,8 @@ RULES
120
120
  - Every form input MUST have a unique specials.field_name.
121
121
  - events item: { "id", "type", "action", "target", ...action-specific extra fields }. TRIGGER (type): click & hover on any element; success & error on a FORM (success = after a successful submit, error = on validation failure); delay on any element (when it scrolls into view); unset on init. Action vocab per trigger: click→CLICK_ACTIONS, hover→HOVER_ACTIONS, success→SUCCESS_ACTIONS, error→ERROR_ACTIONS, delay→DELAY_ACTIONS (all returned by get_generation_guide). For element-targeting actions (open_popup, close_popup, scroll_to, show_section, hide_section, show_hide_element, change_tab, collapse) target = the target element's id; open_link/download_file target = URL; open_sms/send_email/phone_call target = phone/email; copy target = text (or element id when copyType='elementValue'); set_field_value target = field_name; target may be null (e.g. animation_hover). Each action also reads extra fields (e.g. open_link→targetURL/delayTime, scroll_to→scrollMore, change_tab→moveTo/tabIndex, lightbox→typeLightbox/alt, show_hide_element→onlyMode, open_app→appTarget+provider fields, set_field_value→set_value) — see the action maps for the full list.
122
122
  - ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }. Animations only run on these 9 element types: group, image-block, text-block, rectangle, button, countdown, line, list-paragraph, notify (renderer contract: landing_page_build/render/build/animate.js). Any other type with a non-"none" name renders stuck/dim in its pre-animation state — keep "none" on all other types. The name must be from the editor's animate.css set; common entrance families: fadeIn* (fadeInUp, fadeInDown, fadeInLeft, fadeInRight…), slideIn* (slideInUp, slideInDown, slideInLeft, slideInRight), zoomIn* (zoomIn, zoomInUp, zoomInDown…), bounceIn* (bounceIn, bounceInUp…), backIn* (backInDown, backInLeft…), flipIn* (flipInX, flipInY), lightSpeedIn* (lightSpeedInLeft, lightSpeedInRight), rotateIn* (rotateIn, rotateInDownLeft…), rollIn, jackInTheBox; attention seekers: bounce, pulse, tada, headShake, wobble, jello, heartBeat, rubberBand, shakeX, shakeY. The full set is enforced by validate_page — use an invalid name and the element renders stuck. NEVER set styles.opacity < 1 for a "subtle" or "muted" look — opacity is permanent and renders the element and all its content faded forever; use rgba() alpha on the color or background property instead.
123
- - BEYOND ELEMENT CAPABILITY (custom CSS / class / JS — use these so the page is as COMPLETE as possible instead of dropping an effect the built-in specials can't express): the renderer supports real escape hatches — REACH FOR THEM when a reference (esp. a Google Stitch / Tailwind page) has hover/transition effects, gradients, glassmorphism, custom shadows, gradient/clipped text, sticky tricks, or keyframe animations outside the 9-type entrance set. Tools, in order of preference: (1) element specials.custom_css — extra CSS DECLARATIONS injected INTO that element's own rule "#w-<id>{ … }" (declarations only — NO selector, NO :hover, NO @keyframes). Requires specials.customAdvance:true (without it the renderer drops it). Use for: "background:linear-gradient(...)" (gradient button/hero — also reproduces the AST's gradients), "box-shadow:0 20px 40px rgba(0,0,0,.08)", "backdrop-filter:blur(20px)" (glass header — pair with an rgba background), "transition:transform .3s ease", "-webkit-background-clip:text;-webkit-text-fill-color:transparent" (gradient text). (2) element specials.custom_class (comma-separated, also needs customAdvance:true) — adds class names to "#w-<id>" so settings.extra_css can target a group of elements. (3) page settings.extra_css — a FULL stylesheet injected raw into <head>: this is where SELECTORS live — :hover, :focus, @keyframes, media queries. The DOM id of any element is "#w-<its id>". THIS is how you implement the AST's per-section hover_effects: scale → "#w-<id>{transition:transform .3s} #w-<id>:hover{transform:scale(1.05)}"; card lift → "#w-<id>:hover{transform:translateY(-8px)}"; image-zoom in a card → "#w-<cardId>{overflow:hidden} #w-<imgId>{transition:transform .5s} #w-<cardId>:hover #w-<imgId>{transform:scale(1.1)}"; color/underline on hover are also fine as a hover EVENT (change_color / change_underline) — pick whichever, but DON'T silently drop the effect. (4) page settings.extra_script — raw JS injected before </body> for behavior the event vocab can't express. (5) page settings.bhet ('before </head>') and settings.bbet ('before </body>') — raw HTML BLOCKS (not just CSS/JS): bhet for <link> webfonts/stylesheets, <style>, <meta>, verification tags, analytics/pixels (GTM, FB); bbet for chat widgets, deferred <script>, GTM <noscript>, third-party embeds. Use these when you need a real font file, a tag/pixel, or an external widget — extra_css/extra_script are for raw CSS/JS only, bhet/bbet take arbitrary HTML (valid markup only). RULES: custom_css is declarations-only (validate_page warns if you put a selector there) and needs customAdvance:true (validate_page warns if missing); extra_css/extra_script are raw and unscoped, so keep selectors specific to "#w-<id>" / your custom_class to avoid bleeding into the rest of the page; don't use these to replace a capability a built-in special already has (e.g. entrance animation → config.animation; click behavior → events).
123
+ - BEYOND ELEMENT CAPABILITY (custom CSS / class / JS — use these so the page is as COMPLETE as possible instead of dropping an effect the built-in specials can't express): the renderer supports real escape hatches — REACH FOR THEM when a reference (esp. a Google Stitch / Tailwind page) has hover/transition effects, gradients, glassmorphism, custom shadows, gradient/clipped text, sticky tricks, or keyframe animations outside the 9-type entrance set. Tools, in order of preference: (1) element specials.custom_css — extra CSS DECLARATIONS injected INTO that element's own rule "#w-<id>{ … }" (declarations only — NO selector, NO :hover, NO @keyframes). Requires specials.customAdvance:true (without it the renderer drops it). Use for: "background:linear-gradient(...)" (gradient button/hero — also reproduces the AST's gradients), "box-shadow:0 20px 40px rgba(0,0,0,.08)", "backdrop-filter:blur(20px)" (glass header — pair with an rgba background), "transition:transform .3s ease", "-webkit-background-clip:text;-webkit-text-fill-color:transparent" (gradient text). (2) element specials.custom_class (comma-separated, also needs customAdvance:true) — adds class names to "#w-<id>" so settings.extra_css can target a group of elements. (3) page settings.extra_css — a FULL stylesheet injected raw into <head>: this is where SELECTORS live — :hover, :focus, @keyframes, media queries. The DOM id of any element is "#w-<its id>". THIS is how you implement the AST's per-section hover_effects: scale → "#w-<id>{transition:transform .3s} #w-<id>:hover{transform:scale(1.05)}"; card lift → "#w-<id>:hover{transform:translateY(-8px)}"; image-zoom in a card → "#w-<cardId>{overflow:hidden} #w-<imgId>{transition:transform .5s} #w-<cardId>:hover #w-<imgId>{transform:scale(1.1)}"; color/underline on hover are also fine as a hover EVENT (change_color / change_underline) — pick whichever, but DON'T silently drop the effect. (4) page settings.extra_script — raw JS injected before </body> for behavior the event vocab can't express. (5) page settings.bhet ('before </head>') and settings.bbet ('before </body>') — raw HTML BLOCKS (not just CSS/JS): bhet for <link> webfonts/stylesheets, <style>, <meta>, verification tags, analytics/pixels (GTM, FB); bbet for chat widgets, deferred <script>, GTM <noscript>, third-party embeds. Use these when you need a real font file, a tag/pixel, or an external widget — extra_css/extra_script are for raw CSS/JS only, bhet/bbet take arbitrary HTML (valid markup only). SAFETY custom is RAW and global, so sloppy custom is the #1 way to BREAK the UI; obey these or the page shatters (validate_page warns on each): (a) SCOPE every settings.extra_css rule to a specific element — "#w-<id>" or a specials.custom_class you added. NEVER write a bare-tag (body, div, section, p, img, a, button, ul, li, h1…), universal "*", or Webcake-internal-class (.section-container, .section-wrapper, .pageview, .rectangle-css, .text-block-css, .image-block-css, .button-css, .group-*, .gallery-*, .overlay, .full-width/.full-height…) selector — those restyle the WHOLE page and wreck the layout. (b) custom_css is VISUAL props ONLY (background, box-shadow, border, border-radius, filter, backdrop-filter, transition, transform, -webkit-background-clip) and declarations-only (no selector / :hover / @keyframes) and needs customAdvance:true — NEVER put position / top / left / right / bottom / inset / width / height / display / float / flex / grid in custom_css (they override the element box and break the absolute canvas; set geometry via responsive.<bp>.styles). (c) bhet/bbet must be VALID, fully-CLOSED HTML (an unclosed <script>/<style>/<div> swallows the rest of the page); put CSS in extra_css and JS in extra_script (bhet/bbet only for real HTML like <link>/<meta>/widgets), and any <style> inside them follows rule (a). (d) extra_script: wrap in try/catch, run on DOMContentLoaded, and NEVER remove/restructure "#w-…" elements (the renderer owns them) only attach behavior. (e) balance your braces. (f) don't use custom to replace a capability a built-in special already has (entrance animation → config.animation; click/hover behavior → events; size/position → styles).
124
+ - ICONS (icon-font glyphs — don't drop them, and DON'T blind-svg-mask them): references (esp. Google Stitch) render icons with an icon-font CLASS, not images or inline SVG — Material Symbols (<span class="material-symbols-outlined">verified</span>) or Font Awesome (<i class="fa-solid fa-check">). ingest gives only the NAME as block.icon "ms:<name>" / "fa:<name>" (e.g. "ms:support_agent"). render it as Webcake's NATIVE icon element — a RECTANGLE with an svg mask: STEP 1 — call get_icon_svg with those refs to get each REAL <svg> (Material Symbols outlined / Font Awesome) via Iconify (NEVER invent an SVG from a name). STEP 2 — make a rectangle and put that svg in BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background = the icon color, and use a SQUARE box (width === height). Why each rule: the svg is only a MASK (its own fill is IGNORED) — visible pixels come ENTIRELY from styles.background, so WITHOUT a solid background the icon is BLANK (that's the "svg in a rectangle doesn't show" bug); the renderer reads each breakpoint's config separately (no fallback), so the mask must be in BOTH; and it forces preserveAspectRatio='none', so a non-square box stretches the icon. validate_page warns if background / viewBox / both breakpoints are missing. If get_icon_svg can't resolve a ref, fall back to an emoji inline in a sentence or skip that slot — NEVER leave a feature card iconless.
124
125
  - Real data the page DISPLAYS must come from the user — never invent it: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, exact stats/social-proof numbers. If a value the page needs is missing, ASK for it (in intake, or pause before generating); use a clearly-labelled placeholder ONLY when the user explicitly declines, and tell them exactly what to fill. Write ALL page copy in the SAME language the user is chatting in (mirror it), with FULL, CORRECT diacritics/accents — for Vietnamese this means proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025"), NEVER accent-stripped "không dấu" text. Do not romanize, transliterate, or drop accent marks from any language.
125
126
 
126
127
  INTAKE — act as a DESIGN CONSULTANT, not a form. Goal: understand what the customer actually wants, then design as close to their intent as possible. Ask BEFORE generating, EVERY time (even a "quick"/"test" page).
@@ -39,7 +39,7 @@ MODEL (essentials):
39
39
  - Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
40
40
  - IMAGES: include them (hero/product, feature icons, about photo). The server AUTO-HOSTS external image URLs on every save: any real http(s) image URL you put in specials.src / a url(...) background / gallery item.link / video poster is downloaded and re-hosted to the Webcake CDN automatically by create_page/update_page/add_section/patch_page (the result's rehost field reports how many) — so you do NOT have to pre-call upload_images for reference or web images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL → put the REAL source URL straight into specials.src and let the save host it (use those exact images, never swap them for stock photos; the original URL must reach the save un-altered — NEVER replace a real source image URL with a placeholder). The ONE case you MUST still call upload_images yourself: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images urls — the save can't read local files; NEVER upload a user's local file to a third-party host like catbox or imgur first), then use the returned URL. (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images); (3) if search_images returns ok:false / is unreachable / has no fitting photo → find a real image YOURSELF using whatever web search/fetch capability you have (brand site, product page, free-to-use source) and re-host it via upload_images; (4) a PLACEHOLDER sized to the box (https://placehold.co/<width>x<height>) is the LAST resort, only after (2) AND (3) both failed. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
41
41
 
42
- - BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes; validate_page warns if custom_css lacks customAdvance or holds a selector.
42
+ - BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes. SAFETY (custom is raw + global → sloppy custom BREAKS the whole UI): SCOPE every extra_css rule to "#w-<id>" or a specials.custom_class — NEVER a bare tag / "*" / a Webcake-internal class (.section-container/.rectangle-css/.text-block-css/.group-*…); keep custom_css to VISUAL props only (no position/top/left/width/height/display/float); close every bhet/bbet tag; don't restructure "#w-…" elements in extra_script. validate_page flags unscoped selectors, layout props in custom_css, unbalanced braces/tags, missing customAdvance, and CSS/JS in the wrong field — fix every such warning.
43
43
  - PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
44
44
 
45
- Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
45
+ Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, get_icon_svg, upload_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page, publish_page.`;
@@ -46,8 +46,11 @@
46
46
  "fontGeneral": { "type": "string", "description": "Default page font-family, e.g. \"'Roboto', sans-serif\"." },
47
47
  "width_section": {
48
48
  "type": "object",
49
- "description": "Canvas reference widths.",
50
- "properties": { "desktop": { "type": "number" }, "mobile": { "type": "number" } }
49
+ "description": "The canvas width per breakpoint (px) — the coordinate space every element's top/left/width lives in. Editor-allowed values ONLY: desktop 960 or 1200, mobile 420 or 360 (default 960/420). Choose 1200 for wide/multi-column/editorial layouts or when cloning a reference wider than 960 (e.g. Google Stitch ~1280) so columns aren't squished; 360 mobile to match a ~360–390 design. Changing it does NOT rescale existing coords.",
50
+ "properties": {
51
+ "desktop": { "type": "number", "enum": [960, 1200], "description": "960 or 1200." },
52
+ "mobile": { "type": "number", "enum": [420, 360], "description": "420 or 360." }
53
+ }
51
54
  },
52
55
  "country": { "type": "string", "description": "Dialing/locale code, e.g. \"84\"." },
53
56
  "currency": { "type": "string", "description": "Page currency (the editor's canonical home for currency).", "examples": ["VND", "USD"] },
@@ -315,6 +315,17 @@ export function validatePage(input) {
315
315
  if (typeof sp.custom_css === "string" && /[{}]|@keyframes|:hover|:focus|::/.test(sp.custom_css)) {
316
316
  warnings.push(`${path} (${type}): specials.custom_css is injected as plain declarations inside #w-${node.id}{…} — a selector/:hover/@keyframes/media-query there breaks the rule. Keep declarations only here (e.g. "box-shadow:0 20px 40px rgba(0,0,0,.08);backdrop-filter:blur(20px);"); put hover/keyframes/media rules in settings.extra_css targeting #w-${node.id} (or a specials.custom_class).`);
317
317
  }
318
+ // Layout/structural props in custom_css fight the absolute-canvas system
319
+ // (the renderer sets the element's box + display) → the element jumps or the
320
+ // page breaks. Geometry belongs in responsive.<bp>.styles, not custom_css.
321
+ if (typeof sp.custom_css === "string") {
322
+ const badProps = (sp.custom_css.match(/(?:^|[;{]\s*)(position|top|left|right|bottom|inset|width|height|display|float|flex|grid)\s*:/gi) ?? [])
323
+ .map((m) => m.replace(/[;{:\s]/g, ""))
324
+ .filter((p, i, a) => a.indexOf(p) === i);
325
+ if (badProps.length) {
326
+ warnings.push(`${path} (${type}): specials.custom_css sets layout prop(s) ${badProps.join(", ")} — these override the element's box/display and break the absolute-canvas layout. Set size/position via responsive.<bp>.styles (top/left/width/height per breakpoint); keep custom_css to VISUAL props only (background, box-shadow, border, filter, backdrop-filter, transition, transform).`);
327
+ }
328
+ }
318
329
  }
319
330
  }
320
331
  // animation contract — checked per breakpoint
@@ -419,8 +430,12 @@ export function validatePage(input) {
419
430
  // variation selector-16 (FE0F), keycap (20E3), whitespace
420
431
  /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|[\u200D\uFE0F\u20E3\s])+$/u.test(visible);
421
432
  if (onlyEmoji) {
422
- warnings.push(`${path} (text-block): specials.text is only the emoji "${visible}" — keyboard emoji as standalone icons look unprofessional and render inconsistently across devices. Use a rectangle with per-breakpoint config.svgMask (raw <svg> string) + styles.background set to the brand accent color instead (see the rectangle element's example). Emoji are fine inline within sentences, never as card icons.`);
433
+ warnings.push(`${path} (text-block): specials.text is only the emoji "${visible}" — keyboard emoji as standalone icons look unprofessional and render inconsistently across devices. Use a real icon instead: call get_icon_svg to fetch the <svg>, then a rectangle with that svg in per-breakpoint config.svgMask + styles.background set to the accent color (the native Webcake icon). Emoji are fine inline within sentences, never as card icons.`);
423
434
  }
435
+ // An icon-font text-block (Material Symbols / Font Awesome span) is a
436
+ // SINGLE glyph, not wrapping text — the ligature name ("verified") is not
437
+ // displayed, so the text-metrics estimate is meaningless here. Skip it.
438
+ const isIconGlyph = /\b(material-symbols|material-icons)\b/.test(rawText) || /<i\b[^>]*\bfa-/.test(rawText);
424
439
  // Wrapped-text overflow: live text height is AUTO — text that wraps to
425
440
  // more lines than the declared box spills DOWN and overlaps the element
426
441
  // below (the classic "2-line card title over the card body" defect).
@@ -429,6 +444,8 @@ export function validatePage(input) {
429
444
  // 1-line-tall box — slip through, while body text (16px → 22px line)
430
445
  // keeps its old tolerance against the rough estimate.
431
446
  for (const bp of ["desktop", "mobile"]) {
447
+ if (isIconGlyph)
448
+ break;
432
449
  const styles = node.responsive?.[bp]?.styles;
433
450
  const w = num(styles?.width);
434
451
  const h = num(styles?.height);
@@ -854,7 +871,9 @@ export function validatePage(input) {
854
871
  return;
855
872
  const cpath = `${path}.children[${idx}]`;
856
873
  const rawText = child.type === "text-block" ? child.specials?.text : undefined;
857
- if (typeof rawText === "string") {
874
+ // Icon-font glyph (Material Symbols / Font Awesome) — one glyph, not wrapping text.
875
+ const isIconGlyph = typeof rawText === "string" && (/\b(material-symbols|material-icons)\b/.test(rawText) || /<i\b[^>]*\bfa-/.test(rawText));
876
+ if (typeof rawText === "string" && !isIconGlyph) {
858
877
  for (const bp of ["desktop", "mobile"]) {
859
878
  if (overlapWarnings >= MAX_OVERLAP_WARNINGS)
860
879
  break;
@@ -1067,6 +1086,70 @@ export function validatePage(input) {
1067
1086
  const ms = p?.responsive?.mobile?.styles ?? {};
1068
1087
  checkBounds(p, num(ds.width) ?? rootCanvasD, num(ds.height) ?? DEFAULT_SECTION_HEIGHT, num(ms.width) ?? rootCanvasM, num(ms.height) ?? DEFAULT_SECTION_HEIGHT, `popup[${i}]`);
1069
1088
  });
1089
+ // ── custom-code safety: settings.extra_css / extra_script / bhet / bbet ──────
1090
+ // These inject RAW into the page (extra_css in <head>, extra_script before
1091
+ // </body>, bhet at end of <head>, bbet at end of <body>) — unscoped/broken
1092
+ // custom code is the #1 way "custom breaks the UI". Flag the dangerous shapes.
1093
+ {
1094
+ // Webcake's own runtime class names — restyling them globally breaks the layout.
1095
+ const INTERNAL_CLASS_RE = /\.(section-container|section-wrapper|pageview|rectangle-css|text-block-css|image-block-css|button-css|group-[\w-]*|gallery-[\w-]*|popup-[\w-]*|overlay|lazy|full-mask-size|mask-position|full-(?:width|height)|ladi-[\w-]*)\b/;
1096
+ // Bare element selectors that, unscoped, restyle the WHOLE page.
1097
+ const BARE_TAG_SEL_RE = /(?:^|[,{}>+~\s])(html|body|section|article|header|footer|nav|main|aside|div|span|p|a|button|ul|ol|li|img|table|tr|td|input|form|h[1-6])\s*(?:[,{>+~:.[]|$)/i;
1098
+ /** Selectors in a raw stylesheet not scoped to #w-… that would restyle the whole page (broad tags, *, Webcake internals). */
1099
+ const broadCssSelectors = (css) => {
1100
+ const out = new Set();
1101
+ const ruleRe = /([^{}]+)\{[^{}]*\}/g;
1102
+ let m;
1103
+ while ((m = ruleRe.exec(css)) !== null && out.size < 5) {
1104
+ for (const selRaw of m[1].split(",")) {
1105
+ const sel = selRaw.trim();
1106
+ if (!sel || sel.startsWith("@") || sel.includes("#w-"))
1107
+ continue; // scoped/at-rule → safe
1108
+ if (/\*/.test(sel) || BARE_TAG_SEL_RE.test(sel) || INTERNAL_CLASS_RE.test(sel))
1109
+ out.add(sel.slice(0, 60));
1110
+ }
1111
+ }
1112
+ return [...out];
1113
+ };
1114
+ const settings = page?.settings;
1115
+ if (settings && typeof settings === "object") {
1116
+ const extraCss = settings.extra_css;
1117
+ if (typeof extraCss === "string" && extraCss.trim()) {
1118
+ const opens = (extraCss.match(/{/g) ?? []).length;
1119
+ const closes = (extraCss.match(/}/g) ?? []).length;
1120
+ if (opens !== closes) {
1121
+ warnings.push(`settings.extra_css has unbalanced braces (${opens} '{' vs ${closes} '}') — a malformed rule leaks into the rest of the page CSS and breaks the layout. Fix the braces.`);
1122
+ }
1123
+ const broad = broadCssSelectors(extraCss);
1124
+ if (broad.length) {
1125
+ warnings.push(`settings.extra_css has UNSCOPED selector(s) that restyle the whole page and break the UI: ${broad.join(" | ")}. Scope EVERY rule to a specific element — #w-<element id> (or a specials.custom_class you added) — never bare tags, '*', or Webcake's own classes (.section-container / .rectangle-css / .text-block-css / .group-* / …).`);
1126
+ }
1127
+ }
1128
+ for (const field of ["bhet", "bbet"]) {
1129
+ const v = settings[field];
1130
+ if (typeof v !== "string" || !v.trim())
1131
+ continue;
1132
+ if (!v.includes("<")) {
1133
+ warnings.push(`settings.${field} contains no HTML tags — it is injected as raw HTML (${field === "bhet" ? "end of <head>" : "end of <body>"}), not CSS/JS. Put CSS in settings.extra_css and JS in settings.extra_script, or wrap this in <style>…</style> / <script>…</script>.`);
1134
+ }
1135
+ for (const sb of v.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) ?? []) {
1136
+ const broad = broadCssSelectors(sb.replace(/<\/?style[^>]*>/gi, ""));
1137
+ if (broad.length) {
1138
+ warnings.push(`settings.${field} has a <style> with UNSCOPED selector(s) that break the page UI: ${broad.join(" | ")}. Scope every rule to #w-<id> / a custom_class, or move page-wide CSS into settings.extra_css with scoped selectors.`);
1139
+ break;
1140
+ }
1141
+ }
1142
+ for (const tag of ["script", "style", "div"]) {
1143
+ const o = (v.match(new RegExp(`<${tag}\\b`, "gi")) ?? []).length;
1144
+ const c = (v.match(new RegExp(`</${tag}>`, "gi")) ?? []).length;
1145
+ if (o !== c) {
1146
+ warnings.push(`settings.${field} has an unbalanced <${tag}> tag (${o} open vs ${c} close) — an unclosed tag swallows the rest of the page and breaks rendering. Close every tag.`);
1147
+ break;
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+ }
1070
1153
  return {
1071
1154
  valid: errors.length === 0,
1072
1155
  errors,
@@ -4,7 +4,20 @@
4
4
  * fields the render_v4 dispatcher reads beyond { id, type, action, target }.
5
5
  * Derived from assets/render_v4/event/index.js.
6
6
  */
7
- export const CANVAS = { desktopWidth: 960, mobileWidth: 420, defaultSectionHeight: 800 };
7
+ // Canvas width is a per-breakpoint CHOICE (settings.width_section), NOT a single
8
+ // fixed value — the editor's Page Configuration offers desktop 960|1200 and
9
+ // mobile 420|360. desktopWidth/mobileWidth are the DEFAULTS (the editor default);
10
+ // desktopWidthOptions/mobileWidthOptions are the full allowed sets. Pick 1200 for
11
+ // wide/multi-column/editorial layouts and when cloning a reference wider than 960
12
+ // (e.g. Google Stitch ~1280) so columns aren't squished; pick 360 mobile to
13
+ // match a ~360–390 mobile design. Source: landing_page_backend Page Configuration.
14
+ export const CANVAS = {
15
+ desktopWidth: 960,
16
+ mobileWidth: 420,
17
+ defaultSectionHeight: 800,
18
+ desktopWidthOptions: [960, 1200],
19
+ mobileWidthOptions: [420, 360],
20
+ };
8
21
  /**
9
22
  * Element types the runtime animator handles.
10
23
  * Source: landing_page_build/render/build/animate.js — the switch statement
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Icon resolver: turn an icon-font NAME (Material Symbols / Font Awesome, the form
3
+ * ingest surfaces as block.icon "ms:<name>" / "fa:<name>") into a real inline SVG,
4
+ * via the public Iconify API (https://iconify.design — unifies both icon sets).
5
+ *
6
+ * This is what lets a clone reproduce a Stitch icon FAITHFULLY and SELF-CONTAINED:
7
+ * the SVG is embedded straight into a text-block (fill="currentColor", colored by
8
+ * the element's styles.color) — no webfont to load, no svg-mask background trap.
9
+ *
10
+ * Pure network helper (no Webcake creds). Mirrors the search_images pattern.
11
+ */
12
+ const ICONIFY_BASE = "https://api.iconify.design";
13
+ const ICON_FETCH_TIMEOUT_MS = 8_000;
14
+ /** Normalize a Material/FA icon name to the Iconify token form (underscores → hyphens, lowercased, trimmed). */
15
+ function normName(name) {
16
+ return name.trim().toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "");
17
+ }
18
+ /**
19
+ * Ordered Iconify "prefix/name" candidates for an icon reference. Tries the most
20
+ * faithful variant first, then sensible fallbacks:
21
+ * - ms:NAME → material-symbols/NAME-outline (the "-outlined" class look), then the filled base
22
+ * - fa:NAME / fas: → fa6-solid, fa-solid
23
+ * - far: / fab: → regular / brands variants
24
+ * - prefix:NAME → used as-is (already an Iconify id)
25
+ * - NAME (bare) → assumed Material Symbols
26
+ */
27
+ export function iconifyCandidates(ref) {
28
+ const raw = ref.trim();
29
+ // Already an Iconify id "prefix:name" (but NOT our ms:/fa: shorthands).
30
+ const m = /^([a-z0-9]+(?:-[a-z0-9]+)*):(.+)$/i.exec(raw);
31
+ const kind = m ? m[1].toLowerCase() : "";
32
+ const rest = m ? normName(m[2]) : normName(raw);
33
+ if (!rest)
34
+ return [];
35
+ if (kind === "ms" || kind === "material-symbols" || kind === "msr") {
36
+ return [`material-symbols/${rest}-outline`, `material-symbols/${rest}`];
37
+ }
38
+ if (kind === "fa" || kind === "fas" || kind === "fa-solid" || kind === "fa6-solid") {
39
+ return [`fa6-solid/${rest}`, `fa-solid/${rest}`, `fa6-regular/${rest}`, `fa6-brands/${rest}`];
40
+ }
41
+ if (kind === "far" || kind === "fa-regular")
42
+ return [`fa6-regular/${rest}`, `fa-regular/${rest}`];
43
+ if (kind === "fab" || kind === "fa-brands")
44
+ return [`fa6-brands/${rest}`, `fa-brands/${rest}`];
45
+ // A real Iconify prefix (e.g. mdi:home, lucide:check) → use verbatim, then bare-as-material.
46
+ if (kind)
47
+ return [`${kind}/${rest}`];
48
+ // Bare name → assume Material Symbols (the Stitch default).
49
+ return [`material-symbols/${rest}-outline`, `material-symbols/${rest}`];
50
+ }
51
+ /** Fetch one Iconify "prefix/name" → SVG string, or null when it 404s / isn't an SVG. */
52
+ async function fetchOne(prefixName) {
53
+ let res;
54
+ try {
55
+ res = await fetch(`${ICONIFY_BASE}/${prefixName}.svg`, { signal: AbortSignal.timeout(ICON_FETCH_TIMEOUT_MS) });
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ if (!res.ok)
61
+ return null;
62
+ const body = (await res.text()).trim();
63
+ // Unknown icons return a non-SVG body ("404"/"Not found"); only accept real SVG markup.
64
+ return body.startsWith("<svg") ? body : null;
65
+ }
66
+ /**
67
+ * Resolve an icon reference ("ms:verified" / "fa:chart-line" / "mdi:home" / a bare
68
+ * name) to an inline SVG. The SVG keeps fill="currentColor" so the embedding
69
+ * element's styles.color decides the icon color.
70
+ */
71
+ export async function resolveIconSvg(ref) {
72
+ const candidates = iconifyCandidates(ref);
73
+ if (!candidates.length)
74
+ return { ok: false, error: `Empty/invalid icon ref: "${ref}"` };
75
+ for (const c of candidates) {
76
+ const svg = await fetchOne(c);
77
+ if (svg)
78
+ return { ok: true, svg, iconify: c };
79
+ }
80
+ return { ok: false, error: `No icon found for "${ref}" (tried: ${candidates.join(", ")})` };
81
+ }
@@ -49,12 +49,38 @@ function isPricingSection(el) {
49
49
  // Currency symbol + per-period pattern anywhere in the element.
50
50
  return /[$€£¥₫]\s*\d/.test(text) && /\/(month|mo\b|year|yr\b|annual)/i.test(text);
51
51
  }
52
+ /** A top navigation bar without a <header> tag: <nav>, or a sticky/fixed top bar (no heading) with a logo + links. Stitch desktops use <nav class="fixed top-0 …">. A BOTTOM-pinned bar (mobile sticky action/nav bar, <nav class="fixed bottom-0 …">) is NOT the header. */
53
+ function looksLikeTopBar(el) {
54
+ const cls = (el.getAttribute("class") ?? "").toLowerCase();
55
+ if (/\bbottom-0\b|bottom:\s*0/.test(cls))
56
+ return false; // bottom action/nav bar, not the page header
57
+ const tag = el.tagName?.toLowerCase();
58
+ if (tag === "nav")
59
+ return true;
60
+ const pinnedTop = /\b(fixed|sticky)\b/.test(cls) && /\btop-0\b|top:\s*0/.test(cls);
61
+ if (!pinnedTop)
62
+ return false;
63
+ if (el.querySelector(HEADING_TAGS.join(",")))
64
+ return false; // a pinned hero/banner, not a nav bar
65
+ return !!el.querySelector("nav") || el.querySelectorAll("a").length >= 2;
66
+ }
67
+ /** A repeating grid of ≥2 card-like siblings that each carry a title (heading/strong) or an icon — a feature/benefit grid. Catches 2-column Stitch grids that the ≥3 count misses. */
68
+ function looksLikeCardGrid(el) {
69
+ const found = findRepeatingContainer(el);
70
+ if (!found || found.items.length < 2)
71
+ return false;
72
+ const titled = found.items.filter((it) => it.querySelector(HEADING_TAGS.join(",")) || it.querySelector("strong, b") || detectIconFont(it)).length;
73
+ return titled >= 2;
74
+ }
52
75
  export function classifySection(el, detail) {
53
76
  const tag = el.tagName?.toLowerCase();
54
77
  if (tag === "header")
55
78
  return classifyHeader(el, detail);
56
79
  if (tag === "footer")
57
80
  return classifyFooter(el, detail);
81
+ // A top nav bar without a <header> tag (Stitch uses <nav class="fixed top-0…">) is the header.
82
+ if (looksLikeTopBar(el))
83
+ return classifyHeader(el, detail);
58
84
  const form = el.querySelector("form");
59
85
  if (form)
60
86
  return classifyForm(el, form, detail);
@@ -82,7 +108,10 @@ export function classifySection(el, detail) {
82
108
  }
83
109
  return sec;
84
110
  }
85
- if (countFeatureBlocks(el) >= 3) {
111
+ const headingTag = heading?.tagName?.toLowerCase();
112
+ // features: the classic ≥3 count OR a ≥2-card repeating grid (Stitch 2-column
113
+ // benefit grids) — but never steal an h1 section (that's the hero).
114
+ if (countFeatureBlocks(el) >= 3 || (headingTag !== "h1" && looksLikeCardGrid(el))) {
86
115
  const sec = { role: "features", heading: elText(heading), subheading, ctas: ctas.length ? ctas : undefined };
87
116
  if (detail === "full") {
88
117
  const blocks = detectBlocks(el);
@@ -94,7 +123,7 @@ export function classifySection(el, detail) {
94
123
  }
95
124
  return sec;
96
125
  }
97
- if (heading?.tagName?.toLowerCase() === "h1" && (imgSrcs.length > 0 || ctas.length > 0)) {
126
+ if (headingTag === "h1" && (imgSrcs.length > 0 || ctas.length > 0)) {
98
127
  const sec = {
99
128
  role: "hero",
100
129
  heading: elText(heading),
@@ -105,8 +134,35 @@ export function classifySection(el, detail) {
105
134
  };
106
135
  return sec;
107
136
  }
108
- if (ctas.length > 0 && paragraphs.length <= 1) {
109
- return { role: "cta", heading: elText(heading), subheading, ctas };
137
+ // CTA band: a heading + call-to-action with no card grid and no imagery — allow
138
+ // a couple of supporting paragraphs (a closing "ready to start?" band), not just one.
139
+ if (ctas.length > 0 && imgSrcs.length === 0 && paragraphs.length <= 3) {
140
+ return {
141
+ role: "cta",
142
+ heading: elText(heading),
143
+ subheading,
144
+ paragraphs: paragraphs.length ? paragraphs.slice(0, detail === "full" ? 2 : 1) : undefined,
145
+ ctas,
146
+ };
147
+ }
148
+ // Content/about band: a heading with real prose (and maybe an image) — a far more
149
+ // useful role than "unknown" for the rebuild. Reserve "unknown" for sections with
150
+ // no heading AND no paragraphs.
151
+ if (elText(heading) && paragraphs.length >= 1) {
152
+ const sec = {
153
+ role: "about",
154
+ heading: elText(heading),
155
+ subheading,
156
+ paragraphs: paragraphs.slice(0, detail === "full" ? 6 : 3),
157
+ images: detail === "full" ? images.slice(0, 4) : images.slice(0, 4),
158
+ ctas: ctas.length ? ctas : undefined,
159
+ };
160
+ if (detail === "full") {
161
+ const blocks = detectBlocks(el);
162
+ if (blocks.length)
163
+ sec.blocks = blocks;
164
+ }
165
+ return sec;
110
166
  }
111
167
  const sec = {
112
168
  role: "unknown",
@@ -330,31 +386,69 @@ function findRepeatingContainer(root, maxDepth = 4) {
330
386
  * text is ≤4 chars (emoji/badge) — surfaced separately so the model can map
331
387
  * it to a Webcake svg-mask rectangle.
332
388
  */
389
+ // Font-Awesome style classes that are NOT the icon name (fa-solid, fa-2x, …).
390
+ const FA_STYLE_TOKENS = new Set(["solid", "regular", "light", "thin", "duotone", "brands", "sharp", "fw", "lg", "sm", "xs", "xl", "2xs", "1x", "2x", "3x", "4x", "5x", "6x", "7x", "8x", "9x", "10x", "rotate", "flip", "spin", "pulse", "border", "pull", "stack"]);
391
+ /**
392
+ * A Material-Symbols / Material-Icons / Font-Awesome icon inside `scope`,
393
+ * normalized to "ms:<name>" / "fa:<name>" (the icon NAME, not a glyph). These
394
+ * are font ligatures the clone can't render as plain text — surfaced so the model
395
+ * loads the icon font (settings.bhet) and renders each as a text-block/html-box,
396
+ * instead of dropping the icon or pasting the raw ligature word.
397
+ */
398
+ function detectIconFont(scope) {
399
+ const ms = scope.querySelector('[class*="material-symbols"],[class*="material-icons"]');
400
+ if (ms) {
401
+ const name = ms.textContent.trim().toLowerCase().replace(/\s+/g, "_");
402
+ if (name && /^[a-z0-9_]+$/.test(name))
403
+ return `ms:${name}`;
404
+ }
405
+ const fa = scope.querySelector('i[class*="fa-"],span[class*="fa-"]');
406
+ if (fa) {
407
+ const name = (fa.getAttribute("class") ?? "")
408
+ .split(/\s+/)
409
+ .map((c) => /^fa-([a-z0-9-]+)$/i.exec(c)?.[1])
410
+ .find((n) => !!n && !FA_STYLE_TOKENS.has(n));
411
+ if (name)
412
+ return `fa:${name}`;
413
+ }
414
+ return undefined;
415
+ }
333
416
  function detectBlocks(el) {
334
417
  const found = findRepeatingContainer(el);
335
418
  if (!found || found.items.length < 2)
336
419
  return [];
337
420
  return found.items.slice(0, 12).map((c) => {
338
421
  const kids = elementChildren(c);
339
- // ── icon slot ──
422
+ // True when a child IS or CONTAINS an icon-font glyph (so it's never mistaken
423
+ // for the title/body — Material Symbols ligature text like "verified" reads as
424
+ // a word but is an icon).
425
+ const containsIcon = (k) => /material-symbols|material-icons|\bfa-/.test(k.getAttribute("class") ?? "") ||
426
+ !!k.querySelector('[class*="material-symbols"],[class*="material-icons"],i[class*="fa-"]');
427
+ // ── icon slot ── prefer a real icon-font glyph (Material Symbols / Font
428
+ // Awesome), captured as "ms:<name>"/"fa:<name>" regardless of name length;
429
+ // fall back to an emoji / short-text / icon-class child.
430
+ const iconFont = detectIconFont(c);
340
431
  const iconEl = kids.find((k) => {
341
432
  const cls = (k.getAttribute("class") ?? "").toLowerCase();
342
- if (/icon|emoji|badge|img/.test(cls))
433
+ if (/icon|emoji|badge|img|material-symbols|material-icons/.test(cls))
434
+ return true;
435
+ if (containsIcon(k))
343
436
  return true;
344
437
  const t = k.textContent.trim();
345
438
  return t.length > 0 && t.length <= 4; // likely a single emoji
346
439
  });
347
- const icon = iconEl?.textContent?.trim() || undefined;
440
+ const icon = iconFont ?? (iconEl?.textContent?.trim() || undefined);
441
+ const isIcon = (k) => k === iconEl || containsIcon(k);
348
442
  // ── title ──
349
443
  const headingEl = c.querySelector(HEADING_TAGS.join(","));
350
444
  const titleClassEl = kids.find((k) => {
351
445
  const cls = (k.getAttribute("class") ?? "").toLowerCase();
352
- return /title|name|heading|label/.test(cls) && k !== iconEl;
446
+ return /title|name|heading|label/.test(cls) && !isIcon(k);
353
447
  });
354
448
  const strongEl = c.querySelector("strong") || c.querySelector("b");
355
449
  // fallback: first non-icon short-text child
356
450
  const fallbackTitleEl = kids.find((k) => {
357
- if (k === iconEl)
451
+ if (isIcon(k))
358
452
  return false;
359
453
  const cls = (k.getAttribute("class") ?? "").toLowerCase();
360
454
  if (/icon|emoji|badge/.test(cls))
@@ -367,15 +461,14 @@ function detectBlocks(el) {
367
461
  // ── body ──
368
462
  // Prefer <p> tags; fall back to div children whose class contains body|desc|text|content
369
463
  const titleText = title ?? "";
370
- const iconText = icon ?? "";
371
464
  const bodyFromP = c.querySelectorAll("p")
372
465
  .map((p) => p.text.trim())
373
- .filter((t) => t && t !== titleText && t !== iconText && t.length > 5)
466
+ .filter((t) => t && t !== titleText && t.length > 5)
374
467
  .join(" ")
375
468
  .slice(0, 250);
376
469
  const bodyFromDiv = !bodyFromP ? kids
377
470
  .filter((k) => {
378
- if (k === iconEl || k === titleEl)
471
+ if (isIcon(k) || k === titleEl)
379
472
  return false;
380
473
  const cls = (k.getAttribute("class") ?? "").toLowerCase();
381
474
  return /body|desc|text|content|copy/.test(cls);
package/dist/smoke.js CHANGED
@@ -15,6 +15,7 @@ import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsPr
15
15
  import { putDraft, getDraft, updateDraft, deleteDraft } from "./persistence/draft-cache.js";
16
16
  import { buildConnectUrl, parseCallback } from "./auth/login.js";
17
17
  import { isLocalPath, resolveLocalPath, sniffMime, localContentType } from "./tools/media.js";
18
+ import { iconifyCandidates } from "./persistence/icon-client.js";
18
19
  import { collectExternalImageUrls, rewriteImageUrls, isRehostableImageUrl } from "./persistence/rehost.js";
19
20
  let failures = 0;
20
21
  const check = (name, cond, extra) => {
@@ -176,6 +177,28 @@ console.log("== expand: relocates misplaced responsive.<bp>.animation into confi
176
177
  console.log("== validate: accepts JSON string input ==");
177
178
  const r3 = validatePage(JSON.stringify(good));
178
179
  check("string input parsed & valid", r3.valid, r3.errors);
180
+ console.log("== canvas width is a CHOICE (settings.width_section 960|1200 / 420|360) ==");
181
+ {
182
+ // createPageSource sets the chosen width up front.
183
+ const wide = landingDomain.createPageSource({ settings: { width_section: { desktop: 1200, mobile: 360 } } });
184
+ check("width: createPageSource honors width_section override", wide.settings.width_section.desktop === 1200 && wide.settings.width_section.mobile === 360, wide.settings.width_section);
185
+ // An element at left:1000 width:180 (right edge 1180) overflows a 960 canvas but FITS a 1200 canvas.
186
+ const mk = (deskW) => {
187
+ const p = JSON.parse(JSON.stringify(good));
188
+ p.settings.width_section = { desktop: deskW, mobile: 420 };
189
+ p.page[0].children[0].responsive.desktop.styles = { top: 100, left: 1000, width: 180, height: 44 };
190
+ return p;
191
+ };
192
+ const at960 = validatePage(mk(960));
193
+ check("width: overflow flagged against the 960 canvas", at960.warnings.some((w) => /exceeds canvas 960/.test(w)), at960.warnings);
194
+ const at1200 = validatePage(mk(1200));
195
+ check("width: SAME element fits the 1200 canvas (no overflow warning)", !at1200.warnings.some((w) => /exceeds canvas/.test(w)), at1200.warnings);
196
+ check("width: 1200 canvas page is valid", at1200.valid, at1200.errors);
197
+ // Only the editor-allowed widths pass — a stray width is a schema error.
198
+ const badW = JSON.parse(JSON.stringify(good));
199
+ badW.settings.width_section = { desktop: 1000, mobile: 420 };
200
+ check("width: non-allowed desktop width (1000) rejected by schema enum", !validatePage(badW).valid, validatePage(badW).errors);
201
+ }
179
202
  console.log("== validate: custom CSS/class/JS escape hatches (beyond-element capability) ==");
180
203
  {
181
204
  const clone = () => JSON.parse(JSON.stringify(good));
@@ -201,6 +224,75 @@ console.log("== validate: custom CSS/class/JS escape hatches (beyond-element cap
201
224
  check("escape hatch: warns selector/:hover inside custom_css", selR.warnings.some((w) => /declarations inside/.test(w)), selR.warnings);
202
225
  check("escape hatch: declarations-only misuse does NOT block (warning, not error)", selR.valid, selR.errors);
203
226
  }
227
+ console.log("== validate: custom-code SAFETY (broad/broken custom breaks the UI) ==");
228
+ {
229
+ const cloneG = () => JSON.parse(JSON.stringify(good));
230
+ const W = (r, re) => r.warnings.filter((w) => re.test(w));
231
+ // settings.extra_css with bare-tag + Webcake-internal selectors → unscoped warning; scoped #w- rule does NOT trip it.
232
+ const broad = cloneG();
233
+ broad.settings.extra_css = "body{margin:0} .rectangle-css{opacity:.5} #w-btn1:hover{transform:scale(1.02)}";
234
+ const broadR = validatePage(broad);
235
+ check("custom-safety: unscoped extra_css selectors (body/.rectangle-css) flagged", W(broadR, /UNSCOPED selector/).length > 0, broadR.warnings);
236
+ check("custom-safety: a #w- scoped rule is NOT flagged", !/#w-btn1/.test(W(broadR, /UNSCOPED selector/)[0] ?? ""), W(broadR, /UNSCOPED/));
237
+ // unbalanced braces in extra_css.
238
+ const braces = cloneG();
239
+ braces.settings.extra_css = "#w-btn1{color:red";
240
+ check("custom-safety: unbalanced extra_css braces flagged", W(validatePage(braces), /unbalanced braces/).length > 0);
241
+ // bhet holding raw CSS (no tags) → wrong-field warning.
242
+ const bhetCss = cloneG();
243
+ bhetCss.settings.bhet = "body{margin:0}";
244
+ check("custom-safety: bhet with no HTML tags flagged (belongs in extra_css)", W(validatePage(bhetCss), /no HTML tags/).length > 0);
245
+ // bbet with an unclosed <script> → swallow warning.
246
+ const bbetBad = cloneG();
247
+ bbetBad.settings.bbet = "<script>init()";
248
+ check("custom-safety: bbet unclosed <script> flagged", W(validatePage(bbetBad), /unbalanced <script>/).length > 0);
249
+ // element custom_css with layout props → break-layout warning (visual props alone do NOT trip it).
250
+ const layoutCss = cloneG();
251
+ layoutCss.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "width:100%;display:flex;box-shadow:0 2px 8px rgba(0,0,0,.1);" };
252
+ check("custom-safety: custom_css layout props (width/display) flagged", W(validatePage(layoutCss), /layout prop/).length > 0, validatePage(layoutCss).warnings);
253
+ const visualCss = cloneG();
254
+ visualCss.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "box-shadow:0 2px 8px rgba(0,0,0,.1);backdrop-filter:blur(8px);" };
255
+ check("custom-safety: visual-only custom_css is NOT flagged", W(validatePage(visualCss), /layout prop/).length === 0, validatePage(visualCss).warnings);
256
+ // a correct, fully-scoped custom setup → none of these warnings.
257
+ const clean = cloneG();
258
+ clean.settings.extra_css = "#w-btn1{transition:transform .3s}#w-btn1:hover{transform:translateY(-2px)}";
259
+ clean.settings.bhet = "<link href='https://fonts.googleapis.com/css2?family=Inter' rel='stylesheet'>";
260
+ clean.page[0].children[0].specials = { text: "X", customAdvance: true, custom_css: "box-shadow:0 8px 24px rgba(0,0,0,.08);" };
261
+ check("custom-safety: correctly-scoped custom triggers no safety warning", W(validatePage(clean), /UNSCOPED|unbalanced|no HTML tags|layout prop/).length === 0, validatePage(clean).warnings);
262
+ }
263
+ console.log("== validate: icon rendering (svg-mask needs background; font-class route is clean) ==");
264
+ {
265
+ const cloneG = () => JSON.parse(JSON.stringify(good));
266
+ const maskChild = (bg) => ({
267
+ id: "icon1", type: "rectangle", properties: { name: "icon", movable: true, sync: true },
268
+ specials: {}, runtime: {}, events: [],
269
+ responsive: {
270
+ desktop: { config: { svgMask: "<svg viewBox='0 0 24 24'><path d='M4 4h16v16H4z'/></svg>" }, styles: { top: 10, left: 10, width: 40, height: 40, ...(bg ? { background: bg } : {}) } },
271
+ mobile: { config: { svgMask: "<svg viewBox='0 0 24 24'><path d='M4 4h16v16H4z'/></svg>" }, styles: { top: 10, left: 10, width: 40, height: 40, ...(bg ? { background: bg } : {}) } },
272
+ },
273
+ });
274
+ // svg-mask WITHOUT a background fill → invisible-icon warning (the "svg in a rectangle doesn't show" bug).
275
+ const noBg = cloneG();
276
+ noBg.page[0].children = [maskChild()];
277
+ check("icon-render: svg-mask rectangle without background → invisible warning", validatePage(noBg).warnings.some((w) => /svgMask is set but styles\.background/.test(w)), validatePage(noBg).warnings);
278
+ // svg-mask WITH a solid background → no invisible warning.
279
+ const withBg = cloneG();
280
+ withBg.page[0].children = [maskChild("rgba(0,88,188,1)")];
281
+ check("icon-render: svg-mask rectangle WITH background → no invisible warning", !validatePage(withBg).warnings.some((w) => /svgMask is set but styles\.background/.test(w)), validatePage(withBg).warnings);
282
+ // font-class route (the Stitch-faithful one): text-block with a Material Symbols span + the font loaded via bhet → valid & clean.
283
+ const fontRoute = cloneG();
284
+ fontRoute.settings.bhet = "<link href='https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined' rel='stylesheet'>";
285
+ fontRoute.page[0].children = [{
286
+ id: "icon2", type: "text-block", properties: { name: "icon", movable: true, sync: true },
287
+ specials: { text: "<span class=\"material-symbols-outlined\">verified</span>" }, runtime: {}, events: [],
288
+ responsive: {
289
+ desktop: { config: {}, styles: { top: 10, left: 10, width: 40, height: 40, fontSize: 32, color: "rgba(0,88,188,1)" } },
290
+ mobile: { config: {}, styles: { top: 10, left: 10, width: 40, height: 40, fontSize: 28, color: "rgba(0,88,188,1)" } },
291
+ },
292
+ }];
293
+ const fr = validatePage(fontRoute);
294
+ check("icon-render: font-class text-block icon validates clean (no svg/emoji warning)", fr.valid && !fr.warnings.some((w) => /svgMask|emoji/.test(w)), { errors: fr.errors, warnings: fr.warnings });
295
+ }
204
296
  console.log("== expand: hydrates sparse nodes ==");
205
297
  const sparse = {
206
298
  page: [
@@ -303,6 +395,31 @@ check("ingest: hero image captured", (hero?.images?.length ?? 0) > 0, hero?.imag
303
395
  const form = ast.sections.find((s) => s.role === "form");
304
396
  check("ingest: form fields captured", (form?.form_fields?.length ?? 0) >= 2, form?.form_fields);
305
397
  check("ingest: form submit CTA captured", !!form?.ctas?.[0]?.text?.includes("Place"), form?.ctas);
398
+ console.log("== ingest: Stitch-structure section classification (generalizes to any Stitch HTML) ==");
399
+ {
400
+ // Mirrors the real Stitch shape: <nav> top bar (no <header>), <main> with a
401
+ // h1 hero, 2-column card grids, a card-less CTA band, <footer>, and a sticky
402
+ // BOTTOM <nav> action bar.
403
+ const stitchStruct = `<!DOCTYPE html><html><head></head><body>
404
+ <nav class="fixed top-0 w-full z-50 bg-white/80"><div class="font-bold">BrandName</div><div><a href="#">Pricing</a><a href="#">Docs</a></div><a class="px-5 py-2 rounded-xl bg-primary" href="#">Sign in</a></nav>
405
+ <main class="pt-20">
406
+ <section class="pt-24 pb-32"><div class="grid lg:grid-cols-12"><div class="lg:col-span-7"><h1>Become a strategic partner</h1><p>Earn recurring commission on every customer you refer to us.</p><a class="px-8 py-4 rounded-xl bg-primary" href="#">Join now</a></div><div class="lg:col-span-5"><img src="https://x/hero.jpg"/></div></div></section>
407
+ <section class="py-24 bg-surface-container-low"><div class="text-center"><h2>Transparent income</h2></div><div class="grid md:grid-cols-2 gap-6"><div class="card p-8 rounded-xl"><span class="material-symbols-outlined">payments</span><h3>Fast payouts</h3><p>Money in your account within days, not months at all.</p></div><div class="card p-8 rounded-xl"><span class="material-symbols-outlined">monitoring</span><h3>Live tracking</h3><p>Watch every referral convert in a real-time dashboard.</p></div></div></section>
408
+ <section class="py-20"><div class="rounded-3xl p-12 text-center"><h2>Ready to boost your income?</h2><p>Join thousands of partners already earning with us.</p><p>No setup fee, cancel anytime, get started in minutes.</p><a class="px-8 py-4 rounded-xl bg-primary" href="#">Get started</a></div></section>
409
+ </main>
410
+ <footer class="pt-12 pb-8"><div class="grid grid-cols-4"><a href="#">About</a><a href="#">Blog</a><a href="#">Terms</a><a href="#">Contact</a></div><p>© 2026 BrandName</p></footer>
411
+ <nav class="fixed bottom-0 left-0 right-0 z-50 bg-white"><a class="px-6 py-3 rounded-xl bg-primary" href="#">Join now</a></nav>
412
+ </body></html>`;
413
+ const ss = parseHtml(stitchStruct, "full");
414
+ const roles = ss.sections.map((s) => s.role);
415
+ check("stitch-struct: <nav> top bar (no <header>) → header", roles[0] === "header", roles);
416
+ check("stitch-struct: h1 section → hero", ss.sections.some((s) => s.role === "hero" && /strategic partner/.test(s.heading ?? "")), roles);
417
+ check("stitch-struct: 2-card grid → features (not unknown)", ss.sections.some((s) => s.role === "features" && (s.blocks?.length ?? 0) === 2), roles);
418
+ check("stitch-struct: card-less CTA band with 2 paragraphs → cta (not unknown)", ss.sections.some((s) => s.role === "cta" && /Ready to boost/.test(s.heading ?? "")), roles);
419
+ check("stitch-struct: <footer> → footer", roles.includes("footer"), roles);
420
+ check("stitch-struct: sticky BOTTOM <nav> is NOT a second header", roles.filter((r) => r === "header").length === 1, roles);
421
+ check("stitch-struct: no 'unknown' sections left", !roles.includes("unknown"), roles);
422
+ }
306
423
  console.log("== ingest: size_hint (desktop section heights) ==");
307
424
  check("ingest: every section has a size_hint", ast.sections.every((s) => (s.size_hint?.height ?? 0) > 0), ast.sections.map((s) => s.size_hint));
308
425
  const headerHint = ast.sections.find((s) => s.role === "header")?.size_hint;
@@ -491,6 +608,23 @@ const arbGrad = parseHtml(`<!DOCTYPE html><html><head>${fxCfg}</head><body><main
491
608
  check("fx: arbitrary [#hex] gradient stop resolved", (arbGrad.gradients ?? []).includes("linear-gradient(to right, #ff0000, #0058bc)"), arbGrad.gradients);
492
609
  // no tailwind config → no gradients from this path, no hover noise on a plain page.
493
610
  check("fx: plain page (no config, no hover classes) has no gradients/hover", (() => { const a = parseHtml(stylesheetHtml, "compact"); return a.gradients === undefined && a.sections.every((s) => s.hover_effects === undefined); })());
611
+ console.log("== ingest: icon-font extraction (Material Symbols / Font Awesome) ==");
612
+ {
613
+ // Feature cards with Material Symbols icons (a long ligature name + a nested-wrapper icon) and one Font Awesome card.
614
+ const iconHtml = `<!DOCTYPE html><html><head></head><body><main><section><h2>Why choose us</h2>
615
+ <div class="grid"><div class="card"><span class="material-symbols-outlined">verified</span><h3>Trusted</h3><p>Verified by thousands of happy partners every month.</p></div>
616
+ <div class="card"><div class="icon-wrap"><span class="material-symbols-outlined">support_agent</span></div><h3>Support</h3><p>A dedicated manager helps you grow revenue fast.</p></div>
617
+ <div class="card"><i class="fa-solid fa-chart-line fa-2x"></i><h3>Analytics</h3><p>Track every referral conversion in real time dashboards.</p></div></div>
618
+ </section></main></body></html>`;
619
+ const ia = parseHtml(iconHtml, "full");
620
+ const fsec = ia.sections.find((s) => (s.blocks || []).length >= 3);
621
+ const blocks = fsec?.blocks ?? [];
622
+ check("icon: Material Symbols long ligature captured as ms:<name>", blocks[0]?.icon === "ms:verified", blocks[0]);
623
+ check("icon: nested-wrapper Material Symbol captured", blocks[1]?.icon === "ms:support_agent", blocks[1]);
624
+ check("icon: Font Awesome captured as fa:<name> (style tokens skipped)", blocks[2]?.icon === "fa:chart-line", blocks[2]);
625
+ check("icon: ligature name does NOT leak into the card title", blocks[0]?.title === "Trusted" && blocks[1]?.title === "Support", blocks.map((b) => b.title));
626
+ check("icon: real body kept (icon excluded)", /Verified by thousands/.test(blocks[0]?.body ?? ""), blocks[0]?.body);
627
+ }
494
628
  console.log("== ingest: full mode — blocks detection, gradients, images-as-objects ==");
495
629
  const fullAst = parseHtml(stylesheetHtml, "full");
496
630
  check("ingest: full mode palette present", fullAst.palette?.["primary"] === "#0A7C6E", fullAst.palette);
@@ -1599,6 +1733,12 @@ console.log("== rehost: external-image URL collect + rewrite (pure, offline) =="
1599
1733
  }
1600
1734
  console.log("== upload_images: local-path detector (pure, offline) ==");
1601
1735
  {
1736
+ // get_icon_svg name → Iconify candidate mapping (pure; the fetch itself is networked)
1737
+ check("icon-svg: ms:<name> → material-symbols outline-first, underscore→hyphen", JSON.stringify(iconifyCandidates("ms:support_agent")) === JSON.stringify(["material-symbols/support-agent-outline", "material-symbols/support-agent"]), iconifyCandidates("ms:support_agent"));
1738
+ check("icon-svg: fa:<name> → fa6-solid first with fallbacks", iconifyCandidates("fa:chart-line")[0] === "fa6-solid/chart-line", iconifyCandidates("fa:chart-line"));
1739
+ check("icon-svg: bare name assumed Material Symbols", iconifyCandidates("verified")[0] === "material-symbols/verified-outline", iconifyCandidates("verified"));
1740
+ check("icon-svg: real Iconify id passes through", iconifyCandidates("mdi:home")[0] === "mdi/home", iconifyCandidates("mdi:home"));
1741
+ check("icon-svg: empty ref → no candidates", iconifyCandidates("")?.length === 0, iconifyCandidates(""));
1602
1742
  // isLocalPath: recognised forms
1603
1743
  check("localPath: absolute POSIX /…", isLocalPath("/home/user/photo.jpg"));
1604
1744
  check("localPath: home-dir ~/…", isLocalPath("~/Pictures/logo.png"));
@@ -21,7 +21,16 @@ export function registerGenerationTools(server, domain) {
21
21
  return text(el);
22
22
  });
23
23
  // 6) New page skeleton ------------------------------------------------------
24
- server.tool("new_page_skeleton", "Returns an empty but complete top-level page source { page:[], popup:[], settings:{...defaults}, options:{...}, cartConfigs:{} } matching the real editor shape.", { mobileOnly: z.boolean().optional().describe("true if the page renders mobile-only.") }, { title: "New Page Skeleton", readOnlyHint: true, openWorldHint: false }, async ({ mobileOnly }) => text(domain.createPageSource({ mobileOnly: mobileOnly ?? false })));
24
+ server.tool("new_page_skeleton", "Returns an empty but complete top-level page source { page:[], popup:[], settings:{...defaults}, options:{...}, cartConfigs:{} } matching the real editor shape. Pass desktopWidth/mobileWidth to set the canvas width (settings.width_section) up front pick desktop 1200 for wide/multi-column/editorial pages or when cloning a reference wider than 960 (e.g. Google Stitch ~1280), else 960; then place every element's coords in that width's space.", {
25
+ mobileOnly: z.boolean().optional().describe("true if the page renders mobile-only."),
26
+ desktopWidth: z.union([z.literal(960), z.literal(1200)]).optional().describe("Desktop canvas width (settings.width_section.desktop). 960 (default, simple/narrow) or 1200 (wide/multi-column/editorial, or cloning a >960 reference)."),
27
+ mobileWidth: z.union([z.literal(420), z.literal(360)]).optional().describe("Mobile canvas width (settings.width_section.mobile). 420 (default) or 360 (to match a ~360–390 mobile design)."),
28
+ }, { title: "New Page Skeleton", readOnlyHint: true, openWorldHint: false }, async ({ mobileOnly, desktopWidth, mobileWidth }) => text(domain.createPageSource({
29
+ mobileOnly: mobileOnly ?? false,
30
+ settings: desktopWidth || mobileWidth
31
+ ? { width_section: { desktop: desktopWidth ?? 960, mobile: mobileWidth ?? 420 } }
32
+ : undefined,
33
+ })));
25
34
  // 7) Validate page ----------------------------------------------------------
26
35
  server.tool("validate_page", "Validates a page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types) plus form-data bindings (duplicate field_name within one form, dangling option-event promoId / connectedSurvey / connectedForm / set_field_value targets). Returns errors (blocking — fix before persisting) and warnings (visible design defects — fix these too and re-validate to an empty list; only a demonstrably false positive may remain).", {
27
36
  page: z
@@ -29,6 +29,7 @@ import { fileURLToPath } from "node:url";
29
29
  import { text } from "../mcp/response.js";
30
30
  import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
31
31
  import { uploadImageMultipart } from "../persistence/webcake-client.js";
32
+ import { resolveIconSvg } from "../persistence/icon-client.js";
32
33
  import { configFromHeaders, ENVIRONMENTS, stripTrailingSlash } from "../persistence/config.js";
33
34
  /** Resolve just the API base (no JWT required) from per-request headers → env → WEBCAKE_ENV preset → prod default. */
34
35
  function resolveApiBase(headers) {
@@ -221,6 +222,36 @@ export function registerMediaTools(server, allowLocalFiles = true) {
221
222
  }
222
223
  return text({ queries: out });
223
224
  });
225
+ // 13b) Resolve icon-font names to real inline SVGs --------------------------
226
+ server.tool("get_icon_svg", "Resolves icon-font NAMES into real inline SVG markup via the public Iconify API — so a clone reproduces a reference's icons (esp. Google Stitch, which renders icons with a Material Symbols / Font Awesome CLASS, not an image). ingest_html/ingest_url surface those icons as block.icon \"ms:<name>\" (Material Symbols) / \"fa:<name>\" (Font Awesome); pass them here to get the SVG. ACCEPTS: \"ms:verified\", \"fa:chart-line\", a real Iconify id (\"mdi:home\"), or a bare name (assumed Material Symbols); underscores are normalized to hyphens, and Material Symbols resolve to the OUTLINED variant (the Stitch look) with a filled fallback. Returns { icons: { \"<ref>\": { ok, svg, iconify } } }. RENDER each svg as Webcake's native icon element — a RECTANGLE: put the svg in BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background = the icon color, and keep the box SQUARE (width === height). The svg is only a MASK (its own fill is ignored), so the icon is BLANK without a solid styles.background; the renderer reads each breakpoint's svgMask separately (no fallback) and forces preserveAspectRatio='none' (a non-square box stretches it). No Webcake credentials needed.", {
227
+ icons: z
228
+ .array(z.string())
229
+ .min(1)
230
+ .max(40)
231
+ .describe("Icon references to resolve (1–40), e.g. [\"ms:verified\", \"ms:support_agent\", \"fa:chart-line\"] — typically the block.icon values from an ingest result."),
232
+ }, { title: "Resolve Icon SVGs", readOnlyHint: true, openWorldHint: true }, async ({ icons }) => {
233
+ const unique = [...new Set(icons)];
234
+ const results = await Promise.all(unique.map(async (ref) => [ref, await resolveIconSvg(ref)]));
235
+ const out = {};
236
+ let resolved = 0;
237
+ let failed = 0;
238
+ for (const [ref, r] of results) {
239
+ if (r.ok) {
240
+ resolved++;
241
+ out[ref] = { ok: true, svg: r.svg, iconify: r.iconify };
242
+ }
243
+ else {
244
+ failed++;
245
+ out[ref] = { ok: false, error: r.error };
246
+ }
247
+ }
248
+ return text({
249
+ icons: out,
250
+ resolved,
251
+ failed,
252
+ usage: "For each icons[<ref>].svg, make a rectangle in that card's icon slot: copy the svg into BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background to the icon color, and keep the box SQUARE (width === height) — without styles.background the masked icon is invisible, and a non-square box stretches it. For any ok:false ref, fall back to an emoji inline or skip — never leave a feature card iconless.",
253
+ });
254
+ });
224
255
  // 14) Upload images to Webcake -----------------------------------------------
225
256
  server.tool("upload_images", "Converts external image URLs (typically collected from ingest_html/ingest_url results), data: URIs, or LOCAL FILE PATHS from the user's computer into Webcake-hosted URLs (statics.pancake.vn) by reading/downloading each image and re-uploading it to the Webcake backend via multipart upload (200 MB backend limit). Use this whenever the page is built from a reference HTML/URL (BOTH intents — adapt AND clone), the user supplies their own image URLs, OR the user provides local image files from their machine — pass the path directly in `urls`; NEVER upload a user's local file to a third-party host (catbox, imgur, transfer.sh…) to obtain a URL first. The returned URLs go directly into specials.src — same as search_images results. Processes up to 20 entries per call in parallel, with a 200 MB per-image cap. No Webcake credentials required (the upload endpoint is public). UPLOADS BY DEFAULT (dry_run defaults to FALSE — unlike the page-persistence tools, this touches no account data, so the default is the real upload): the call downloads/reads each entry, uploads it, and returns the images map (original URL → hosted URL); WAIT for that map before assembling the page and never fall back to a placeholder for a slot whose upload succeeded. Pass dry_run:true only to preview what would be processed without any network/filesystem activity. Use search_images instead when you need stock photos. Local file paths are only permitted when the MCP server runs locally (stdio mode); on the remote HTTP transport they are rejected per-entry.", {
226
257
  urls: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.74",
3
+ "version": "1.0.76",
4
4
  "description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
5
5
  "mcpName": "io.github.vuluu2k/webcake-landing-mcp",
6
6
  "type": "module",